Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) {
func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) {
t.Parallel()

limits := chain.Limits{ReportSize: 4, GasLimit: 10}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason for this change?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the test does not require it. its a semantic change.

limits := chain.Limits{GasLimit: 10}
inner := &evmClientCapabilityStub{}
wrapper := NewLimitedEVMChain(inner, limits)

Expand Down
80 changes: 80 additions & 0 deletions cmd/workflow/simulate/chain/solana/capabilities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package solana

import (
"context"
"fmt"

"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"

solanaserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/solana/server"
"github.com/smartcontractkit/chainlink-common/pkg/logger"
solanafakes "github.com/smartcontractkit/chainlink-solana/contracts/capabilities/fakes"
"github.com/smartcontractkit/chainlink/v2/core/capabilities"

"github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain"
)

// SolanaChainCapabilities holds the per-selector FakeSolanaChain instances
// created for simulation.
type SolanaChainCapabilities struct {
SolanaChains map[uint64]*solanafakes.FakeSolanaChain
}

// NewSolanaChainCapabilities builds FakeSolanaChain instances for every
// (selector -> client) pair, wraps them with LimitedSolanaChain, and registers
// each with the capability registry.
func NewSolanaChainCapabilities(
ctx context.Context,
lggr logger.Logger,
registry *capabilities.Registry,
clients map[uint64]*rpc.Client,
forwarderProgramIDs map[uint64]solana.PublicKey,
forwarderStateAccounts map[uint64]solana.PublicKey,
transmitter solana.PrivateKey,
dryRunChainWrite bool,
limits chain.Limits,
) (*SolanaChainCapabilities, error) {
chains := make(map[uint64]*solanafakes.FakeSolanaChain)
for sel, client := range clients {
programID, ok := forwarderProgramIDs[sel]
if !ok {
lggr.Infow("Forwarder program ID not found for chain", "selector", sel)
continue
}
stateAccount, ok := forwarderStateAccounts[sel]
if !ok {
lggr.Infow("Forwarder state account not found for chain", "selector", sel)
continue
}
fc, err := solanafakes.NewFakeSolanaChain(lggr, client, transmitter, programID, stateAccount, sel, dryRunChainWrite)
if err != nil {
return nil, fmt.Errorf("new FakeSolanaChain for selector %d: %w", sel, err)
}
capability := NewLimitedSolanaChain(fc, limits)
server := solanaserver.NewClientServer(capability)
if err := registry.Add(ctx, server); err != nil {
return nil, fmt.Errorf("register solana capability for selector %d: %w", sel, err)
}
chains[sel] = fc
}
return &SolanaChainCapabilities{SolanaChains: chains}, nil
}

func (c *SolanaChainCapabilities) Start(ctx context.Context) error {
for _, fc := range c.SolanaChains {
if err := fc.Start(ctx); err != nil {
return err
}
}
return nil
}

func (c *SolanaChainCapabilities) Close() error {
for _, fc := range c.SolanaChains {
if err := fc.Close(); err != nil {
return err
}
}
return nil
}
188 changes: 188 additions & 0 deletions cmd/workflow/simulate/chain/solana/chaintype.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package solana

import (
"context"
"fmt"
"strings"

"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/rpc"
"github.com/rs/zerolog"
"github.com/spf13/viper"

corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys"
"github.com/smartcontractkit/chainlink-common/pkg/services"
"github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings"

"github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain"
"github.com/smartcontractkit/cre-cli/internal/settings"
)

func init() {
chain.Register(string(corekeys.Solana), func(lggr *zerolog.Logger) chain.ChainType {
return &SolanaChainType{log: lggr}
}, nil)
}

// SolanaChainType implements chain.ChainType for Solana.
type SolanaChainType struct {
log *zerolog.Logger
solanaChains *SolanaChainCapabilities
programIDs map[uint64]solana.PublicKey
stateAccounts map[uint64]solana.PublicKey
}

var _ chain.ChainType = (*SolanaChainType)(nil)

func (ct *SolanaChainType) Name() string { return string(corekeys.Solana) }
func (ct *SolanaChainType) SupportedChains() []chain.ChainConfig { return SupportedChains }

func (ct *SolanaChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, error) {
clients := make(map[uint64]chain.ChainClient)
forwarders := make(map[uint64]string)
ct.programIDs = make(map[uint64]solana.PublicKey)
ct.stateAccounts = make(map[uint64]solana.PublicKey)

for _, c := range SupportedChains {
name, err := settings.GetChainNameByChainSelector(c.Selector)
if err != nil {
ct.log.Error().Msgf("Invalid Solana chain selector %d; skipping", c.Selector)
continue
}
rpcURL, err := settings.GetRpcUrlSettings(v, name)
if err != nil || strings.TrimSpace(rpcURL) == "" {
ct.log.Debug().Msgf("RPC not provided for %s; skipping", name)
continue
}
ct.log.Debug().Msgf("Using RPC for %s: %s", name, chain.Redacturl("https://github.com/smartcontractkit/cre-cli/pull/504/rpcURL"))

programID, err := solana.PublicKeyFromBase58(c.Forwarder)
if err != nil {
return chain.ResolvedChains{}, fmt.Errorf("invalid forwarder program ID for %s: %w", name, err)
}
stateB58, ok := forwarderStateAccounts[c.Selector]
if !ok || strings.TrimSpace(stateB58) == "" {
return chain.ResolvedChains{}, fmt.Errorf("no forwarder state account configured for %s", name)
}
state, err := solana.PublicKeyFromBase58(stateB58)
if err != nil {
return chain.ResolvedChains{}, fmt.Errorf("invalid forwarder state account for %s: %w", name, err)
}

clients[c.Selector] = rpc.New(rpcURL)
forwarders[c.Selector] = c.Forwarder
ct.programIDs[c.Selector] = programID
ct.stateAccounts[c.Selector] = state
}

return chain.ResolvedChains{Clients: clients, Forwarders: forwarders}, nil
}

func (ct *SolanaChainType) ResolveKey(s *settings.Settings, broadcast bool) (interface{}, error) {
raw := strings.TrimSpace(s.User.PrivateKey(settings.Solana))

// Solana simulation requires a valid private key in all cases (both broadcast and non-broadcast).
// Unlike EVM (which uses Anvil with pre-funded deterministic accounts), Solana's test network
// requires the transmitter account to exist and be funded on-chain. Using a random or sentinel key
// will fail when the RPC tries to access a non-existent signer account.
// Solution: Mandate CRE_SOLANA_PRIVATE_KEY for all Solana workflow simulations.
if raw == "" {
return nil, fmt.Errorf(
"CRE_SOLANA_PRIVATE_KEY is required for Solana workflow simulation.\n\n" +
"The Solana test network requires the transmitter account (derived from your private key) to exist and be funded on-chain.\n" +
"Please set your private key in your .env file or system environment:\n\n" +
" CRE_SOLANA_PRIVATE_KEY=<your-64-byte-base58-keypair>\n\n" +
"You can generate a test key using: solana-keygen new\n" +
"Then fund it on devnet: solana airdrop 10 <your-address> --url devnet",
)
}

// Try base58 (64-byte solana keypair, standard Solana CLI / wallet format).
if key, err := solana.PrivateKeyFromBase58(raw); err == nil && len(key) == 64 {
if broadcast && key.PublicKey().IsZero() {
return nil, fmt.Errorf("CRE_SOLANA_PRIVATE_KEY decodes to a zero key; refusing to broadcast")
}
return key, nil
}

return nil, fmt.Errorf("CRE_SOLANA_PRIVATE_KEY must be a 64-byte base58 keypair")
}

func (ct *SolanaChainType) ResolveTriggerData(_ context.Context, _ uint64, _ chain.TriggerParams) (interface{}, error) {
return nil, fmt.Errorf("solana: no trigger surface")
}

func (ct *SolanaChainType) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) ([]services.Service, error) {
typedClients := make(map[uint64]*rpc.Client, len(cfg.Clients))
for sel, c := range cfg.Clients {
sc, ok := c.(*rpc.Client)
if !ok {
return nil, fmt.Errorf("solana: client for selector %d is not *rpc.Client", sel)
}
typedClients[sel] = sc
}
var key solana.PrivateKey
if cfg.PrivateKey != nil {
var ok bool
key, ok = cfg.PrivateKey.(solana.PrivateKey)
if !ok {
return nil, fmt.Errorf("solana: private key is not solana.PrivateKey")
}
}
var lim chain.Limits
if cfg.Limits != nil {
lim = ExtractLimits(cfg.Limits)
}
caps, err := NewSolanaChainCapabilities(
ctx, cfg.Logger, cfg.Registry,
typedClients,
ct.programIDs,
ct.stateAccounts,
key,
!cfg.Broadcast,
lim,
)
if err != nil {
return nil, err
}
if err := caps.Start(ctx); err != nil {
return nil, fmt.Errorf("solana: failed to start: %w", err)
}
ct.solanaChains = caps
out := make([]services.Service, 0, len(caps.SolanaChains))
for _, fc := range caps.SolanaChains {
out = append(out, fc)
}
return out, nil
}

