From 1d3000be32a3c936afa74fc102ba1e5b17ec17c0 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 9 Apr 2026 20:48:30 +0100 Subject: [PATCH 01/26] refactor: make simulator chain-agnostic with pluggable chain family architecture Introduce ChainFamily interface and package-level registry enabling future Aptos/Solana chain support. Extract all EVM-specific simulation code into cmd/workflow/simulate/chain/evm/ package with self-registration via init(). - Add ChainFamily interface with 7 methods (Name, ResolveClients, RegisterCapabilities, ExecuteTrigger, ParseTriggerChainSelector, RunHealthCheck, SupportedChains) - Add thread-safe chain family registry with Register/Get/All/Names - Move EVM health checks, trigger parsing, supported chains list, and limited capabilities into chain/evm package - Refactor simulate.go to iterate registered families instead of hardcoding EVM logic - Fix EVM chains not being Start()ed after registration - Remove redundant blank import (named import triggers init()) - Rename test stubs for clarity --- cmd/workflow/simulate/capabilities.go | 86 +-- .../simulate/chain/evm/capabilities.go | 88 +++ cmd/workflow/simulate/chain/evm/family.go | 180 ++++++ cmd/workflow/simulate/chain/evm/health.go | 73 +++ .../simulate/chain/evm/health_test.go | 169 ++++++ .../chain/evm/limited_capabilities.go | 107 ++++ .../chain/evm/limited_capabilities_test.go | 147 +++++ .../simulate/chain/evm/supported_chains.go | 119 ++++ cmd/workflow/simulate/chain/evm/trigger.go | 157 +++++ .../simulate/chain/evm/trigger_test.go | 84 +++ cmd/workflow/simulate/chain/registry.go | 94 +++ cmd/workflow/simulate/chain/registry_test.go | 155 +++++ cmd/workflow/simulate/chain/types.go | 27 + cmd/workflow/simulate/limited_capabilities.go | 90 --- .../simulate/limited_capabilities_test.go | 118 ---- cmd/workflow/simulate/simulate.go | 543 ++++++------------ cmd/workflow/simulate/simulator_utils.go | 194 ------- cmd/workflow/simulate/utils_test.go | 236 +------- 18 files changed, 1610 insertions(+), 1057 deletions(-) create mode 100644 cmd/workflow/simulate/chain/evm/capabilities.go create mode 100644 cmd/workflow/simulate/chain/evm/family.go create mode 100644 cmd/workflow/simulate/chain/evm/health.go create mode 100644 cmd/workflow/simulate/chain/evm/health_test.go create mode 100644 cmd/workflow/simulate/chain/evm/limited_capabilities.go create mode 100644 cmd/workflow/simulate/chain/evm/limited_capabilities_test.go create mode 100644 cmd/workflow/simulate/chain/evm/supported_chains.go create mode 100644 cmd/workflow/simulate/chain/evm/trigger.go create mode 100644 cmd/workflow/simulate/chain/evm/trigger_test.go create mode 100644 cmd/workflow/simulate/chain/registry.go create mode 100644 cmd/workflow/simulate/chain/registry_test.go create mode 100644 cmd/workflow/simulate/chain/types.go diff --git a/cmd/workflow/simulate/capabilities.go b/cmd/workflow/simulate/capabilities.go index 57cc2b5b..58d917b1 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" "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,121 +18,64 @@ 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 families. +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 } -func (m *ManualTriggers) Start(ctx context.Context) error { - err := m.ManualCronTrigger.Start(ctx) +// Start starts cron and HTTP trigger services. +func (t *ManualTriggers) Start(ctx context.Context) error { + err := t.ManualCronTrigger.Start(ctx) if err != nil { return err } - err = m.ManualHTTPTrigger.Start(ctx) + err = t.ManualHTTPTrigger.Start(ctx) if err != nil { return err } - // Start all configured EVM chains - for _, evm := range m.ManualEVMChains { - if err := evm.Start(ctx); err != nil { - return err - } - } - return nil } -func (m *ManualTriggers) Close() error { - err := m.ManualCronTrigger.Close() +// Close closes cron and HTTP trigger services. +func (t *ManualTriggers) Close() error { + err := t.ManualCronTrigger.Close() if err != nil { return err } - err = m.ManualHTTPTrigger.Close() + err = t.ManualHTTPTrigger.Close() if err != nil { 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) diff --git a/cmd/workflow/simulate/chain/evm/capabilities.go b/cmd/workflow/simulate/chain/evm/capabilities.go new file mode 100644 index 00000000..e2ebafce --- /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]common.Address, + privateKey *ecdsa.PrivateKey, + dryRunChainWrite bool, + limits EVMChainLimits, +) (*EVMChainCapabilities, error) { + evmChains := make(map[uint64]*fakes.FakeEVMChain) + for sel, client := range clients { + fwd, ok := forwarders[sel] + if !ok { + lggr.Infow("Forwarder not found for chain", "selector", sel) + continue + } + + evm := fakes.NewFakeEvmChain( + lggr, + client, + privateKey, + fwd, + 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/family.go b/cmd/workflow/simulate/chain/evm/family.go new file mode 100644 index 00000000..cea21fe0 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/family.go @@ -0,0 +1,180 @@ +package evm + +import ( + "context" + "crypto/ecdsa" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/viper" + + evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +func init() { + chain.Register(&EVMFamily{}) +} + +// EVMFamily implements chain.ChainFamily for EVM-based blockchains. +type EVMFamily struct { + evmChains *EVMChainCapabilities +} + +var _ chain.ChainFamily = (*EVMFamily)(nil) + +func (f *EVMFamily) Name() string { return "evm" } + +func (f *EVMFamily) SupportedChains() []chain.ChainConfig { + return SupportedChains +} + +func (f *EVMFamily) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient, map[uint64]string, error) { + clients := make(map[uint64]chain.ChainClient) + forwarders := make(map[uint64]string) + + // Resolve supported chains + for _, ch := range SupportedChains { + chainName, err := settings.GetChainNameByChainSelector(ch.Selector) + if err != nil { + continue + } + rpcURL, err := settings.GetRpcUrlSettings(v, chainName) + if err != nil || strings.TrimSpace(rpcURL) == "" { + continue + } + 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 nil, nil, fmt.Errorf("failed to load experimental chains config: %w", err) + } + + for _, ec := range expChains { + if ec.ChainSelector == 0 { + return nil, nil, fmt.Errorf("experimental chain missing chain-selector") + } + if strings.TrimSpace(ec.RPCURL) == "" { + return nil, nil, fmt.Errorf("experimental chain %d missing rpc-url", ec.ChainSelector) + } + if strings.TrimSpace(ec.Forwarder) == "" { + return nil, nil, 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 !strings.EqualFold(forwarders[ec.ChainSelector], 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 + } + continue + } + + c, err := ethclient.Dial(ec.RPCURL) + if err != nil { + return nil, nil, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) + } + clients[ec.ChainSelector] = c + forwarders[ec.ChainSelector] = ec.Forwarder + ui.Dim(fmt.Sprintf("Added experimental chain (chain-selector: %d)\n", ec.ChainSelector)) + } + + return clients, forwarders, nil +} + +func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) 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 fmt.Errorf("EVM family: client for selector %d is not *ethclient.Client", sel) + } + ethClients[sel] = ec + } + + // Convert string forwarders to common.Address + evmForwarders := make(map[uint64]common.Address) + for sel, fwd := range cfg.Forwarders { + evmForwarders[sel] = common.HexToAddress(fwd) + } + + // 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 fmt.Errorf("EVM family: private key is not *ecdsa.PrivateKey") + } + } + + // Type-assert the limits (SimulationLimits satisfies EVMChainLimits implicitly) + var evmLimits EVMChainLimits + if cfg.Limits != nil { + var ok bool + evmLimits, ok = cfg.Limits.(EVMChainLimits) + if !ok { + return fmt.Errorf("EVM family: limits does not satisfy EVMChainLimits interface") + } + } + + dryRun := !cfg.Broadcast + + evmCaps, err := NewEVMChainCapabilities( + ctx, cfg.Logger, cfg.Registry, + ethClients, evmForwarders, pk, + dryRun, evmLimits, + ) + if err != nil { + return err + } + + // Start the EVM chains so they begin listening for triggers + if err := evmCaps.Start(ctx); err != nil { + return fmt.Errorf("EVM family: failed to start chain capabilities: %w", err) + } + + f.evmChains = evmCaps + return nil +} + +func (f *EVMFamily) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { + if f.evmChains == nil { + return fmt.Errorf("EVM family: capabilities not registered") + } + evmChain := f.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 family: trigger data is not *evm.Log") + } + return evmChain.ManualTrigger(ctx, registrationID, log) +} + +func (f *EVMFamily) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + return ParseTriggerChainSelector(triggerID) +} + +func (f *EVMFamily) RunHealthCheck(clients map[uint64]chain.ChainClient) error { + return RunRPCHealthCheck(clients, nil) +} diff --git a/cmd/workflow/simulate/chain/evm/health.go b/cmd/workflow/simulate/chain/evm/health.go new file mode 100644 index 00000000..a8969da2 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/health.go @@ -0,0 +1,73 @@ +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 family", sel) + } + ethClients[sel] = ec + } + + return runRPCHealthCheck(ethClients, experimentalSelectors) +} + +// runRPCHealthCheck runs connectivity check against every configured client. +func runRPCHealthCheck(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 { + errs = append(errs, fmt.Errorf("[%d] nil client", selector)) + continue + } + + var chainLabel string + if experimentalSelectors[selector] { + chainLabel = fmt.Sprintf("experimental chain %d", selector) + } else { + name, err := settings.GetChainNameByChainSelector(selector) + if err != nil { + 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/health_test.go b/cmd/workflow/simulate/chain/evm/health_test.go new file mode 100644 index 00000000..c5db9ff5 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/health_test.go @@ -0,0 +1,169 @@ +package evm + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +const selectorSepolia uint64 = 16015286601757825753 // expects "ethereum-testnet-sepolia" + +// 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 := 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, + }, nil) + if err == nil { + t.Fatalf("expected error for nil client") + } + mustContain(t, err.Error(), "RPC health check failed", "[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 := 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") + } + 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, + 777: nil, + }, 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", + ) +} + +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 family") +} 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..1f7d6ab6 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -0,0 +1,107 @@ +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" +) + +// EVMChainLimits defines the limit accessors needed by LimitedEVMChain. +// The SimulationLimits type from the parent package satisfies this interface. +type EVMChainLimits interface { + ChainWriteReportSizeLimit() int + ChainWriteEVMGasLimit() 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.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/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go new file mode 100644 index 00000000..81420879 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -0,0 +1,147 @@ +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 EVMChainLimit struct { + reportSizeLimit int + gasLimit uint64 +} + +func (s *EVMChainLimit) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } +func (s *EVMChainLimit) ChainWriteEVMGasLimit() 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 := &EVMChainLimit{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 := &EVMChainLimit{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 := &EVMChainLimit{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/chain/evm/supported_chains.go b/cmd/workflow/simulate/chain/evm/supported_chains.go new file mode 100644 index 00000000..7db9aeed --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/supported_chains.go @@ -0,0 +1,119 @@ +package evm + +import ( + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// 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"}, + + // Base + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_BASE_1.Selector, Forwarder: "0x82300bd7c3958625581cc2f77bc6464dcecdf3e5"}, + {Selector: chainselectors.ETHEREUM_MAINNET_BASE_1.Selector, Forwarder: "0x5e342a8438b4f5d39e72875fcee6f76b39cce548"}, + + // Avalanche + {Selector: chainselectors.AVALANCHE_TESTNET_FUJI.Selector, Forwarder: "0x2e7371a5d032489e4f60216d8d898a4c10805963"}, + {Selector: chainselectors.AVALANCHE_MAINNET.Selector, Forwarder: "0xdc21e279934ff6721cadfdd112dafb3261f09a2c"}, + + // Polygon + {Selector: chainselectors.POLYGON_TESTNET_AMOY.Selector, Forwarder: "0x3675a5eb2286a3f87e8278fc66edf458a2e3bb74"}, + {Selector: chainselectors.POLYGON_MAINNET.Selector, Forwarder: "0xf458d621885e29a5003ea9bbba5280d54e19b1ce"}, + + // BNB Chain + {Selector: chainselectors.BINANCE_SMART_CHAIN_TESTNET.Selector, Forwarder: "0xa238e42cb8782808dbb2f37e19859244ec4779b0"}, + {Selector: chainselectors.BINANCE_SMART_CHAIN_MAINNET.Selector, Forwarder: "0x6f3239bbb26e98961e1115aba83f8a282e5508c8"}, + + // Arbitrum + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1.Selector, Forwarder: "0xd41263567ddfead91504199b8c6c87371e83ca5d"}, + {Selector: chainselectors.ETHEREUM_MAINNET_ARBITRUM_1.Selector, Forwarder: "0xd770499057619c9a76205fd4168161cf94abc532"}, + + // Optimism + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_OPTIMISM_1.Selector, Forwarder: "0xa2888380dff3704a8ab6d1cd1a8f69c15fea5ee3"}, + {Selector: chainselectors.ETHEREUM_MAINNET_OPTIMISM_1.Selector, Forwarder: "0x9119a1501550ed94a3f2794038ed9258337afa18"}, + + // Andesite (private testnet) + {Selector: chainselectors.PRIVATE_TESTNET_ANDESITE.Selector, Forwarder: "0xcF4629d8DC7a5fa17F4D77233F5b953225669821"}, + + // ZkSync + {Selector: chainselectors.ETHEREUM_MAINNET_ZKSYNC_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_ZKSYNC_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Jovay + {Selector: chainselectors.JOVAY_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.JOVAY_MAINNET.Selector, Forwarder: "0x2B3068C4B288A2CD1f8B3613b8f33ef7cEecadC4"}, + + // Pharos + {Selector: chainselectors.PHAROS_ATLANTIC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.PHAROS_MAINNET.Selector, Forwarder: "0x2B3068C4B288A2CD1f8B3613b8f33ef7cEecadC4"}, + + // Worldchain + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_WORLDCHAIN_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.ETHEREUM_MAINNET_WORLDCHAIN_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Plasma + {Selector: chainselectors.PLASMA_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.PLASMA_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Linea + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_LINEA_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.ETHEREUM_MAINNET_LINEA_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Ink + {Selector: chainselectors.INK_TESTNET_SEPOLIA.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.ETHEREUM_MAINNET_INK_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Hyperliquid + {Selector: chainselectors.HYPERLIQUID_TESTNET.Selector, Forwarder: "0xB27fA1c28288c50542527F64BCda22C9FbAc24CB"}, + {Selector: chainselectors.HYPERLIQUID_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Apechain + {Selector: chainselectors.APECHAIN_TESTNET_CURTIS.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Arc + {Selector: chainselectors.ARC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Xlayer + {Selector: chainselectors.XLAYER_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.ETHEREUM_MAINNET_XLAYER_1.Selector, Forwarder: "0x2B3068C4B288A2CD1f8B3613b8f33ef7cEecadC4"}, + + // MegaETH + {Selector: chainselectors.MEGAETH_TESTNET_2.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.MEGAETH_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Celo + // {Selector: chainselectors.CELO_SEPOLIA.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.CELO_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Gnosis + {Selector: chainselectors.GNOSIS_CHAIN_TESTNET_CHIADO.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.GNOSIS_CHAIN_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Cronos + {Selector: chainselectors.CRONOS_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Mantle + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_MANTLE_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.ETHEREUM_MAINNET_MANTLE_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // TAC + {Selector: chainselectors.TAC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Unichain + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_UNICHAIN_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Scroll + {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_SCROLL_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.ETHEREUM_MAINNET_SCROLL_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // Sonic + {Selector: chainselectors.SONIC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + {Selector: chainselectors.SONIC_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, + + // DTCC + {Selector: chainselectors.DTCC_TESTNET_ANDESITE.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, +} diff --git a/cmd/workflow/simulate/chain/evm/trigger.go b/cmd/workflow/simulate/chain/evm/trigger.go new file mode 100644 index 00000000..9dc11761 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/trigger.go @@ -0,0 +1,157 @@ +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" +) + +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) +} + +// GetEVMTriggerLogFromValues fetches a log given tx hash string and event index. +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) +} + +// fetchAndConvertLog fetches a transaction receipt log and converts it to the protobuf format. +func fetchAndConvertLog(ctx context.Context, ethClient *ethclient.Client, txHash common.Hash, eventIndex uint64) (*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] + + 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() + } + + 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..8f7f3876 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/trigger_test.go @@ -0,0 +1,84 @@ +package evm + +import ( + "testing" +) + +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, + }, + } + + 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) + } + }) + } +} diff --git a/cmd/workflow/simulate/chain/registry.go b/cmd/workflow/simulate/chain/registry.go new file mode 100644 index 00000000..98e9c7ef --- /dev/null +++ b/cmd/workflow/simulate/chain/registry.go @@ -0,0 +1,94 @@ +package chain + +import ( + "context" + "fmt" + "sort" + "sync" + + "github.com/spf13/viper" +) + +// ChainFamily defines what a chain family plugin must implement +// to participate in workflow simulation. +type ChainFamily interface { + // Name returns the family identifier (e.g., "evm", "aptos"). + Name() string + + // ResolveClients creates RPC clients for all chains this family + // can simulate, including both supported and experimental chains. + // Returns clients keyed by chain selector, and forwarder addresses + // for chains that have them. + ResolveClients(v *viper.Viper) (clients map[uint64]ChainClient, forwarders map[uint64]string, err error) + + // RegisterCapabilities creates capability servers for this family's + // chains and adds them to the registry. + RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) error + + // ExecuteTrigger fires a chain-specific trigger for a given selector. + // Each family defines what triggerData looks like. + ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error + + // 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 family. + ParseTriggerChainSelector(triggerID string) (uint64, bool) + + // RunHealthCheck validates RPC connectivity for all resolved clients. + RunHealthCheck(clients map[uint64]ChainClient) error + + // SupportedChains returns the list of chains this family supports + // out of the box (for display/documentation purposes). + SupportedChains() []ChainConfig +} + +var ( + mu sync.RWMutex + families = make(map[string]ChainFamily) +) + +// Register adds a chain family to the registry. +// Panics on duplicate registration (programming error). +func Register(family ChainFamily) { + mu.Lock() + defer mu.Unlock() + name := family.Name() + if _, exists := families[name]; exists { + panic(fmt.Sprintf("chain family %q already registered", name)) + } + families[name] = family +} + +// Get returns a registered chain family by name. +func Get(name string) (ChainFamily, error) { + mu.RLock() + defer mu.RUnlock() + f, ok := families[name] + if !ok { + return nil, fmt.Errorf("unknown chain family %q; registered: %v", name, Names()) + } + return f, nil +} + +// All returns a copy of all registered families. +func All() map[string]ChainFamily { + mu.RLock() + defer mu.RUnlock() + result := make(map[string]ChainFamily, len(families)) + for k, v := range families { + result[k] = v + } + return result +} + +// Names returns sorted registered family names. +func Names() []string { + mu.RLock() + defer mu.RUnlock() + names := make([]string, 0, len(families)) + for k := range families { + names = append(names, k) + } + sort.Strings(names) + return names +} diff --git a/cmd/workflow/simulate/chain/registry_test.go b/cmd/workflow/simulate/chain/registry_test.go new file mode 100644 index 00000000..1e432d94 --- /dev/null +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -0,0 +1,155 @@ +package chain + +import ( + "context" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func resetRegistry() { + mu.Lock() + defer mu.Unlock() + families = make(map[string]ChainFamily) +} + +// mockChainFamily is a testify/mock implementation of ChainFamily. +type mockChainFamily struct { + mock.Mock +} + +var _ ChainFamily = (*mockChainFamily)(nil) + +func (m *mockChainFamily) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *mockChainFamily) ResolveClients(v *viper.Viper) (map[uint64]ChainClient, map[uint64]string, error) { + args := m.Called(v) + clients, _ := args.Get(0).(map[uint64]ChainClient) + forwarders, _ := args.Get(1).(map[uint64]string) + return clients, forwarders, args.Error(2) +} + +func (m *mockChainFamily) RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) error { + args := m.Called(ctx, cfg) + return args.Error(0) +} + +func (m *mockChainFamily) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { + args := m.Called(ctx, selector, registrationID, triggerData) + return args.Error(0) +} + +func (m *mockChainFamily) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + args := m.Called(triggerID) + return args.Get(0).(uint64), args.Bool(1) +} + +func (m *mockChainFamily) RunHealthCheck(clients map[uint64]ChainClient) error { + args := m.Called(clients) + return args.Error(0) +} + +func (m *mockChainFamily) SupportedChains() []ChainConfig { + args := m.Called() + result, _ := args.Get(0).([]ChainConfig) + return result +} + +func newMockFamily(name string) *mockChainFamily { + f := new(mockChainFamily) + f.On("Name").Return(name) + return f +} + +func TestRegisterAndGet(t *testing.T) { + resetRegistry() + defer resetRegistry() + + mockFamily := newMockFamily("test") + Register(mockFamily) + + f, err := Get("test") + require.NoError(t, err) + assert.Equal(t, "test", f.Name()) + mockFamily.AssertExpectations(t) +} + +func TestGetUnknownFamily(t *testing.T) { + resetRegistry() + defer resetRegistry() + + _, err := Get("nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown chain family") +} + +func TestRegisterDuplicatePanics(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register(newMockFamily("dup")) + assert.Panics(t, func() { + Register(newMockFamily("dup")) + }) +} + +func TestAll(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register(newMockFamily("alpha")) + Register(newMockFamily("beta")) + + all := All() + assert.Len(t, all, 2) + assert.Contains(t, all, "alpha") + assert.Contains(t, all, "beta") +} + +func TestNamesReturnsSorted(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register(newMockFamily("zebra")) + Register(newMockFamily("alpha")) + Register(newMockFamily("middle")) + + names := Names() + assert.Equal(t, []string{"alpha", "middle", "zebra"}, names) +} + +func TestGetErrorIncludesRegisteredNames(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register(newMockFamily("evm")) + Register(newMockFamily("aptos")) + + _, err := Get("solana") + require.Error(t, err) + assert.Contains(t, err.Error(), "aptos") + assert.Contains(t, err.Error(), "evm") +} + +func TestAllReturnsCopy(t *testing.T) { + resetRegistry() + defer resetRegistry() + + mockFamily := newMockFamily("original") + Register(mockFamily) + + 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()) + mockFamily.AssertExpectations(t) +} diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go new file mode 100644 index 00000000..b87f95ab --- /dev/null +++ b/cmd/workflow/simulate/chain/types.go @@ -0,0 +1,27 @@ +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 family casts this to its concrete type internally. +type ChainClient interface{} + +// ChainConfig identifies a supported chain within a family. +type ChainConfig struct { + Selector uint64 + Forwarder string // family-specific forwarding address +} + +// CapabilityConfig holds everything a family needs to register capabilities. +type CapabilityConfig struct { + Registry *capabilities.Registry + Clients map[uint64]ChainClient + Forwarders map[uint64]string + PrivateKey interface{} // family-specific key type; EVM uses *ecdsa.PrivateKey + Broadcast bool + Limits interface{} // *SimulationLimits from parent package, or nil + Logger logger.Logger +} diff --git a/cmd/workflow/simulate/limited_capabilities.go b/cmd/workflow/simulate/limited_capabilities.go index 3a48a850..576d5015 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" @@ -194,91 +192,3 @@ func (l *LimitedConsensusNoDAG) Initialise(ctx context.Context, deps core.Standa 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..a81c924f 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,61 +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() @@ -377,65 +321,3 @@ func TestLimitedConsensusNoDAGReportDelegates(t *testing.T) { 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/simulate.go b/cmd/workflow/simulate/simulate.go index 1b4f8b2c..a9b99dba 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -4,17 +4,15 @@ import ( "context" "crypto/ecdsa" "encoding/json" + "errors" "fmt" - "math" "math/big" "os" "os/signal" - "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" @@ -23,7 +21,6 @@ import ( "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" @@ -31,12 +28,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" + evmpkg "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -46,23 +44,23 @@ import ( ) 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"` + // Family-generic chain fields + FamilyClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` + FamilyForwarders map[string]map[uint64]string `validate:"-"` + FamilyKeys map[string]interface{} `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 // Limits enforcement LimitsPath string `validate:"-"` // "default" or path to custom limits JSON // SkipTypeChecks passes --skip-type-checks to cre-compile for TypeScript workflows. @@ -126,93 +124,32 @@ 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)) + familyClients := make(map[string]map[uint64]chain.ChainClient) + familyForwarders := make(map[string]map[uint64]string) + familyKeys := make(map[string]interface{}) - c, err := ethclient.Dial(rpcURL) + for name, family := range chain.All() { + clients, forwarders, err := family.ResolveClients(v) 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) - } - - 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) - } - - // 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 + return Inputs{}, fmt.Errorf("failed to resolve %s clients: %w", name, err) } - // 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) - if err != nil { - return Inputs{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) + if len(clients) > 0 { + familyClients[name] = clients + familyForwarders[name] = forwarders } - - 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(clients) == 0 { + // Check at least one family has clients + totalClients := 0 + for _, fc := range familyClients { + totalClients += len(fc) + } + if totalClients == 0 { return Inputs{}, fmt.Errorf("no RPC URLs found for supported or experimental chains") } + // Private key parsing (EVM-specific for now, stored under "evm" key) pk, err := crypto.HexToECDSA(creSettings.User.EthPrivateKey) if err != nil { if v.GetBool("broadcast") { @@ -225,25 +162,26 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) } 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.") } + familyKeys["evm"] = pk 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), + 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"), + FamilyClients: familyClients, + FamilyForwarders: familyForwarders, + FamilyKeys: familyKeys, + 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"), + LimitsPath: v.GetString("limits"), + SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), }, nil } @@ -271,12 +209,27 @@ func (h *handler) ValidateInputs(inputs Inputs) error { 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") + if inputs.Broadcast { + if pkIface, ok := inputs.FamilyKeys["evm"]; ok { + if pk, ok2 := pkIface.(*ecdsa.PrivateKey); ok2 && pk.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, family := range chain.All() { + if clients, ok := inputs.FamilyClients[name]; ok { + if err := family.RunHealthCheck(clients); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil }) if rpcErr != nil { // we don't block execution, just show the error to the user @@ -469,7 +422,7 @@ func run( initializedCh := make(chan struct{}) executionFinishedCh := make(chan struct{}) - var triggerCaps *ManualTriggers + var manualTriggers *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 @@ -499,33 +452,38 @@ 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) + manualTriggers, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) if err != nil { - ui.Error(fmt.Sprintf("Failed to create trigger capabilities: %v", err)) + ui.Error(fmt.Sprintf("Failed to create cron/HTTP trigger capabilities: %v", err)) os.Exit(1) } + // Register chain-family-specific capabilities + for name, family := range chain.All() { + clients, ok := inputs.FamilyClients[name] + if !ok || len(clients) == 0 { + continue + } + + err := family.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Registry: registry, + Clients: clients, + Forwarders: inputs.FamilyForwarders[name], + PrivateKey: inputs.FamilyKeys[name], + Broadcast: inputs.Broadcast, + Limits: simLimits, + Logger: triggerLggr, + }) + if err != nil { + ui.Error(fmt.Sprintf("Failed to register %s capabilities: %v", name, err)) + os.Exit(1) + } + } + + // 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 { @@ -534,8 +492,8 @@ func run( } // Start trigger capabilities - if err := triggerCaps.Start(ctx); err != nil { - ui.Error(fmt.Sprintf("Failed to start trigger: %v", err)) + if err := manualTriggers.Start(ctx); err != nil { + ui.Error(fmt.Sprintf("Failed to start cron/HTTP triggers: %v", err)) os.Exit(1) } @@ -547,10 +505,7 @@ func run( } } - srvcs = append(srvcs, triggerCaps.ManualCronTrigger, triggerCaps.ManualHTTPTrigger) - for _, evm := range triggerCaps.ManualEVMChains { - srvcs = append(srvcs, evm) - } + srvcs = append(srvcs, manualTriggers.ManualCronTrigger, manualTriggers.ManualHTTPTrigger) srvcs = append(srvcs, computeCaps...) return registry, srvcs } @@ -558,11 +513,11 @@ func run( // Create a holder for trigger info that will be populated in beforeStart triggerInfoAndBeforeStart := &TriggerInfoAndBeforeStart{} - getTriggerCaps := func() *ManualTriggers { return triggerCaps } + getManualTriggers := func() *ManualTriggers { return manualTriggers } if inputs.NonInteractive { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggers) } else { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggers) } waitFn := func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service) { @@ -697,7 +652,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, manualTriggersGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -736,12 +691,12 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", triggerIndex) trigger := holder.TriggerToRun.Id - triggerCaps := triggerCapsGetter() + manualTriggerCaps := manualTriggersGetter() switch { case trigger == "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": payload, err := getHTTPTriggerPayload() @@ -750,44 +705,40 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs 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 family + handled := false + for name, family := range chain.All() { + sel, ok := family.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) - } + triggerData, err := getTriggerDataForFamily(ctx, name, sel, inputs, true) + if err != nil { + ui.Error(fmt.Sprintf("Failed to get trigger data: %v", err)) + 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) + handled = true + holder.TriggerFunc = func() error { + return family.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, manualTriggersGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -811,12 +762,12 @@ 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 := manualTriggersGetter() switch { case trigger == "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": if strings.TrimSpace(inputs.HTTPPayload) == "" { @@ -829,42 +780,34 @@ 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 family + handled := false + for name, family := range chain.All() { + sel, ok := family.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) - } + triggerData, err := getTriggerDataForFamily(ctx, name, sel, inputs, false) + if err != nil { + ui.Error(fmt.Sprintf("Failed to get trigger data: %v", err)) + 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) + handled = true + holder.TriggerFunc = func() error { + return family.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) + } + break } - 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) - } - 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) } } } @@ -951,114 +894,37 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { return payload, nil } -// 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)), - } +// getTriggerDataForFamily resolves trigger data for a specific chain family. +// For EVM, this fetches transaction receipt logs. Each family has its own trigger data format. +func getTriggerDataForFamily(ctx context.Context, familyName string, selector uint64, inputs Inputs, interactive bool) (interface{}, error) { + switch familyName { + case "evm": + // Get the EVM client for this selector + evmClients, ok := inputs.FamilyClients["evm"] + if !ok { + return nil, fmt.Errorf("no EVM clients configured") + } + clientIface, ok := evmClients[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) + } - // Convert topics - for _, topic := range log.Topics { - pbLog.Topics = append(pbLog.Topics, topic.Bytes()) - } + if interactive { + return evmpkg.GetEVMTriggerLog(ctx, client) + } - // Set event signature (first topic is usually the event signature) - if len(log.Topics) > 0 { - pbLog.EventSig = log.Topics[0].Bytes() + // Non-interactive: use CLI flags + if strings.TrimSpace(inputs.EVMTxHash) == "" || inputs.EVMEventIndex < 0 { + return nil, fmt.Errorf("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") + } + return evmpkg.GetEVMTriggerLogFromValues(ctx, client, inputs.EVMTxHash, uint64(inputs.EVMEventIndex)) // #nosec G115 -- EVMEventIndex validated >= 0 above + default: + return nil, fmt.Errorf("trigger data resolution not implemented for chain family %q", familyName) } - - 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 '@') @@ -1090,60 +956,3 @@ func getHTTPTriggerPayloadFromInput(input string) (*httptypedapi.Payload, error) 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/simulator_utils.go b/cmd/workflow/simulate/simulator_utils.go index 6334a2c5..5a2c5570 100644 --- a/cmd/workflow/simulate/simulator_utils.go +++ b/cmd/workflow/simulate/simulator_utils.go @@ -1,161 +1,16 @@ package simulate 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" ) 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{ - // Ethereum - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector, Forwarder: "0x15fC6ae953E024d975e77382eEeC56A9101f9F88"}, - {Selector: chainselectors.ETHEREUM_MAINNET.Selector, Forwarder: "0xa3d1ad4ac559a6575a114998affb2fb2ec97a7d9"}, - - // Base - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_BASE_1.Selector, Forwarder: "0x82300bd7c3958625581cc2f77bc6464dcecdf3e5"}, - {Selector: chainselectors.ETHEREUM_MAINNET_BASE_1.Selector, Forwarder: "0x5e342a8438b4f5d39e72875fcee6f76b39cce548"}, - - // Avalanche - {Selector: chainselectors.AVALANCHE_TESTNET_FUJI.Selector, Forwarder: "0x2e7371a5d032489e4f60216d8d898a4c10805963"}, - {Selector: chainselectors.AVALANCHE_MAINNET.Selector, Forwarder: "0xdc21e279934ff6721cadfdd112dafb3261f09a2c"}, - - // Polygon - {Selector: chainselectors.POLYGON_TESTNET_AMOY.Selector, Forwarder: "0x3675a5eb2286a3f87e8278fc66edf458a2e3bb74"}, - {Selector: chainselectors.POLYGON_MAINNET.Selector, Forwarder: "0xf458d621885e29a5003ea9bbba5280d54e19b1ce"}, - - // BNB Chain - {Selector: chainselectors.BINANCE_SMART_CHAIN_TESTNET.Selector, Forwarder: "0xa238e42cb8782808dbb2f37e19859244ec4779b0"}, - {Selector: chainselectors.BINANCE_SMART_CHAIN_MAINNET.Selector, Forwarder: "0x6f3239bbb26e98961e1115aba83f8a282e5508c8"}, - - // Arbitrum - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_ARBITRUM_1.Selector, Forwarder: "0xd41263567ddfead91504199b8c6c87371e83ca5d"}, - {Selector: chainselectors.ETHEREUM_MAINNET_ARBITRUM_1.Selector, Forwarder: "0xd770499057619c9a76205fd4168161cf94abc532"}, - - // Optimism - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_OPTIMISM_1.Selector, Forwarder: "0xa2888380dff3704a8ab6d1cd1a8f69c15fea5ee3"}, - {Selector: chainselectors.ETHEREUM_MAINNET_OPTIMISM_1.Selector, Forwarder: "0x9119a1501550ed94a3f2794038ed9258337afa18"}, - - // Andesite (private testnet) - {Selector: chainselectors.PRIVATE_TESTNET_ANDESITE.Selector, Forwarder: "0xcF4629d8DC7a5fa17F4D77233F5b953225669821"}, - - // ZkSync - {Selector: chainselectors.ETHEREUM_MAINNET_ZKSYNC_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_ZKSYNC_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Jovay - {Selector: chainselectors.JOVAY_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.JOVAY_MAINNET.Selector, Forwarder: "0x2B3068C4B288A2CD1f8B3613b8f33ef7cEecadC4"}, - - // Pharos - {Selector: chainselectors.PHAROS_ATLANTIC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.PHAROS_MAINNET.Selector, Forwarder: "0x2B3068C4B288A2CD1f8B3613b8f33ef7cEecadC4"}, - - // Worldchain - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_WORLDCHAIN_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.ETHEREUM_MAINNET_WORLDCHAIN_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Plasma - {Selector: chainselectors.PLASMA_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.PLASMA_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Linea - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_LINEA_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.ETHEREUM_MAINNET_LINEA_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Ink - {Selector: chainselectors.INK_TESTNET_SEPOLIA.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.ETHEREUM_MAINNET_INK_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Hyperliquid - {Selector: chainselectors.HYPERLIQUID_TESTNET.Selector, Forwarder: "0xB27fA1c28288c50542527F64BCda22C9FbAc24CB"}, - {Selector: chainselectors.HYPERLIQUID_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Apechain - {Selector: chainselectors.APECHAIN_TESTNET_CURTIS.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Arc - {Selector: chainselectors.ARC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Xlayer - {Selector: chainselectors.XLAYER_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.ETHEREUM_MAINNET_XLAYER_1.Selector, Forwarder: "0x2B3068C4B288A2CD1f8B3613b8f33ef7cEecadC4"}, - - // MegaETH - {Selector: chainselectors.MEGAETH_TESTNET_2.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.MEGAETH_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Celo - // {Selector: chainselectors.CELO_SEPOLIA.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.CELO_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Gnosis - {Selector: chainselectors.GNOSIS_CHAIN_TESTNET_CHIADO.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.GNOSIS_CHAIN_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Cronos - {Selector: chainselectors.CRONOS_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Mantle - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_MANTLE_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.ETHEREUM_MAINNET_MANTLE_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // TAC - {Selector: chainselectors.TAC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Unichain - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_UNICHAIN_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Scroll - {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA_SCROLL_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.ETHEREUM_MAINNET_SCROLL_1.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // Sonic - {Selector: chainselectors.SONIC_TESTNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - {Selector: chainselectors.SONIC_MAINNET.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, - - // 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/***". @@ -180,52 +35,3 @@ func redactURL(rawURL string) string { // 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/utils_test.go b/cmd/workflow/simulate/utils_test.go index 14c5fd26..3bae27c0 100644 --- a/cmd/workflow/simulate/utils_test.go +++ b/cmd/workflow/simulate/utils_test.go @@ -1,242 +1,48 @@ package simulate import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" "testing" - - "github.com/ethereum/go-ethereum/ethclient" ) -func TestParseChainSelectorFromTriggerID(t *testing.T) { +func TestRedactURL(t *testing.T) { tests := []struct { name string - id string - want uint64 - ok bool + raw string + want string }{ { - 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: "masks last path segment", + raw: "https://rpc.example.com/v1/my-secret-key", + want: "https://rpc.example.com/v1/***", }, { - name: "no selector present", - id: "evm@1.0.0 LogTrigger", - want: 0, - ok: false, + name: "removes query params", + raw: "https://rpc.example.com/v1/key?token=secret", + want: "https://rpc.example.com/v1/***", }, { - name: "non-numeric selector", - id: "evm:ChainSelector:notanumber@1.0.0", - want: 0, - ok: false, + name: "single path segment masked", + raw: "https://rpc.example.com/key", + want: "https://rpc.example.com/***", }, { - name: "empty selector", - id: "evm:ChainSelector:@1.0.0", - want: 0, - ok: false, + name: "no path", + raw: "https://rpc.example.com", + want: "https://rpc.example.com", }, { - 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, + name: "invalid URL", + raw: "://bad", + want: "***", }, } 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) + got := redactURL(tt.raw) + if got != tt.want { + t.Errorf("redactURL(%q) = %q, want %q", tt.raw, got, tt.want) } }) } } - -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", - ) -} From c004b88f4a0b0283297d1fcb89dc0661d7772d3e Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Wed, 15 Apr 2026 20:39:16 +0100 Subject: [PATCH 02/26] chore: audit refactor for parity and abstraction - Restore missing debug/error log statements in EVM ResolveClients - Restore ExecuteTrigger nil guards (f.evmChains, EVMChains[selector]) - Restore ui.Success for EVM log discovery in fetchAndConvertLog - Restore comments: Use Opaque, runRPCHealthCheck semantics, regex example - Track experimentalSelectors on EVMFamily, pass to RunHealthCheck - Factory-pattern family registration with logger injection (chain.Build) - Extract ResolveKey + ResolveTriggerData to EVM family - Extract simulator_utils.go contents; WorkflowExecutionTimeout to simulate.go - RegisterCapabilities returns []services.Service for lifecycle parity - getTriggerDataForFamily doc comment chain-agnostic - PROMPT.md + SMOKE_TESTS.md audit artifacts (65 tests) --- cmd/workflow/simulate/chain/evm/family.go | 101 ++++++++++++++++-- cmd/workflow/simulate/chain/evm/health.go | 4 + cmd/workflow/simulate/chain/evm/trigger.go | 2 + cmd/workflow/simulate/chain/registry.go | 57 ++++++++-- cmd/workflow/simulate/chain/registry_test.go | 55 +++++++--- cmd/workflow/simulate/chain/types.go | 10 ++ .../{simulator_utils.go => chain/utils.go} | 11 +- .../simulate/{ => chain}/utils_test.go | 6 +- cmd/workflow/simulate/simulate.go | 100 +++++++---------- 9 files changed, 242 insertions(+), 104 deletions(-) rename cmd/workflow/simulate/{simulator_utils.go => chain/utils.go} (79%) rename cmd/workflow/simulate/{ => chain}/utils_test.go (88%) diff --git a/cmd/workflow/simulate/chain/evm/family.go b/cmd/workflow/simulate/chain/evm/family.go index cea21fe0..82552ce8 100644 --- a/cmd/workflow/simulate/chain/evm/family.go +++ b/cmd/workflow/simulate/chain/evm/family.go @@ -4,26 +4,36 @@ import ( "context" "crypto/ecdsa" "fmt" + "math/big" "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" 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(&EVMFamily{}) + chain.Register("evm", func(lggr *zerolog.Logger) chain.ChainFamily { + return &EVMFamily{log: lggr} + }) } // EVMFamily implements chain.ChainFamily for EVM-based blockchains. type EVMFamily struct { - evmChains *EVMChainCapabilities + log *zerolog.Logger + evmChains *EVMChainCapabilities + experimentalSelectors map[uint64]bool } var _ chain.ChainFamily = (*EVMFamily)(nil) @@ -37,17 +47,22 @@ func (f *EVMFamily) SupportedChains() []chain.ChainConfig { func (f *EVMFamily) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient, map[uint64]string, error) { clients := make(map[uint64]chain.ChainClient) forwarders := make(map[uint64]string) + experimental := make(map[uint64]bool) // Resolve supported chains for _, ch := range SupportedChains { chainName, err := settings.GetChainNameByChainSelector(ch.Selector) if err != nil { + f.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) == "" { + f.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) continue } + f.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)) @@ -79,33 +94,38 @@ func (f *EVMFamily) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient // For duplicate selectors, keep the supported client and only // override the forwarder. if _, exists := clients[ec.ChainSelector]; exists { - if !strings.EqualFold(forwarders[ec.ChainSelector], ec.Forwarder) { + 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 { + f.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") } continue } + f.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 nil, nil, 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)) } + f.experimentalSelectors = experimental return clients, forwarders, nil } -func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) error { +func (f *EVMFamily) 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 fmt.Errorf("EVM family: client for selector %d is not *ethclient.Client", sel) + return nil, fmt.Errorf("EVM family: client for selector %d is not *ethclient.Client", sel) } ethClients[sel] = ec } @@ -122,7 +142,7 @@ func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.Capabili var ok bool pk, ok = cfg.PrivateKey.(*ecdsa.PrivateKey) if !ok { - return fmt.Errorf("EVM family: private key is not *ecdsa.PrivateKey") + return nil, fmt.Errorf("EVM family: private key is not *ecdsa.PrivateKey") } } @@ -132,7 +152,7 @@ func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.Capabili var ok bool evmLimits, ok = cfg.Limits.(EVMChainLimits) if !ok { - return fmt.Errorf("EVM family: limits does not satisfy EVMChainLimits interface") + return nil, fmt.Errorf("EVM family: limits does not satisfy EVMChainLimits interface") } } @@ -144,16 +164,21 @@ func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.Capabili dryRun, evmLimits, ) if err != nil { - return err + return nil, err } // Start the EVM chains so they begin listening for triggers if err := evmCaps.Start(ctx); err != nil { - return fmt.Errorf("EVM family: failed to start chain capabilities: %w", err) + return nil, fmt.Errorf("EVM family: failed to start chain capabilities: %w", err) } f.evmChains = evmCaps - return nil + + srvcs := make([]services.Service, 0, len(evmCaps.EVMChains)) + for _, evm := range evmCaps.EVMChains { + srvcs = append(srvcs, evm) + } + return srvcs, nil } func (f *EVMFamily) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { @@ -171,10 +196,64 @@ func (f *EVMFamily) ExecuteTrigger(ctx context.Context, selector uint64, registr 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 family cannot dispatch against. +func (f *EVMFamily) HasSelector(selector uint64) bool { + if f.evmChains == nil { + return false + } + return f.evmChains.EVMChains[selector] != nil +} + func (f *EVMFamily) ParseTriggerChainSelector(triggerID string) (uint64, bool) { return ParseTriggerChainSelector(triggerID) } func (f *EVMFamily) RunHealthCheck(clients map[uint64]chain.ChainClient) error { - return RunRPCHealthCheck(clients, nil) + return RunRPCHealthCheck(clients, f.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 (f *EVMFamily) 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 +} + +// ResolveTriggerData fetches the EVM log payload for the given selector from +// CLI-supplied or interactively-prompted inputs. +func (f *EVMFamily) 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) + } + + if strings.TrimSpace(params.EVMTxHash) == "" || params.EVMEventIndex < 0 { + return nil, fmt.Errorf("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") + } + return GetEVMTriggerLogFromValues(ctx, client, params.EVMTxHash, uint64(params.EVMEventIndex)) // #nosec G115 -- EVMEventIndex validated >= 0 above } diff --git a/cmd/workflow/simulate/chain/evm/health.go b/cmd/workflow/simulate/chain/evm/health.go index a8969da2..cc82473c 100644 --- a/cmd/workflow/simulate/chain/evm/health.go +++ b/cmd/workflow/simulate/chain/evm/health.go @@ -28,6 +28,7 @@ func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelecto } // runRPCHealthCheck runs connectivity check against every configured client. +// experimentalSelectors set identifies experimental chains (not in chain-selectors). func runRPCHealthCheck(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") @@ -36,16 +37,19 @@ func runRPCHealthCheck(clients map[uint64]*ethclient.Client, experimentalSelecto 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 diff --git a/cmd/workflow/simulate/chain/evm/trigger.go b/cmd/workflow/simulate/chain/evm/trigger.go index 9dc11761..e3beac61 100644 --- a/cmd/workflow/simulate/chain/evm/trigger.go +++ b/cmd/workflow/simulate/chain/evm/trigger.go @@ -18,6 +18,7 @@ import ( "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. @@ -123,6 +124,7 @@ func fetchAndConvertLog(ctx context.Context, ethClient *ethclient.Client, txHash } 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))) var txIndex, logIndex uint32 if log.TxIndex > math.MaxUint32 { diff --git a/cmd/workflow/simulate/chain/registry.go b/cmd/workflow/simulate/chain/registry.go index 98e9c7ef..c4739a4a 100644 --- a/cmd/workflow/simulate/chain/registry.go +++ b/cmd/workflow/simulate/chain/registry.go @@ -6,9 +6,18 @@ import ( "sort" "sync" + "github.com/rs/zerolog" "github.com/spf13/viper" + + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/cre-cli/internal/settings" ) +// Factory constructs a ChainFamily with the logger the simulator uses. +// Registered at init() time; invoked during Build() at command runtime. +type Factory func(lggr *zerolog.Logger) ChainFamily + // ChainFamily defines what a chain family plugin must implement // to participate in workflow simulation. type ChainFamily interface { @@ -21,14 +30,32 @@ type ChainFamily interface { // for chains that have them. ResolveClients(v *viper.Viper) (clients map[uint64]ChainClient, forwarders map[uint64]string, err error) + // ResolveKey parses and validates this family'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 (family-specific type) or nil if the family + // does not use a signing key. + ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) + + // ResolveTriggerData produces the family-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 family's - // chains and adds them to the registry. - RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) error + // 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 family defines what triggerData looks like. ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error + // HasSelector reports whether the family 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 family 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 family. @@ -43,20 +70,32 @@ type ChainFamily interface { } var ( - mu sync.RWMutex - families = make(map[string]ChainFamily) + mu sync.RWMutex + factories = make(map[string]Factory) + families = make(map[string]ChainFamily) ) -// Register adds a chain family to the registry. +// Register adds a chain family factory to the registry. +// Called from family package init(); the factory is invoked later in Build(). // Panics on duplicate registration (programming error). -func Register(family ChainFamily) { +func Register(name string, factory Factory) { mu.Lock() defer mu.Unlock() - name := family.Name() - if _, exists := families[name]; exists { + if _, exists := factories[name]; exists { panic(fmt.Sprintf("chain family %q already registered", name)) } - families[name] = family + factories[name] = factory +} + +// Build instantiates every registered family 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, factory := range factories { + families[name] = factory(lggr) + } } // Get returns a registered chain family by name. diff --git a/cmd/workflow/simulate/chain/registry_test.go b/cmd/workflow/simulate/chain/registry_test.go index 1e432d94..f35402cb 100644 --- a/cmd/workflow/simulate/chain/registry_test.go +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -4,15 +4,21 @@ import ( "context" "testing" + "github.com/rs/zerolog" "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() + factories = make(map[string]Factory) families = make(map[string]ChainFamily) } @@ -35,9 +41,10 @@ func (m *mockChainFamily) ResolveClients(v *viper.Viper) (map[uint64]ChainClient return clients, forwarders, args.Error(2) } -func (m *mockChainFamily) RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) error { +func (m *mockChainFamily) RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) ([]services.Service, error) { args := m.Called(ctx, cfg) - return args.Error(0) + srvcs, _ := args.Get(0).([]services.Service) + return srvcs, args.Error(1) } func (m *mockChainFamily) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { @@ -45,6 +52,11 @@ func (m *mockChainFamily) ExecuteTrigger(ctx context.Context, selector uint64, r return args.Error(0) } +func (m *mockChainFamily) HasSelector(selector uint64) bool { + args := m.Called(selector) + return args.Bool(0) +} + func (m *mockChainFamily) ParseTriggerChainSelector(triggerID string) (uint64, bool) { args := m.Called(triggerID) return args.Get(0).(uint64), args.Bool(1) @@ -61,18 +73,35 @@ func (m *mockChainFamily) SupportedChains() []ChainConfig { return result } +func (m *mockChainFamily) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { + args := m.Called(creSettings, broadcast) + return args.Get(0), args.Error(1) +} + +func (m *mockChainFamily) ResolveTriggerData(ctx context.Context, selector uint64, params TriggerParams) (interface{}, error) { + args := m.Called(ctx, selector, params) + return args.Get(0), args.Error(1) +} + func newMockFamily(name string) *mockChainFamily { f := new(mockChainFamily) f.On("Name").Return(name) return f } +// registerMock registers a pre-built mock family and immediately builds it so +// tests can exercise Get/All/Names without wiring a real logger. +func registerMock(name string, family ChainFamily) { + Register(name, func(*zerolog.Logger) ChainFamily { return family }) + Build(nil) +} + func TestRegisterAndGet(t *testing.T) { resetRegistry() defer resetRegistry() mockFamily := newMockFamily("test") - Register(mockFamily) + registerMock("test", mockFamily) f, err := Get("test") require.NoError(t, err) @@ -93,9 +122,9 @@ func TestRegisterDuplicatePanics(t *testing.T) { resetRegistry() defer resetRegistry() - Register(newMockFamily("dup")) + registerMock("dup", newMockFamily("dup")) assert.Panics(t, func() { - Register(newMockFamily("dup")) + registerMock("dup", newMockFamily("dup")) }) } @@ -103,8 +132,8 @@ func TestAll(t *testing.T) { resetRegistry() defer resetRegistry() - Register(newMockFamily("alpha")) - Register(newMockFamily("beta")) + registerMock("alpha", newMockFamily("alpha")) + registerMock("beta", newMockFamily("beta")) all := All() assert.Len(t, all, 2) @@ -116,9 +145,9 @@ func TestNamesReturnsSorted(t *testing.T) { resetRegistry() defer resetRegistry() - Register(newMockFamily("zebra")) - Register(newMockFamily("alpha")) - Register(newMockFamily("middle")) + registerMock("zebra", newMockFamily("zebra")) + registerMock("alpha", newMockFamily("alpha")) + registerMock("middle", newMockFamily("middle")) names := Names() assert.Equal(t, []string{"alpha", "middle", "zebra"}, names) @@ -128,8 +157,8 @@ func TestGetErrorIncludesRegisteredNames(t *testing.T) { resetRegistry() defer resetRegistry() - Register(newMockFamily("evm")) - Register(newMockFamily("aptos")) + registerMock("evm", newMockFamily("evm")) + registerMock("aptos", newMockFamily("aptos")) _, err := Get("solana") require.Error(t, err) @@ -142,7 +171,7 @@ func TestAllReturnsCopy(t *testing.T) { defer resetRegistry() mockFamily := newMockFamily("original") - Register(mockFamily) + registerMock("original", mockFamily) all := All() delete(all, "original") diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go index b87f95ab..11e14ecf 100644 --- a/cmd/workflow/simulate/chain/types.go +++ b/cmd/workflow/simulate/chain/types.go @@ -25,3 +25,13 @@ type CapabilityConfig struct { Limits interface{} // *SimulationLimits from parent package, or nil Logger logger.Logger } + +// TriggerParams carries family-agnostic inputs needed to resolve trigger data +// for a given chain trigger. Family-specific fields are ignored by families +// that don't need them. +type TriggerParams struct { + Clients map[uint64]ChainClient + Interactive bool + EVMTxHash string + EVMEventIndex int +} diff --git a/cmd/workflow/simulate/simulator_utils.go b/cmd/workflow/simulate/chain/utils.go similarity index 79% rename from cmd/workflow/simulate/simulator_utils.go rename to cmd/workflow/simulate/chain/utils.go index 5a2c5570..d8291766 100644 --- a/cmd/workflow/simulate/simulator_utils.go +++ b/cmd/workflow/simulate/chain/utils.go @@ -1,20 +1,15 @@ -package simulate +package chain import ( "fmt" "net/url" "strings" - "time" ) -const WorkflowExecutionTimeout = 5 * time.Minute - -type ChainSelector = uint64 - -// redactURL returns a version of the URL with path segments and query parameters +// 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 { +func RedactURL(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { return "***" diff --git a/cmd/workflow/simulate/utils_test.go b/cmd/workflow/simulate/chain/utils_test.go similarity index 88% rename from cmd/workflow/simulate/utils_test.go rename to cmd/workflow/simulate/chain/utils_test.go index 3bae27c0..3247e477 100644 --- a/cmd/workflow/simulate/utils_test.go +++ b/cmd/workflow/simulate/chain/utils_test.go @@ -1,4 +1,4 @@ -package simulate +package chain import ( "testing" @@ -39,9 +39,9 @@ func TestRedactURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := redactURL(tt.raw) + got := RedactURL(tt.raw) if got != tt.want { - t.Errorf("redactURL(%q) = %q, want %q", tt.raw, got, tt.want) + t.Errorf("RedactURL(%q) = %q, want %q", tt.raw, got, tt.want) } }) } diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index a9b99dba..ce5a5075 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -2,19 +2,15 @@ package simulate import ( "context" - "crypto/ecdsa" "encoding/json" "errors" "fmt" - "math/big" "os" "os/signal" "strings" "syscall" "time" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethclient" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -34,7 +30,7 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" - evmpkg "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" + _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -43,6 +39,8 @@ 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"` @@ -124,6 +122,8 @@ func newHandler(ctx *runtime.Context) *handler { } func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) (Inputs, error) { + chain.Build(h.log) + familyClients := make(map[string]map[uint64]chain.ChainClient) familyForwarders := make(map[string]map[uint64]string) familyKeys := make(map[string]interface{}) @@ -149,20 +149,16 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) return Inputs{}, fmt.Errorf("no RPC URLs found for supported or experimental chains") } - // Private key parsing (EVM-specific for now, stored under "evm" key) - 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) - } - pk, err = crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") + broadcast := v.GetBool("broadcast") + for name, family := range chain.All() { + key, err := family.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 { + familyKeys[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.") } - familyKeys["evm"] = pk return Inputs{ WasmPath: v.GetString("wasm"), @@ -208,15 +204,6 @@ func (h *handler) ValidateInputs(inputs Inputs) error { inputs.WasmPath = savedWasm inputs.ConfigPath = savedConfig - // forbid the default 0x...01 key when broadcasting - if inputs.Broadcast { - if pkIface, ok := inputs.FamilyKeys["evm"]; ok { - if pk, ok2 := pkIface.(*ecdsa.PrivateKey); ok2 && pk.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 { var errs []error for name, family := range chain.All() { @@ -468,7 +455,7 @@ func run( continue } - err := family.RegisterCapabilities(ctx, chain.CapabilityConfig{ + familySrvcs, err := family.RegisterCapabilities(ctx, chain.CapabilityConfig{ Registry: registry, Clients: clients, Forwarders: inputs.FamilyForwarders[name], @@ -481,6 +468,7 @@ func run( ui.Error(fmt.Sprintf("Failed to register %s capabilities: %v", name, err)) os.Exit(1) } + srvcs = append(srvcs, familySrvcs...) } // Register chain-agnostic action capabilities (consensus, HTTP, confidential HTTP) @@ -716,9 +704,14 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs continue } - triggerData, err := getTriggerDataForFamily(ctx, name, sel, inputs, true) + triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, true) if err != nil { - ui.Error(fmt.Sprintf("Failed to get trigger data: %v", err)) + ui.Error(err.Error()) + os.Exit(1) + } + + if !family.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) os.Exit(1) } @@ -791,9 +784,14 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp continue } - triggerData, err := getTriggerDataForFamily(ctx, name, sel, inputs, false) + triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, false) if err != nil { - ui.Error(fmt.Sprintf("Failed to get trigger data: %v", err)) + ui.Error(err.Error()) + os.Exit(1) + } + + if !family.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) os.Exit(1) } @@ -895,36 +893,18 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { } // getTriggerDataForFamily resolves trigger data for a specific chain family. -// For EVM, this fetches transaction receipt logs. Each family has its own trigger data format. -func getTriggerDataForFamily(ctx context.Context, familyName string, selector uint64, inputs Inputs, interactive bool) (interface{}, error) { - switch familyName { - case "evm": - // Get the EVM client for this selector - evmClients, ok := inputs.FamilyClients["evm"] - if !ok { - return nil, fmt.Errorf("no EVM clients configured") - } - clientIface, ok := evmClients[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 interactive { - return evmpkg.GetEVMTriggerLog(ctx, client) - } - - // Non-interactive: use CLI flags - if strings.TrimSpace(inputs.EVMTxHash) == "" || inputs.EVMEventIndex < 0 { - return nil, fmt.Errorf("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") - } - return evmpkg.GetEVMTriggerLogFromValues(ctx, client, inputs.EVMTxHash, uint64(inputs.EVMEventIndex)) // #nosec G115 -- EVMEventIndex validated >= 0 above - default: - return nil, fmt.Errorf("trigger data resolution not implemented for chain family %q", familyName) - } +// Each family defines its own trigger data format. +func getTriggerDataForFamily(ctx context.Context, family chain.ChainFamily, selector uint64, inputs Inputs, interactive bool) (interface{}, error) { + clients, ok := inputs.FamilyClients[family.Name()] + if !ok { + return nil, fmt.Errorf("no %s clients configured", family.Name()) + } + return family.ResolveTriggerData(ctx, selector, chain.TriggerParams{ + Clients: clients, + Interactive: interactive, + EVMTxHash: inputs.EVMTxHash, + EVMEventIndex: inputs.EVMEventIndex, + }) } // getHTTPTriggerPayloadFromInput builds an HTTP trigger payload from a JSON string or a file path (optionally prefixed with '@') From cbf498d7971656185cff48faa9bc62d7828764e7 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Wed, 15 Apr 2026 22:16:27 +0100 Subject: [PATCH 03/26] test: add EVM chain family unit tests Covers ResolveKey private-key matrix (valid/invalid/sentinel/empty across broadcast and non-broadcast), LimitedEVMChain delegation for every method, trigger hash validation and log-fetch mock scenarios, supported-chains invariants (unique selectors, valid 20-byte forwarders), and extended health-check labelling for experimental, known, and unknown selectors. 100+ new test cases, zero production code changes. --- .../simulate/chain/evm/family_test.go | 525 ++++++++++++++++++ .../simulate/chain/evm/health_more_test.go | 203 +++++++ .../limited_capabilities_delegation_test.go | 352 ++++++++++++ .../chain/evm/supported_chains_test.go | 143 +++++ .../chain/evm/trigger_validation_test.go | 378 +++++++++++++ 5 files changed, 1601 insertions(+) create mode 100644 cmd/workflow/simulate/chain/evm/family_test.go create mode 100644 cmd/workflow/simulate/chain/evm/health_more_test.go create mode 100644 cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go create mode 100644 cmd/workflow/simulate/chain/evm/supported_chains_test.go create mode 100644 cmd/workflow/simulate/chain/evm/trigger_validation_test.go diff --git a/cmd/workflow/simulate/chain/evm/family_test.go b/cmd/workflow/simulate/chain/evm/family_test.go new file mode 100644 index 00000000..aa22f80b --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/family_test.go @@ -0,0 +1,525 @@ +package evm + +import ( + "bytes" + "context" + "crypto/ecdsa" + "errors" + "io" + "math/big" + "os" + "strings" + "sync" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/rs/zerolog" + "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 +} + +// --- helpers --- + +// 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() +} + +// captureStdout captures anything written to os.Stdout during fn. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + stdioMu.Lock() + defer stdioMu.Unlock() + old := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + defer func() { + os.Stdout = old + }() + + fn() + + _ = w.Close() + <-done + return buf.String() +} + +func newFamily() *EVMFamily { + lg := zerolog.Nop() + return &EVMFamily{log: &lg} +} + +// Valid anvil dev key #0; known non-sentinel. +const validPK = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +// --------------------------------------------------------------------------- +// Name +// --------------------------------------------------------------------------- + +func TestEVMFamily_Name_IsEVM(t *testing.T) { + t.Parallel() + require.Equal(t, "evm", newFamily().Name()) +} + +// --------------------------------------------------------------------------- +// SupportedChains pass-through +// --------------------------------------------------------------------------- + +func TestEVMFamily_SupportedChains_ReturnsPackageVar(t *testing.T) { + t.Parallel() + got := newFamily().SupportedChains() + require.Equal(t, len(SupportedChains), len(got)) + require.Greater(t, len(got), 20, "expected many supported chains") +} + +// --------------------------------------------------------------------------- +// ResolveKey table +// --------------------------------------------------------------------------- + +func TestEVMFamily_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 { + tt := tt + t.Run(tt.name, func(t *testing.T) { + f := newFamily() + s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: tt.pk}} + + var got interface{} + var err error + stderr := captureStderr(t, func() { + got, err = f.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) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ResolveKey sentinel identity +// --------------------------------------------------------------------------- + +func TestEVMFamily_ResolveKey_SentinelDecodesToD1(t *testing.T) { + t.Parallel() + pk, err := crypto.HexToECDSA(defaultSentinelPrivateKey) + require.NoError(t, err) + require.Equal(t, 0, pk.D.Cmp(bigOne())) +} + +// --------------------------------------------------------------------------- +// ResolveTriggerData — non-interactive validation +// --------------------------------------------------------------------------- + +func TestEVMFamily_ResolveTriggerData_NoClient(t *testing.T) { + t.Parallel() + f := newFamily() + _, err := f.ResolveTriggerData(context.Background(), 777, chain.TriggerParams{ + Clients: map[uint64]chain.ChainClient{}, + Interactive: false, + EVMTxHash: "0x" + strings.Repeat("a", 64), + EVMEventIndex: 0, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RPC configured for chain selector 777") +} + +func TestEVMFamily_ResolveTriggerData_WrongClientType(t *testing.T) { + t.Parallel() + f := newFamily() + _, err := f.ResolveTriggerData(context.Background(), 1, chain.TriggerParams{ + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Interactive: false, + EVMTxHash: "0x" + strings.Repeat("a", 64), + EVMEventIndex: 0, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for EVM chain selector 1") +} + +// --------------------------------------------------------------------------- +// ExecuteTrigger +// --------------------------------------------------------------------------- + +func TestEVMFamily_ExecuteTrigger_NotRegistered(t *testing.T) { + t.Parallel() + f := newFamily() + err := f.ExecuteTrigger(context.Background(), 1, "regID", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "EVM family: capabilities not registered") +} + +func TestEVMFamily_ExecuteTrigger_UnknownSelector(t *testing.T) { + t.Parallel() + f := newFamily() + // set evmChains with empty map to bypass nil check + f.evmChains = &EVMChainCapabilities{EVMChains: nil} + err := f.ExecuteTrigger(context.Background(), 999, "regID", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no EVM chain initialized for selector 999") +} + +// --------------------------------------------------------------------------- +// HasSelector +// --------------------------------------------------------------------------- + +func TestEVMFamily_HasSelector_WhenNotRegistered_ReturnsFalse(t *testing.T) { + t.Parallel() + f := newFamily() + assert.False(t, f.HasSelector(1)) + assert.False(t, f.HasSelector(0)) +} + +func TestEVMFamily_HasSelector_EmptyMap_ReturnsFalse(t *testing.T) { + t.Parallel() + f := newFamily() + f.evmChains = &EVMChainCapabilities{EVMChains: nil} + assert.False(t, f.HasSelector(1)) +} + +// --------------------------------------------------------------------------- +// ParseTriggerChainSelector (via family interface) +// --------------------------------------------------------------------------- + +func TestEVMFamily_ParseTriggerChainSelector_Delegates(t *testing.T) { + t.Parallel() + f := newFamily() + got, ok := f.ParseTriggerChainSelector("evm:ChainSelector:42@1.0.0") + require.True(t, ok) + require.Equal(t, uint64(42), got) + + got, ok = f.ParseTriggerChainSelector("no-selector-here") + require.False(t, ok) + require.Zero(t, got) +} + +// --------------------------------------------------------------------------- +// RegisterCapabilities type-assertion failures +// --------------------------------------------------------------------------- + +func TestEVMFamily_RegisterCapabilities_WrongClientType(t *testing.T) { + t.Parallel() + f := newFamily() + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Forwarders: map[uint64]string{1: "0x" + strings.Repeat("a", 40)}, + } + _, err := f.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 TestEVMFamily_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { + t.Parallel() + f := newFamily() + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + Forwarders: map[uint64]string{}, + Logger: nopCommonLogger(), + Registry: newRegistry(t), + } + srvcs, err := f.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, f.HasSelector(1)) +} + +// --------------------------------------------------------------------------- +// RunHealthCheck plumbing +// --------------------------------------------------------------------------- + +func TestEVMFamily_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { + t.Parallel() + f := newFamily() + err := f.RunHealthCheck(map[uint64]chain.ChainClient{1: "not-ethclient"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for EVM family") +} + +func TestEVMFamily_RunHealthCheck_NoClients_Errors(t *testing.T) { + t.Parallel() + f := newFamily() + err := f.RunHealthCheck(map[uint64]chain.ChainClient{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RPC URLs found") +} + +// --------------------------------------------------------------------------- +// ChainFamily interface contract +// --------------------------------------------------------------------------- + +func TestEVMFamily_ImplementsChainFamily(t *testing.T) { + t.Parallel() + var _ chain.ChainFamily = (*EVMFamily)(nil) +} + +// --------------------------------------------------------------------------- +// Registered via init +// --------------------------------------------------------------------------- + +func TestEVMFamily_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 family should be registered at init; got %v", names) + + fam, err := chain.Get("evm") + require.NoError(t, err) + require.Equal(t, "evm", fam.Name()) +} + +// --------------------------------------------------------------------------- +// Sentinel error wrapping +// --------------------------------------------------------------------------- + +func TestEVMFamily_ResolveKey_BroadcastErrorWrapsUnderlying(t *testing.T) { + t.Parallel() + f := newFamily() + s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: "zz"}} + _, err := f.ResolveKey(s, true) + require.Error(t, err) + // Must mention env var for operator-facing clarity. + assert.Contains(t, err.Error(), "CRE_ETH_PRIVATE_KEY") +} + +// --------------------------------------------------------------------------- +// Non-broadcast with valid key: no UI warning leaked +// --------------------------------------------------------------------------- + +func TestEVMFamily_ResolveKey_ValidNonBroadcast_NoWarning(t *testing.T) { + t.Parallel() + f := newFamily() + s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: validPK}} + stderr := captureStderr(t, func() { + _, err := f.ResolveKey(s, false) + require.NoError(t, err) + }) + assert.NotContains(t, stderr, "Using default private key") +} + +// --------------------------------------------------------------------------- +// ExecuteTrigger wrong triggerData type +// --------------------------------------------------------------------------- + +func TestEVMFamily_ExecuteTrigger_WrongTriggerDataType(t *testing.T) { + t.Parallel() + // Register a nil FakeEVMChain entry via map so the nil-check passes but the + // triggerData type assertion fails first. + f := newFamily() + f.evmChains = &EVMChainCapabilities{EVMChains: nil} + err := f.ExecuteTrigger(context.Background(), 1, "regID", "not-a-log") + require.Error(t, err) + // Whichever check fails first — both are acceptable. + if !errorContainsAny(err, "trigger data is not *evm.Log", "no EVM chain initialized") { + t.Fatalf("unexpected error: %v", err) + } +} + +func errorContainsAny(err error, subs ...string) bool { + if err == nil { + return false + } + for _, s := range subs { + if strings.Contains(err.Error(), s) { + return true + } + } + return false +} + +// Defensive check: crypto.HexToECDSA rejects the string "0x..." so our +// fallback behaviour under non-broadcast keeps functioning even if a user +// copies their key with a prefix. +func TestEVMFamily_ResolveKey_PrefixedHex_FallsBackToSentinel(t *testing.T) { + t.Parallel() + f := newFamily() + s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: "0x" + validPK}} + stderr := captureStderr(t, func() { + got, err := f.ResolveKey(s, false) + require.NoError(t, err) + pk := got.(*ecdsa.PrivateKey) + require.Equal(t, 0, pk.D.Cmp(bigOne())) + }) + assert.Contains(t, stderr, "Using default private key") +} + +// --------------------------------------------------------------------------- +// Error type is standard error (not a sentinel) — ensures errors.Is behaviour. +// --------------------------------------------------------------------------- + +func TestEVMFamily_ResolveKey_BroadcastError_IsError(t *testing.T) { + t.Parallel() + f := newFamily() + s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: ""}} + _, err := f.ResolveKey(s, true) + require.Error(t, err) + require.NotNil(t, errors.Unwrap(err)) +} diff --git a/cmd/workflow/simulate/chain/evm/health_more_test.go b/cmd/workflow/simulate/chain/evm/health_more_test.go new file mode 100644 index 00000000..5454d0da --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/health_more_test.go @@ -0,0 +1,203 @@ +package evm + +import ( + "fmt" + "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" +) + +// --------------------------------------------------------------------------- +// Experimental selectors label — "experimental chain N" in error messages. +// --------------------------------------------------------------------------- + +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 := runRPCHealthCheck( + map[uint64]*ethclient.Client{expSel: c}, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "RPC health check failed", + "[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 := runRPCHealthCheck( + 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 := runRPCHealthCheck( + 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 := runRPCHealthCheck( + map[uint64]*ethclient.Client{ + selectorSepolia: cOK, + expSel: cErr, + }, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "RPC health check failed", + "[experimental chain 99999999] failed RPC health check", + ) + // sepolia is healthy; its label must not appear. + assert.NotContains(t, err.Error(), "[ethereum-testnet-sepolia] failed") +} + +func TestHealthCheck_MultipleOK_NoError(t *testing.T) { + sOK := newChainIDServer(t, "0xaa36a7") + defer sOK.Close() + cOK := newEthClient(t, sOK.URL) + defer cOK.Close() + + sOK2 := newChainIDServer(t, "0x1") + defer sOK2.Close() + cOK2 := newEthClient(t, sOK2.URL) + defer cOK2.Close() + + err := runRPCHealthCheck( + map[uint64]*ethclient.Client{ + selectorSepolia: cOK, + chainEthMainnet: cOK2, + }, + nil, + ) + require.NoError(t, err) +} + +const chainEthMainnet uint64 = 5009297550715157269 // ethereum-mainnet + +func TestHealthCheck_EmptyExperimentalMap_StillWorks(t *testing.T) { + sOK := newChainIDServer(t, "0x1") + defer sOK.Close() + c := newEthClient(t, sOK.URL) + defer c.Close() + + err := runRPCHealthCheck( + map[uint64]*ethclient.Client{selectorSepolia: c}, + map[uint64]bool{}, + ) + require.NoError(t, err) +} + +func TestHealthCheck_NilExperimentalMap_EquivalentToEmpty(t *testing.T) { + sOK := newChainIDServer(t, "0x1") + defer sOK.Close() + c := newEthClient(t, sOK.URL) + defer c.Close() + + err := runRPCHealthCheck( + map[uint64]*ethclient.Client{selectorSepolia: c}, + nil, + ) + require.NoError(t, err) +} + +// 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 TestRunRPCHealthCheck_WrapperFailsOnNonEthClient(t *testing.T) { + err := RunRPCHealthCheck( + map[uint64]chain.ChainClient{1: 42}, // int masquerading as client + nil, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for EVM family") +} + +func TestRunRPCHealthCheck_EmptyReturnsSettingsError(t *testing.T) { + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "check your settings") + assert.Contains(t, err.Error(), "no RPC URLs found for supported or experimental chains") +} + +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 := runRPCHealthCheck( + 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_delegation_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go new file mode 100644 index 00000000..8d416857 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go @@ -0,0 +1,352 @@ +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" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" +) + +// fullStubCapability extends the base stub with counters on every delegating +// method so we can verify the limiter passes calls through. +type fullStubCapability struct { + evmCapabilityBaseStub + calls map[string]int + + // optional return override + writeErr caperrors.Error + + closeErr error + startErr error +} + +func newFullStub() *fullStubCapability { + return &fullStubCapability{calls: map[string]int{}} +} + +func (s *fullStubCapability) CallContract(context.Context, commonCap.RequestMetadata, *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { + s.calls["CallContract"]++ + return &commonCap.ResponseAndMetadata[*evmcappb.CallContractReply]{}, nil +} + +func (s *fullStubCapability) FilterLogs(context.Context, commonCap.RequestMetadata, *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { + s.calls["FilterLogs"]++ + return &commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply]{}, nil +} + +func (s *fullStubCapability) BalanceAt(context.Context, commonCap.RequestMetadata, *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { + s.calls["BalanceAt"]++ + return &commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply]{}, nil +} + +func (s *fullStubCapability) EstimateGas(context.Context, commonCap.RequestMetadata, *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { + s.calls["EstimateGas"]++ + return &commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply]{}, nil +} + +func (s *fullStubCapability) GetTransactionByHash(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { + s.calls["GetTransactionByHash"]++ + return &commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply]{}, nil +} + +func (s *fullStubCapability) GetTransactionReceipt(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { + s.calls["GetTransactionReceipt"]++ + return &commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply]{}, nil +} + +func (s *fullStubCapability) HeaderByNumber(context.Context, commonCap.RequestMetadata, *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { + s.calls["HeaderByNumber"]++ + return &commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply]{}, nil +} + +func (s *fullStubCapability) RegisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { + s.calls["RegisterLogTrigger"]++ + return nil, nil +} + +func (s *fullStubCapability) UnregisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) caperrors.Error { + s.calls["UnregisterLogTrigger"]++ + return nil +} + +func (s *fullStubCapability) WriteReport(context.Context, commonCap.RequestMetadata, *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + s.calls["WriteReport"]++ + if s.writeErr != nil { + return nil, s.writeErr + } + return &commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply]{}, nil +} + +func (s *fullStubCapability) AckEvent(context.Context, string, string, string) caperrors.Error { + s.calls["AckEvent"]++ + return nil +} + +func (s *fullStubCapability) ChainSelector() uint64 { + s.calls["ChainSelector"]++ + return 42 +} + +// Override lifecycle / metadata to count calls. +func (s *fullStubCapability) Start(context.Context) error { + s.calls["Start"]++ + return s.startErr +} + +func (s *fullStubCapability) Close() error { + s.calls["Close"]++ + return s.closeErr +} + +func (s *fullStubCapability) HealthReport() map[string]error { + s.calls["HealthReport"]++ + return map[string]error{"ok": nil} +} + +func (s *fullStubCapability) Name() string { + s.calls["Name"]++ + return "stub-chain" +} + +func (s *fullStubCapability) Description() string { + s.calls["Description"]++ + return "stub-desc" +} + +func (s *fullStubCapability) Ready() error { + s.calls["Ready"]++ + return nil +} + +func (s *fullStubCapability) Initialise(context.Context, core.StandardCapabilitiesDependencies) error { + s.calls["Initialise"]++ + return nil +} + +// --------------------------------------------------------------------------- +// Table-driven: every method is called through the limiter exactly once. +// --------------------------------------------------------------------------- + +func TestLimitedEVMChain_AllMethodsDelegate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + call func(w *LimitedEVMChain, s *fullStubCapability) + wants string + }{ + {"CallContract", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.CallContract(context.Background(), commonCap.RequestMetadata{}, &evmcappb.CallContractRequest{}) + }, "CallContract"}, + {"FilterLogs", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.FilterLogs(context.Background(), commonCap.RequestMetadata{}, &evmcappb.FilterLogsRequest{}) + }, "FilterLogs"}, + {"BalanceAt", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.BalanceAt(context.Background(), commonCap.RequestMetadata{}, &evmcappb.BalanceAtRequest{}) + }, "BalanceAt"}, + {"EstimateGas", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.EstimateGas(context.Background(), commonCap.RequestMetadata{}, &evmcappb.EstimateGasRequest{}) + }, "EstimateGas"}, + {"GetTransactionByHash", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.GetTransactionByHash(context.Background(), commonCap.RequestMetadata{}, &evmcappb.GetTransactionByHashRequest{}) + }, "GetTransactionByHash"}, + {"GetTransactionReceipt", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.GetTransactionReceipt(context.Background(), commonCap.RequestMetadata{}, &evmcappb.GetTransactionReceiptRequest{}) + }, "GetTransactionReceipt"}, + {"HeaderByNumber", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.HeaderByNumber(context.Background(), commonCap.RequestMetadata{}, &evmcappb.HeaderByNumberRequest{}) + }, "HeaderByNumber"}, + {"RegisterLogTrigger", func(w *LimitedEVMChain, _ *fullStubCapability) { + _, _ = w.RegisterLogTrigger(context.Background(), "tid", commonCap.RequestMetadata{}, &evmcappb.FilterLogTriggerRequest{}) + }, "RegisterLogTrigger"}, + {"UnregisterLogTrigger", func(w *LimitedEVMChain, _ *fullStubCapability) { + _ = w.UnregisterLogTrigger(context.Background(), "tid", commonCap.RequestMetadata{}, &evmcappb.FilterLogTriggerRequest{}) + }, "UnregisterLogTrigger"}, + {"AckEvent", func(w *LimitedEVMChain, _ *fullStubCapability) { + _ = w.AckEvent(context.Background(), "tid", "eid", "m") + }, "AckEvent"}, + {"ChainSelector", func(w *LimitedEVMChain, s *fullStubCapability) { + require.Equal(t, uint64(42), w.ChainSelector()) + require.Equal(t, 1, s.calls["ChainSelector"]) + }, "ChainSelector"}, + {"Start", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Start(context.Background()) }, "Start"}, + {"Close", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Close() }, "Close"}, + {"HealthReport", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.HealthReport() }, "HealthReport"}, + {"Name", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Name() }, "Name"}, + {"Description", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Description() }, "Description"}, + {"Ready", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Ready() }, "Ready"}, + {"Initialise", func(w *LimitedEVMChain, _ *fullStubCapability) { + _ = w.Initialise(context.Background(), core.StandardCapabilitiesDependencies{}) + }, "Initialise"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + tt.call(w, stub) + assert.Equal(t, 1, stub.calls[tt.wants], "expected 1 call to %s", tt.wants) + }) + } +} + +// --------------------------------------------------------------------------- +// WriteReport policy edge cases +// --------------------------------------------------------------------------- + +func TestLimitedEVMChain_WriteReport_NilReport_Delegates(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 4}) + resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{Report: nil}) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 1, stub.calls["WriteReport"]) +} + +func TestLimitedEVMChain_WriteReport_NilGasConfig_Delegates(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 100}) + resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{GasConfig: nil}) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 1, stub.calls["WriteReport"]) +} + +func TestLimitedEVMChain_WriteReport_ZeroReportLimit_Disabled(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 0}) + resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: make([]byte, 1<<20)}, + }) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 1, stub.calls["WriteReport"]) +} + +func TestLimitedEVMChain_WriteReport_ZeroGasLimit_Disabled(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 0}) + resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + GasConfig: &evmcappb.GasConfig{GasLimit: 1 << 30}, + }) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 1, stub.calls["WriteReport"]) +} + +func TestLimitedEVMChain_WriteReport_GasBoundaryEqualsLimit_Delegates(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 1000}) + resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + GasConfig: &evmcappb.GasConfig{GasLimit: 1000}, + }) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +func TestLimitedEVMChain_WriteReport_ReportBoundaryEqualsLimit_Delegates(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 5}) + resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, + }) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +func TestLimitedEVMChain_WriteReport_ReportOneOverLimit_Rejects(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 5}) + _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("123456")}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "6 bytes exceeds limit of 5 bytes") + assert.Equal(t, 0, stub.calls["WriteReport"]) +} + +func TestLimitedEVMChain_WriteReport_GasOneOverLimit_Rejects(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 1_000_000}) + _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + GasConfig: &evmcappb.GasConfig{GasLimit: 1_000_001}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "1000001 exceeds maximum of 1000000") + assert.Equal(t, 0, stub.calls["WriteReport"]) +} + +func TestLimitedEVMChain_WriteReport_ReportCheckedBeforeGas(t *testing.T) { + // When both fail, report-size error is surfaced first. + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 1, gasLimit: 1}) + _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("ab")}, + GasConfig: &evmcappb.GasConfig{GasLimit: 999}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "chain write report size") + assert.NotContains(t, err.Error(), "EVM gas limit") +} + +func TestLimitedEVMChain_WriteReport_ReturnsResourceExhaustedCode(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 1}) + _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("too-big")}, + }) + require.Error(t, err) + assert.Equal(t, caperrors.ResourceExhausted, err.Code()) +} + +func TestLimitedEVMChain_Constructor_StoresInnerAndLimits(t *testing.T) { + t.Parallel() + stub := newFullStub() + limits := &EVMChainLimit{reportSizeLimit: 7, gasLimit: 11} + w := NewLimitedEVMChain(stub, limits) + require.NotNil(t, w) + // Verify Description delegates (indirectly proves inner is stored) + require.Equal(t, "stub-desc", w.Description()) +} + +func TestLimitedEVMChain_ChainSelector_ReflectsInner(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + require.Equal(t, uint64(42), w.ChainSelector()) +} + +func TestLimitedEVMChain_Name_ReflectsInner(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + require.Equal(t, "stub-chain", w.Name()) +} + +func TestLimitedEVMChain_HealthReport_ReflectsInner(t *testing.T) { + t.Parallel() + stub := newFullStub() + w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + hr := w.HealthReport() + _, ok := hr["ok"] + require.True(t, ok) +} 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..fdd7886d --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/supported_chains_test.go @@ -0,0 +1,143 @@ +package evm + +import ( + "regexp" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// 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_NotEmpty(t *testing.T) { + t.Parallel() + require.NotEmpty(t, SupportedChains) + require.Greater(t, len(SupportedChains), 20) +} + +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_AllForwardersDecodableAsAddress(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + addr := common.HexToAddress(c.Forwarder) + assert.NotEqual(t, common.Address{}, addr, + "selector %d: forwarder decodes to zero address", c.Selector) + } +} + +func TestSupportedChains_AllForwardersLowercasedOrChecksummed(t *testing.T) { + // Not enforcing checksum specifically — only ensuring HexToAddress normalises + // to a canonical form. + t.Parallel() + for _, c := range SupportedChains { + // If user writes uppercase hex, HexToAddress still accepts it. + norm := common.HexToAddress(c.Forwarder).Hex() + require.NotEmpty(t, norm) + } +} + +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_EthereumMainnetPresent(t *testing.T) { + t.Parallel() + found := false + for _, c := range SupportedChains { + if c.Selector == chainselectors.ETHEREUM_MAINNET.Selector { + found = true + assert.NotEmpty(t, c.Forwarder) + break + } + } + require.True(t, found, "ethereum mainnet must be in SupportedChains") +} + +func TestSupportedChains_SepoliaPresent(t *testing.T) { + t.Parallel() + found := false + for _, c := range SupportedChains { + if c.Selector == chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector { + found = true + assert.Equal(t, strings.ToLower("0x15fC6ae953E024d975e77382eEeC56A9101f9F88"), + strings.ToLower(c.Forwarder), "sepolia forwarder must match known value") + break + } + } + require.True(t, found, "ethereum-testnet-sepolia must be in SupportedChains") +} + +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_ReturnedByFamily(t *testing.T) { + t.Parallel() + f := newFamily() + 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) + } +} + +func TestSupportedChains_AllForwardersAre20Bytes(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + b := common.FromHex(c.Forwarder) + assert.Len(t, b, 20, "selector %d forwarder not 20 bytes: %q", c.Selector, c.Forwarder) + } +} + +func TestChainConfigType_ImplementedCorrectly(t *testing.T) { + t.Parallel() + cfg := chain.ChainConfig{Selector: 1, Forwarder: "0x" + strings.Repeat("a", 40)} + assert.Equal(t, uint64(1), cfg.Selector) + assert.Len(t, cfg.Forwarder, 42) +} diff --git a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go new file mode 100644 index 00000000..7a1e124f --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go @@ -0,0 +1,378 @@ +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" + +// --------------------------------------------------------------------------- +// GetEVMTriggerLogFromValues — validation table. +// --------------------------------------------------------------------------- + +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 { + tt := tt + 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) + }) + } +} + +// --------------------------------------------------------------------------- +// fetchAndConvertLog + GetEVMTriggerLogFromValues via mock eth_getTransactionReceipt. +// --------------------------------------------------------------------------- + +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 + + stdout := captureStdout(t, func() {}) + _ = stdout + 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) +} + +// --------------------------------------------------------------------------- +// ParseTriggerChainSelector — additional coverage. +// --------------------------------------------------------------------------- + +func TestParseTriggerChainSelector_AdditionalEdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + want uint64 + ok bool + }{ + {"zero selector", "evm:ChainSelector:0@1.0.0", 0, true}, + {"max uint64", "evm:ChainSelector:18446744073709551615@1.0.0", 18446744073709551615, true}, + {"max uint64 plus one", "evm:ChainSelector:18446744073709551616@1.0.0", 0, false}, + {"negative not parsed", "evm:ChainSelector:-1@1.0.0", 0, false}, + {"scientific notation not parsed", "evm:ChainSelector:1e5@1.0.0", 1, true}, + {"hex prefix rejected", "evm:ChainSelector:0x10@1.0.0", 0, true}, + {"ChainSelector without colon", "evm:ChainSelector123@1.0.0", 0, false}, + {"mixed case CHAINselector", "evm:CHAINselector:123@1.0.0", 123, true}, + {"empty string", "", 0, false}, + {"only prefix", "evm:ChainSelector:", 0, false}, + {"unicode digits rejected", "evm:ChainSelector:123@1.0.0", 0, false}, + {"tab before number", "evm:ChainSelector:\t42@1.0.0", 0, false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + 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) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Regex directly — defensive coverage. +// --------------------------------------------------------------------------- + +func TestChainSelectorRegex_Matches(t *testing.T) { + t.Parallel() + matches := chainSelectorRe.FindAllStringSubmatch("ChainSelector:1 chainselector:2 CHAINSELECTOR:3", -1) + require.Len(t, matches, 3) + assert.Equal(t, "1", matches[0][1]) + assert.Equal(t, "2", matches[1][1]) + assert.Equal(t, "3", matches[2][1]) +} + +func TestChainSelectorRegex_NoMatch(t *testing.T) { + t.Parallel() + assert.Nil(t, chainSelectorRe.FindStringSubmatch("just-a-string")) + assert.Nil(t, chainSelectorRe.FindStringSubmatch("")) +} + +// --------------------------------------------------------------------------- +// Non-0x rejection fast — doesn't touch RPC. +// --------------------------------------------------------------------------- + +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") +} + +// --------------------------------------------------------------------------- +// Zero-address log still decodes. +// --------------------------------------------------------------------------- + +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 +} From 329c9ad37128b6ad9156e941f011f0357f86c6e9 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Wed, 15 Apr 2026 22:45:55 +0100 Subject: [PATCH 04/26] refactor: decouple TriggerParams from EVM-specific fields Replace EVMTxHash/EVMEventIndex on TriggerParams with a generic FamilyInputs string map so each chain family owns its input keys. Unify EVMChainLimits as a type alias for chain.Limits, removing the redundant interface and the type assertion in RegisterCapabilities. Guard against the typed-nil interface trap when boxing SimulationLimits. --- cmd/workflow/simulate/chain/evm/family.go | 31 +++++++++++-------- .../simulate/chain/evm/family_test.go | 20 +++++++----- .../chain/evm/limited_capabilities.go | 12 +++---- cmd/workflow/simulate/chain/types.go | 22 ++++++++----- cmd/workflow/simulate/simulate.go | 25 ++++++++++++--- 5 files changed, 71 insertions(+), 39 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/family.go b/cmd/workflow/simulate/chain/evm/family.go index 82552ce8..884ec521 100644 --- a/cmd/workflow/simulate/chain/evm/family.go +++ b/cmd/workflow/simulate/chain/evm/family.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "fmt" "math/big" + "strconv" "strings" "github.com/ethereum/go-ethereum/common" @@ -146,22 +147,14 @@ func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.Capabili } } - // Type-assert the limits (SimulationLimits satisfies EVMChainLimits implicitly) - var evmLimits EVMChainLimits - if cfg.Limits != nil { - var ok bool - evmLimits, ok = cfg.Limits.(EVMChainLimits) - if !ok { - return nil, fmt.Errorf("EVM family: limits does not satisfy EVMChainLimits interface") - } - } - dryRun := !cfg.Broadcast + // cfg.Limits already satisfies EVMChainLimits via the chain.Limits interface + // contract; no type assertion needed. evmCaps, err := NewEVMChainCapabilities( ctx, cfg.Logger, cfg.Registry, ethClients, evmForwarders, pk, - dryRun, evmLimits, + dryRun, cfg.Limits, ) if err != nil { return nil, err @@ -236,6 +229,12 @@ func (f *EVMFamily) ResolveKey(creSettings *settings.Settings, broadcast bool) ( return pk, nil } +// CLI input keys consumed from chain.TriggerParams.FamilyInputs. +const ( + TriggerInputTxHash = "evm-tx-hash" + TriggerInputEventIndex = "evm-event-index" +) + // ResolveTriggerData fetches the EVM log payload for the given selector from // CLI-supplied or interactively-prompted inputs. func (f *EVMFamily) ResolveTriggerData(ctx context.Context, selector uint64, params chain.TriggerParams) (interface{}, error) { @@ -252,8 +251,14 @@ func (f *EVMFamily) ResolveTriggerData(ctx context.Context, selector uint64, par return GetEVMTriggerLog(ctx, client) } - if strings.TrimSpace(params.EVMTxHash) == "" || params.EVMEventIndex < 0 { + txHash := strings.TrimSpace(params.FamilyInputs[TriggerInputTxHash]) + eventIndexStr := strings.TrimSpace(params.FamilyInputs[TriggerInputEventIndex]) + if txHash == "" || eventIndexStr == "" { return nil, fmt.Errorf("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") } - return GetEVMTriggerLogFromValues(ctx, client, params.EVMTxHash, uint64(params.EVMEventIndex)) // #nosec G115 -- EVMEventIndex validated >= 0 above + 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/family_test.go b/cmd/workflow/simulate/chain/evm/family_test.go index aa22f80b..36d31c89 100644 --- a/cmd/workflow/simulate/chain/evm/family_test.go +++ b/cmd/workflow/simulate/chain/evm/family_test.go @@ -271,10 +271,12 @@ func TestEVMFamily_ResolveTriggerData_NoClient(t *testing.T) { t.Parallel() f := newFamily() _, err := f.ResolveTriggerData(context.Background(), 777, chain.TriggerParams{ - Clients: map[uint64]chain.ChainClient{}, - Interactive: false, - EVMTxHash: "0x" + strings.Repeat("a", 64), - EVMEventIndex: 0, + Clients: map[uint64]chain.ChainClient{}, + Interactive: false, + FamilyInputs: 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") @@ -284,10 +286,12 @@ func TestEVMFamily_ResolveTriggerData_WrongClientType(t *testing.T) { t.Parallel() f := newFamily() _, err := f.ResolveTriggerData(context.Background(), 1, chain.TriggerParams{ - Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, - Interactive: false, - EVMTxHash: "0x" + strings.Repeat("a", 64), - EVMEventIndex: 0, + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Interactive: false, + FamilyInputs: 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") diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go index 1f7d6ab6..9499a357 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -9,14 +9,14 @@ import ( 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 defines the limit accessors needed by LimitedEVMChain. -// The SimulationLimits type from the parent package satisfies this interface. -type EVMChainLimits interface { - ChainWriteReportSizeLimit() int - ChainWriteEVMGasLimit() uint64 -} +// EVMChainLimits is the limit-accessor contract LimitedEVMChain enforces. +// Aliased to chain.Limits so the family-agnostic CapabilityConfig.Limits +// value can be passed straight through without a type assertion. +type EVMChainLimits = chain.Limits // LimitedEVMChain wraps an evmserver.ClientCapability and enforces chain write // report size and gas limits. diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go index 11e14ecf..26e28e5c 100644 --- a/cmd/workflow/simulate/chain/types.go +++ b/cmd/workflow/simulate/chain/types.go @@ -15,6 +15,15 @@ type ChainConfig struct { Forwarder string // family-specific forwarding address } +// Limits exposes the chain-write limit accessors a family needs at +// capability-registration time. Implementations live in the parent simulate +// package; this interface is defined here so CapabilityConfig stays in the +// chain package without an import cycle. +type Limits interface { + ChainWriteReportSizeLimit() int + ChainWriteEVMGasLimit() uint64 +} + // CapabilityConfig holds everything a family needs to register capabilities. type CapabilityConfig struct { Registry *capabilities.Registry @@ -22,16 +31,15 @@ type CapabilityConfig struct { Forwarders map[uint64]string PrivateKey interface{} // family-specific key type; EVM uses *ecdsa.PrivateKey Broadcast bool - Limits interface{} // *SimulationLimits from parent package, or nil + Limits Limits // nil disables limit enforcement Logger logger.Logger } // TriggerParams carries family-agnostic inputs needed to resolve trigger data -// for a given chain trigger. Family-specific fields are ignored by families -// that don't need them. +// for a given chain trigger. FamilyInputs is a free-form bag of CLI-supplied +// strings; each family interprets the keys it knows about and ignores the rest. type TriggerParams struct { - Clients map[uint64]ChainClient - Interactive bool - EVMTxHash string - EVMEventIndex int + Clients map[uint64]ChainClient + Interactive bool + FamilyInputs map[string]string } diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index ce5a5075..c86ca794 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/signal" + "strconv" "strings" "syscall" "time" @@ -448,6 +449,13 @@ func run( os.Exit(1) } + // 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-family-specific capabilities for name, family := range chain.All() { clients, ok := inputs.FamilyClients[name] @@ -461,7 +469,7 @@ func run( Forwarders: inputs.FamilyForwarders[name], PrivateKey: inputs.FamilyKeys[name], Broadcast: inputs.Broadcast, - Limits: simLimits, + Limits: capLimits, Logger: triggerLggr, }) if err != nil { @@ -899,11 +907,18 @@ func getTriggerDataForFamily(ctx context.Context, family chain.ChainFamily, sele if !ok { return nil, fmt.Errorf("no %s clients configured", family.Name()) } + // Keys must match the constants the receiving family reads (e.g. evm.TriggerInputTxHash). + familyInputs := map[string]string{} + if strings.TrimSpace(inputs.EVMTxHash) != "" { + familyInputs["evm-tx-hash"] = inputs.EVMTxHash + } + if inputs.EVMEventIndex >= 0 { + familyInputs["evm-event-index"] = strconv.Itoa(inputs.EVMEventIndex) + } return family.ResolveTriggerData(ctx, selector, chain.TriggerParams{ - Clients: clients, - Interactive: interactive, - EVMTxHash: inputs.EVMTxHash, - EVMEventIndex: inputs.EVMEventIndex, + Clients: clients, + Interactive: interactive, + FamilyInputs: familyInputs, }) } From b9a4f581aa06738335cf238a75b113ab847c9151 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 10:36:27 +0100 Subject: [PATCH 05/26] refactor: decouple CLI flags from simulator core into chain family registry Chain families now own their CLI flag definitions and input collection, removing all EVM-specific knowledge from simulate.go. Fixes a potential deadlock in registry.Get() by extracting namesLocked() helper, and guards ResolveKey to skip families without configured clients. --- cmd/workflow/simulate/chain/evm/family.go | 14 +++ .../simulate/chain/evm/family_test.go | 55 +++++++++++ cmd/workflow/simulate/chain/registry.go | 95 ++++++++++++++++--- cmd/workflow/simulate/chain/registry_test.go | 76 ++++++++++++++- cmd/workflow/simulate/simulate.go | 52 +++++----- 5 files changed, 247 insertions(+), 45 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/family.go b/cmd/workflow/simulate/chain/evm/family.go index 884ec521..0eae7e9e 100644 --- a/cmd/workflow/simulate/chain/evm/family.go +++ b/cmd/workflow/simulate/chain/evm/family.go @@ -27,6 +27,9 @@ const defaultSentinelPrivateKey = "000000000000000000000000000000000000000000000 func init() { chain.Register("evm", func(lggr *zerolog.Logger) chain.ChainFamily { return &EVMFamily{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}, }) } @@ -235,6 +238,17 @@ const ( TriggerInputEventIndex = "evm-event-index" ) +func (f *EVMFamily) 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 (f *EVMFamily) ResolveTriggerData(ctx context.Context, selector uint64, params chain.TriggerParams) (interface{}, error) { diff --git a/cmd/workflow/simulate/chain/evm/family_test.go b/cmd/workflow/simulate/chain/evm/family_test.go index 36d31c89..43079dce 100644 --- a/cmd/workflow/simulate/chain/evm/family_test.go +++ b/cmd/workflow/simulate/chain/evm/family_test.go @@ -14,6 +14,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/rs/zerolog" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -527,3 +528,57 @@ func TestEVMFamily_ResolveKey_BroadcastError_IsError(t *testing.T) { require.Error(t, err) require.NotNil(t, errors.Unwrap(err)) } + +// --------------------------------------------------------------------------- +// CollectCLIInputs +// --------------------------------------------------------------------------- + +func TestEVMFamily_CollectCLIInputs_BothSet(t *testing.T) { + t.Parallel() + f := newFamily() + v := viper.New() + v.Set("evm-tx-hash", "0xabc123") + v.Set("evm-event-index", 2) + + result := f.CollectCLIInputs(v) + assert.Equal(t, "0xabc123", result[TriggerInputTxHash]) + assert.Equal(t, "2", result[TriggerInputEventIndex]) +} + +func TestEVMFamily_CollectCLIInputs_NegativeIndexOmitted(t *testing.T) { + t.Parallel() + f := newFamily() + v := viper.New() + v.Set("evm-tx-hash", "0xabc") + v.Set("evm-event-index", -1) + + result := f.CollectCLIInputs(v) + assert.Equal(t, "0xabc", result[TriggerInputTxHash]) + _, hasIndex := result[TriggerInputEventIndex] + assert.False(t, hasIndex, "negative index should be omitted") +} + +func TestEVMFamily_CollectCLIInputs_EmptyTxHashOmitted(t *testing.T) { + t.Parallel() + f := newFamily() + v := viper.New() + v.Set("evm-tx-hash", "") + v.Set("evm-event-index", 0) + + result := f.CollectCLIInputs(v) + _, hasTx := result[TriggerInputTxHash] + assert.False(t, hasTx, "empty tx hash should be omitted") + assert.Equal(t, "0", result[TriggerInputEventIndex]) +} + +func TestEVMFamily_CollectCLIInputs_DefaultsOnly(t *testing.T) { + t.Parallel() + f := newFamily() + 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 := f.CollectCLIInputs(v) + assert.Empty(t, result) +} diff --git a/cmd/workflow/simulate/chain/registry.go b/cmd/workflow/simulate/chain/registry.go index c4739a4a..c505943f 100644 --- a/cmd/workflow/simulate/chain/registry.go +++ b/cmd/workflow/simulate/chain/registry.go @@ -4,9 +4,11 @@ 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" @@ -67,24 +69,50 @@ type ChainFamily interface { // SupportedChains returns the list of chains this family supports // out of the box (for display/documentation purposes). SupportedChains() []ChainConfig + + // CollectCLIInputs reads this family's CLI flags from viper and + // returns them as key-value pairs for TriggerParams.FamilyInputs. + CollectCLIInputs(v *viper.Viper) map[string]string +} + +// CLIFlagDef describes a CLI flag a chain family 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 - factories = make(map[string]Factory) - families = make(map[string]ChainFamily) + mu sync.RWMutex + registrations = make(map[string]registration) + families = make(map[string]ChainFamily) ) -// Register adds a chain family factory to the registry. -// Called from family package init(); the factory is invoked later in Build(). -// Panics on duplicate registration (programming error). -func Register(name string, factory Factory) { +// Register adds a chain family factory and its CLI flag definitions to the +// registry. Called from family 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 := factories[name]; exists { + if _, exists := registrations[name]; exists { panic(fmt.Sprintf("chain family %q already registered", name)) } - factories[name] = factory + registrations[name] = registration{factory: factory, flagDefs: flagDefs} } // Build instantiates every registered family with the given logger. @@ -93,8 +121,8 @@ func Register(name string, factory Factory) { func Build(lggr *zerolog.Logger) { mu.Lock() defer mu.Unlock() - for name, factory := range factories { - families[name] = factory(lggr) + for name, reg := range registrations { + families[name] = reg.factory(lggr) } } @@ -104,7 +132,7 @@ func Get(name string) (ChainFamily, error) { defer mu.RUnlock() f, ok := families[name] if !ok { - return nil, fmt.Errorf("unknown chain family %q; registered: %v", name, Names()) + return nil, fmt.Errorf("unknown chain family %q; registered: %v", name, namesLocked()) } return f, nil } @@ -120,10 +148,42 @@ func All() map[string]ChainFamily { return result } -// Names returns sorted registered family names. -func Names() []string { +// RegisterAllCLIFlags registers CLI flags from every registered family'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 family. +func CollectAllCLIInputs(v *viper.Viper) map[string]string { + result := map[string]string{} + for _, family := range All() { + for k, v := range family.CollectCLIInputs(v) { + result[k] = v + } + } + return result +} + +// namesLocked returns sorted family names. Caller must hold mu. +func namesLocked() []string { names := make([]string, 0, len(families)) for k := range families { names = append(names, k) @@ -131,3 +191,10 @@ func Names() []string { sort.Strings(names) return names } + +// Names returns sorted registered family 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 index f35402cb..cb595a77 100644 --- a/cmd/workflow/simulate/chain/registry_test.go +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/rs/zerolog" + "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -18,7 +19,7 @@ import ( func resetRegistry() { mu.Lock() defer mu.Unlock() - factories = make(map[string]Factory) + registrations = make(map[string]registration) families = make(map[string]ChainFamily) } @@ -83,6 +84,12 @@ func (m *mockChainFamily) ResolveTriggerData(ctx context.Context, selector uint6 return args.Get(0), args.Error(1) } +func (m *mockChainFamily) CollectCLIInputs(v *viper.Viper) map[string]string { + args := m.Called(v) + result, _ := args.Get(0).(map[string]string) + return result +} + func newMockFamily(name string) *mockChainFamily { f := new(mockChainFamily) f.On("Name").Return(name) @@ -92,7 +99,7 @@ func newMockFamily(name string) *mockChainFamily { // registerMock registers a pre-built mock family and immediately builds it so // tests can exercise Get/All/Names without wiring a real logger. func registerMock(name string, family ChainFamily) { - Register(name, func(*zerolog.Logger) ChainFamily { return family }) + Register(name, func(*zerolog.Logger) ChainFamily { return family }, nil) Build(nil) } @@ -166,6 +173,71 @@ func TestGetErrorIncludesRegisteredNames(t *testing.T) { assert.Contains(t, err.Error(), "evm") } +func TestRegisterAllCLIFlags_StringAndInt(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register("test", func(*zerolog.Logger) ChainFamily { return newMockFamily("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 TestRegisterAllCLIFlags_NilFlagDefs(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register("test", func(*zerolog.Logger) ChainFamily { return newMockFamily("test") }, nil) + + cmd := &cobra.Command{Use: "test"} + RegisterAllCLIFlags(cmd) // should not panic +} + +func TestCollectAllCLIInputs_MergesAcrossFamilies(t *testing.T) { + resetRegistry() + defer resetRegistry() + + fam1 := newMockFamily("alpha") + fam1.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-a": "val-a"}) + registerMock("alpha", fam1) + + fam2 := newMockFamily("beta") + fam2.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-b": "val-b"}) + registerMock("beta", fam2) + + v := viper.New() + result := CollectAllCLIInputs(v) + + assert.Equal(t, "val-a", result["key-a"]) + assert.Equal(t, "val-b", result["key-b"]) +} + +func TestCollectAllCLIInputs_EmptyWhenNoInputs(t *testing.T) { + resetRegistry() + defer resetRegistry() + + fam := newMockFamily("empty") + fam.On("CollectCLIInputs", mock.Anything).Return(map[string]string{}) + registerMock("empty", fam) + + v := viper.New() + result := CollectAllCLIInputs(v) + assert.Empty(t, result) +} + func TestAllReturnsCopy(t *testing.T) { resetRegistry() defer resetRegistry() diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index c86ca794..c7503489 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -7,7 +7,6 @@ import ( "fmt" "os" "os/signal" - "strconv" "strings" "syscall" "time" @@ -55,11 +54,10 @@ type Inputs struct { FamilyForwarders map[string]map[uint64]string `validate:"-"` FamilyKeys map[string]interface{} `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:"-"` + NonInteractive bool `validate:"-"` + TriggerIndex int `validate:"-"` + HTTPPayload string `validate:"-"` // JSON string or @/path/to/file.json + FamilyInputs map[string]string `validate:"-"` // CLI-supplied family-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. @@ -99,8 +97,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-family-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 @@ -152,6 +152,9 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) broadcast := v.GetBool("broadcast") for name, family := range chain.All() { + if _, ok := familyClients[name]; !ok { + continue // no clients for this family; skip key resolution + } key, err := family.ResolveKey(creSettings, broadcast) if err != nil { return Inputs{}, err @@ -175,8 +178,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) 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"), + FamilyInputs: chain.CollectAllCLIInputs(v), LimitsPath: v.GetString("limits"), SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), }, nil @@ -712,14 +714,14 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs continue } - triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, true) - if err != nil { - ui.Error(err.Error()) + if !family.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) os.Exit(1) } - if !family.HasSelector(sel) { - ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) + triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, true) + if err != nil { + ui.Error(err.Error()) os.Exit(1) } @@ -792,14 +794,14 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp continue } - triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, false) - if err != nil { - ui.Error(err.Error()) + if !family.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) os.Exit(1) } - if !family.HasSelector(sel) { - ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) + triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, false) + if err != nil { + ui.Error(err.Error()) os.Exit(1) } @@ -907,18 +909,10 @@ func getTriggerDataForFamily(ctx context.Context, family chain.ChainFamily, sele if !ok { return nil, fmt.Errorf("no %s clients configured", family.Name()) } - // Keys must match the constants the receiving family reads (e.g. evm.TriggerInputTxHash). - familyInputs := map[string]string{} - if strings.TrimSpace(inputs.EVMTxHash) != "" { - familyInputs["evm-tx-hash"] = inputs.EVMTxHash - } - if inputs.EVMEventIndex >= 0 { - familyInputs["evm-event-index"] = strconv.Itoa(inputs.EVMEventIndex) - } return family.ResolveTriggerData(ctx, selector, chain.TriggerParams{ Clients: clients, Interactive: interactive, - FamilyInputs: familyInputs, + FamilyInputs: inputs.FamilyInputs, }) } From bf0e64c2996e22271396b2e7c8f12742c4517536 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 11:38:57 +0100 Subject: [PATCH 06/26] refactor: rename ChainWriteEVMGasLimit to ChainWriteGasLimit Drop EVM prefix from the chain.Limits interface method to better reflect the generic abstraction layer. --- cmd/workflow/simulate/chain/evm/limited_capabilities.go | 2 +- .../simulate/chain/evm/limited_capabilities_test.go | 2 +- cmd/workflow/simulate/chain/types.go | 2 +- cmd/workflow/simulate/limits.go | 4 ++-- cmd/workflow/simulate/limits_test.go | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go index 9499a357..d97ae4fe 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -42,7 +42,7 @@ func (l *LimitedEVMChain) WriteReport(ctx context.Context, metadata commonCap.Re } // Check gas limit - gasLimit := l.limits.ChainWriteEVMGasLimit() + 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), diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go index 81420879..e2d84ef2 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -21,7 +21,7 @@ type EVMChainLimit struct { } func (s *EVMChainLimit) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } -func (s *EVMChainLimit) ChainWriteEVMGasLimit() uint64 { return s.gasLimit } +func (s *EVMChainLimit) ChainWriteGasLimit() uint64 { return s.gasLimit } type evmCapabilityBaseStub struct{} diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go index 26e28e5c..1c2fdb12 100644 --- a/cmd/workflow/simulate/chain/types.go +++ b/cmd/workflow/simulate/chain/types.go @@ -21,7 +21,7 @@ type ChainConfig struct { // chain package without an import cycle. type Limits interface { ChainWriteReportSizeLimit() int - ChainWriteEVMGasLimit() uint64 + ChainWriteGasLimit() uint64 } // CapabilityConfig holds everything a family needs to register capabilities. diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index 4feffa84..6cba2712 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -123,8 +123,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) From 872363d506c77eb06cb4859aa8a46b24f9b49150 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 12:42:18 +0100 Subject: [PATCH 07/26] fix: suppress ui.Success messages on non-interactive trigger path fetchAndConvertLog now takes a verbose flag. Interactive mode emits ui.Success messages as before; non-interactive mode stays silent, matching the original behavior. --- cmd/workflow/simulate/chain/evm/trigger.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/trigger.go b/cmd/workflow/simulate/chain/evm/trigger.go index e3beac61..ecfac13d 100644 --- a/cmd/workflow/simulate/chain/evm/trigger.go +++ b/cmd/workflow/simulate/chain/evm/trigger.go @@ -90,10 +90,12 @@ func GetEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evmpb. return nil, fmt.Errorf("invalid event index: %w", err) } - return fetchAndConvertLog(ctx, ethClient, txHash, eventIndex) + 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 == "" { @@ -107,11 +109,12 @@ func GetEVMTriggerLogFromValues(ctx context.Context, ethClient *ethclient.Client } txHash := common.HexToHash(txHashStr) - return fetchAndConvertLog(ctx, ethClient, txHash, eventIndex) + return fetchAndConvertLog(ctx, ethClient, txHash, eventIndex, false) } // fetchAndConvertLog fetches a transaction receipt log and converts it to the protobuf format. -func fetchAndConvertLog(ctx context.Context, ethClient *ethclient.Client, txHash common.Hash, eventIndex uint64) (*evmpb.Log, error) { +// 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) @@ -124,7 +127,9 @@ func fetchAndConvertLog(ctx context.Context, ethClient *ethclient.Client, txHash } 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))) + 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 { @@ -154,6 +159,8 @@ func fetchAndConvertLog(ctx context.Context, ethClient *ethclient.Client, txHash pbLog.EventSig = log.Topics[0].Bytes() } - ui.Success(fmt.Sprintf("Created EVM trigger log for transaction %s, event %d", txHash.Hex(), eventIndex)) + if verbose { + ui.Success(fmt.Sprintf("Created EVM trigger log for transaction %s, event %d", txHash.Hex(), eventIndex)) + } return pbLog, nil } From 458329330a0133d258877742e71c4fd513229149 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 13:25:51 +0100 Subject: [PATCH 08/26] refactor: rename ChainFamily to ChainType throughout simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the chain abstraction from "family" to "type" for clearer domain language. EVM is a chain type, not a chain family. - ChainFamily interface → ChainType - EVMFamily struct → EVMChainType - family.go → chaintype.go (file rename) - FamilyClients/Forwarders/Keys/Inputs → ChainTypeClients/etc - getTriggerDataForFamily → getTriggerDataForChainType - Import alias chaintype → corekeys (conflict avoidance) - All comments, error strings, test names updated --- cmd/workflow/simulate/capabilities.go | 6 +- .../chain/evm/{family.go => chaintype.go} | 74 ++++---- .../evm/{family_test.go => chaintype_test.go} | 178 +++++++++--------- cmd/workflow/simulate/chain/evm/health.go | 2 +- .../simulate/chain/evm/health_more_test.go | 2 +- .../simulate/chain/evm/health_test.go | 2 +- .../chain/evm/limited_capabilities.go | 2 +- .../chain/evm/supported_chains_test.go | 4 +- cmd/workflow/simulate/chain/registry.go | 86 ++++----- cmd/workflow/simulate/chain/registry_test.go | 98 +++++----- cmd/workflow/simulate/chain/types.go | 24 +-- cmd/workflow/simulate/simulate.go | 140 +++++++------- 12 files changed, 309 insertions(+), 309 deletions(-) rename cmd/workflow/simulate/chain/evm/{family.go => chaintype.go} (73%) rename cmd/workflow/simulate/chain/evm/{family_test.go => chaintype_test.go} (75%) diff --git a/cmd/workflow/simulate/capabilities.go b/cmd/workflow/simulate/capabilities.go index 58d917b1..2312317c 100644 --- a/cmd/workflow/simulate/capabilities.go +++ b/cmd/workflow/simulate/capabilities.go @@ -5,7 +5,7 @@ import ( "github.com/ethereum/go-ethereum/common" - 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" @@ -25,7 +25,7 @@ type ManualTriggers struct { } // NewManualTriggerCapabilities creates and registers cron and HTTP trigger capabilities. -// These are chain-agnostic and shared across all chain families. +// 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) @@ -84,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/family.go b/cmd/workflow/simulate/chain/evm/chaintype.go similarity index 73% rename from cmd/workflow/simulate/chain/evm/family.go rename to cmd/workflow/simulate/chain/evm/chaintype.go index 0eae7e9e..9814b5f2 100644 --- a/cmd/workflow/simulate/chain/evm/family.go +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -25,30 +25,30 @@ import ( const defaultSentinelPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001" func init() { - chain.Register("evm", func(lggr *zerolog.Logger) chain.ChainFamily { - return &EVMFamily{log: lggr} + chain.Register("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}, }) } -// EVMFamily implements chain.ChainFamily for EVM-based blockchains. -type EVMFamily struct { +// EVMChainType implements chain.ChainType for EVM-based blockchains. +type EVMChainType struct { log *zerolog.Logger evmChains *EVMChainCapabilities experimentalSelectors map[uint64]bool } -var _ chain.ChainFamily = (*EVMFamily)(nil) +var _ chain.ChainType = (*EVMChainType)(nil) -func (f *EVMFamily) Name() string { return "evm" } +func (ct *EVMChainType) Name() string { return "evm" } -func (f *EVMFamily) SupportedChains() []chain.ChainConfig { +func (ct *EVMChainType) SupportedChains() []chain.ChainConfig { return SupportedChains } -func (f *EVMFamily) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient, map[uint64]string, error) { +func (ct *EVMChainType) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient, map[uint64]string, error) { clients := make(map[uint64]chain.ChainClient) forwarders := make(map[uint64]string) experimental := make(map[uint64]bool) @@ -57,15 +57,15 @@ func (f *EVMFamily) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient for _, ch := range SupportedChains { chainName, err := settings.GetChainNameByChainSelector(ch.Selector) if err != nil { - f.log.Error().Msgf("Invalid chain selector for supported EVM chains %d; skipping", ch.Selector) + 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) == "" { - f.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) + ct.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) continue } - f.log.Debug().Msgf("Using RPC for %s: %s", chainName, chain.RedactURL(rpcURL)) + ct.log.Debug().Msgf("Using RPC for %s: %s", chainName, chain.RedactURL(rpcURL)) c, err := ethclient.Dial(rpcURL) if err != nil { @@ -103,12 +103,12 @@ func (f *EVMFamily) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient ec.ChainSelector, forwarders[ec.ChainSelector], ec.Forwarder)) forwarders[ec.ChainSelector] = ec.Forwarder } else { - f.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") + ct.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") } continue } - f.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, chain.RedactURL(ec.RPCURL)) + 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 nil, nil, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) @@ -119,17 +119,17 @@ func (f *EVMFamily) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient ui.Dim(fmt.Sprintf("Added experimental chain (chain-selector: %d)\n", ec.ChainSelector)) } - f.experimentalSelectors = experimental + ct.experimentalSelectors = experimental return clients, forwarders, nil } -func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) ([]services.Service, error) { +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 family: client for selector %d is not *ethclient.Client", sel) + return nil, fmt.Errorf("EVM: client for selector %d is not *ethclient.Client", sel) } ethClients[sel] = ec } @@ -146,7 +146,7 @@ func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.Capabili var ok bool pk, ok = cfg.PrivateKey.(*ecdsa.PrivateKey) if !ok { - return nil, fmt.Errorf("EVM family: private key is not *ecdsa.PrivateKey") + return nil, fmt.Errorf("EVM: private key is not *ecdsa.PrivateKey") } } @@ -165,10 +165,10 @@ func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.Capabili // Start the EVM chains so they begin listening for triggers if err := evmCaps.Start(ctx); err != nil { - return nil, fmt.Errorf("EVM family: failed to start chain capabilities: %w", err) + return nil, fmt.Errorf("EVM: failed to start chain capabilities: %w", err) } - f.evmChains = evmCaps + ct.evmChains = evmCaps srvcs := make([]services.Service, 0, len(evmCaps.EVMChains)) for _, evm := range evmCaps.EVMChains { @@ -177,43 +177,43 @@ func (f *EVMFamily) RegisterCapabilities(ctx context.Context, cfg chain.Capabili return srvcs, nil } -func (f *EVMFamily) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { - if f.evmChains == nil { - return fmt.Errorf("EVM family: capabilities not registered") +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 := f.evmChains.EVMChains[selector] + 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 family: trigger data is not *evm.Log") + 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 family cannot dispatch against. -func (f *EVMFamily) HasSelector(selector uint64) bool { - if f.evmChains == nil { +// 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 f.evmChains.EVMChains[selector] != nil + return ct.evmChains.EVMChains[selector] != nil } -func (f *EVMFamily) ParseTriggerChainSelector(triggerID string) (uint64, bool) { +func (ct *EVMChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { return ParseTriggerChainSelector(triggerID) } -func (f *EVMFamily) RunHealthCheck(clients map[uint64]chain.ChainClient) error { - return RunRPCHealthCheck(clients, f.experimentalSelectors) +func (ct *EVMChainType) RunHealthCheck(clients map[uint64]chain.ChainClient) error { + return RunRPCHealthCheck(clients, ct.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 (f *EVMFamily) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { +func (ct *EVMChainType) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { pk, err := crypto.HexToECDSA(creSettings.User.EthPrivateKey) if err != nil { if broadcast { @@ -232,13 +232,13 @@ func (f *EVMFamily) ResolveKey(creSettings *settings.Settings, broadcast bool) ( return pk, nil } -// CLI input keys consumed from chain.TriggerParams.FamilyInputs. +// CLI input keys consumed from chain.TriggerParams.ChainTypeInputs. const ( TriggerInputTxHash = "evm-tx-hash" TriggerInputEventIndex = "evm-event-index" ) -func (f *EVMFamily) CollectCLIInputs(v *viper.Viper) map[string]string { +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 @@ -251,7 +251,7 @@ func (f *EVMFamily) CollectCLIInputs(v *viper.Viper) map[string]string { // ResolveTriggerData fetches the EVM log payload for the given selector from // CLI-supplied or interactively-prompted inputs. -func (f *EVMFamily) ResolveTriggerData(ctx context.Context, selector uint64, params chain.TriggerParams) (interface{}, error) { +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) @@ -265,8 +265,8 @@ func (f *EVMFamily) ResolveTriggerData(ctx context.Context, selector uint64, par return GetEVMTriggerLog(ctx, client) } - txHash := strings.TrimSpace(params.FamilyInputs[TriggerInputTxHash]) - eventIndexStr := strings.TrimSpace(params.FamilyInputs[TriggerInputEventIndex]) + 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") } diff --git a/cmd/workflow/simulate/chain/evm/family_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go similarity index 75% rename from cmd/workflow/simulate/chain/evm/family_test.go rename to cmd/workflow/simulate/chain/evm/chaintype_test.go index 43079dce..c440f7ba 100644 --- a/cmd/workflow/simulate/chain/evm/family_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -100,9 +100,9 @@ func captureStdout(t *testing.T, fn func()) string { return buf.String() } -func newFamily() *EVMFamily { +func newEVMChainType() *EVMChainType { lg := zerolog.Nop() - return &EVMFamily{log: &lg} + return &EVMChainType{log: &lg} } // Valid anvil dev key #0; known non-sentinel. @@ -112,18 +112,18 @@ const validPK = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff8 // Name // --------------------------------------------------------------------------- -func TestEVMFamily_Name_IsEVM(t *testing.T) { +func TestEVMChainType_Name_IsEVM(t *testing.T) { t.Parallel() - require.Equal(t, "evm", newFamily().Name()) + require.Equal(t, "evm", newEVMChainType().Name()) } // --------------------------------------------------------------------------- // SupportedChains pass-through // --------------------------------------------------------------------------- -func TestEVMFamily_SupportedChains_ReturnsPackageVar(t *testing.T) { +func TestEVMChainType_SupportedChains_ReturnsPackageVar(t *testing.T) { t.Parallel() - got := newFamily().SupportedChains() + got := newEVMChainType().SupportedChains() require.Equal(t, len(SupportedChains), len(got)) require.Greater(t, len(got), 20, "expected many supported chains") } @@ -132,7 +132,7 @@ func TestEVMFamily_SupportedChains_ReturnsPackageVar(t *testing.T) { // ResolveKey table // --------------------------------------------------------------------------- -func TestEVMFamily_ResolveKey(t *testing.T) { +func TestEVMChainType_ResolveKey(t *testing.T) { t.Parallel() tests := []struct { @@ -221,13 +221,13 @@ func TestEVMFamily_ResolveKey(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - f := newFamily() + ct := newEVMChainType() s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: tt.pk}} var got interface{} var err error stderr := captureStderr(t, func() { - got, err = f.ResolveKey(s, tt.broadcast) + got, err = ct.ResolveKey(s, tt.broadcast) }) if tt.wantErr { @@ -257,7 +257,7 @@ func TestEVMFamily_ResolveKey(t *testing.T) { // ResolveKey sentinel identity // --------------------------------------------------------------------------- -func TestEVMFamily_ResolveKey_SentinelDecodesToD1(t *testing.T) { +func TestEVMChainType_ResolveKey_SentinelDecodesToD1(t *testing.T) { t.Parallel() pk, err := crypto.HexToECDSA(defaultSentinelPrivateKey) require.NoError(t, err) @@ -268,13 +268,13 @@ func TestEVMFamily_ResolveKey_SentinelDecodesToD1(t *testing.T) { // ResolveTriggerData — non-interactive validation // --------------------------------------------------------------------------- -func TestEVMFamily_ResolveTriggerData_NoClient(t *testing.T) { +func TestEVMChainType_ResolveTriggerData_NoClient(t *testing.T) { t.Parallel() - f := newFamily() - _, err := f.ResolveTriggerData(context.Background(), 777, chain.TriggerParams{ + ct := newEVMChainType() + _, err := ct.ResolveTriggerData(context.Background(), 777, chain.TriggerParams{ Clients: map[uint64]chain.ChainClient{}, Interactive: false, - FamilyInputs: map[string]string{ + ChainTypeInputs: map[string]string{ "evm-tx-hash": "0x" + strings.Repeat("a", 64), "evm-event-index": "0", }, @@ -283,13 +283,13 @@ func TestEVMFamily_ResolveTriggerData_NoClient(t *testing.T) { assert.Contains(t, err.Error(), "no RPC configured for chain selector 777") } -func TestEVMFamily_ResolveTriggerData_WrongClientType(t *testing.T) { +func TestEVMChainType_ResolveTriggerData_WrongClientType(t *testing.T) { t.Parallel() - f := newFamily() - _, err := f.ResolveTriggerData(context.Background(), 1, chain.TriggerParams{ + ct := newEVMChainType() + _, err := ct.ResolveTriggerData(context.Background(), 1, chain.TriggerParams{ Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, Interactive: false, - FamilyInputs: map[string]string{ + ChainTypeInputs: map[string]string{ "evm-tx-hash": "0x" + strings.Repeat("a", 64), "evm-event-index": "0", }, @@ -302,20 +302,20 @@ func TestEVMFamily_ResolveTriggerData_WrongClientType(t *testing.T) { // ExecuteTrigger // --------------------------------------------------------------------------- -func TestEVMFamily_ExecuteTrigger_NotRegistered(t *testing.T) { +func TestEVMChainType_ExecuteTrigger_NotRegistered(t *testing.T) { t.Parallel() - f := newFamily() - err := f.ExecuteTrigger(context.Background(), 1, "regID", nil) + ct := newEVMChainType() + err := ct.ExecuteTrigger(context.Background(), 1, "regID", nil) require.Error(t, err) - assert.Contains(t, err.Error(), "EVM family: capabilities not registered") + assert.Contains(t, err.Error(), "EVM: capabilities not registered") } -func TestEVMFamily_ExecuteTrigger_UnknownSelector(t *testing.T) { +func TestEVMChainType_ExecuteTrigger_UnknownSelector(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() // set evmChains with empty map to bypass nil check - f.evmChains = &EVMChainCapabilities{EVMChains: nil} - err := f.ExecuteTrigger(context.Background(), 999, "regID", nil) + 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") } @@ -324,32 +324,32 @@ func TestEVMFamily_ExecuteTrigger_UnknownSelector(t *testing.T) { // HasSelector // --------------------------------------------------------------------------- -func TestEVMFamily_HasSelector_WhenNotRegistered_ReturnsFalse(t *testing.T) { +func TestEVMChainType_HasSelector_WhenNotRegistered_ReturnsFalse(t *testing.T) { t.Parallel() - f := newFamily() - assert.False(t, f.HasSelector(1)) - assert.False(t, f.HasSelector(0)) + ct := newEVMChainType() + assert.False(t, ct.HasSelector(1)) + assert.False(t, ct.HasSelector(0)) } -func TestEVMFamily_HasSelector_EmptyMap_ReturnsFalse(t *testing.T) { +func TestEVMChainType_HasSelector_EmptyMap_ReturnsFalse(t *testing.T) { t.Parallel() - f := newFamily() - f.evmChains = &EVMChainCapabilities{EVMChains: nil} - assert.False(t, f.HasSelector(1)) + ct := newEVMChainType() + ct.evmChains = &EVMChainCapabilities{EVMChains: nil} + assert.False(t, ct.HasSelector(1)) } // --------------------------------------------------------------------------- -// ParseTriggerChainSelector (via family interface) +// ParseTriggerChainSelector (via chain type interface) // --------------------------------------------------------------------------- -func TestEVMFamily_ParseTriggerChainSelector_Delegates(t *testing.T) { +func TestEVMChainType_ParseTriggerChainSelector_Delegates(t *testing.T) { t.Parallel() - f := newFamily() - got, ok := f.ParseTriggerChainSelector("evm:ChainSelector:42@1.0.0") + ct := newEVMChainType() + got, ok := ct.ParseTriggerChainSelector("evm:ChainSelector:42@1.0.0") require.True(t, ok) require.Equal(t, uint64(42), got) - got, ok = f.ParseTriggerChainSelector("no-selector-here") + got, ok = ct.ParseTriggerChainSelector("no-selector-here") require.False(t, ok) require.Zero(t, got) } @@ -358,71 +358,71 @@ func TestEVMFamily_ParseTriggerChainSelector_Delegates(t *testing.T) { // RegisterCapabilities type-assertion failures // --------------------------------------------------------------------------- -func TestEVMFamily_RegisterCapabilities_WrongClientType(t *testing.T) { +func TestEVMChainType_RegisterCapabilities_WrongClientType(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() cfg := chain.CapabilityConfig{ - Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, Forwarders: map[uint64]string{1: "0x" + strings.Repeat("a", 40)}, } - _, err := f.RegisterCapabilities(context.Background(), cfg) + _, 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 TestEVMFamily_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { +func TestEVMChainType_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() cfg := chain.CapabilityConfig{ Clients: map[uint64]chain.ChainClient{}, Forwarders: map[uint64]string{}, Logger: nopCommonLogger(), Registry: newRegistry(t), } - srvcs, err := f.RegisterCapabilities(context.Background(), cfg) + 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, f.HasSelector(1)) + assert.False(t, ct.HasSelector(1)) } // --------------------------------------------------------------------------- // RunHealthCheck plumbing // --------------------------------------------------------------------------- -func TestEVMFamily_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { +func TestEVMChainType_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { t.Parallel() - f := newFamily() - err := f.RunHealthCheck(map[uint64]chain.ChainClient{1: "not-ethclient"}) + ct := newEVMChainType() + err := ct.RunHealthCheck(map[uint64]chain.ChainClient{1: "not-ethclient"}) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid client type for EVM family") + assert.Contains(t, err.Error(), "invalid client type for EVM chain type") } -func TestEVMFamily_RunHealthCheck_NoClients_Errors(t *testing.T) { +func TestEVMChainType_RunHealthCheck_NoClients_Errors(t *testing.T) { t.Parallel() - f := newFamily() - err := f.RunHealthCheck(map[uint64]chain.ChainClient{}) + ct := newEVMChainType() + err := ct.RunHealthCheck(map[uint64]chain.ChainClient{}) require.Error(t, err) assert.Contains(t, err.Error(), "no RPC URLs found") } // --------------------------------------------------------------------------- -// ChainFamily interface contract +// ChainType interface contract // --------------------------------------------------------------------------- -func TestEVMFamily_ImplementsChainFamily(t *testing.T) { +func TestEVMChainType_ImplementsChainType(t *testing.T) { t.Parallel() - var _ chain.ChainFamily = (*EVMFamily)(nil) + var _ chain.ChainType = (*EVMChainType)(nil) } // --------------------------------------------------------------------------- // Registered via init // --------------------------------------------------------------------------- -func TestEVMFamily_RegisteredInFactoryRegistry(t *testing.T) { +func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { t.Parallel() lg := zerolog.Nop() chain.Build(&lg) @@ -434,22 +434,22 @@ func TestEVMFamily_RegisteredInFactoryRegistry(t *testing.T) { break } } - require.True(t, found, "evm family should be registered at init; got %v", names) + require.True(t, found, "evm chain type should be registered at init; got %v", names) - fam, err := chain.Get("evm") + ct, err := chain.Get("evm") require.NoError(t, err) - require.Equal(t, "evm", fam.Name()) + require.Equal(t, "evm", ct.Name()) } // --------------------------------------------------------------------------- // Sentinel error wrapping // --------------------------------------------------------------------------- -func TestEVMFamily_ResolveKey_BroadcastErrorWrapsUnderlying(t *testing.T) { +func TestEVMChainType_ResolveKey_BroadcastErrorWrapsUnderlying(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: "zz"}} - _, err := f.ResolveKey(s, true) + _, err := ct.ResolveKey(s, true) require.Error(t, err) // Must mention env var for operator-facing clarity. assert.Contains(t, err.Error(), "CRE_ETH_PRIVATE_KEY") @@ -459,12 +459,12 @@ func TestEVMFamily_ResolveKey_BroadcastErrorWrapsUnderlying(t *testing.T) { // Non-broadcast with valid key: no UI warning leaked // --------------------------------------------------------------------------- -func TestEVMFamily_ResolveKey_ValidNonBroadcast_NoWarning(t *testing.T) { +func TestEVMChainType_ResolveKey_ValidNonBroadcast_NoWarning(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: validPK}} stderr := captureStderr(t, func() { - _, err := f.ResolveKey(s, false) + _, err := ct.ResolveKey(s, false) require.NoError(t, err) }) assert.NotContains(t, stderr, "Using default private key") @@ -474,13 +474,13 @@ func TestEVMFamily_ResolveKey_ValidNonBroadcast_NoWarning(t *testing.T) { // ExecuteTrigger wrong triggerData type // --------------------------------------------------------------------------- -func TestEVMFamily_ExecuteTrigger_WrongTriggerDataType(t *testing.T) { +func TestEVMChainType_ExecuteTrigger_WrongTriggerDataType(t *testing.T) { t.Parallel() // Register a nil FakeEVMChain entry via map so the nil-check passes but the // triggerData type assertion fails first. - f := newFamily() - f.evmChains = &EVMChainCapabilities{EVMChains: nil} - err := f.ExecuteTrigger(context.Background(), 1, "regID", "not-a-log") + ct := newEVMChainType() + ct.evmChains = &EVMChainCapabilities{EVMChains: nil} + err := ct.ExecuteTrigger(context.Background(), 1, "regID", "not-a-log") require.Error(t, err) // Whichever check fails first — both are acceptable. if !errorContainsAny(err, "trigger data is not *evm.Log", "no EVM chain initialized") { @@ -503,12 +503,12 @@ func errorContainsAny(err error, subs ...string) bool { // Defensive check: crypto.HexToECDSA rejects the string "0x..." so our // fallback behaviour under non-broadcast keeps functioning even if a user // copies their key with a prefix. -func TestEVMFamily_ResolveKey_PrefixedHex_FallsBackToSentinel(t *testing.T) { +func TestEVMChainType_ResolveKey_PrefixedHex_FallsBackToSentinel(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: "0x" + validPK}} stderr := captureStderr(t, func() { - got, err := f.ResolveKey(s, false) + got, err := ct.ResolveKey(s, false) require.NoError(t, err) pk := got.(*ecdsa.PrivateKey) require.Equal(t, 0, pk.D.Cmp(bigOne())) @@ -520,11 +520,11 @@ func TestEVMFamily_ResolveKey_PrefixedHex_FallsBackToSentinel(t *testing.T) { // Error type is standard error (not a sentinel) — ensures errors.Is behaviour. // --------------------------------------------------------------------------- -func TestEVMFamily_ResolveKey_BroadcastError_IsError(t *testing.T) { +func TestEVMChainType_ResolveKey_BroadcastError_IsError(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: ""}} - _, err := f.ResolveKey(s, true) + _, err := ct.ResolveKey(s, true) require.Error(t, err) require.NotNil(t, errors.Unwrap(err)) } @@ -533,52 +533,52 @@ func TestEVMFamily_ResolveKey_BroadcastError_IsError(t *testing.T) { // CollectCLIInputs // --------------------------------------------------------------------------- -func TestEVMFamily_CollectCLIInputs_BothSet(t *testing.T) { +func TestEVMChainType_CollectCLIInputs_BothSet(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() v := viper.New() v.Set("evm-tx-hash", "0xabc123") v.Set("evm-event-index", 2) - result := f.CollectCLIInputs(v) + result := ct.CollectCLIInputs(v) assert.Equal(t, "0xabc123", result[TriggerInputTxHash]) assert.Equal(t, "2", result[TriggerInputEventIndex]) } -func TestEVMFamily_CollectCLIInputs_NegativeIndexOmitted(t *testing.T) { +func TestEVMChainType_CollectCLIInputs_NegativeIndexOmitted(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() v := viper.New() v.Set("evm-tx-hash", "0xabc") v.Set("evm-event-index", -1) - result := f.CollectCLIInputs(v) + result := ct.CollectCLIInputs(v) assert.Equal(t, "0xabc", result[TriggerInputTxHash]) _, hasIndex := result[TriggerInputEventIndex] assert.False(t, hasIndex, "negative index should be omitted") } -func TestEVMFamily_CollectCLIInputs_EmptyTxHashOmitted(t *testing.T) { +func TestEVMChainType_CollectCLIInputs_EmptyTxHashOmitted(t *testing.T) { t.Parallel() - f := newFamily() + ct := newEVMChainType() v := viper.New() v.Set("evm-tx-hash", "") v.Set("evm-event-index", 0) - result := f.CollectCLIInputs(v) + result := ct.CollectCLIInputs(v) _, hasTx := result[TriggerInputTxHash] assert.False(t, hasTx, "empty tx hash should be omitted") assert.Equal(t, "0", result[TriggerInputEventIndex]) } -func TestEVMFamily_CollectCLIInputs_DefaultsOnly(t *testing.T) { +func TestEVMChainType_CollectCLIInputs_DefaultsOnly(t *testing.T) { t.Parallel() - f := newFamily() + 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 := f.CollectCLIInputs(v) + 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 index cc82473c..b3859632 100644 --- a/cmd/workflow/simulate/chain/evm/health.go +++ b/cmd/workflow/simulate/chain/evm/health.go @@ -19,7 +19,7 @@ func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelecto for sel, c := range clients { ec, ok := c.(*ethclient.Client) if !ok { - return fmt.Errorf("[%d] invalid client type for EVM family", sel) + return fmt.Errorf("[%d] invalid client type for EVM chain type", sel) } ethClients[sel] = ec } diff --git a/cmd/workflow/simulate/chain/evm/health_more_test.go b/cmd/workflow/simulate/chain/evm/health_more_test.go index 5454d0da..d27bb044 100644 --- a/cmd/workflow/simulate/chain/evm/health_more_test.go +++ b/cmd/workflow/simulate/chain/evm/health_more_test.go @@ -165,7 +165,7 @@ func TestRunRPCHealthCheck_WrapperFailsOnNonEthClient(t *testing.T) { nil, ) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid client type for EVM family") + assert.Contains(t, err.Error(), "invalid client type for EVM chain type") } func TestRunRPCHealthCheck_EmptyReturnsSettingsError(t *testing.T) { diff --git a/cmd/workflow/simulate/chain/evm/health_test.go b/cmd/workflow/simulate/chain/evm/health_test.go index c5db9ff5..18ed6889 100644 --- a/cmd/workflow/simulate/chain/evm/health_test.go +++ b/cmd/workflow/simulate/chain/evm/health_test.go @@ -165,5 +165,5 @@ func TestRunRPCHealthCheck_InvalidClientType(t *testing.T) { if err == nil { t.Fatalf("expected error for invalid client type") } - mustContain(t, err.Error(), "invalid client type for EVM family") + mustContain(t, err.Error(), "invalid client type for EVM chain type") } diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go index d97ae4fe..07189ee2 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -14,7 +14,7 @@ import ( ) // EVMChainLimits is the limit-accessor contract LimitedEVMChain enforces. -// Aliased to chain.Limits so the family-agnostic CapabilityConfig.Limits +// Aliased to chain.Limits so the chain-type-agnostic CapabilityConfig.Limits // value can be passed straight through without a type assertion. type EVMChainLimits = chain.Limits diff --git a/cmd/workflow/simulate/chain/evm/supported_chains_test.go b/cmd/workflow/simulate/chain/evm/supported_chains_test.go index fdd7886d..ec4ea54e 100644 --- a/cmd/workflow/simulate/chain/evm/supported_chains_test.go +++ b/cmd/workflow/simulate/chain/evm/supported_chains_test.go @@ -115,9 +115,9 @@ func TestSupportedChains_NoForwarderEmpty(t *testing.T) { } } -func TestSupportedChains_ReturnedByFamily(t *testing.T) { +func TestSupportedChains_ReturnedByChainType(t *testing.T) { t.Parallel() - f := newFamily() + f := newEVMChainType() ret := f.SupportedChains() require.Equal(t, len(SupportedChains), len(ret)) // Element-wise identity (same struct values, same order). diff --git a/cmd/workflow/simulate/chain/registry.go b/cmd/workflow/simulate/chain/registry.go index c505943f..a7549929 100644 --- a/cmd/workflow/simulate/chain/registry.go +++ b/cmd/workflow/simulate/chain/registry.go @@ -16,66 +16,66 @@ import ( "github.com/smartcontractkit/cre-cli/internal/settings" ) -// Factory constructs a ChainFamily with the logger the simulator uses. +// 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) ChainFamily +type Factory func(lggr *zerolog.Logger) ChainType -// ChainFamily defines what a chain family plugin must implement +// ChainType defines what a chain type plugin must implement // to participate in workflow simulation. -type ChainFamily interface { - // Name returns the family identifier (e.g., "evm", "aptos"). +type ChainType interface { + // Name returns the chain type identifier (e.g., "evm", "aptos"). Name() string - // ResolveClients creates RPC clients for all chains this family + // ResolveClients creates RPC clients for all chains this chain type // can simulate, including both supported and experimental chains. // Returns clients keyed by chain selector, and forwarder addresses // for chains that have them. ResolveClients(v *viper.Viper) (clients map[uint64]ChainClient, forwarders map[uint64]string, err error) - // ResolveKey parses and validates this family's signing key from + // 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 (family-specific type) or nil if the family - // does not use a signing key. + // 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 family-specific trigger payload for + // 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 family's + // 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 family defines what triggerData looks like. + // Each chain type defines what triggerData looks like. ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error - // HasSelector reports whether the family has a fully initialised + // 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 family cannot actually dispatch against. + // 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 family. + // 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. RunHealthCheck(clients map[uint64]ChainClient) error - // SupportedChains returns the list of chains this family supports + // SupportedChains returns the list of chains this chain type supports // out of the box (for display/documentation purposes). SupportedChains() []ChainConfig - // CollectCLIInputs reads this family's CLI flags from viper and - // returns them as key-value pairs for TriggerParams.FamilyInputs. + // 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 family needs registered. +// CLIFlagDef describes a CLI flag a chain type needs registered. type CLIFlagDef struct { Name string Description string @@ -100,55 +100,55 @@ type registration struct { var ( mu sync.RWMutex registrations = make(map[string]registration) - families = make(map[string]ChainFamily) + chainTypes = make(map[string]ChainType) ) -// Register adds a chain family factory and its CLI flag definitions to the -// registry. Called from family package init(); the factory is invoked later +// 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 family %q already registered", name)) + panic(fmt.Sprintf("chain type %q already registered", name)) } registrations[name] = registration{factory: factory, flagDefs: flagDefs} } -// Build instantiates every registered family with the given logger. +// 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 { - families[name] = reg.factory(lggr) + chainTypes[name] = reg.factory(lggr) } } -// Get returns a registered chain family by name. -func Get(name string) (ChainFamily, error) { +// Get returns a registered chain type by name. +func Get(name string) (ChainType, error) { mu.RLock() defer mu.RUnlock() - f, ok := families[name] + ct, ok := chainTypes[name] if !ok { - return nil, fmt.Errorf("unknown chain family %q; registered: %v", name, namesLocked()) + return nil, fmt.Errorf("unknown chain type %q; registered: %v", name, namesLocked()) } - return f, nil + return ct, nil } -// All returns a copy of all registered families. -func All() map[string]ChainFamily { +// All returns a copy of all registered chain types. +func All() map[string]ChainType { mu.RLock() defer mu.RUnlock() - result := make(map[string]ChainFamily, len(families)) - for k, v := range families { + result := make(map[string]ChainType, len(chainTypes)) + for k, v := range chainTypes { result[k] = v } return result } -// RegisterAllCLIFlags registers CLI flags from every registered family's +// 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() @@ -171,28 +171,28 @@ func RegisterAllCLIFlags(cmd *cobra.Command) { } } -// CollectAllCLIInputs gathers CLI inputs from every registered family. +// CollectAllCLIInputs gathers CLI inputs from every registered chain type. func CollectAllCLIInputs(v *viper.Viper) map[string]string { result := map[string]string{} - for _, family := range All() { - for k, v := range family.CollectCLIInputs(v) { - result[k] = v + for _, ct := range All() { + for k, val := range ct.CollectCLIInputs(v) { + result[k] = val } } return result } -// namesLocked returns sorted family names. Caller must hold mu. +// namesLocked returns sorted chain type names. Caller must hold mu. func namesLocked() []string { - names := make([]string, 0, len(families)) - for k := range families { + names := make([]string, 0, len(chainTypes)) + for k := range chainTypes { names = append(names, k) } sort.Strings(names) return names } -// Names returns sorted registered family names. +// Names returns sorted registered chain type names. func Names() []string { mu.RLock() defer mu.RUnlock() diff --git a/cmd/workflow/simulate/chain/registry_test.go b/cmd/workflow/simulate/chain/registry_test.go index cb595a77..27439d2b 100644 --- a/cmd/workflow/simulate/chain/registry_test.go +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -20,86 +20,86 @@ func resetRegistry() { mu.Lock() defer mu.Unlock() registrations = make(map[string]registration) - families = make(map[string]ChainFamily) + chainTypes = make(map[string]ChainType) } -// mockChainFamily is a testify/mock implementation of ChainFamily. -type mockChainFamily struct { +// mockChainType is a testify/mock implementation of ChainType. +type mockChainType struct { mock.Mock } -var _ ChainFamily = (*mockChainFamily)(nil) +var _ ChainType = (*mockChainType)(nil) -func (m *mockChainFamily) Name() string { +func (m *mockChainType) Name() string { args := m.Called() return args.String(0) } -func (m *mockChainFamily) ResolveClients(v *viper.Viper) (map[uint64]ChainClient, map[uint64]string, error) { +func (m *mockChainType) ResolveClients(v *viper.Viper) (map[uint64]ChainClient, map[uint64]string, error) { args := m.Called(v) clients, _ := args.Get(0).(map[uint64]ChainClient) forwarders, _ := args.Get(1).(map[uint64]string) return clients, forwarders, args.Error(2) } -func (m *mockChainFamily) RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) ([]services.Service, error) { +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 *mockChainFamily) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { +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 *mockChainFamily) HasSelector(selector uint64) bool { +func (m *mockChainType) HasSelector(selector uint64) bool { args := m.Called(selector) return args.Bool(0) } -func (m *mockChainFamily) ParseTriggerChainSelector(triggerID string) (uint64, bool) { +func (m *mockChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { args := m.Called(triggerID) return args.Get(0).(uint64), args.Bool(1) } -func (m *mockChainFamily) RunHealthCheck(clients map[uint64]ChainClient) error { +func (m *mockChainType) RunHealthCheck(clients map[uint64]ChainClient) error { args := m.Called(clients) return args.Error(0) } -func (m *mockChainFamily) SupportedChains() []ChainConfig { +func (m *mockChainType) SupportedChains() []ChainConfig { args := m.Called() result, _ := args.Get(0).([]ChainConfig) return result } -func (m *mockChainFamily) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { +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 *mockChainFamily) ResolveTriggerData(ctx context.Context, selector uint64, params TriggerParams) (interface{}, error) { +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 *mockChainFamily) CollectCLIInputs(v *viper.Viper) map[string]string { +func (m *mockChainType) CollectCLIInputs(v *viper.Viper) map[string]string { args := m.Called(v) result, _ := args.Get(0).(map[string]string) return result } -func newMockFamily(name string) *mockChainFamily { - f := new(mockChainFamily) +func newMockType(name string) *mockChainType { + f := new(mockChainType) f.On("Name").Return(name) return f } -// registerMock registers a pre-built mock family and immediately builds it so +// 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, family ChainFamily) { - Register(name, func(*zerolog.Logger) ChainFamily { return family }, nil) +func registerMock(name string, chainType ChainType) { + Register(name, func(*zerolog.Logger) ChainType { return chainType }, nil) Build(nil) } @@ -107,31 +107,31 @@ func TestRegisterAndGet(t *testing.T) { resetRegistry() defer resetRegistry() - mockFamily := newMockFamily("test") - registerMock("test", mockFamily) + mockCT := newMockType("test") + registerMock("test", mockCT) f, err := Get("test") require.NoError(t, err) assert.Equal(t, "test", f.Name()) - mockFamily.AssertExpectations(t) + mockCT.AssertExpectations(t) } -func TestGetUnknownFamily(t *testing.T) { +func TestGetUnknownChainType(t *testing.T) { resetRegistry() defer resetRegistry() _, err := Get("nonexistent") require.Error(t, err) - assert.Contains(t, err.Error(), "unknown chain family") + assert.Contains(t, err.Error(), "unknown chain type") } func TestRegisterDuplicatePanics(t *testing.T) { resetRegistry() defer resetRegistry() - registerMock("dup", newMockFamily("dup")) + registerMock("dup", newMockType("dup")) assert.Panics(t, func() { - registerMock("dup", newMockFamily("dup")) + registerMock("dup", newMockType("dup")) }) } @@ -139,8 +139,8 @@ func TestAll(t *testing.T) { resetRegistry() defer resetRegistry() - registerMock("alpha", newMockFamily("alpha")) - registerMock("beta", newMockFamily("beta")) + registerMock("alpha", newMockType("alpha")) + registerMock("beta", newMockType("beta")) all := All() assert.Len(t, all, 2) @@ -152,9 +152,9 @@ func TestNamesReturnsSorted(t *testing.T) { resetRegistry() defer resetRegistry() - registerMock("zebra", newMockFamily("zebra")) - registerMock("alpha", newMockFamily("alpha")) - registerMock("middle", newMockFamily("middle")) + registerMock("zebra", newMockType("zebra")) + registerMock("alpha", newMockType("alpha")) + registerMock("middle", newMockType("middle")) names := Names() assert.Equal(t, []string{"alpha", "middle", "zebra"}, names) @@ -164,8 +164,8 @@ func TestGetErrorIncludesRegisteredNames(t *testing.T) { resetRegistry() defer resetRegistry() - registerMock("evm", newMockFamily("evm")) - registerMock("aptos", newMockFamily("aptos")) + registerMock("evm", newMockType("evm")) + registerMock("aptos", newMockType("aptos")) _, err := Get("solana") require.Error(t, err) @@ -177,7 +177,7 @@ func TestRegisterAllCLIFlags_StringAndInt(t *testing.T) { resetRegistry() defer resetRegistry() - Register("test", func(*zerolog.Logger) ChainFamily { return newMockFamily("test") }, []CLIFlagDef{ + 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}, }) @@ -200,23 +200,23 @@ func TestRegisterAllCLIFlags_NilFlagDefs(t *testing.T) { resetRegistry() defer resetRegistry() - Register("test", func(*zerolog.Logger) ChainFamily { return newMockFamily("test") }, nil) + Register("test", func(*zerolog.Logger) ChainType { return newMockType("test") }, nil) cmd := &cobra.Command{Use: "test"} RegisterAllCLIFlags(cmd) // should not panic } -func TestCollectAllCLIInputs_MergesAcrossFamilies(t *testing.T) { +func TestCollectAllCLIInputs_MergesAcrossChainTypes(t *testing.T) { resetRegistry() defer resetRegistry() - fam1 := newMockFamily("alpha") - fam1.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-a": "val-a"}) - registerMock("alpha", fam1) + ct1 := newMockType("alpha") + ct1.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-a": "val-a"}) + registerMock("alpha", ct1) - fam2 := newMockFamily("beta") - fam2.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-b": "val-b"}) - registerMock("beta", fam2) + 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) @@ -229,9 +229,9 @@ func TestCollectAllCLIInputs_EmptyWhenNoInputs(t *testing.T) { resetRegistry() defer resetRegistry() - fam := newMockFamily("empty") - fam.On("CollectCLIInputs", mock.Anything).Return(map[string]string{}) - registerMock("empty", fam) + ct := newMockType("empty") + ct.On("CollectCLIInputs", mock.Anything).Return(map[string]string{}) + registerMock("empty", ct) v := viper.New() result := CollectAllCLIInputs(v) @@ -242,8 +242,8 @@ func TestAllReturnsCopy(t *testing.T) { resetRegistry() defer resetRegistry() - mockFamily := newMockFamily("original") - registerMock("original", mockFamily) + mockCT := newMockType("original") + registerMock("original", mockCT) all := All() delete(all, "original") @@ -252,5 +252,5 @@ func TestAllReturnsCopy(t *testing.T) { f, err := Get("original") require.NoError(t, err) assert.Equal(t, "original", f.Name()) - mockFamily.AssertExpectations(t) + mockCT.AssertExpectations(t) } diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go index 1c2fdb12..058e10b7 100644 --- a/cmd/workflow/simulate/chain/types.go +++ b/cmd/workflow/simulate/chain/types.go @@ -6,16 +6,16 @@ import ( ) // ChainClient is an opaque handle to a chain-specific RPC client. -// Each family casts this to its concrete type internally. +// Each chain type casts this to its concrete type internally. type ChainClient interface{} -// ChainConfig identifies a supported chain within a family. +// ChainConfig identifies a supported chain within a chain type. type ChainConfig struct { Selector uint64 - Forwarder string // family-specific forwarding address + Forwarder string // chain-type-specific forwarding address } -// Limits exposes the chain-write limit accessors a family needs at +// Limits exposes the chain-write limit accessors a chain type needs at // capability-registration time. Implementations live in the parent simulate // package; this interface is defined here so CapabilityConfig stays in the // chain package without an import cycle. @@ -24,22 +24,22 @@ type Limits interface { ChainWriteGasLimit() uint64 } -// CapabilityConfig holds everything a family needs to register capabilities. +// 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{} // family-specific key type; EVM uses *ecdsa.PrivateKey + PrivateKey interface{} // chain-type-specific key type; EVM uses *ecdsa.PrivateKey Broadcast bool Limits Limits // nil disables limit enforcement Logger logger.Logger } -// TriggerParams carries family-agnostic inputs needed to resolve trigger data -// for a given chain trigger. FamilyInputs is a free-form bag of CLI-supplied -// strings; each family interprets the keys it knows about and ignores the rest. +// 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 - FamilyInputs map[string]string + Clients map[uint64]ChainClient + Interactive bool + ChainTypeInputs map[string]string } diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index c7503489..1e648276 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -49,15 +49,15 @@ type Inputs struct { EngineLogs bool `validate:"omitempty" cli:"--engine-logs"` Broadcast bool `validate:"-"` WorkflowName string `validate:"required"` - // Family-generic chain fields - FamilyClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` - FamilyForwarders map[string]map[uint64]string `validate:"-"` - FamilyKeys map[string]interface{} `validate:"-"` + // Chain-type-specific fields + ChainTypeClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` + ChainTypeForwarders map[string]map[uint64]string `validate:"-"` + ChainTypeKeys map[string]interface{} `validate:"-"` // Non-interactive mode options - NonInteractive bool `validate:"-"` - TriggerIndex int `validate:"-"` - HTTPPayload string `validate:"-"` // JSON string or @/path/to/file.json - FamilyInputs map[string]string `validate:"-"` // CLI-supplied family-specific trigger inputs + 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. @@ -98,7 +98,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { 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)") - // Register chain-family-specific CLI flags (e.g., --evm-tx-hash). + // 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") @@ -125,25 +125,25 @@ func newHandler(ctx *runtime.Context) *handler { func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) (Inputs, error) { chain.Build(h.log) - familyClients := make(map[string]map[uint64]chain.ChainClient) - familyForwarders := make(map[string]map[uint64]string) - familyKeys := make(map[string]interface{}) + ctClients := make(map[string]map[uint64]chain.ChainClient) + ctForwarders := make(map[string]map[uint64]string) + ctKeys := make(map[string]interface{}) - for name, family := range chain.All() { - clients, forwarders, err := family.ResolveClients(v) + for name, ct := range chain.All() { + clients, forwarders, err := ct.ResolveClients(v) if err != nil { return Inputs{}, fmt.Errorf("failed to resolve %s clients: %w", name, err) } if len(clients) > 0 { - familyClients[name] = clients - familyForwarders[name] = forwarders + ctClients[name] = clients + ctForwarders[name] = forwarders } } - // Check at least one family has clients + // Check at least one chain type has clients totalClients := 0 - for _, fc := range familyClients { + for _, fc := range ctClients { totalClients += len(fc) } if totalClients == 0 { @@ -151,36 +151,36 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) } broadcast := v.GetBool("broadcast") - for name, family := range chain.All() { - if _, ok := familyClients[name]; !ok { - continue // no clients for this family; skip key resolution + for name, ct := range chain.All() { + if _, ok := ctClients[name]; !ok { + continue // no clients for this chain type; skip key resolution } - key, err := family.ResolveKey(creSettings, broadcast) + key, err := ct.ResolveKey(creSettings, broadcast) if err != nil { return Inputs{}, err } if key != nil { - familyKeys[name] = key + ctKeys[name] = key } } 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"), - FamilyClients: familyClients, - FamilyForwarders: familyForwarders, - FamilyKeys: familyKeys, - WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, - NonInteractive: v.GetBool("non-interactive"), - TriggerIndex: v.GetInt("trigger-index"), - HTTPPayload: v.GetString("http-payload"), - FamilyInputs: chain.CollectAllCLIInputs(v), - LimitsPath: v.GetString("limits"), - SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), + 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, + ChainTypeForwarders: ctForwarders, + 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), }, nil } @@ -209,9 +209,9 @@ func (h *handler) ValidateInputs(inputs Inputs) error { rpcErr := ui.WithSpinner("Checking RPC connectivity...", func() error { var errs []error - for name, family := range chain.All() { - if clients, ok := inputs.FamilyClients[name]; ok { - if err := family.RunHealthCheck(clients); err != nil { + for name, ct := range chain.All() { + if clients, ok := inputs.ChainTypeClients[name]; ok { + if err := ct.RunHealthCheck(clients); err != nil { errs = append(errs, err) } } @@ -458,18 +458,18 @@ func run( capLimits = simLimits } - // Register chain-family-specific capabilities - for name, family := range chain.All() { - clients, ok := inputs.FamilyClients[name] + // Register chain-type-specific capabilities + for name, ct := range chain.All() { + clients, ok := inputs.ChainTypeClients[name] if !ok || len(clients) == 0 { continue } - familySrvcs, err := family.RegisterCapabilities(ctx, chain.CapabilityConfig{ + ctSrvcs, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ Registry: registry, Clients: clients, - Forwarders: inputs.FamilyForwarders[name], - PrivateKey: inputs.FamilyKeys[name], + Forwarders: inputs.ChainTypeForwarders[name], + PrivateKey: inputs.ChainTypeKeys[name], Broadcast: inputs.Broadcast, Limits: capLimits, Logger: triggerLggr, @@ -478,7 +478,7 @@ func run( ui.Error(fmt.Sprintf("Failed to register %s capabilities: %v", name, err)) os.Exit(1) } - srvcs = append(srvcs, familySrvcs...) + srvcs = append(srvcs, ctSrvcs...) } // Register chain-agnostic action capabilities (consensus, HTTP, confidential HTTP) @@ -706,20 +706,20 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } default: - // Try each registered chain family + // Try each registered chain type handled := false - for name, family := range chain.All() { - sel, ok := family.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) + for name, ct := range chain.All() { + sel, ok := ct.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) if !ok { continue } - if !family.HasSelector(sel) { + if !ct.HasSelector(sel) { ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) os.Exit(1) } - triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, true) + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, true) if err != nil { ui.Error(err.Error()) os.Exit(1) @@ -727,7 +727,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs handled = true holder.TriggerFunc = func() error { - return family.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) + return ct.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) } break } @@ -786,20 +786,20 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } default: - // Try each registered chain family + // Try each registered chain type handled := false - for name, family := range chain.All() { - sel, ok := family.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) + for name, ct := range chain.All() { + sel, ok := ct.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) if !ok { continue } - if !family.HasSelector(sel) { + if !ct.HasSelector(sel) { ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) os.Exit(1) } - triggerData, err := getTriggerDataForFamily(ctx, family, sel, inputs, false) + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, false) if err != nil { ui.Error(err.Error()) os.Exit(1) @@ -807,7 +807,7 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp handled = true holder.TriggerFunc = func() error { - return family.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) + return ct.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) } break } @@ -902,17 +902,17 @@ func getHTTPTriggerPayload() (*httptypedapi.Payload, error) { return payload, nil } -// getTriggerDataForFamily resolves trigger data for a specific chain family. -// Each family defines its own trigger data format. -func getTriggerDataForFamily(ctx context.Context, family chain.ChainFamily, selector uint64, inputs Inputs, interactive bool) (interface{}, error) { - clients, ok := inputs.FamilyClients[family.Name()] +// 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) { + clients, ok := inputs.ChainTypeClients[ct.Name()] if !ok { - return nil, fmt.Errorf("no %s clients configured", family.Name()) + return nil, fmt.Errorf("no %s clients configured", ct.Name()) } - return family.ResolveTriggerData(ctx, selector, chain.TriggerParams{ - Clients: clients, - Interactive: interactive, - FamilyInputs: inputs.FamilyInputs, + return ct.ResolveTriggerData(ctx, selector, chain.TriggerParams{ + Clients: clients, + Interactive: interactive, + ChainTypeInputs: inputs.ChainTypeInputs, }) } From d68b63e37242a675cc836b0e0fb4a044f174248c Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 19:19:00 +0100 Subject: [PATCH 09/26] fix(lint): resolve golangci-lint errors - Remove redundant copyloopvar 'tt := tt' (Go 1.22+) - Remove unused evmCapabilityBaseStub embed in fullStubCapability - Convert 'switch { case x == a }' to 'switch x { case a }' (staticcheck QF1002) - Fix goimports formatting --- .../simulate/chain/evm/chaintype_test.go | 1 - .../limited_capabilities_delegation_test.go | 2 -- .../chain/evm/trigger_validation_test.go | 2 -- cmd/workflow/simulate/limited_capabilities.go | 1 - .../simulate/limited_capabilities_test.go | 2 -- cmd/workflow/simulate/simulate.go | 20 +++++++++---------- 6 files changed, 10 insertions(+), 18 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index c440f7ba..62dfa18d 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -219,7 +219,6 @@ func TestEVMChainType_ResolveKey(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { ct := newEVMChainType() s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: tt.pk}} diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go index 8d416857..5be50d22 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go @@ -17,7 +17,6 @@ import ( // fullStubCapability extends the base stub with counters on every delegating // method so we can verify the limiter passes calls through. type fullStubCapability struct { - evmCapabilityBaseStub calls map[string]int // optional return override @@ -188,7 +187,6 @@ func TestLimitedEVMChain_AllMethodsDelegate(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() stub := newFullStub() diff --git a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go index 7a1e124f..50eba2e1 100644 --- a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go +++ b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go @@ -41,7 +41,6 @@ func TestGetEVMTriggerLogFromValues_Validation(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() _, err := GetEVMTriggerLogFromValues(context.Background(), nil, tt.hash, 0) @@ -307,7 +306,6 @@ func TestParseTriggerChainSelector_AdditionalEdgeCases(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() got, ok := ParseTriggerChainSelector(tt.id) diff --git a/cmd/workflow/simulate/limited_capabilities.go b/cmd/workflow/simulate/limited_capabilities.go index 576d5015..50441a7e 100644 --- a/cmd/workflow/simulate/limited_capabilities.go +++ b/cmd/workflow/simulate/limited_capabilities.go @@ -191,4 +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) } - diff --git a/cmd/workflow/simulate/limited_capabilities_test.go b/cmd/workflow/simulate/limited_capabilities_test.go index a81c924f..a927874c 100644 --- a/cmd/workflow/simulate/limited_capabilities_test.go +++ b/cmd/workflow/simulate/limited_capabilities_test.go @@ -87,7 +87,6 @@ func (s *consensusCapabilityStub) Report(ctx context.Context, metadata commonCap return nil, nil } - func newTestLimits(t *testing.T) *SimulationLimits { t.Helper() limits, err := DefaultLimits() @@ -320,4 +319,3 @@ func TestLimitedConsensusNoDAGReportDelegates(t *testing.T) { assert.Same(t, expectedResp, resp) assert.Equal(t, 1, inner.reportCalls) } - diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 1e648276..77a1a4c8 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -51,8 +51,8 @@ type Inputs struct { WorkflowName string `validate:"required"` // Chain-type-specific fields ChainTypeClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` - ChainTypeForwarders map[string]map[uint64]string `validate:"-"` - ChainTypeKeys map[string]interface{} `validate:"-"` + ChainTypeForwarders map[string]map[uint64]string `validate:"-"` + ChainTypeKeys map[string]interface{} `validate:"-"` // Non-interactive mode options NonInteractive bool `validate:"-"` TriggerIndex int `validate:"-"` @@ -97,10 +97,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)") - + // 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 @@ -691,12 +691,12 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs trigger := holder.TriggerToRun.Id manualTriggerCaps := manualTriggersGetter() - switch { - case trigger == "cron-trigger@1.0.0": + switch trigger { + case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { 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() if err != nil { ui.Error(fmt.Sprintf("Failed to get HTTP trigger payload: %v", err)) @@ -767,12 +767,12 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp trigger := holder.TriggerToRun.Id manualTriggerCaps := manualTriggersGetter() - switch { - case trigger == "cron-trigger@1.0.0": + switch trigger { + case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { 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) From 2c25107d5feb711c3a54e7fcdf4efe077e1ece61 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 19:30:08 +0100 Subject: [PATCH 10/26] fix(lint): gofmt whitespace in chain/evm test files --- .../chain/evm/limited_capabilities_test.go | 18 ++++++++++-------- .../chain/evm/trigger_validation_test.go | 4 ++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go index e2d84ef2..fb99ae84 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -21,17 +21,19 @@ type EVMChainLimit struct { } func (s *EVMChainLimit) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } -func (s *EVMChainLimit) ChainWriteGasLimit() uint64 { return s.gasLimit } +func (s *EVMChainLimit) 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 } +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 diff --git a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go index 50eba2e1..19879179 100644 --- a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go +++ b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go @@ -68,8 +68,8 @@ func newMockRPC(t *testing.T) *mockRPC { } 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"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` Params []json.RawMessage `json:"params"` } _ = json.NewDecoder(r.Body).Decode(&req) From e3cb7e5a8fd6608166bf17dabd87dfb539c7d2bb Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 19:40:41 +0100 Subject: [PATCH 11/26] fix(lint): rename runRPCHealthCheck to avoid confusing-naming; annotate blank import --- cmd/workflow/simulate/chain/evm/health.go | 6 +++--- .../simulate/chain/evm/health_more_test.go | 16 ++++++++-------- cmd/workflow/simulate/chain/evm/health_test.go | 12 ++++++------ cmd/workflow/simulate/simulate.go | 1 + 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/health.go b/cmd/workflow/simulate/chain/evm/health.go index b3859632..a292a9ea 100644 --- a/cmd/workflow/simulate/chain/evm/health.go +++ b/cmd/workflow/simulate/chain/evm/health.go @@ -24,12 +24,12 @@ func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelecto ethClients[sel] = ec } - return runRPCHealthCheck(ethClients, experimentalSelectors) + return checkRPCConnectivity(ethClients, experimentalSelectors) } -// runRPCHealthCheck runs connectivity check against every configured client. +// checkRPCConnectivity runs connectivity check against every configured client. // experimentalSelectors set identifies experimental chains (not in chain-selectors). -func runRPCHealthCheck(clients map[uint64]*ethclient.Client, experimentalSelectors map[uint64]bool) error { +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") } diff --git a/cmd/workflow/simulate/chain/evm/health_more_test.go b/cmd/workflow/simulate/chain/evm/health_more_test.go index d27bb044..5809583a 100644 --- a/cmd/workflow/simulate/chain/evm/health_more_test.go +++ b/cmd/workflow/simulate/chain/evm/health_more_test.go @@ -22,7 +22,7 @@ func TestHealthCheck_ExperimentalSelector_UsesExperimentalLabel(t *testing.T) { defer c.Close() const expSel uint64 = 99999999 - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{expSel: c}, map[uint64]bool{expSel: true}, ) @@ -40,7 +40,7 @@ func TestHealthCheck_ExperimentalSelector_ZeroChainID_UsesExperimentalLabel(t *t defer c.Close() const expSel uint64 = 42424242 - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{expSel: c}, map[uint64]bool{expSel: true}, ) @@ -58,7 +58,7 @@ func TestHealthCheck_UnknownSelector_FallsBackToSelectorLabel(t *testing.T) { defer c.Close() const unknown uint64 = 11111 - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{unknown: c}, nil, ) @@ -80,7 +80,7 @@ func TestHealthCheck_MixedKnownAndExperimental(t *testing.T) { defer cErr.Close() const expSel uint64 = 99999999 - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{ selectorSepolia: cOK, expSel: cErr, @@ -107,7 +107,7 @@ func TestHealthCheck_MultipleOK_NoError(t *testing.T) { cOK2 := newEthClient(t, sOK2.URL) defer cOK2.Close() - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{ selectorSepolia: cOK, chainEthMainnet: cOK2, @@ -125,7 +125,7 @@ func TestHealthCheck_EmptyExperimentalMap_StillWorks(t *testing.T) { c := newEthClient(t, sOK.URL) defer c.Close() - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{selectorSepolia: c}, map[uint64]bool{}, ) @@ -138,7 +138,7 @@ func TestHealthCheck_NilExperimentalMap_EquivalentToEmpty(t *testing.T) { c := newEthClient(t, sOK.URL) defer c.Close() - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{selectorSepolia: c}, nil, ) @@ -186,7 +186,7 @@ func TestHealthCheck_ThreeErrors_AllLabelsInAggregated(t *testing.T) { cErr2 := newEthClient(t, sErr2.URL) defer cErr2.Close() - err := runRPCHealthCheck( + err := checkRPCConnectivity( map[uint64]*ethclient.Client{ selectorSepolia: cErr1, chainEthMainnet: cErr2, diff --git a/cmd/workflow/simulate/chain/evm/health_test.go b/cmd/workflow/simulate/chain/evm/health_test.go index 18ed6889..e4ef6c76 100644 --- a/cmd/workflow/simulate/chain/evm/health_test.go +++ b/cmd/workflow/simulate/chain/evm/health_test.go @@ -67,7 +67,7 @@ func mustContain(t *testing.T, s string, subs ...string) { } func TestHealthCheck_NoClientsConfigured(t *testing.T) { - err := runRPCHealthCheck(map[uint64]*ethclient.Client{}, nil) + err := checkRPCConnectivity(map[uint64]*ethclient.Client{}, nil) if err == nil { t.Fatalf("expected error for no clients configured") } @@ -75,7 +75,7 @@ func TestHealthCheck_NoClientsConfigured(t *testing.T) { } func TestHealthCheck_NilClient(t *testing.T) { - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ 123: nil, }, nil) if err == nil { @@ -91,7 +91,7 @@ func TestHealthCheck_AllOK(t *testing.T) { cOK := newEthClient(t, sOK.URL) defer cOK.Close() - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ selectorSepolia: cOK, }, nil) if err != nil { @@ -106,7 +106,7 @@ func TestHealthCheck_RPCError_usesChainName(t *testing.T) { cErr := newEthClient(t, sErr.URL) defer cErr.Close() - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ selectorSepolia: cErr, }, nil) if err == nil { @@ -125,7 +125,7 @@ func TestHealthCheck_ZeroChainID_usesChainName(t *testing.T) { cZero := newEthClient(t, sZero.URL) defer cZero.Close() - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ selectorSepolia: cZero, }, nil) if err == nil { @@ -144,7 +144,7 @@ func TestHealthCheck_AggregatesMultipleErrors(t *testing.T) { cErr := newEthClient(t, sErr.URL) defer cErr.Close() - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ selectorSepolia: cErr, 777: nil, }, nil) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 77a1a4c8..ab05b627 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -30,6 +30,7 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + // Register the EVM chain family via package init. _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" From 086ee934b9b40b7028fd6e36d7c8b153962bcfe5 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 20:03:41 +0100 Subject: [PATCH 12/26] fix(lint): inline comment on blank import to satisfy goimports --- cmd/workflow/simulate/simulate.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index ab05b627..2bfd0d80 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -30,8 +30,7 @@ import ( cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" - // Register the EVM chain family via package init. - _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" + _ "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" From 3916398d78725e65f63028199e13d6b343c95188 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Thu, 16 Apr 2026 22:35:19 +0100 Subject: [PATCH 13/26] refactor: address review feedback on chain family registry Consolidated review-feedback fixes on top of the chain-family refactor. Interface cleanup (makes the abstraction live up to its chain-agnostic name): - chain.Limits: narrow to ChainWriteReportSizeLimit only; move ChainWriteGasLimit onto a new evm.EVMChainLimits sub-interface so EVM-specific accessors do not leak into the generic contract. - chain.ResolvedChains: new struct returned from ResolveClients, carrying clients, forwarders, and experimental-selector flags. Replaces the hidden cross-method state the EVM implementation was keeping on its struct for RunHealthCheck to read. - ChainType.ResolveClients and ChainType.RunHealthCheck adopt the new return/argument shape. User-visible parity with main: - Per-family error prefix restored when trigger-data resolution fails, using the family name (e.g. 'Failed to get evm trigger data: ...'). - 'No {name} chain initialized for selector %d' uses the registry-key name rather than a manually-uppercased variant. Small cleanups touched on the way: - Hoist the 'RPC health check failed:' wrap from evm.RunRPCHealthCheck to the simulate.go caller so a future second chain family cannot produce doubled headers. - Remove dead ManualTriggers.Close (no callers before or after this PR). - Rename the EVMChainLimits test stub to stubEVMLimits to stop it colliding by one letter with the production EVMChainLimits alias. --- cmd/workflow/simulate/chain/evm/chaintype.go | 44 ++++++++++++------- .../simulate/chain/evm/chaintype_test.go | 9 +++- cmd/workflow/simulate/chain/evm/health.go | 5 ++- .../simulate/chain/evm/health_more_test.go | 2 - .../simulate/chain/evm/health_test.go | 5 +-- .../chain/evm/limited_capabilities.go | 11 +++-- .../limited_capabilities_delegation_test.go | 30 ++++++------- .../chain/evm/limited_capabilities_test.go | 12 ++--- cmd/workflow/simulate/chain/registry.go | 16 ++++--- cmd/workflow/simulate/chain/registry_test.go | 11 +++-- cmd/workflow/simulate/chain/types.go | 21 ++++++--- cmd/workflow/simulate/simulate.go | 32 +++++++++----- 12 files changed, 120 insertions(+), 78 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go index 9814b5f2..5cd7dda7 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype.go +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -35,9 +35,8 @@ func init() { // EVMChainType implements chain.ChainType for EVM-based blockchains. type EVMChainType struct { - log *zerolog.Logger - evmChains *EVMChainCapabilities - experimentalSelectors map[uint64]bool + log *zerolog.Logger + evmChains *EVMChainCapabilities } var _ chain.ChainType = (*EVMChainType)(nil) @@ -48,7 +47,7 @@ func (ct *EVMChainType) SupportedChains() []chain.ChainConfig { return SupportedChains } -func (ct *EVMChainType) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainClient, map[uint64]string, error) { +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) @@ -81,18 +80,18 @@ func (ct *EVMChainType) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainCl // Resolve experimental chains expChains, err := settings.GetExperimentalChains(v) if err != nil { - return nil, nil, fmt.Errorf("failed to load experimental chains config: %w", err) + return chain.ResolvedChains{}, fmt.Errorf("failed to load experimental chains config: %w", err) } for _, ec := range expChains { if ec.ChainSelector == 0 { - return nil, nil, fmt.Errorf("experimental chain missing chain-selector") + return chain.ResolvedChains{}, fmt.Errorf("experimental chain missing chain-selector") } if strings.TrimSpace(ec.RPCURL) == "" { - return nil, nil, fmt.Errorf("experimental chain %d missing rpc-url", ec.ChainSelector) + return chain.ResolvedChains{}, fmt.Errorf("experimental chain %d missing rpc-url", ec.ChainSelector) } if strings.TrimSpace(ec.Forwarder) == "" { - return nil, nil, fmt.Errorf("experimental chain %d missing forwarder", ec.ChainSelector) + return chain.ResolvedChains{}, fmt.Errorf("experimental chain %d missing forwarder", ec.ChainSelector) } // For duplicate selectors, keep the supported client and only @@ -111,7 +110,7 @@ func (ct *EVMChainType) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainCl 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 nil, nil, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) + 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 @@ -119,8 +118,11 @@ func (ct *EVMChainType) ResolveClients(v *viper.Viper) (map[uint64]chain.ChainCl ui.Dim(fmt.Sprintf("Added experimental chain (chain-selector: %d)\n", ec.ChainSelector)) } - ct.experimentalSelectors = experimental - return clients, forwarders, nil + return chain.ResolvedChains{ + Clients: clients, + Forwarders: forwarders, + ExperimentalSelectors: experimental, + }, nil } func (ct *EVMChainType) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) ([]services.Service, error) { @@ -152,12 +154,22 @@ func (ct *EVMChainType) RegisterCapabilities(ctx context.Context, cfg chain.Capa dryRun := !cfg.Broadcast - // cfg.Limits already satisfies EVMChainLimits via the chain.Limits interface - // contract; no type assertion needed. + // 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, evmForwarders, pk, - dryRun, cfg.Limits, + dryRun, evmLimits, ) if err != nil { return nil, err @@ -206,8 +218,8 @@ func (ct *EVMChainType) ParseTriggerChainSelector(triggerID string) (uint64, boo return ParseTriggerChainSelector(triggerID) } -func (ct *EVMChainType) RunHealthCheck(clients map[uint64]chain.ChainClient) error { - return RunRPCHealthCheck(clients, ct.experimentalSelectors) +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 diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index 62dfa18d..132e0cc0 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -395,7 +395,10 @@ func TestEVMChainType_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing. func TestEVMChainType_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { t.Parallel() ct := newEVMChainType() - err := ct.RunHealthCheck(map[uint64]chain.ChainClient{1: "not-ethclient"}) + 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") } @@ -403,7 +406,9 @@ func TestEVMChainType_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { func TestEVMChainType_RunHealthCheck_NoClients_Errors(t *testing.T) { t.Parallel() ct := newEVMChainType() - err := ct.RunHealthCheck(map[uint64]chain.ChainClient{}) + err := ct.RunHealthCheck(chain.ResolvedChains{ + Clients: map[uint64]chain.ChainClient{}, + }) require.Error(t, err) assert.Contains(t, err.Error(), "no RPC URLs found") } diff --git a/cmd/workflow/simulate/chain/evm/health.go b/cmd/workflow/simulate/chain/evm/health.go index a292a9ea..05dde43f 100644 --- a/cmd/workflow/simulate/chain/evm/health.go +++ b/cmd/workflow/simulate/chain/evm/health.go @@ -71,7 +71,10 @@ func checkRPCConnectivity(clients map[uint64]*ethclient.Client, experimentalSele } if len(errs) > 0 { - return fmt.Errorf("RPC health check failed:\n%w", errors.Join(errs...)) + // 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_more_test.go b/cmd/workflow/simulate/chain/evm/health_more_test.go index 5809583a..638c314b 100644 --- a/cmd/workflow/simulate/chain/evm/health_more_test.go +++ b/cmd/workflow/simulate/chain/evm/health_more_test.go @@ -28,7 +28,6 @@ func TestHealthCheck_ExperimentalSelector_UsesExperimentalLabel(t *testing.T) { ) require.Error(t, err) mustContain(t, err.Error(), - "RPC health check failed", "[experimental chain 99999999]", ) } @@ -89,7 +88,6 @@ func TestHealthCheck_MixedKnownAndExperimental(t *testing.T) { ) require.Error(t, err) mustContain(t, err.Error(), - "RPC health check failed", "[experimental chain 99999999] failed RPC health check", ) // sepolia is healthy; its label must not appear. diff --git a/cmd/workflow/simulate/chain/evm/health_test.go b/cmd/workflow/simulate/chain/evm/health_test.go index e4ef6c76..11880f89 100644 --- a/cmd/workflow/simulate/chain/evm/health_test.go +++ b/cmd/workflow/simulate/chain/evm/health_test.go @@ -81,7 +81,7 @@ func TestHealthCheck_NilClient(t *testing.T) { if err == nil { t.Fatalf("expected error for nil client") } - mustContain(t, err.Error(), "RPC health check failed", "[123] nil client") + mustContain(t, err.Error(), "[123] nil client") } func TestHealthCheck_AllOK(t *testing.T) { @@ -113,7 +113,6 @@ func TestHealthCheck_RPCError_usesChainName(t *testing.T) { t.Fatalf("expected error for RPC failure") } mustContain(t, err.Error(), - "RPC health check failed", "[ethereum-testnet-sepolia] failed RPC health check", ) } @@ -132,7 +131,6 @@ func TestHealthCheck_ZeroChainID_usesChainName(t *testing.T) { 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", ) } @@ -152,7 +150,6 @@ func TestHealthCheck_AggregatesMultipleErrors(t *testing.T) { t.Fatalf("expected aggregated error") } mustContain(t, err.Error(), - "RPC health check failed", "[ethereum-testnet-sepolia] failed RPC health check", "[777] nil client", ) diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go index 07189ee2..b7b50e02 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -13,10 +13,13 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) -// EVMChainLimits is the limit-accessor contract LimitedEVMChain enforces. -// Aliased to chain.Limits so the chain-type-agnostic CapabilityConfig.Limits -// value can be passed straight through without a type assertion. -type EVMChainLimits = chain.Limits +// 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. diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go index 5be50d22..2247b515 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go @@ -190,7 +190,7 @@ func TestLimitedEVMChain_AllMethodsDelegate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{}) tt.call(w, stub) assert.Equal(t, 1, stub.calls[tt.wants], "expected 1 call to %s", tt.wants) }) @@ -204,7 +204,7 @@ func TestLimitedEVMChain_AllMethodsDelegate(t *testing.T) { func TestLimitedEVMChain_WriteReport_NilReport_Delegates(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 4}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 4}) resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{Report: nil}) require.NoError(t, err) assert.NotNil(t, resp) @@ -214,7 +214,7 @@ func TestLimitedEVMChain_WriteReport_NilReport_Delegates(t *testing.T) { func TestLimitedEVMChain_WriteReport_NilGasConfig_Delegates(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 100}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 100}) resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{GasConfig: nil}) require.NoError(t, err) assert.NotNil(t, resp) @@ -224,7 +224,7 @@ func TestLimitedEVMChain_WriteReport_NilGasConfig_Delegates(t *testing.T) { func TestLimitedEVMChain_WriteReport_ZeroReportLimit_Disabled(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 0}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 0}) resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ Report: &sdkpb.ReportResponse{RawReport: make([]byte, 1<<20)}, }) @@ -236,7 +236,7 @@ func TestLimitedEVMChain_WriteReport_ZeroReportLimit_Disabled(t *testing.T) { func TestLimitedEVMChain_WriteReport_ZeroGasLimit_Disabled(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 0}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 0}) resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ GasConfig: &evmcappb.GasConfig{GasLimit: 1 << 30}, }) @@ -248,7 +248,7 @@ func TestLimitedEVMChain_WriteReport_ZeroGasLimit_Disabled(t *testing.T) { func TestLimitedEVMChain_WriteReport_GasBoundaryEqualsLimit_Delegates(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 1000}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 1000}) resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ GasConfig: &evmcappb.GasConfig{GasLimit: 1000}, }) @@ -259,7 +259,7 @@ func TestLimitedEVMChain_WriteReport_GasBoundaryEqualsLimit_Delegates(t *testing func TestLimitedEVMChain_WriteReport_ReportBoundaryEqualsLimit_Delegates(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 5}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 5}) resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, }) @@ -270,7 +270,7 @@ func TestLimitedEVMChain_WriteReport_ReportBoundaryEqualsLimit_Delegates(t *test func TestLimitedEVMChain_WriteReport_ReportOneOverLimit_Rejects(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 5}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 5}) _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ Report: &sdkpb.ReportResponse{RawReport: []byte("123456")}, }) @@ -282,7 +282,7 @@ func TestLimitedEVMChain_WriteReport_ReportOneOverLimit_Rejects(t *testing.T) { func TestLimitedEVMChain_WriteReport_GasOneOverLimit_Rejects(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{gasLimit: 1_000_000}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 1_000_000}) _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ GasConfig: &evmcappb.GasConfig{GasLimit: 1_000_001}, }) @@ -295,7 +295,7 @@ func TestLimitedEVMChain_WriteReport_ReportCheckedBeforeGas(t *testing.T) { // When both fail, report-size error is surfaced first. t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 1, gasLimit: 1}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 1, gasLimit: 1}) _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ Report: &sdkpb.ReportResponse{RawReport: []byte("ab")}, GasConfig: &evmcappb.GasConfig{GasLimit: 999}, @@ -308,7 +308,7 @@ func TestLimitedEVMChain_WriteReport_ReportCheckedBeforeGas(t *testing.T) { func TestLimitedEVMChain_WriteReport_ReturnsResourceExhaustedCode(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{reportSizeLimit: 1}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 1}) _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ Report: &sdkpb.ReportResponse{RawReport: []byte("too-big")}, }) @@ -319,7 +319,7 @@ func TestLimitedEVMChain_WriteReport_ReturnsResourceExhaustedCode(t *testing.T) func TestLimitedEVMChain_Constructor_StoresInnerAndLimits(t *testing.T) { t.Parallel() stub := newFullStub() - limits := &EVMChainLimit{reportSizeLimit: 7, gasLimit: 11} + limits := &stubEVMLimits{reportSizeLimit: 7, gasLimit: 11} w := NewLimitedEVMChain(stub, limits) require.NotNil(t, w) // Verify Description delegates (indirectly proves inner is stored) @@ -329,21 +329,21 @@ func TestLimitedEVMChain_Constructor_StoresInnerAndLimits(t *testing.T) { func TestLimitedEVMChain_ChainSelector_ReflectsInner(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{}) require.Equal(t, uint64(42), w.ChainSelector()) } func TestLimitedEVMChain_Name_ReflectsInner(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{}) require.Equal(t, "stub-chain", w.Name()) } func TestLimitedEVMChain_HealthReport_ReflectsInner(t *testing.T) { t.Parallel() stub := newFullStub() - w := NewLimitedEVMChain(stub, &EVMChainLimit{}) + w := NewLimitedEVMChain(stub, &stubEVMLimits{}) hr := w.HealthReport() _, ok := hr["ok"] require.True(t, ok) diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go index fb99ae84..362a3bb4 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -15,13 +15,13 @@ import ( sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" ) -type EVMChainLimit struct { +type stubEVMLimits struct { reportSizeLimit int gasLimit uint64 } -func (s *EVMChainLimit) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } -func (s *EVMChainLimit) ChainWriteGasLimit() uint64 { return s.gasLimit } +func (s *stubEVMLimits) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } +func (s *stubEVMLimits) ChainWriteGasLimit() uint64 { return s.gasLimit } type evmCapabilityBaseStub struct{} @@ -94,7 +94,7 @@ func (s *evmClientCapabilityStub) ChainSelector() uint64 { return 0 } func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { t.Parallel() - limits := &EVMChainLimit{reportSizeLimit: 4} + limits := &stubEVMLimits{reportSizeLimit: 4} inner := &evmClientCapabilityStub{} wrapper := NewLimitedEVMChain(inner, limits) @@ -110,7 +110,7 @@ func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { t.Parallel() - limits := &EVMChainLimit{gasLimit: 10} + limits := &stubEVMLimits{gasLimit: 10} inner := &evmClientCapabilityStub{} wrapper := NewLimitedEVMChain(inner, limits) @@ -126,7 +126,7 @@ func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { t.Parallel() - limits := &EVMChainLimit{reportSizeLimit: 4, gasLimit: 10} + limits := &stubEVMLimits{reportSizeLimit: 4, gasLimit: 10} input := &evmcappb.WriteReportRequest{ Report: &sdkpb.ReportResponse{RawReport: []byte("1234")}, diff --git a/cmd/workflow/simulate/chain/registry.go b/cmd/workflow/simulate/chain/registry.go index a7549929..6ed6e11a 100644 --- a/cmd/workflow/simulate/chain/registry.go +++ b/cmd/workflow/simulate/chain/registry.go @@ -26,11 +26,12 @@ 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 clients keyed by chain selector, and forwarder addresses - // for chains that have them. - ResolveClients(v *viper.Viper) (clients map[uint64]ChainClient, forwarders map[uint64]string, err error) + // 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 @@ -64,7 +65,10 @@ type ChainType interface { ParseTriggerChainSelector(triggerID string) (uint64, bool) // RunHealthCheck validates RPC connectivity for all resolved clients. - RunHealthCheck(clients map[uint64]ChainClient) error + // 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). diff --git a/cmd/workflow/simulate/chain/registry_test.go b/cmd/workflow/simulate/chain/registry_test.go index 27439d2b..d38bf33e 100644 --- a/cmd/workflow/simulate/chain/registry_test.go +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -35,11 +35,10 @@ func (m *mockChainType) Name() string { return args.String(0) } -func (m *mockChainType) ResolveClients(v *viper.Viper) (map[uint64]ChainClient, map[uint64]string, error) { +func (m *mockChainType) ResolveClients(v *viper.Viper) (ResolvedChains, error) { args := m.Called(v) - clients, _ := args.Get(0).(map[uint64]ChainClient) - forwarders, _ := args.Get(1).(map[uint64]string) - return clients, forwarders, args.Error(2) + resolved, _ := args.Get(0).(ResolvedChains) + return resolved, args.Error(1) } func (m *mockChainType) RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) ([]services.Service, error) { @@ -63,8 +62,8 @@ func (m *mockChainType) ParseTriggerChainSelector(triggerID string) (uint64, boo return args.Get(0).(uint64), args.Bool(1) } -func (m *mockChainType) RunHealthCheck(clients map[uint64]ChainClient) error { - args := m.Called(clients) +func (m *mockChainType) RunHealthCheck(resolved ResolvedChains) error { + args := m.Called(resolved) return args.Error(0) } diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go index 058e10b7..12f8c1cb 100644 --- a/cmd/workflow/simulate/chain/types.go +++ b/cmd/workflow/simulate/chain/types.go @@ -15,13 +15,24 @@ type ChainConfig struct { Forwarder string // chain-type-specific forwarding address } -// Limits exposes the chain-write limit accessors a chain type needs at -// capability-registration time. Implementations live in the parent simulate -// package; this interface is defined here so CapabilityConfig stays in the -// chain package without an import cycle. +// 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 - ChainWriteGasLimit() uint64 +} + +// 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. diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 2bfd0d80..bb33ac32 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -53,6 +53,11 @@ type Inputs struct { ChainTypeClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` ChainTypeForwarders map[string]map[uint64]string `validate:"-"` ChainTypeKeys map[string]interface{} `validate:"-"` + // ChainTypeResolved holds the full ResolveClients bundle per chain type + // so later steps (health check) have access to any chain-type-agnostic + // metadata (e.g. experimental-selector flags) without relying on hidden + // state on the ChainType instance. + ChainTypeResolved map[string]chain.ResolvedChains `validate:"-"` // Non-interactive mode options NonInteractive bool `validate:"-"` TriggerIndex int `validate:"-"` @@ -127,17 +132,19 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) ctClients := make(map[string]map[uint64]chain.ChainClient) ctForwarders := make(map[string]map[uint64]string) + ctResolved := make(map[string]chain.ResolvedChains) ctKeys := make(map[string]interface{}) for name, ct := range chain.All() { - clients, forwarders, err := ct.ResolveClients(v) + resolved, err := ct.ResolveClients(v) if err != nil { return Inputs{}, fmt.Errorf("failed to resolve %s clients: %w", name, err) } - if len(clients) > 0 { - ctClients[name] = clients - ctForwarders[name] = forwarders + if len(resolved.Clients) > 0 { + ctClients[name] = resolved.Clients + ctForwarders[name] = resolved.Forwarders + ctResolved[name] = resolved } } @@ -173,6 +180,7 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) Broadcast: v.GetBool("broadcast"), ChainTypeClients: ctClients, ChainTypeForwarders: ctForwarders, + ChainTypeResolved: ctResolved, ChainTypeKeys: ctKeys, WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, NonInteractive: v.GetBool("non-interactive"), @@ -210,14 +218,16 @@ func (h *handler) ValidateInputs(inputs Inputs) error { rpcErr := ui.WithSpinner("Checking RPC connectivity...", func() error { var errs []error for name, ct := range chain.All() { - if clients, ok := inputs.ChainTypeClients[name]; ok { - if err := ct.RunHealthCheck(clients); err != nil { - errs = append(errs, err) - } + resolved, ok := inputs.ChainTypeResolved[name] + if !ok { + continue + } + if err := ct.RunHealthCheck(resolved); err != nil { + errs = append(errs, err) } } if len(errs) > 0 { - return errors.Join(errs...) + return fmt.Errorf("RPC health check failed:\n%w", errors.Join(errs...)) } return nil }) @@ -721,7 +731,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, true) if err != nil { - ui.Error(err.Error()) + ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) os.Exit(1) } @@ -801,7 +811,7 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, false) if err != nil { - ui.Error(err.Error()) + ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) os.Exit(1) } From cfed4e591a1a2ec0c1db02d81d47741921b0a6ae Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 11:47:39 +0100 Subject: [PATCH 14/26] fix: restore disableEngineLimits call site lost in merge Merge from main pulled in the disableEngineLimits function but the call site in WorkflowSettingsCfgFn was dropped during conflict resolution, leaving the function unused. Re-add the `--limits none` branch so engine limits are actually disabled when requested. --- cmd/workflow/simulate/simulate.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 3e57a248..1a4380a1 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -647,6 +647,8 @@ func run( // Apply simulation limits to engine-level settings when --limits is set if simLimits != nil { applyEngineLimits(cfg, simLimits) + } else if inputs.LimitsPath == "none" { + disableEngineLimits(cfg) } // Always allow all chains in simulation, overriding any chain restrictions from limits cfg.ChainAllowed = commonsettings.PerChainSelector( From 932a82576ca88c6b05fc785e555df5521858ae55 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 11:57:54 +0100 Subject: [PATCH 15/26] chore: restore getHTTPTriggerPayload comments to match main --- cmd/workflow/simulate/simulate.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 1a4380a1..3ea85c00 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -889,7 +889,11 @@ func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) var jsonData map[string]interface{} + // Resolve the path against the invocation directory so that relative paths + // like ./production.json work from where the user ran the command, even though + // the process cwd has been changed to the workflow subdirectory. resolvedPath := resolvePathFromInvocation(input, invocationDir) + if _, err := os.Stat(resolvedPath); err == nil { data, err := os.ReadFile(resolvedPath) if err != nil { @@ -900,7 +904,7 @@ func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) } ui.Success(fmt.Sprintf("Loaded JSON from file: %s", resolvedPath)) } else { - // It's direct JSON input + // Treat as direct JSON input if err := json.Unmarshal([]byte(input), &jsonData); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } @@ -911,7 +915,6 @@ func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) if err != nil { return nil, fmt.Errorf("failed to marshal JSON: %w", err) } - // Create the payload payload := &httptypedapi.Payload{ Input: jsonDataBytes, // Key is optional for simulation From ce7adf30627b98890b195642fba1397da34ac6a6 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 12:02:42 +0100 Subject: [PATCH 16/26] refactor: rename manualTriggers to triggerCaps to match main --- cmd/workflow/simulate/simulate.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 3ea85c00..dd775410 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -428,7 +428,7 @@ func run( initializedCh := make(chan struct{}) executionFinishedCh := make(chan struct{}) - var manualTriggers *ManualTriggers + var triggerCaps *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 @@ -461,7 +461,7 @@ func run( // Register chain-agnostic cron and HTTP triggers triggerLggr := lggr.Named("TriggerCapabilities") var err error - manualTriggers, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) + triggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) if err != nil { ui.Error(fmt.Sprintf("Failed to create cron/HTTP trigger capabilities: %v", err)) os.Exit(1) @@ -506,7 +506,7 @@ func run( } // Start trigger capabilities - if err := manualTriggers.Start(ctx); err != nil { + if err := triggerCaps.Start(ctx); err != nil { ui.Error(fmt.Sprintf("Failed to start cron/HTTP triggers: %v", err)) os.Exit(1) } @@ -519,7 +519,7 @@ func run( } } - srvcs = append(srvcs, manualTriggers.ManualCronTrigger, manualTriggers.ManualHTTPTrigger) + srvcs = append(srvcs, triggerCaps.ManualCronTrigger, triggerCaps.ManualHTTPTrigger) srvcs = append(srvcs, computeCaps...) return registry, srvcs } @@ -527,11 +527,11 @@ func run( // Create a holder for trigger info that will be populated in beforeStart triggerInfoAndBeforeStart := &TriggerInfoAndBeforeStart{} - getManualTriggers := func() *ManualTriggers { return manualTriggers } + getTriggerCaps := func() *ManualTriggers { return triggerCaps } if inputs.NonInteractive { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggers) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) } else { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggers) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) } waitFn := func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service) { @@ -668,7 +668,7 @@ type TriggerInfoAndBeforeStart struct { } // makeBeforeStartInteractive builds the interactive BeforeStart closure -func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggersGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, triggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -707,12 +707,12 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", triggerIndex) trigger := holder.TriggerToRun.Id - manualTriggerCaps := manualTriggersGetter() + triggerCaps := triggerCapsGetter() switch trigger { case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { - return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) + return triggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) } case "http-trigger@1.0.0-alpha": payload, err := getHTTPTriggerPayload(inputs.InvocationDir) @@ -721,7 +721,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs os.Exit(1) } holder.TriggerFunc = func() error { - return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) + return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } default: // Try each registered chain type @@ -759,7 +759,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs } // makeBeforeStartNonInteractive builds the non-interactive BeforeStart closure -func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggersGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, triggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -783,12 +783,12 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp holder.TriggerToRun = triggerSub[inputs.TriggerIndex] triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", inputs.TriggerIndex) trigger := holder.TriggerToRun.Id - manualTriggerCaps := manualTriggersGetter() + triggerCaps := triggerCapsGetter() switch trigger { case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { - return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) + return triggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) } case "http-trigger@1.0.0-alpha": if strings.TrimSpace(inputs.HTTPPayload) == "" { @@ -801,7 +801,7 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp os.Exit(1) } holder.TriggerFunc = func() error { - return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) + return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } default: // Try each registered chain type From 49ec92a8fae7e50bfdc9c0950224cf9b60bf70a2 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 12:05:39 +0100 Subject: [PATCH 17/26] refactor: rename triggerCaps to manualTriggerCaps for clarity Distinguish manual trigger capabilities (cron, HTTP) from chain-related capabilities by using an explicit name at the call sites. --- cmd/workflow/simulate/simulate.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index dd775410..b7146848 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -428,7 +428,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 @@ -461,7 +461,7 @@ func run( // Register chain-agnostic cron and HTTP triggers triggerLggr := lggr.Named("TriggerCapabilities") var err error - triggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) + manualTriggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) if err != nil { ui.Error(fmt.Sprintf("Failed to create cron/HTTP trigger capabilities: %v", err)) os.Exit(1) @@ -506,7 +506,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 cron/HTTP triggers: %v", err)) os.Exit(1) } @@ -519,7 +519,7 @@ func run( } } - srvcs = append(srvcs, triggerCaps.ManualCronTrigger, triggerCaps.ManualHTTPTrigger) + srvcs = append(srvcs, manualTriggerCaps.ManualCronTrigger, manualTriggerCaps.ManualHTTPTrigger) srvcs = append(srvcs, computeCaps...) return registry, srvcs } @@ -527,11 +527,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) { @@ -668,7 +668,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, @@ -707,12 +707,12 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", triggerIndex) trigger := holder.TriggerToRun.Id - triggerCaps := triggerCapsGetter() + manualTriggerCaps := manualTriggerCapsGetter() 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 "http-trigger@1.0.0-alpha": payload, err := getHTTPTriggerPayload(inputs.InvocationDir) @@ -721,7 +721,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs os.Exit(1) } holder.TriggerFunc = func() error { - return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) + return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } default: // Try each registered chain type @@ -759,7 +759,7 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs } // 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, @@ -783,12 +783,12 @@ 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 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 "http-trigger@1.0.0-alpha": if strings.TrimSpace(inputs.HTTPPayload) == "" { @@ -801,7 +801,7 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp os.Exit(1) } holder.TriggerFunc = func() error { - return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) + return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } default: // Try each registered chain type From bc305e8161b5f6169502cc547362bdbe7f42c49b Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 12:10:40 +0100 Subject: [PATCH 18/26] refactor: drop redundant ChainTypeClients presence check HasSelector guard upstream and per-selector check inside ResolveTriggerData make the map-level check unreachable; remove to reduce indirection. --- cmd/workflow/simulate/simulate.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index b7146848..b55fc01e 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -927,12 +927,8 @@ func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) // 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) { - clients, ok := inputs.ChainTypeClients[ct.Name()] - if !ok { - return nil, fmt.Errorf("no %s clients configured", ct.Name()) - } return ct.ResolveTriggerData(ctx, selector, chain.TriggerParams{ - Clients: clients, + Clients: inputs.ChainTypeClients[ct.Name()], Interactive: interactive, ChainTypeInputs: inputs.ChainTypeInputs, }) From c71c91e4b6b071c793438ba8f8e4963e713cb4b0 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 12:34:56 +0100 Subject: [PATCH 19/26] chore: restore trigger start error message to match main --- cmd/workflow/simulate/simulate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index b55fc01e..feb7e5dc 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -507,7 +507,7 @@ func run( // Start trigger capabilities if err := manualTriggerCaps.Start(ctx); err != nil { - ui.Error(fmt.Sprintf("Failed to start cron/HTTP triggers: %v", err)) + ui.Error(fmt.Sprintf("Failed to start trigger: %v", err)) os.Exit(1) } From 9462cb4a2455ecbd173bd4825a6bfe794d15a145 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 13:09:34 +0100 Subject: [PATCH 20/26] refactor: restore service append order + trigger caps error message - Append manualTriggerCaps cron/HTTP services immediately after creation so the shutdown order matches main (cron/HTTP before chain-type services). - Revert "Failed to create cron/HTTP trigger capabilities" back to "Failed to create trigger capabilities" to match main. --- cmd/workflow/simulate/simulate.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index feb7e5dc..0a4acb97 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -463,9 +463,10 @@ func run( var err error manualTriggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) if err != nil { - ui.Error(fmt.Sprintf("Failed to create cron/HTTP trigger capabilities: %v", err)) + 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). @@ -519,7 +520,6 @@ func run( } } - srvcs = append(srvcs, manualTriggerCaps.ManualCronTrigger, manualTriggerCaps.ManualHTTPTrigger) srvcs = append(srvcs, computeCaps...) return registry, srvcs } From 683fd4d3b3f540f02f20b14a8b9c07dd27c5a7c0 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 14:00:57 +0100 Subject: [PATCH 21/26] refactor: tighten chain capability wiring - Rename ManualTriggers receiver t -> m to match main. - Use corekeys.EVM directly for chain.Register; import in chain/evm package. - Push string -> common.Address forwarder conversion into NewEVMChainCapabilities so it happens only for chains with clients; drop intermediate map in RegisterCapabilities. - Remove Inputs.ChainTypeForwarders; read forwarders via ChainTypeResolved[name].Forwarders for a single source of truth. --- cmd/workflow/simulate/capabilities.go | 12 +++++------ .../simulate/chain/evm/capabilities.go | 6 +++--- cmd/workflow/simulate/chain/evm/chaintype.go | 13 ++++-------- cmd/workflow/simulate/simulate.go | 21 +++++++------------ 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/cmd/workflow/simulate/capabilities.go b/cmd/workflow/simulate/capabilities.go index 2312317c..5cd28a45 100644 --- a/cmd/workflow/simulate/capabilities.go +++ b/cmd/workflow/simulate/capabilities.go @@ -46,13 +46,13 @@ func NewManualTriggerCapabilities(ctx context.Context, lggr logger.Logger, regis } // Start starts cron and HTTP trigger services. -func (t *ManualTriggers) Start(ctx context.Context) error { - err := t.ManualCronTrigger.Start(ctx) +func (m *ManualTriggers) Start(ctx context.Context) error { + err := m.ManualCronTrigger.Start(ctx) if err != nil { return err } - err = t.ManualHTTPTrigger.Start(ctx) + err = m.ManualHTTPTrigger.Start(ctx) if err != nil { return err } @@ -61,13 +61,13 @@ func (t *ManualTriggers) Start(ctx context.Context) error { } // Close closes cron and HTTP trigger services. -func (t *ManualTriggers) Close() error { - err := t.ManualCronTrigger.Close() +func (m *ManualTriggers) Close() error { + err := m.ManualCronTrigger.Close() if err != nil { return err } - err = t.ManualHTTPTrigger.Close() + err = m.ManualHTTPTrigger.Close() if err != nil { return err } diff --git a/cmd/workflow/simulate/chain/evm/capabilities.go b/cmd/workflow/simulate/chain/evm/capabilities.go index e2ebafce..22f000b1 100644 --- a/cmd/workflow/simulate/chain/evm/capabilities.go +++ b/cmd/workflow/simulate/chain/evm/capabilities.go @@ -26,14 +26,14 @@ func NewEVMChainCapabilities( lggr logger.Logger, registry *capabilities.Registry, clients map[uint64]*ethclient.Client, - forwarders map[uint64]common.Address, + forwarders map[uint64]string, privateKey *ecdsa.PrivateKey, dryRunChainWrite bool, limits EVMChainLimits, ) (*EVMChainCapabilities, error) { evmChains := make(map[uint64]*fakes.FakeEVMChain) for sel, client := range clients { - fwd, ok := forwarders[sel] + fwdStr, ok := forwarders[sel] if !ok { lggr.Infow("Forwarder not found for chain", "selector", sel) continue @@ -43,7 +43,7 @@ func NewEVMChainCapabilities( lggr, client, privateKey, - fwd, + common.HexToAddress(fwdStr), sel, dryRunChainWrite, ) diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go index 5cd7dda7..ff09731b 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype.go +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -14,6 +14,7 @@ import ( "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" @@ -25,7 +26,7 @@ import ( const defaultSentinelPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001" func init() { - chain.Register("evm", func(lggr *zerolog.Logger) chain.ChainType { + 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}, @@ -52,7 +53,7 @@ func (ct *EVMChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, er forwarders := make(map[uint64]string) experimental := make(map[uint64]bool) - // Resolve supported chains + // 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 { @@ -136,12 +137,6 @@ func (ct *EVMChainType) RegisterCapabilities(ctx context.Context, cfg chain.Capa ethClients[sel] = ec } - // Convert string forwarders to common.Address - evmForwarders := make(map[uint64]common.Address) - for sel, fwd := range cfg.Forwarders { - evmForwarders[sel] = common.HexToAddress(fwd) - } - // Type-assert the private key var pk *ecdsa.PrivateKey if cfg.PrivateKey != nil { @@ -168,7 +163,7 @@ func (ct *EVMChainType) RegisterCapabilities(ctx context.Context, cfg chain.Capa evmCaps, err := NewEVMChainCapabilities( ctx, cfg.Logger, cfg.Registry, - ethClients, evmForwarders, pk, + ethClients, cfg.Forwarders, pk, dryRun, evmLimits, ) if err != nil { diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 0a4acb97..acb76232 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -51,13 +51,11 @@ type Inputs struct { Broadcast bool `validate:"-"` WorkflowName string `validate:"required"` // Chain-type-specific fields - ChainTypeClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` - ChainTypeForwarders map[string]map[uint64]string `validate:"-"` - ChainTypeKeys map[string]interface{} `validate:"-"` + ChainTypeClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` + ChainTypeKeys map[string]interface{} `validate:"-"` // ChainTypeResolved holds the full ResolveClients bundle per chain type - // so later steps (health check) have access to any chain-type-agnostic - // metadata (e.g. experimental-selector flags) without relying on hidden - // state on the ChainType instance. + // (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:"-"` @@ -136,7 +134,6 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) chain.Build(h.log) ctClients := make(map[string]map[uint64]chain.ChainClient) - ctForwarders := make(map[string]map[uint64]string) ctResolved := make(map[string]chain.ResolvedChains) ctKeys := make(map[string]interface{}) @@ -148,7 +145,6 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) if len(resolved.Clients) > 0 { ctClients[name] = resolved.Clients - ctForwarders[name] = resolved.Forwarders ctResolved[name] = resolved } } @@ -183,10 +179,9 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, EngineLogs: v.GetBool("engine-logs"), Broadcast: v.GetBool("broadcast"), - ChainTypeClients: ctClients, - ChainTypeForwarders: ctForwarders, - ChainTypeResolved: ctResolved, - ChainTypeKeys: ctKeys, + ChainTypeClients: ctClients, + ChainTypeResolved: ctResolved, + ChainTypeKeys: ctKeys, WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, NonInteractive: v.GetBool("non-interactive"), TriggerIndex: v.GetInt("trigger-index"), @@ -485,7 +480,7 @@ func run( ctSrvcs, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ Registry: registry, Clients: clients, - Forwarders: inputs.ChainTypeForwarders[name], + Forwarders: inputs.ChainTypeResolved[name].Forwarders, PrivateKey: inputs.ChainTypeKeys[name], Broadcast: inputs.Broadcast, Limits: capLimits, From 8fd4a1624eb97311baac59291f38a17d896daa24 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 14:42:57 +0100 Subject: [PATCH 22/26] style(test): strip section divider comments from evm test files --- .../simulate/chain/evm/chaintype_test.go | 70 ------------------- .../simulate/chain/evm/health_more_test.go | 4 -- .../limited_capabilities_delegation_test.go | 8 --- .../chain/evm/trigger_validation_test.go | 24 ------- 4 files changed, 106 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index 132e0cc0..30bbdeee 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -38,8 +38,6 @@ func newRegistry(t *testing.T) *capabilities.Registry { return r } -// --- helpers --- - // stdioMu serialises os.Stderr / os.Stdout hijacks so parallel capture tests // don't clobber each other's pipes. var stdioMu sync.Mutex @@ -108,19 +106,11 @@ func newEVMChainType() *EVMChainType { // Valid anvil dev key #0; known non-sentinel. const validPK = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" -// --------------------------------------------------------------------------- -// Name -// --------------------------------------------------------------------------- - func TestEVMChainType_Name_IsEVM(t *testing.T) { t.Parallel() require.Equal(t, "evm", newEVMChainType().Name()) } -// --------------------------------------------------------------------------- -// SupportedChains pass-through -// --------------------------------------------------------------------------- - func TestEVMChainType_SupportedChains_ReturnsPackageVar(t *testing.T) { t.Parallel() got := newEVMChainType().SupportedChains() @@ -128,10 +118,6 @@ func TestEVMChainType_SupportedChains_ReturnsPackageVar(t *testing.T) { require.Greater(t, len(got), 20, "expected many supported chains") } -// --------------------------------------------------------------------------- -// ResolveKey table -// --------------------------------------------------------------------------- - func TestEVMChainType_ResolveKey(t *testing.T) { t.Parallel() @@ -252,10 +238,6 @@ func TestEVMChainType_ResolveKey(t *testing.T) { } } -// --------------------------------------------------------------------------- -// ResolveKey sentinel identity -// --------------------------------------------------------------------------- - func TestEVMChainType_ResolveKey_SentinelDecodesToD1(t *testing.T) { t.Parallel() pk, err := crypto.HexToECDSA(defaultSentinelPrivateKey) @@ -263,10 +245,6 @@ func TestEVMChainType_ResolveKey_SentinelDecodesToD1(t *testing.T) { require.Equal(t, 0, pk.D.Cmp(bigOne())) } -// --------------------------------------------------------------------------- -// ResolveTriggerData — non-interactive validation -// --------------------------------------------------------------------------- - func TestEVMChainType_ResolveTriggerData_NoClient(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -297,10 +275,6 @@ func TestEVMChainType_ResolveTriggerData_WrongClientType(t *testing.T) { assert.Contains(t, err.Error(), "invalid client type for EVM chain selector 1") } -// --------------------------------------------------------------------------- -// ExecuteTrigger -// --------------------------------------------------------------------------- - func TestEVMChainType_ExecuteTrigger_NotRegistered(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -319,10 +293,6 @@ func TestEVMChainType_ExecuteTrigger_UnknownSelector(t *testing.T) { assert.Contains(t, err.Error(), "no EVM chain initialized for selector 999") } -// --------------------------------------------------------------------------- -// HasSelector -// --------------------------------------------------------------------------- - func TestEVMChainType_HasSelector_WhenNotRegistered_ReturnsFalse(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -337,10 +307,6 @@ func TestEVMChainType_HasSelector_EmptyMap_ReturnsFalse(t *testing.T) { assert.False(t, ct.HasSelector(1)) } -// --------------------------------------------------------------------------- -// ParseTriggerChainSelector (via chain type interface) -// --------------------------------------------------------------------------- - func TestEVMChainType_ParseTriggerChainSelector_Delegates(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -353,10 +319,6 @@ func TestEVMChainType_ParseTriggerChainSelector_Delegates(t *testing.T) { require.Zero(t, got) } -// --------------------------------------------------------------------------- -// RegisterCapabilities type-assertion failures -// --------------------------------------------------------------------------- - func TestEVMChainType_RegisterCapabilities_WrongClientType(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -388,10 +350,6 @@ func TestEVMChainType_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing. assert.False(t, ct.HasSelector(1)) } -// --------------------------------------------------------------------------- -// RunHealthCheck plumbing -// --------------------------------------------------------------------------- - func TestEVMChainType_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -413,19 +371,11 @@ func TestEVMChainType_RunHealthCheck_NoClients_Errors(t *testing.T) { assert.Contains(t, err.Error(), "no RPC URLs found") } -// --------------------------------------------------------------------------- -// ChainType interface contract -// --------------------------------------------------------------------------- - func TestEVMChainType_ImplementsChainType(t *testing.T) { t.Parallel() var _ chain.ChainType = (*EVMChainType)(nil) } -// --------------------------------------------------------------------------- -// Registered via init -// --------------------------------------------------------------------------- - func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { t.Parallel() lg := zerolog.Nop() @@ -445,10 +395,6 @@ func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { require.Equal(t, "evm", ct.Name()) } -// --------------------------------------------------------------------------- -// Sentinel error wrapping -// --------------------------------------------------------------------------- - func TestEVMChainType_ResolveKey_BroadcastErrorWrapsUnderlying(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -459,10 +405,6 @@ func TestEVMChainType_ResolveKey_BroadcastErrorWrapsUnderlying(t *testing.T) { assert.Contains(t, err.Error(), "CRE_ETH_PRIVATE_KEY") } -// --------------------------------------------------------------------------- -// Non-broadcast with valid key: no UI warning leaked -// --------------------------------------------------------------------------- - func TestEVMChainType_ResolveKey_ValidNonBroadcast_NoWarning(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -474,10 +416,6 @@ func TestEVMChainType_ResolveKey_ValidNonBroadcast_NoWarning(t *testing.T) { assert.NotContains(t, stderr, "Using default private key") } -// --------------------------------------------------------------------------- -// ExecuteTrigger wrong triggerData type -// --------------------------------------------------------------------------- - func TestEVMChainType_ExecuteTrigger_WrongTriggerDataType(t *testing.T) { t.Parallel() // Register a nil FakeEVMChain entry via map so the nil-check passes but the @@ -520,10 +458,6 @@ func TestEVMChainType_ResolveKey_PrefixedHex_FallsBackToSentinel(t *testing.T) { assert.Contains(t, stderr, "Using default private key") } -// --------------------------------------------------------------------------- -// Error type is standard error (not a sentinel) — ensures errors.Is behaviour. -// --------------------------------------------------------------------------- - func TestEVMChainType_ResolveKey_BroadcastError_IsError(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -533,10 +467,6 @@ func TestEVMChainType_ResolveKey_BroadcastError_IsError(t *testing.T) { require.NotNil(t, errors.Unwrap(err)) } -// --------------------------------------------------------------------------- -// CollectCLIInputs -// --------------------------------------------------------------------------- - func TestEVMChainType_CollectCLIInputs_BothSet(t *testing.T) { t.Parallel() ct := newEVMChainType() diff --git a/cmd/workflow/simulate/chain/evm/health_more_test.go b/cmd/workflow/simulate/chain/evm/health_more_test.go index 638c314b..3d2b678a 100644 --- a/cmd/workflow/simulate/chain/evm/health_more_test.go +++ b/cmd/workflow/simulate/chain/evm/health_more_test.go @@ -11,10 +11,6 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) -// --------------------------------------------------------------------------- -// Experimental selectors label — "experimental chain N" in error messages. -// --------------------------------------------------------------------------- - func TestHealthCheck_ExperimentalSelector_UsesExperimentalLabel(t *testing.T) { sErr := newChainIDServer(t, fmt.Errorf("boom")) defer sErr.Close() diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go index 2247b515..3bca74bf 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go @@ -129,10 +129,6 @@ func (s *fullStubCapability) Initialise(context.Context, core.StandardCapabiliti return nil } -// --------------------------------------------------------------------------- -// Table-driven: every method is called through the limiter exactly once. -// --------------------------------------------------------------------------- - func TestLimitedEVMChain_AllMethodsDelegate(t *testing.T) { t.Parallel() @@ -197,10 +193,6 @@ func TestLimitedEVMChain_AllMethodsDelegate(t *testing.T) { } } -// --------------------------------------------------------------------------- -// WriteReport policy edge cases -// --------------------------------------------------------------------------- - func TestLimitedEVMChain_WriteReport_NilReport_Delegates(t *testing.T) { t.Parallel() stub := newFullStub() diff --git a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go index 19879179..974a8b06 100644 --- a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go +++ b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go @@ -18,10 +18,6 @@ import ( const zero64 = "0x" + "0000000000000000000000000000000000000000000000000000000000000000" -// --------------------------------------------------------------------------- -// GetEVMTriggerLogFromValues — validation table. -// --------------------------------------------------------------------------- - func TestGetEVMTriggerLogFromValues_Validation(t *testing.T) { t.Parallel() @@ -50,10 +46,6 @@ func TestGetEVMTriggerLogFromValues_Validation(t *testing.T) { } } -// --------------------------------------------------------------------------- -// fetchAndConvertLog + GetEVMTriggerLogFromValues via mock eth_getTransactionReceipt. -// --------------------------------------------------------------------------- - type mockRPC struct { srv *httptest.Server receipts map[string]*types.Receipt @@ -278,10 +270,6 @@ func TestGetEVMTriggerLogFromValues_SuccessNoTopicsLeavesEventSigNil(t *testing. assert.Nil(t, got.EventSig) } -// --------------------------------------------------------------------------- -// ParseTriggerChainSelector — additional coverage. -// --------------------------------------------------------------------------- - func TestParseTriggerChainSelector_AdditionalEdgeCases(t *testing.T) { t.Parallel() @@ -316,10 +304,6 @@ func TestParseTriggerChainSelector_AdditionalEdgeCases(t *testing.T) { } } -// --------------------------------------------------------------------------- -// Regex directly — defensive coverage. -// --------------------------------------------------------------------------- - func TestChainSelectorRegex_Matches(t *testing.T) { t.Parallel() matches := chainSelectorRe.FindAllStringSubmatch("ChainSelector:1 chainselector:2 CHAINSELECTOR:3", -1) @@ -335,10 +319,6 @@ func TestChainSelectorRegex_NoMatch(t *testing.T) { assert.Nil(t, chainSelectorRe.FindStringSubmatch("")) } -// --------------------------------------------------------------------------- -// Non-0x rejection fast — doesn't touch RPC. -// --------------------------------------------------------------------------- - func TestGetEVMTriggerLogFromValues_NoRPCWhenHashInvalid(t *testing.T) { t.Parallel() // Pass nil client; validation should fire before any RPC attempt. @@ -347,10 +327,6 @@ func TestGetEVMTriggerLogFromValues_NoRPCWhenHashInvalid(t *testing.T) { assert.Contains(t, err.Error(), "must start with 0x") } -// --------------------------------------------------------------------------- -// Zero-address log still decodes. -// --------------------------------------------------------------------------- - func TestGetEVMTriggerLogFromValues_ZeroAddressLog(t *testing.T) { t.Parallel() m := newMockRPC(t) From 18ab169a7d7636ac2877a0ab63732c12f3e1e02f Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 15:25:11 +0100 Subject: [PATCH 23/26] test: prune low-signal tests and consolidate test files Remove 29 net-new tests plus delegation test file (~1100 lines) that verified tautologies, library behaviour, compile-time invariants, or one-line pass-throughs. Merge the 'more' and 'validation' split files back into their single-file counterparts. Deletions: - limited_capabilities_delegation_test.go (whole file): verified stub.calls[method]==1 on pass-through methods. WriteReport gating logic (size+gas) remains covered by limited_capabilities_test.go. - chaintype_test.go: literal-constant getters, crypto-library echo, duplicate ResolveKey cases already in the table, one-line delegation, compile-time interface check. - health_more_test.go: nil/empty-map tautologies and exact duplicates of tests in health_test.go. - supported_chains_test.go: pin tests (NotEmpty, EthereumMainnet, Sepolia with hardcoded forwarder), tautologies (Lowercased, Are20Bytes redundant with hex regex), compile-time struct check. - trigger_validation_test.go: regex coverage redundant with parser table; misleading-name edge cases dropped; 5 genuinely novel cases (zero, max uint64, negative, unicode digits, tab) merged into the parser table. - registry_test.go: map round-trip smokes and happy-path nil acceptance; all real branches covered by duplicate-panic, sort, defensive-copy, error-with-names, CLI-branching tests. Consolidations: - Merged health_more_test.go into health_test.go (11 tests total). - Merged trigger_validation_test.go into trigger_test.go (8 tests plus the expanded parser table). Kept tests exercise real branches and data invariants: ResolveKey hex/sentinel logic, RegisterCapabilities type assertions, ExecuteTrigger error paths, CollectCLIInputs filters, experimental/unknown health labels, unique selectors, valid hex forwarders, chain-selector resolution, httptest RPC round-trips in GetEVMTriggerLogFromValues, and registry duplicate-panic / sort / defensive-copy / CLI branching. --- .../simulate/chain/evm/chaintype_test.go | 91 ----- .../simulate/chain/evm/health_more_test.go | 197 ---------- .../simulate/chain/evm/health_test.go | 127 ++++++- .../limited_capabilities_delegation_test.go | 342 ----------------- .../chain/evm/supported_chains_test.go | 72 ---- .../simulate/chain/evm/trigger_test.go | 326 ++++++++++++++++ .../chain/evm/trigger_validation_test.go | 352 ------------------ cmd/workflow/simulate/chain/registry_test.go | 49 --- 8 files changed, 452 insertions(+), 1104 deletions(-) delete mode 100644 cmd/workflow/simulate/chain/evm/health_more_test.go delete mode 100644 cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go delete mode 100644 cmd/workflow/simulate/chain/evm/trigger_validation_test.go diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index 30bbdeee..b237c125 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/ecdsa" - "errors" "io" "math/big" "os" @@ -12,7 +11,6 @@ import ( "sync" "testing" - "github.com/ethereum/go-ethereum/crypto" "github.com/rs/zerolog" "github.com/spf13/viper" "github.com/stretchr/testify/assert" @@ -106,18 +104,6 @@ func newEVMChainType() *EVMChainType { // Valid anvil dev key #0; known non-sentinel. const validPK = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" -func TestEVMChainType_Name_IsEVM(t *testing.T) { - t.Parallel() - require.Equal(t, "evm", newEVMChainType().Name()) -} - -func TestEVMChainType_SupportedChains_ReturnsPackageVar(t *testing.T) { - t.Parallel() - got := newEVMChainType().SupportedChains() - require.Equal(t, len(SupportedChains), len(got)) - require.Greater(t, len(got), 20, "expected many supported chains") -} - func TestEVMChainType_ResolveKey(t *testing.T) { t.Parallel() @@ -238,13 +224,6 @@ func TestEVMChainType_ResolveKey(t *testing.T) { } } -func TestEVMChainType_ResolveKey_SentinelDecodesToD1(t *testing.T) { - t.Parallel() - pk, err := crypto.HexToECDSA(defaultSentinelPrivateKey) - require.NoError(t, err) - require.Equal(t, 0, pk.D.Cmp(bigOne())) -} - func TestEVMChainType_ResolveTriggerData_NoClient(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -300,25 +279,6 @@ func TestEVMChainType_HasSelector_WhenNotRegistered_ReturnsFalse(t *testing.T) { assert.False(t, ct.HasSelector(0)) } -func TestEVMChainType_HasSelector_EmptyMap_ReturnsFalse(t *testing.T) { - t.Parallel() - ct := newEVMChainType() - ct.evmChains = &EVMChainCapabilities{EVMChains: nil} - assert.False(t, ct.HasSelector(1)) -} - -func TestEVMChainType_ParseTriggerChainSelector_Delegates(t *testing.T) { - t.Parallel() - ct := newEVMChainType() - got, ok := ct.ParseTriggerChainSelector("evm:ChainSelector:42@1.0.0") - require.True(t, ok) - require.Equal(t, uint64(42), got) - - got, ok = ct.ParseTriggerChainSelector("no-selector-here") - require.False(t, ok) - require.Zero(t, got) -} - func TestEVMChainType_RegisterCapabilities_WrongClientType(t *testing.T) { t.Parallel() ct := newEVMChainType() @@ -371,11 +331,6 @@ func TestEVMChainType_RunHealthCheck_NoClients_Errors(t *testing.T) { assert.Contains(t, err.Error(), "no RPC URLs found") } -func TestEVMChainType_ImplementsChainType(t *testing.T) { - t.Parallel() - var _ chain.ChainType = (*EVMChainType)(nil) -} - func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { t.Parallel() lg := zerolog.Nop() @@ -395,27 +350,6 @@ func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { require.Equal(t, "evm", ct.Name()) } -func TestEVMChainType_ResolveKey_BroadcastErrorWrapsUnderlying(t *testing.T) { - t.Parallel() - ct := newEVMChainType() - s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: "zz"}} - _, err := ct.ResolveKey(s, true) - require.Error(t, err) - // Must mention env var for operator-facing clarity. - assert.Contains(t, err.Error(), "CRE_ETH_PRIVATE_KEY") -} - -func TestEVMChainType_ResolveKey_ValidNonBroadcast_NoWarning(t *testing.T) { - t.Parallel() - ct := newEVMChainType() - s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: validPK}} - stderr := captureStderr(t, func() { - _, err := ct.ResolveKey(s, false) - require.NoError(t, err) - }) - assert.NotContains(t, stderr, "Using default private key") -} - func TestEVMChainType_ExecuteTrigger_WrongTriggerDataType(t *testing.T) { t.Parallel() // Register a nil FakeEVMChain entry via map so the nil-check passes but the @@ -442,31 +376,6 @@ func errorContainsAny(err error, subs ...string) bool { return false } -// Defensive check: crypto.HexToECDSA rejects the string "0x..." so our -// fallback behaviour under non-broadcast keeps functioning even if a user -// copies their key with a prefix. -func TestEVMChainType_ResolveKey_PrefixedHex_FallsBackToSentinel(t *testing.T) { - t.Parallel() - ct := newEVMChainType() - s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: "0x" + validPK}} - stderr := captureStderr(t, func() { - got, err := ct.ResolveKey(s, false) - require.NoError(t, err) - pk := got.(*ecdsa.PrivateKey) - require.Equal(t, 0, pk.D.Cmp(bigOne())) - }) - assert.Contains(t, stderr, "Using default private key") -} - -func TestEVMChainType_ResolveKey_BroadcastError_IsError(t *testing.T) { - t.Parallel() - ct := newEVMChainType() - s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: ""}} - _, err := ct.ResolveKey(s, true) - require.Error(t, err) - require.NotNil(t, errors.Unwrap(err)) -} - func TestEVMChainType_CollectCLIInputs_BothSet(t *testing.T) { t.Parallel() ct := newEVMChainType() diff --git a/cmd/workflow/simulate/chain/evm/health_more_test.go b/cmd/workflow/simulate/chain/evm/health_more_test.go deleted file mode 100644 index 3d2b678a..00000000 --- a/cmd/workflow/simulate/chain/evm/health_more_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package evm - -import ( - "fmt" - "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" -) - -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") -} - -func TestHealthCheck_MultipleOK_NoError(t *testing.T) { - sOK := newChainIDServer(t, "0xaa36a7") - defer sOK.Close() - cOK := newEthClient(t, sOK.URL) - defer cOK.Close() - - sOK2 := newChainIDServer(t, "0x1") - defer sOK2.Close() - cOK2 := newEthClient(t, sOK2.URL) - defer cOK2.Close() - - err := checkRPCConnectivity( - map[uint64]*ethclient.Client{ - selectorSepolia: cOK, - chainEthMainnet: cOK2, - }, - nil, - ) - require.NoError(t, err) -} - -const chainEthMainnet uint64 = 5009297550715157269 // ethereum-mainnet - -func TestHealthCheck_EmptyExperimentalMap_StillWorks(t *testing.T) { - sOK := newChainIDServer(t, "0x1") - defer sOK.Close() - c := newEthClient(t, sOK.URL) - defer c.Close() - - err := checkRPCConnectivity( - map[uint64]*ethclient.Client{selectorSepolia: c}, - map[uint64]bool{}, - ) - require.NoError(t, err) -} - -func TestHealthCheck_NilExperimentalMap_EquivalentToEmpty(t *testing.T) { - sOK := newChainIDServer(t, "0x1") - defer sOK.Close() - c := newEthClient(t, sOK.URL) - defer c.Close() - - err := checkRPCConnectivity( - map[uint64]*ethclient.Client{selectorSepolia: c}, - nil, - ) - require.NoError(t, err) -} - -// 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 TestRunRPCHealthCheck_WrapperFailsOnNonEthClient(t *testing.T) { - err := RunRPCHealthCheck( - map[uint64]chain.ChainClient{1: 42}, // int masquerading as client - nil, - ) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid client type for EVM chain type") -} - -func TestRunRPCHealthCheck_EmptyReturnsSettingsError(t *testing.T) { - err := RunRPCHealthCheck(map[uint64]chain.ChainClient{}, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "check your settings") - assert.Contains(t, err.Error(), "no RPC URLs found for supported or experimental chains") -} - -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/health_test.go b/cmd/workflow/simulate/chain/evm/health_test.go index 11880f89..3b6de2a7 100644 --- a/cmd/workflow/simulate/chain/evm/health_test.go +++ b/cmd/workflow/simulate/chain/evm/health_test.go @@ -9,11 +9,16 @@ import ( "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" +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 { @@ -164,3 +169,123 @@ func TestRunRPCHealthCheck_InvalidClientType(t *testing.T) { } 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_delegation_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go deleted file mode 100644 index 3bca74bf..00000000 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_delegation_test.go +++ /dev/null @@ -1,342 +0,0 @@ -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" - "github.com/smartcontractkit/chainlink-common/pkg/types/core" - sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" -) - -// fullStubCapability extends the base stub with counters on every delegating -// method so we can verify the limiter passes calls through. -type fullStubCapability struct { - calls map[string]int - - // optional return override - writeErr caperrors.Error - - closeErr error - startErr error -} - -func newFullStub() *fullStubCapability { - return &fullStubCapability{calls: map[string]int{}} -} - -func (s *fullStubCapability) CallContract(context.Context, commonCap.RequestMetadata, *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { - s.calls["CallContract"]++ - return &commonCap.ResponseAndMetadata[*evmcappb.CallContractReply]{}, nil -} - -func (s *fullStubCapability) FilterLogs(context.Context, commonCap.RequestMetadata, *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { - s.calls["FilterLogs"]++ - return &commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply]{}, nil -} - -func (s *fullStubCapability) BalanceAt(context.Context, commonCap.RequestMetadata, *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { - s.calls["BalanceAt"]++ - return &commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply]{}, nil -} - -func (s *fullStubCapability) EstimateGas(context.Context, commonCap.RequestMetadata, *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { - s.calls["EstimateGas"]++ - return &commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply]{}, nil -} - -func (s *fullStubCapability) GetTransactionByHash(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { - s.calls["GetTransactionByHash"]++ - return &commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply]{}, nil -} - -func (s *fullStubCapability) GetTransactionReceipt(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { - s.calls["GetTransactionReceipt"]++ - return &commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply]{}, nil -} - -func (s *fullStubCapability) HeaderByNumber(context.Context, commonCap.RequestMetadata, *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { - s.calls["HeaderByNumber"]++ - return &commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply]{}, nil -} - -func (s *fullStubCapability) RegisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { - s.calls["RegisterLogTrigger"]++ - return nil, nil -} - -func (s *fullStubCapability) UnregisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) caperrors.Error { - s.calls["UnregisterLogTrigger"]++ - return nil -} - -func (s *fullStubCapability) WriteReport(context.Context, commonCap.RequestMetadata, *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { - s.calls["WriteReport"]++ - if s.writeErr != nil { - return nil, s.writeErr - } - return &commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply]{}, nil -} - -func (s *fullStubCapability) AckEvent(context.Context, string, string, string) caperrors.Error { - s.calls["AckEvent"]++ - return nil -} - -func (s *fullStubCapability) ChainSelector() uint64 { - s.calls["ChainSelector"]++ - return 42 -} - -// Override lifecycle / metadata to count calls. -func (s *fullStubCapability) Start(context.Context) error { - s.calls["Start"]++ - return s.startErr -} - -func (s *fullStubCapability) Close() error { - s.calls["Close"]++ - return s.closeErr -} - -func (s *fullStubCapability) HealthReport() map[string]error { - s.calls["HealthReport"]++ - return map[string]error{"ok": nil} -} - -func (s *fullStubCapability) Name() string { - s.calls["Name"]++ - return "stub-chain" -} - -func (s *fullStubCapability) Description() string { - s.calls["Description"]++ - return "stub-desc" -} - -func (s *fullStubCapability) Ready() error { - s.calls["Ready"]++ - return nil -} - -func (s *fullStubCapability) Initialise(context.Context, core.StandardCapabilitiesDependencies) error { - s.calls["Initialise"]++ - return nil -} - -func TestLimitedEVMChain_AllMethodsDelegate(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - call func(w *LimitedEVMChain, s *fullStubCapability) - wants string - }{ - {"CallContract", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.CallContract(context.Background(), commonCap.RequestMetadata{}, &evmcappb.CallContractRequest{}) - }, "CallContract"}, - {"FilterLogs", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.FilterLogs(context.Background(), commonCap.RequestMetadata{}, &evmcappb.FilterLogsRequest{}) - }, "FilterLogs"}, - {"BalanceAt", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.BalanceAt(context.Background(), commonCap.RequestMetadata{}, &evmcappb.BalanceAtRequest{}) - }, "BalanceAt"}, - {"EstimateGas", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.EstimateGas(context.Background(), commonCap.RequestMetadata{}, &evmcappb.EstimateGasRequest{}) - }, "EstimateGas"}, - {"GetTransactionByHash", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.GetTransactionByHash(context.Background(), commonCap.RequestMetadata{}, &evmcappb.GetTransactionByHashRequest{}) - }, "GetTransactionByHash"}, - {"GetTransactionReceipt", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.GetTransactionReceipt(context.Background(), commonCap.RequestMetadata{}, &evmcappb.GetTransactionReceiptRequest{}) - }, "GetTransactionReceipt"}, - {"HeaderByNumber", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.HeaderByNumber(context.Background(), commonCap.RequestMetadata{}, &evmcappb.HeaderByNumberRequest{}) - }, "HeaderByNumber"}, - {"RegisterLogTrigger", func(w *LimitedEVMChain, _ *fullStubCapability) { - _, _ = w.RegisterLogTrigger(context.Background(), "tid", commonCap.RequestMetadata{}, &evmcappb.FilterLogTriggerRequest{}) - }, "RegisterLogTrigger"}, - {"UnregisterLogTrigger", func(w *LimitedEVMChain, _ *fullStubCapability) { - _ = w.UnregisterLogTrigger(context.Background(), "tid", commonCap.RequestMetadata{}, &evmcappb.FilterLogTriggerRequest{}) - }, "UnregisterLogTrigger"}, - {"AckEvent", func(w *LimitedEVMChain, _ *fullStubCapability) { - _ = w.AckEvent(context.Background(), "tid", "eid", "m") - }, "AckEvent"}, - {"ChainSelector", func(w *LimitedEVMChain, s *fullStubCapability) { - require.Equal(t, uint64(42), w.ChainSelector()) - require.Equal(t, 1, s.calls["ChainSelector"]) - }, "ChainSelector"}, - {"Start", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Start(context.Background()) }, "Start"}, - {"Close", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Close() }, "Close"}, - {"HealthReport", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.HealthReport() }, "HealthReport"}, - {"Name", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Name() }, "Name"}, - {"Description", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Description() }, "Description"}, - {"Ready", func(w *LimitedEVMChain, _ *fullStubCapability) { _ = w.Ready() }, "Ready"}, - {"Initialise", func(w *LimitedEVMChain, _ *fullStubCapability) { - _ = w.Initialise(context.Background(), core.StandardCapabilitiesDependencies{}) - }, "Initialise"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{}) - tt.call(w, stub) - assert.Equal(t, 1, stub.calls[tt.wants], "expected 1 call to %s", tt.wants) - }) - } -} - -func TestLimitedEVMChain_WriteReport_NilReport_Delegates(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 4}) - resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{Report: nil}) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, 1, stub.calls["WriteReport"]) -} - -func TestLimitedEVMChain_WriteReport_NilGasConfig_Delegates(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 100}) - resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{GasConfig: nil}) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, 1, stub.calls["WriteReport"]) -} - -func TestLimitedEVMChain_WriteReport_ZeroReportLimit_Disabled(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 0}) - resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: make([]byte, 1<<20)}, - }) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, 1, stub.calls["WriteReport"]) -} - -func TestLimitedEVMChain_WriteReport_ZeroGasLimit_Disabled(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 0}) - resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - GasConfig: &evmcappb.GasConfig{GasLimit: 1 << 30}, - }) - require.NoError(t, err) - assert.NotNil(t, resp) - assert.Equal(t, 1, stub.calls["WriteReport"]) -} - -func TestLimitedEVMChain_WriteReport_GasBoundaryEqualsLimit_Delegates(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 1000}) - resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - GasConfig: &evmcappb.GasConfig{GasLimit: 1000}, - }) - require.NoError(t, err) - assert.NotNil(t, resp) -} - -func TestLimitedEVMChain_WriteReport_ReportBoundaryEqualsLimit_Delegates(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 5}) - resp, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, - }) - require.NoError(t, err) - assert.NotNil(t, resp) -} - -func TestLimitedEVMChain_WriteReport_ReportOneOverLimit_Rejects(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 5}) - _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("123456")}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "6 bytes exceeds limit of 5 bytes") - assert.Equal(t, 0, stub.calls["WriteReport"]) -} - -func TestLimitedEVMChain_WriteReport_GasOneOverLimit_Rejects(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{gasLimit: 1_000_000}) - _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - GasConfig: &evmcappb.GasConfig{GasLimit: 1_000_001}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "1000001 exceeds maximum of 1000000") - assert.Equal(t, 0, stub.calls["WriteReport"]) -} - -func TestLimitedEVMChain_WriteReport_ReportCheckedBeforeGas(t *testing.T) { - // When both fail, report-size error is surfaced first. - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 1, gasLimit: 1}) - _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("ab")}, - GasConfig: &evmcappb.GasConfig{GasLimit: 999}, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "chain write report size") - assert.NotContains(t, err.Error(), "EVM gas limit") -} - -func TestLimitedEVMChain_WriteReport_ReturnsResourceExhaustedCode(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{reportSizeLimit: 1}) - _, err := w.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("too-big")}, - }) - require.Error(t, err) - assert.Equal(t, caperrors.ResourceExhausted, err.Code()) -} - -func TestLimitedEVMChain_Constructor_StoresInnerAndLimits(t *testing.T) { - t.Parallel() - stub := newFullStub() - limits := &stubEVMLimits{reportSizeLimit: 7, gasLimit: 11} - w := NewLimitedEVMChain(stub, limits) - require.NotNil(t, w) - // Verify Description delegates (indirectly proves inner is stored) - require.Equal(t, "stub-desc", w.Description()) -} - -func TestLimitedEVMChain_ChainSelector_ReflectsInner(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{}) - require.Equal(t, uint64(42), w.ChainSelector()) -} - -func TestLimitedEVMChain_Name_ReflectsInner(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{}) - require.Equal(t, "stub-chain", w.Name()) -} - -func TestLimitedEVMChain_HealthReport_ReflectsInner(t *testing.T) { - t.Parallel() - stub := newFullStub() - w := NewLimitedEVMChain(stub, &stubEVMLimits{}) - hr := w.HealthReport() - _, ok := hr["ok"] - require.True(t, ok) -} diff --git a/cmd/workflow/simulate/chain/evm/supported_chains_test.go b/cmd/workflow/simulate/chain/evm/supported_chains_test.go index ec4ea54e..708984d2 100644 --- a/cmd/workflow/simulate/chain/evm/supported_chains_test.go +++ b/cmd/workflow/simulate/chain/evm/supported_chains_test.go @@ -2,16 +2,12 @@ package evm import ( "regexp" - "strings" "testing" - "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" chainselectors "github.com/smartcontractkit/chain-selectors" - - "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) // All forwarders declared in supported_chains.go must be valid 0x-prefixed @@ -20,12 +16,6 @@ import ( var forwarderRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) -func TestSupportedChains_NotEmpty(t *testing.T) { - t.Parallel() - require.NotEmpty(t, SupportedChains) - require.Greater(t, len(SupportedChains), 20) -} - func TestSupportedChains_AllSelectorsNonZero(t *testing.T) { t.Parallel() for i, c := range SupportedChains { @@ -52,26 +42,6 @@ func TestSupportedChains_AllForwardersValidHexAddress(t *testing.T) { } } -func TestSupportedChains_AllForwardersDecodableAsAddress(t *testing.T) { - t.Parallel() - for _, c := range SupportedChains { - addr := common.HexToAddress(c.Forwarder) - assert.NotEqual(t, common.Address{}, addr, - "selector %d: forwarder decodes to zero address", c.Selector) - } -} - -func TestSupportedChains_AllForwardersLowercasedOrChecksummed(t *testing.T) { - // Not enforcing checksum specifically — only ensuring HexToAddress normalises - // to a canonical form. - t.Parallel() - for _, c := range SupportedChains { - // If user writes uppercase hex, HexToAddress still accepts it. - norm := common.HexToAddress(c.Forwarder).Hex() - require.NotEmpty(t, norm) - } -} - func TestSupportedChains_AllSelectorsResolveToChainName(t *testing.T) { t.Parallel() for _, c := range SupportedChains { @@ -81,33 +51,6 @@ func TestSupportedChains_AllSelectorsResolveToChainName(t *testing.T) { } } -func TestSupportedChains_EthereumMainnetPresent(t *testing.T) { - t.Parallel() - found := false - for _, c := range SupportedChains { - if c.Selector == chainselectors.ETHEREUM_MAINNET.Selector { - found = true - assert.NotEmpty(t, c.Forwarder) - break - } - } - require.True(t, found, "ethereum mainnet must be in SupportedChains") -} - -func TestSupportedChains_SepoliaPresent(t *testing.T) { - t.Parallel() - found := false - for _, c := range SupportedChains { - if c.Selector == chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector { - found = true - assert.Equal(t, strings.ToLower("0x15fC6ae953E024d975e77382eEeC56A9101f9F88"), - strings.ToLower(c.Forwarder), "sepolia forwarder must match known value") - break - } - } - require.True(t, found, "ethereum-testnet-sepolia must be in SupportedChains") -} - func TestSupportedChains_NoForwarderEmpty(t *testing.T) { t.Parallel() for i, c := range SupportedChains { @@ -126,18 +69,3 @@ func TestSupportedChains_ReturnedByChainType(t *testing.T) { assert.Equal(t, c.Forwarder, ret[i].Forwarder, "forwarder at index %d", i) } } - -func TestSupportedChains_AllForwardersAre20Bytes(t *testing.T) { - t.Parallel() - for _, c := range SupportedChains { - b := common.FromHex(c.Forwarder) - assert.Len(t, b, 20, "selector %d forwarder not 20 bytes: %q", c.Selector, c.Forwarder) - } -} - -func TestChainConfigType_ImplementedCorrectly(t *testing.T) { - t.Parallel() - cfg := chain.ChainConfig{Selector: 1, Forwarder: "0x" + strings.Repeat("a", 40)} - assert.Equal(t, uint64(1), cfg.Selector) - assert.Len(t, cfg.Forwarder, 42) -} diff --git a/cmd/workflow/simulate/chain/evm/trigger_test.go b/cmd/workflow/simulate/chain/evm/trigger_test.go index 8f7f3876..45b3b0d9 100644 --- a/cmd/workflow/simulate/chain/evm/trigger_test.go +++ b/cmd/workflow/simulate/chain/evm/trigger_test.go @@ -1,9 +1,23 @@ 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 @@ -71,6 +85,36 @@ func TestParseTriggerChainSelector(t *testing.T) { 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 { @@ -82,3 +126,285 @@ func TestParseTriggerChainSelector(t *testing.T) { }) } } + +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/evm/trigger_validation_test.go b/cmd/workflow/simulate/chain/evm/trigger_validation_test.go deleted file mode 100644 index 974a8b06..00000000 --- a/cmd/workflow/simulate/chain/evm/trigger_validation_test.go +++ /dev/null @@ -1,352 +0,0 @@ -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 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 - - stdout := captureStdout(t, func() {}) - _ = stdout - 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 TestParseTriggerChainSelector_AdditionalEdgeCases(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - id string - want uint64 - ok bool - }{ - {"zero selector", "evm:ChainSelector:0@1.0.0", 0, true}, - {"max uint64", "evm:ChainSelector:18446744073709551615@1.0.0", 18446744073709551615, true}, - {"max uint64 plus one", "evm:ChainSelector:18446744073709551616@1.0.0", 0, false}, - {"negative not parsed", "evm:ChainSelector:-1@1.0.0", 0, false}, - {"scientific notation not parsed", "evm:ChainSelector:1e5@1.0.0", 1, true}, - {"hex prefix rejected", "evm:ChainSelector:0x10@1.0.0", 0, true}, - {"ChainSelector without colon", "evm:ChainSelector123@1.0.0", 0, false}, - {"mixed case CHAINselector", "evm:CHAINselector:123@1.0.0", 123, true}, - {"empty string", "", 0, false}, - {"only prefix", "evm:ChainSelector:", 0, false}, - {"unicode digits rejected", "evm:ChainSelector:123@1.0.0", 0, false}, - {"tab before number", "evm:ChainSelector:\t42@1.0.0", 0, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - 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 TestChainSelectorRegex_Matches(t *testing.T) { - t.Parallel() - matches := chainSelectorRe.FindAllStringSubmatch("ChainSelector:1 chainselector:2 CHAINSELECTOR:3", -1) - require.Len(t, matches, 3) - assert.Equal(t, "1", matches[0][1]) - assert.Equal(t, "2", matches[1][1]) - assert.Equal(t, "3", matches[2][1]) -} - -func TestChainSelectorRegex_NoMatch(t *testing.T) { - t.Parallel() - assert.Nil(t, chainSelectorRe.FindStringSubmatch("just-a-string")) - assert.Nil(t, chainSelectorRe.FindStringSubmatch("")) -} - -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_test.go b/cmd/workflow/simulate/chain/registry_test.go index d38bf33e..aa91c81a 100644 --- a/cmd/workflow/simulate/chain/registry_test.go +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -102,19 +102,6 @@ func registerMock(name string, chainType ChainType) { Build(nil) } -func TestRegisterAndGet(t *testing.T) { - resetRegistry() - defer resetRegistry() - - mockCT := newMockType("test") - registerMock("test", mockCT) - - f, err := Get("test") - require.NoError(t, err) - assert.Equal(t, "test", f.Name()) - mockCT.AssertExpectations(t) -} - func TestGetUnknownChainType(t *testing.T) { resetRegistry() defer resetRegistry() @@ -134,19 +121,6 @@ func TestRegisterDuplicatePanics(t *testing.T) { }) } -func TestAll(t *testing.T) { - resetRegistry() - defer resetRegistry() - - registerMock("alpha", newMockType("alpha")) - registerMock("beta", newMockType("beta")) - - all := All() - assert.Len(t, all, 2) - assert.Contains(t, all, "alpha") - assert.Contains(t, all, "beta") -} - func TestNamesReturnsSorted(t *testing.T) { resetRegistry() defer resetRegistry() @@ -195,16 +169,6 @@ func TestRegisterAllCLIFlags_StringAndInt(t *testing.T) { assert.Equal(t, "an index", f.Usage) } -func TestRegisterAllCLIFlags_NilFlagDefs(t *testing.T) { - resetRegistry() - defer resetRegistry() - - Register("test", func(*zerolog.Logger) ChainType { return newMockType("test") }, nil) - - cmd := &cobra.Command{Use: "test"} - RegisterAllCLIFlags(cmd) // should not panic -} - func TestCollectAllCLIInputs_MergesAcrossChainTypes(t *testing.T) { resetRegistry() defer resetRegistry() @@ -224,19 +188,6 @@ func TestCollectAllCLIInputs_MergesAcrossChainTypes(t *testing.T) { assert.Equal(t, "val-b", result["key-b"]) } -func TestCollectAllCLIInputs_EmptyWhenNoInputs(t *testing.T) { - resetRegistry() - defer resetRegistry() - - ct := newMockType("empty") - ct.On("CollectCLIInputs", mock.Anything).Return(map[string]string{}) - registerMock("empty", ct) - - v := viper.New() - result := CollectAllCLIInputs(v) - assert.Empty(t, result) -} - func TestAllReturnsCopy(t *testing.T) { resetRegistry() defer resetRegistry() From f55d8ae61b2cebb45b6d071f25a30eafe4bb01a8 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 15:51:44 +0100 Subject: [PATCH 24/26] test: drop misleading ExecuteTrigger_WrongTriggerDataType test The test claimed to cover the triggerData type-assertion branch in ExecuteTrigger but its setup (empty EVMChains map) always tripped the earlier nil-chain check first. The errorContainsAny hedge masked the issue by accepting either error, making the test effectively a duplicate of TestEVMChainType_ExecuteTrigger_UnknownSelector. --- .../simulate/chain/evm/chaintype_test.go | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index b237c125..5cb5f09b 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -350,32 +350,6 @@ func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { require.Equal(t, "evm", ct.Name()) } -func TestEVMChainType_ExecuteTrigger_WrongTriggerDataType(t *testing.T) { - t.Parallel() - // Register a nil FakeEVMChain entry via map so the nil-check passes but the - // triggerData type assertion fails first. - ct := newEVMChainType() - ct.evmChains = &EVMChainCapabilities{EVMChains: nil} - err := ct.ExecuteTrigger(context.Background(), 1, "regID", "not-a-log") - require.Error(t, err) - // Whichever check fails first — both are acceptable. - if !errorContainsAny(err, "trigger data is not *evm.Log", "no EVM chain initialized") { - t.Fatalf("unexpected error: %v", err) - } -} - -func errorContainsAny(err error, subs ...string) bool { - if err == nil { - return false - } - for _, s := range subs { - if strings.Contains(err.Error(), s) { - return true - } - } - return false -} - func TestEVMChainType_CollectCLIInputs_BothSet(t *testing.T) { t.Parallel() ct := newEVMChainType() From f653c979dd37ec3b3c725ae279951cb385952998 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 16:49:34 +0100 Subject: [PATCH 25/26] test: remove unused captureStdout helper Only call site was wrapping an empty function in TestGetEVMTriggerLogFromValues_Success; that dead line was dropped during the trigger test consolidation, leaving the helper unreferenced. --- .../simulate/chain/evm/chaintype_test.go | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index 5cb5f09b..ee40c8a4 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -68,34 +68,6 @@ func captureStderr(t *testing.T, fn func()) string { return buf.String() } -// captureStdout captures anything written to os.Stdout during fn. -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - stdioMu.Lock() - defer stdioMu.Unlock() - old := os.Stdout - r, w, err := os.Pipe() - require.NoError(t, err) - os.Stdout = w - - done := make(chan struct{}) - var buf bytes.Buffer - go func() { - _, _ = io.Copy(&buf, r) - close(done) - }() - - defer func() { - os.Stdout = old - }() - - fn() - - _ = w.Close() - <-done - return buf.String() -} - func newEVMChainType() *EVMChainType { lg := zerolog.Nop() return &EVMChainType{log: &lg} From 8f738e2c6147890e048e2aefb71083a0696d23c3 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 17 Apr 2026 17:09:14 +0100 Subject: [PATCH 26/26] style: fix gci alignment in simulate.go Inputs literal --- cmd/workflow/simulate/simulate.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index acb76232..87621cf1 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -173,23 +173,23 @@ func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) } 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"), + 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, + 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 }