diff --git a/cmd/workflow/simulate/capabilities.go b/cmd/workflow/simulate/capabilities.go index 57cc2b5b..5cd28a45 100644 --- a/cmd/workflow/simulate/capabilities.go +++ b/cmd/workflow/simulate/capabilities.go @@ -2,16 +2,13 @@ package simulate import ( "context" - "crypto/ecdsa" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" - chaintype "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" "github.com/smartcontractkit/chainlink-common/keystore/corekeys/ocr2key" confhttpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp/server" httpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http/server" - evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" consensusserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/consensus/server" crontrigger "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron/server" httptrigger "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http/server" @@ -21,79 +18,34 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes" ) -type ManualTriggerCapabilitiesConfig struct { - Clients map[uint64]*ethclient.Client - Forwarders map[uint64]common.Address - PrivateKey *ecdsa.PrivateKey -} - +// ManualTriggers holds chain-agnostic trigger services used in simulation. type ManualTriggers struct { ManualCronTrigger *fakes.ManualCronTriggerService ManualHTTPTrigger *fakes.ManualHTTPTriggerService - ManualEVMChains map[uint64]*fakes.FakeEVMChain } -func NewManualTriggerCapabilities( - ctx context.Context, - lggr logger.Logger, - registry *capabilities.Registry, - cfg ManualTriggerCapabilitiesConfig, - dryRunChainWrite bool, - limits *SimulationLimits, -) (*ManualTriggers, error) { - // Cron +// NewManualTriggerCapabilities creates and registers cron and HTTP trigger capabilities. +// These are chain-agnostic and shared across all chain types. +func NewManualTriggerCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry) (*ManualTriggers, error) { manualCronTrigger := fakes.NewManualCronTriggerService(lggr) manualCronTriggerServer := crontrigger.NewCronServer(manualCronTrigger) if err := registry.Add(ctx, manualCronTriggerServer); err != nil { return nil, err } - // HTTP manualHTTPTrigger := fakes.NewManualHTTPTriggerService(lggr) manualHTTPTriggerServer := httptrigger.NewHTTPServer(manualHTTPTrigger) if err := registry.Add(ctx, manualHTTPTriggerServer); err != nil { return nil, err } - // EVM - evmChains := make(map[uint64]*fakes.FakeEVMChain) - for sel, client := range cfg.Clients { - fwd, ok := cfg.Forwarders[sel] - if !ok { - lggr.Infow("Forwarder not found for chain", "selector", sel) - continue - } - - evm := fakes.NewFakeEvmChain( - lggr, - client, - cfg.PrivateKey, - fwd, - sel, - dryRunChainWrite, - ) - - // Wrap with limits enforcement if limits are enabled - var evmCap evmserver.ClientCapability = evm - if limits != nil { - evmCap = NewLimitedEVMChain(evm, limits) - } - - evmServer := evmserver.NewClientServer(evmCap) - if err := registry.Add(ctx, evmServer); err != nil { - return nil, err - } - - evmChains[sel] = evm - } - return &ManualTriggers{ ManualCronTrigger: manualCronTrigger, ManualHTTPTrigger: manualHTTPTrigger, - ManualEVMChains: evmChains, }, nil } +// Start starts cron and HTTP trigger services. func (m *ManualTriggers) Start(ctx context.Context) error { err := m.ManualCronTrigger.Start(ctx) if err != nil { @@ -105,16 +57,10 @@ func (m *ManualTriggers) Start(ctx context.Context) error { return err } - // Start all configured EVM chains - for _, evm := range m.ManualEVMChains { - if err := evm.Start(ctx); err != nil { - return err - } - } - return nil } +// Close closes cron and HTTP trigger services. func (m *ManualTriggers) Close() error { err := m.ManualCronTrigger.Close() if err != nil { @@ -126,16 +72,10 @@ func (m *ManualTriggers) Close() error { return err } - // Close all EVM chains - for _, evm := range m.ManualEVMChains { - if err := evm.Close(); err != nil { - return err - } - } return nil } -// NewFakeCapabilities builds faked capabilities, then registers them with the capability registry. +// NewFakeActionCapabilities builds faked capabilities, then registers them with the capability registry. func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry, secretsPath string, limits *SimulationLimits) ([]services.Service, error) { caps := make([]services.Service, 0) @@ -144,7 +84,7 @@ func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry nSigners := 4 signers := []ocr2key.KeyBundle{} for i := 0; i < nSigners; i++ { - signer := ocr2key.MustNewInsecure(fakes.SeedForKeys(), chaintype.EVM) + signer := ocr2key.MustNewInsecure(fakes.SeedForKeys(), corekeys.EVM) lggr.Infow("Generated new consensus signer", "address", common.BytesToAddress(signer.PublicKey())) signers = append(signers, signer) } diff --git a/cmd/workflow/simulate/chain/evm/capabilities.go b/cmd/workflow/simulate/chain/evm/capabilities.go new file mode 100644 index 00000000..22f000b1 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/capabilities.go @@ -0,0 +1,88 @@ +package evm + +import ( + "context" + "crypto/ecdsa" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes" +) + +// EVMChainCapabilities holds the EVM chain capability servers created for simulation. +type EVMChainCapabilities struct { + EVMChains map[uint64]*fakes.FakeEVMChain +} + +// NewEVMChainCapabilities creates EVM chain capability servers and registers them +// with the capability registry. Cron and HTTP triggers are not created here — they +// are chain-agnostic and managed by the simulate command directly. +func NewEVMChainCapabilities( + ctx context.Context, + lggr logger.Logger, + registry *capabilities.Registry, + clients map[uint64]*ethclient.Client, + forwarders map[uint64]string, + privateKey *ecdsa.PrivateKey, + dryRunChainWrite bool, + limits EVMChainLimits, +) (*EVMChainCapabilities, error) { + evmChains := make(map[uint64]*fakes.FakeEVMChain) + for sel, client := range clients { + fwdStr, ok := forwarders[sel] + if !ok { + lggr.Infow("Forwarder not found for chain", "selector", sel) + continue + } + + evm := fakes.NewFakeEvmChain( + lggr, + client, + privateKey, + common.HexToAddress(fwdStr), + sel, + dryRunChainWrite, + ) + + // Wrap with limits enforcement if limits are provided + var evmCap evmserver.ClientCapability = evm + if limits != nil { + evmCap = NewLimitedEVMChain(evm, limits) + } + + evmServer := evmserver.NewClientServer(evmCap) + if err := registry.Add(ctx, evmServer); err != nil { + return nil, err + } + + evmChains[sel] = evm + } + + return &EVMChainCapabilities{ + EVMChains: evmChains, + }, nil +} + +// Start starts all configured EVM chains. +func (c *EVMChainCapabilities) Start(ctx context.Context) error { + for _, evm := range c.EVMChains { + if err := evm.Start(ctx); err != nil { + return err + } + } + return nil +} + +// Close closes all EVM chains. +func (c *EVMChainCapabilities) Close() error { + for _, evm := range c.EVMChains { + if err := evm.Close(); err != nil { + return err + } + } + return nil +} diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go new file mode 100644 index 00000000..ff09731b --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -0,0 +1,285 @@ +package evm + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog" + "github.com/spf13/viper" + + corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +const defaultSentinelPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001" + +func init() { + chain.Register(string(corekeys.EVM), func(lggr *zerolog.Logger) chain.ChainType { + return &EVMChainType{log: lggr} + }, []chain.CLIFlagDef{ + {Name: TriggerInputTxHash, Description: "EVM trigger transaction hash (0x...)", FlagType: chain.CLIFlagString}, + {Name: TriggerInputEventIndex, Description: "EVM trigger log index (0-based)", DefaultValue: "-1", FlagType: chain.CLIFlagInt}, + }) +} + +// EVMChainType implements chain.ChainType for EVM-based blockchains. +type EVMChainType struct { + log *zerolog.Logger + evmChains *EVMChainCapabilities +} + +var _ chain.ChainType = (*EVMChainType)(nil) + +func (ct *EVMChainType) Name() string { return "evm" } + +func (ct *EVMChainType) SupportedChains() []chain.ChainConfig { + return SupportedChains +} + +func (ct *EVMChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, error) { + clients := make(map[uint64]chain.ChainClient) + forwarders := make(map[uint64]string) + experimental := make(map[uint64]bool) + + // build clients for each supported chain from settings, skip if rpc is empty + for _, ch := range SupportedChains { + chainName, err := settings.GetChainNameByChainSelector(ch.Selector) + if err != nil { + ct.log.Error().Msgf("Invalid chain selector for supported EVM chains %d; skipping", ch.Selector) + continue + } + rpcURL, err := settings.GetRpcUrlSettings(v, chainName) + if err != nil || strings.TrimSpace(rpcURL) == "" { + ct.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) + continue + } + ct.log.Debug().Msgf("Using RPC for %s: %s", chainName, chain.RedactURL(rpcURL)) + + c, err := ethclient.Dial(rpcURL) + if err != nil { + ui.Warning(fmt.Sprintf("Failed to create eth client for %s: %v", chainName, err)) + continue + } + clients[ch.Selector] = c + if strings.TrimSpace(ch.Forwarder) != "" { + forwarders[ch.Selector] = ch.Forwarder + } + } + + // Resolve experimental chains + expChains, err := settings.GetExperimentalChains(v) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("failed to load experimental chains config: %w", err) + } + + for _, ec := range expChains { + if ec.ChainSelector == 0 { + return chain.ResolvedChains{}, fmt.Errorf("experimental chain missing chain-selector") + } + if strings.TrimSpace(ec.RPCURL) == "" { + return chain.ResolvedChains{}, fmt.Errorf("experimental chain %d missing rpc-url", ec.ChainSelector) + } + if strings.TrimSpace(ec.Forwarder) == "" { + return chain.ResolvedChains{}, fmt.Errorf("experimental chain %d missing forwarder", ec.ChainSelector) + } + + // For duplicate selectors, keep the supported client and only + // override the forwarder. + if _, exists := clients[ec.ChainSelector]; exists { + if common.HexToAddress(forwarders[ec.ChainSelector]) != common.HexToAddress(ec.Forwarder) { + ui.Warning(fmt.Sprintf("Warning: experimental chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", + ec.ChainSelector, forwarders[ec.ChainSelector], ec.Forwarder)) + forwarders[ec.ChainSelector] = ec.Forwarder + } else { + ct.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") + } + continue + } + + ct.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, chain.RedactURL(ec.RPCURL)) + c, err := ethclient.Dial(ec.RPCURL) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) + } + clients[ec.ChainSelector] = c + forwarders[ec.ChainSelector] = ec.Forwarder + experimental[ec.ChainSelector] = true + ui.Dim(fmt.Sprintf("Added experimental chain (chain-selector: %d)\n", ec.ChainSelector)) + } + + return chain.ResolvedChains{ + Clients: clients, + Forwarders: forwarders, + ExperimentalSelectors: experimental, + }, nil +} + +func (ct *EVMChainType) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) ([]services.Service, error) { + // Convert generic ChainClient map to typed *ethclient.Client map + ethClients := make(map[uint64]*ethclient.Client) + for sel, c := range cfg.Clients { + ec, ok := c.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("EVM: client for selector %d is not *ethclient.Client", sel) + } + ethClients[sel] = ec + } + + // Type-assert the private key + var pk *ecdsa.PrivateKey + if cfg.PrivateKey != nil { + var ok bool + pk, ok = cfg.PrivateKey.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("EVM: private key is not *ecdsa.PrivateKey") + } + } + + dryRun := !cfg.Broadcast + + // cfg.Limits is the generic chain.Limits contract. The EVM chain type + // needs the wider EVMChainLimits contract (adds ChainWriteGasLimit). A + // nil cfg.Limits disables enforcement entirely. + var evmLimits EVMChainLimits + if cfg.Limits != nil { + el, ok := cfg.Limits.(EVMChainLimits) + if !ok { + return nil, fmt.Errorf("EVM chain type: limits value does not implement evm.EVMChainLimits (got %T)", cfg.Limits) + } + evmLimits = el + } + + evmCaps, err := NewEVMChainCapabilities( + ctx, cfg.Logger, cfg.Registry, + ethClients, cfg.Forwarders, pk, + dryRun, evmLimits, + ) + if err != nil { + return nil, err + } + + // Start the EVM chains so they begin listening for triggers + if err := evmCaps.Start(ctx); err != nil { + return nil, fmt.Errorf("EVM: failed to start chain capabilities: %w", err) + } + + ct.evmChains = evmCaps + + srvcs := make([]services.Service, 0, len(evmCaps.EVMChains)) + for _, evm := range evmCaps.EVMChains { + srvcs = append(srvcs, evm) + } + return srvcs, nil +} + +func (ct *EVMChainType) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { + if ct.evmChains == nil { + return fmt.Errorf("EVM: capabilities not registered") + } + evmChain := ct.evmChains.EVMChains[selector] + if evmChain == nil { + return fmt.Errorf("no EVM chain initialized for selector %d", selector) + } + log, ok := triggerData.(*evmpb.Log) + if !ok { + return fmt.Errorf("EVM: trigger data is not *evm.Log") + } + return evmChain.ManualTrigger(ctx, registrationID, log) +} + +// HasSelector reports whether an EVM chain capability has been initialised +// for the given selector. Callers use this at trigger-setup time to avoid +// building a TriggerFunc for a selector the chain type cannot dispatch against. +func (ct *EVMChainType) HasSelector(selector uint64) bool { + if ct.evmChains == nil { + return false + } + return ct.evmChains.EVMChains[selector] != nil +} + +func (ct *EVMChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + return ParseTriggerChainSelector(triggerID) +} + +func (ct *EVMChainType) RunHealthCheck(resolved chain.ResolvedChains) error { + return RunRPCHealthCheck(resolved.Clients, resolved.ExperimentalSelectors) +} + +// ResolveKey parses the user's ECDSA private key from settings. When broadcast +// is true, an invalid or default-sentinel key is a hard error. Otherwise a +// sentinel key is used with a warning so non-broadcast simulations can run. +func (ct *EVMChainType) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { + pk, err := crypto.HexToECDSA(creSettings.User.EthPrivateKey) + if err != nil { + if broadcast { + return nil, fmt.Errorf( + "failed to parse private key, required to broadcast. Please check CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + } + pk, err = crypto.HexToECDSA(defaultSentinelPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse default private key. Please set CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + } + ui.Warning("Using default private key for chain write simulation. To use your own key, set CRE_ETH_PRIVATE_KEY in your .env file or system environment.") + } + if broadcast && pk.D.Cmp(big.NewInt(1)) == 0 { + return nil, fmt.Errorf("you must configure a valid private key to perform on-chain writes. Please set your private key in the .env file before using the --broadcast flag") + } + return pk, nil +} + +// CLI input keys consumed from chain.TriggerParams.ChainTypeInputs. +const ( + TriggerInputTxHash = "evm-tx-hash" + TriggerInputEventIndex = "evm-event-index" +) + +func (ct *EVMChainType) CollectCLIInputs(v *viper.Viper) map[string]string { + inputs := map[string]string{} + if txHash := strings.TrimSpace(v.GetString(TriggerInputTxHash)); txHash != "" { + inputs[TriggerInputTxHash] = txHash + } + if idx := v.GetInt(TriggerInputEventIndex); idx >= 0 { + inputs[TriggerInputEventIndex] = strconv.Itoa(idx) + } + return inputs +} + +// ResolveTriggerData fetches the EVM log payload for the given selector from +// CLI-supplied or interactively-prompted inputs. +func (ct *EVMChainType) ResolveTriggerData(ctx context.Context, selector uint64, params chain.TriggerParams) (interface{}, error) { + clientIface, ok := params.Clients[selector] + if !ok { + return nil, fmt.Errorf("no RPC configured for chain selector %d", selector) + } + client, ok := clientIface.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("invalid client type for EVM chain selector %d", selector) + } + + if params.Interactive { + return GetEVMTriggerLog(ctx, client) + } + + txHash := strings.TrimSpace(params.ChainTypeInputs[TriggerInputTxHash]) + eventIndexStr := strings.TrimSpace(params.ChainTypeInputs[TriggerInputEventIndex]) + if txHash == "" || eventIndexStr == "" { + return nil, fmt.Errorf("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") + } + eventIndex, err := strconv.ParseUint(eventIndexStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid --evm-event-index %q: %w", eventIndexStr, err) + } + return GetEVMTriggerLogFromValues(ctx, client, txHash, eventIndex) +} diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go new file mode 100644 index 00000000..ee40c8a4 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -0,0 +1,373 @@ +package evm + +import ( + "bytes" + "context" + "crypto/ecdsa" + "io" + "math/big" + "os" + "strings" + "sync" + "testing" + + "github.com/rs/zerolog" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func bigOne() *big.Int { return big.NewInt(1) } + +func nopCommonLogger() logger.Logger { + lg := logger.NewWithSync(io.Discard) + return lg +} + +func newRegistry(t *testing.T) *capabilities.Registry { + t.Helper() + r := capabilities.NewRegistry(logger.Test(t)) + return r +} + +// stdioMu serialises os.Stderr / os.Stdout hijacks so parallel capture tests +// don't clobber each other's pipes. +var stdioMu sync.Mutex + +// captureStderr captures anything written to os.Stderr during fn. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + stdioMu.Lock() + defer stdioMu.Unlock() + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + defer func() { + os.Stderr = old + }() + + fn() + + _ = w.Close() + <-done + return buf.String() +} + +func newEVMChainType() *EVMChainType { + lg := zerolog.Nop() + return &EVMChainType{log: &lg} +} + +// Valid anvil dev key #0; known non-sentinel. +const validPK = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +func TestEVMChainType_ResolveKey(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pk string + broadcast bool + wantErr bool + errContains string + wantStderr string // substring expected in ui.Warning stderr; "" = no warn + checkD1 bool // sentinel (D==1) expected if non-err non-broadcast + }{ + { + name: "valid key, non-broadcast, returns parsed key, no warning", + pk: validPK, + broadcast: false, + }, + { + name: "valid key, broadcast, returns parsed key", + pk: validPK, + broadcast: true, + }, + { + name: "invalid hex, non-broadcast, falls back to sentinel and warns", + pk: "notahex", + broadcast: false, + wantStderr: "Using default private key for chain write simulation", + checkD1: true, + }, + { + name: "empty key, non-broadcast, falls back to sentinel and warns", + pk: "", + broadcast: false, + wantStderr: "Using default private key for chain write simulation", + checkD1: true, + }, + { + name: "0x-prefixed key (invalid per HexToECDSA), non-broadcast, falls back + warns", + pk: "0x" + validPK, + broadcast: false, + wantStderr: "Using default private key", + checkD1: true, + }, + { + name: "too-short key, non-broadcast, falls back + warns", + pk: "ab", + broadcast: false, + wantStderr: "Using default private key", + checkD1: true, + }, + { + name: "invalid hex, broadcast, hard error", + pk: "notahex", + broadcast: true, + wantErr: true, + errContains: "failed to parse private key, required to broadcast", + }, + { + name: "empty key, broadcast, hard error", + pk: "", + broadcast: true, + wantErr: true, + errContains: "CRE_ETH_PRIVATE_KEY", + }, + { + name: "sentinel key, broadcast, hard error about configuring valid key", + pk: defaultSentinelPrivateKey, + broadcast: true, + wantErr: true, + errContains: "configure a valid private key", + }, + { + name: "sentinel key, non-broadcast, returned without warning (parses fine)", + pk: defaultSentinelPrivateKey, + broadcast: false, + checkD1: true, + }, + { + name: "too-short key, broadcast, hard error", + pk: "ab", + broadcast: true, + wantErr: true, + errContains: "required to broadcast", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ct := newEVMChainType() + s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: tt.pk}} + + var got interface{} + var err error + stderr := captureStderr(t, func() { + got, err = ct.ResolveKey(s, tt.broadcast) + }) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + assert.Nil(t, got) + return + } + require.NoError(t, err) + pk, ok := got.(*ecdsa.PrivateKey) + require.True(t, ok, "expected *ecdsa.PrivateKey, got %T", got) + require.NotNil(t, pk) + if tt.checkD1 { + assert.Equal(t, 0, pk.D.Cmp(bigOne()), "expected sentinel D==1") + } + if tt.wantStderr == "" { + assert.NotContains(t, stderr, "Using default private key", + "did not expect sentinel warning but got: %s", stderr) + } else { + assert.Contains(t, stderr, tt.wantStderr) + } + }) + } +} + +func TestEVMChainType_ResolveTriggerData_NoClient(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + _, err := ct.ResolveTriggerData(context.Background(), 777, chain.TriggerParams{ + Clients: map[uint64]chain.ChainClient{}, + Interactive: false, + ChainTypeInputs: map[string]string{ + "evm-tx-hash": "0x" + strings.Repeat("a", 64), + "evm-event-index": "0", + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RPC configured for chain selector 777") +} + +func TestEVMChainType_ResolveTriggerData_WrongClientType(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + _, err := ct.ResolveTriggerData(context.Background(), 1, chain.TriggerParams{ + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Interactive: false, + ChainTypeInputs: map[string]string{ + "evm-tx-hash": "0x" + strings.Repeat("a", 64), + "evm-event-index": "0", + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for EVM chain selector 1") +} + +func TestEVMChainType_ExecuteTrigger_NotRegistered(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + err := ct.ExecuteTrigger(context.Background(), 1, "regID", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "EVM: capabilities not registered") +} + +func TestEVMChainType_ExecuteTrigger_UnknownSelector(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + // set evmChains with empty map to bypass nil check + ct.evmChains = &EVMChainCapabilities{EVMChains: nil} + err := ct.ExecuteTrigger(context.Background(), 999, "regID", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no EVM chain initialized for selector 999") +} + +func TestEVMChainType_HasSelector_WhenNotRegistered_ReturnsFalse(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + assert.False(t, ct.HasSelector(1)) + assert.False(t, ct.HasSelector(0)) +} + +func TestEVMChainType_RegisterCapabilities_WrongClientType(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Forwarders: map[uint64]string{1: "0x" + strings.Repeat("a", 40)}, + } + _, err := ct.RegisterCapabilities(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "client for selector 1 is not *ethclient.Client") +} + +// With no clients the caps should still construct, no type-assertion error. +func TestEVMChainType_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + Forwarders: map[uint64]string{}, + Logger: nopCommonLogger(), + Registry: newRegistry(t), + } + srvcs, err := ct.RegisterCapabilities(context.Background(), cfg) + // No clients means no chains; should succeed with empty service list. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assert.Empty(t, srvcs) + assert.False(t, ct.HasSelector(1)) +} + +func TestEVMChainType_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + resolved := chain.ResolvedChains{ + Clients: map[uint64]chain.ChainClient{1: "not-ethclient"}, + } + err := ct.RunHealthCheck(resolved) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for EVM chain type") +} + +func TestEVMChainType_RunHealthCheck_NoClients_Errors(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + err := ct.RunHealthCheck(chain.ResolvedChains{ + Clients: map[uint64]chain.ChainClient{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RPC URLs found") +} + +func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { + t.Parallel() + lg := zerolog.Nop() + chain.Build(&lg) + names := chain.Names() + found := false + for _, n := range names { + if n == "evm" { + found = true + break + } + } + require.True(t, found, "evm chain type should be registered at init; got %v", names) + + ct, err := chain.Get("evm") + require.NoError(t, err) + require.Equal(t, "evm", ct.Name()) +} + +func TestEVMChainType_CollectCLIInputs_BothSet(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + v.Set("evm-tx-hash", "0xabc123") + v.Set("evm-event-index", 2) + + result := ct.CollectCLIInputs(v) + assert.Equal(t, "0xabc123", result[TriggerInputTxHash]) + assert.Equal(t, "2", result[TriggerInputEventIndex]) +} + +func TestEVMChainType_CollectCLIInputs_NegativeIndexOmitted(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + v.Set("evm-tx-hash", "0xabc") + v.Set("evm-event-index", -1) + + result := ct.CollectCLIInputs(v) + assert.Equal(t, "0xabc", result[TriggerInputTxHash]) + _, hasIndex := result[TriggerInputEventIndex] + assert.False(t, hasIndex, "negative index should be omitted") +} + +func TestEVMChainType_CollectCLIInputs_EmptyTxHashOmitted(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + v.Set("evm-tx-hash", "") + v.Set("evm-event-index", 0) + + result := ct.CollectCLIInputs(v) + _, hasTx := result[TriggerInputTxHash] + assert.False(t, hasTx, "empty tx hash should be omitted") + assert.Equal(t, "0", result[TriggerInputEventIndex]) +} + +func TestEVMChainType_CollectCLIInputs_DefaultsOnly(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + // Viper defaults int to 0; simulate's flag registration sets default to -1. + // Without explicit flag defaults, CollectCLIInputs sees 0 (>= 0) and includes it. + v.SetDefault("evm-event-index", -1) + + result := ct.CollectCLIInputs(v) + assert.Empty(t, result) +} diff --git a/cmd/workflow/simulate/chain/evm/health.go b/cmd/workflow/simulate/chain/evm/health.go new file mode 100644 index 00000000..05dde43f --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/health.go @@ -0,0 +1,80 @@ +package evm + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// RunRPCHealthCheck validates RPC connectivity for all configured EVM clients. +// The experimentalSelectors set identifies which selectors are experimental chains. +func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelectors map[uint64]bool) error { + ethClients := make(map[uint64]*ethclient.Client) + for sel, c := range clients { + ec, ok := c.(*ethclient.Client) + if !ok { + return fmt.Errorf("[%d] invalid client type for EVM chain type", sel) + } + ethClients[sel] = ec + } + + return checkRPCConnectivity(ethClients, experimentalSelectors) +} + +// checkRPCConnectivity runs connectivity check against every configured client. +// experimentalSelectors set identifies experimental chains (not in chain-selectors). +func checkRPCConnectivity(clients map[uint64]*ethclient.Client, experimentalSelectors map[uint64]bool) error { + if len(clients) == 0 { + return fmt.Errorf("check your settings: no RPC URLs found for supported or experimental chains") + } + + var errs []error + for selector, c := range clients { + if c == nil { + // shouldnt happen + errs = append(errs, fmt.Errorf("[%d] nil client", selector)) + continue + } + + // Determine chain label for error messages + var chainLabel string + if experimentalSelectors[selector] { + chainLabel = fmt.Sprintf("experimental chain %d", selector) + } else { + name, err := settings.GetChainNameByChainSelector(selector) + if err != nil { + // If we can't get the name, use the selector as the label + chainLabel = fmt.Sprintf("chain %d", selector) + } else { + chainLabel = name + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + chainID, err := c.ChainID(ctx) + cancel() // don't defer in a loop + + if err != nil { + errs = append(errs, fmt.Errorf("[%s] failed RPC health check: %w", chainLabel, err)) + continue + } + if chainID == nil || chainID.Sign() <= 0 { + errs = append(errs, fmt.Errorf("[%s] invalid RPC response: empty or zero chain ID", chainLabel)) + continue + } + } + + if len(errs) > 0 { + // Caller aggregates per-chain-type health-check errors under a single + // "RPC health check failed:" heading, so we only return the joined + // per-selector errors here. + return errors.Join(errs...) + } + return nil +} diff --git a/cmd/workflow/simulate/chain/evm/health_test.go b/cmd/workflow/simulate/chain/evm/health_test.go new file mode 100644 index 00000000..3b6de2a7 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/health_test.go @@ -0,0 +1,291 @@ +package evm + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +const ( + selectorSepolia uint64 = 16015286601757825753 // expects "ethereum-testnet-sepolia" + chainEthMainnet uint64 = 5009297550715157269 // ethereum-mainnet +) + +// newChainIDServer returns a JSON-RPC 2.0 server that replies to eth_chainId. +func newChainIDServer(t *testing.T, reply interface{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + + type rpcErr struct { + Code int `json:"code"` + Message string `json:"message"` + } + + res := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + } + switch v := reply.(type) { + case string: + res["result"] = v + case error: + res["error"] = rpcErr{Code: -32603, Message: v.Error()} + default: + res["result"] = v + } + _ = json.NewEncoder(w).Encode(res) + })) +} + +func newEthClient(t *testing.T, url string) *ethclient.Client { + t.Helper() + c, err := ethclient.Dial(url) + if err != nil { + t.Fatalf("dial eth client: %v", err) + } + return c +} + +func mustContain(t *testing.T, s string, subs ...string) { + t.Helper() + for _, sub := range subs { + if !strings.Contains(s, sub) { + t.Fatalf("expected error to contain %q, got:\n%s", sub, s) + } + } +} + +func TestHealthCheck_NoClientsConfigured(t *testing.T) { + err := checkRPCConnectivity(map[uint64]*ethclient.Client{}, nil) + if err == nil { + t.Fatalf("expected error for no clients configured") + } + mustContain(t, err.Error(), "check your settings: no RPC URLs found for supported or experimental chains") +} + +func TestHealthCheck_NilClient(t *testing.T) { + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + 123: nil, + }, nil) + if err == nil { + t.Fatalf("expected error for nil client") + } + mustContain(t, err.Error(), "[123] nil client") +} + +func TestHealthCheck_AllOK(t *testing.T) { + sOK := newChainIDServer(t, "0xaa36a7") + defer sOK.Close() + + cOK := newEthClient(t, sOK.URL) + defer cOK.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cOK, + }, nil) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestHealthCheck_RPCError_usesChainName(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + + cErr := newEthClient(t, sErr.URL) + defer cErr.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cErr, + }, nil) + if err == nil { + t.Fatalf("expected error for RPC failure") + } + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] failed RPC health check", + ) +} + +func TestHealthCheck_ZeroChainID_usesChainName(t *testing.T) { + sZero := newChainIDServer(t, "0x0") + defer sZero.Close() + + cZero := newEthClient(t, sZero.URL) + defer cZero.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cZero, + }, nil) + if err == nil { + t.Fatalf("expected error for zero chain id") + } + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] invalid RPC response: empty or zero chain ID", + ) +} + +func TestHealthCheck_AggregatesMultipleErrors(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + + cErr := newEthClient(t, sErr.URL) + defer cErr.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cErr, + 777: nil, + }, nil) + if err == nil { + t.Fatalf("expected aggregated error") + } + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] failed RPC health check", + "[777] nil client", + ) +} + +func TestRunRPCHealthCheck_InvalidClientType(t *testing.T) { + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{ + 123: "not-an-ethclient", + }, nil) + if err == nil { + t.Fatalf("expected error for invalid client type") + } + mustContain(t, err.Error(), "invalid client type for EVM chain type") +} + +func TestHealthCheck_ExperimentalSelector_UsesExperimentalLabel(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + c := newEthClient(t, sErr.URL) + defer c.Close() + + const expSel uint64 = 99999999 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{expSel: c}, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[experimental chain 99999999]", + ) +} + +func TestHealthCheck_ExperimentalSelector_ZeroChainID_UsesExperimentalLabel(t *testing.T) { + sZero := newChainIDServer(t, "0x0") + defer sZero.Close() + c := newEthClient(t, sZero.URL) + defer c.Close() + + const expSel uint64 = 42424242 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{expSel: c}, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[experimental chain 42424242]", + "invalid RPC response: empty or zero chain ID", + ) +} + +func TestHealthCheck_UnknownSelector_FallsBackToSelectorLabel(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + c := newEthClient(t, sErr.URL) + defer c.Close() + + const unknown uint64 = 11111 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{unknown: c}, + nil, + ) + require.Error(t, err) + mustContain(t, err.Error(), + fmt.Sprintf("[chain %d]", unknown), + ) +} + +func TestHealthCheck_MixedKnownAndExperimental(t *testing.T) { + sOK := newChainIDServer(t, "0xaa36a7") + defer sOK.Close() + cOK := newEthClient(t, sOK.URL) + defer cOK.Close() + + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + cErr := newEthClient(t, sErr.URL) + defer cErr.Close() + + const expSel uint64 = 99999999 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{ + selectorSepolia: cOK, + expSel: cErr, + }, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[experimental chain 99999999] failed RPC health check", + ) + // sepolia is healthy; its label must not appear. + assert.NotContains(t, err.Error(), "[ethereum-testnet-sepolia] failed") +} + +// RunRPCHealthCheck (public wrapper) — ensures ChainClient map conversion. +func TestRunRPCHealthCheck_WrapperConvertsEthClientMap(t *testing.T) { + sOK := newChainIDServer(t, "0xaa36a7") + defer sOK.Close() + c := newEthClient(t, sOK.URL) + defer c.Close() + + err := RunRPCHealthCheck( + map[uint64]chain.ChainClient{selectorSepolia: c}, + map[uint64]bool{}, + ) + require.NoError(t, err) +} + +func TestHealthCheck_ThreeErrors_AllLabelsInAggregated(t *testing.T) { + sErr1 := newChainIDServer(t, fmt.Errorf("boom1")) + defer sErr1.Close() + cErr1 := newEthClient(t, sErr1.URL) + defer cErr1.Close() + + sErr2 := newChainIDServer(t, fmt.Errorf("boom2")) + defer sErr2.Close() + cErr2 := newEthClient(t, sErr2.URL) + defer cErr2.Close() + + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{ + selectorSepolia: cErr1, + chainEthMainnet: cErr2, + 77777: nil, + }, + nil, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] failed RPC health check", + "[ethereum-mainnet] failed RPC health check", + "[77777] nil client", + ) +} diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go new file mode 100644 index 00000000..b7b50e02 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -0,0 +1,110 @@ +package evm + +import ( + "context" + "fmt" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// EVMChainLimits is the EVM-scoped limit contract LimitedEVMChain enforces. +// It extends chain.Limits with EVM-specific accessors (e.g. gas limit) so +// non-EVM chain types cannot accidentally depend on EVM semantics. +type EVMChainLimits interface { + chain.Limits + ChainWriteGasLimit() uint64 +} + +// LimitedEVMChain wraps an evmserver.ClientCapability and enforces chain write +// report size and gas limits. +type LimitedEVMChain struct { + inner evmserver.ClientCapability + limits EVMChainLimits +} + +var _ evmserver.ClientCapability = (*LimitedEVMChain)(nil) + +func NewLimitedEVMChain(inner evmserver.ClientCapability, limits EVMChainLimits) *LimitedEVMChain { + return &LimitedEVMChain{inner: inner, limits: limits} +} + +func (l *LimitedEVMChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + // Check report size + reportLimit := l.limits.ChainWriteReportSizeLimit() + if reportLimit > 0 && input.Report != nil && len(input.Report.RawReport) > reportLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), reportLimit), + caperrors.ResourceExhausted, + ) + } + + // Check gas limit + gasLimit := l.limits.ChainWriteGasLimit() + if gasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > gasLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, gasLimit), + caperrors.ResourceExhausted, + ) + } + + return l.inner.WriteReport(ctx, metadata, input) +} + +// All other methods delegate to the inner capability. + +func (l *LimitedEVMChain) CallContract(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { + return l.inner.CallContract(ctx, metadata, input) +} + +func (l *LimitedEVMChain) FilterLogs(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { + return l.inner.FilterLogs(ctx, metadata, input) +} + +func (l *LimitedEVMChain) BalanceAt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { + return l.inner.BalanceAt(ctx, metadata, input) +} + +func (l *LimitedEVMChain) EstimateGas(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { + return l.inner.EstimateGas(ctx, metadata, input) +} + +func (l *LimitedEVMChain) GetTransactionByHash(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { + return l.inner.GetTransactionByHash(ctx, metadata, input) +} + +func (l *LimitedEVMChain) GetTransactionReceipt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { + return l.inner.GetTransactionReceipt(ctx, metadata, input) +} + +func (l *LimitedEVMChain) HeaderByNumber(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { + return l.inner.HeaderByNumber(ctx, metadata, input) +} + +func (l *LimitedEVMChain) RegisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { + return l.inner.RegisterLogTrigger(ctx, triggerID, metadata, input) +} + +func (l *LimitedEVMChain) UnregisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) caperrors.Error { + return l.inner.UnregisterLogTrigger(ctx, triggerID, metadata, input) +} + +func (l *LimitedEVMChain) ChainSelector() uint64 { return l.inner.ChainSelector() } +func (l *LimitedEVMChain) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedEVMChain) Close() error { return l.inner.Close() } +func (l *LimitedEVMChain) HealthReport() map[string]error { return l.inner.HealthReport() } +func (l *LimitedEVMChain) Name() string { return l.inner.Name() } +func (l *LimitedEVMChain) Description() string { return l.inner.Description() } +func (l *LimitedEVMChain) Ready() error { return l.inner.Ready() } +func (l *LimitedEVMChain) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} + +func (l *LimitedEVMChain) AckEvent(ctx context.Context, triggerId string, eventId string, method string) caperrors.Error { + return l.inner.AckEvent(ctx, triggerId, eventId, method) +} diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go new file mode 100644 index 00000000..362a3bb4 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -0,0 +1,149 @@ +package evm + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" +) + +type stubEVMLimits struct { + reportSizeLimit int + gasLimit uint64 +} + +func (s *stubEVMLimits) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } +func (s *stubEVMLimits) ChainWriteGasLimit() uint64 { return s.gasLimit } + +type evmCapabilityBaseStub struct{} + +func (evmCapabilityBaseStub) Start(context.Context) error { return nil } +func (evmCapabilityBaseStub) Close() error { return nil } +func (evmCapabilityBaseStub) HealthReport() map[string]error { return map[string]error{} } +func (evmCapabilityBaseStub) Name() string { return "stub" } +func (evmCapabilityBaseStub) Description() string { return "stub" } +func (evmCapabilityBaseStub) Ready() error { return nil } +func (evmCapabilityBaseStub) Initialise(context.Context, core.StandardCapabilitiesDependencies) error { + return nil +} + +type evmClientCapabilityStub struct { + evmCapabilityBaseStub + writeReportFn func(context.Context, commonCap.RequestMetadata, *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) + writeReportCalls int +} + +var _ evmserver.ClientCapability = (*evmClientCapabilityStub)(nil) + +func (s *evmClientCapabilityStub) CallContract(context.Context, commonCap.RequestMetadata, *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) FilterLogs(context.Context, commonCap.RequestMetadata, *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) BalanceAt(context.Context, commonCap.RequestMetadata, *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) EstimateGas(context.Context, commonCap.RequestMetadata, *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) GetTransactionByHash(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) GetTransactionReceipt(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) HeaderByNumber(context.Context, commonCap.RequestMetadata, *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) RegisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) UnregisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) caperrors.Error { + return nil +} + +func (s *evmClientCapabilityStub) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + s.writeReportCalls++ + if s.writeReportFn != nil { + return s.writeReportFn(ctx, metadata, input) + } + return nil, nil +} +func (s *evmClientCapabilityStub) AckEvent(context.Context, string, string, string) caperrors.Error { + return nil +} +func (s *evmClientCapabilityStub) ChainSelector() uint64 { return 0 } + +func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { + t.Parallel() + + limits := &stubEVMLimits{reportSizeLimit: 4} + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, limits) + + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "chain write report size 5 bytes exceeds limit of 4 bytes") + assert.Equal(t, 0, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { + t.Parallel() + + limits := &stubEVMLimits{gasLimit: 10} + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, limits) + + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + GasConfig: &evmcappb.GasConfig{GasLimit: 11}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "EVM gas limit 11 exceeds maximum of 10") + assert.Equal(t, 0, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { + t.Parallel() + + limits := &stubEVMLimits{reportSizeLimit: 4, gasLimit: 10} + + input := &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("1234")}, + GasConfig: &evmcappb.GasConfig{GasLimit: 10}, + } + expectedResp := &commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply]{Response: &evmcappb.WriteReportReply{}} + + inner := &evmClientCapabilityStub{ + writeReportFn: func(_ context.Context, _ commonCap.RequestMetadata, got *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + assert.Same(t, input, got) + return expectedResp, nil + }, + } + + wrapper := NewLimitedEVMChain(inner, limits) + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, input) + require.NoError(t, err) + assert.Same(t, expectedResp, resp) + assert.Equal(t, 1, inner.writeReportCalls) +} diff --git a/cmd/workflow/simulate/simulator_utils.go b/cmd/workflow/simulate/chain/evm/supported_chains.go similarity index 65% rename from cmd/workflow/simulate/simulator_utils.go rename to cmd/workflow/simulate/chain/evm/supported_chains.go index 6334a2c5..7db9aeed 100644 --- a/cmd/workflow/simulate/simulator_utils.go +++ b/cmd/workflow/simulate/chain/evm/supported_chains.go @@ -1,34 +1,13 @@ -package simulate +package evm import ( - "context" - "errors" - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" - chainselectors "github.com/smartcontractkit/chain-selectors" - "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) -const WorkflowExecutionTimeout = 5 * time.Minute - -type ChainSelector = uint64 - -type ChainConfig struct { - Selector ChainSelector - Forwarder string -} - -// SupportedEVM is the canonical list you can range over. -var SupportedEVM = []ChainConfig{ +// SupportedChains is the canonical list of EVM chains supported for simulation. +var SupportedChains = []chain.ChainConfig{ // Ethereum {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector, Forwarder: "0x15fC6ae953E024d975e77382eEeC56A9101f9F88"}, {Selector: chainselectors.ETHEREUM_MAINNET.Selector, Forwarder: "0xa3d1ad4ac559a6575a114998affb2fb2ec97a7d9"}, @@ -138,94 +117,3 @@ var SupportedEVM = []ChainConfig{ // DTCC {Selector: chainselectors.DTCC_TESTNET_ANDESITE.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, } - -// parse "ChainSelector:" from trigger id, e.g. "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger" -var chainSelectorRe = regexp.MustCompile(`(?i)chainselector:(\d+)`) - -func parseChainSelectorFromTriggerID(id string) (uint64, bool) { - m := chainSelectorRe.FindStringSubmatch(id) - if len(m) < 2 { - return 0, false - } - - v, err := strconv.ParseUint(m[1], 10, 64) - if err != nil { - return 0, false - } - - return v, true -} - -// redactURL returns a version of the URL with path segments and query parameters -// masked to avoid leaking secrets that may have been resolved from environment variables. -// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***". -func redactURL(rawURL string) string { - u, err := url.Parse(rawURL) - if err != nil { - return "***" - } - // Mask the last path segment (most common location for API keys) - u.Path = strings.TrimRight(u.Path, "/") - if u.Path != "" && u.Path != "/" { - parts := strings.Split(u.Path, "/") - if len(parts) > 1 { - parts[len(parts)-1] = "***" - } - u.RawPath = "" - u.Path = strings.Join(parts, "/") - } - // Remove query params entirely - u.RawQuery = "" - u.Fragment = "" - // Use Opaque to avoid re-encoding the path - return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) -} - -// runRPCHealthCheck runs connectivity check against every configured client. -// experimentalForwarders keys identify experimental chains (not in chain-selectors). -func runRPCHealthCheck(clients map[uint64]*ethclient.Client, experimentalForwarders map[uint64]common.Address) error { - if len(clients) == 0 { - return fmt.Errorf("check your settings: no RPC URLs found for supported or experimental chains") - } - - var errs []error - for selector, c := range clients { - if c == nil { - // shouldnt happen - errs = append(errs, fmt.Errorf("[%d] nil client", selector)) - continue - } - - // Determine chain label for error messages - var chainLabel string - if _, isExperimental := experimentalForwarders[selector]; isExperimental { - chainLabel = fmt.Sprintf("experimental chain %d", selector) - } else { - name, err := settings.GetChainNameByChainSelector(selector) - if err != nil { - // If we can't get the name, use the selector as the label - chainLabel = fmt.Sprintf("chain %d", selector) - } else { - chainLabel = name - } - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - chainID, err := c.ChainID(ctx) - cancel() // don't defer in a loop - - if err != nil { - errs = append(errs, fmt.Errorf("[%s] failed RPC health check: %w", chainLabel, err)) - continue - } - if chainID == nil || chainID.Sign() <= 0 { - errs = append(errs, fmt.Errorf("[%s] invalid RPC response: empty or zero chain ID", chainLabel)) - continue - } - } - - if len(errs) > 0 { - return fmt.Errorf("RPC health check failed:\n%w", errors.Join(errs...)) - } - return nil -} diff --git a/cmd/workflow/simulate/chain/evm/supported_chains_test.go b/cmd/workflow/simulate/chain/evm/supported_chains_test.go new file mode 100644 index 00000000..708984d2 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/supported_chains_test.go @@ -0,0 +1,71 @@ +package evm + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" +) + +// All forwarders declared in supported_chains.go must be valid 0x-prefixed +// 20-byte hex addresses. Catches typos that would only surface as runtime +// "invalid address" errors later in simulation. + +var forwarderRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) + +func TestSupportedChains_AllSelectorsNonZero(t *testing.T) { + t.Parallel() + for i, c := range SupportedChains { + require.NotZerof(t, c.Selector, "index %d has zero selector", i) + } +} + +func TestSupportedChains_AllSelectorsUnique(t *testing.T) { + t.Parallel() + seen := map[uint64]int{} + for i, c := range SupportedChains { + if prev, ok := seen[c.Selector]; ok { + t.Fatalf("duplicate selector %d at indices %d and %d", c.Selector, prev, i) + } + seen[c.Selector] = i + } +} + +func TestSupportedChains_AllForwardersValidHexAddress(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + assert.True(t, forwarderRe.MatchString(c.Forwarder), + "selector %d: invalid forwarder hex %q", c.Selector, c.Forwarder) + } +} + +func TestSupportedChains_AllSelectorsResolveToChainName(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + info, err := chainselectors.GetSelectorFamily(c.Selector) + require.NoErrorf(t, err, "selector %d missing family", c.Selector) + assert.NotEmpty(t, info) + } +} + +func TestSupportedChains_NoForwarderEmpty(t *testing.T) { + t.Parallel() + for i, c := range SupportedChains { + require.NotEmpty(t, c.Forwarder, "supported chain at index %d has empty forwarder", i) + } +} + +func TestSupportedChains_ReturnedByChainType(t *testing.T) { + t.Parallel() + f := newEVMChainType() + ret := f.SupportedChains() + require.Equal(t, len(SupportedChains), len(ret)) + // Element-wise identity (same struct values, same order). + for i, c := range SupportedChains { + assert.Equal(t, c.Selector, ret[i].Selector, "selector at index %d", i) + assert.Equal(t, c.Forwarder, ret[i].Forwarder, "forwarder at index %d", i) + } +} diff --git a/cmd/workflow/simulate/chain/evm/trigger.go b/cmd/workflow/simulate/chain/evm/trigger.go new file mode 100644 index 00000000..ecfac13d --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/trigger.go @@ -0,0 +1,166 @@ +package evm + +import ( + "context" + "fmt" + "math" + "math/big" + "regexp" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" + + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// parse "ChainSelector:" from trigger id, e.g. "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger" +var chainSelectorRe = regexp.MustCompile(`(?i)chainselector:(\d+)`) + +// ParseTriggerChainSelector extracts a chain selector from a trigger ID string. +// Returns 0, false if not found. +func ParseTriggerChainSelector(id string) (uint64, bool) { + m := chainSelectorRe.FindStringSubmatch(id) + if len(m) < 2 { + return 0, false + } + v, err := strconv.ParseUint(m[1], 10, 64) + if err != nil { + return 0, false + } + return v, true +} + +// GetEVMTriggerLog prompts user for EVM trigger data and fetches the log interactively. +func GetEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evmpb.Log, error) { + var txHashInput string + var eventIndexInput string + + ui.Line() + if err := ui.InputForm([]ui.InputField{ + { + Title: "EVM Trigger Configuration", + Description: "Transaction hash for the EVM log event", + Placeholder: "0x...", + Value: &txHashInput, + Validate: func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return fmt.Errorf("transaction hash cannot be empty") + } + if !strings.HasPrefix(s, "0x") { + return fmt.Errorf("transaction hash must start with 0x") + } + if len(s) != 66 { + return fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(s)) + } + return nil + }, + }, + { + Title: "Event Index", + Description: "Log event index (0-based)", + Placeholder: "0", + Suggestions: []string{"0"}, + Value: &eventIndexInput, + Validate: func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("event index cannot be empty") + } + if _, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32); err != nil { + return fmt.Errorf("invalid event index: must be a number") + } + return nil + }, + }, + }); err != nil { + return nil, fmt.Errorf("EVM trigger input cancelled: %w", err) + } + + txHashInput = strings.TrimSpace(txHashInput) + txHash := common.HexToHash(txHashInput) + + eventIndexInput = strings.TrimSpace(eventIndexInput) + eventIndex, err := strconv.ParseUint(eventIndexInput, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid event index: %w", err) + } + + return fetchAndConvertLog(ctx, ethClient, txHash, eventIndex, true) +} + +// GetEVMTriggerLogFromValues fetches a log given tx hash string and event index. +// Unlike GetEVMTriggerLog (interactive), this does not emit ui.Success messages +// to keep non-interactive/CI output clean. +func GetEVMTriggerLogFromValues(ctx context.Context, ethClient *ethclient.Client, txHashStr string, eventIndex uint64) (*evmpb.Log, error) { + txHashStr = strings.TrimSpace(txHashStr) + if txHashStr == "" { + return nil, fmt.Errorf("transaction hash cannot be empty") + } + if !strings.HasPrefix(txHashStr, "0x") { + return nil, fmt.Errorf("transaction hash must start with 0x") + } + if len(txHashStr) != 66 { + return nil, fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(txHashStr)) + } + + txHash := common.HexToHash(txHashStr) + return fetchAndConvertLog(ctx, ethClient, txHash, eventIndex, false) +} + +// fetchAndConvertLog fetches a transaction receipt log and converts it to the protobuf format. +// When verbose is true (interactive mode), ui.Success messages are emitted. +func fetchAndConvertLog(ctx context.Context, ethClient *ethclient.Client, txHash common.Hash, eventIndex uint64, verbose bool) (*evmpb.Log, error) { + receiptSpinner := ui.NewSpinner() + receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) + txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) + receiptSpinner.Stop() + if err != nil { + return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) + } + if eventIndex >= uint64(len(txReceipt.Logs)) { + return nil, fmt.Errorf("event index %d out of range, transaction has %d log events", eventIndex, len(txReceipt.Logs)) + } + + log := txReceipt.Logs[eventIndex] + if verbose { + ui.Success(fmt.Sprintf("Found log event at index %d: contract=%s, topics=%d", eventIndex, log.Address.Hex(), len(log.Topics))) + } + + var txIndex, logIndex uint32 + if log.TxIndex > math.MaxUint32 { + return nil, fmt.Errorf("transaction index %d exceeds uint32 maximum value", log.TxIndex) + } + txIndex = uint32(log.TxIndex) // #nosec G115 -- validated above + + if log.Index > math.MaxUint32 { + return nil, fmt.Errorf("log index %d exceeds uint32 maximum value", log.Index) + } + logIndex = uint32(log.Index) // #nosec G115 -- validated above + + pbLog := &evmpb.Log{ + Address: log.Address.Bytes(), + Data: log.Data, + BlockHash: log.BlockHash.Bytes(), + TxHash: log.TxHash.Bytes(), + TxIndex: txIndex, + Index: logIndex, + Removed: log.Removed, + BlockNumber: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(log.BlockNumber)), + } + for _, topic := range log.Topics { + pbLog.Topics = append(pbLog.Topics, topic.Bytes()) + } + if len(log.Topics) > 0 { + pbLog.EventSig = log.Topics[0].Bytes() + } + + if verbose { + ui.Success(fmt.Sprintf("Created EVM trigger log for transaction %s, event %d", txHash.Hex(), eventIndex)) + } + return pbLog, nil +} diff --git a/cmd/workflow/simulate/chain/evm/trigger_test.go b/cmd/workflow/simulate/chain/evm/trigger_test.go new file mode 100644 index 00000000..45b3b0d9 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/trigger_test.go @@ -0,0 +1,410 @@ +package evm + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const zero64 = "0x" + "0000000000000000000000000000000000000000000000000000000000000000" + +func TestParseTriggerChainSelector(t *testing.T) { + tests := []struct { + name string + id string + want uint64 + ok bool + }{ + { + name: "mainnet format", + id: "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger", + want: uint64(5009297550715157269), + ok: true, + }, + { + name: "sepolia lowercase", + id: "evm:chainselector:16015286601757825753@1.0.0", + want: uint64(16015286601757825753), + ok: true, + }, + { + name: "sepolia uppercase", + id: "EVM:CHAINSELECTOR:16015286601757825753@1.0.0", + want: uint64(16015286601757825753), + ok: true, + }, + { + name: "leading and trailing spaces", + id: " evm:ChainSelector:123@1.0.0 ", + want: uint64(123), + ok: true, + }, + { + name: "no selector present", + id: "evm@1.0.0 LogTrigger", + want: 0, + ok: false, + }, + { + name: "non-numeric selector", + id: "evm:ChainSelector:notanumber@1.0.0", + want: 0, + ok: false, + }, + { + name: "empty selector", + id: "evm:ChainSelector:@1.0.0", + want: 0, + ok: false, + }, + { + name: "overflow uint64", + id: "evm:ChainSelector:18446744073709551616@1.0.0", + want: 0, + ok: false, + }, + { + name: "digits followed by letters (regex grabs only digits)", + id: "evm:ChainSelector:987abc@1.0.0", + want: uint64(987), + ok: true, + }, + { + name: "multiple occurrences - returns first", + id: "foo ChainSelector:1 bar ChainSelector:2 baz", + want: uint64(1), + ok: true, + }, + { + name: "zero selector", + id: "evm:ChainSelector:0@1.0.0", + want: 0, + ok: true, + }, + { + name: "max uint64", + id: "evm:ChainSelector:18446744073709551615@1.0.0", + want: uint64(18446744073709551615), + ok: true, + }, + { + name: "negative sign not matched", + id: "evm:ChainSelector:-1@1.0.0", + want: 0, + ok: false, + }, + { + name: "unicode digits rejected", + id: "evm:ChainSelector:123@1.0.0", + want: 0, + ok: false, + }, + { + name: "tab before number rejected", + id: "evm:ChainSelector:\t42@1.0.0", + want: 0, + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseTriggerChainSelector(tt.id) + if ok != tt.ok || got != tt.want { + t.Fatalf("ParseTriggerChainSelector(%q) = (%d, %v); want (%d, %v)", tt.id, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestGetEVMTriggerLogFromValues_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hash string + errSub string + }{ + {"empty string", "", "transaction hash cannot be empty"}, + {"whitespace only", " ", "transaction hash cannot be empty"}, + {"no 0x prefix, right length", strings.Repeat("a", 66), "must start with 0x"}, + {"0x prefix, too short", "0x" + strings.Repeat("a", 10), "invalid transaction hash length"}, + {"0x prefix, too long", "0x" + strings.Repeat("a", 100), "invalid transaction hash length"}, + {"valid length but 65 chars", "0x" + strings.Repeat("a", 63), "invalid transaction hash length"}, + {"valid length but 67 chars", "0x" + strings.Repeat("a", 65), "invalid transaction hash length"}, + {"uppercase 0X rejected", "0X" + strings.Repeat("a", 64), "must start with 0x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := GetEVMTriggerLogFromValues(context.Background(), nil, tt.hash, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errSub) + }) + } +} + +type mockRPC struct { + srv *httptest.Server + receipts map[string]*types.Receipt + errFor map[string]error +} + +func newMockRPC(t *testing.T) *mockRPC { + t.Helper() + m := &mockRPC{ + receipts: map[string]*types.Receipt{}, + errFor: map[string]error{}, + } + m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{"jsonrpc": "2.0", "id": req.ID} + + switch req.Method { + case "eth_getTransactionReceipt": + if len(req.Params) == 0 { + resp["error"] = map[string]any{"code": -32602, "message": "missing params"} + break + } + var hash string + _ = json.Unmarshal(req.Params[0], &hash) + if e, ok := m.errFor[strings.ToLower(hash)]; ok { + resp["error"] = map[string]any{"code": -32603, "message": e.Error()} + break + } + rec, ok := m.receipts[strings.ToLower(hash)] + if !ok { + resp["result"] = nil + break + } + resp["result"] = receiptToJSON(rec) + case "eth_chainId": + resp["result"] = "0x1" + default: + resp["error"] = map[string]any{"code": -32601, "message": "method not found"} + } + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(m.srv.Close) + return m +} + +func receiptToJSON(r *types.Receipt) map[string]any { + logs := make([]map[string]any, 0, len(r.Logs)) + for _, l := range r.Logs { + tpcs := make([]string, 0, len(l.Topics)) + for _, t := range l.Topics { + tpcs = append(tpcs, t.Hex()) + } + logs = append(logs, map[string]any{ + "address": l.Address.Hex(), + "topics": tpcs, + "data": "0x" + common.Bytes2Hex(l.Data), + "blockNumber": fmt.Sprintf("0x%x", l.BlockNumber), + "transactionHash": l.TxHash.Hex(), + "transactionIndex": fmt.Sprintf("0x%x", l.TxIndex), + "blockHash": l.BlockHash.Hex(), + "logIndex": fmt.Sprintf("0x%x", l.Index), + "removed": l.Removed, + }) + } + return map[string]any{ + "transactionHash": r.TxHash.Hex(), + "transactionIndex": fmt.Sprintf("0x%x", r.TransactionIndex), + "blockHash": r.BlockHash.Hex(), + "blockNumber": fmt.Sprintf("0x%x", r.BlockNumber), + "cumulativeGasUsed": fmt.Sprintf("0x%x", r.CumulativeGasUsed), + "gasUsed": fmt.Sprintf("0x%x", r.GasUsed), + "contractAddress": nil, + "logs": logs, + "logsBloom": "0x" + strings.Repeat("00", 256), + "status": "0x1", + "type": "0x0", + "effectiveGasPrice": "0x0", + } +} + +func addrFromHex(h string) common.Address { return common.HexToAddress(h) } +func hashFromHex(h string) common.Hash { return common.HexToHash(h) } + +func mkReceipt(txHash common.Hash, logs []*types.Log) *types.Receipt { + return &types.Receipt{ + TxHash: txHash, + TransactionIndex: 0, + BlockHash: hashFromHex("0xb1"), + BlockNumber: big.NewInt(1), + Logs: logs, + Status: types.ReceiptStatusSuccessful, + } +} + +func TestGetEVMTriggerLogFromValues_FetchError(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("a", 64) + m.errFor[strings.ToLower(txHash)] = fmt.Errorf("receipt not found") + + _, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch transaction receipt") +} + +func TestGetEVMTriggerLogFromValues_EventIndexOutOfRange(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("b", 64) + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: addrFromHex("0xabcd0000000000000000000000000000000000ab"), + Topics: []common.Hash{hashFromHex("0xaa")}, + Data: []byte{0x01, 0x02}, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 1, + TxIndex: 0, + Index: 0, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + _, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "event index 5 out of range") + assert.Contains(t, err.Error(), "transaction has 1 log events") +} + +func TestGetEVMTriggerLogFromValues_ZeroLogs_OutOfRange(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("c", 64) + m.receipts[strings.ToLower(txHash)] = mkReceipt(hashFromHex(txHash), nil) + + _, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "event index 0 out of range") + assert.Contains(t, err.Error(), "transaction has 0 log events") +} + +func TestGetEVMTriggerLogFromValues_Success(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("d", 64) + log0Addr := addrFromHex("0x1111111111111111111111111111111111111111") + topicSig := hashFromHex("0x" + strings.Repeat("2", 64)) + extraTopic := hashFromHex("0x" + strings.Repeat("3", 64)) + data := []byte{0xde, 0xad, 0xbe, 0xef} + + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: log0Addr, + Topics: []common.Hash{topicSig, extraTopic}, + Data: data, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 42, + TxIndex: 7, + Index: 3, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + got, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, log0Addr.Bytes(), got.Address) + assert.Equal(t, data, got.Data) + require.Len(t, got.Topics, 2) + assert.Equal(t, topicSig.Bytes(), got.Topics[0]) + assert.Equal(t, extraTopic.Bytes(), got.Topics[1]) + assert.Equal(t, topicSig.Bytes(), got.EventSig) + assert.Equal(t, uint32(7), got.TxIndex) + assert.Equal(t, uint32(3), got.Index) + require.NotNil(t, got.BlockNumber) +} + +func TestGetEVMTriggerLogFromValues_SuccessNoTopicsLeavesEventSigNil(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("e", 64) + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: addrFromHex("0x2222222222222222222222222222222222222222"), + Topics: nil, + Data: []byte{0x01}, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 1, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + got, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.NoError(t, err) + assert.Empty(t, got.Topics) + assert.Nil(t, got.EventSig) +} + +func TestGetEVMTriggerLogFromValues_NoRPCWhenHashInvalid(t *testing.T) { + t.Parallel() + // Pass nil client; validation should fire before any RPC attempt. + _, err := GetEVMTriggerLogFromValues(context.Background(), nil, "not-a-hash", 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "must start with 0x") +} + +func TestGetEVMTriggerLogFromValues_ZeroAddressLog(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("f", 64) + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: addrFromHex(zero64[:42]), + Topics: []common.Hash{hashFromHex("0x00")}, + Data: []byte{}, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 1, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + got, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.NoError(t, err) + assert.Len(t, got.Address, 20) // 20-byte address always +} diff --git a/cmd/workflow/simulate/chain/registry.go b/cmd/workflow/simulate/chain/registry.go new file mode 100644 index 00000000..6ed6e11a --- /dev/null +++ b/cmd/workflow/simulate/chain/registry.go @@ -0,0 +1,204 @@ +package chain + +import ( + "context" + "fmt" + "sort" + "strconv" + "sync" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// Factory constructs a ChainType with the logger the simulator uses. +// Registered at init() time; invoked during Build() at command runtime. +type Factory func(lggr *zerolog.Logger) ChainType + +// ChainType defines what a chain type plugin must implement +// to participate in workflow simulation. +type ChainType interface { + // Name returns the chain type identifier (e.g., "evm", "aptos"). + Name() string + + // ResolveClients creates RPC clients for all chains this chain type can + // simulate, including both supported and experimental chains. Returns a + // ResolvedChains bundle containing clients keyed by chain selector, + // forwarder addresses, and any chain-type-agnostic metadata (e.g. + // experimental-selector set) that later interface methods need. + ResolveClients(v *viper.Viper) (ResolvedChains, error) + + // ResolveKey parses and validates this chain type's signing key from + // settings. If broadcast is true, missing or default-sentinel keys + // are a hard error; otherwise a sentinel may be used with a warning. + // Returns the parsed key (chain-type-specific) or nil if the chain + // type does not use a signing key. + ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) + + // ResolveTriggerData produces the chain-type-specific trigger payload for + // a given chain selector, using runtime parameters from the caller. + ResolveTriggerData(ctx context.Context, selector uint64, params TriggerParams) (interface{}, error) + + // RegisterCapabilities creates capability servers for this chain type's + // chains and adds them to the registry. Returns the underlying services + // (e.g., per-selector chain fakes) so the caller can manage their lifecycle. + RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) ([]services.Service, error) + + // ExecuteTrigger fires a chain-specific trigger for a given selector. + // Each chain type defines what triggerData looks like. + ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error + + // HasSelector reports whether the chain type has a fully initialised + // capability for the given selector after RegisterCapabilities ran. + // Used by the trigger-setup loop to fail fast before a TriggerFunc is + // assigned for a selector the chain type cannot actually dispatch against. + HasSelector(selector uint64) bool + + // ParseTriggerChainSelector extracts a chain selector from a + // trigger subscription ID string (e.g., "evm:ChainSelector:123@1.0.0"). + // Returns 0, false if the trigger doesn't belong to this chain type. + ParseTriggerChainSelector(triggerID string) (uint64, bool) + + // RunHealthCheck validates RPC connectivity for all resolved clients. + // The resolved argument is the same bundle ResolveClients returned, + // threaded back by the caller so RunHealthCheck is self-contained and + // does not depend on hidden state on the ChainType instance. + RunHealthCheck(resolved ResolvedChains) error + + // SupportedChains returns the list of chains this chain type supports + // out of the box (for display/documentation purposes). + SupportedChains() []ChainConfig + + // CollectCLIInputs reads this chain type's CLI flags from viper and + // returns them as key-value pairs for TriggerParams.ChainTypeInputs. + CollectCLIInputs(v *viper.Viper) map[string]string +} + +// CLIFlagDef describes a CLI flag a chain type needs registered. +type CLIFlagDef struct { + Name string + Description string + DefaultValue string // empty string for string flags, or special handling + FlagType CLIFlagType +} + +// CLIFlagType indicates the Go type of a CLI flag. +type CLIFlagType int + +const ( + CLIFlagString CLIFlagType = iota + CLIFlagInt +) + +// registration bundles a factory with its CLI flag definitions. +type registration struct { + factory Factory + flagDefs []CLIFlagDef +} + +var ( + mu sync.RWMutex + registrations = make(map[string]registration) + chainTypes = make(map[string]ChainType) +) + +// Register adds a chain type factory and its CLI flag definitions to the +// registry. Called from chain type package init(); the factory is invoked later +// in Build(). Panics on duplicate registration (programming error). +func Register(name string, factory Factory, flagDefs []CLIFlagDef) { + mu.Lock() + defer mu.Unlock() + if _, exists := registrations[name]; exists { + panic(fmt.Sprintf("chain type %q already registered", name)) + } + registrations[name] = registration{factory: factory, flagDefs: flagDefs} +} + +// Build instantiates every registered chain type with the given logger. +// Must be called once at command startup before All()/Get() return +// meaningful results. +func Build(lggr *zerolog.Logger) { + mu.Lock() + defer mu.Unlock() + for name, reg := range registrations { + chainTypes[name] = reg.factory(lggr) + } +} + +// Get returns a registered chain type by name. +func Get(name string) (ChainType, error) { + mu.RLock() + defer mu.RUnlock() + ct, ok := chainTypes[name] + if !ok { + return nil, fmt.Errorf("unknown chain type %q; registered: %v", name, namesLocked()) + } + return ct, nil +} + +// All returns a copy of all registered chain types. +func All() map[string]ChainType { + mu.RLock() + defer mu.RUnlock() + result := make(map[string]ChainType, len(chainTypes)) + for k, v := range chainTypes { + result[k] = v + } + return result +} + +// RegisterAllCLIFlags registers CLI flags from every registered chain type's +// flag definitions. Called at command setup time before Build(). +func RegisterAllCLIFlags(cmd *cobra.Command) { + mu.RLock() + defer mu.RUnlock() + for _, reg := range registrations { + for _, def := range reg.flagDefs { + switch def.FlagType { + case CLIFlagInt: + defaultVal := -1 + if def.DefaultValue != "" { + if v, err := strconv.Atoi(def.DefaultValue); err == nil { + defaultVal = v + } + } + cmd.Flags().Int(def.Name, defaultVal, def.Description) + default: + cmd.Flags().String(def.Name, def.DefaultValue, def.Description) + } + } + } +} + +// CollectAllCLIInputs gathers CLI inputs from every registered chain type. +func CollectAllCLIInputs(v *viper.Viper) map[string]string { + result := map[string]string{} + for _, ct := range All() { + for k, val := range ct.CollectCLIInputs(v) { + result[k] = val + } + } + return result +} + +// namesLocked returns sorted chain type names. Caller must hold mu. +func namesLocked() []string { + names := make([]string, 0, len(chainTypes)) + for k := range chainTypes { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// Names returns sorted registered chain type names. +func Names() []string { + mu.RLock() + defer mu.RUnlock() + return namesLocked() +} diff --git a/cmd/workflow/simulate/chain/registry_test.go b/cmd/workflow/simulate/chain/registry_test.go new file mode 100644 index 00000000..aa91c81a --- /dev/null +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -0,0 +1,206 @@ +package chain + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func resetRegistry() { + mu.Lock() + defer mu.Unlock() + registrations = make(map[string]registration) + chainTypes = make(map[string]ChainType) +} + +// mockChainType is a testify/mock implementation of ChainType. +type mockChainType struct { + mock.Mock +} + +var _ ChainType = (*mockChainType)(nil) + +func (m *mockChainType) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *mockChainType) ResolveClients(v *viper.Viper) (ResolvedChains, error) { + args := m.Called(v) + resolved, _ := args.Get(0).(ResolvedChains) + return resolved, args.Error(1) +} + +func (m *mockChainType) RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) ([]services.Service, error) { + args := m.Called(ctx, cfg) + srvcs, _ := args.Get(0).([]services.Service) + return srvcs, args.Error(1) +} + +func (m *mockChainType) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { + args := m.Called(ctx, selector, registrationID, triggerData) + return args.Error(0) +} + +func (m *mockChainType) HasSelector(selector uint64) bool { + args := m.Called(selector) + return args.Bool(0) +} + +func (m *mockChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + args := m.Called(triggerID) + return args.Get(0).(uint64), args.Bool(1) +} + +func (m *mockChainType) RunHealthCheck(resolved ResolvedChains) error { + args := m.Called(resolved) + return args.Error(0) +} + +func (m *mockChainType) SupportedChains() []ChainConfig { + args := m.Called() + result, _ := args.Get(0).([]ChainConfig) + return result +} + +func (m *mockChainType) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { + args := m.Called(creSettings, broadcast) + return args.Get(0), args.Error(1) +} + +func (m *mockChainType) ResolveTriggerData(ctx context.Context, selector uint64, params TriggerParams) (interface{}, error) { + args := m.Called(ctx, selector, params) + return args.Get(0), args.Error(1) +} + +func (m *mockChainType) CollectCLIInputs(v *viper.Viper) map[string]string { + args := m.Called(v) + result, _ := args.Get(0).(map[string]string) + return result +} + +func newMockType(name string) *mockChainType { + f := new(mockChainType) + f.On("Name").Return(name) + return f +} + +// registerMock registers a pre-built mock chain type and immediately builds it so +// tests can exercise Get/All/Names without wiring a real logger. +func registerMock(name string, chainType ChainType) { + Register(name, func(*zerolog.Logger) ChainType { return chainType }, nil) + Build(nil) +} + +func TestGetUnknownChainType(t *testing.T) { + resetRegistry() + defer resetRegistry() + + _, err := Get("nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown chain type") +} + +func TestRegisterDuplicatePanics(t *testing.T) { + resetRegistry() + defer resetRegistry() + + registerMock("dup", newMockType("dup")) + assert.Panics(t, func() { + registerMock("dup", newMockType("dup")) + }) +} + +func TestNamesReturnsSorted(t *testing.T) { + resetRegistry() + defer resetRegistry() + + registerMock("zebra", newMockType("zebra")) + registerMock("alpha", newMockType("alpha")) + registerMock("middle", newMockType("middle")) + + names := Names() + assert.Equal(t, []string{"alpha", "middle", "zebra"}, names) +} + +func TestGetErrorIncludesRegisteredNames(t *testing.T) { + resetRegistry() + defer resetRegistry() + + registerMock("evm", newMockType("evm")) + registerMock("aptos", newMockType("aptos")) + + _, err := Get("solana") + require.Error(t, err) + assert.Contains(t, err.Error(), "aptos") + assert.Contains(t, err.Error(), "evm") +} + +func TestRegisterAllCLIFlags_StringAndInt(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register("test", func(*zerolog.Logger) ChainType { return newMockType("test") }, []CLIFlagDef{ + {Name: "test-hash", Description: "a hash", FlagType: CLIFlagString}, + {Name: "test-index", Description: "an index", DefaultValue: "-1", FlagType: CLIFlagInt}, + }) + + cmd := &cobra.Command{Use: "test"} + RegisterAllCLIFlags(cmd) + + f := cmd.Flags().Lookup("test-hash") + require.NotNil(t, f) + assert.Equal(t, "", f.DefValue) + assert.Equal(t, "a hash", f.Usage) + + f = cmd.Flags().Lookup("test-index") + require.NotNil(t, f) + assert.Equal(t, "-1", f.DefValue) + assert.Equal(t, "an index", f.Usage) +} + +func TestCollectAllCLIInputs_MergesAcrossChainTypes(t *testing.T) { + resetRegistry() + defer resetRegistry() + + ct1 := newMockType("alpha") + ct1.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-a": "val-a"}) + registerMock("alpha", ct1) + + ct2 := newMockType("beta") + ct2.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-b": "val-b"}) + registerMock("beta", ct2) + + v := viper.New() + result := CollectAllCLIInputs(v) + + assert.Equal(t, "val-a", result["key-a"]) + assert.Equal(t, "val-b", result["key-b"]) +} + +func TestAllReturnsCopy(t *testing.T) { + resetRegistry() + defer resetRegistry() + + mockCT := newMockType("original") + registerMock("original", mockCT) + + all := All() + delete(all, "original") + + // The registry should still have it + f, err := Get("original") + require.NoError(t, err) + assert.Equal(t, "original", f.Name()) + mockCT.AssertExpectations(t) +} diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go new file mode 100644 index 00000000..12f8c1cb --- /dev/null +++ b/cmd/workflow/simulate/chain/types.go @@ -0,0 +1,56 @@ +package chain + +import ( + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" +) + +// ChainClient is an opaque handle to a chain-specific RPC client. +// Each chain type casts this to its concrete type internally. +type ChainClient interface{} + +// ChainConfig identifies a supported chain within a chain type. +type ChainConfig struct { + Selector uint64 + Forwarder string // chain-type-specific forwarding address +} + +// Limits exposes the chain-write limits that every chain type's capability +// enforcement layer needs. Chain-type-specific accessors (e.g. EVM gas limit) +// live on chain-type-scoped extension interfaces in the family package so +// non-EVM chain types cannot accidentally depend on EVM semantics. +type Limits interface { + ChainWriteReportSizeLimit() int +} + +// ResolvedChains is the result of ChainType.ResolveClients: the RPC clients, +// forwarders, and any chain-type-agnostic metadata later interface methods +// (e.g. RunHealthCheck) depend on. +type ResolvedChains struct { + Clients map[uint64]ChainClient + Forwarders map[uint64]string + // ExperimentalSelectors marks selectors that came from experimental-chain + // config rather than the chain type's built-in supported list. Used for + // error labelling (e.g. "experimental chain N" vs a chain name). + ExperimentalSelectors map[uint64]bool +} + +// CapabilityConfig holds everything a chain type needs to register capabilities. +type CapabilityConfig struct { + Registry *capabilities.Registry + Clients map[uint64]ChainClient + Forwarders map[uint64]string + PrivateKey interface{} // chain-type-specific key type; EVM uses *ecdsa.PrivateKey + Broadcast bool + Limits Limits // nil disables limit enforcement + Logger logger.Logger +} + +// TriggerParams carries chain-type-agnostic inputs needed to resolve trigger data +// for a given chain trigger. ChainTypeInputs is a free-form bag of CLI-supplied +// strings; each chain type interprets the keys it knows about and ignores the rest. +type TriggerParams struct { + Clients map[uint64]ChainClient + Interactive bool + ChainTypeInputs map[string]string +} diff --git a/cmd/workflow/simulate/chain/utils.go b/cmd/workflow/simulate/chain/utils.go new file mode 100644 index 00000000..d8291766 --- /dev/null +++ b/cmd/workflow/simulate/chain/utils.go @@ -0,0 +1,32 @@ +package chain + +import ( + "fmt" + "net/url" + "strings" +) + +// RedactURL returns a version of the URL with path segments and query parameters +// masked to avoid leaking secrets that may have been resolved from environment variables. +// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***". +func RedactURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "***" + } + // Mask the last path segment (most common location for API keys) + u.Path = strings.TrimRight(u.Path, "/") + if u.Path != "" && u.Path != "/" { + parts := strings.Split(u.Path, "/") + if len(parts) > 1 { + parts[len(parts)-1] = "***" + } + u.RawPath = "" + u.Path = strings.Join(parts, "/") + } + // Remove query params entirely + u.RawQuery = "" + u.Fragment = "" + // Use Opaque to avoid re-encoding the path + return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) +} diff --git a/cmd/workflow/simulate/chain/utils_test.go b/cmd/workflow/simulate/chain/utils_test.go new file mode 100644 index 00000000..3247e477 --- /dev/null +++ b/cmd/workflow/simulate/chain/utils_test.go @@ -0,0 +1,48 @@ +package chain + +import ( + "testing" +) + +func TestRedactURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "masks last path segment", + raw: "https://rpc.example.com/v1/my-secret-key", + want: "https://rpc.example.com/v1/***", + }, + { + name: "removes query params", + raw: "https://rpc.example.com/v1/key?token=secret", + want: "https://rpc.example.com/v1/***", + }, + { + name: "single path segment masked", + raw: "https://rpc.example.com/key", + want: "https://rpc.example.com/***", + }, + { + name: "no path", + raw: "https://rpc.example.com", + want: "https://rpc.example.com", + }, + { + name: "invalid URL", + raw: "://bad", + want: "***", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RedactURL(tt.raw) + if got != tt.want { + t.Errorf("RedactURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} diff --git a/cmd/workflow/simulate/limited_capabilities.go b/cmd/workflow/simulate/limited_capabilities.go index 3a48a850..50441a7e 100644 --- a/cmd/workflow/simulate/limited_capabilities.go +++ b/cmd/workflow/simulate/limited_capabilities.go @@ -13,8 +13,6 @@ import ( confhttpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp/server" customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" httpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http/server" - evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" - evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" consensusserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/consensus/server" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" @@ -193,92 +191,3 @@ func (l *LimitedConsensusNoDAG) Ready() error { return l.inne func (l *LimitedConsensusNoDAG) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { return l.inner.Initialise(ctx, deps) } - -// --- LimitedEVMChain --- - -// LimitedEVMChain wraps an evmserver.ClientCapability and enforces chain write -// report size and gas limits from SimulationLimits. -type LimitedEVMChain struct { - inner evmserver.ClientCapability - limits *SimulationLimits -} - -var _ evmserver.ClientCapability = (*LimitedEVMChain)(nil) - -func NewLimitedEVMChain(inner evmserver.ClientCapability, limits *SimulationLimits) *LimitedEVMChain { - return &LimitedEVMChain{inner: inner, limits: limits} -} - -func (l *LimitedEVMChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { - // Check report size - reportLimit := l.limits.ChainWriteReportSizeLimit() - if reportLimit > 0 && input.Report != nil && len(input.Report.RawReport) > reportLimit { - return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), reportLimit), - caperrors.ResourceExhausted, - ) - } - - // Check gas limit - gasLimit := l.limits.ChainWriteEVMGasLimit() - if gasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > gasLimit { - return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, gasLimit), - caperrors.ResourceExhausted, - ) - } - - return l.inner.WriteReport(ctx, metadata, input) -} - -// All other methods delegate to the inner capability. -func (l *LimitedEVMChain) CallContract(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { - return l.inner.CallContract(ctx, metadata, input) -} - -func (l *LimitedEVMChain) FilterLogs(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { - return l.inner.FilterLogs(ctx, metadata, input) -} - -func (l *LimitedEVMChain) BalanceAt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { - return l.inner.BalanceAt(ctx, metadata, input) -} - -func (l *LimitedEVMChain) EstimateGas(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { - return l.inner.EstimateGas(ctx, metadata, input) -} - -func (l *LimitedEVMChain) GetTransactionByHash(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { - return l.inner.GetTransactionByHash(ctx, metadata, input) -} - -func (l *LimitedEVMChain) GetTransactionReceipt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { - return l.inner.GetTransactionReceipt(ctx, metadata, input) -} - -func (l *LimitedEVMChain) HeaderByNumber(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { - return l.inner.HeaderByNumber(ctx, metadata, input) -} - -func (l *LimitedEVMChain) RegisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { - return l.inner.RegisterLogTrigger(ctx, triggerID, metadata, input) -} - -func (l *LimitedEVMChain) UnregisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) caperrors.Error { - return l.inner.UnregisterLogTrigger(ctx, triggerID, metadata, input) -} - -func (l *LimitedEVMChain) ChainSelector() uint64 { return l.inner.ChainSelector() } -func (l *LimitedEVMChain) Start(ctx context.Context) error { return l.inner.Start(ctx) } -func (l *LimitedEVMChain) Close() error { return l.inner.Close() } -func (l *LimitedEVMChain) HealthReport() map[string]error { return l.inner.HealthReport() } -func (l *LimitedEVMChain) Name() string { return l.inner.Name() } -func (l *LimitedEVMChain) Description() string { return l.inner.Description() } -func (l *LimitedEVMChain) Ready() error { return l.inner.Ready() } -func (l *LimitedEVMChain) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { - return l.inner.Initialise(ctx, deps) -} - -func (l *LimitedEVMChain) AckEvent(ctx context.Context, triggerId string, eventId string, method string) caperrors.Error { - return l.inner.AckEvent(ctx, triggerId, eventId, method) -} diff --git a/cmd/workflow/simulate/limited_capabilities_test.go b/cmd/workflow/simulate/limited_capabilities_test.go index 9a8a0016..a927874c 100644 --- a/cmd/workflow/simulate/limited_capabilities_test.go +++ b/cmd/workflow/simulate/limited_capabilities_test.go @@ -15,7 +15,6 @@ import ( caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp" customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" - evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" "github.com/smartcontractkit/chainlink-common/pkg/config" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" @@ -88,62 +87,6 @@ func (s *consensusCapabilityStub) Report(ctx context.Context, metadata commonCap return nil, nil } -type evmClientCapabilityStub struct { - capabilityBaseStub - writeReportFn func(context.Context, commonCap.RequestMetadata, *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) - writeReportCalls int -} - -func (s *evmClientCapabilityStub) CallContract(context.Context, commonCap.RequestMetadata, *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) FilterLogs(context.Context, commonCap.RequestMetadata, *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) BalanceAt(context.Context, commonCap.RequestMetadata, *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) EstimateGas(context.Context, commonCap.RequestMetadata, *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) GetTransactionByHash(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) GetTransactionReceipt(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) HeaderByNumber(context.Context, commonCap.RequestMetadata, *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) RegisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) UnregisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) caperrors.Error { - return nil -} - -func (s *evmClientCapabilityStub) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { - s.writeReportCalls++ - if s.writeReportFn != nil { - return s.writeReportFn(ctx, metadata, input) - } - return nil, nil -} - -func (s *evmClientCapabilityStub) AckEvent(context.Context, string, string, string) caperrors.Error { - return nil -} - -func (s *evmClientCapabilityStub) ChainSelector() uint64 { return 0 } - func newTestLimits(t *testing.T) *SimulationLimits { t.Helper() limits, err := DefaultLimits() @@ -376,66 +319,3 @@ func TestLimitedConsensusNoDAGReportDelegates(t *testing.T) { assert.Same(t, expectedResp, resp) assert.Equal(t, 1, inner.reportCalls) } - -func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { - t.Parallel() - - limits := newTestLimits(t) - limits.Workflows.ChainWrite.ReportSizeLimit.DefaultValue = 4 - - inner := &evmClientCapabilityStub{} - wrapper := NewLimitedEVMChain(inner, limits) - - resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, - }) - require.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "chain write report size 5 bytes exceeds limit of 4 bytes") - assert.Equal(t, 0, inner.writeReportCalls) -} - -func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { - t.Parallel() - - limits := newTestLimits(t) - limits.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue = 10 - - inner := &evmClientCapabilityStub{} - wrapper := NewLimitedEVMChain(inner, limits) - - resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - GasConfig: &evmcappb.GasConfig{GasLimit: 11}, - }) - require.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "EVM gas limit 11 exceeds maximum of 10") - assert.Equal(t, 0, inner.writeReportCalls) -} - -func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { - t.Parallel() - - limits := newTestLimits(t) - limits.Workflows.ChainWrite.ReportSizeLimit.DefaultValue = 4 - limits.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue = 10 - - input := &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("1234")}, - GasConfig: &evmcappb.GasConfig{GasLimit: 10}, - } - expectedResp := &commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply]{Response: &evmcappb.WriteReportReply{}} - - inner := &evmClientCapabilityStub{ - writeReportFn: func(_ context.Context, _ commonCap.RequestMetadata, got *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { - assert.Same(t, input, got) - return expectedResp, nil - }, - } - - wrapper := NewLimitedEVMChain(inner, limits) - resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, input) - require.NoError(t, err) - assert.Same(t, expectedResp, resp) - assert.Equal(t, 1, inner.writeReportCalls) -} diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index d1dbdb71..d4b0cfbb 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -191,8 +191,8 @@ func (l *SimulationLimits) ChainWriteReportSizeLimit() int { return int(l.Workflows.ChainWrite.ReportSizeLimit.DefaultValue) } -// ChainWriteEVMGasLimit returns the default EVM gas limit. -func (l *SimulationLimits) ChainWriteEVMGasLimit() uint64 { +// ChainWriteGasLimit returns the default EVM gas limit. +func (l *SimulationLimits) ChainWriteGasLimit() uint64 { return l.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue } diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go index 487adbf4..08389fb3 100644 --- a/cmd/workflow/simulate/limits_test.go +++ b/cmd/workflow/simulate/limits_test.go @@ -31,7 +31,7 @@ func TestDefaultLimitsAndExportDefaultLimitsJSON(t *testing.T) { assert.Equal(t, 100_000, limits.ConfHTTPResponseSizeLimit()) assert.Equal(t, 100_000, limits.ConsensusObservationSizeLimit()) assert.Equal(t, 5_000, limits.ChainWriteReportSizeLimit()) - assert.Equal(t, uint64(5_000_000), limits.ChainWriteEVMGasLimit()) + assert.Equal(t, uint64(5_000_000), limits.ChainWriteGasLimit()) assert.Equal(t, 100_000_000, limits.WASMBinarySize()) assert.Equal(t, 20_000_000, limits.WASMCompressedBinarySize()) assert.JSONEq(t, string(defaultLimitsJSON), string(ExportDefaultLimitsJSON())) @@ -61,7 +61,7 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing assert.Equal(t, 7_000, limits.HTTPRequestSizeLimit()) assert.Equal(t, 100_000, limits.HTTPResponseSizeLimit(), "unset values should keep embedded defaults") assert.Equal(t, 9_000, limits.ChainWriteReportSizeLimit()) - assert.Equal(t, uint64(123), limits.ChainWriteEVMGasLimit()) + assert.Equal(t, uint64(123), limits.ChainWriteGasLimit()) assert.Equal(t, 2*time.Second, limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue) } @@ -95,7 +95,7 @@ func TestResolveLimitsHandlesAllSupportedModes(t *testing.T) { baseline, err := DefaultLimits() require.NoError(t, err) assert.Equal(t, baseline.HTTPRequestSizeLimit(), defaultLimits.HTTPRequestSizeLimit()) - assert.Equal(t, baseline.ChainWriteEVMGasLimit(), defaultLimits.ChainWriteEVMGasLimit()) + assert.Equal(t, baseline.ChainWriteGasLimit(), defaultLimits.ChainWriteGasLimit()) path := writeLimitsFile(t, `{"Consensus":{"ObservationSizeLimit":"2kb"}}`) customLimits, err := ResolveLimits(path) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index cdd372b6..87621cf1 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -2,29 +2,22 @@ package simulate import ( "context" - "crypto/ecdsa" "encoding/json" + "errors" "fmt" - "math" - "math/big" "os" "os/signal" "path/filepath" - "strconv" "strings" "syscall" "time" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethclient" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" "go.uber.org/zap/zapcore" "github.com/smartcontractkit/chainlink-common/pkg/beholder" - "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" httptypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" @@ -32,12 +25,13 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" pb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" "github.com/smartcontractkit/chainlink-protos/cre/go/values" - valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" "github.com/smartcontractkit/chainlink/v2/core/capabilities" simulator "github.com/smartcontractkit/chainlink/v2/core/services/workflows/cmd/cre/utils" v2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/v2" cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" // register EVM chain family via package init "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -46,24 +40,28 @@ import ( "github.com/smartcontractkit/cre-cli/internal/validation" ) +const WorkflowExecutionTimeout = 5 * time.Minute + type Inputs struct { - WasmPath string `validate:"omitempty,file,ascii,max=97" cli:"--wasm"` - WorkflowPath string `validate:"required,workflow_path_read"` - ConfigPath string `validate:"omitempty,file,ascii,max=97"` - SecretsPath string `validate:"omitempty,file,ascii,max=97"` - EngineLogs bool `validate:"omitempty" cli:"--engine-logs"` - Broadcast bool `validate:"-"` - EVMClients map[uint64]*ethclient.Client `validate:"omitempty"` // multichain clients keyed by selector (or chain ID for experimental) - EthPrivateKey *ecdsa.PrivateKey `validate:"omitempty"` - WorkflowName string `validate:"required"` + WasmPath string `validate:"omitempty,file,ascii,max=97" cli:"--wasm"` + WorkflowPath string `validate:"required,workflow_path_read"` + ConfigPath string `validate:"omitempty,file,ascii,max=97"` + SecretsPath string `validate:"omitempty,file,ascii,max=97"` + EngineLogs bool `validate:"omitempty" cli:"--engine-logs"` + Broadcast bool `validate:"-"` + WorkflowName string `validate:"required"` + // Chain-type-specific fields + ChainTypeClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` + ChainTypeKeys map[string]interface{} `validate:"-"` + // ChainTypeResolved holds the full ResolveClients bundle per chain type + // (clients, forwarders, experimental-selector flags) so later steps + // (health check, capability registration) have a single source of truth. + ChainTypeResolved map[string]chain.ResolvedChains `validate:"-"` // Non-interactive mode options - NonInteractive bool `validate:"-"` - TriggerIndex int `validate:"-"` - HTTPPayload string `validate:"-"` // JSON string or @/path/to/file.json - EVMTxHash string `validate:"-"` // 0x-prefixed - EVMEventIndex int `validate:"-"` - // Experimental chains support (for chains not in official chain-selectors) - ExperimentalForwarders map[uint64]common.Address `validate:"-"` // forwarders keyed by chain ID + NonInteractive bool `validate:"-"` + TriggerIndex int `validate:"-"` + HTTPPayload string `validate:"-"` // JSON string or @/path/to/file.json + ChainTypeInputs map[string]string `validate:"-"` // CLI-supplied chain-type-specific trigger inputs // Limits enforcement LimitsPath string `validate:"-"` // "default" or path to custom limits JSON // SkipTypeChecks passes --skip-type-checks to cre-compile for TypeScript workflows. @@ -107,8 +105,10 @@ func New(runtimeContext *runtime.Context) *cobra.Command { simulateCmd.Flags().Bool(settings.Flags.NonInteractive.Name, false, "Run without prompts; requires --trigger-index and inputs for the selected trigger type") simulateCmd.Flags().Int("trigger-index", -1, "Index of the trigger to run (0-based)") simulateCmd.Flags().String("http-payload", "", "HTTP trigger payload as JSON string or path to JSON file (with or without @ prefix)") - simulateCmd.Flags().String("evm-tx-hash", "", "EVM trigger transaction hash (0x...)") - simulateCmd.Flags().Int("evm-event-index", -1, "EVM trigger log index (0-based)") + + // Register chain-type-specific CLI flags (e.g., --evm-tx-hash). + chain.RegisterAllCLIFlags(simulateCmd) + simulateCmd.Flags().String("limits", "default", "Production limits to enforce during simulation: 'default' for prod defaults, path to a limits JSON file (e.g. from 'cre workflow limits export'), or 'none' to disable") simulateCmd.Flags().Bool(cmdcommon.SkipTypeChecksCLIFlag, false, "Skip TypeScript project typecheck during compilation (passes "+cmdcommon.SkipTypeChecksFlag+" to cre-compile)") return simulateCmd @@ -131,125 +131,65 @@ func newHandler(ctx *runtime.Context) *handler { } func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) (Inputs, error) { - // build clients for each supported chain from settings, skip if rpc is empty - clients := make(map[uint64]*ethclient.Client) - for _, chain := range SupportedEVM { - chainName, err := settings.GetChainNameByChainSelector(chain.Selector) - if err != nil { - h.log.Error().Msgf("Invalid chain selector for supported EVM chains %d; skipping", chain.Selector) - continue - } - rpcURL, err := settings.GetRpcUrlSettings(v, chainName) - if err != nil || strings.TrimSpace(rpcURL) == "" { - h.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) - continue - } - h.log.Debug().Msgf("Using RPC for %s: %s", chainName, redactURL(rpcURL)) - - c, err := ethclient.Dial(rpcURL) - if err != nil { - ui.Warning(fmt.Sprintf("Failed to create eth client for %s: %v", chainName, err)) - continue - } - - clients[chain.Selector] = c - } - - // Experimental chains support (automatically loaded from config if present) - experimentalForwarders := make(map[uint64]common.Address) - - expChains, err := settings.GetExperimentalChains(v) - if err != nil { - return Inputs{}, fmt.Errorf("failed to load experimental chains config: %w", err) - } + chain.Build(h.log) - for _, ec := range expChains { - // Validate required fields - if ec.ChainSelector == 0 { - return Inputs{}, fmt.Errorf("experimental chain missing chain-selector") - } - if strings.TrimSpace(ec.RPCURL) == "" { - return Inputs{}, fmt.Errorf("experimental chain %d missing rpc-url", ec.ChainSelector) - } - if strings.TrimSpace(ec.Forwarder) == "" { - return Inputs{}, fmt.Errorf("experimental chain %d missing forwarder", ec.ChainSelector) - } + ctClients := make(map[string]map[uint64]chain.ChainClient) + ctResolved := make(map[string]chain.ResolvedChains) + ctKeys := make(map[string]interface{}) - // Check if chain selector already exists (supported chain) - if _, exists := clients[ec.ChainSelector]; exists { - // Find the supported chain's forwarder - var supportedForwarder string - for _, supported := range SupportedEVM { - if supported.Selector == ec.ChainSelector { - supportedForwarder = supported.Forwarder - break - } - } - - expFwd := common.HexToAddress(ec.Forwarder) - if supportedForwarder != "" && common.HexToAddress(supportedForwarder) == expFwd { - // Same forwarder, just debug log - h.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") - continue - } - - // Different forwarder - respect user's config, warn about override - ui.Warning(fmt.Sprintf("Warning: experimental chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", ec.ChainSelector, supportedForwarder, ec.Forwarder)) - - // Use existing client but override the forwarder - experimentalForwarders[ec.ChainSelector] = expFwd - continue - } - - // Dial the RPC - h.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, redactURL(ec.RPCURL)) - c, err := ethclient.Dial(ec.RPCURL) + for name, ct := range chain.All() { + resolved, err := ct.ResolveClients(v) if err != nil { - return Inputs{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) + return Inputs{}, fmt.Errorf("failed to resolve %s clients: %w", name, err) } - clients[ec.ChainSelector] = c - experimentalForwarders[ec.ChainSelector] = common.HexToAddress(ec.Forwarder) - ui.Dim(fmt.Sprintf("Added experimental chain (chain-selector: %d)\n", ec.ChainSelector)) - + if len(resolved.Clients) > 0 { + ctClients[name] = resolved.Clients + ctResolved[name] = resolved + } } - if len(clients) == 0 { + // Check at least one chain type has clients + totalClients := 0 + for _, fc := range ctClients { + totalClients += len(fc) + } + if totalClients == 0 { return Inputs{}, fmt.Errorf("no RPC URLs found for supported or experimental chains") } - pk, err := crypto.HexToECDSA(creSettings.User.EthPrivateKey) - if err != nil { - if v.GetBool("broadcast") { - return Inputs{}, fmt.Errorf( - "failed to parse private key, required to broadcast. Please check CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + broadcast := v.GetBool("broadcast") + for name, ct := range chain.All() { + if _, ok := ctClients[name]; !ok { + continue // no clients for this chain type; skip key resolution } - pk, err = crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") + key, err := ct.ResolveKey(creSettings, broadcast) if err != nil { - return Inputs{}, fmt.Errorf("failed to parse default private key. Please set CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + return Inputs{}, err + } + if key != nil { + ctKeys[name] = key } - ui.Warning("Using default private key for chain write simulation. To use your own key, set CRE_ETH_PRIVATE_KEY in your .env file or system environment.") } return Inputs{ - WasmPath: v.GetString("wasm"), - WorkflowPath: creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath, - ConfigPath: cmdcommon.ResolveConfigPath(v, creSettings.Workflow.WorkflowArtifactSettings.ConfigPath), - SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, - EngineLogs: v.GetBool("engine-logs"), - Broadcast: v.GetBool("broadcast"), - EVMClients: clients, - EthPrivateKey: pk, - WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, - NonInteractive: v.GetBool("non-interactive"), - TriggerIndex: v.GetInt("trigger-index"), - HTTPPayload: v.GetString("http-payload"), - EVMTxHash: v.GetString("evm-tx-hash"), - EVMEventIndex: v.GetInt("evm-event-index"), - ExperimentalForwarders: experimentalForwarders, - LimitsPath: v.GetString("limits"), - SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), - InvocationDir: h.runtimeContext.InvocationDir, + WasmPath: v.GetString("wasm"), + WorkflowPath: creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath, + ConfigPath: cmdcommon.ResolveConfigPath(v, creSettings.Workflow.WorkflowArtifactSettings.ConfigPath), + SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, + EngineLogs: v.GetBool("engine-logs"), + Broadcast: v.GetBool("broadcast"), + ChainTypeClients: ctClients, + ChainTypeResolved: ctResolved, + ChainTypeKeys: ctKeys, + WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, + NonInteractive: v.GetBool("non-interactive"), + TriggerIndex: v.GetInt("trigger-index"), + HTTPPayload: v.GetString("http-payload"), + ChainTypeInputs: chain.CollectAllCLIInputs(v), + LimitsPath: v.GetString("limits"), + SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), + InvocationDir: h.runtimeContext.InvocationDir, }, nil } @@ -276,13 +216,21 @@ func (h *handler) ValidateInputs(inputs Inputs) error { inputs.WasmPath = savedWasm inputs.ConfigPath = savedConfig - // forbid the default 0x...01 key when broadcasting - if inputs.Broadcast && inputs.EthPrivateKey != nil && inputs.EthPrivateKey.D.Cmp(big.NewInt(1)) == 0 { - return fmt.Errorf("you must configure a valid private key to perform on-chain writes. Please set your private key in the .env file before using the -–broadcast flag") - } - rpcErr := ui.WithSpinner("Checking RPC connectivity...", func() error { - return runRPCHealthCheck(inputs.EVMClients, inputs.ExperimentalForwarders) + var errs []error + for name, ct := range chain.All() { + resolved, ok := inputs.ChainTypeResolved[name] + if !ok { + continue + } + if err := ct.RunHealthCheck(resolved); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return fmt.Errorf("RPC health check failed:\n%w", errors.Join(errs...)) + } + return nil }) if rpcErr != nil { // we don't block execution, just show the error to the user @@ -475,7 +423,7 @@ func run( initializedCh := make(chan struct{}) executionFinishedCh := make(chan struct{}) - var triggerCaps *ManualTriggers + var manualTriggerCaps *ManualTriggers simulatorInitialize := func(ctx context.Context, cfg simulator.RunnerConfig) (*capabilities.Registry, []services.Service) { lggr := logger.Sugared(cfg.Lggr) // Create the registry and fake capabilities with specific loggers @@ -505,33 +453,47 @@ func run( } } - // Build forwarder address map based on which chains actually have RPC clients configured - forwarders := map[uint64]common.Address{} - for _, c := range SupportedEVM { - if _, ok := inputs.EVMClients[c.Selector]; ok && strings.TrimSpace(c.Forwarder) != "" { - forwarders[c.Selector] = common.HexToAddress(c.Forwarder) - } - } - - // Merge experimental forwarders (keyed by chain ID) - for chainID, fwdAddr := range inputs.ExperimentalForwarders { - forwarders[chainID] = fwdAddr - } - - manualTriggerCapConfig := ManualTriggerCapabilitiesConfig{ - Clients: inputs.EVMClients, - PrivateKey: inputs.EthPrivateKey, - Forwarders: forwarders, - } - + // Register chain-agnostic cron and HTTP triggers triggerLggr := lggr.Named("TriggerCapabilities") var err error - triggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry, manualTriggerCapConfig, !inputs.Broadcast, simLimits) + manualTriggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) if err != nil { ui.Error(fmt.Sprintf("Failed to create trigger capabilities: %v", err)) os.Exit(1) } + srvcs = append(srvcs, manualTriggerCaps.ManualCronTrigger, manualTriggerCaps.ManualHTTPTrigger) + + // Only set Limits when non-nil to avoid the typed-nil interface trap + // (a nil *SimulationLimits boxed into chain.Limits compares != nil). + var capLimits chain.Limits + if simLimits != nil { + capLimits = simLimits + } + // Register chain-type-specific capabilities + for name, ct := range chain.All() { + clients, ok := inputs.ChainTypeClients[name] + if !ok || len(clients) == 0 { + continue + } + + ctSrvcs, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Registry: registry, + Clients: clients, + Forwarders: inputs.ChainTypeResolved[name].Forwarders, + PrivateKey: inputs.ChainTypeKeys[name], + Broadcast: inputs.Broadcast, + Limits: capLimits, + Logger: triggerLggr, + }) + if err != nil { + ui.Error(fmt.Sprintf("Failed to register %s capabilities: %v", name, err)) + os.Exit(1) + } + srvcs = append(srvcs, ctSrvcs...) + } + + // Register chain-agnostic action capabilities (consensus, HTTP, confidential HTTP) computeLggr := lggr.Named("ActionsCapabilities") computeCaps, err := NewFakeActionCapabilities(ctx, computeLggr, registry, inputs.SecretsPath, simLimits) if err != nil { @@ -540,7 +502,7 @@ func run( } // Start trigger capabilities - if err := triggerCaps.Start(ctx); err != nil { + if err := manualTriggerCaps.Start(ctx); err != nil { ui.Error(fmt.Sprintf("Failed to start trigger: %v", err)) os.Exit(1) } @@ -553,10 +515,6 @@ func run( } } - srvcs = append(srvcs, triggerCaps.ManualCronTrigger, triggerCaps.ManualHTTPTrigger) - for _, evm := range triggerCaps.ManualEVMChains { - srvcs = append(srvcs, evm) - } srvcs = append(srvcs, computeCaps...) return registry, srvcs } @@ -564,11 +522,11 @@ func run( // Create a holder for trigger info that will be populated in beforeStart triggerInfoAndBeforeStart := &TriggerInfoAndBeforeStart{} - getTriggerCaps := func() *ManualTriggers { return triggerCaps } + getManualTriggerCaps := func() *ManualTriggers { return manualTriggerCaps } if inputs.NonInteractive { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps) } else { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps) } waitFn := func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service) { @@ -705,7 +663,7 @@ type TriggerInfoAndBeforeStart struct { } // makeBeforeStartInteractive builds the interactive BeforeStart closure -func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, triggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -744,58 +702,59 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", triggerIndex) trigger := holder.TriggerToRun.Id - triggerCaps := triggerCapsGetter() + manualTriggerCaps := manualTriggerCapsGetter() - switch { - case trigger == "cron-trigger@1.0.0": + switch trigger { + case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { - return triggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) + return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) } - case trigger == "http-trigger@1.0.0-alpha": + case "http-trigger@1.0.0-alpha": payload, err := getHTTPTriggerPayload(inputs.InvocationDir) if err != nil { ui.Error(fmt.Sprintf("Failed to get HTTP trigger payload: %v", err)) os.Exit(1) } holder.TriggerFunc = func() error { - return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) - } - case strings.HasPrefix(trigger, "evm") && strings.HasSuffix(trigger, "@1.0.0"): - // Derive the chain selector directly from the selected trigger ID. - sel, ok := parseChainSelectorFromTriggerID(holder.TriggerToRun.GetId()) - if !ok { - ui.Error(fmt.Sprintf("Could not determine chain selector from trigger id %q", holder.TriggerToRun.GetId())) - os.Exit(1) + return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } + default: + // Try each registered chain type + handled := false + for name, ct := range chain.All() { + sel, ok := ct.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) + if !ok { + continue + } - client := inputs.EVMClients[sel] - if client == nil { - ui.Error(fmt.Sprintf("No RPC configured for chain selector %d", sel)) - os.Exit(1) - } + if !ct.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) + os.Exit(1) + } - log, err := getEVMTriggerLog(ctx, client) - if err != nil { - ui.Error(fmt.Sprintf("Failed to get EVM trigger log: %v", err)) - os.Exit(1) + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, true) + if err != nil { + ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) + os.Exit(1) + } + + handled = true + holder.TriggerFunc = func() error { + return ct.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) + } + break } - evmChain := triggerCaps.ManualEVMChains[sel] - if evmChain == nil { - ui.Error(fmt.Sprintf("No EVM chain initialized for selector %d", sel)) + + if !handled { + ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) os.Exit(1) } - holder.TriggerFunc = func() error { - return evmChain.ManualTrigger(ctx, triggerRegistrationID, log) - } - default: - ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) - os.Exit(1) } } } // makeBeforeStartNonInteractive builds the non-interactive BeforeStart closure -func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, triggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -819,14 +778,14 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp holder.TriggerToRun = triggerSub[inputs.TriggerIndex] triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", inputs.TriggerIndex) trigger := holder.TriggerToRun.Id - triggerCaps := triggerCapsGetter() + manualTriggerCaps := manualTriggerCapsGetter() - switch { - case trigger == "cron-trigger@1.0.0": + switch trigger { + case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { - return triggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) + return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) } - case trigger == "http-trigger@1.0.0-alpha": + case "http-trigger@1.0.0-alpha": if strings.TrimSpace(inputs.HTTPPayload) == "" { ui.Error("--http-payload is required for http-trigger@1.0.0-alpha in non-interactive mode") os.Exit(1) @@ -837,42 +796,39 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp os.Exit(1) } holder.TriggerFunc = func() error { - return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) - } - case strings.HasPrefix(trigger, "evm") && strings.HasSuffix(trigger, "@1.0.0"): - if strings.TrimSpace(inputs.EVMTxHash) == "" || inputs.EVMEventIndex < 0 { - ui.Error("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") - os.Exit(1) + return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } + default: + // Try each registered chain type + handled := false + for name, ct := range chain.All() { + sel, ok := ct.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) + if !ok { + continue + } - sel, ok := parseChainSelectorFromTriggerID(holder.TriggerToRun.GetId()) - if !ok { - ui.Error(fmt.Sprintf("Could not determine chain selector from trigger id %q", holder.TriggerToRun.GetId())) - os.Exit(1) - } + if !ct.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) + os.Exit(1) + } - client := inputs.EVMClients[sel] - if client == nil { - ui.Error(fmt.Sprintf("No RPC configured for chain selector %d", sel)) - os.Exit(1) - } + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, false) + if err != nil { + ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) + os.Exit(1) + } - log, err := getEVMTriggerLogFromValues(ctx, client, inputs.EVMTxHash, uint64(inputs.EVMEventIndex)) // #nosec G115 -- EVMEventIndex validated >= 0 above - if err != nil { - ui.Error(fmt.Sprintf("Failed to build EVM trigger log: %v", err)) - os.Exit(1) + handled = true + holder.TriggerFunc = func() error { + return ct.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) + } + break } - evmChain := triggerCaps.ManualEVMChains[sel] - if evmChain == nil { - ui.Error(fmt.Sprintf("No EVM chain initialized for selector %d", sel)) + + if !handled { + ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) os.Exit(1) } - holder.TriggerFunc = func() error { - return evmChain.ManualTrigger(ctx, triggerRegistrationID, log) - } - default: - ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) - os.Exit(1) } } } @@ -908,10 +864,9 @@ func cleanupBeholder() error { return nil } -// getHTTPTriggerPayload prompts user for HTTP trigger data. -// invocationDir is the working directory at the time the CLI was invoked; relative -// paths entered by the user are resolved against it rather than the current working -// directory (which may have been changed to the workflow folder by SetExecutionContext). +// getHTTPTriggerPayload prompts user for HTTP trigger data. Relative paths are +// resolved against invocationDir so file references work from where the user ran +// the command even after SetExecutionContext switches cwd to the workflow dir. func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) { ui.Line() input, err := ui.Input("HTTP Trigger Configuration", @@ -964,6 +919,16 @@ func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) return payload, nil } +// getTriggerDataForChainType resolves trigger data for a specific chain type. +// Each chain type defines its own trigger data format. +func getTriggerDataForChainType(ctx context.Context, ct chain.ChainType, selector uint64, inputs Inputs, interactive bool) (interface{}, error) { + return ct.ResolveTriggerData(ctx, selector, chain.TriggerParams{ + Clients: inputs.ChainTypeClients[ct.Name()], + Interactive: interactive, + ChainTypeInputs: inputs.ChainTypeInputs, + }) +} + // resolvePathFromInvocation converts a (potentially relative) path to an absolute // path anchored at invocationDir. Absolute paths and paths that are already // reachable from the current working directory are returned unchanged. @@ -974,116 +939,6 @@ func resolvePathFromInvocation(path, invocationDir string) string { return filepath.Join(invocationDir, path) } -// getEVMTriggerLog prompts user for EVM trigger data and fetches the log -func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Log, error) { - var txHashInput string - var eventIndexInput string - - ui.Line() - if err := ui.InputForm([]ui.InputField{ - { - Title: "EVM Trigger Configuration", - Description: "Transaction hash for the EVM log event", - Placeholder: "0x...", - Value: &txHashInput, - Validate: func(s string) error { - s = strings.TrimSpace(s) - if s == "" { - return fmt.Errorf("transaction hash cannot be empty") - } - if !strings.HasPrefix(s, "0x") { - return fmt.Errorf("transaction hash must start with 0x") - } - if len(s) != 66 { - return fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(s)) - } - return nil - }, - }, - { - Title: "Event Index", - Description: "Log event index (0-based)", - Placeholder: "0", - Suggestions: []string{"0"}, - Value: &eventIndexInput, - Validate: func(s string) error { - if strings.TrimSpace(s) == "" { - return fmt.Errorf("event index cannot be empty") - } - if _, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32); err != nil { - return fmt.Errorf("invalid event index: must be a number") - } - return nil - }, - }, - }); err != nil { - return nil, fmt.Errorf("EVM trigger input cancelled: %w", err) - } - - txHashInput = strings.TrimSpace(txHashInput) - txHash := common.HexToHash(txHashInput) - - eventIndexInput = strings.TrimSpace(eventIndexInput) - eventIndex, err := strconv.ParseUint(eventIndexInput, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid event index: %w", err) - } - - // Fetch the transaction receipt - receiptSpinner := ui.NewSpinner() - receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) - txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) - receiptSpinner.Stop() - if err != nil { - return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) - } - - // Check if event index is valid - if eventIndex >= uint64(len(txReceipt.Logs)) { - return nil, fmt.Errorf("event index %d out of range, transaction has %d log events", eventIndex, len(txReceipt.Logs)) - } - - log := txReceipt.Logs[eventIndex] - ui.Success(fmt.Sprintf("Found log event at index %d: contract=%s, topics=%d", eventIndex, log.Address.Hex(), len(log.Topics))) - - // Check for potential uint32 overflow (prevents noisy linter warnings) - var txIndex, logIndex uint32 - if log.TxIndex > math.MaxUint32 { - return nil, fmt.Errorf("transaction index %d exceeds uint32 maximum value", log.TxIndex) - } - txIndex = uint32(log.TxIndex) - - if log.Index > math.MaxUint32 { - return nil, fmt.Errorf("log index %d exceeds uint32 maximum value", log.Index) - } - logIndex = uint32(log.Index) - - // Convert to protobuf format - pbLog := &evm.Log{ - Address: log.Address.Bytes(), - Data: log.Data, - BlockHash: log.BlockHash.Bytes(), - TxHash: log.TxHash.Bytes(), - TxIndex: txIndex, - Index: logIndex, - Removed: log.Removed, - BlockNumber: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(log.BlockNumber)), - } - - // Convert topics - for _, topic := range log.Topics { - pbLog.Topics = append(pbLog.Topics, topic.Bytes()) - } - - // Set event signature (first topic is usually the event signature) - if len(log.Topics) > 0 { - pbLog.EventSig = log.Topics[0].Bytes() - } - - ui.Success(fmt.Sprintf("Created EVM trigger log for transaction %s, event %d", txHash.Hex(), eventIndex)) - return pbLog, nil -} - // getHTTPTriggerPayloadFromInput builds an HTTP trigger payload from a JSON string or a file path // (optionally prefixed with '@'). invocationDir is used to resolve relative paths against the // directory where the user invoked the CLI rather than the current working directory. @@ -1116,60 +971,3 @@ func getHTTPTriggerPayloadFromInput(input, invocationDir string) (*httptypedapi. return &httptypedapi.Payload{Input: raw}, nil } - -// getEVMTriggerLogFromValues fetches a log given tx hash and event index -func getEVMTriggerLogFromValues(ctx context.Context, ethClient *ethclient.Client, txHashStr string, eventIndex uint64) (*evm.Log, error) { - txHashStr = strings.TrimSpace(txHashStr) - if txHashStr == "" { - return nil, fmt.Errorf("transaction hash cannot be empty") - } - if !strings.HasPrefix(txHashStr, "0x") { - return nil, fmt.Errorf("transaction hash must start with 0x") - } - if len(txHashStr) != 66 { // 0x + 64 hex chars - return nil, fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(txHashStr)) - } - - txHash := common.HexToHash(txHashStr) - receiptSpinner := ui.NewSpinner() - receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) - txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) - receiptSpinner.Stop() - if err != nil { - return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) - } - if eventIndex >= uint64(len(txReceipt.Logs)) { - return nil, fmt.Errorf("event index %d out of range, transaction has %d log events", eventIndex, len(txReceipt.Logs)) - } - - log := txReceipt.Logs[eventIndex] - - // Check for potential uint32 overflow - var txIndex, logIndex uint32 - if log.TxIndex > math.MaxUint32 { - return nil, fmt.Errorf("transaction index %d exceeds uint32 maximum value", log.TxIndex) - } - txIndex = uint32(log.TxIndex) - if log.Index > math.MaxUint32 { - return nil, fmt.Errorf("log index %d exceeds uint32 maximum value", log.Index) - } - logIndex = uint32(log.Index) - - pbLog := &evm.Log{ - Address: log.Address.Bytes(), - Data: log.Data, - BlockHash: log.BlockHash.Bytes(), - TxHash: log.TxHash.Bytes(), - TxIndex: txIndex, - Index: logIndex, - Removed: log.Removed, - BlockNumber: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(log.BlockNumber)), - } - for _, topic := range log.Topics { - pbLog.Topics = append(pbLog.Topics, topic.Bytes()) - } - if len(log.Topics) > 0 { - pbLog.EventSig = log.Topics[0].Bytes() - } - return pbLog, nil -} diff --git a/cmd/workflow/simulate/utils_test.go b/cmd/workflow/simulate/utils_test.go deleted file mode 100644 index 14c5fd26..00000000 --- a/cmd/workflow/simulate/utils_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package simulate - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/ethereum/go-ethereum/ethclient" -) - -func TestParseChainSelectorFromTriggerID(t *testing.T) { - tests := []struct { - name string - id string - want uint64 - ok bool - }{ - { - name: "mainnet format", - id: "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger", - want: uint64(5009297550715157269), - ok: true, - }, - { - name: "sepolia lowercase", - id: "evm:chainselector:16015286601757825753@1.0.0", - want: uint64(16015286601757825753), - ok: true, - }, - { - name: "sepolia uppercase", - id: "EVM:CHAINSELECTOR:16015286601757825753@1.0.0", - want: uint64(16015286601757825753), - ok: true, - }, - { - name: "leading and trailing spaces", - id: " evm:ChainSelector:123@1.0.0 ", - want: uint64(123), - ok: true, - }, - { - name: "no selector present", - id: "evm@1.0.0 LogTrigger", - want: 0, - ok: false, - }, - { - name: "non-numeric selector", - id: "evm:ChainSelector:notanumber@1.0.0", - want: 0, - ok: false, - }, - { - name: "empty selector", - id: "evm:ChainSelector:@1.0.0", - want: 0, - ok: false, - }, - { - name: "overflow uint64", - // 2^64 is overflow for uint64 (max is 2^64-1) - id: "evm:ChainSelector:18446744073709551616@1.0.0", - want: 0, - ok: false, - }, - { - name: "digits followed by letters (regex grabs only digits)", - id: "evm:ChainSelector:987abc@1.0.0", - want: uint64(987), - ok: true, - }, - { - name: "multiple occurrences - returns first", - id: "foo ChainSelector:1 bar ChainSelector:2 baz", - want: uint64(1), - ok: true, - }, - } - - for _, tt := range tests { - - t.Run(tt.name, func(t *testing.T) { - got, ok := parseChainSelectorFromTriggerID(tt.id) - if ok != tt.ok || got != tt.want { - t.Fatalf("parseChainSelectorFromTriggerID(%q) = (%d, %v); want (%d, %v)", tt.id, got, ok, tt.want, tt.ok) - } - }) - } -} - -const selectorSepolia uint64 = 16015286601757825753 // expects "ethereum-testnet-sepolia" - -// newChainIDServer returns a JSON-RPC 2.0 server that replies to eth_chainId. -// reply can be: string (hex like "0x1" or "0x0") or error (JSON-RPC error). -func newChainIDServer(t *testing.T, reply interface{}) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req struct { - ID json.RawMessage `json:"id"` - Method string `json:"method"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - type rpcErr struct { - Code int `json:"code"` - Message string `json:"message"` - } - - res := map[string]any{ - "jsonrpc": "2.0", - "id": req.ID, - } - switch v := reply.(type) { - case string: - res["result"] = v - case error: - res["error"] = rpcErr{Code: -32603, Message: v.Error()} - default: - res["result"] = v - } - _ = json.NewEncoder(w).Encode(res) - })) -} - -func newEthClient(t *testing.T, url string) *ethclient.Client { - t.Helper() - c, err := ethclient.Dial(url) - if err != nil { - t.Fatalf("dial eth client: %v", err) - } - return c -} - -func mustContain(t *testing.T, s string, subs ...string) { - t.Helper() - for _, sub := range subs { - if !strings.Contains(s, sub) { - t.Fatalf("expected error to contain %q, got:\n%s", sub, s) - } - } -} - -func TestHealthCheck_NoClientsConfigured(t *testing.T) { - err := runRPCHealthCheck(map[uint64]*ethclient.Client{}, nil) - if err == nil { - t.Fatalf("expected error for no clients configured") - } - mustContain(t, err.Error(), "check your settings: no RPC URLs found for supported or experimental chains") -} - -func TestHealthCheck_NilClient(t *testing.T) { - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - 123: nil, // resolver is not called for nil clients - }, nil) - if err == nil { - t.Fatalf("expected error for nil client") - } - // nil-client path renders numeric selector in brackets - mustContain(t, err.Error(), "RPC health check failed", "[123] nil client") -} - -func TestHealthCheck_AllOK(t *testing.T) { - // Any positive chain ID works; use Sepolia id (0xaa36a7 == 11155111) for realism - sOK := newChainIDServer(t, "0xaa36a7") - defer sOK.Close() - - cOK := newEthClient(t, sOK.URL) - defer cOK.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cOK, - }, nil) - if err != nil { - t.Fatalf("expected nil error, got: %v", err) - } -} - -func TestHealthCheck_RPCError_usesChainName(t *testing.T) { - sErr := newChainIDServer(t, fmt.Errorf("boom")) - defer sErr.Close() - - cErr := newEthClient(t, sErr.URL) - defer cErr.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cErr, - }, nil) - if err == nil { - t.Fatalf("expected error for RPC failure") - } - // We assert the friendly chain name appears (from settings) - mustContain(t, err.Error(), - "RPC health check failed", - "[ethereum-testnet-sepolia] failed RPC health check", - ) -} - -func TestHealthCheck_ZeroChainID_usesChainName(t *testing.T) { - sZero := newChainIDServer(t, "0x0") - defer sZero.Close() - - cZero := newEthClient(t, sZero.URL) - defer cZero.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cZero, - }, nil) - if err == nil { - t.Fatalf("expected error for zero chain id") - } - mustContain(t, err.Error(), - "RPC health check failed", - "[ethereum-testnet-sepolia] invalid RPC response: empty or zero chain ID", - ) -} - -func TestHealthCheck_AggregatesMultipleErrors(t *testing.T) { - sErr := newChainIDServer(t, fmt.Errorf("boom")) - defer sErr.Close() - - cErr := newEthClient(t, sErr.URL) - defer cErr.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cErr, // named failure - 777: nil, // nil client (numeric selector path) - }, nil) - if err == nil { - t.Fatalf("expected aggregated error") - } - mustContain(t, err.Error(), - "RPC health check failed", - "[ethereum-testnet-sepolia] failed RPC health check", - "[777] nil client", - ) -}