func (ct *SolanaChainType) ExecuteTrigger(_ context.Context, _ uint64, _ string, _ interface{}) error {
return fmt.Errorf("solana: no trigger surface")
}

func (ct *SolanaChainType) Supports(selector uint64) bool {
if ct.solanaChains == nil {
return false
}
return ct.solanaChains.SolanaChains[selector] != nil
}

func (ct *SolanaChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) {
return chain.ParseTriggerChainSelector(ct.Name(), triggerID)
}

func (ct *SolanaChainType) RunHealthCheck(resolved chain.ResolvedChains) error {
return RunRPCHealthCheck(resolved.Clients, resolved.ExperimentalSelectors)
}

func (ct *SolanaChainType) CollectCLIInputs(_ *viper.Viper) map[string]string {
return map[string]string{}
}

func ExtractLimits(w *cresettings.Workflows) chain.Limits {
return chain.Limits{
ReportSize: int(w.ChainWrite.Solana.ReportSizeLimit.DefaultValue),
// Solana compute-unit limit is Setting[uint32]; widen to chain.Limits.GasLimit (uint64).
GasLimit: uint64(w.ChainWrite.Solana.GasLimit.Default.DefaultValue),
}
}
57 changes: 57 additions & 0 deletions cmd/workflow/simulate/chain/solana/health.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package solana

import (
"context"
"errors"
"fmt"
"time"

"github.com/gagliardetto/solana-go/rpc"

"github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain"
"github.com/smartcontractkit/cre-cli/internal/settings"
)

const healthCheckTimeout = 5 * time.Second

// RunRPCHealthCheck probes GetHealth() on every configured Solana client.
// experimentalSelectors identifies chains sourced from experimental-chains config.
func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelectors map[uint64]bool) error {
if len(clients) == 0 {
return fmt.Errorf("check your settings: no Solana RPC URLs found for supported or experimental chains")
}
var errs []error
for sel, c := range clients {
if c == nil {
errs = append(errs, fmt.Errorf("[%d] nil client", sel))
continue
}
sc, ok := c.(*rpc.Client)
if !ok {
errs = append(errs, fmt.Errorf("[%d] invalid client type for Solana chain type", sel))
continue
}
label := selectorLabel(sel, experimentalSelectors)

ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout)
// GetHealth returns "ok" on healthy nodes and an error otherwise.
if _, err := sc.GetHealth(ctx); err != nil {
errs = append(errs, fmt.Errorf("[%s] failed RPC health check: %w", label, err))
}
cancel()
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}

func selectorLabel(sel uint64, experimentalSelectors map[uint64]bool) string {
if experimentalSelectors[sel] {
return fmt.Sprintf("experimental chain %d", sel)
}
if name, err := settings.GetChainNameByChainSelector(sel); err == nil {
return name
}
return fmt.Sprintf("chain %d", sel)
}
Loading
Loading