From c7289e25d7b66e5550124f76ab874ea5ebaf9b1e Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Tue, 21 Apr 2026 13:58:42 +0100 Subject: [PATCH 01/28] feat(simulate): Aptos chain family for `cre workflow simulate` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires an Aptos chain type into `cre workflow simulate` so workflows targeting `aptos:ChainSelector:N@1.0.0` can be exercised locally against testnet RPCs + a user-published mock forwarder. chain/aptos plugin: - chaintype.go — registers `aptos` via chain.Register; implements ChainType (Name, SupportedChains, ResolveClients, ResolveKey, RegisterCapabilities, RunHealthCheck, ParseTriggerChainSelector, etc.). No trigger surface on Aptos. - capabilities.go — AptosChainCapabilities wires FakeAptosChain per selector and registers with the capability registry. - limited_capabilities.go — LimitedAptosChain enforces chain-write size + Aptos MaxGasAmount via caperrors.ResourceExhausted. - supported_chains.go — mainnet + testnet selectors. - health.go — RunRPCHealthCheck probes GetChainId per client. chain/evm refactor: extracts the EVM-specific pieces out of simulate.go so the simulate command delegates per-chain work through a chain-type registry (EVM + Aptos siblings). Trigger handling, registry, types, utils moved to chain/*. evm/trigger.go carries manual-log-trigger glue. Settings + limits: - CRE_APTOS_PRIVATE_KEY plumbing (Ed25519 hex seed); sentinel seed allowed non-broadcast with ui.Warning; hard-fail under --broadcast for unparseable or sentinel key. - ChainWriteAptosMaxGasAmount in SimulationLimits. Tests: - Unit: chaintype / capabilities / limited / supported_chains / health. - 30-scenario CLI end-to-end suite (simulator_scenarios_test.go). - test/aptos_cli_scenarios_test.go integration. - test_project/aptos_smoke/ example workflow for manual smoke. PLEX-2751 --- cmd/workflow/simulate/capabilities.go | 78 +-- .../simulate/chain/aptos/capabilities.go | 80 +++ .../simulate/chain/aptos/chaintype.go | 162 +++++ .../simulate/chain/aptos/chaintype_test.go | 60 ++ cmd/workflow/simulate/chain/aptos/health.go | 54 ++ .../simulate/chain/aptos/health_test.go | 101 +++ .../chain/aptos/limited_capabilities.go | 76 +++ .../chain/aptos/limited_capabilities_test.go | 87 +++ .../chain/aptos/simulator_scenarios_test.go | 399 ++++++++++++ .../simulate/chain/aptos/supported_chains.go | 17 + .../chain/aptos/supported_chains_test.go | 23 + .../simulate/chain/evm/capabilities.go | 88 +++ cmd/workflow/simulate/chain/evm/chaintype.go | 285 +++++++++ .../simulate/chain/evm/chaintype_test.go | 373 +++++++++++ cmd/workflow/simulate/chain/evm/health.go | 80 +++ .../simulate/chain/evm/health_test.go | 291 +++++++++ .../chain/evm/limited_capabilities.go | 110 ++++ .../chain/evm/limited_capabilities_test.go | 149 +++++ .../evm/supported_chains.go} | 120 +--- .../chain/evm/supported_chains_test.go | 71 ++ cmd/workflow/simulate/chain/evm/trigger.go | 166 +++++ .../simulate/chain/evm/trigger_test.go | 410 ++++++++++++ cmd/workflow/simulate/chain/registry.go | 204 ++++++ cmd/workflow/simulate/chain/registry_test.go | 206 ++++++ cmd/workflow/simulate/chain/types.go | 56 ++ cmd/workflow/simulate/chain/utils.go | 32 + cmd/workflow/simulate/chain/utils_test.go | 48 ++ cmd/workflow/simulate/limited_capabilities.go | 91 --- .../simulate/limited_capabilities_test.go | 120 ---- cmd/workflow/simulate/limits.go | 9 +- cmd/workflow/simulate/limits_test.go | 6 +- cmd/workflow/simulate/simulate.go | 605 ++++++------------ cmd/workflow/simulate/utils_test.go | 242 ------- go.mod | 39 +- go.sum | 80 +-- internal/settings/settings.go | 22 +- internal/settings/settings_get.go | 23 +- test/aptos_cli_scenarios_test.go | 172 +++++ test/test_project/aptos_smoke/config.json | 7 + test/test_project/aptos_smoke/go.mod | 20 + test/test_project/aptos_smoke/go.sum | 26 + test/test_project/aptos_smoke/main.go | 112 ++++ test/test_project/aptos_smoke/project.yaml | 4 + test/test_project/aptos_smoke/workflow.yaml | 6 + 44 files changed, 4292 insertions(+), 1118 deletions(-) create mode 100644 cmd/workflow/simulate/chain/aptos/capabilities.go create mode 100644 cmd/workflow/simulate/chain/aptos/chaintype.go create mode 100644 cmd/workflow/simulate/chain/aptos/chaintype_test.go create mode 100644 cmd/workflow/simulate/chain/aptos/health.go create mode 100644 cmd/workflow/simulate/chain/aptos/health_test.go create mode 100644 cmd/workflow/simulate/chain/aptos/limited_capabilities.go create mode 100644 cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go create mode 100644 cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go create mode 100644 cmd/workflow/simulate/chain/aptos/supported_chains.go create mode 100644 cmd/workflow/simulate/chain/aptos/supported_chains_test.go create mode 100644 cmd/workflow/simulate/chain/evm/capabilities.go create mode 100644 cmd/workflow/simulate/chain/evm/chaintype.go create mode 100644 cmd/workflow/simulate/chain/evm/chaintype_test.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 rename cmd/workflow/simulate/{simulator_utils.go => chain/evm/supported_chains.go} (65%) create mode 100644 cmd/workflow/simulate/chain/evm/supported_chains_test.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 create mode 100644 cmd/workflow/simulate/chain/utils.go create mode 100644 cmd/workflow/simulate/chain/utils_test.go delete mode 100644 cmd/workflow/simulate/utils_test.go create mode 100644 test/aptos_cli_scenarios_test.go create mode 100644 test/test_project/aptos_smoke/config.json create mode 100644 test/test_project/aptos_smoke/go.mod create mode 100644 test/test_project/aptos_smoke/go.sum create mode 100644 test/test_project/aptos_smoke/main.go create mode 100644 test/test_project/aptos_smoke/project.yaml create mode 100644 test/test_project/aptos_smoke/workflow.yaml diff --git a/cmd/workflow/simulate/capabilities.go b/cmd/workflow/simulate/capabilities.go index 57cc2b5b..5cd28a45 100644 --- a/cmd/workflow/simulate/capabilities.go +++ b/cmd/workflow/simulate/capabilities.go @@ -2,16 +2,13 @@ package simulate import ( "context" - "crypto/ecdsa" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" - chaintype "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" "github.com/smartcontractkit/chainlink-common/keystore/corekeys/ocr2key" confhttpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp/server" httpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http/server" - evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" consensusserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/consensus/server" crontrigger "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron/server" httptrigger "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http/server" @@ -21,79 +18,34 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes" ) -type ManualTriggerCapabilitiesConfig struct { - Clients map[uint64]*ethclient.Client - Forwarders map[uint64]common.Address - PrivateKey *ecdsa.PrivateKey -} - +// ManualTriggers holds chain-agnostic trigger services used in simulation. type ManualTriggers struct { ManualCronTrigger *fakes.ManualCronTriggerService ManualHTTPTrigger *fakes.ManualHTTPTriggerService - ManualEVMChains map[uint64]*fakes.FakeEVMChain } -func NewManualTriggerCapabilities( - ctx context.Context, - lggr logger.Logger, - registry *capabilities.Registry, - cfg ManualTriggerCapabilitiesConfig, - dryRunChainWrite bool, - limits *SimulationLimits, -) (*ManualTriggers, error) { - // Cron +// NewManualTriggerCapabilities creates and registers cron and HTTP trigger capabilities. +// These are chain-agnostic and shared across all chain types. +func NewManualTriggerCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry) (*ManualTriggers, error) { manualCronTrigger := fakes.NewManualCronTriggerService(lggr) manualCronTriggerServer := crontrigger.NewCronServer(manualCronTrigger) if err := registry.Add(ctx, manualCronTriggerServer); err != nil { return nil, err } - // HTTP manualHTTPTrigger := fakes.NewManualHTTPTriggerService(lggr) manualHTTPTriggerServer := httptrigger.NewHTTPServer(manualHTTPTrigger) if err := registry.Add(ctx, manualHTTPTriggerServer); err != nil { return nil, err } - // EVM - evmChains := make(map[uint64]*fakes.FakeEVMChain) - for sel, client := range cfg.Clients { - fwd, ok := cfg.Forwarders[sel] - if !ok { - lggr.Infow("Forwarder not found for chain", "selector", sel) - continue - } - - evm := fakes.NewFakeEvmChain( - lggr, - client, - cfg.PrivateKey, - fwd, - sel, - dryRunChainWrite, - ) - - // Wrap with limits enforcement if limits are enabled - var evmCap evmserver.ClientCapability = evm - if limits != nil { - evmCap = NewLimitedEVMChain(evm, limits) - } - - evmServer := evmserver.NewClientServer(evmCap) - if err := registry.Add(ctx, evmServer); err != nil { - return nil, err - } - - evmChains[sel] = evm - } - return &ManualTriggers{ ManualCronTrigger: manualCronTrigger, ManualHTTPTrigger: manualHTTPTrigger, - ManualEVMChains: evmChains, }, nil } +// Start starts cron and HTTP trigger services. func (m *ManualTriggers) Start(ctx context.Context) error { err := m.ManualCronTrigger.Start(ctx) if err != nil { @@ -105,16 +57,10 @@ func (m *ManualTriggers) Start(ctx context.Context) error { return err } - // Start all configured EVM chains - for _, evm := range m.ManualEVMChains { - if err := evm.Start(ctx); err != nil { - return err - } - } - return nil } +// Close closes cron and HTTP trigger services. func (m *ManualTriggers) Close() error { err := m.ManualCronTrigger.Close() if err != nil { @@ -126,16 +72,10 @@ func (m *ManualTriggers) Close() error { return err } - // Close all EVM chains - for _, evm := range m.ManualEVMChains { - if err := evm.Close(); err != nil { - return err - } - } return nil } -// NewFakeCapabilities builds faked capabilities, then registers them with the capability registry. +// NewFakeActionCapabilities builds faked capabilities, then registers them with the capability registry. func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry *capabilities.Registry, secretsPath string, limits *SimulationLimits) ([]services.Service, error) { caps := make([]services.Service, 0) @@ -144,7 +84,7 @@ func NewFakeActionCapabilities(ctx context.Context, lggr logger.Logger, registry nSigners := 4 signers := []ocr2key.KeyBundle{} for i := 0; i < nSigners; i++ { - signer := ocr2key.MustNewInsecure(fakes.SeedForKeys(), chaintype.EVM) + signer := ocr2key.MustNewInsecure(fakes.SeedForKeys(), corekeys.EVM) lggr.Infow("Generated new consensus signer", "address", common.BytesToAddress(signer.PublicKey())) signers = append(signers, signer) } diff --git a/cmd/workflow/simulate/chain/aptos/capabilities.go b/cmd/workflow/simulate/chain/aptos/capabilities.go new file mode 100644 index 00000000..051af9f3 --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/capabilities.go @@ -0,0 +1,80 @@ +package aptos + +import ( + "context" + "fmt" + + "github.com/aptos-labs/aptos-go-sdk" + "github.com/aptos-labs/aptos-go-sdk/crypto" + + aptosserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos/server" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" + + aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" +) + +// AptosChainCapabilities holds the per-selector FakeAptosChain instances +// created for simulation. +type AptosChainCapabilities struct { + AptosChains map[uint64]*aptosfakes.FakeAptosChain +} + +// NewAptosChainCapabilities builds FakeAptosChain instances for every +// (selector -> client) pair, optionally wraps them with LimitedAptosChain, +// and registers each with the capability registry. +func NewAptosChainCapabilities( + ctx context.Context, + lggr logger.Logger, + registry *capabilities.Registry, + clients map[uint64]aptosfakes.AptosClient, + forwarders map[uint64]string, + privateKey *crypto.Ed25519PrivateKey, + dryRunChainWrite bool, + limits AptosChainLimits, +) (*AptosChainCapabilities, error) { + chains := make(map[uint64]*aptosfakes.FakeAptosChain) + for sel, client := range clients { + fwdStr, ok := forwarders[sel] + if !ok { + lggr.Infow("Forwarder not found for chain", "selector", sel) + continue + } + var fwd aptos.AccountAddress + if err := fwd.ParseStringRelaxed(fwdStr); err != nil { + return nil, fmt.Errorf("parse forwarder for selector %d: %w", sel, err) + } + fc, err := aptosfakes.NewFakeAptosChain(lggr, client, privateKey, fwd, sel, dryRunChainWrite) + if err != nil { + return nil, fmt.Errorf("new FakeAptosChain for selector %d: %w", sel, err) + } + var capability aptosserver.ClientCapability = fc + if limits != nil { + capability = NewLimitedAptosChain(fc, limits) + } + server := aptosserver.NewClientServer(capability) + if err := registry.Add(ctx, server); err != nil { + return nil, fmt.Errorf("register aptos capability for selector %d: %w", sel, err) + } + chains[sel] = fc + } + return &AptosChainCapabilities{AptosChains: chains}, nil +} + +func (c *AptosChainCapabilities) Start(ctx context.Context) error { + for _, fc := range c.AptosChains { + if err := fc.Start(ctx); err != nil { + return err + } + } + return nil +} + +func (c *AptosChainCapabilities) Close() error { + for _, fc := range c.AptosChains { + if err := fc.Close(); err != nil { + return err + } + } + return nil +} diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go new file mode 100644 index 00000000..0b8a896a --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -0,0 +1,162 @@ +package aptos + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + + "github.com/aptos-labs/aptos-go-sdk/crypto" + "github.com/rs/zerolog" + "github.com/spf13/viper" + + "github.com/smartcontractkit/chainlink-common/pkg/services" + + aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +const defaultSentinelAptosSeed = "0000000000000000000000000000000000000000000000000000000000000001" + +func init() { + chain.Register("aptos", func(lggr *zerolog.Logger) chain.ChainType { + return &AptosChainType{log: lggr} + }, nil) +} + +// AptosChainType implements chain.ChainType for Aptos. +type AptosChainType struct { + log *zerolog.Logger + aptosChains *AptosChainCapabilities +} + +var _ chain.ChainType = (*AptosChainType)(nil) + +func (ct *AptosChainType) Name() string { return "aptos" } +func (ct *AptosChainType) SupportedChains() []chain.ChainConfig { return SupportedChains } + +func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, error) { + clients := make(map[uint64]chain.ChainClient) + forwarders := make(map[uint64]string) + for _, c := range SupportedChains { + name, err := settings.GetChainNameByChainSelector(c.Selector) + if err != nil { + ct.log.Error().Msgf("Invalid Aptos chain selector %d; skipping", c.Selector) + continue + } + rpcURL, err := settings.GetRpcUrlSettings(v, name) + if err != nil || strings.TrimSpace(rpcURL) == "" { + ct.log.Debug().Msgf("RPC not provided for %s; skipping", name) + continue + } + ct.log.Debug().Msgf("Using RPC for %s: %s", name, chain.RedactURL(rpcURL)) + client, err := aptosfakes.NewAptosClient(rpcURL) + if err != nil { + ui.Warning(fmt.Sprintf("Failed to build Aptos client for %s: %v", name, err)) + continue + } + clients[c.Selector] = client + if strings.TrimSpace(c.Forwarder) != "" { + forwarders[c.Selector] = c.Forwarder + } + } + return chain.ResolvedChains{Clients: clients, Forwarders: forwarders}, nil +} + +func (ct *AptosChainType) ResolveKey(s *settings.Settings, broadcast bool) (interface{}, error) { + seed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s.User.AptosPrivateKey)), "0x") + bytes, err := hex.DecodeString(seed) + if err != nil || len(bytes) != 32 { + if broadcast { + return nil, fmt.Errorf("CRE_APTOS_PRIVATE_KEY must be 32 hex bytes (64 chars); got len=%d err=%v", len(bytes), err) + } + bytes, _ = hex.DecodeString(defaultSentinelAptosSeed) + ui.Warning("Using default Aptos private key for dry-run simulation. Set CRE_APTOS_PRIVATE_KEY to broadcast.") + } + sentinel, _ := hex.DecodeString(defaultSentinelAptosSeed) + if broadcast && hex.EncodeToString(bytes) == hex.EncodeToString(sentinel) { + return nil, fmt.Errorf("CRE_APTOS_PRIVATE_KEY must not be the sentinel seed under --broadcast") + } + k := &crypto.Ed25519PrivateKey{} + if err := k.FromBytes(bytes); err != nil { + return nil, fmt.Errorf("build Ed25519 key: %w", err) + } + return k, nil +} + +func (ct *AptosChainType) ResolveTriggerData(_ context.Context, _ uint64, _ chain.TriggerParams) (interface{}, error) { + return nil, fmt.Errorf("aptos: no trigger surface") +} + +func (ct *AptosChainType) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) ([]services.Service, error) { + typedClients := make(map[uint64]aptosfakes.AptosClient, len(cfg.Clients)) + for sel, c := range cfg.Clients { + ac, ok := c.(aptosfakes.AptosClient) + if !ok { + return nil, fmt.Errorf("aptos: client for selector %d is not aptosfakes.AptosClient (got %T)", sel, c) + } + typedClients[sel] = ac + } + var pk *crypto.Ed25519PrivateKey + if cfg.PrivateKey != nil { + var ok bool + pk, ok = cfg.PrivateKey.(*crypto.Ed25519PrivateKey) + if !ok { + return nil, fmt.Errorf("aptos: private key is not *crypto.Ed25519PrivateKey (got %T)", cfg.PrivateKey) + } + } + var lim AptosChainLimits + if cfg.Limits != nil { + al, ok := cfg.Limits.(AptosChainLimits) + if !ok { + return nil, fmt.Errorf("aptos: limits does not implement AptosChainLimits (got %T)", cfg.Limits) + } + lim = al + } + caps, err := NewAptosChainCapabilities(ctx, cfg.Logger, cfg.Registry, typedClients, cfg.Forwarders, pk, !cfg.Broadcast, lim) + if err != nil { + return nil, err + } + if err := caps.Start(ctx); err != nil { + return nil, fmt.Errorf("aptos: failed to start: %w", err) + } + ct.aptosChains = caps + out := make([]services.Service, 0, len(caps.AptosChains)) + for _, fc := range caps.AptosChains { + out = append(out, fc) + } + return out, nil +} + +func (ct *AptosChainType) ExecuteTrigger(_ context.Context, _ uint64, _ string, _ interface{}) error { + return fmt.Errorf("aptos: no trigger surface") +} + +func (ct *AptosChainType) HasSelector(selector uint64) bool { + if ct.aptosChains == nil { + return false + } + return ct.aptosChains.AptosChains[selector] != nil +} + +func (ct *AptosChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + if !strings.HasPrefix(triggerID, "aptos:ChainSelector:") { + return 0, false + } + var sel uint64 + if _, err := fmt.Sscanf(triggerID, "aptos:ChainSelector:%d@1.0.0", &sel); err != nil { + return 0, false + } + return sel, true +} + +func (ct *AptosChainType) RunHealthCheck(resolved chain.ResolvedChains) error { + return RunRPCHealthCheck(resolved.Clients, resolved.ExperimentalSelectors) +} + +func (ct *AptosChainType) CollectCLIInputs(_ *viper.Viper) map[string]string { + return map[string]string{} +} diff --git a/cmd/workflow/simulate/chain/aptos/chaintype_test.go b/cmd/workflow/simulate/chain/aptos/chaintype_test.go new file mode 100644 index 00000000..4c5baf4b --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/chaintype_test.go @@ -0,0 +1,60 @@ +package aptos + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func TestResolveKey_SentinelUnderBroadcastFails(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "0000000000000000000000000000000000000000000000000000000000000001"}} + _, err := ct.ResolveKey(s, true) + require.Error(t, err) +} + +func TestResolveKey_UnparseableUnderBroadcastFails(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "not-hex"}} + _, err := ct.ResolveKey(s, true) + require.Error(t, err) +} + +func TestResolveKey_UnparseableNonBroadcastFallsBackToSentinel(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: ""}} + k, err := ct.ResolveKey(s, false) + require.NoError(t, err) + assert.NotNil(t, k) +} + +func TestResolveKey_ValidKeyBroadcast(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "1111111111111111111111111111111111111111111111111111111111111111"}} + k, err := ct.ResolveKey(s, true) + require.NoError(t, err) + assert.NotNil(t, k) +} + +func TestParseTriggerChainSelector(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + sel, ok := ct.ParseTriggerChainSelector("aptos:ChainSelector:4741433654826277614@1.0.0") + require.True(t, ok) + assert.Equal(t, uint64(4741433654826277614), sel) + _, ok = ct.ParseTriggerChainSelector("evm:ChainSelector:1@1.0.0") + assert.False(t, ok) +} + +func TestHasSelector_False(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + assert.False(t, ct.HasSelector(1)) +} diff --git a/cmd/workflow/simulate/chain/aptos/health.go b/cmd/workflow/simulate/chain/aptos/health.go new file mode 100644 index 00000000..dbe93733 --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/health.go @@ -0,0 +1,54 @@ +package aptos + +import ( + "errors" + "fmt" + + aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// RunRPCHealthCheck probes GetChainId() on every configured Aptos client. +// experimentalSelectors identifies chains sourced from experimental-chains config. +func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelectors map[uint64]bool) error { + if len(clients) == 0 { + return fmt.Errorf("no Aptos RPC URLs found for supported or experimental chains") + } + var errs []error + for sel, c := range clients { + if c == nil { + errs = append(errs, fmt.Errorf("[%d] nil client", sel)) + continue + } + ac, ok := c.(aptosfakes.AptosClient) + if !ok { + errs = append(errs, fmt.Errorf("[%d] invalid client type for Aptos chain type", sel)) + continue + } + var label string + switch { + case experimentalSelectors[sel]: + label = fmt.Sprintf("experimental chain %d", sel) + default: + if name, err := settings.GetChainNameByChainSelector(sel); err == nil { + label = name + } else { + label = fmt.Sprintf("chain %d", sel) + } + } + chainID, err := ac.GetChainId() + if err != nil { + errs = append(errs, fmt.Errorf("[%s] failed RPC health check: %w", label, err)) + continue + } + if chainID == 0 { + errs = append(errs, fmt.Errorf("[%s] invalid RPC response: zero chain ID", label)) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} diff --git a/cmd/workflow/simulate/chain/aptos/health_test.go b/cmd/workflow/simulate/chain/aptos/health_test.go new file mode 100644 index 00000000..75f68f2f --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/health_test.go @@ -0,0 +1,101 @@ +package aptos + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + mocks "github.com/smartcontractkit/chainlink-aptos/relayer/monitor/mocks" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +func TestRunRPCHealthCheck_NoClients(t *testing.T) { + t.Parallel() + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no Aptos RPC URLs") +} + +func TestRunRPCHealthCheck_InvalidClientType(t *testing.T) { + t.Parallel() + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{1: stubNonAptosClient{}}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type") + assert.Contains(t, err.Error(), "[1]") +} + +func TestRunRPCHealthCheck_NilClient(t *testing.T) { + t.Parallel() + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{9: nil}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "[9] nil client") +} + +func TestRunRPCHealthCheck_Healthy(t *testing.T) { + t.Parallel() + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().GetChainId().Return(uint8(1), nil).Once() + require.NoError(t, RunRPCHealthCheck(map[uint64]chain.ChainClient{1: rpc}, nil)) +} + +func TestRunRPCHealthCheck_ZeroChainID(t *testing.T) { + t.Parallel() + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().GetChainId().Return(uint8(0), nil).Once() + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{7: rpc}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "zero chain ID") + assert.Contains(t, err.Error(), "[chain 7]") +} + +func TestRunRPCHealthCheck_RPCError(t *testing.T) { + t.Parallel() + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().GetChainId().Return(uint8(0), errors.New("boom")).Once() + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{3: rpc}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + assert.Contains(t, err.Error(), "[chain 3]") +} + +func TestRunRPCHealthCheck_NamedChain(t *testing.T) { + t.Parallel() + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().GetChainId().Return(uint8(0), errors.New("unreachable")).Once() + err := RunRPCHealthCheck( + map[uint64]chain.ChainClient{chainselectors.APTOS_TESTNET.Selector: rpc}, + nil, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "[aptos-testnet]") +} + +func TestRunRPCHealthCheck_ExperimentalLabel(t *testing.T) { + t.Parallel() + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().GetChainId().Return(uint8(0), nil).Once() + err := RunRPCHealthCheck( + map[uint64]chain.ChainClient{42: rpc}, + map[uint64]bool{42: true}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "experimental chain 42") +} + +func TestRunRPCHealthCheck_AggregatesMultiple(t *testing.T) { + t.Parallel() + bad := mocks.NewAptosRpcClient(t) + bad.EXPECT().GetChainId().Return(uint8(0), errors.New("net down")).Once() + zero := mocks.NewAptosRpcClient(t) + zero.EXPECT().GetChainId().Return(uint8(0), nil).Once() + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{1: bad, 2: zero}, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "net down") + assert.Contains(t, err.Error(), "zero chain ID") +} + +type stubNonAptosClient struct{} diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go new file mode 100644 index 00000000..774ef8fb --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go @@ -0,0 +1,76 @@ +package aptos + +import ( + "context" + "fmt" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + aptoscappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos" + aptosserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos/server" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// AptosChainLimits extends chain.Limits with the Aptos gas-amount limit. +type AptosChainLimits interface { + chain.Limits + ChainWriteAptosMaxGasAmount() uint64 +} + +// LimitedAptosChain enforces chain-write size + Aptos max_gas_amount. +type LimitedAptosChain struct { + inner aptosserver.ClientCapability + limits AptosChainLimits +} + +var _ aptosserver.ClientCapability = (*LimitedAptosChain)(nil) + +func NewLimitedAptosChain(inner aptosserver.ClientCapability, limits AptosChainLimits) *LimitedAptosChain { + return &LimitedAptosChain{inner: inner, limits: limits} +} + +func (l *LimitedAptosChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *aptoscappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.WriteReportReply], caperrors.Error) { + if input != nil && input.Report != nil { + if lim := l.limits.ChainWriteReportSizeLimit(); lim > 0 && len(input.Report.RawReport) > lim { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: aptos report size %d > %d", len(input.Report.RawReport), lim), + caperrors.ResourceExhausted, + ) + } + } + if input != nil && input.GasConfig != nil { + if gl := l.limits.ChainWriteAptosMaxGasAmount(); gl > 0 && input.GasConfig.MaxGasAmount > gl { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: aptos max_gas_amount %d > %d", input.GasConfig.MaxGasAmount, gl), + caperrors.ResourceExhausted, + ) + } + } + return l.inner.WriteReport(ctx, metadata, input) +} + +func (l *LimitedAptosChain) AccountAPTBalance(ctx context.Context, m commonCap.RequestMetadata, i *aptoscappb.AccountAPTBalanceRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.AccountAPTBalanceReply], caperrors.Error) { + return l.inner.AccountAPTBalance(ctx, m, i) +} +func (l *LimitedAptosChain) View(ctx context.Context, m commonCap.RequestMetadata, i *aptoscappb.ViewRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.ViewReply], caperrors.Error) { + return l.inner.View(ctx, m, i) +} +func (l *LimitedAptosChain) TransactionByHash(ctx context.Context, m commonCap.RequestMetadata, i *aptoscappb.TransactionByHashRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.TransactionByHashReply], caperrors.Error) { + return l.inner.TransactionByHash(ctx, m, i) +} +func (l *LimitedAptosChain) AccountTransactions(ctx context.Context, m commonCap.RequestMetadata, i *aptoscappb.AccountTransactionsRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.AccountTransactionsReply], caperrors.Error) { + return l.inner.AccountTransactions(ctx, m, i) +} + +func (l *LimitedAptosChain) ChainSelector() uint64 { return l.inner.ChainSelector() } +func (l *LimitedAptosChain) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedAptosChain) Close() error { return l.inner.Close() } +func (l *LimitedAptosChain) HealthReport() map[string]error { return l.inner.HealthReport() } +func (l *LimitedAptosChain) Name() string { return l.inner.Name() } +func (l *LimitedAptosChain) Description() string { return l.inner.Description() } +func (l *LimitedAptosChain) Ready() error { return l.inner.Ready() } +func (l *LimitedAptosChain) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go new file mode 100644 index 00000000..89d2a745 --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go @@ -0,0 +1,87 @@ +package aptos + +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" + aptoscappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sdk "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" +) + +type stubLimits struct { + reportSize int + maxGas uint64 +} + +func (s stubLimits) ChainWriteReportSizeLimit() int { return s.reportSize } +func (s stubLimits) ChainWriteAptosMaxGasAmount() uint64 { return s.maxGas } + +type stubCap struct{ writeCalled bool } + +func (s *stubCap) AccountAPTBalance(context.Context, commonCap.RequestMetadata, *aptoscappb.AccountAPTBalanceRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.AccountAPTBalanceReply], caperrors.Error) { + return nil, nil +} +func (s *stubCap) View(context.Context, commonCap.RequestMetadata, *aptoscappb.ViewRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.ViewReply], caperrors.Error) { + return nil, nil +} +func (s *stubCap) TransactionByHash(context.Context, commonCap.RequestMetadata, *aptoscappb.TransactionByHashRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.TransactionByHashReply], caperrors.Error) { + return nil, nil +} +func (s *stubCap) AccountTransactions(context.Context, commonCap.RequestMetadata, *aptoscappb.AccountTransactionsRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.AccountTransactionsReply], caperrors.Error) { + return nil, nil +} +func (s *stubCap) WriteReport(context.Context, commonCap.RequestMetadata, *aptoscappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.WriteReportReply], caperrors.Error) { + s.writeCalled = true + return &commonCap.ResponseAndMetadata[*aptoscappb.WriteReportReply]{Response: &aptoscappb.WriteReportReply{}}, nil +} +func (s *stubCap) ChainSelector() uint64 { return 0 } +func (s *stubCap) Start(context.Context) error { return nil } +func (s *stubCap) Close() error { return nil } +func (s *stubCap) HealthReport() map[string]error { return nil } +func (s *stubCap) Name() string { return "stub" } +func (s *stubCap) Description() string { return "" } +func (s *stubCap) Ready() error { return nil } +func (s *stubCap) Initialise(context.Context, core.StandardCapabilitiesDependencies) error { + return nil +} + +func TestLimitedAptosChain_WriteReport_ReportTooLarge(t *testing.T) { + t.Parallel() + inner := &stubCap{} + l := NewLimitedAptosChain(inner, stubLimits{reportSize: 10, maxGas: 1000}) + _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ + Report: &sdk.ReportResponse{RawReport: make([]byte, 11)}, + }) + require.NotNil(t, capErr) + assert.False(t, inner.writeCalled) +} + +func TestLimitedAptosChain_WriteReport_MaxGasTooHigh(t *testing.T) { + t.Parallel() + inner := &stubCap{} + l := NewLimitedAptosChain(inner, stubLimits{reportSize: 100, maxGas: 100}) + _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ + Report: &sdk.ReportResponse{RawReport: []byte("x")}, + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 101}, + }) + require.NotNil(t, capErr) + assert.False(t, inner.writeCalled) +} + +func TestLimitedAptosChain_WriteReport_Delegates(t *testing.T) { + t.Parallel() + inner := &stubCap{} + l := NewLimitedAptosChain(inner, stubLimits{reportSize: 100, maxGas: 1000}) + _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ + Report: &sdk.ReportResponse{RawReport: []byte("x")}, + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 50}, + }) + require.Nil(t, capErr) + assert.True(t, inner.writeCalled) +} diff --git a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go new file mode 100644 index 00000000..ced2a689 --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go @@ -0,0 +1,399 @@ +package aptos + +// simulator_scenarios_test.go runs 30 dry-run scenarios exercising FakeAptosChain +// via the simulator plumbing. All scenarios are fully in-process: no network I/O, +// no --broadcast. They verify parity with the EVM simulator's behavioural surface +// (success paths, validation errors, limit enforcement, per-selector dispatch, +// key resolution semantics). + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/aptos-labs/aptos-go-sdk" + "github.com/aptos-labs/aptos-go-sdk/api" + "github.com/aptos-labs/aptos-go-sdk/crypto" + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + aptoscappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + sdk "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" + + aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" + mocks "github.com/smartcontractkit/chainlink-aptos/relayer/monitor/mocks" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// simScenario is a self-contained dry-run scenario. +type simScenario struct { + name string + run func(t *testing.T) +} + +// mkAddr returns a 32-byte address whose first byte is b. +func mkAddr(b byte) []byte { out := make([]byte, 32); out[0] = b; return out } + +func testAddr(t *testing.T, s string) aptos.AccountAddress { + t.Helper() + var a aptos.AccountAddress + require.NoError(t, a.ParseStringRelaxed(s)) + return a +} + +func newKey(t *testing.T) *crypto.Ed25519PrivateKey { + t.Helper() + k, err := crypto.GenerateEd25519PrivateKey() + require.NoError(t, err) + return k +} + +func newChain(t *testing.T, rpc *mocks.AptosRpcClient, dryRun bool, selector uint64) *aptosfakes.FakeAptosChain { + t.Helper() + fc, err := aptosfakes.NewFakeAptosChain(logger.Test(t), rpc, newKey(t), + testAddr(t, "0xdead"), selector, dryRun) + require.NoError(t, err) + return fc +} + +func simulatorScenarios() []simScenario { + meta := commonCap.RequestMetadata{} + ctx := context.Background() + + validGas := func() *aptoscappb.GasConfig { + return &aptoscappb.GasConfig{MaxGasAmount: 10_000, GasUnitPrice: 100} + } + validReport := func() *sdk.ReportResponse { + return &sdk.ReportResponse{RawReport: []byte("report")} + } + + return []simScenario{ + // --- read-path scenarios (1-10) --- + {name: "01 AccountAPTBalance returns u64", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(12345), nil).Once() + fc := newChain(t, rpc, true, chainselectors.APTOS_TESTNET.Selector) + reply, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0xA1)}) + require.Nil(t, capErr) + assert.Equal(t, uint64(12345), reply.Response.Value) + }}, + {name: "02 AccountAPTBalance rejects nil", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.AccountAPTBalance(ctx, meta, nil) + require.NotNil(t, capErr) + }}, + {name: "03 AccountAPTBalance rejects short address", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: []byte{1, 2, 3}}) + require.NotNil(t, capErr) + }}, + {name: "04 AccountAPTBalance surfaces RPC error", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(0), fmt.Errorf("503")).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x01)}) + require.NotNil(t, capErr) + }}, + {name: "05 View round-trips opaque bytes", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().View(mock.Anything).Return([]any{"hello"}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{ + Module: &aptoscappb.ModuleID{Address: mkAddr(0x01), Name: "m"}, + Function: "f", + }, + }) + require.Nil(t, capErr) + assert.Equal(t, []byte("hello"), reply.Response.Data) + }}, + {name: "06 View rejects nil payload", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{}) + require.NotNil(t, capErr) + }}, + {name: "07 View respects ledger_version", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().View(mock.Anything, mock.Anything).Return([]any{"0"}, nil).Once() + fc := newChain(t, rpc, true, 1) + ledger := uint64(42) + _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, + LedgerVersion: &ledger, + }) + require.Nil(t, capErr) + }}, + {name: "08 TransactionByHash found", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().TransactionByHash("0x1").Return(&api.Transaction{ + Type: api.TransactionVariantUser, Inner: &api.UserTransaction{Hash: "0x1", Version: 1, Success: true}, + }, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0x1"}) + require.Nil(t, capErr) + require.NotNil(t, reply.Response.Transaction) + assert.Equal(t, "0x1", reply.Response.Transaction.Hash) + }}, + {name: "09 TransactionByHash missing returns nil tx", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().TransactionByHash(mock.Anything).Return(nil, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0xnope"}) + require.Nil(t, capErr) + assert.Nil(t, reply.Response.Transaction) + }}, + {name: "10 TransactionByHash empty hash rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: ""}) + require.NotNil(t, capErr) + }}, + + // --- pagination + account list (11-13) --- + {name: "11 AccountTransactions delegates pagination", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountTransactions(mock.Anything, mock.Anything, mock.Anything). + Return([]*api.CommittedTransaction{ + {Type: api.TransactionVariantUser, Inner: &api.UserTransaction{Hash: "0xa"}}, + }, nil).Once() + fc := newChain(t, rpc, true, 1) + s, l := uint64(0), uint64(10) + reply, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{ + Address: mkAddr(0x01), Start: &s, Limit: &l, + }) + require.Nil(t, capErr) + require.Len(t, reply.Response.Transactions, 1) + }}, + {name: "12 AccountTransactions rejects bad address", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: []byte{1}}) + require.NotNil(t, capErr) + }}, + {name: "13 AccountTransactions rpc error surfaced", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountTransactions(mock.Anything, mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("transport")).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: mkAddr(0x01)}) + require.NotNil(t, capErr) + }}, + + // --- WriteReport validation (14-17) --- + {name: "14 WriteReport nil request rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.WriteReport(ctx, meta, nil) + require.NotNil(t, capErr) + }}, + {name: "15 WriteReport nil gas config rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{Receiver: mkAddr(1), Report: validReport()}) + require.NotNil(t, capErr) + }}, + {name: "16 WriteReport nil report rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{Receiver: mkAddr(1), GasConfig: validGas()}) + require.NotNil(t, capErr) + }}, + {name: "17 WriteReport bad receiver len rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: []byte{1}, GasConfig: validGas(), Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + + // --- WriteReport dry-run behaviour (18-22) --- + {name: "18 WriteReport dry-run SUCCESS, no tx hash, zero fee", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.Nil(t, capErr) + assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_SUCCESS, reply.Response.TxStatus) + assert.Nil(t, reply.Response.TxHash) + require.NotNil(t, reply.Response.TransactionFee) + assert.Zero(t, *reply.Response.TransactionFee) + }}, + {name: "19 WriteReport dry-run receiver abort -> REVERTED", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: false, VmStatus: "Move abort in 0xabc::receiver: Reject"}}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, _ := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) + require.NotNil(t, reply.Response.ReceiverContractExecutionStatus) + assert.Equal(t, + aptoscappb.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, + *reply.Response.ReceiverContractExecutionStatus) + }}, + {name: "20 WriteReport dry-run forwarder abort -> no receiver status", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: false, VmStatus: "Move abort in 0xdead::forwarder: Bad"}}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, _ := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) + assert.Nil(t, reply.Response.ReceiverContractExecutionStatus) + }}, + {name: "21 WriteReport dry-run BuildTransaction error surfaces", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("rpc-down")).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + {name: "22 WriteReport dry-run Simulate error surfaces", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("sim-fail")).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + + // --- LimitedAptosChain enforcement (23-26) --- + {name: "23 LimitedAptosChain blocks oversized report", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 5, maxGas: 10_000}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), + Report: &sdk.ReportResponse{RawReport: make([]byte, 999)}, + }) + require.NotNil(t, capErr) + }}, + {name: "24 LimitedAptosChain blocks excessive max_gas_amount", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 50}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 100, GasUnitPrice: 1}, + Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + {name: "25 LimitedAptosChain passes through within limits (dry-run)", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 100_000}) + reply, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.Nil(t, capErr) + assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_SUCCESS, reply.Response.TxStatus) + }}, + {name: "26 LimitedAptosChain delegates reads unconditionally", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(9), nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1, maxGas: 1}) + reply, capErr := l.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x02)}) + require.Nil(t, capErr) + assert.Equal(t, uint64(9), reply.Response.Value) + }}, + + // --- Multi-selector + key-resolution semantics (27-30) --- + {name: "27 Per-selector dispatch isolates chains", run: func(t *testing.T) { + rpcA := mocks.NewAptosRpcClient(t) + rpcB := mocks.NewAptosRpcClient(t) + rpcA.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(100), nil).Once() + rpcB.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(200), nil).Once() + fcA := newChain(t, rpcA, true, chainselectors.APTOS_MAINNET.Selector) + fcB := newChain(t, rpcB, true, chainselectors.APTOS_TESTNET.Selector) + rA, _ := fcA.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x01)}) + rB, _ := fcB.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x02)}) + assert.Equal(t, uint64(100), rA.Response.Value) + assert.Equal(t, uint64(200), rB.Response.Value) + }}, + {name: "28 ResolveKey sentinel OK under dry-run", run: func(t *testing.T) { + ct := &AptosChainType{} + k, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{AptosPrivateKey: ""}}, false) + require.NoError(t, err) + require.NotNil(t, k) + }}, + {name: "29 ResolveKey rejects sentinel under --broadcast", run: func(t *testing.T) { + ct := &AptosChainType{} + _, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{AptosPrivateKey: defaultSentinelAptosSeed}}, true) + require.Error(t, err) + }}, + {name: "30 Concurrent reads + writes are race-clean", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(1), nil) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil) + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil) + fc := newChain(t, rpc, true, 1) + const n = 10 + var wg sync.WaitGroup + errs := make(chan caperrors.Error, n*2) + for i := 0; i < n; i++ { + wg.Add(2) + go func() { + defer wg.Done() + if _, e := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(1)}); e != nil { + errs <- e + } + }() + go func() { + defer wg.Done() + if _, e := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(1), GasConfig: validGas(), Report: validReport(), + }); e != nil { + errs <- e + } + }() + } + wg.Wait() + close(errs) + for e := range errs { + require.Nil(t, e) + } + }}, + } +} + +// TestSimulatorScenarios_30 runs 30 dry-run scenarios and reports pass/fail per +// scenario. Verifies parity with FakeEVMChain's behavioural surface. +func TestSimulatorScenarios_30(t *testing.T) { + t.Parallel() + cases := simulatorScenarios() + require.Len(t, cases, 30, "must have exactly 30 simulator scenarios") + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + c.run(t) + }) + } +} diff --git a/cmd/workflow/simulate/chain/aptos/supported_chains.go b/cmd/workflow/simulate/chain/aptos/supported_chains.go new file mode 100644 index 00000000..6a846b9d --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/supported_chains.go @@ -0,0 +1,17 @@ +package aptos + +import ( + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// placeholderForwarder is used until canonical platform_mock addresses are +// published per network. Users override via experimental-chains config. +const placeholderForwarder = "0x0000000000000000000000000000000000000000000000000000000000000000" + +// SupportedChains lists Aptos networks cre-cli simulate can target. +var SupportedChains = []chain.ChainConfig{ + {Selector: chainselectors.APTOS_MAINNET.Selector, Forwarder: placeholderForwarder}, + {Selector: chainselectors.APTOS_TESTNET.Selector, Forwarder: placeholderForwarder}, +} diff --git a/cmd/workflow/simulate/chain/aptos/supported_chains_test.go b/cmd/workflow/simulate/chain/aptos/supported_chains_test.go new file mode 100644 index 00000000..f0536d6b --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/supported_chains_test.go @@ -0,0 +1,23 @@ +package aptos + +import ( + "testing" + + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/assert" +) + +func TestSupportedChains_MainnetAndTestnet(t *testing.T) { + t.Parallel() + var hasMainnet, hasTestnet bool + for _, c := range SupportedChains { + switch c.Selector { + case chainselectors.APTOS_MAINNET.Selector: + hasMainnet = true + case chainselectors.APTOS_TESTNET.Selector: + hasTestnet = true + } + } + assert.True(t, hasMainnet) + assert.True(t, hasTestnet) +} diff --git a/cmd/workflow/simulate/chain/evm/capabilities.go b/cmd/workflow/simulate/chain/evm/capabilities.go new file mode 100644 index 00000000..22f000b1 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/capabilities.go @@ -0,0 +1,88 @@ +package evm + +import ( + "context" + "crypto/ecdsa" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes" +) + +// EVMChainCapabilities holds the EVM chain capability servers created for simulation. +type EVMChainCapabilities struct { + EVMChains map[uint64]*fakes.FakeEVMChain +} + +// NewEVMChainCapabilities creates EVM chain capability servers and registers them +// with the capability registry. Cron and HTTP triggers are not created here — they +// are chain-agnostic and managed by the simulate command directly. +func NewEVMChainCapabilities( + ctx context.Context, + lggr logger.Logger, + registry *capabilities.Registry, + clients map[uint64]*ethclient.Client, + forwarders map[uint64]string, + privateKey *ecdsa.PrivateKey, + dryRunChainWrite bool, + limits EVMChainLimits, +) (*EVMChainCapabilities, error) { + evmChains := make(map[uint64]*fakes.FakeEVMChain) + for sel, client := range clients { + fwdStr, ok := forwarders[sel] + if !ok { + lggr.Infow("Forwarder not found for chain", "selector", sel) + continue + } + + evm := fakes.NewFakeEvmChain( + lggr, + client, + privateKey, + common.HexToAddress(fwdStr), + sel, + dryRunChainWrite, + ) + + // Wrap with limits enforcement if limits are provided + var evmCap evmserver.ClientCapability = evm + if limits != nil { + evmCap = NewLimitedEVMChain(evm, limits) + } + + evmServer := evmserver.NewClientServer(evmCap) + if err := registry.Add(ctx, evmServer); err != nil { + return nil, err + } + + evmChains[sel] = evm + } + + return &EVMChainCapabilities{ + EVMChains: evmChains, + }, nil +} + +// Start starts all configured EVM chains. +func (c *EVMChainCapabilities) Start(ctx context.Context) error { + for _, evm := range c.EVMChains { + if err := evm.Start(ctx); err != nil { + return err + } + } + return nil +} + +// Close closes all EVM chains. +func (c *EVMChainCapabilities) Close() error { + for _, evm := range c.EVMChains { + if err := evm.Close(); err != nil { + return err + } + } + return nil +} diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go new file mode 100644 index 00000000..ff09731b --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -0,0 +1,285 @@ +package evm + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog" + "github.com/spf13/viper" + + corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +const defaultSentinelPrivateKey = "0000000000000000000000000000000000000000000000000000000000000001" + +func init() { + chain.Register(string(corekeys.EVM), func(lggr *zerolog.Logger) chain.ChainType { + return &EVMChainType{log: lggr} + }, []chain.CLIFlagDef{ + {Name: TriggerInputTxHash, Description: "EVM trigger transaction hash (0x...)", FlagType: chain.CLIFlagString}, + {Name: TriggerInputEventIndex, Description: "EVM trigger log index (0-based)", DefaultValue: "-1", FlagType: chain.CLIFlagInt}, + }) +} + +// EVMChainType implements chain.ChainType for EVM-based blockchains. +type EVMChainType struct { + log *zerolog.Logger + evmChains *EVMChainCapabilities +} + +var _ chain.ChainType = (*EVMChainType)(nil) + +func (ct *EVMChainType) Name() string { return "evm" } + +func (ct *EVMChainType) SupportedChains() []chain.ChainConfig { + return SupportedChains +} + +func (ct *EVMChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, error) { + clients := make(map[uint64]chain.ChainClient) + forwarders := make(map[uint64]string) + experimental := make(map[uint64]bool) + + // build clients for each supported chain from settings, skip if rpc is empty + for _, ch := range SupportedChains { + chainName, err := settings.GetChainNameByChainSelector(ch.Selector) + if err != nil { + ct.log.Error().Msgf("Invalid chain selector for supported EVM chains %d; skipping", ch.Selector) + continue + } + rpcURL, err := settings.GetRpcUrlSettings(v, chainName) + if err != nil || strings.TrimSpace(rpcURL) == "" { + ct.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) + continue + } + ct.log.Debug().Msgf("Using RPC for %s: %s", chainName, chain.RedactURL(rpcURL)) + + c, err := ethclient.Dial(rpcURL) + if err != nil { + ui.Warning(fmt.Sprintf("Failed to create eth client for %s: %v", chainName, err)) + continue + } + clients[ch.Selector] = c + if strings.TrimSpace(ch.Forwarder) != "" { + forwarders[ch.Selector] = ch.Forwarder + } + } + + // Resolve experimental chains + expChains, err := settings.GetExperimentalChains(v) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("failed to load experimental chains config: %w", err) + } + + for _, ec := range expChains { + if ec.ChainSelector == 0 { + return chain.ResolvedChains{}, fmt.Errorf("experimental chain missing chain-selector") + } + if strings.TrimSpace(ec.RPCURL) == "" { + return chain.ResolvedChains{}, fmt.Errorf("experimental chain %d missing rpc-url", ec.ChainSelector) + } + if strings.TrimSpace(ec.Forwarder) == "" { + return chain.ResolvedChains{}, fmt.Errorf("experimental chain %d missing forwarder", ec.ChainSelector) + } + + // For duplicate selectors, keep the supported client and only + // override the forwarder. + if _, exists := clients[ec.ChainSelector]; exists { + if common.HexToAddress(forwarders[ec.ChainSelector]) != common.HexToAddress(ec.Forwarder) { + ui.Warning(fmt.Sprintf("Warning: experimental chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", + ec.ChainSelector, forwarders[ec.ChainSelector], ec.Forwarder)) + forwarders[ec.ChainSelector] = ec.Forwarder + } else { + ct.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") + } + continue + } + + ct.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, chain.RedactURL(ec.RPCURL)) + c, err := ethclient.Dial(ec.RPCURL) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) + } + clients[ec.ChainSelector] = c + forwarders[ec.ChainSelector] = ec.Forwarder + experimental[ec.ChainSelector] = true + ui.Dim(fmt.Sprintf("Added experimental chain (chain-selector: %d)\n", ec.ChainSelector)) + } + + return chain.ResolvedChains{ + Clients: clients, + Forwarders: forwarders, + ExperimentalSelectors: experimental, + }, nil +} + +func (ct *EVMChainType) RegisterCapabilities(ctx context.Context, cfg chain.CapabilityConfig) ([]services.Service, error) { + // Convert generic ChainClient map to typed *ethclient.Client map + ethClients := make(map[uint64]*ethclient.Client) + for sel, c := range cfg.Clients { + ec, ok := c.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("EVM: client for selector %d is not *ethclient.Client", sel) + } + ethClients[sel] = ec + } + + // Type-assert the private key + var pk *ecdsa.PrivateKey + if cfg.PrivateKey != nil { + var ok bool + pk, ok = cfg.PrivateKey.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("EVM: private key is not *ecdsa.PrivateKey") + } + } + + dryRun := !cfg.Broadcast + + // cfg.Limits is the generic chain.Limits contract. The EVM chain type + // needs the wider EVMChainLimits contract (adds ChainWriteGasLimit). A + // nil cfg.Limits disables enforcement entirely. + var evmLimits EVMChainLimits + if cfg.Limits != nil { + el, ok := cfg.Limits.(EVMChainLimits) + if !ok { + return nil, fmt.Errorf("EVM chain type: limits value does not implement evm.EVMChainLimits (got %T)", cfg.Limits) + } + evmLimits = el + } + + evmCaps, err := NewEVMChainCapabilities( + ctx, cfg.Logger, cfg.Registry, + ethClients, cfg.Forwarders, pk, + dryRun, evmLimits, + ) + if err != nil { + return nil, err + } + + // Start the EVM chains so they begin listening for triggers + if err := evmCaps.Start(ctx); err != nil { + return nil, fmt.Errorf("EVM: failed to start chain capabilities: %w", err) + } + + ct.evmChains = evmCaps + + srvcs := make([]services.Service, 0, len(evmCaps.EVMChains)) + for _, evm := range evmCaps.EVMChains { + srvcs = append(srvcs, evm) + } + return srvcs, nil +} + +func (ct *EVMChainType) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { + if ct.evmChains == nil { + return fmt.Errorf("EVM: capabilities not registered") + } + evmChain := ct.evmChains.EVMChains[selector] + if evmChain == nil { + return fmt.Errorf("no EVM chain initialized for selector %d", selector) + } + log, ok := triggerData.(*evmpb.Log) + if !ok { + return fmt.Errorf("EVM: trigger data is not *evm.Log") + } + return evmChain.ManualTrigger(ctx, registrationID, log) +} + +// HasSelector reports whether an EVM chain capability has been initialised +// for the given selector. Callers use this at trigger-setup time to avoid +// building a TriggerFunc for a selector the chain type cannot dispatch against. +func (ct *EVMChainType) HasSelector(selector uint64) bool { + if ct.evmChains == nil { + return false + } + return ct.evmChains.EVMChains[selector] != nil +} + +func (ct *EVMChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + return ParseTriggerChainSelector(triggerID) +} + +func (ct *EVMChainType) RunHealthCheck(resolved chain.ResolvedChains) error { + return RunRPCHealthCheck(resolved.Clients, resolved.ExperimentalSelectors) +} + +// ResolveKey parses the user's ECDSA private key from settings. When broadcast +// is true, an invalid or default-sentinel key is a hard error. Otherwise a +// sentinel key is used with a warning so non-broadcast simulations can run. +func (ct *EVMChainType) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { + pk, err := crypto.HexToECDSA(creSettings.User.EthPrivateKey) + if err != nil { + if broadcast { + return nil, fmt.Errorf( + "failed to parse private key, required to broadcast. Please check CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + } + pk, err = crypto.HexToECDSA(defaultSentinelPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to parse default private key. Please set CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + } + ui.Warning("Using default private key for chain write simulation. To use your own key, set CRE_ETH_PRIVATE_KEY in your .env file or system environment.") + } + if broadcast && pk.D.Cmp(big.NewInt(1)) == 0 { + return nil, fmt.Errorf("you must configure a valid private key to perform on-chain writes. Please set your private key in the .env file before using the --broadcast flag") + } + return pk, nil +} + +// CLI input keys consumed from chain.TriggerParams.ChainTypeInputs. +const ( + TriggerInputTxHash = "evm-tx-hash" + TriggerInputEventIndex = "evm-event-index" +) + +func (ct *EVMChainType) CollectCLIInputs(v *viper.Viper) map[string]string { + inputs := map[string]string{} + if txHash := strings.TrimSpace(v.GetString(TriggerInputTxHash)); txHash != "" { + inputs[TriggerInputTxHash] = txHash + } + if idx := v.GetInt(TriggerInputEventIndex); idx >= 0 { + inputs[TriggerInputEventIndex] = strconv.Itoa(idx) + } + return inputs +} + +// ResolveTriggerData fetches the EVM log payload for the given selector from +// CLI-supplied or interactively-prompted inputs. +func (ct *EVMChainType) ResolveTriggerData(ctx context.Context, selector uint64, params chain.TriggerParams) (interface{}, error) { + clientIface, ok := params.Clients[selector] + if !ok { + return nil, fmt.Errorf("no RPC configured for chain selector %d", selector) + } + client, ok := clientIface.(*ethclient.Client) + if !ok { + return nil, fmt.Errorf("invalid client type for EVM chain selector %d", selector) + } + + if params.Interactive { + return GetEVMTriggerLog(ctx, client) + } + + txHash := strings.TrimSpace(params.ChainTypeInputs[TriggerInputTxHash]) + eventIndexStr := strings.TrimSpace(params.ChainTypeInputs[TriggerInputEventIndex]) + if txHash == "" || eventIndexStr == "" { + return nil, fmt.Errorf("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") + } + eventIndex, err := strconv.ParseUint(eventIndexStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid --evm-event-index %q: %w", eventIndexStr, err) + } + return GetEVMTriggerLogFromValues(ctx, client, txHash, eventIndex) +} diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go new file mode 100644 index 00000000..ee40c8a4 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -0,0 +1,373 @@ +package evm + +import ( + "bytes" + "context" + "crypto/ecdsa" + "io" + "math/big" + "os" + "strings" + "sync" + "testing" + + "github.com/rs/zerolog" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func bigOne() *big.Int { return big.NewInt(1) } + +func nopCommonLogger() logger.Logger { + lg := logger.NewWithSync(io.Discard) + return lg +} + +func newRegistry(t *testing.T) *capabilities.Registry { + t.Helper() + r := capabilities.NewRegistry(logger.Test(t)) + return r +} + +// stdioMu serialises os.Stderr / os.Stdout hijacks so parallel capture tests +// don't clobber each other's pipes. +var stdioMu sync.Mutex + +// captureStderr captures anything written to os.Stderr during fn. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + stdioMu.Lock() + defer stdioMu.Unlock() + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + defer func() { + os.Stderr = old + }() + + fn() + + _ = w.Close() + <-done + return buf.String() +} + +func newEVMChainType() *EVMChainType { + lg := zerolog.Nop() + return &EVMChainType{log: &lg} +} + +// Valid anvil dev key #0; known non-sentinel. +const validPK = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +func TestEVMChainType_ResolveKey(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pk string + broadcast bool + wantErr bool + errContains string + wantStderr string // substring expected in ui.Warning stderr; "" = no warn + checkD1 bool // sentinel (D==1) expected if non-err non-broadcast + }{ + { + name: "valid key, non-broadcast, returns parsed key, no warning", + pk: validPK, + broadcast: false, + }, + { + name: "valid key, broadcast, returns parsed key", + pk: validPK, + broadcast: true, + }, + { + name: "invalid hex, non-broadcast, falls back to sentinel and warns", + pk: "notahex", + broadcast: false, + wantStderr: "Using default private key for chain write simulation", + checkD1: true, + }, + { + name: "empty key, non-broadcast, falls back to sentinel and warns", + pk: "", + broadcast: false, + wantStderr: "Using default private key for chain write simulation", + checkD1: true, + }, + { + name: "0x-prefixed key (invalid per HexToECDSA), non-broadcast, falls back + warns", + pk: "0x" + validPK, + broadcast: false, + wantStderr: "Using default private key", + checkD1: true, + }, + { + name: "too-short key, non-broadcast, falls back + warns", + pk: "ab", + broadcast: false, + wantStderr: "Using default private key", + checkD1: true, + }, + { + name: "invalid hex, broadcast, hard error", + pk: "notahex", + broadcast: true, + wantErr: true, + errContains: "failed to parse private key, required to broadcast", + }, + { + name: "empty key, broadcast, hard error", + pk: "", + broadcast: true, + wantErr: true, + errContains: "CRE_ETH_PRIVATE_KEY", + }, + { + name: "sentinel key, broadcast, hard error about configuring valid key", + pk: defaultSentinelPrivateKey, + broadcast: true, + wantErr: true, + errContains: "configure a valid private key", + }, + { + name: "sentinel key, non-broadcast, returned without warning (parses fine)", + pk: defaultSentinelPrivateKey, + broadcast: false, + checkD1: true, + }, + { + name: "too-short key, broadcast, hard error", + pk: "ab", + broadcast: true, + wantErr: true, + errContains: "required to broadcast", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ct := newEVMChainType() + s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: tt.pk}} + + var got interface{} + var err error + stderr := captureStderr(t, func() { + got, err = ct.ResolveKey(s, tt.broadcast) + }) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errContains) + assert.Nil(t, got) + return + } + require.NoError(t, err) + pk, ok := got.(*ecdsa.PrivateKey) + require.True(t, ok, "expected *ecdsa.PrivateKey, got %T", got) + require.NotNil(t, pk) + if tt.checkD1 { + assert.Equal(t, 0, pk.D.Cmp(bigOne()), "expected sentinel D==1") + } + if tt.wantStderr == "" { + assert.NotContains(t, stderr, "Using default private key", + "did not expect sentinel warning but got: %s", stderr) + } else { + assert.Contains(t, stderr, tt.wantStderr) + } + }) + } +} + +func TestEVMChainType_ResolveTriggerData_NoClient(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + _, err := ct.ResolveTriggerData(context.Background(), 777, chain.TriggerParams{ + Clients: map[uint64]chain.ChainClient{}, + Interactive: false, + ChainTypeInputs: map[string]string{ + "evm-tx-hash": "0x" + strings.Repeat("a", 64), + "evm-event-index": "0", + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RPC configured for chain selector 777") +} + +func TestEVMChainType_ResolveTriggerData_WrongClientType(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + _, err := ct.ResolveTriggerData(context.Background(), 1, chain.TriggerParams{ + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Interactive: false, + ChainTypeInputs: map[string]string{ + "evm-tx-hash": "0x" + strings.Repeat("a", 64), + "evm-event-index": "0", + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for EVM chain selector 1") +} + +func TestEVMChainType_ExecuteTrigger_NotRegistered(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + err := ct.ExecuteTrigger(context.Background(), 1, "regID", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "EVM: capabilities not registered") +} + +func TestEVMChainType_ExecuteTrigger_UnknownSelector(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + // set evmChains with empty map to bypass nil check + ct.evmChains = &EVMChainCapabilities{EVMChains: nil} + err := ct.ExecuteTrigger(context.Background(), 999, "regID", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no EVM chain initialized for selector 999") +} + +func TestEVMChainType_HasSelector_WhenNotRegistered_ReturnsFalse(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + assert.False(t, ct.HasSelector(1)) + assert.False(t, ct.HasSelector(0)) +} + +func TestEVMChainType_RegisterCapabilities_WrongClientType(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{1: "not-an-ethclient"}, + Forwarders: map[uint64]string{1: "0x" + strings.Repeat("a", 40)}, + } + _, err := ct.RegisterCapabilities(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "client for selector 1 is not *ethclient.Client") +} + +// With no clients the caps should still construct, no type-assertion error. +func TestEVMChainType_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + Forwarders: map[uint64]string{}, + Logger: nopCommonLogger(), + Registry: newRegistry(t), + } + srvcs, err := ct.RegisterCapabilities(context.Background(), cfg) + // No clients means no chains; should succeed with empty service list. + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assert.Empty(t, srvcs) + assert.False(t, ct.HasSelector(1)) +} + +func TestEVMChainType_RunHealthCheck_PropagatesInvalidClientType(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + resolved := chain.ResolvedChains{ + Clients: map[uint64]chain.ChainClient{1: "not-ethclient"}, + } + err := ct.RunHealthCheck(resolved) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for EVM chain type") +} + +func TestEVMChainType_RunHealthCheck_NoClients_Errors(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + err := ct.RunHealthCheck(chain.ResolvedChains{ + Clients: map[uint64]chain.ChainClient{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RPC URLs found") +} + +func TestEVMChainType_RegisteredInFactoryRegistry(t *testing.T) { + t.Parallel() + lg := zerolog.Nop() + chain.Build(&lg) + names := chain.Names() + found := false + for _, n := range names { + if n == "evm" { + found = true + break + } + } + require.True(t, found, "evm chain type should be registered at init; got %v", names) + + ct, err := chain.Get("evm") + require.NoError(t, err) + require.Equal(t, "evm", ct.Name()) +} + +func TestEVMChainType_CollectCLIInputs_BothSet(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + v.Set("evm-tx-hash", "0xabc123") + v.Set("evm-event-index", 2) + + result := ct.CollectCLIInputs(v) + assert.Equal(t, "0xabc123", result[TriggerInputTxHash]) + assert.Equal(t, "2", result[TriggerInputEventIndex]) +} + +func TestEVMChainType_CollectCLIInputs_NegativeIndexOmitted(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + v.Set("evm-tx-hash", "0xabc") + v.Set("evm-event-index", -1) + + result := ct.CollectCLIInputs(v) + assert.Equal(t, "0xabc", result[TriggerInputTxHash]) + _, hasIndex := result[TriggerInputEventIndex] + assert.False(t, hasIndex, "negative index should be omitted") +} + +func TestEVMChainType_CollectCLIInputs_EmptyTxHashOmitted(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + v.Set("evm-tx-hash", "") + v.Set("evm-event-index", 0) + + result := ct.CollectCLIInputs(v) + _, hasTx := result[TriggerInputTxHash] + assert.False(t, hasTx, "empty tx hash should be omitted") + assert.Equal(t, "0", result[TriggerInputEventIndex]) +} + +func TestEVMChainType_CollectCLIInputs_DefaultsOnly(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + v := viper.New() + // Viper defaults int to 0; simulate's flag registration sets default to -1. + // Without explicit flag defaults, CollectCLIInputs sees 0 (>= 0) and includes it. + v.SetDefault("evm-event-index", -1) + + result := ct.CollectCLIInputs(v) + assert.Empty(t, result) +} diff --git a/cmd/workflow/simulate/chain/evm/health.go b/cmd/workflow/simulate/chain/evm/health.go new file mode 100644 index 00000000..05dde43f --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/health.go @@ -0,0 +1,80 @@ +package evm + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// RunRPCHealthCheck validates RPC connectivity for all configured EVM clients. +// The experimentalSelectors set identifies which selectors are experimental chains. +func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelectors map[uint64]bool) error { + ethClients := make(map[uint64]*ethclient.Client) + for sel, c := range clients { + ec, ok := c.(*ethclient.Client) + if !ok { + return fmt.Errorf("[%d] invalid client type for EVM chain type", sel) + } + ethClients[sel] = ec + } + + return checkRPCConnectivity(ethClients, experimentalSelectors) +} + +// checkRPCConnectivity runs connectivity check against every configured client. +// experimentalSelectors set identifies experimental chains (not in chain-selectors). +func checkRPCConnectivity(clients map[uint64]*ethclient.Client, experimentalSelectors map[uint64]bool) error { + if len(clients) == 0 { + return fmt.Errorf("check your settings: no RPC URLs found for supported or experimental chains") + } + + var errs []error + for selector, c := range clients { + if c == nil { + // shouldnt happen + errs = append(errs, fmt.Errorf("[%d] nil client", selector)) + continue + } + + // Determine chain label for error messages + var chainLabel string + if experimentalSelectors[selector] { + chainLabel = fmt.Sprintf("experimental chain %d", selector) + } else { + name, err := settings.GetChainNameByChainSelector(selector) + if err != nil { + // If we can't get the name, use the selector as the label + chainLabel = fmt.Sprintf("chain %d", selector) + } else { + chainLabel = name + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + chainID, err := c.ChainID(ctx) + cancel() // don't defer in a loop + + if err != nil { + errs = append(errs, fmt.Errorf("[%s] failed RPC health check: %w", chainLabel, err)) + continue + } + if chainID == nil || chainID.Sign() <= 0 { + errs = append(errs, fmt.Errorf("[%s] invalid RPC response: empty or zero chain ID", chainLabel)) + continue + } + } + + if len(errs) > 0 { + // Caller aggregates per-chain-type health-check errors under a single + // "RPC health check failed:" heading, so we only return the joined + // per-selector errors here. + return errors.Join(errs...) + } + return nil +} diff --git a/cmd/workflow/simulate/chain/evm/health_test.go b/cmd/workflow/simulate/chain/evm/health_test.go new file mode 100644 index 00000000..3b6de2a7 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/health_test.go @@ -0,0 +1,291 @@ +package evm + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +const ( + selectorSepolia uint64 = 16015286601757825753 // expects "ethereum-testnet-sepolia" + chainEthMainnet uint64 = 5009297550715157269 // ethereum-mainnet +) + +// newChainIDServer returns a JSON-RPC 2.0 server that replies to eth_chainId. +func newChainIDServer(t *testing.T, reply interface{}) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + + type rpcErr struct { + Code int `json:"code"` + Message string `json:"message"` + } + + res := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + } + switch v := reply.(type) { + case string: + res["result"] = v + case error: + res["error"] = rpcErr{Code: -32603, Message: v.Error()} + default: + res["result"] = v + } + _ = json.NewEncoder(w).Encode(res) + })) +} + +func newEthClient(t *testing.T, url string) *ethclient.Client { + t.Helper() + c, err := ethclient.Dial(url) + if err != nil { + t.Fatalf("dial eth client: %v", err) + } + return c +} + +func mustContain(t *testing.T, s string, subs ...string) { + t.Helper() + for _, sub := range subs { + if !strings.Contains(s, sub) { + t.Fatalf("expected error to contain %q, got:\n%s", sub, s) + } + } +} + +func TestHealthCheck_NoClientsConfigured(t *testing.T) { + err := checkRPCConnectivity(map[uint64]*ethclient.Client{}, nil) + if err == nil { + t.Fatalf("expected error for no clients configured") + } + mustContain(t, err.Error(), "check your settings: no RPC URLs found for supported or experimental chains") +} + +func TestHealthCheck_NilClient(t *testing.T) { + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + 123: nil, + }, nil) + if err == nil { + t.Fatalf("expected error for nil client") + } + mustContain(t, err.Error(), "[123] nil client") +} + +func TestHealthCheck_AllOK(t *testing.T) { + sOK := newChainIDServer(t, "0xaa36a7") + defer sOK.Close() + + cOK := newEthClient(t, sOK.URL) + defer cOK.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cOK, + }, nil) + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } +} + +func TestHealthCheck_RPCError_usesChainName(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + + cErr := newEthClient(t, sErr.URL) + defer cErr.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cErr, + }, nil) + if err == nil { + t.Fatalf("expected error for RPC failure") + } + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] failed RPC health check", + ) +} + +func TestHealthCheck_ZeroChainID_usesChainName(t *testing.T) { + sZero := newChainIDServer(t, "0x0") + defer sZero.Close() + + cZero := newEthClient(t, sZero.URL) + defer cZero.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cZero, + }, nil) + if err == nil { + t.Fatalf("expected error for zero chain id") + } + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] invalid RPC response: empty or zero chain ID", + ) +} + +func TestHealthCheck_AggregatesMultipleErrors(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + + cErr := newEthClient(t, sErr.URL) + defer cErr.Close() + + err := checkRPCConnectivity(map[uint64]*ethclient.Client{ + selectorSepolia: cErr, + 777: nil, + }, nil) + if err == nil { + t.Fatalf("expected aggregated error") + } + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] failed RPC health check", + "[777] nil client", + ) +} + +func TestRunRPCHealthCheck_InvalidClientType(t *testing.T) { + err := RunRPCHealthCheck(map[uint64]chain.ChainClient{ + 123: "not-an-ethclient", + }, nil) + if err == nil { + t.Fatalf("expected error for invalid client type") + } + mustContain(t, err.Error(), "invalid client type for EVM chain type") +} + +func TestHealthCheck_ExperimentalSelector_UsesExperimentalLabel(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + c := newEthClient(t, sErr.URL) + defer c.Close() + + const expSel uint64 = 99999999 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{expSel: c}, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[experimental chain 99999999]", + ) +} + +func TestHealthCheck_ExperimentalSelector_ZeroChainID_UsesExperimentalLabel(t *testing.T) { + sZero := newChainIDServer(t, "0x0") + defer sZero.Close() + c := newEthClient(t, sZero.URL) + defer c.Close() + + const expSel uint64 = 42424242 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{expSel: c}, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[experimental chain 42424242]", + "invalid RPC response: empty or zero chain ID", + ) +} + +func TestHealthCheck_UnknownSelector_FallsBackToSelectorLabel(t *testing.T) { + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + c := newEthClient(t, sErr.URL) + defer c.Close() + + const unknown uint64 = 11111 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{unknown: c}, + nil, + ) + require.Error(t, err) + mustContain(t, err.Error(), + fmt.Sprintf("[chain %d]", unknown), + ) +} + +func TestHealthCheck_MixedKnownAndExperimental(t *testing.T) { + sOK := newChainIDServer(t, "0xaa36a7") + defer sOK.Close() + cOK := newEthClient(t, sOK.URL) + defer cOK.Close() + + sErr := newChainIDServer(t, fmt.Errorf("boom")) + defer sErr.Close() + cErr := newEthClient(t, sErr.URL) + defer cErr.Close() + + const expSel uint64 = 99999999 + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{ + selectorSepolia: cOK, + expSel: cErr, + }, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[experimental chain 99999999] failed RPC health check", + ) + // sepolia is healthy; its label must not appear. + assert.NotContains(t, err.Error(), "[ethereum-testnet-sepolia] failed") +} + +// RunRPCHealthCheck (public wrapper) — ensures ChainClient map conversion. +func TestRunRPCHealthCheck_WrapperConvertsEthClientMap(t *testing.T) { + sOK := newChainIDServer(t, "0xaa36a7") + defer sOK.Close() + c := newEthClient(t, sOK.URL) + defer c.Close() + + err := RunRPCHealthCheck( + map[uint64]chain.ChainClient{selectorSepolia: c}, + map[uint64]bool{}, + ) + require.NoError(t, err) +} + +func TestHealthCheck_ThreeErrors_AllLabelsInAggregated(t *testing.T) { + sErr1 := newChainIDServer(t, fmt.Errorf("boom1")) + defer sErr1.Close() + cErr1 := newEthClient(t, sErr1.URL) + defer cErr1.Close() + + sErr2 := newChainIDServer(t, fmt.Errorf("boom2")) + defer sErr2.Close() + cErr2 := newEthClient(t, sErr2.URL) + defer cErr2.Close() + + err := checkRPCConnectivity( + map[uint64]*ethclient.Client{ + selectorSepolia: cErr1, + chainEthMainnet: cErr2, + 77777: nil, + }, + nil, + ) + require.Error(t, err) + mustContain(t, err.Error(), + "[ethereum-testnet-sepolia] failed RPC health check", + "[ethereum-mainnet] failed RPC health check", + "[77777] nil client", + ) +} diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go new file mode 100644 index 00000000..b7b50e02 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -0,0 +1,110 @@ +package evm + +import ( + "context" + "fmt" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +// EVMChainLimits is the EVM-scoped limit contract LimitedEVMChain enforces. +// It extends chain.Limits with EVM-specific accessors (e.g. gas limit) so +// non-EVM chain types cannot accidentally depend on EVM semantics. +type EVMChainLimits interface { + chain.Limits + ChainWriteGasLimit() uint64 +} + +// LimitedEVMChain wraps an evmserver.ClientCapability and enforces chain write +// report size and gas limits. +type LimitedEVMChain struct { + inner evmserver.ClientCapability + limits EVMChainLimits +} + +var _ evmserver.ClientCapability = (*LimitedEVMChain)(nil) + +func NewLimitedEVMChain(inner evmserver.ClientCapability, limits EVMChainLimits) *LimitedEVMChain { + return &LimitedEVMChain{inner: inner, limits: limits} +} + +func (l *LimitedEVMChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + // Check report size + reportLimit := l.limits.ChainWriteReportSizeLimit() + if reportLimit > 0 && input.Report != nil && len(input.Report.RawReport) > reportLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), reportLimit), + caperrors.ResourceExhausted, + ) + } + + // Check gas limit + gasLimit := l.limits.ChainWriteGasLimit() + if gasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > gasLimit { + return nil, caperrors.NewPublicUserError( + fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, gasLimit), + caperrors.ResourceExhausted, + ) + } + + return l.inner.WriteReport(ctx, metadata, input) +} + +// All other methods delegate to the inner capability. + +func (l *LimitedEVMChain) CallContract(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { + return l.inner.CallContract(ctx, metadata, input) +} + +func (l *LimitedEVMChain) FilterLogs(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { + return l.inner.FilterLogs(ctx, metadata, input) +} + +func (l *LimitedEVMChain) BalanceAt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { + return l.inner.BalanceAt(ctx, metadata, input) +} + +func (l *LimitedEVMChain) EstimateGas(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { + return l.inner.EstimateGas(ctx, metadata, input) +} + +func (l *LimitedEVMChain) GetTransactionByHash(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { + return l.inner.GetTransactionByHash(ctx, metadata, input) +} + +func (l *LimitedEVMChain) GetTransactionReceipt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { + return l.inner.GetTransactionReceipt(ctx, metadata, input) +} + +func (l *LimitedEVMChain) HeaderByNumber(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { + return l.inner.HeaderByNumber(ctx, metadata, input) +} + +func (l *LimitedEVMChain) RegisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { + return l.inner.RegisterLogTrigger(ctx, triggerID, metadata, input) +} + +func (l *LimitedEVMChain) UnregisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) caperrors.Error { + return l.inner.UnregisterLogTrigger(ctx, triggerID, metadata, input) +} + +func (l *LimitedEVMChain) ChainSelector() uint64 { return l.inner.ChainSelector() } +func (l *LimitedEVMChain) Start(ctx context.Context) error { return l.inner.Start(ctx) } +func (l *LimitedEVMChain) Close() error { return l.inner.Close() } +func (l *LimitedEVMChain) HealthReport() map[string]error { return l.inner.HealthReport() } +func (l *LimitedEVMChain) Name() string { return l.inner.Name() } +func (l *LimitedEVMChain) Description() string { return l.inner.Description() } +func (l *LimitedEVMChain) Ready() error { return l.inner.Ready() } +func (l *LimitedEVMChain) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { + return l.inner.Initialise(ctx, deps) +} + +func (l *LimitedEVMChain) AckEvent(ctx context.Context, triggerId string, eventId string, method string) caperrors.Error { + return l.inner.AckEvent(ctx, triggerId, eventId, method) +} diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go new file mode 100644 index 00000000..362a3bb4 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -0,0 +1,149 @@ +package evm + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" + caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" + evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" +) + +type stubEVMLimits struct { + reportSizeLimit int + gasLimit uint64 +} + +func (s *stubEVMLimits) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } +func (s *stubEVMLimits) ChainWriteGasLimit() uint64 { return s.gasLimit } + +type evmCapabilityBaseStub struct{} + +func (evmCapabilityBaseStub) Start(context.Context) error { return nil } +func (evmCapabilityBaseStub) Close() error { return nil } +func (evmCapabilityBaseStub) HealthReport() map[string]error { return map[string]error{} } +func (evmCapabilityBaseStub) Name() string { return "stub" } +func (evmCapabilityBaseStub) Description() string { return "stub" } +func (evmCapabilityBaseStub) Ready() error { return nil } +func (evmCapabilityBaseStub) Initialise(context.Context, core.StandardCapabilitiesDependencies) error { + return nil +} + +type evmClientCapabilityStub struct { + evmCapabilityBaseStub + writeReportFn func(context.Context, commonCap.RequestMetadata, *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) + writeReportCalls int +} + +var _ evmserver.ClientCapability = (*evmClientCapabilityStub)(nil) + +func (s *evmClientCapabilityStub) CallContract(context.Context, commonCap.RequestMetadata, *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) FilterLogs(context.Context, commonCap.RequestMetadata, *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) BalanceAt(context.Context, commonCap.RequestMetadata, *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) EstimateGas(context.Context, commonCap.RequestMetadata, *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) GetTransactionByHash(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) GetTransactionReceipt(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) HeaderByNumber(context.Context, commonCap.RequestMetadata, *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) RegisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { + return nil, nil +} + +func (s *evmClientCapabilityStub) UnregisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) caperrors.Error { + return nil +} + +func (s *evmClientCapabilityStub) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + s.writeReportCalls++ + if s.writeReportFn != nil { + return s.writeReportFn(ctx, metadata, input) + } + return nil, nil +} +func (s *evmClientCapabilityStub) AckEvent(context.Context, string, string, string) caperrors.Error { + return nil +} +func (s *evmClientCapabilityStub) ChainSelector() uint64 { return 0 } + +func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { + t.Parallel() + + limits := &stubEVMLimits{reportSizeLimit: 4} + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, limits) + + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "chain write report size 5 bytes exceeds limit of 4 bytes") + assert.Equal(t, 0, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { + t.Parallel() + + limits := &stubEVMLimits{gasLimit: 10} + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, limits) + + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + GasConfig: &evmcappb.GasConfig{GasLimit: 11}, + }) + require.Error(t, err) + assert.Nil(t, resp) + assert.Contains(t, err.Error(), "EVM gas limit 11 exceeds maximum of 10") + assert.Equal(t, 0, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { + t.Parallel() + + limits := &stubEVMLimits{reportSizeLimit: 4, gasLimit: 10} + + input := &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("1234")}, + GasConfig: &evmcappb.GasConfig{GasLimit: 10}, + } + expectedResp := &commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply]{Response: &evmcappb.WriteReportReply{}} + + inner := &evmClientCapabilityStub{ + writeReportFn: func(_ context.Context, _ commonCap.RequestMetadata, got *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { + assert.Same(t, input, got) + return expectedResp, nil + }, + } + + wrapper := NewLimitedEVMChain(inner, limits) + resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, input) + require.NoError(t, err) + assert.Same(t, expectedResp, resp) + assert.Equal(t, 1, inner.writeReportCalls) +} diff --git a/cmd/workflow/simulate/simulator_utils.go b/cmd/workflow/simulate/chain/evm/supported_chains.go similarity index 65% rename from cmd/workflow/simulate/simulator_utils.go rename to cmd/workflow/simulate/chain/evm/supported_chains.go index 6334a2c5..7db9aeed 100644 --- a/cmd/workflow/simulate/simulator_utils.go +++ b/cmd/workflow/simulate/chain/evm/supported_chains.go @@ -1,34 +1,13 @@ -package simulate +package evm import ( - "context" - "errors" - "fmt" - "net/url" - "regexp" - "strconv" - "strings" - "time" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" - chainselectors "github.com/smartcontractkit/chain-selectors" - "github.com/smartcontractkit/cre-cli/internal/settings" + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) -const WorkflowExecutionTimeout = 5 * time.Minute - -type ChainSelector = uint64 - -type ChainConfig struct { - Selector ChainSelector - Forwarder string -} - -// SupportedEVM is the canonical list you can range over. -var SupportedEVM = []ChainConfig{ +// SupportedChains is the canonical list of EVM chains supported for simulation. +var SupportedChains = []chain.ChainConfig{ // Ethereum {Selector: chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector, Forwarder: "0x15fC6ae953E024d975e77382eEeC56A9101f9F88"}, {Selector: chainselectors.ETHEREUM_MAINNET.Selector, Forwarder: "0xa3d1ad4ac559a6575a114998affb2fb2ec97a7d9"}, @@ -138,94 +117,3 @@ var SupportedEVM = []ChainConfig{ // DTCC {Selector: chainselectors.DTCC_TESTNET_ANDESITE.Selector, Forwarder: "0x6E9EE680ef59ef64Aa8C7371279c27E496b5eDc1"}, } - -// parse "ChainSelector:" from trigger id, e.g. "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger" -var chainSelectorRe = regexp.MustCompile(`(?i)chainselector:(\d+)`) - -func parseChainSelectorFromTriggerID(id string) (uint64, bool) { - m := chainSelectorRe.FindStringSubmatch(id) - if len(m) < 2 { - return 0, false - } - - v, err := strconv.ParseUint(m[1], 10, 64) - if err != nil { - return 0, false - } - - return v, true -} - -// redactURL returns a version of the URL with path segments and query parameters -// masked to avoid leaking secrets that may have been resolved from environment variables. -// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***". -func redactURL(rawURL string) string { - u, err := url.Parse(rawURL) - if err != nil { - return "***" - } - // Mask the last path segment (most common location for API keys) - u.Path = strings.TrimRight(u.Path, "/") - if u.Path != "" && u.Path != "/" { - parts := strings.Split(u.Path, "/") - if len(parts) > 1 { - parts[len(parts)-1] = "***" - } - u.RawPath = "" - u.Path = strings.Join(parts, "/") - } - // Remove query params entirely - u.RawQuery = "" - u.Fragment = "" - // Use Opaque to avoid re-encoding the path - return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) -} - -// runRPCHealthCheck runs connectivity check against every configured client. -// experimentalForwarders keys identify experimental chains (not in chain-selectors). -func runRPCHealthCheck(clients map[uint64]*ethclient.Client, experimentalForwarders map[uint64]common.Address) error { - if len(clients) == 0 { - return fmt.Errorf("check your settings: no RPC URLs found for supported or experimental chains") - } - - var errs []error - for selector, c := range clients { - if c == nil { - // shouldnt happen - errs = append(errs, fmt.Errorf("[%d] nil client", selector)) - continue - } - - // Determine chain label for error messages - var chainLabel string - if _, isExperimental := experimentalForwarders[selector]; isExperimental { - chainLabel = fmt.Sprintf("experimental chain %d", selector) - } else { - name, err := settings.GetChainNameByChainSelector(selector) - if err != nil { - // If we can't get the name, use the selector as the label - chainLabel = fmt.Sprintf("chain %d", selector) - } else { - chainLabel = name - } - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - chainID, err := c.ChainID(ctx) - cancel() // don't defer in a loop - - if err != nil { - errs = append(errs, fmt.Errorf("[%s] failed RPC health check: %w", chainLabel, err)) - continue - } - if chainID == nil || chainID.Sign() <= 0 { - errs = append(errs, fmt.Errorf("[%s] invalid RPC response: empty or zero chain ID", chainLabel)) - continue - } - } - - if len(errs) > 0 { - return fmt.Errorf("RPC health check failed:\n%w", errors.Join(errs...)) - } - return nil -} diff --git a/cmd/workflow/simulate/chain/evm/supported_chains_test.go b/cmd/workflow/simulate/chain/evm/supported_chains_test.go new file mode 100644 index 00000000..708984d2 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/supported_chains_test.go @@ -0,0 +1,71 @@ +package evm + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" +) + +// All forwarders declared in supported_chains.go must be valid 0x-prefixed +// 20-byte hex addresses. Catches typos that would only surface as runtime +// "invalid address" errors later in simulation. + +var forwarderRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) + +func TestSupportedChains_AllSelectorsNonZero(t *testing.T) { + t.Parallel() + for i, c := range SupportedChains { + require.NotZerof(t, c.Selector, "index %d has zero selector", i) + } +} + +func TestSupportedChains_AllSelectorsUnique(t *testing.T) { + t.Parallel() + seen := map[uint64]int{} + for i, c := range SupportedChains { + if prev, ok := seen[c.Selector]; ok { + t.Fatalf("duplicate selector %d at indices %d and %d", c.Selector, prev, i) + } + seen[c.Selector] = i + } +} + +func TestSupportedChains_AllForwardersValidHexAddress(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + assert.True(t, forwarderRe.MatchString(c.Forwarder), + "selector %d: invalid forwarder hex %q", c.Selector, c.Forwarder) + } +} + +func TestSupportedChains_AllSelectorsResolveToChainName(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + info, err := chainselectors.GetSelectorFamily(c.Selector) + require.NoErrorf(t, err, "selector %d missing family", c.Selector) + assert.NotEmpty(t, info) + } +} + +func TestSupportedChains_NoForwarderEmpty(t *testing.T) { + t.Parallel() + for i, c := range SupportedChains { + require.NotEmpty(t, c.Forwarder, "supported chain at index %d has empty forwarder", i) + } +} + +func TestSupportedChains_ReturnedByChainType(t *testing.T) { + t.Parallel() + f := newEVMChainType() + ret := f.SupportedChains() + require.Equal(t, len(SupportedChains), len(ret)) + // Element-wise identity (same struct values, same order). + for i, c := range SupportedChains { + assert.Equal(t, c.Selector, ret[i].Selector, "selector at index %d", i) + assert.Equal(t, c.Forwarder, ret[i].Forwarder, "forwarder at index %d", i) + } +} diff --git a/cmd/workflow/simulate/chain/evm/trigger.go b/cmd/workflow/simulate/chain/evm/trigger.go new file mode 100644 index 00000000..ecfac13d --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/trigger.go @@ -0,0 +1,166 @@ +package evm + +import ( + "context" + "fmt" + "math" + "math/big" + "regexp" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + evmpb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" + valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" + + "github.com/smartcontractkit/cre-cli/internal/ui" +) + +// parse "ChainSelector:" from trigger id, e.g. "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger" +var chainSelectorRe = regexp.MustCompile(`(?i)chainselector:(\d+)`) + +// ParseTriggerChainSelector extracts a chain selector from a trigger ID string. +// Returns 0, false if not found. +func ParseTriggerChainSelector(id string) (uint64, bool) { + m := chainSelectorRe.FindStringSubmatch(id) + if len(m) < 2 { + return 0, false + } + v, err := strconv.ParseUint(m[1], 10, 64) + if err != nil { + return 0, false + } + return v, true +} + +// GetEVMTriggerLog prompts user for EVM trigger data and fetches the log interactively. +func GetEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evmpb.Log, error) { + var txHashInput string + var eventIndexInput string + + ui.Line() + if err := ui.InputForm([]ui.InputField{ + { + Title: "EVM Trigger Configuration", + Description: "Transaction hash for the EVM log event", + Placeholder: "0x...", + Value: &txHashInput, + Validate: func(s string) error { + s = strings.TrimSpace(s) + if s == "" { + return fmt.Errorf("transaction hash cannot be empty") + } + if !strings.HasPrefix(s, "0x") { + return fmt.Errorf("transaction hash must start with 0x") + } + if len(s) != 66 { + return fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(s)) + } + return nil + }, + }, + { + Title: "Event Index", + Description: "Log event index (0-based)", + Placeholder: "0", + Suggestions: []string{"0"}, + Value: &eventIndexInput, + Validate: func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("event index cannot be empty") + } + if _, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32); err != nil { + return fmt.Errorf("invalid event index: must be a number") + } + return nil + }, + }, + }); err != nil { + return nil, fmt.Errorf("EVM trigger input cancelled: %w", err) + } + + txHashInput = strings.TrimSpace(txHashInput) + txHash := common.HexToHash(txHashInput) + + eventIndexInput = strings.TrimSpace(eventIndexInput) + eventIndex, err := strconv.ParseUint(eventIndexInput, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid event index: %w", err) + } + + return fetchAndConvertLog(ctx, ethClient, txHash, eventIndex, true) +} + +// GetEVMTriggerLogFromValues fetches a log given tx hash string and event index. +// Unlike GetEVMTriggerLog (interactive), this does not emit ui.Success messages +// to keep non-interactive/CI output clean. +func GetEVMTriggerLogFromValues(ctx context.Context, ethClient *ethclient.Client, txHashStr string, eventIndex uint64) (*evmpb.Log, error) { + txHashStr = strings.TrimSpace(txHashStr) + if txHashStr == "" { + return nil, fmt.Errorf("transaction hash cannot be empty") + } + if !strings.HasPrefix(txHashStr, "0x") { + return nil, fmt.Errorf("transaction hash must start with 0x") + } + if len(txHashStr) != 66 { + return nil, fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(txHashStr)) + } + + txHash := common.HexToHash(txHashStr) + return fetchAndConvertLog(ctx, ethClient, txHash, eventIndex, false) +} + +// fetchAndConvertLog fetches a transaction receipt log and converts it to the protobuf format. +// When verbose is true (interactive mode), ui.Success messages are emitted. +func fetchAndConvertLog(ctx context.Context, ethClient *ethclient.Client, txHash common.Hash, eventIndex uint64, verbose bool) (*evmpb.Log, error) { + receiptSpinner := ui.NewSpinner() + receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) + txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) + receiptSpinner.Stop() + if err != nil { + return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) + } + if eventIndex >= uint64(len(txReceipt.Logs)) { + return nil, fmt.Errorf("event index %d out of range, transaction has %d log events", eventIndex, len(txReceipt.Logs)) + } + + log := txReceipt.Logs[eventIndex] + if verbose { + ui.Success(fmt.Sprintf("Found log event at index %d: contract=%s, topics=%d", eventIndex, log.Address.Hex(), len(log.Topics))) + } + + var txIndex, logIndex uint32 + if log.TxIndex > math.MaxUint32 { + return nil, fmt.Errorf("transaction index %d exceeds uint32 maximum value", log.TxIndex) + } + txIndex = uint32(log.TxIndex) // #nosec G115 -- validated above + + if log.Index > math.MaxUint32 { + return nil, fmt.Errorf("log index %d exceeds uint32 maximum value", log.Index) + } + logIndex = uint32(log.Index) // #nosec G115 -- validated above + + pbLog := &evmpb.Log{ + Address: log.Address.Bytes(), + Data: log.Data, + BlockHash: log.BlockHash.Bytes(), + TxHash: log.TxHash.Bytes(), + TxIndex: txIndex, + Index: logIndex, + Removed: log.Removed, + BlockNumber: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(log.BlockNumber)), + } + for _, topic := range log.Topics { + pbLog.Topics = append(pbLog.Topics, topic.Bytes()) + } + if len(log.Topics) > 0 { + pbLog.EventSig = log.Topics[0].Bytes() + } + + if verbose { + ui.Success(fmt.Sprintf("Created EVM trigger log for transaction %s, event %d", txHash.Hex(), eventIndex)) + } + return pbLog, nil +} diff --git a/cmd/workflow/simulate/chain/evm/trigger_test.go b/cmd/workflow/simulate/chain/evm/trigger_test.go new file mode 100644 index 00000000..45b3b0d9 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/trigger_test.go @@ -0,0 +1,410 @@ +package evm + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const zero64 = "0x" + "0000000000000000000000000000000000000000000000000000000000000000" + +func TestParseTriggerChainSelector(t *testing.T) { + tests := []struct { + name string + id string + want uint64 + ok bool + }{ + { + name: "mainnet format", + id: "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger", + want: uint64(5009297550715157269), + ok: true, + }, + { + name: "sepolia lowercase", + id: "evm:chainselector:16015286601757825753@1.0.0", + want: uint64(16015286601757825753), + ok: true, + }, + { + name: "sepolia uppercase", + id: "EVM:CHAINSELECTOR:16015286601757825753@1.0.0", + want: uint64(16015286601757825753), + ok: true, + }, + { + name: "leading and trailing spaces", + id: " evm:ChainSelector:123@1.0.0 ", + want: uint64(123), + ok: true, + }, + { + name: "no selector present", + id: "evm@1.0.0 LogTrigger", + want: 0, + ok: false, + }, + { + name: "non-numeric selector", + id: "evm:ChainSelector:notanumber@1.0.0", + want: 0, + ok: false, + }, + { + name: "empty selector", + id: "evm:ChainSelector:@1.0.0", + want: 0, + ok: false, + }, + { + name: "overflow uint64", + id: "evm:ChainSelector:18446744073709551616@1.0.0", + want: 0, + ok: false, + }, + { + name: "digits followed by letters (regex grabs only digits)", + id: "evm:ChainSelector:987abc@1.0.0", + want: uint64(987), + ok: true, + }, + { + name: "multiple occurrences - returns first", + id: "foo ChainSelector:1 bar ChainSelector:2 baz", + want: uint64(1), + ok: true, + }, + { + name: "zero selector", + id: "evm:ChainSelector:0@1.0.0", + want: 0, + ok: true, + }, + { + name: "max uint64", + id: "evm:ChainSelector:18446744073709551615@1.0.0", + want: uint64(18446744073709551615), + ok: true, + }, + { + name: "negative sign not matched", + id: "evm:ChainSelector:-1@1.0.0", + want: 0, + ok: false, + }, + { + name: "unicode digits rejected", + id: "evm:ChainSelector:123@1.0.0", + want: 0, + ok: false, + }, + { + name: "tab before number rejected", + id: "evm:ChainSelector:\t42@1.0.0", + want: 0, + ok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ParseTriggerChainSelector(tt.id) + if ok != tt.ok || got != tt.want { + t.Fatalf("ParseTriggerChainSelector(%q) = (%d, %v); want (%d, %v)", tt.id, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestGetEVMTriggerLogFromValues_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + hash string + errSub string + }{ + {"empty string", "", "transaction hash cannot be empty"}, + {"whitespace only", " ", "transaction hash cannot be empty"}, + {"no 0x prefix, right length", strings.Repeat("a", 66), "must start with 0x"}, + {"0x prefix, too short", "0x" + strings.Repeat("a", 10), "invalid transaction hash length"}, + {"0x prefix, too long", "0x" + strings.Repeat("a", 100), "invalid transaction hash length"}, + {"valid length but 65 chars", "0x" + strings.Repeat("a", 63), "invalid transaction hash length"}, + {"valid length but 67 chars", "0x" + strings.Repeat("a", 65), "invalid transaction hash length"}, + {"uppercase 0X rejected", "0X" + strings.Repeat("a", 64), "must start with 0x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := GetEVMTriggerLogFromValues(context.Background(), nil, tt.hash, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errSub) + }) + } +} + +type mockRPC struct { + srv *httptest.Server + receipts map[string]*types.Receipt + errFor map[string]error +} + +func newMockRPC(t *testing.T) *mockRPC { + t.Helper() + m := &mockRPC{ + receipts: map[string]*types.Receipt{}, + errFor: map[string]error{}, + } + m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{"jsonrpc": "2.0", "id": req.ID} + + switch req.Method { + case "eth_getTransactionReceipt": + if len(req.Params) == 0 { + resp["error"] = map[string]any{"code": -32602, "message": "missing params"} + break + } + var hash string + _ = json.Unmarshal(req.Params[0], &hash) + if e, ok := m.errFor[strings.ToLower(hash)]; ok { + resp["error"] = map[string]any{"code": -32603, "message": e.Error()} + break + } + rec, ok := m.receipts[strings.ToLower(hash)] + if !ok { + resp["result"] = nil + break + } + resp["result"] = receiptToJSON(rec) + case "eth_chainId": + resp["result"] = "0x1" + default: + resp["error"] = map[string]any{"code": -32601, "message": "method not found"} + } + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(m.srv.Close) + return m +} + +func receiptToJSON(r *types.Receipt) map[string]any { + logs := make([]map[string]any, 0, len(r.Logs)) + for _, l := range r.Logs { + tpcs := make([]string, 0, len(l.Topics)) + for _, t := range l.Topics { + tpcs = append(tpcs, t.Hex()) + } + logs = append(logs, map[string]any{ + "address": l.Address.Hex(), + "topics": tpcs, + "data": "0x" + common.Bytes2Hex(l.Data), + "blockNumber": fmt.Sprintf("0x%x", l.BlockNumber), + "transactionHash": l.TxHash.Hex(), + "transactionIndex": fmt.Sprintf("0x%x", l.TxIndex), + "blockHash": l.BlockHash.Hex(), + "logIndex": fmt.Sprintf("0x%x", l.Index), + "removed": l.Removed, + }) + } + return map[string]any{ + "transactionHash": r.TxHash.Hex(), + "transactionIndex": fmt.Sprintf("0x%x", r.TransactionIndex), + "blockHash": r.BlockHash.Hex(), + "blockNumber": fmt.Sprintf("0x%x", r.BlockNumber), + "cumulativeGasUsed": fmt.Sprintf("0x%x", r.CumulativeGasUsed), + "gasUsed": fmt.Sprintf("0x%x", r.GasUsed), + "contractAddress": nil, + "logs": logs, + "logsBloom": "0x" + strings.Repeat("00", 256), + "status": "0x1", + "type": "0x0", + "effectiveGasPrice": "0x0", + } +} + +func addrFromHex(h string) common.Address { return common.HexToAddress(h) } +func hashFromHex(h string) common.Hash { return common.HexToHash(h) } + +func mkReceipt(txHash common.Hash, logs []*types.Log) *types.Receipt { + return &types.Receipt{ + TxHash: txHash, + TransactionIndex: 0, + BlockHash: hashFromHex("0xb1"), + BlockNumber: big.NewInt(1), + Logs: logs, + Status: types.ReceiptStatusSuccessful, + } +} + +func TestGetEVMTriggerLogFromValues_FetchError(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("a", 64) + m.errFor[strings.ToLower(txHash)] = fmt.Errorf("receipt not found") + + _, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch transaction receipt") +} + +func TestGetEVMTriggerLogFromValues_EventIndexOutOfRange(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("b", 64) + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: addrFromHex("0xabcd0000000000000000000000000000000000ab"), + Topics: []common.Hash{hashFromHex("0xaa")}, + Data: []byte{0x01, 0x02}, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 1, + TxIndex: 0, + Index: 0, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + _, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 5) + require.Error(t, err) + assert.Contains(t, err.Error(), "event index 5 out of range") + assert.Contains(t, err.Error(), "transaction has 1 log events") +} + +func TestGetEVMTriggerLogFromValues_ZeroLogs_OutOfRange(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("c", 64) + m.receipts[strings.ToLower(txHash)] = mkReceipt(hashFromHex(txHash), nil) + + _, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "event index 0 out of range") + assert.Contains(t, err.Error(), "transaction has 0 log events") +} + +func TestGetEVMTriggerLogFromValues_Success(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("d", 64) + log0Addr := addrFromHex("0x1111111111111111111111111111111111111111") + topicSig := hashFromHex("0x" + strings.Repeat("2", 64)) + extraTopic := hashFromHex("0x" + strings.Repeat("3", 64)) + data := []byte{0xde, 0xad, 0xbe, 0xef} + + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: log0Addr, + Topics: []common.Hash{topicSig, extraTopic}, + Data: data, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 42, + TxIndex: 7, + Index: 3, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + got, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, log0Addr.Bytes(), got.Address) + assert.Equal(t, data, got.Data) + require.Len(t, got.Topics, 2) + assert.Equal(t, topicSig.Bytes(), got.Topics[0]) + assert.Equal(t, extraTopic.Bytes(), got.Topics[1]) + assert.Equal(t, topicSig.Bytes(), got.EventSig) + assert.Equal(t, uint32(7), got.TxIndex) + assert.Equal(t, uint32(3), got.Index) + require.NotNil(t, got.BlockNumber) +} + +func TestGetEVMTriggerLogFromValues_SuccessNoTopicsLeavesEventSigNil(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("e", 64) + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: addrFromHex("0x2222222222222222222222222222222222222222"), + Topics: nil, + Data: []byte{0x01}, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 1, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + got, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.NoError(t, err) + assert.Empty(t, got.Topics) + assert.Nil(t, got.EventSig) +} + +func TestGetEVMTriggerLogFromValues_NoRPCWhenHashInvalid(t *testing.T) { + t.Parallel() + // Pass nil client; validation should fire before any RPC attempt. + _, err := GetEVMTriggerLogFromValues(context.Background(), nil, "not-a-hash", 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "must start with 0x") +} + +func TestGetEVMTriggerLogFromValues_ZeroAddressLog(t *testing.T) { + t.Parallel() + m := newMockRPC(t) + c := newEthClient(t, m.srv.URL) + defer c.Close() + + txHash := "0x" + strings.Repeat("f", 64) + rec := mkReceipt(hashFromHex(txHash), []*types.Log{ + { + Address: addrFromHex(zero64[:42]), + Topics: []common.Hash{hashFromHex("0x00")}, + Data: []byte{}, + BlockHash: hashFromHex("0xbb"), + TxHash: hashFromHex(txHash), + BlockNumber: 1, + }, + }) + m.receipts[strings.ToLower(txHash)] = rec + + got, err := GetEVMTriggerLogFromValues(context.Background(), c, txHash, 0) + require.NoError(t, err) + assert.Len(t, got.Address, 20) // 20-byte address always +} diff --git a/cmd/workflow/simulate/chain/registry.go b/cmd/workflow/simulate/chain/registry.go new file mode 100644 index 00000000..6ed6e11a --- /dev/null +++ b/cmd/workflow/simulate/chain/registry.go @@ -0,0 +1,204 @@ +package chain + +import ( + "context" + "fmt" + "sort" + "strconv" + "sync" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +// Factory constructs a ChainType with the logger the simulator uses. +// Registered at init() time; invoked during Build() at command runtime. +type Factory func(lggr *zerolog.Logger) ChainType + +// ChainType defines what a chain type plugin must implement +// to participate in workflow simulation. +type ChainType interface { + // Name returns the chain type identifier (e.g., "evm", "aptos"). + Name() string + + // ResolveClients creates RPC clients for all chains this chain type can + // simulate, including both supported and experimental chains. Returns a + // ResolvedChains bundle containing clients keyed by chain selector, + // forwarder addresses, and any chain-type-agnostic metadata (e.g. + // experimental-selector set) that later interface methods need. + ResolveClients(v *viper.Viper) (ResolvedChains, error) + + // ResolveKey parses and validates this chain type's signing key from + // settings. If broadcast is true, missing or default-sentinel keys + // are a hard error; otherwise a sentinel may be used with a warning. + // Returns the parsed key (chain-type-specific) or nil if the chain + // type does not use a signing key. + ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) + + // ResolveTriggerData produces the chain-type-specific trigger payload for + // a given chain selector, using runtime parameters from the caller. + ResolveTriggerData(ctx context.Context, selector uint64, params TriggerParams) (interface{}, error) + + // RegisterCapabilities creates capability servers for this chain type's + // chains and adds them to the registry. Returns the underlying services + // (e.g., per-selector chain fakes) so the caller can manage their lifecycle. + RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) ([]services.Service, error) + + // ExecuteTrigger fires a chain-specific trigger for a given selector. + // Each chain type defines what triggerData looks like. + ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error + + // HasSelector reports whether the chain type has a fully initialised + // capability for the given selector after RegisterCapabilities ran. + // Used by the trigger-setup loop to fail fast before a TriggerFunc is + // assigned for a selector the chain type cannot actually dispatch against. + HasSelector(selector uint64) bool + + // ParseTriggerChainSelector extracts a chain selector from a + // trigger subscription ID string (e.g., "evm:ChainSelector:123@1.0.0"). + // Returns 0, false if the trigger doesn't belong to this chain type. + ParseTriggerChainSelector(triggerID string) (uint64, bool) + + // RunHealthCheck validates RPC connectivity for all resolved clients. + // The resolved argument is the same bundle ResolveClients returned, + // threaded back by the caller so RunHealthCheck is self-contained and + // does not depend on hidden state on the ChainType instance. + RunHealthCheck(resolved ResolvedChains) error + + // SupportedChains returns the list of chains this chain type supports + // out of the box (for display/documentation purposes). + SupportedChains() []ChainConfig + + // CollectCLIInputs reads this chain type's CLI flags from viper and + // returns them as key-value pairs for TriggerParams.ChainTypeInputs. + CollectCLIInputs(v *viper.Viper) map[string]string +} + +// CLIFlagDef describes a CLI flag a chain type needs registered. +type CLIFlagDef struct { + Name string + Description string + DefaultValue string // empty string for string flags, or special handling + FlagType CLIFlagType +} + +// CLIFlagType indicates the Go type of a CLI flag. +type CLIFlagType int + +const ( + CLIFlagString CLIFlagType = iota + CLIFlagInt +) + +// registration bundles a factory with its CLI flag definitions. +type registration struct { + factory Factory + flagDefs []CLIFlagDef +} + +var ( + mu sync.RWMutex + registrations = make(map[string]registration) + chainTypes = make(map[string]ChainType) +) + +// Register adds a chain type factory and its CLI flag definitions to the +// registry. Called from chain type package init(); the factory is invoked later +// in Build(). Panics on duplicate registration (programming error). +func Register(name string, factory Factory, flagDefs []CLIFlagDef) { + mu.Lock() + defer mu.Unlock() + if _, exists := registrations[name]; exists { + panic(fmt.Sprintf("chain type %q already registered", name)) + } + registrations[name] = registration{factory: factory, flagDefs: flagDefs} +} + +// Build instantiates every registered chain type with the given logger. +// Must be called once at command startup before All()/Get() return +// meaningful results. +func Build(lggr *zerolog.Logger) { + mu.Lock() + defer mu.Unlock() + for name, reg := range registrations { + chainTypes[name] = reg.factory(lggr) + } +} + +// Get returns a registered chain type by name. +func Get(name string) (ChainType, error) { + mu.RLock() + defer mu.RUnlock() + ct, ok := chainTypes[name] + if !ok { + return nil, fmt.Errorf("unknown chain type %q; registered: %v", name, namesLocked()) + } + return ct, nil +} + +// All returns a copy of all registered chain types. +func All() map[string]ChainType { + mu.RLock() + defer mu.RUnlock() + result := make(map[string]ChainType, len(chainTypes)) + for k, v := range chainTypes { + result[k] = v + } + return result +} + +// RegisterAllCLIFlags registers CLI flags from every registered chain type's +// flag definitions. Called at command setup time before Build(). +func RegisterAllCLIFlags(cmd *cobra.Command) { + mu.RLock() + defer mu.RUnlock() + for _, reg := range registrations { + for _, def := range reg.flagDefs { + switch def.FlagType { + case CLIFlagInt: + defaultVal := -1 + if def.DefaultValue != "" { + if v, err := strconv.Atoi(def.DefaultValue); err == nil { + defaultVal = v + } + } + cmd.Flags().Int(def.Name, defaultVal, def.Description) + default: + cmd.Flags().String(def.Name, def.DefaultValue, def.Description) + } + } + } +} + +// CollectAllCLIInputs gathers CLI inputs from every registered chain type. +func CollectAllCLIInputs(v *viper.Viper) map[string]string { + result := map[string]string{} + for _, ct := range All() { + for k, val := range ct.CollectCLIInputs(v) { + result[k] = val + } + } + return result +} + +// namesLocked returns sorted chain type names. Caller must hold mu. +func namesLocked() []string { + names := make([]string, 0, len(chainTypes)) + for k := range chainTypes { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// Names returns sorted registered chain type names. +func Names() []string { + mu.RLock() + defer mu.RUnlock() + return namesLocked() +} diff --git a/cmd/workflow/simulate/chain/registry_test.go b/cmd/workflow/simulate/chain/registry_test.go new file mode 100644 index 00000000..aa91c81a --- /dev/null +++ b/cmd/workflow/simulate/chain/registry_test.go @@ -0,0 +1,206 @@ +package chain + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/cre-cli/internal/settings" +) + +func resetRegistry() { + mu.Lock() + defer mu.Unlock() + registrations = make(map[string]registration) + chainTypes = make(map[string]ChainType) +} + +// mockChainType is a testify/mock implementation of ChainType. +type mockChainType struct { + mock.Mock +} + +var _ ChainType = (*mockChainType)(nil) + +func (m *mockChainType) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *mockChainType) ResolveClients(v *viper.Viper) (ResolvedChains, error) { + args := m.Called(v) + resolved, _ := args.Get(0).(ResolvedChains) + return resolved, args.Error(1) +} + +func (m *mockChainType) RegisterCapabilities(ctx context.Context, cfg CapabilityConfig) ([]services.Service, error) { + args := m.Called(ctx, cfg) + srvcs, _ := args.Get(0).([]services.Service) + return srvcs, args.Error(1) +} + +func (m *mockChainType) ExecuteTrigger(ctx context.Context, selector uint64, registrationID string, triggerData interface{}) error { + args := m.Called(ctx, selector, registrationID, triggerData) + return args.Error(0) +} + +func (m *mockChainType) HasSelector(selector uint64) bool { + args := m.Called(selector) + return args.Bool(0) +} + +func (m *mockChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { + args := m.Called(triggerID) + return args.Get(0).(uint64), args.Bool(1) +} + +func (m *mockChainType) RunHealthCheck(resolved ResolvedChains) error { + args := m.Called(resolved) + return args.Error(0) +} + +func (m *mockChainType) SupportedChains() []ChainConfig { + args := m.Called() + result, _ := args.Get(0).([]ChainConfig) + return result +} + +func (m *mockChainType) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { + args := m.Called(creSettings, broadcast) + return args.Get(0), args.Error(1) +} + +func (m *mockChainType) ResolveTriggerData(ctx context.Context, selector uint64, params TriggerParams) (interface{}, error) { + args := m.Called(ctx, selector, params) + return args.Get(0), args.Error(1) +} + +func (m *mockChainType) CollectCLIInputs(v *viper.Viper) map[string]string { + args := m.Called(v) + result, _ := args.Get(0).(map[string]string) + return result +} + +func newMockType(name string) *mockChainType { + f := new(mockChainType) + f.On("Name").Return(name) + return f +} + +// registerMock registers a pre-built mock chain type and immediately builds it so +// tests can exercise Get/All/Names without wiring a real logger. +func registerMock(name string, chainType ChainType) { + Register(name, func(*zerolog.Logger) ChainType { return chainType }, nil) + Build(nil) +} + +func TestGetUnknownChainType(t *testing.T) { + resetRegistry() + defer resetRegistry() + + _, err := Get("nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown chain type") +} + +func TestRegisterDuplicatePanics(t *testing.T) { + resetRegistry() + defer resetRegistry() + + registerMock("dup", newMockType("dup")) + assert.Panics(t, func() { + registerMock("dup", newMockType("dup")) + }) +} + +func TestNamesReturnsSorted(t *testing.T) { + resetRegistry() + defer resetRegistry() + + registerMock("zebra", newMockType("zebra")) + registerMock("alpha", newMockType("alpha")) + registerMock("middle", newMockType("middle")) + + names := Names() + assert.Equal(t, []string{"alpha", "middle", "zebra"}, names) +} + +func TestGetErrorIncludesRegisteredNames(t *testing.T) { + resetRegistry() + defer resetRegistry() + + registerMock("evm", newMockType("evm")) + registerMock("aptos", newMockType("aptos")) + + _, err := Get("solana") + require.Error(t, err) + assert.Contains(t, err.Error(), "aptos") + assert.Contains(t, err.Error(), "evm") +} + +func TestRegisterAllCLIFlags_StringAndInt(t *testing.T) { + resetRegistry() + defer resetRegistry() + + Register("test", func(*zerolog.Logger) ChainType { return newMockType("test") }, []CLIFlagDef{ + {Name: "test-hash", Description: "a hash", FlagType: CLIFlagString}, + {Name: "test-index", Description: "an index", DefaultValue: "-1", FlagType: CLIFlagInt}, + }) + + cmd := &cobra.Command{Use: "test"} + RegisterAllCLIFlags(cmd) + + f := cmd.Flags().Lookup("test-hash") + require.NotNil(t, f) + assert.Equal(t, "", f.DefValue) + assert.Equal(t, "a hash", f.Usage) + + f = cmd.Flags().Lookup("test-index") + require.NotNil(t, f) + assert.Equal(t, "-1", f.DefValue) + assert.Equal(t, "an index", f.Usage) +} + +func TestCollectAllCLIInputs_MergesAcrossChainTypes(t *testing.T) { + resetRegistry() + defer resetRegistry() + + ct1 := newMockType("alpha") + ct1.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-a": "val-a"}) + registerMock("alpha", ct1) + + ct2 := newMockType("beta") + ct2.On("CollectCLIInputs", mock.Anything).Return(map[string]string{"key-b": "val-b"}) + registerMock("beta", ct2) + + v := viper.New() + result := CollectAllCLIInputs(v) + + assert.Equal(t, "val-a", result["key-a"]) + assert.Equal(t, "val-b", result["key-b"]) +} + +func TestAllReturnsCopy(t *testing.T) { + resetRegistry() + defer resetRegistry() + + mockCT := newMockType("original") + registerMock("original", mockCT) + + all := All() + delete(all, "original") + + // The registry should still have it + f, err := Get("original") + require.NoError(t, err) + assert.Equal(t, "original", f.Name()) + mockCT.AssertExpectations(t) +} diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go new file mode 100644 index 00000000..12f8c1cb --- /dev/null +++ b/cmd/workflow/simulate/chain/types.go @@ -0,0 +1,56 @@ +package chain + +import ( + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" +) + +// ChainClient is an opaque handle to a chain-specific RPC client. +// Each chain type casts this to its concrete type internally. +type ChainClient interface{} + +// ChainConfig identifies a supported chain within a chain type. +type ChainConfig struct { + Selector uint64 + Forwarder string // chain-type-specific forwarding address +} + +// Limits exposes the chain-write limits that every chain type's capability +// enforcement layer needs. Chain-type-specific accessors (e.g. EVM gas limit) +// live on chain-type-scoped extension interfaces in the family package so +// non-EVM chain types cannot accidentally depend on EVM semantics. +type Limits interface { + ChainWriteReportSizeLimit() int +} + +// ResolvedChains is the result of ChainType.ResolveClients: the RPC clients, +// forwarders, and any chain-type-agnostic metadata later interface methods +// (e.g. RunHealthCheck) depend on. +type ResolvedChains struct { + Clients map[uint64]ChainClient + Forwarders map[uint64]string + // ExperimentalSelectors marks selectors that came from experimental-chain + // config rather than the chain type's built-in supported list. Used for + // error labelling (e.g. "experimental chain N" vs a chain name). + ExperimentalSelectors map[uint64]bool +} + +// CapabilityConfig holds everything a chain type needs to register capabilities. +type CapabilityConfig struct { + Registry *capabilities.Registry + Clients map[uint64]ChainClient + Forwarders map[uint64]string + PrivateKey interface{} // chain-type-specific key type; EVM uses *ecdsa.PrivateKey + Broadcast bool + Limits Limits // nil disables limit enforcement + Logger logger.Logger +} + +// TriggerParams carries chain-type-agnostic inputs needed to resolve trigger data +// for a given chain trigger. ChainTypeInputs is a free-form bag of CLI-supplied +// strings; each chain type interprets the keys it knows about and ignores the rest. +type TriggerParams struct { + Clients map[uint64]ChainClient + Interactive bool + ChainTypeInputs map[string]string +} diff --git a/cmd/workflow/simulate/chain/utils.go b/cmd/workflow/simulate/chain/utils.go new file mode 100644 index 00000000..d8291766 --- /dev/null +++ b/cmd/workflow/simulate/chain/utils.go @@ -0,0 +1,32 @@ +package chain + +import ( + "fmt" + "net/url" + "strings" +) + +// RedactURL returns a version of the URL with path segments and query parameters +// masked to avoid leaking secrets that may have been resolved from environment variables. +// For example, "https://rpc.example.com/v1/my-secret-key" becomes "https://rpc.example.com/v1/***". +func RedactURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "***" + } + // Mask the last path segment (most common location for API keys) + u.Path = strings.TrimRight(u.Path, "/") + if u.Path != "" && u.Path != "/" { + parts := strings.Split(u.Path, "/") + if len(parts) > 1 { + parts[len(parts)-1] = "***" + } + u.RawPath = "" + u.Path = strings.Join(parts, "/") + } + // Remove query params entirely + u.RawQuery = "" + u.Fragment = "" + // Use Opaque to avoid re-encoding the path + return fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path) +} diff --git a/cmd/workflow/simulate/chain/utils_test.go b/cmd/workflow/simulate/chain/utils_test.go new file mode 100644 index 00000000..3247e477 --- /dev/null +++ b/cmd/workflow/simulate/chain/utils_test.go @@ -0,0 +1,48 @@ +package chain + +import ( + "testing" +) + +func TestRedactURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "masks last path segment", + raw: "https://rpc.example.com/v1/my-secret-key", + want: "https://rpc.example.com/v1/***", + }, + { + name: "removes query params", + raw: "https://rpc.example.com/v1/key?token=secret", + want: "https://rpc.example.com/v1/***", + }, + { + name: "single path segment masked", + raw: "https://rpc.example.com/key", + want: "https://rpc.example.com/***", + }, + { + name: "no path", + raw: "https://rpc.example.com", + want: "https://rpc.example.com", + }, + { + name: "invalid URL", + raw: "://bad", + want: "***", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RedactURL(tt.raw) + if got != tt.want { + t.Errorf("RedactURL(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} diff --git a/cmd/workflow/simulate/limited_capabilities.go b/cmd/workflow/simulate/limited_capabilities.go index 3a48a850..50441a7e 100644 --- a/cmd/workflow/simulate/limited_capabilities.go +++ b/cmd/workflow/simulate/limited_capabilities.go @@ -13,8 +13,6 @@ import ( confhttpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp/server" customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" httpserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http/server" - evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" - evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" consensusserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/consensus/server" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" @@ -193,92 +191,3 @@ func (l *LimitedConsensusNoDAG) Ready() error { return l.inne func (l *LimitedConsensusNoDAG) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { return l.inner.Initialise(ctx, deps) } - -// --- LimitedEVMChain --- - -// LimitedEVMChain wraps an evmserver.ClientCapability and enforces chain write -// report size and gas limits from SimulationLimits. -type LimitedEVMChain struct { - inner evmserver.ClientCapability - limits *SimulationLimits -} - -var _ evmserver.ClientCapability = (*LimitedEVMChain)(nil) - -func NewLimitedEVMChain(inner evmserver.ClientCapability, limits *SimulationLimits) *LimitedEVMChain { - return &LimitedEVMChain{inner: inner, limits: limits} -} - -func (l *LimitedEVMChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { - // Check report size - reportLimit := l.limits.ChainWriteReportSizeLimit() - if reportLimit > 0 && input.Report != nil && len(input.Report.RawReport) > reportLimit { - return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), reportLimit), - caperrors.ResourceExhausted, - ) - } - - // Check gas limit - gasLimit := l.limits.ChainWriteEVMGasLimit() - if gasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > gasLimit { - return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, gasLimit), - caperrors.ResourceExhausted, - ) - } - - return l.inner.WriteReport(ctx, metadata, input) -} - -// All other methods delegate to the inner capability. -func (l *LimitedEVMChain) CallContract(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { - return l.inner.CallContract(ctx, metadata, input) -} - -func (l *LimitedEVMChain) FilterLogs(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { - return l.inner.FilterLogs(ctx, metadata, input) -} - -func (l *LimitedEVMChain) BalanceAt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { - return l.inner.BalanceAt(ctx, metadata, input) -} - -func (l *LimitedEVMChain) EstimateGas(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { - return l.inner.EstimateGas(ctx, metadata, input) -} - -func (l *LimitedEVMChain) GetTransactionByHash(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { - return l.inner.GetTransactionByHash(ctx, metadata, input) -} - -func (l *LimitedEVMChain) GetTransactionReceipt(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { - return l.inner.GetTransactionReceipt(ctx, metadata, input) -} - -func (l *LimitedEVMChain) HeaderByNumber(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { - return l.inner.HeaderByNumber(ctx, metadata, input) -} - -func (l *LimitedEVMChain) RegisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { - return l.inner.RegisterLogTrigger(ctx, triggerID, metadata, input) -} - -func (l *LimitedEVMChain) UnregisterLogTrigger(ctx context.Context, triggerID string, metadata commonCap.RequestMetadata, input *evmcappb.FilterLogTriggerRequest) caperrors.Error { - return l.inner.UnregisterLogTrigger(ctx, triggerID, metadata, input) -} - -func (l *LimitedEVMChain) ChainSelector() uint64 { return l.inner.ChainSelector() } -func (l *LimitedEVMChain) Start(ctx context.Context) error { return l.inner.Start(ctx) } -func (l *LimitedEVMChain) Close() error { return l.inner.Close() } -func (l *LimitedEVMChain) HealthReport() map[string]error { return l.inner.HealthReport() } -func (l *LimitedEVMChain) Name() string { return l.inner.Name() } -func (l *LimitedEVMChain) Description() string { return l.inner.Description() } -func (l *LimitedEVMChain) Ready() error { return l.inner.Ready() } -func (l *LimitedEVMChain) Initialise(ctx context.Context, deps core.StandardCapabilitiesDependencies) error { - return l.inner.Initialise(ctx, deps) -} - -func (l *LimitedEVMChain) AckEvent(ctx context.Context, triggerId string, eventId string, method string) caperrors.Error { - return l.inner.AckEvent(ctx, triggerId, eventId, method) -} diff --git a/cmd/workflow/simulate/limited_capabilities_test.go b/cmd/workflow/simulate/limited_capabilities_test.go index 9a8a0016..a927874c 100644 --- a/cmd/workflow/simulate/limited_capabilities_test.go +++ b/cmd/workflow/simulate/limited_capabilities_test.go @@ -15,7 +15,6 @@ import ( caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/confidentialhttp" customhttp "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/actions/http" - evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" "github.com/smartcontractkit/chainlink-common/pkg/config" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" @@ -88,62 +87,6 @@ func (s *consensusCapabilityStub) Report(ctx context.Context, metadata commonCap return nil, nil } -type evmClientCapabilityStub struct { - capabilityBaseStub - writeReportFn func(context.Context, commonCap.RequestMetadata, *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) - writeReportCalls int -} - -func (s *evmClientCapabilityStub) CallContract(context.Context, commonCap.RequestMetadata, *evmcappb.CallContractRequest) (*commonCap.ResponseAndMetadata[*evmcappb.CallContractReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) FilterLogs(context.Context, commonCap.RequestMetadata, *evmcappb.FilterLogsRequest) (*commonCap.ResponseAndMetadata[*evmcappb.FilterLogsReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) BalanceAt(context.Context, commonCap.RequestMetadata, *evmcappb.BalanceAtRequest) (*commonCap.ResponseAndMetadata[*evmcappb.BalanceAtReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) EstimateGas(context.Context, commonCap.RequestMetadata, *evmcappb.EstimateGasRequest) (*commonCap.ResponseAndMetadata[*evmcappb.EstimateGasReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) GetTransactionByHash(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionByHashRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionByHashReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) GetTransactionReceipt(context.Context, commonCap.RequestMetadata, *evmcappb.GetTransactionReceiptRequest) (*commonCap.ResponseAndMetadata[*evmcappb.GetTransactionReceiptReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) HeaderByNumber(context.Context, commonCap.RequestMetadata, *evmcappb.HeaderByNumberRequest) (*commonCap.ResponseAndMetadata[*evmcappb.HeaderByNumberReply], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) RegisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) (<-chan commonCap.TriggerAndId[*evmcappb.Log], caperrors.Error) { - return nil, nil -} - -func (s *evmClientCapabilityStub) UnregisterLogTrigger(context.Context, string, commonCap.RequestMetadata, *evmcappb.FilterLogTriggerRequest) caperrors.Error { - return nil -} - -func (s *evmClientCapabilityStub) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { - s.writeReportCalls++ - if s.writeReportFn != nil { - return s.writeReportFn(ctx, metadata, input) - } - return nil, nil -} - -func (s *evmClientCapabilityStub) AckEvent(context.Context, string, string, string) caperrors.Error { - return nil -} - -func (s *evmClientCapabilityStub) ChainSelector() uint64 { return 0 } - func newTestLimits(t *testing.T) *SimulationLimits { t.Helper() limits, err := DefaultLimits() @@ -376,66 +319,3 @@ func TestLimitedConsensusNoDAGReportDelegates(t *testing.T) { assert.Same(t, expectedResp, resp) assert.Equal(t, 1, inner.reportCalls) } - -func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { - t.Parallel() - - limits := newTestLimits(t) - limits.Workflows.ChainWrite.ReportSizeLimit.DefaultValue = 4 - - inner := &evmClientCapabilityStub{} - wrapper := NewLimitedEVMChain(inner, limits) - - resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("12345")}, - }) - require.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "chain write report size 5 bytes exceeds limit of 4 bytes") - assert.Equal(t, 0, inner.writeReportCalls) -} - -func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { - t.Parallel() - - limits := newTestLimits(t) - limits.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue = 10 - - inner := &evmClientCapabilityStub{} - wrapper := NewLimitedEVMChain(inner, limits) - - resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ - GasConfig: &evmcappb.GasConfig{GasLimit: 11}, - }) - require.Error(t, err) - assert.Nil(t, resp) - assert.Contains(t, err.Error(), "EVM gas limit 11 exceeds maximum of 10") - assert.Equal(t, 0, inner.writeReportCalls) -} - -func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { - t.Parallel() - - limits := newTestLimits(t) - limits.Workflows.ChainWrite.ReportSizeLimit.DefaultValue = 4 - limits.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue = 10 - - input := &evmcappb.WriteReportRequest{ - Report: &sdkpb.ReportResponse{RawReport: []byte("1234")}, - GasConfig: &evmcappb.GasConfig{GasLimit: 10}, - } - expectedResp := &commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply]{Response: &evmcappb.WriteReportReply{}} - - inner := &evmClientCapabilityStub{ - writeReportFn: func(_ context.Context, _ commonCap.RequestMetadata, got *evmcappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*evmcappb.WriteReportReply], caperrors.Error) { - assert.Same(t, input, got) - return expectedResp, nil - }, - } - - wrapper := NewLimitedEVMChain(inner, limits) - resp, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, input) - require.NoError(t, err) - assert.Same(t, expectedResp, resp) - assert.Equal(t, 1, inner.writeReportCalls) -} diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index d1dbdb71..ce7c5285 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -191,11 +191,16 @@ 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 } +// ChainWriteAptosMaxGasAmount returns the default Aptos max_gas_amount per WriteReport. +func (l *SimulationLimits) ChainWriteAptosMaxGasAmount() uint64 { + return l.Workflows.ChainWrite.Aptos.GasLimit.Default.DefaultValue +} + // WASMBinarySize returns the WASM binary size limit in bytes. func (l *SimulationLimits) WASMBinarySize() int { return int(l.Workflows.WASMBinarySizeLimit.DefaultValue) diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go index 487adbf4..08389fb3 100644 --- a/cmd/workflow/simulate/limits_test.go +++ b/cmd/workflow/simulate/limits_test.go @@ -31,7 +31,7 @@ func TestDefaultLimitsAndExportDefaultLimitsJSON(t *testing.T) { assert.Equal(t, 100_000, limits.ConfHTTPResponseSizeLimit()) assert.Equal(t, 100_000, limits.ConsensusObservationSizeLimit()) assert.Equal(t, 5_000, limits.ChainWriteReportSizeLimit()) - assert.Equal(t, uint64(5_000_000), limits.ChainWriteEVMGasLimit()) + assert.Equal(t, uint64(5_000_000), limits.ChainWriteGasLimit()) assert.Equal(t, 100_000_000, limits.WASMBinarySize()) assert.Equal(t, 20_000_000, limits.WASMCompressedBinarySize()) assert.JSONEq(t, string(defaultLimitsJSON), string(ExportDefaultLimitsJSON())) @@ -61,7 +61,7 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing assert.Equal(t, 7_000, limits.HTTPRequestSizeLimit()) assert.Equal(t, 100_000, limits.HTTPResponseSizeLimit(), "unset values should keep embedded defaults") assert.Equal(t, 9_000, limits.ChainWriteReportSizeLimit()) - assert.Equal(t, uint64(123), limits.ChainWriteEVMGasLimit()) + assert.Equal(t, uint64(123), limits.ChainWriteGasLimit()) assert.Equal(t, 2*time.Second, limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue) } @@ -95,7 +95,7 @@ func TestResolveLimitsHandlesAllSupportedModes(t *testing.T) { baseline, err := DefaultLimits() require.NoError(t, err) assert.Equal(t, baseline.HTTPRequestSizeLimit(), defaultLimits.HTTPRequestSizeLimit()) - assert.Equal(t, baseline.ChainWriteEVMGasLimit(), defaultLimits.ChainWriteEVMGasLimit()) + assert.Equal(t, baseline.ChainWriteGasLimit(), defaultLimits.ChainWriteGasLimit()) path := writeLimitsFile(t, `{"Consensus":{"ObservationSizeLimit":"2kb"}}`) customLimits, err := ResolveLimits(path) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index cdd372b6..cf2b48fe 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -2,29 +2,22 @@ package simulate import ( "context" - "crypto/ecdsa" "encoding/json" + "errors" "fmt" - "math" - "math/big" "os" "os/signal" "path/filepath" - "strconv" "strings" "syscall" "time" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethclient" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" "go.uber.org/zap/zapcore" "github.com/smartcontractkit/chainlink-common/pkg/beholder" - "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm" httptypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/http" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" @@ -32,12 +25,14 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" pb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" "github.com/smartcontractkit/chainlink-protos/cre/go/values" - valuespb "github.com/smartcontractkit/chainlink-protos/cre/go/values/pb" "github.com/smartcontractkit/chainlink/v2/core/capabilities" simulator "github.com/smartcontractkit/chainlink/v2/core/services/workflows/cmd/cre/utils" v2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/v2" cmdcommon "github.com/smartcontractkit/cre-cli/cmd/common" + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" + _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/aptos" // register Aptos chain family via package init + _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/evm" // register EVM chain family via package init "github.com/smartcontractkit/cre-cli/internal/constants" "github.com/smartcontractkit/cre-cli/internal/credentials" "github.com/smartcontractkit/cre-cli/internal/runtime" @@ -46,24 +41,28 @@ import ( "github.com/smartcontractkit/cre-cli/internal/validation" ) +const WorkflowExecutionTimeout = 5 * time.Minute + type Inputs struct { - WasmPath string `validate:"omitempty,file,ascii,max=97" cli:"--wasm"` - WorkflowPath string `validate:"required,workflow_path_read"` - ConfigPath string `validate:"omitempty,file,ascii,max=97"` - SecretsPath string `validate:"omitempty,file,ascii,max=97"` - EngineLogs bool `validate:"omitempty" cli:"--engine-logs"` - Broadcast bool `validate:"-"` - EVMClients map[uint64]*ethclient.Client `validate:"omitempty"` // multichain clients keyed by selector (or chain ID for experimental) - EthPrivateKey *ecdsa.PrivateKey `validate:"omitempty"` - WorkflowName string `validate:"required"` + WasmPath string `validate:"omitempty,file,ascii,max=97" cli:"--wasm"` + WorkflowPath string `validate:"required,workflow_path_read"` + ConfigPath string `validate:"omitempty,file,ascii,max=97"` + SecretsPath string `validate:"omitempty,file,ascii,max=97"` + EngineLogs bool `validate:"omitempty" cli:"--engine-logs"` + Broadcast bool `validate:"-"` + WorkflowName string `validate:"required"` + // Chain-type-specific fields + ChainTypeClients map[string]map[uint64]chain.ChainClient `validate:"omitempty"` + ChainTypeKeys map[string]interface{} `validate:"-"` + // ChainTypeResolved holds the full ResolveClients bundle per chain type + // (clients, forwarders, experimental-selector flags) so later steps + // (health check, capability registration) have a single source of truth. + ChainTypeResolved map[string]chain.ResolvedChains `validate:"-"` // Non-interactive mode options - NonInteractive bool `validate:"-"` - TriggerIndex int `validate:"-"` - HTTPPayload string `validate:"-"` // JSON string or @/path/to/file.json - EVMTxHash string `validate:"-"` // 0x-prefixed - EVMEventIndex int `validate:"-"` - // Experimental chains support (for chains not in official chain-selectors) - ExperimentalForwarders map[uint64]common.Address `validate:"-"` // forwarders keyed by chain ID + NonInteractive bool `validate:"-"` + TriggerIndex int `validate:"-"` + HTTPPayload string `validate:"-"` // JSON string or @/path/to/file.json + ChainTypeInputs map[string]string `validate:"-"` // CLI-supplied chain-type-specific trigger inputs // Limits enforcement LimitsPath string `validate:"-"` // "default" or path to custom limits JSON // SkipTypeChecks passes --skip-type-checks to cre-compile for TypeScript workflows. @@ -107,8 +106,10 @@ func New(runtimeContext *runtime.Context) *cobra.Command { simulateCmd.Flags().Bool(settings.Flags.NonInteractive.Name, false, "Run without prompts; requires --trigger-index and inputs for the selected trigger type") simulateCmd.Flags().Int("trigger-index", -1, "Index of the trigger to run (0-based)") simulateCmd.Flags().String("http-payload", "", "HTTP trigger payload as JSON string or path to JSON file (with or without @ prefix)") - simulateCmd.Flags().String("evm-tx-hash", "", "EVM trigger transaction hash (0x...)") - simulateCmd.Flags().Int("evm-event-index", -1, "EVM trigger log index (0-based)") + + // Register chain-type-specific CLI flags (e.g., --evm-tx-hash). + chain.RegisterAllCLIFlags(simulateCmd) + simulateCmd.Flags().String("limits", "default", "Production limits to enforce during simulation: 'default' for prod defaults, path to a limits JSON file (e.g. from 'cre workflow limits export'), or 'none' to disable") simulateCmd.Flags().Bool(cmdcommon.SkipTypeChecksCLIFlag, false, "Skip TypeScript project typecheck during compilation (passes "+cmdcommon.SkipTypeChecksFlag+" to cre-compile)") return simulateCmd @@ -131,125 +132,65 @@ func newHandler(ctx *runtime.Context) *handler { } func (h *handler) ResolveInputs(v *viper.Viper, creSettings *settings.Settings) (Inputs, error) { - // build clients for each supported chain from settings, skip if rpc is empty - clients := make(map[uint64]*ethclient.Client) - for _, chain := range SupportedEVM { - chainName, err := settings.GetChainNameByChainSelector(chain.Selector) - if err != nil { - h.log.Error().Msgf("Invalid chain selector for supported EVM chains %d; skipping", chain.Selector) - continue - } - rpcURL, err := settings.GetRpcUrlSettings(v, chainName) - if err != nil || strings.TrimSpace(rpcURL) == "" { - h.log.Debug().Msgf("RPC not provided for %s; skipping", chainName) - continue - } - h.log.Debug().Msgf("Using RPC for %s: %s", chainName, redactURL(rpcURL)) - - c, err := ethclient.Dial(rpcURL) - if err != nil { - ui.Warning(fmt.Sprintf("Failed to create eth client for %s: %v", chainName, err)) - continue - } - - clients[chain.Selector] = c - } - - // Experimental chains support (automatically loaded from config if present) - experimentalForwarders := make(map[uint64]common.Address) - - expChains, err := settings.GetExperimentalChains(v) - if err != nil { - return Inputs{}, fmt.Errorf("failed to load experimental chains config: %w", err) - } + chain.Build(h.log) - for _, ec := range expChains { - // Validate required fields - if ec.ChainSelector == 0 { - return Inputs{}, fmt.Errorf("experimental chain missing chain-selector") - } - if strings.TrimSpace(ec.RPCURL) == "" { - return Inputs{}, fmt.Errorf("experimental chain %d missing rpc-url", ec.ChainSelector) - } - if strings.TrimSpace(ec.Forwarder) == "" { - return Inputs{}, fmt.Errorf("experimental chain %d missing forwarder", ec.ChainSelector) - } + ctClients := make(map[string]map[uint64]chain.ChainClient) + ctResolved := make(map[string]chain.ResolvedChains) + ctKeys := make(map[string]interface{}) - // Check if chain selector already exists (supported chain) - if _, exists := clients[ec.ChainSelector]; exists { - // Find the supported chain's forwarder - var supportedForwarder string - for _, supported := range SupportedEVM { - if supported.Selector == ec.ChainSelector { - supportedForwarder = supported.Forwarder - break - } - } - - expFwd := common.HexToAddress(ec.Forwarder) - if supportedForwarder != "" && common.HexToAddress(supportedForwarder) == expFwd { - // Same forwarder, just debug log - h.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") - continue - } - - // Different forwarder - respect user's config, warn about override - ui.Warning(fmt.Sprintf("Warning: experimental chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", ec.ChainSelector, supportedForwarder, ec.Forwarder)) - - // Use existing client but override the forwarder - experimentalForwarders[ec.ChainSelector] = expFwd - continue - } - - // Dial the RPC - h.log.Debug().Msgf("Using RPC for experimental chain %d: %s", ec.ChainSelector, redactURL(ec.RPCURL)) - c, err := ethclient.Dial(ec.RPCURL) + for name, ct := range chain.All() { + resolved, err := ct.ResolveClients(v) if err != nil { - return Inputs{}, fmt.Errorf("failed to create eth client for experimental chain %d: %w", ec.ChainSelector, err) + return Inputs{}, fmt.Errorf("failed to resolve %s clients: %w", name, err) } - clients[ec.ChainSelector] = c - experimentalForwarders[ec.ChainSelector] = common.HexToAddress(ec.Forwarder) - ui.Dim(fmt.Sprintf("Added experimental chain (chain-selector: %d)\n", ec.ChainSelector)) - + if len(resolved.Clients) > 0 { + ctClients[name] = resolved.Clients + ctResolved[name] = resolved + } } - if len(clients) == 0 { + // Check at least one chain type has clients + totalClients := 0 + for _, fc := range ctClients { + totalClients += len(fc) + } + if totalClients == 0 { return Inputs{}, fmt.Errorf("no RPC URLs found for supported or experimental chains") } - pk, err := crypto.HexToECDSA(creSettings.User.EthPrivateKey) - if err != nil { - if v.GetBool("broadcast") { - return Inputs{}, fmt.Errorf( - "failed to parse private key, required to broadcast. Please check CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + broadcast := v.GetBool("broadcast") + for name, ct := range chain.All() { + if _, ok := ctClients[name]; !ok { + continue // no clients for this chain type; skip key resolution } - pk, err = crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") + key, err := ct.ResolveKey(creSettings, broadcast) if err != nil { - return Inputs{}, fmt.Errorf("failed to parse default private key. Please set CRE_ETH_PRIVATE_KEY in your .env file or system environment: %w", err) + return Inputs{}, err + } + if key != nil { + ctKeys[name] = key } - ui.Warning("Using default private key for chain write simulation. To use your own key, set CRE_ETH_PRIVATE_KEY in your .env file or system environment.") } return Inputs{ - WasmPath: v.GetString("wasm"), - WorkflowPath: creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath, - ConfigPath: cmdcommon.ResolveConfigPath(v, creSettings.Workflow.WorkflowArtifactSettings.ConfigPath), - SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, - EngineLogs: v.GetBool("engine-logs"), - Broadcast: v.GetBool("broadcast"), - EVMClients: clients, - EthPrivateKey: pk, - WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, - NonInteractive: v.GetBool("non-interactive"), - TriggerIndex: v.GetInt("trigger-index"), - HTTPPayload: v.GetString("http-payload"), - EVMTxHash: v.GetString("evm-tx-hash"), - EVMEventIndex: v.GetInt("evm-event-index"), - ExperimentalForwarders: experimentalForwarders, - LimitsPath: v.GetString("limits"), - SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), - InvocationDir: h.runtimeContext.InvocationDir, + WasmPath: v.GetString("wasm"), + WorkflowPath: creSettings.Workflow.WorkflowArtifactSettings.WorkflowPath, + ConfigPath: cmdcommon.ResolveConfigPath(v, creSettings.Workflow.WorkflowArtifactSettings.ConfigPath), + SecretsPath: creSettings.Workflow.WorkflowArtifactSettings.SecretsPath, + EngineLogs: v.GetBool("engine-logs"), + Broadcast: v.GetBool("broadcast"), + ChainTypeClients: ctClients, + ChainTypeResolved: ctResolved, + ChainTypeKeys: ctKeys, + WorkflowName: creSettings.Workflow.UserWorkflowSettings.WorkflowName, + NonInteractive: v.GetBool("non-interactive"), + TriggerIndex: v.GetInt("trigger-index"), + HTTPPayload: v.GetString("http-payload"), + ChainTypeInputs: chain.CollectAllCLIInputs(v), + LimitsPath: v.GetString("limits"), + SkipTypeChecks: v.GetBool(cmdcommon.SkipTypeChecksCLIFlag), + InvocationDir: h.runtimeContext.InvocationDir, }, nil } @@ -276,13 +217,21 @@ func (h *handler) ValidateInputs(inputs Inputs) error { inputs.WasmPath = savedWasm inputs.ConfigPath = savedConfig - // forbid the default 0x...01 key when broadcasting - if inputs.Broadcast && inputs.EthPrivateKey != nil && inputs.EthPrivateKey.D.Cmp(big.NewInt(1)) == 0 { - return fmt.Errorf("you must configure a valid private key to perform on-chain writes. Please set your private key in the .env file before using the -–broadcast flag") - } - rpcErr := ui.WithSpinner("Checking RPC connectivity...", func() error { - return runRPCHealthCheck(inputs.EVMClients, inputs.ExperimentalForwarders) + var errs []error + for name, ct := range chain.All() { + resolved, ok := inputs.ChainTypeResolved[name] + if !ok { + continue + } + if err := ct.RunHealthCheck(resolved); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return fmt.Errorf("RPC health check failed:\n%w", errors.Join(errs...)) + } + return nil }) if rpcErr != nil { // we don't block execution, just show the error to the user @@ -475,7 +424,7 @@ func run( initializedCh := make(chan struct{}) executionFinishedCh := make(chan struct{}) - var triggerCaps *ManualTriggers + var manualTriggerCaps *ManualTriggers simulatorInitialize := func(ctx context.Context, cfg simulator.RunnerConfig) (*capabilities.Registry, []services.Service) { lggr := logger.Sugared(cfg.Lggr) // Create the registry and fake capabilities with specific loggers @@ -505,33 +454,47 @@ func run( } } - // Build forwarder address map based on which chains actually have RPC clients configured - forwarders := map[uint64]common.Address{} - for _, c := range SupportedEVM { - if _, ok := inputs.EVMClients[c.Selector]; ok && strings.TrimSpace(c.Forwarder) != "" { - forwarders[c.Selector] = common.HexToAddress(c.Forwarder) - } - } - - // Merge experimental forwarders (keyed by chain ID) - for chainID, fwdAddr := range inputs.ExperimentalForwarders { - forwarders[chainID] = fwdAddr - } - - manualTriggerCapConfig := ManualTriggerCapabilitiesConfig{ - Clients: inputs.EVMClients, - PrivateKey: inputs.EthPrivateKey, - Forwarders: forwarders, - } - + // Register chain-agnostic cron and HTTP triggers triggerLggr := lggr.Named("TriggerCapabilities") var err error - triggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry, manualTriggerCapConfig, !inputs.Broadcast, simLimits) + manualTriggerCaps, err = NewManualTriggerCapabilities(ctx, triggerLggr, registry) if err != nil { ui.Error(fmt.Sprintf("Failed to create trigger capabilities: %v", err)) os.Exit(1) } + srvcs = append(srvcs, manualTriggerCaps.ManualCronTrigger, manualTriggerCaps.ManualHTTPTrigger) + + // Only set Limits when non-nil to avoid the typed-nil interface trap + // (a nil *SimulationLimits boxed into chain.Limits compares != nil). + var capLimits chain.Limits + if simLimits != nil { + capLimits = simLimits + } + // Register chain-type-specific capabilities + for name, ct := range chain.All() { + clients, ok := inputs.ChainTypeClients[name] + if !ok || len(clients) == 0 { + continue + } + + ctSrvcs, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Registry: registry, + Clients: clients, + Forwarders: inputs.ChainTypeResolved[name].Forwarders, + PrivateKey: inputs.ChainTypeKeys[name], + Broadcast: inputs.Broadcast, + Limits: capLimits, + Logger: triggerLggr, + }) + if err != nil { + ui.Error(fmt.Sprintf("Failed to register %s capabilities: %v", name, err)) + os.Exit(1) + } + srvcs = append(srvcs, ctSrvcs...) + } + + // Register chain-agnostic action capabilities (consensus, HTTP, confidential HTTP) computeLggr := lggr.Named("ActionsCapabilities") computeCaps, err := NewFakeActionCapabilities(ctx, computeLggr, registry, inputs.SecretsPath, simLimits) if err != nil { @@ -540,7 +503,7 @@ func run( } // Start trigger capabilities - if err := triggerCaps.Start(ctx); err != nil { + if err := manualTriggerCaps.Start(ctx); err != nil { ui.Error(fmt.Sprintf("Failed to start trigger: %v", err)) os.Exit(1) } @@ -553,10 +516,6 @@ func run( } } - srvcs = append(srvcs, triggerCaps.ManualCronTrigger, triggerCaps.ManualHTTPTrigger) - for _, evm := range triggerCaps.ManualEVMChains { - srvcs = append(srvcs, evm) - } srvcs = append(srvcs, computeCaps...) return registry, srvcs } @@ -564,11 +523,11 @@ func run( // Create a holder for trigger info that will be populated in beforeStart triggerInfoAndBeforeStart := &TriggerInfoAndBeforeStart{} - getTriggerCaps := func() *ManualTriggers { return triggerCaps } + getManualTriggerCaps := func() *ManualTriggers { return manualTriggerCaps } if inputs.NonInteractive { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartNonInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps) } else { - triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getTriggerCaps) + triggerInfoAndBeforeStart.BeforeStart = makeBeforeStartInteractive(triggerInfoAndBeforeStart, inputs, getManualTriggerCaps) } waitFn := func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service) { @@ -705,7 +664,7 @@ type TriggerInfoAndBeforeStart struct { } // makeBeforeStartInteractive builds the interactive BeforeStart closure -func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, triggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -744,58 +703,59 @@ func makeBeforeStartInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", triggerIndex) trigger := holder.TriggerToRun.Id - triggerCaps := triggerCapsGetter() + manualTriggerCaps := manualTriggerCapsGetter() - switch { - case trigger == "cron-trigger@1.0.0": + switch trigger { + case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { - return triggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) + return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) } - case trigger == "http-trigger@1.0.0-alpha": + case "http-trigger@1.0.0-alpha": payload, err := getHTTPTriggerPayload(inputs.InvocationDir) if err != nil { ui.Error(fmt.Sprintf("Failed to get HTTP trigger payload: %v", err)) os.Exit(1) } holder.TriggerFunc = func() error { - return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) - } - case strings.HasPrefix(trigger, "evm") && strings.HasSuffix(trigger, "@1.0.0"): - // Derive the chain selector directly from the selected trigger ID. - sel, ok := parseChainSelectorFromTriggerID(holder.TriggerToRun.GetId()) - if !ok { - ui.Error(fmt.Sprintf("Could not determine chain selector from trigger id %q", holder.TriggerToRun.GetId())) - os.Exit(1) + return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } + default: + // Try each registered chain type + handled := false + for name, ct := range chain.All() { + sel, ok := ct.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) + if !ok { + continue + } - client := inputs.EVMClients[sel] - if client == nil { - ui.Error(fmt.Sprintf("No RPC configured for chain selector %d", sel)) - os.Exit(1) - } + if !ct.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) + os.Exit(1) + } - log, err := getEVMTriggerLog(ctx, client) - if err != nil { - ui.Error(fmt.Sprintf("Failed to get EVM trigger log: %v", err)) - os.Exit(1) + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, true) + if err != nil { + ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) + os.Exit(1) + } + + handled = true + holder.TriggerFunc = func() error { + return ct.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) + } + break } - evmChain := triggerCaps.ManualEVMChains[sel] - if evmChain == nil { - ui.Error(fmt.Sprintf("No EVM chain initialized for selector %d", sel)) + + if !handled { + ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) os.Exit(1) } - holder.TriggerFunc = func() error { - return evmChain.ManualTrigger(ctx, triggerRegistrationID, log) - } - default: - ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) - os.Exit(1) } } } // makeBeforeStartNonInteractive builds the non-interactive BeforeStart closure -func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, triggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { +func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inputs, manualTriggerCapsGetter func() *ManualTriggers) func(context.Context, simulator.RunnerConfig, *capabilities.Registry, []services.Service, []*pb.TriggerSubscription) { return func( ctx context.Context, cfg simulator.RunnerConfig, @@ -819,14 +779,14 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp holder.TriggerToRun = triggerSub[inputs.TriggerIndex] triggerRegistrationID := fmt.Sprintf("trigger_reg_1111111111111111111111111111111111111111111111111111111111111111_%d", inputs.TriggerIndex) trigger := holder.TriggerToRun.Id - triggerCaps := triggerCapsGetter() + manualTriggerCaps := manualTriggerCapsGetter() - switch { - case trigger == "cron-trigger@1.0.0": + switch trigger { + case "cron-trigger@1.0.0": holder.TriggerFunc = func() error { - return triggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) + return manualTriggerCaps.ManualCronTrigger.ManualTrigger(ctx, triggerRegistrationID, time.Now()) } - case trigger == "http-trigger@1.0.0-alpha": + case "http-trigger@1.0.0-alpha": if strings.TrimSpace(inputs.HTTPPayload) == "" { ui.Error("--http-payload is required for http-trigger@1.0.0-alpha in non-interactive mode") os.Exit(1) @@ -837,42 +797,39 @@ func makeBeforeStartNonInteractive(holder *TriggerInfoAndBeforeStart, inputs Inp os.Exit(1) } holder.TriggerFunc = func() error { - return triggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) - } - case strings.HasPrefix(trigger, "evm") && strings.HasSuffix(trigger, "@1.0.0"): - if strings.TrimSpace(inputs.EVMTxHash) == "" || inputs.EVMEventIndex < 0 { - ui.Error("--evm-tx-hash and --evm-event-index are required for EVM triggers in non-interactive mode") - os.Exit(1) + return manualTriggerCaps.ManualHTTPTrigger.ManualTrigger(ctx, triggerRegistrationID, payload) } + default: + // Try each registered chain type + handled := false + for name, ct := range chain.All() { + sel, ok := ct.ParseTriggerChainSelector(holder.TriggerToRun.GetId()) + if !ok { + continue + } - sel, ok := parseChainSelectorFromTriggerID(holder.TriggerToRun.GetId()) - if !ok { - ui.Error(fmt.Sprintf("Could not determine chain selector from trigger id %q", holder.TriggerToRun.GetId())) - os.Exit(1) - } + if !ct.HasSelector(sel) { + ui.Error(fmt.Sprintf("No %s chain initialized for selector %d", name, sel)) + os.Exit(1) + } - client := inputs.EVMClients[sel] - if client == nil { - ui.Error(fmt.Sprintf("No RPC configured for chain selector %d", sel)) - os.Exit(1) - } + triggerData, err := getTriggerDataForChainType(ctx, ct, sel, inputs, false) + if err != nil { + ui.Error(fmt.Sprintf("Failed to get %s trigger data: %v", name, err)) + os.Exit(1) + } - log, err := getEVMTriggerLogFromValues(ctx, client, inputs.EVMTxHash, uint64(inputs.EVMEventIndex)) // #nosec G115 -- EVMEventIndex validated >= 0 above - if err != nil { - ui.Error(fmt.Sprintf("Failed to build EVM trigger log: %v", err)) - os.Exit(1) + handled = true + holder.TriggerFunc = func() error { + return ct.ExecuteTrigger(ctx, sel, triggerRegistrationID, triggerData) + } + break } - evmChain := triggerCaps.ManualEVMChains[sel] - if evmChain == nil { - ui.Error(fmt.Sprintf("No EVM chain initialized for selector %d", sel)) + + if !handled { + ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) os.Exit(1) } - holder.TriggerFunc = func() error { - return evmChain.ManualTrigger(ctx, triggerRegistrationID, log) - } - default: - ui.Error(fmt.Sprintf("Unsupported trigger type: %s", holder.TriggerToRun.Id)) - os.Exit(1) } } } @@ -908,10 +865,9 @@ func cleanupBeholder() error { return nil } -// getHTTPTriggerPayload prompts user for HTTP trigger data. -// invocationDir is the working directory at the time the CLI was invoked; relative -// paths entered by the user are resolved against it rather than the current working -// directory (which may have been changed to the workflow folder by SetExecutionContext). +// getHTTPTriggerPayload prompts user for HTTP trigger data. Relative paths are +// resolved against invocationDir so file references work from where the user ran +// the command even after SetExecutionContext switches cwd to the workflow dir. func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) { ui.Line() input, err := ui.Input("HTTP Trigger Configuration", @@ -964,6 +920,16 @@ func getHTTPTriggerPayload(invocationDir string) (*httptypedapi.Payload, error) return payload, nil } +// getTriggerDataForChainType resolves trigger data for a specific chain type. +// Each chain type defines its own trigger data format. +func getTriggerDataForChainType(ctx context.Context, ct chain.ChainType, selector uint64, inputs Inputs, interactive bool) (interface{}, error) { + return ct.ResolveTriggerData(ctx, selector, chain.TriggerParams{ + Clients: inputs.ChainTypeClients[ct.Name()], + Interactive: interactive, + ChainTypeInputs: inputs.ChainTypeInputs, + }) +} + // resolvePathFromInvocation converts a (potentially relative) path to an absolute // path anchored at invocationDir. Absolute paths and paths that are already // reachable from the current working directory are returned unchanged. @@ -974,116 +940,6 @@ func resolvePathFromInvocation(path, invocationDir string) string { return filepath.Join(invocationDir, path) } -// getEVMTriggerLog prompts user for EVM trigger data and fetches the log -func getEVMTriggerLog(ctx context.Context, ethClient *ethclient.Client) (*evm.Log, error) { - var txHashInput string - var eventIndexInput string - - ui.Line() - if err := ui.InputForm([]ui.InputField{ - { - Title: "EVM Trigger Configuration", - Description: "Transaction hash for the EVM log event", - Placeholder: "0x...", - Value: &txHashInput, - Validate: func(s string) error { - s = strings.TrimSpace(s) - if s == "" { - return fmt.Errorf("transaction hash cannot be empty") - } - if !strings.HasPrefix(s, "0x") { - return fmt.Errorf("transaction hash must start with 0x") - } - if len(s) != 66 { - return fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(s)) - } - return nil - }, - }, - { - Title: "Event Index", - Description: "Log event index (0-based)", - Placeholder: "0", - Suggestions: []string{"0"}, - Value: &eventIndexInput, - Validate: func(s string) error { - if strings.TrimSpace(s) == "" { - return fmt.Errorf("event index cannot be empty") - } - if _, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32); err != nil { - return fmt.Errorf("invalid event index: must be a number") - } - return nil - }, - }, - }); err != nil { - return nil, fmt.Errorf("EVM trigger input cancelled: %w", err) - } - - txHashInput = strings.TrimSpace(txHashInput) - txHash := common.HexToHash(txHashInput) - - eventIndexInput = strings.TrimSpace(eventIndexInput) - eventIndex, err := strconv.ParseUint(eventIndexInput, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid event index: %w", err) - } - - // Fetch the transaction receipt - receiptSpinner := ui.NewSpinner() - receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) - txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) - receiptSpinner.Stop() - if err != nil { - return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) - } - - // Check if event index is valid - if eventIndex >= uint64(len(txReceipt.Logs)) { - return nil, fmt.Errorf("event index %d out of range, transaction has %d log events", eventIndex, len(txReceipt.Logs)) - } - - log := txReceipt.Logs[eventIndex] - ui.Success(fmt.Sprintf("Found log event at index %d: contract=%s, topics=%d", eventIndex, log.Address.Hex(), len(log.Topics))) - - // Check for potential uint32 overflow (prevents noisy linter warnings) - var txIndex, logIndex uint32 - if log.TxIndex > math.MaxUint32 { - return nil, fmt.Errorf("transaction index %d exceeds uint32 maximum value", log.TxIndex) - } - txIndex = uint32(log.TxIndex) - - if log.Index > math.MaxUint32 { - return nil, fmt.Errorf("log index %d exceeds uint32 maximum value", log.Index) - } - logIndex = uint32(log.Index) - - // Convert to protobuf format - pbLog := &evm.Log{ - Address: log.Address.Bytes(), - Data: log.Data, - BlockHash: log.BlockHash.Bytes(), - TxHash: log.TxHash.Bytes(), - TxIndex: txIndex, - Index: logIndex, - Removed: log.Removed, - BlockNumber: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(log.BlockNumber)), - } - - // Convert topics - for _, topic := range log.Topics { - pbLog.Topics = append(pbLog.Topics, topic.Bytes()) - } - - // Set event signature (first topic is usually the event signature) - if len(log.Topics) > 0 { - pbLog.EventSig = log.Topics[0].Bytes() - } - - ui.Success(fmt.Sprintf("Created EVM trigger log for transaction %s, event %d", txHash.Hex(), eventIndex)) - return pbLog, nil -} - // getHTTPTriggerPayloadFromInput builds an HTTP trigger payload from a JSON string or a file path // (optionally prefixed with '@'). invocationDir is used to resolve relative paths against the // directory where the user invoked the CLI rather than the current working directory. @@ -1116,60 +972,3 @@ func getHTTPTriggerPayloadFromInput(input, invocationDir string) (*httptypedapi. return &httptypedapi.Payload{Input: raw}, nil } - -// getEVMTriggerLogFromValues fetches a log given tx hash and event index -func getEVMTriggerLogFromValues(ctx context.Context, ethClient *ethclient.Client, txHashStr string, eventIndex uint64) (*evm.Log, error) { - txHashStr = strings.TrimSpace(txHashStr) - if txHashStr == "" { - return nil, fmt.Errorf("transaction hash cannot be empty") - } - if !strings.HasPrefix(txHashStr, "0x") { - return nil, fmt.Errorf("transaction hash must start with 0x") - } - if len(txHashStr) != 66 { // 0x + 64 hex chars - return nil, fmt.Errorf("invalid transaction hash length: expected 66 characters, got %d", len(txHashStr)) - } - - txHash := common.HexToHash(txHashStr) - receiptSpinner := ui.NewSpinner() - receiptSpinner.Start(fmt.Sprintf("Fetching transaction receipt for %s...", txHash.Hex())) - txReceipt, err := ethClient.TransactionReceipt(ctx, txHash) - receiptSpinner.Stop() - if err != nil { - return nil, fmt.Errorf("failed to fetch transaction receipt: %w", err) - } - if eventIndex >= uint64(len(txReceipt.Logs)) { - return nil, fmt.Errorf("event index %d out of range, transaction has %d log events", eventIndex, len(txReceipt.Logs)) - } - - log := txReceipt.Logs[eventIndex] - - // Check for potential uint32 overflow - var txIndex, logIndex uint32 - if log.TxIndex > math.MaxUint32 { - return nil, fmt.Errorf("transaction index %d exceeds uint32 maximum value", log.TxIndex) - } - txIndex = uint32(log.TxIndex) - if log.Index > math.MaxUint32 { - return nil, fmt.Errorf("log index %d exceeds uint32 maximum value", log.Index) - } - logIndex = uint32(log.Index) - - pbLog := &evm.Log{ - Address: log.Address.Bytes(), - Data: log.Data, - BlockHash: log.BlockHash.Bytes(), - TxHash: log.TxHash.Bytes(), - TxIndex: txIndex, - Index: logIndex, - Removed: log.Removed, - BlockNumber: valuespb.NewBigIntFromInt(new(big.Int).SetUint64(log.BlockNumber)), - } - for _, topic := range log.Topics { - pbLog.Topics = append(pbLog.Topics, topic.Bytes()) - } - if len(log.Topics) > 0 { - pbLog.EventSig = log.Topics[0].Bytes() - } - return pbLog, nil -} diff --git a/cmd/workflow/simulate/utils_test.go b/cmd/workflow/simulate/utils_test.go deleted file mode 100644 index 14c5fd26..00000000 --- a/cmd/workflow/simulate/utils_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package simulate - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/ethereum/go-ethereum/ethclient" -) - -func TestParseChainSelectorFromTriggerID(t *testing.T) { - tests := []struct { - name string - id string - want uint64 - ok bool - }{ - { - name: "mainnet format", - id: "evm:ChainSelector:5009297550715157269@1.0.0 LogTrigger", - want: uint64(5009297550715157269), - ok: true, - }, - { - name: "sepolia lowercase", - id: "evm:chainselector:16015286601757825753@1.0.0", - want: uint64(16015286601757825753), - ok: true, - }, - { - name: "sepolia uppercase", - id: "EVM:CHAINSELECTOR:16015286601757825753@1.0.0", - want: uint64(16015286601757825753), - ok: true, - }, - { - name: "leading and trailing spaces", - id: " evm:ChainSelector:123@1.0.0 ", - want: uint64(123), - ok: true, - }, - { - name: "no selector present", - id: "evm@1.0.0 LogTrigger", - want: 0, - ok: false, - }, - { - name: "non-numeric selector", - id: "evm:ChainSelector:notanumber@1.0.0", - want: 0, - ok: false, - }, - { - name: "empty selector", - id: "evm:ChainSelector:@1.0.0", - want: 0, - ok: false, - }, - { - name: "overflow uint64", - // 2^64 is overflow for uint64 (max is 2^64-1) - id: "evm:ChainSelector:18446744073709551616@1.0.0", - want: 0, - ok: false, - }, - { - name: "digits followed by letters (regex grabs only digits)", - id: "evm:ChainSelector:987abc@1.0.0", - want: uint64(987), - ok: true, - }, - { - name: "multiple occurrences - returns first", - id: "foo ChainSelector:1 bar ChainSelector:2 baz", - want: uint64(1), - ok: true, - }, - } - - for _, tt := range tests { - - t.Run(tt.name, func(t *testing.T) { - got, ok := parseChainSelectorFromTriggerID(tt.id) - if ok != tt.ok || got != tt.want { - t.Fatalf("parseChainSelectorFromTriggerID(%q) = (%d, %v); want (%d, %v)", tt.id, got, ok, tt.want, tt.ok) - } - }) - } -} - -const selectorSepolia uint64 = 16015286601757825753 // expects "ethereum-testnet-sepolia" - -// newChainIDServer returns a JSON-RPC 2.0 server that replies to eth_chainId. -// reply can be: string (hex like "0x1" or "0x0") or error (JSON-RPC error). -func newChainIDServer(t *testing.T, reply interface{}) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req struct { - ID json.RawMessage `json:"id"` - Method string `json:"method"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - - type rpcErr struct { - Code int `json:"code"` - Message string `json:"message"` - } - - res := map[string]any{ - "jsonrpc": "2.0", - "id": req.ID, - } - switch v := reply.(type) { - case string: - res["result"] = v - case error: - res["error"] = rpcErr{Code: -32603, Message: v.Error()} - default: - res["result"] = v - } - _ = json.NewEncoder(w).Encode(res) - })) -} - -func newEthClient(t *testing.T, url string) *ethclient.Client { - t.Helper() - c, err := ethclient.Dial(url) - if err != nil { - t.Fatalf("dial eth client: %v", err) - } - return c -} - -func mustContain(t *testing.T, s string, subs ...string) { - t.Helper() - for _, sub := range subs { - if !strings.Contains(s, sub) { - t.Fatalf("expected error to contain %q, got:\n%s", sub, s) - } - } -} - -func TestHealthCheck_NoClientsConfigured(t *testing.T) { - err := runRPCHealthCheck(map[uint64]*ethclient.Client{}, nil) - if err == nil { - t.Fatalf("expected error for no clients configured") - } - mustContain(t, err.Error(), "check your settings: no RPC URLs found for supported or experimental chains") -} - -func TestHealthCheck_NilClient(t *testing.T) { - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - 123: nil, // resolver is not called for nil clients - }, nil) - if err == nil { - t.Fatalf("expected error for nil client") - } - // nil-client path renders numeric selector in brackets - mustContain(t, err.Error(), "RPC health check failed", "[123] nil client") -} - -func TestHealthCheck_AllOK(t *testing.T) { - // Any positive chain ID works; use Sepolia id (0xaa36a7 == 11155111) for realism - sOK := newChainIDServer(t, "0xaa36a7") - defer sOK.Close() - - cOK := newEthClient(t, sOK.URL) - defer cOK.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cOK, - }, nil) - if err != nil { - t.Fatalf("expected nil error, got: %v", err) - } -} - -func TestHealthCheck_RPCError_usesChainName(t *testing.T) { - sErr := newChainIDServer(t, fmt.Errorf("boom")) - defer sErr.Close() - - cErr := newEthClient(t, sErr.URL) - defer cErr.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cErr, - }, nil) - if err == nil { - t.Fatalf("expected error for RPC failure") - } - // We assert the friendly chain name appears (from settings) - mustContain(t, err.Error(), - "RPC health check failed", - "[ethereum-testnet-sepolia] failed RPC health check", - ) -} - -func TestHealthCheck_ZeroChainID_usesChainName(t *testing.T) { - sZero := newChainIDServer(t, "0x0") - defer sZero.Close() - - cZero := newEthClient(t, sZero.URL) - defer cZero.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cZero, - }, nil) - if err == nil { - t.Fatalf("expected error for zero chain id") - } - mustContain(t, err.Error(), - "RPC health check failed", - "[ethereum-testnet-sepolia] invalid RPC response: empty or zero chain ID", - ) -} - -func TestHealthCheck_AggregatesMultipleErrors(t *testing.T) { - sErr := newChainIDServer(t, fmt.Errorf("boom")) - defer sErr.Close() - - cErr := newEthClient(t, sErr.URL) - defer cErr.Close() - - err := runRPCHealthCheck(map[uint64]*ethclient.Client{ - selectorSepolia: cErr, // named failure - 777: nil, // nil client (numeric selector path) - }, nil) - if err == nil { - t.Fatalf("expected aggregated error") - } - mustContain(t, err.Error(), - "RPC health check failed", - "[ethereum-testnet-sepolia] failed RPC health check", - "[777] nil client", - ) -} diff --git a/go.mod b/go.mod index 860a6735..ace04f3f 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/denisbrodbeck/machineid v1.0.1 - github.com/ethereum/go-ethereum v1.17.1 + github.com/ethereum/go-ethereum v1.17.2 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.30.1 @@ -24,7 +24,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.34.0 github.com/smartcontractkit/chain-selectors v1.0.97 - github.com/smartcontractkit/chainlink-common v0.11.2-0.20260403093224-b39dab3bfe2a + github.com/smartcontractkit/chainlink-common v0.11.2-0.20260406055916-9aa6b6c0ae81 github.com/smartcontractkit/chainlink-common/keystore v1.0.2 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260119171452-39c98c3b33cd github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260326111235-8c09d1a4491f @@ -42,7 +42,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/test-go/testify v1.1.4 go.uber.org/zap v1.27.1 - golang.org/x/term v0.40.0 + golang.org/x/term v0.42.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 @@ -72,7 +72,7 @@ require ( github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/XSAM/otelsql v0.37.0 // indirect github.com/apache/arrow-go/v18 v18.3.1 // indirect - github.com/aptos-labs/aptos-go-sdk v1.12.1-0.20260318141106-21b6ef4ed363 // indirect + github.com/aptos-labs/aptos-go-sdk v1.12.1 // indirect github.com/atombender/go-jsonschema v0.16.1-0.20240916205339-a74cd4e2851c // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect @@ -132,7 +132,7 @@ require ( github.com/cosmos/ics23/go v0.11.0 // indirect github.com/cosmos/ledger-cosmos-go v0.14.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect github.com/creachadair/jrpc2 v1.2.0 // indirect github.com/creachadair/mds v0.13.4 // indirect github.com/danieljoos/wincred v1.2.1 // indirect @@ -195,7 +195,8 @@ require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.2.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect - github.com/grafana/pyroscope-go v1.2.7 // indirect + github.com/grafana/otel-profiling-go v0.5.1 // indirect + github.com/grafana/pyroscope-go v1.2.8 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect @@ -236,7 +237,7 @@ require ( github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/karalabe/hid v1.0.1-0.20240306101548-573246063e52 // indirect + github.com/karalabe/hid v1.0.1-0.20260315100226-f5d04adeffeb // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/pretty v0.3.1 // indirect @@ -274,7 +275,7 @@ require ( github.com/oklog/run v1.2.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect @@ -377,7 +378,7 @@ require ( go.mongodb.org/mongo-driver v1.17.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect @@ -389,28 +390,28 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 // indirect go.opentelemetry.io/otel/log v0.15.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.41.0 // indirect go.opentelemetry.io/otel/sdk/log v0.15.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.11.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect @@ -427,3 +428,5 @@ require ( replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alpha.regen.1 replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014143056-a0c6328c91e9 + +replace github.com/smartcontractkit/chainlink-aptos => ../chainlink-aptos diff --git a/go.sum b/go.sum index 9d30ca40..5ac806c6 100644 --- a/go.sum +++ b/go.sum @@ -114,8 +114,8 @@ github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/aptos-labs/aptos-go-sdk v1.12.1-0.20260318141106-21b6ef4ed363 h1:Gu6JOmSWQMYtWHKyBjxMkg1IqX+pI7BYD25Hog7knmU= -github.com/aptos-labs/aptos-go-sdk v1.12.1-0.20260318141106-21b6ef4ed363/go.mod h1:FTgKp0RLfEefllCdkCj0jPU14xWk11yA7SFVfCDLUj8= +github.com/aptos-labs/aptos-go-sdk v1.12.1 h1:EXtA9GF9fJndRcjWVZZ3Hf5hXxvGWNPu+1k3A6eGOfM= +github.com/aptos-labs/aptos-go-sdk v1.12.1/go.mod h1:FTgKp0RLfEefllCdkCj0jPU14xWk11yA7SFVfCDLUj8= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= @@ -370,8 +370,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= -github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/creachadair/jrpc2 v1.2.0 h1:SXr0OgnwM0X18P+HccJP0uT3KGSDk/BCSRlJBvE2bMY= github.com/creachadair/jrpc2 v1.2.0/go.mod h1:66uKSdr6tR5ZeNvkIjDSbbVUtOv0UhjS/vcd8ECP7Iw= github.com/creachadair/mds v0.13.4 h1:RgU0MhiVqkzp6/xtNWhK6Pw7tDeaVuGFtA0UA2RBYvY= @@ -461,8 +461,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn2 github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho= -github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/failsafe-go/failsafe-go v0.9.0 h1:w0g7iv48RpQvV3UH1VlgUnLx9frQfCwI7ljnJzqEhYg= @@ -554,6 +554,7 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -704,8 +705,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= -github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= -github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M= +github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/graph-gophers/dataloader v5.0.0+incompatible h1:R+yjsbrNq1Mo3aPG+Z/EKYrXrXXUNJHOgbRt+U6jOug= @@ -907,8 +908,8 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/karalabe/hid v1.0.1-0.20240306101548-573246063e52 h1:msKODTL1m0wigztaqILOtla9HeW1ciscYG4xjLtvk5I= -github.com/karalabe/hid v1.0.1-0.20240306101548-573246063e52/go.mod h1:qk1sX/IBgppQNcGCRoj90u6EGC056EBoIc1oEjCWla8= +github.com/karalabe/hid v1.0.1-0.20260315100226-f5d04adeffeb h1:Ag83At00qa4FLkcdMgrwHVSakqky/eZczOlxd4q336E= +github.com/karalabe/hid v1.0.1-0.20260315100226-f5d04adeffeb/go.mod h1:qk1sX/IBgppQNcGCRoj90u6EGC056EBoIc1oEjCWla8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= @@ -1127,8 +1128,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTK github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7 h1:oYW+YCJ1pachXTQmzR3rNLYGGz4g/UgFcjb28p/viDM= github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= @@ -1279,8 +1280,6 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY= github.com/smartcontractkit/chain-selectors v1.0.97 h1:ECOin+SkJv2MUrfqTUu28J0kub04Epds5NPMHERfGjo= github.com/smartcontractkit/chain-selectors v1.0.97/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= @@ -1295,8 +1294,8 @@ github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288/go.mod h1:67YbnoglYD61Pz/jTVCgav9wFq7S35OU8UyQSvPllRw= github.com/smartcontractkit/chainlink-ccv v0.0.0-20260324000441-d4cfddc9f7d2 h1:5HdH/A6yn8INZAltYDLb7UkUi5IKemhJzJkDW4Bgxyg= github.com/smartcontractkit/chainlink-ccv v0.0.0-20260324000441-d4cfddc9f7d2/go.mod h1:wDHq2E0KwUWG0lQ9f5frW1a7CKVW17MJLPuvKmtSRDg= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260403093224-b39dab3bfe2a h1:NkXze2bwcum7a/3ClrnYUzlR1fiAtMHClgim7c9Nr1I= -github.com/smartcontractkit/chainlink-common v0.11.2-0.20260403093224-b39dab3bfe2a/go.mod h1:YbAQANHk6latCHiCz8tgUNzuhtZkcvJicTSh5wBKidI= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260406055916-9aa6b6c0ae81 h1:qBQxh/dndRMJX41xWEihr8FkvPL21luWwTFn/0Nl3RU= +github.com/smartcontractkit/chainlink-common v0.11.2-0.20260406055916-9aa6b6c0ae81/go.mod h1:Ob7ZRLEvPkDwGUjKdDIiHy0Mxu4+UG6oMBkR7Jv/U6o= github.com/smartcontractkit/chainlink-common/keystore v1.0.2 h1:AWisx4JT3QV8tcgh6J5NCrex+wAgTYpWyHsyNPSXzsQ= github.com/smartcontractkit/chainlink-common/keystore v1.0.2/go.mod h1:rSkIHdomyak3YnUtXLenl6poIq8q0V3UZPiiyYqPdGA= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= @@ -1578,8 +1577,9 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 h1:06ZeJRe5BnYXceSM9Vya83XXVaNGe3H1QqsvqRANQq8= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2/go.mod h1:DvPtKE63knkDVP88qpatBj81JxN+w1bqfVbsbCbj1WY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 h1:tPLwQlXbJ8NSOfZc4OkgU5h2A38M4c9kfHSVc4PFQGs= @@ -1602,8 +1602,10 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0 h1:G8Xec/SgZQricwW go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY= go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE= @@ -1612,8 +1614,9 @@ go.opentelemetry.io/otel/sdk/log/logtest v0.13.0 h1:9yio6AFZ3QD9j9oqshV1Ibm9gPLl go.opentelemetry.io/otel/sdk/log/logtest v0.13.0/go.mod h1:QOGiAJHl+fob8Nu85ifXfuQYmJTFAvcrxL6w5/tu168= go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -1675,8 +1678,8 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1713,8 +1716,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1762,8 +1765,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1859,13 +1862,14 @@ golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1876,8 +1880,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1891,8 +1895,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1945,8 +1949,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 580b6940..4937a47d 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -17,8 +17,9 @@ import ( // sensitive information (not in configuration file) const ( - EthPrivateKeyEnvVar = "CRE_ETH_PRIVATE_KEY" - CreTargetEnvVar = "CRE_TARGET" + EthPrivateKeyEnvVar = "CRE_ETH_PRIVATE_KEY" + AptosPrivateKeyEnvVar = "CRE_APTOS_PRIVATE_KEY" + CreTargetEnvVar = "CRE_TARGET" ) // State tracked by LoadEnv / LoadPublicEnv so downstream code (e.g. build @@ -56,9 +57,10 @@ type Settings struct { // UserSettings stores user-specific configurations. type UserSettings struct { - TargetName string - EthPrivateKey string - EthUrl string + TargetName string + EthPrivateKey string + EthUrl string + AptosPrivateKey string } // New initializes and loads settings from YAML config files and the environment. @@ -104,10 +106,14 @@ func New(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Command, registryCha rawPrivKey := v.GetString(EthPrivateKeyEnvVar) normPrivKey := NormalizeHexKey(rawPrivKey) + rawAptosKey := v.GetString(AptosPrivateKeyEnvVar) + normAptosKey := NormalizeHexKey(rawAptosKey) + return &Settings{ User: UserSettings{ - EthPrivateKey: normPrivKey, - TargetName: target, + EthPrivateKey: normPrivKey, + AptosPrivateKey: normAptosKey, + TargetName: target, }, Workflow: workflowSettings, StorageSettings: storageSettings, @@ -163,7 +169,7 @@ func LoadEnv(logger *zerolog.Logger, v *viper.Viper, envPath string) { loadedEnvFilePath = "" loadedEnvVars = nil loadedEnvFilePath, loadedEnvVars = loadEnvFile(logger, envPath) - bindAllVars(v, loadedEnvVars, EthPrivateKeyEnvVar, CreTargetEnvVar) + bindAllVars(v, loadedEnvVars, EthPrivateKeyEnvVar, AptosPrivateKeyEnvVar, CreTargetEnvVar) } // LoadPublicEnv loads variables from envPath into the process environment diff --git a/internal/settings/settings_get.go b/internal/settings/settings_get.go index 3c778a51..fcd0b0f7 100644 --- a/internal/settings/settings_get.go +++ b/internal/settings/settings_get.go @@ -263,15 +263,22 @@ func ChainNameFromSelectorString(raw string) (string, error) { } func GetChainSelectorByChainName(name string) (uint64, error) { - chainID, err := chainSelectors.ChainIdFromName(name) - if err != nil { - return 0, fmt.Errorf("failed to get chain ID from name %q: %w", name, err) + if chainID, err := chainSelectors.ChainIdFromName(name); err == nil { + selector, err := chainSelectors.SelectorFromChainId(chainID) + if err != nil { + return 0, fmt.Errorf("failed to get selector from chain ID %d: %w", chainID, err) + } + return selector, nil } - selector, err := chainSelectors.SelectorFromChainId(chainID) - if err != nil { - return 0, fmt.Errorf("failed to get selector from chain ID %d: %w", chainID, err) + // Fallback to Aptos: chain-selectors has no AptosChainIdFromName, so scan. + for chainID := range chainSelectors.AptosChainIdToChainSelector() { + if n, err := chainSelectors.AptosNameFromChainId(chainID); err == nil && n == name { + sel, ok := chainSelectors.AptosChainIdToChainSelector()[chainID] + if ok { + return sel, nil + } + } } - - return selector, nil + return 0, fmt.Errorf("failed to get chain ID from name %q: chain not found", name) } diff --git a/test/aptos_cli_scenarios_test.go b/test/aptos_cli_scenarios_test.go new file mode 100644 index 00000000..6476b2bb --- /dev/null +++ b/test/aptos_cli_scenarios_test.go @@ -0,0 +1,172 @@ +package test + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cre-cli/internal/credentials" + "github.com/smartcontractkit/cre-cli/internal/testutil" +) + +// TestCLIAptosSimulator_30DryRuns invokes the real cre binary against the +// aptos_smoke fixture 30 times with different config JSON inputs. All runs are +// dry-run (no --broadcast). Each scenario asserts expected stdout substrings +// that prove FakeAptosChain routed the capability call correctly. +// +// Skipped by default (requires live Aptos testnet). Enable with: +// +// CRE_APTOS_CLI_E2E=1 go test -v ./test -run TestCLIAptosSimulator_30DryRuns +// +// The test builds: ./bin/cre, /tmp/aptos_smoke.wasm. +func TestCLIAptosSimulator_30DryRuns(t *testing.T) { + if os.Getenv("CRE_APTOS_CLI_E2E") != "1" { + t.Skip("set CRE_APTOS_CLI_E2E=1 to run CLI e2e scenarios against Aptos testnet") + } + InitLogging() + + repoRoot, err := os.Getwd() + require.NoError(t, err) + repoRoot = filepath.Dir(repoRoot) // test/ -> repo root + + cliBin := filepath.Join(repoRoot, "bin", "cre") + require.FileExists(t, cliBin, "./bin/cre not built; run `go build -o ./bin/cre .`") + + wasmPath := "/tmp/aptos_smoke.wasm" + require.FileExists(t, wasmPath, "WASM not built; run `cd test/test_project/aptos_smoke && GOOS=wasip1 GOARCH=wasm go build -o /tmp/aptos_smoke.wasm .`") + + projectDir := filepath.Join(repoRoot, "test", "test_project", "aptos_smoke") + + gql := testutil.NewGraphQLMockServerGetOrganization(t) + defer gql.Close() + t.Setenv(credentials.CreApiKeyVar, "test-api") + + validAddr := "0000000000000000000000000000000000000000000000000000000000000001" + unusedAddr := "0000000000000000000000000000000000000000000000000000000000000042" + + type sc struct { + name string + cfg map[string]any + expect string // substring that must appear in stdout/stderr + mayBeError bool // if true, errors from RPC are acceptable (still proves plumbing) + } + + base := func(scenario, addr string) map[string]any { + return map[string]any{ + "schedule": "@every 30s", + "chain_selector": uint64(743186221051783445), + "scenario": scenario, + "address_hex": addr, + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", + } + } + + // Expected substring is the workflow's return value (stable, flushed before + // simulator exit). User-log lines can be dropped if the log pipeline hasn't + // flushed before the sim terminates. + scenarios := []sc{ + // 1-10 balance + {"balance_addr1", base("balance", validAddr), "\"balance:", false}, + {"balance_addr2", base("balance", unusedAddr), "\"balance:", false}, + {"balance_zero", base("balance", "0000000000000000000000000000000000000000000000000000000000000000"), "balance:", true}, + {"balance_0x2", base("balance", "0000000000000000000000000000000000000000000000000000000000000002"), "\"balance:", false}, + {"balance_0x3", base("balance", "0000000000000000000000000000000000000000000000000000000000000003"), "\"balance:", false}, + {"balance_0x4", base("balance", "0000000000000000000000000000000000000000000000000000000000000004"), "\"balance:", false}, + {"balance_0x5", base("balance", "0000000000000000000000000000000000000000000000000000000000000005"), "\"balance:", false}, + {"balance_0x6", base("balance", "0000000000000000000000000000000000000000000000000000000000000006"), "\"balance:", false}, + {"balance_0x7", base("balance", "0000000000000000000000000000000000000000000000000000000000000007"), "\"balance:", false}, + {"balance_0xA", base("balance", "000000000000000000000000000000000000000000000000000000000000000a"), "\"balance:", false}, + + // 11-15 view + {"view_coin_1", base("view", validAddr), "\"view:", true}, + {"view_coin_2", base("view", unusedAddr), "\"view:", true}, + {"view_coin_3", base("view", "0000000000000000000000000000000000000000000000000000000000000002"), "\"view:", true}, + {"view_coin_4", base("view", "0000000000000000000000000000000000000000000000000000000000000003"), "\"view:", true}, + {"view_coin_5", base("view", "0000000000000000000000000000000000000000000000000000000000000004"), "\"view:", true}, + + // 16-20 tx-by-hash (nonexistent hashes return nil) + {"tx_missing_1", withHash(base("tx-by-hash", validAddr), "0x1111111111111111111111111111111111111111111111111111111111111111"), "\"tx-by-hash:", true}, + {"tx_missing_2", withHash(base("tx-by-hash", validAddr), "0x2222222222222222222222222222222222222222222222222222222222222222"), "\"tx-by-hash:", true}, + {"tx_missing_3", withHash(base("tx-by-hash", validAddr), "0x3333333333333333333333333333333333333333333333333333333333333333"), "\"tx-by-hash:", true}, + {"tx_missing_4", withHash(base("tx-by-hash", validAddr), "0x4444444444444444444444444444444444444444444444444444444444444444"), "\"tx-by-hash:", true}, + {"tx_missing_5", withHash(base("tx-by-hash", validAddr), "0x5555555555555555555555555555555555555555555555555555555555555555"), "\"tx-by-hash:", true}, + + // 21-25 account-transactions + {"acct_tx_1", base("account-transactions", validAddr), "\"account-transactions:", true}, + {"acct_tx_2", base("account-transactions", unusedAddr), "\"account-transactions:", true}, + {"acct_tx_3", base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000002"), "\"account-transactions:", true}, + {"acct_tx_4", base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000003"), "\"account-transactions:", true}, + {"acct_tx_5", base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000004"), "\"account-transactions:", true}, + + // 26-30 additional testnet variations + {"balance_0xB", base("balance", "000000000000000000000000000000000000000000000000000000000000000b"), "\"balance:", false}, + {"view_coin_6", base("view", "000000000000000000000000000000000000000000000000000000000000000c"), "\"view:", true}, + {"tx_missing_6", withHash(base("tx-by-hash", validAddr), "0x6666666666666666666666666666666666666666666666666666666666666666"), "\"tx-by-hash:", true}, + {"acct_tx_6", base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000d"), "\"account-transactions:", true}, + {"balance_0xC", base("balance", "000000000000000000000000000000000000000000000000000000000000000c"), "\"balance:", false}, + } + require.Len(t, scenarios, 30, "must have 30 CLI scenarios") + + for i, s := range scenarios { + i, s := i, s + t.Run(fmt.Sprintf("%02d_%s", i+1, s.name), func(t *testing.T) { + cfgPath := fmt.Sprintf("/tmp/apcfg_%02d.json", i+1) + data, err := json.Marshal(s.cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cfgPath, data, 0644)) + defer os.Remove(cfgPath) + + args := []string{ + "-T", "dev-aptos-testnet", + "-R", projectDir, + "workflow", "simulate", projectDir, + "--wasm", wasmPath, + "--config", cfgPath, + "--non-interactive", + "--trigger-index", "0", + "--limits", "none", + } + cmd := exec.Command(cliBin, args...) + cmd.Env = append(os.Environ(), + "CRE_API_KEY=test-api", + ) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err = cmd.Run() + combined := out.String() + t.Logf("cre output:\n%s", combined) + + // Every run must reach the simulator init + trigger dispatch + result. + require.Contains(t, combined, "Simulator Initialized", "scenario %q: simulator did not initialise", s.name) + require.Contains(t, combined, "Running trigger trigger=cron-trigger", "scenario %q: cron trigger did not fire", s.name) + require.Contains(t, combined, "Workflow Simulation Result:", "scenario %q: workflow did not return", s.name) + if s.mayBeError { + // Success substring OR an err: string that names the method + // (e.g. "err:...view function") — either proves routing reached + // the Aptos capability. + if strings.Contains(combined, s.expect) || strings.Contains(combined, "\"err:") { + return + } + } + require.Contains(t, combined, s.expect, "scenario %q missing expected substring", s.name) + }) + } +} + +func withHash(m map[string]any, h string) map[string]any { + m["tx_hash"] = h + return m +} + +func withSelector(m map[string]any, s uint64) map[string]any { + m["chain_selector"] = s + return m +} diff --git a/test/test_project/aptos_smoke/config.json b/test/test_project/aptos_smoke/config.json new file mode 100644 index 00000000..030ecff9 --- /dev/null +++ b/test/test_project/aptos_smoke/config.json @@ -0,0 +1,7 @@ +{ + "schedule": "@every 30s", + "chain_selector": 743186221051783445, + "scenario": "balance", + "address_hex": "0000000000000000000000000000000000000000000000000000000000000001", + "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/test/test_project/aptos_smoke/go.mod b/test/test_project/aptos_smoke/go.mod new file mode 100644 index 00000000..bc22d102 --- /dev/null +++ b/test/test_project/aptos_smoke/go.mod @@ -0,0 +1,20 @@ +module aptos_smoke + +go 1.25.3 + +require ( + github.com/smartcontractkit/cre-sdk-go v1.7.0 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260312152957-059f906b6597 // indirect + github.com/stretchr/testify v1.11.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/test/test_project/aptos_smoke/go.sum b/test/test_project/aptos_smoke/go.sum new file mode 100644 index 00000000..81229c39 --- /dev/null +++ b/test/test_project/aptos_smoke/go.sum @@ -0,0 +1,26 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260312152957-059f906b6597 h1:0k5sfKsr3rG2l3HS6o6b6BYg4PaamD6HZ9MUAxP+0Ik= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260312152957-059f906b6597/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/cre-sdk-go v1.7.0 h1:MtaJ4jXS/5RcRCrjoza52/g3c0qrGXGB3V5yO9l6tUA= +github.com/smartcontractkit/cre-sdk-go v1.7.0/go.mod h1:yYrQFz1UH7hhRbPO0q4fgo1tfsJNd4yXnI3oCZE0RzM= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37 h1:UNem52lhklNEp4VdPBYHN+p1wgG0vDYEKSvonQgV+3o= +github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37/go.mod h1:Ht8wSAAJRJgaZig5kwE5ogRJFngEmfQqBO0e+0y0Zng= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 h1:qBZ4y6qlTOynSpU1QAi2Fgr3tUZQ332b6hit9EVZqkk= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0/go.mod h1:Rzhy75vD3FqQo/SV6lypnxIwjWac6IOWzI5BYj3tYMU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/test_project/aptos_smoke/main.go b/test/test_project/aptos_smoke/main.go new file mode 100644 index 00000000..475a9db7 --- /dev/null +++ b/test/test_project/aptos_smoke/main.go @@ -0,0 +1,112 @@ +//go:build wasip1 + +package main + +import ( + "encoding/hex" + "fmt" + "log/slog" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos" + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" +) + +// Config drives which aptos capability method the handler exercises. +// +// Scenario values: +// +// balance - AccountAPTBalance +// view - View of coin::balance +// tx-by-hash - TransactionByHash (expect "not found" path) +// account-transactions - AccountTransactions pagination=1 +type Config struct { + Schedule string `json:"schedule"` + ChainSelector uint64 `json:"chain_selector"` + Scenario string `json:"scenario"` + AddressHex string `json:"address_hex"` // 32-byte hex, no 0x prefix + TxHash string `json:"tx_hash"` +} + +func InitWorkflow(cfg *Config, _ *slog.Logger, _ cre.SecretsProvider) (cre.Workflow[*Config], error) { + return cre.Workflow[*Config]{ + cre.Handler(cron.Trigger(&cron.Config{Schedule: cfg.Schedule}), runHandler), + }, nil +} + +func runHandler(cfg *Config, rt cre.Runtime, _ *cron.Payload) (string, error) { + log := rt.Logger() + client := &aptos.Client{ChainSelector: cfg.ChainSelector} + + addr, err := hex.DecodeString(cfg.AddressHex) + if err != nil { + return "", fmt.Errorf("bad address hex: %w", err) + } + + switch cfg.Scenario { + case "balance": + reply, err := client.AccountAPTBalance(rt, &aptos.AccountAPTBalanceRequest{Address: addr}).Await() + if err != nil { + log.Info("aptos-smoke: balance failed", "err", err.Error()) + return "err:" + err.Error(), nil + } + log.Info("aptos-smoke: balance", "octas", reply.Value) + return fmt.Sprintf("balance:%d", reply.Value), nil + + case "view": + payload := &aptos.ViewRequest{ + Payload: &aptos.ViewPayload{ + Module: &aptos.ModuleID{Address: aptosOneAddr(), Name: "coin"}, + Function: "balance", + ArgTypes: nil, + Args: [][]byte{addr}, + }, + } + reply, err := client.View(rt, payload).Await() + if err != nil { + log.Info("aptos-smoke: view failed", "err", err.Error()) + return "err:" + err.Error(), nil + } + log.Info("aptos-smoke: view", "bytes", len(reply.Data)) + return fmt.Sprintf("view:%d", len(reply.Data)), nil + + case "tx-by-hash": + reply, err := client.TransactionByHash(rt, &aptos.TransactionByHashRequest{Hash: cfg.TxHash}).Await() + if err != nil { + log.Info("aptos-smoke: tx-by-hash failed", "err", err.Error()) + return "err:" + err.Error(), nil + } + if reply.Transaction == nil { + log.Info("aptos-smoke: tx-by-hash missing") + return "tx-by-hash:nil", nil + } + log.Info("aptos-smoke: tx-by-hash", "hash", reply.Transaction.Hash) + return "tx-by-hash:" + reply.Transaction.Hash, nil + + case "account-transactions": + var one uint64 = 1 + reply, err := client.AccountTransactions(rt, &aptos.AccountTransactionsRequest{ + Address: addr, + Limit: &one, + }).Await() + if err != nil { + log.Info("aptos-smoke: account-transactions failed", "err", err.Error()) + return "err:" + err.Error(), nil + } + log.Info("aptos-smoke: account-transactions", "count", len(reply.Transactions)) + return fmt.Sprintf("account-transactions:%d", len(reply.Transactions)), nil + } + return "", fmt.Errorf("unknown scenario %q", cfg.Scenario) +} + +// aptosOneAddr returns the 32-byte address 0x01 as required by coin module. +func aptosOneAddr() []byte { + out := make([]byte, 32) + out[31] = 0x01 + return out +} + +func main() { + wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow) +} diff --git a/test/test_project/aptos_smoke/project.yaml b/test/test_project/aptos_smoke/project.yaml new file mode 100644 index 00000000..395fedb5 --- /dev/null +++ b/test/test_project/aptos_smoke/project.yaml @@ -0,0 +1,4 @@ +dev-aptos-testnet: + rpcs: + - chain-name: aptos-testnet + url: https://api.testnet.aptoslabs.com/v1 diff --git a/test/test_project/aptos_smoke/workflow.yaml b/test/test_project/aptos_smoke/workflow.yaml new file mode 100644 index 00000000..251a11e8 --- /dev/null +++ b/test/test_project/aptos_smoke/workflow.yaml @@ -0,0 +1,6 @@ +dev-aptos-testnet: + user-workflow: + workflow-name: "aptos-smoke" + workflow-artifacts: + workflow-path: "./main.go" + config-path: "./config.json" From 5aa7b15b0906753a2ddd7bacc080a0a7c9959289 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Tue, 21 Apr 2026 14:13:57 +0100 Subject: [PATCH 02/28] test(simulate/aptos): extend scenarios from 30 to 100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broadens TestSimulatorScenarios_100 to cover the full behavioural surface of the Aptos chain plugin + FakeAptosChain: - 31-45: AptosChainType surface (Name, SupportedChains, HasSelector, ParseTriggerChainSelector, ExecuteTrigger/ResolveTriggerData stubs, ResolveKey hex/0x/uppercase/whitespace/short-seed rejections). - 46-52: RegisterCapabilities rejects wrong client, key, and limits types; accepts unknown (experimental) selectors; skips selectors without forwarders; surfaces bad forwarder hex; interface assert. - 53-62: View TypeTag coverage — BOOL, U8, U16, U32, U64, U128, U256, ADDRESS round-trips; SIGNER and VECTOR rejected. - 63-72: read-path edges — all-zero/all-ones addresses, empty result, multi-return, integer return, SDK timeout, nil pagination, nil-entry drop, nil request validation. - 73-82: WriteReport broadcast paths — SUCCESS, VM failure, nil pending, forwarder error, WaitForTransaction error, nil-final fallback, multi-sig, empty sigs, 64KiB report, zero gas accepted. - 83-90: LimitedAptosChain — exact/over/under size and gas limits, zero-limit disables check, View/TransactionByHash delegation. - 91-100: lifecycle + info — ChainSelector, Description, Info ID format, Name embedding, Initialise/Register/Unregister/Execute no-ops, HealthReport shape, AptosChainCapabilities Start/Close, constructor nil guards. PLEX-2751 --- .../chain/aptos/simulator_scenarios_test.go | 668 +++++++++++++++++- 1 file changed, 664 insertions(+), 4 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go index ced2a689..8fb74523 100644 --- a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go +++ b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go @@ -9,13 +9,16 @@ package aptos import ( "context" "fmt" + "strings" "sync" "testing" "github.com/aptos-labs/aptos-go-sdk" "github.com/aptos-labs/aptos-go-sdk/api" "github.com/aptos-labs/aptos-go-sdk/crypto" + "github.com/rs/zerolog" chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -24,11 +27,14 @@ import ( caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" aptoscappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos" "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdk "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" + "github.com/smartcontractkit/chainlink/v2/core/capabilities" aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" mocks "github.com/smartcontractkit/chainlink-aptos/relayer/monitor/mocks" + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" "github.com/smartcontractkit/cre-cli/internal/settings" ) @@ -346,6 +352,612 @@ func simulatorScenarios() []simScenario { _, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{AptosPrivateKey: defaultSentinelAptosSeed}}, true) require.Error(t, err) }}, + // --- chain-type plugin surface (31-45) --- + {name: "31 ChainType.Name returns aptos", run: func(t *testing.T) { + ct := &AptosChainType{} + assert.Equal(t, "aptos", ct.Name()) + }}, + {name: "32 SupportedChains lists mainnet and testnet", run: func(t *testing.T) { + ct := &AptosChainType{} + cfgs := ct.SupportedChains() + selectors := map[uint64]bool{} + for _, c := range cfgs { + selectors[c.Selector] = true + } + assert.True(t, selectors[chainselectors.APTOS_MAINNET.Selector]) + assert.True(t, selectors[chainselectors.APTOS_TESTNET.Selector]) + }}, + {name: "33 HasSelector false when capabilities unset", run: func(t *testing.T) { + ct := &AptosChainType{} + assert.False(t, ct.HasSelector(chainselectors.APTOS_TESTNET.Selector)) + }}, + {name: "34 HasSelector false for evm-shaped selector", run: func(t *testing.T) { + ct := &AptosChainType{} + assert.False(t, ct.HasSelector(1)) + }}, + {name: "35 ParseTriggerChainSelector accepts aptos prefix", run: func(t *testing.T) { + ct := &AptosChainType{} + sel, ok := ct.ParseTriggerChainSelector("aptos:ChainSelector:4741433654826277614@1.0.0") + require.True(t, ok) + assert.Equal(t, uint64(4741433654826277614), sel) + }}, + {name: "36 ParseTriggerChainSelector rejects evm prefix", run: func(t *testing.T) { + ct := &AptosChainType{} + _, ok := ct.ParseTriggerChainSelector("evm:ChainSelector:1@1.0.0") + assert.False(t, ok) + }}, + {name: "37 ParseTriggerChainSelector rejects malformed id", run: func(t *testing.T) { + ct := &AptosChainType{} + _, ok := ct.ParseTriggerChainSelector("aptos:BadFormat") + assert.False(t, ok) + }}, + {name: "38 CollectCLIInputs returns empty map", run: func(t *testing.T) { + ct := &AptosChainType{} + got := ct.CollectCLIInputs(nil) + assert.Empty(t, got) + }}, + {name: "39 ExecuteTrigger returns explicit no-trigger error", run: func(t *testing.T) { + ct := &AptosChainType{} + err := ct.ExecuteTrigger(ctx, 1, "tid", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no trigger surface") + }}, + {name: "40 ResolveTriggerData returns no-trigger error", run: func(t *testing.T) { + ct := &AptosChainType{} + _, err := ct.ResolveTriggerData(ctx, 1, chain.TriggerParams{}) + require.Error(t, err) + }}, + {name: "41 ResolveClients with empty viper returns no clients", run: func(t *testing.T) { + ct := newAptosChainTypeForTest(t) + v := viper.New() + resolved, err := ct.ResolveClients(v) + require.NoError(t, err) + assert.Empty(t, resolved.Clients) + assert.Empty(t, resolved.Forwarders) + }}, + {name: "42 ResolveKey parses 0x-prefixed seed", run: func(t *testing.T) { + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "0x2222222222222222222222222222222222222222222222222222222222222222"}} + k, err := ct.ResolveKey(s, true) + require.NoError(t, err) + require.NotNil(t, k) + }}, + {name: "43 ResolveKey parses uppercase hex", run: func(t *testing.T) { + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"}} + k, err := ct.ResolveKey(s, true) + require.NoError(t, err) + require.NotNil(t, k) + }}, + {name: "44 ResolveKey trims whitespace", run: func(t *testing.T) { + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: " 1111111111111111111111111111111111111111111111111111111111111111 "}} + k, err := ct.ResolveKey(s, true) + require.NoError(t, err) + require.NotNil(t, k) + }}, + {name: "45 ResolveKey short seed hard-fails under broadcast", run: func(t *testing.T) { + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "0102"}} + _, err := ct.ResolveKey(s, true) + require.Error(t, err) + assert.Contains(t, err.Error(), "CRE_APTOS_PRIVATE_KEY") + }}, + + // --- wrong-type / wrong-selector rejections in RegisterCapabilities (46-52) --- + {name: "46 RegisterCapabilities rejects wrong client type", run: func(t *testing.T) { + ct := &AptosChainType{} + _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{1: "not-an-aptos-client"}, + Logger: logger.Test(t), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "not aptosfakes.AptosClient") + }}, + {name: "47 RegisterCapabilities rejects wrong private-key type", run: func(t *testing.T) { + ct := &AptosChainType{} + _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + PrivateKey: "this is not an Ed25519PrivateKey", + Logger: logger.Test(t), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "*crypto.Ed25519PrivateKey") + }}, + {name: "48 RegisterCapabilities rejects wrong limits type", run: func(t *testing.T) { + ct := &AptosChainType{} + _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + Limits: badLimits{}, + Logger: logger.Test(t), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "AptosChainLimits") + }}, + {name: "49 RegisterCapabilities with unknown selector (experimental) wires fake", run: func(t *testing.T) { + // 404040 is not in SupportedChains — still gets a FakeAptosChain because + // ResolveClients is the gatekeeper for selector-vs-supported, not Register. + pk := newKey(t) + rpc := mocks.NewAptosRpcClient(t) + ct := &AptosChainType{} + _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Registry: scenarioRegistry(t), + Clients: map[uint64]chain.ChainClient{404040: aptosfakes.AptosClient(rpc)}, + Forwarders: map[uint64]string{404040: "0xdead"}, + PrivateKey: pk, + Broadcast: false, + Logger: logger.Test(t), + }) + require.NoError(t, err) + assert.True(t, ct.HasSelector(404040)) + }}, + {name: "50 RegisterCapabilities skips selectors without forwarders", run: func(t *testing.T) { + pk := newKey(t) + rpc := mocks.NewAptosRpcClient(t) + ct := &AptosChainType{} + services, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Registry: scenarioRegistry(t), + Clients: map[uint64]chain.ChainClient{9999: aptosfakes.AptosClient(rpc)}, + Forwarders: map[uint64]string{}, + PrivateKey: pk, + Logger: logger.Test(t), + }) + require.NoError(t, err) + assert.Empty(t, services, "no forwarder → no capability wired") + assert.False(t, ct.HasSelector(9999)) + }}, + {name: "51 RegisterCapabilities propagates bad forwarder hex", run: func(t *testing.T) { + pk := newKey(t) + rpc := mocks.NewAptosRpcClient(t) + ct := &AptosChainType{} + _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ + Registry: scenarioRegistry(t), + Clients: map[uint64]chain.ChainClient{1: aptosfakes.AptosClient(rpc)}, + Forwarders: map[uint64]string{1: "not-hex-at-all"}, + PrivateKey: pk, + Logger: logger.Test(t), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "parse forwarder") + }}, + {name: "52 AptosChainType implements chain.ChainType", run: func(t *testing.T) { + var _ chain.ChainType = &AptosChainType{} + }}, + + // --- TypeTag coverage via View (53-62) --- + {name: "53 View BOOL TypeTag round-trips", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_BOOL) + }}, + {name: "54 View U8 TypeTag round-trips", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U8) + }}, + {name: "55 View U16 TypeTag round-trips (iter-10 extension)", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U16) + }}, + {name: "56 View U32 TypeTag round-trips (iter-10 extension)", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U32) + }}, + {name: "57 View U64 TypeTag round-trips", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U64) + }}, + {name: "58 View U128 TypeTag round-trips", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U128) + }}, + {name: "59 View U256 TypeTag round-trips (iter-10 extension)", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U256) + }}, + {name: "60 View ADDRESS TypeTag round-trips", run: func(t *testing.T) { + assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_ADDRESS) + }}, + {name: "61 View SIGNER TypeTag rejected (out of scope for view args)", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{ + Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, + Function: "f", + ArgTypes: []*aptoscappb.TypeTag{{Kind: aptoscappb.TypeTagKind_TYPE_TAG_KIND_SIGNER}}, + }, + }) + require.NotNil(t, capErr) + }}, + {name: "62 View VECTOR TypeTag rejected (deferred)", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{ + Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, + Function: "f", + ArgTypes: []*aptoscappb.TypeTag{{Kind: aptoscappb.TypeTagKind_TYPE_TAG_KIND_VECTOR}}, + }, + }) + require.NotNil(t, capErr) + }}, + + // --- more read-path edges (63-72) --- + {name: "63 AccountAPTBalance at all-zero address", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(0), nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: make([]byte, 32)}) + require.Nil(t, capErr) + assert.Equal(t, uint64(0), reply.Response.Value) + }}, + {name: "64 AccountAPTBalance at all-ones address", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(^uint64(0)), nil).Once() + fc := newChain(t, rpc, true, 1) + addr := make([]byte, 32) + for i := range addr { + addr[i] = 0xff + } + reply, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: addr}) + require.Nil(t, capErr) + assert.Equal(t, ^uint64(0), reply.Response.Value) + }}, + {name: "65 View with empty result returns empty Data", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().View(mock.Anything).Return([]any{}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, + }) + require.Nil(t, capErr) + assert.Empty(t, reply.Response.Data) + }}, + {name: "66 View keeps only result[0] when multi-return", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().View(mock.Anything).Return([]any{"first", "second"}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, + }) + require.Nil(t, capErr) + assert.Equal(t, []byte("first"), reply.Response.Data) + }}, + {name: "67 View integer return stringifies via %v", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().View(mock.Anything).Return([]any{int64(42)}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, + }) + require.Nil(t, capErr) + assert.Equal(t, []byte("42"), reply.Response.Data) + }}, + {name: "68 TransactionByHash SDK error without 404 → Unavailable", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().TransactionByHash(mock.Anything).Return(nil, fmt.Errorf("timeout")).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0xabc"}) + require.NotNil(t, capErr) + }}, + {name: "69 TransactionByHash nil request rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.TransactionByHash(ctx, meta, nil) + require.NotNil(t, capErr) + }}, + {name: "70 AccountTransactions with nil pagination forwards nil pointers", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountTransactions(mock.Anything, (*uint64)(nil), (*uint64)(nil)). + Return([]*api.CommittedTransaction{}, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: mkAddr(1)}) + require.Nil(t, capErr) + assert.Empty(t, reply.Response.Transactions) + }}, + {name: "71 AccountTransactions drops nil committed entries", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().AccountTransactions(mock.Anything, mock.Anything, mock.Anything). + Return([]*api.CommittedTransaction{ + nil, + {Type: api.TransactionVariantUser, Inner: &api.UserTransaction{Hash: "0x1"}}, + nil, + }, nil).Once() + fc := newChain(t, rpc, true, 1) + reply, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: mkAddr(1)}) + require.Nil(t, capErr) + assert.Len(t, reply.Response.Transactions, 1) + }}, + {name: "72 AccountTransactions nil request rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) + _, capErr := fc.AccountTransactions(ctx, meta, nil) + require.NotNil(t, capErr) + }}, + + // --- WriteReport broadcast branches (73-82) --- + {name: "73 WriteReport broadcast success populates TxHash + SUCCESS", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&api.PendingTransaction{Hash: "0xfeed"}, nil).Once() + rpc.EXPECT().WaitForTransaction("0xfeed").Return(&api.UserTransaction{ + Success: true, GasUsed: 10, GasUnitPrice: 1, + }, nil).Once() + fc := newChain(t, rpc, false, 1) + reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.Nil(t, capErr) + assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_SUCCESS, reply.Response.TxStatus) + require.NotNil(t, reply.Response.TxHash) + assert.Equal(t, "0xfeed", *reply.Response.TxHash) + }}, + {name: "74 WriteReport broadcast VM failure → FATAL+vmStatus", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&api.PendingTransaction{Hash: "0xbad"}, nil).Once() + rpc.EXPECT().WaitForTransaction("0xbad").Return(&api.UserTransaction{ + Success: false, VmStatus: "Move abort in 0xreceiver::module: X", GasUsed: 5, GasUnitPrice: 2, + }, nil).Once() + fc := newChain(t, rpc, false, 1) + reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.Nil(t, capErr) + assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) + require.NotNil(t, reply.Response.ErrorMessage) + assert.Contains(t, *reply.Response.ErrorMessage, "Move abort") + }}, + {name: "75 WriteReport broadcast nil pending tx → Internal err", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, nil).Once() + fc := newChain(t, rpc, false, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + {name: "76 WriteReport broadcast forwarder err surfaces Unavailable", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, fmt.Errorf("forwarder refused")).Once() + fc := newChain(t, rpc, false, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + {name: "77 WriteReport broadcast WaitForTransaction err surfaces Unavailable", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&api.PendingTransaction{Hash: "0xhold"}, nil).Once() + rpc.EXPECT().WaitForTransaction("0xhold").Return(nil, fmt.Errorf("timeout")).Once() + fc := newChain(t, rpc, false, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + {name: "78 WriteReport broadcast nil final tx → FATAL with hash", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&api.PendingTransaction{Hash: "0xabsent"}, nil).Once() + rpc.EXPECT().WaitForTransaction("0xabsent").Return(nil, nil).Once() + fc := newChain(t, rpc, false, 1) + reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), + }) + require.Nil(t, capErr) + assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) + require.NotNil(t, reply.Response.TxHash) + assert.Equal(t, "0xabsent", *reply.Response.TxHash) + }}, + {name: "79 WriteReport with multi-sig forwards each signature byte", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), + Report: &sdk.ReportResponse{ + RawReport: []byte("r"), + Sigs: []*sdk.AttributedSignature{ + {Signature: []byte{0x01, 0x02}}, + {Signature: []byte{0x03, 0x04}}, + }, + }, + }) + require.Nil(t, capErr) + }}, + {name: "80 WriteReport with empty sig slice is allowed", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), + Report: &sdk.ReportResponse{RawReport: []byte("r"), Sigs: nil}, + }) + require.Nil(t, capErr) + }}, + {name: "81 WriteReport with 64KiB raw report forwarded intact (dry-run)", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), + Report: &sdk.ReportResponse{RawReport: make([]byte, 64*1024)}, + }) + require.Nil(t, capErr) + }}, + {name: "82 WriteReport zero MaxGasAmount accepted (default applies)", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 0, GasUnitPrice: 0}, + Report: validReport(), + }) + require.Nil(t, capErr) + }}, + + // --- LimitedAptosChain edge cases (83-90) --- + {name: "83 LimitedAptosChain at exact report-size limit passes", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 10, maxGas: 10_000}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), + Report: &sdk.ReportResponse{RawReport: make([]byte, 10)}, + }) + require.Nil(t, capErr) + }}, + {name: "84 LimitedAptosChain at size+1 blocked", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 10, maxGas: 10_000}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), + Report: &sdk.ReportResponse{RawReport: make([]byte, 11)}, + }) + require.NotNil(t, capErr) + }}, + {name: "85 LimitedAptosChain at exact gas limit passes", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 100}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 100, GasUnitPrice: 1}, + Report: validReport(), + }) + require.Nil(t, capErr) + }}, + {name: "86 LimitedAptosChain at gas+1 blocked", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 100}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 101, GasUnitPrice: 1}, + Report: validReport(), + }) + require.NotNil(t, capErr) + }}, + {name: "87 LimitedAptosChain zero report-size limit disables size check", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 0, maxGas: 10_000}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), GasConfig: validGas(), + Report: &sdk.ReportResponse{RawReport: make([]byte, 999_999)}, + }) + require.Nil(t, capErr) + }}, + {name: "88 LimitedAptosChain zero gas limit disables gas check", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&aptos.RawTransaction{}, nil).Once() + rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). + Return([]*api.UserTransaction{{Success: true}}, nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 10_000, maxGas: 0}) + _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ + Receiver: mkAddr(0xBB), + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 999_999, GasUnitPrice: 1}, + Report: validReport(), + }) + require.Nil(t, capErr) + }}, + {name: "89 LimitedAptosChain View delegates to inner", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().View(mock.Anything).Return([]any{"x"}, nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1, maxGas: 1}) + reply, capErr := l.View(ctx, meta, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, + }) + require.Nil(t, capErr) + assert.Equal(t, []byte("x"), reply.Response.Data) + }}, + {name: "90 LimitedAptosChain TransactionByHash delegates to inner", run: func(t *testing.T) { + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().TransactionByHash("0xA").Return(nil, nil).Once() + fc := newChain(t, rpc, true, 1) + l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1, maxGas: 1}) + reply, capErr := l.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0xA"}) + require.Nil(t, capErr) + assert.Nil(t, reply.Response.Transaction) + }}, + + // --- lifecycle + info (91-100) --- + {name: "91 FakeAptosChain ChainSelector reflects constructor arg", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 4741433654826277352) + assert.Equal(t, uint64(4741433654826277352), fc.ChainSelector()) + }}, + {name: "92 FakeAptosChain Description non-empty", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) + assert.NotEmpty(t, fc.Description()) + }}, + {name: "93 FakeAptosChain Info ID includes selector", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 42) + info, err := fc.Info(ctx) + require.NoError(t, err) + assert.Contains(t, info.ID, "42") + assert.Contains(t, info.ID, "aptos") + }}, + {name: "94 FakeAptosChain Name embeds selector", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 7) + assert.True(t, strings.Contains(fc.Name(), "7"), "Name=%s should contain selector", fc.Name()) + }}, + {name: "95 FakeAptosChain Initialise is no-op", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) + assert.NoError(t, fc.Initialise(ctx, core.StandardCapabilitiesDependencies{})) + }}, + {name: "96 FakeAptosChain Register+Unregister workflow are no-ops", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) + require.NoError(t, fc.RegisterToWorkflow(ctx, commonCap.RegisterToWorkflowRequest{Metadata: commonCap.RegistrationMetadata{WorkflowID: "w"}})) + require.NoError(t, fc.UnregisterFromWorkflow(ctx, commonCap.UnregisterFromWorkflowRequest{Metadata: commonCap.RegistrationMetadata{WorkflowID: "w"}})) + }}, + {name: "97 FakeAptosChain Execute returns empty response", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) + resp, err := fc.Execute(ctx, commonCap.CapabilityRequest{}) + require.NoError(t, err) + assert.Equal(t, commonCap.CapabilityResponse{}, resp) + }}, + {name: "98 FakeAptosChain HealthReport single entry, no error", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) + require.NoError(t, fc.Start(ctx)) + hr := fc.HealthReport() + require.Len(t, hr, 1) + assert.NoError(t, hr[fc.Name()]) + assert.NoError(t, fc.Close()) + }}, + {name: "99 AptosChainCapabilities Start+Close are idempotent no-ops", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) + caps := &AptosChainCapabilities{AptosChains: map[uint64]*aptosfakes.FakeAptosChain{1: fc}} + require.NoError(t, caps.Start(ctx)) + require.NoError(t, caps.Close()) + }}, + {name: "100 FakeAptosChain construction fails on nil client or key", run: func(t *testing.T) { + _, err := aptosfakes.NewFakeAptosChain(logger.Test(t), nil, newKey(t), testAddr(t, "0xdead"), 1, false) + require.Error(t, err) + _, err = aptosfakes.NewFakeAptosChain(logger.Test(t), mocks.NewAptosRpcClient(t), nil, testAddr(t, "0xdead"), 1, false) + require.Error(t, err) + }}, + {name: "30 Concurrent reads + writes are race-clean", run: func(t *testing.T) { rpc := mocks.NewAptosRpcClient(t) rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(1), nil) @@ -383,12 +995,15 @@ func simulatorScenarios() []simScenario { } } -// TestSimulatorScenarios_30 runs 30 dry-run scenarios and reports pass/fail per -// scenario. Verifies parity with FakeEVMChain's behavioural surface. -func TestSimulatorScenarios_30(t *testing.T) { +// TestSimulatorScenarios_100 runs 100 dry-run scenarios exercising the full +// behavioural surface of FakeAptosChain + the Aptos chain-type plugin: +// read-path happy/error paths, WriteReport broadcast+dry-run, LimitedAptosChain +// size/gas enforcement, TypeTag scalar coverage, chaintype registration edges, +// and lifecycle/Info contracts. +func TestSimulatorScenarios_100(t *testing.T) { t.Parallel() cases := simulatorScenarios() - require.Len(t, cases, 30, "must have exactly 30 simulator scenarios") + require.Len(t, cases, 100, "must have exactly 100 simulator scenarios") for _, c := range cases { c := c t.Run(c.name, func(t *testing.T) { @@ -397,3 +1012,48 @@ func TestSimulatorScenarios_30(t *testing.T) { }) } } + +// --- scenario helpers (kept in this file to avoid leaking to prod builds) --- + +// assertTypeTagRoundTrip wires a minimal View against a mock and asserts +// that the given TypeTag kind is accepted by viewPayloadFromProto + +// typeTagFromProto. A reject manifests as a PublicUserError. +func assertTypeTagRoundTrip(t *testing.T, kind aptoscappb.TypeTagKind) { + t.Helper() + rpc := mocks.NewAptosRpcClient(t) + rpc.EXPECT().View(mock.Anything).Return([]any{"ok"}, nil).Once() + fc, err := aptosfakes.NewFakeAptosChain(logger.Test(t), rpc, newKey(t), + testAddr(t, "0xdead"), 1, true) + require.NoError(t, err) + _, capErr := fc.View(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.ViewRequest{ + Payload: &aptoscappb.ViewPayload{ + Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, + Function: "f", + ArgTypes: []*aptoscappb.TypeTag{{Kind: kind}}, + }, + }) + require.Nil(t, capErr, "kind %v should be accepted", kind) +} + +// badLimits satisfies chain.Limits but not AptosChainLimits, to exercise +// RegisterCapabilities' type-assertion rejection. +type badLimits struct{} + +func (badLimits) ChainWriteReportSizeLimit() int { return 0 } + +// scenarioRegistry returns a capability registry usable in RegisterCapabilities +// scenarios. Matches the EVM sibling's newRegistry helper. +func scenarioRegistry(t *testing.T) *capabilities.Registry { + t.Helper() + return capabilities.NewRegistry(logger.Test(t)) +} + +// newAptosChainTypeForTest returns a zero-value AptosChainType — its log +// field is only read by scenarios that hit ResolveClients/RegisterCapabilities +// when RPCs are configured, and scenarios pass empty viper so the nil log +// never dereferences. +func newAptosChainTypeForTest(t *testing.T) *AptosChainType { + t.Helper() + zl := zerolog.Nop() + return &AptosChainType{log: &zl} +} From 642e8f004badcf9cc8dfdbd1878201d778d91e27 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Tue, 21 Apr 2026 14:29:02 +0100 Subject: [PATCH 03/28] build(deps): pin chainlink-aptos to PLEX-2751 PR head Drops the local `replace ../chainlink-aptos` directive (breaks CI sibling-dir lookup) and pins chainlink-aptos to the PLEX-2751 feature-branch pseudo-version v0.0.0-20260421125752-47d9d126c005 so unit, tidy, gendoc, and e2e jobs can resolve the `fakes` import. Will be re-pinned to the merged SHA once smartcontractkit/chainlink-aptos#442 lands on develop. PLEX-2751 --- go.mod | 6 ++---- go.sum | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index ace04f3f..1b897a14 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.5.0 github.com/Masterminds/semver/v3 v3.4.0 github.com/andybalholm/brotli v1.2.0 + github.com/aptos-labs/aptos-go-sdk v1.12.1 github.com/avast/retry-go/v4 v4.7.0 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.6 @@ -24,6 +25,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.34.0 github.com/smartcontractkit/chain-selectors v1.0.97 + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260421125752-47d9d126c005 github.com/smartcontractkit/chainlink-common v0.11.2-0.20260406055916-9aa6b6c0ae81 github.com/smartcontractkit/chainlink-common/keystore v1.0.2 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260119171452-39c98c3b33cd @@ -72,7 +74,6 @@ require ( github.com/VictoriaMetrics/fastcache v1.13.0 // indirect github.com/XSAM/otelsql v0.37.0 // indirect github.com/apache/arrow-go/v18 v18.3.1 // indirect - github.com/aptos-labs/aptos-go-sdk v1.12.1 // indirect github.com/atombender/go-jsonschema v0.16.1-0.20240916205339-a74cd4e2851c // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect @@ -307,7 +308,6 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sigurn/crc16 v0.0.0-20211026045750-20ab5afb07e3 // indirect github.com/smartcontractkit/ccip-owner-contracts v0.1.0 // indirect - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 // indirect github.com/smartcontractkit/chainlink-automation v0.8.1 // indirect github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 // indirect github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect @@ -428,5 +428,3 @@ require ( replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alpha.regen.1 replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20251014143056-a0c6328c91e9 - -replace github.com/smartcontractkit/chainlink-aptos => ../chainlink-aptos diff --git a/go.sum b/go.sum index 5ac806c6..7e0d0898 100644 --- a/go.sum +++ b/go.sum @@ -1280,6 +1280,8 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY= github.com/smartcontractkit/chain-selectors v1.0.97 h1:ECOin+SkJv2MUrfqTUu28J0kub04Epds5NPMHERfGjo= github.com/smartcontractkit/chain-selectors v1.0.97/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260421125752-47d9d126c005 h1:zLp+gMvydLDixg9yh5Aaprrqn5e9kevioz/VrviwOD4= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260421125752-47d9d126c005/go.mod h1:ZU57FhGIb+m20yysn2fw+vLh3qB5hcgd06RXEUEDBck= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= From 9a529a2ce6535c0e353aa54e0f7436ff94c33884 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Tue, 21 Apr 2026 14:34:30 +0100 Subject: [PATCH 04/28] test(cli): extend aptos CLI scenarios from 30 to 100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broadens the e2e cre-binary suite (gated by CRE_APTOS_CLI_E2E=1) with 70 additional scenarios covering: - 31-40: balance fan-out across address shapes (high bit, low bit, max_u256, fan-out lanes). - 41-50: view coin::balance edges (all-zero, all-0x01, all-0xff, canonical aligned-byte addresses). - 51-60: tx-by-hash randomised nonexistent hashes (routing proof). - 61-70: account-transactions fan-out. - 71-80: wrong selector / experimental-chain rejection (EVM mainnet, Solana, 0, 1, max uint64, Aptos mainnet without wiring, experimental 99999999 for each read op, baseline testnet selector unchanged). - 81-90: UI + flag variations (--limits none/default, --help global and workflow simulate scoped, missing wasm/config error messages, invalid --trigger-index, empty CRE_TARGET env). - 91-100: broadcast + key edge cases (--broadcast with sentinel / unparseable / short-hex keys — all must hard-fail with CRE_APTOS_PRIVATE_KEY in the error message, dry-run sentinel warning, dry-run with valid key success, follow-up routing regression cases). Runner rewritten to handle three scenario shapes: - standard simulator dry-run (config + args), - help/no-config invocations (skip simulator markers), - mustFail scenarios (process exit non-zero, expect substring in combined output). Scenario padding widened to %03d to fit 100 cases. PLEX-2751 --- test/aptos_cli_scenarios_test.go | 258 +++++++++++++++++++++++-------- 1 file changed, 192 insertions(+), 66 deletions(-) diff --git a/test/aptos_cli_scenarios_test.go b/test/aptos_cli_scenarios_test.go index 6476b2bb..d1025827 100644 --- a/test/aptos_cli_scenarios_test.go +++ b/test/aptos_cli_scenarios_test.go @@ -16,17 +16,18 @@ import ( "github.com/smartcontractkit/cre-cli/internal/testutil" ) -// TestCLIAptosSimulator_30DryRuns invokes the real cre binary against the -// aptos_smoke fixture 30 times with different config JSON inputs. All runs are -// dry-run (no --broadcast). Each scenario asserts expected stdout substrings +// TestCLIAptosSimulator_100DryRuns invokes the real cre binary against the +// aptos_smoke fixture 100 times with different config JSON inputs. All runs +// default to dry-run; a final block of scenarios exercises --broadcast error +// paths and UI/limits edges. Each scenario asserts expected stdout substrings // that prove FakeAptosChain routed the capability call correctly. // // Skipped by default (requires live Aptos testnet). Enable with: // -// CRE_APTOS_CLI_E2E=1 go test -v ./test -run TestCLIAptosSimulator_30DryRuns +// CRE_APTOS_CLI_E2E=1 go test -v ./test -run TestCLIAptosSimulator_100DryRuns // -// The test builds: ./bin/cre, /tmp/aptos_smoke.wasm. -func TestCLIAptosSimulator_30DryRuns(t *testing.T) { +// The test expects: ./bin/cre, /tmp/aptos_smoke.wasm. +func TestCLIAptosSimulator_100DryRuns(t *testing.T) { if os.Getenv("CRE_APTOS_CLI_E2E") != "1" { t.Skip("set CRE_APTOS_CLI_E2E=1 to run CLI e2e scenarios against Aptos testnet") } @@ -54,8 +55,11 @@ func TestCLIAptosSimulator_30DryRuns(t *testing.T) { type sc struct { name string cfg map[string]any - expect string // substring that must appear in stdout/stderr - mayBeError bool // if true, errors from RPC are acceptable (still proves plumbing) + expect string // substring that must appear in stdout/stderr + mayBeError bool // if true, errors from RPC are acceptable (still proves plumbing) + args []string // extra CLI args appended (nil = standard dry-run) + env []string // extra env vars (e.g. sentinel key override) + mustFail bool // process exit must be non-zero; expect substring then checked in stderr/stdout } base := func(scenario, addr string) map[string]any { @@ -71,80 +75,202 @@ func TestCLIAptosSimulator_30DryRuns(t *testing.T) { // Expected substring is the workflow's return value (stable, flushed before // simulator exit). User-log lines can be dropped if the log pipeline hasn't // flushed before the sim terminates. + aptosTestnetSel := uint64(743186221051783445) + _ = aptosTestnetSel // only referenced in wrong_sel_testnet_unchanged below scenarios := []sc{ - // 1-10 balance - {"balance_addr1", base("balance", validAddr), "\"balance:", false}, - {"balance_addr2", base("balance", unusedAddr), "\"balance:", false}, - {"balance_zero", base("balance", "0000000000000000000000000000000000000000000000000000000000000000"), "balance:", true}, - {"balance_0x2", base("balance", "0000000000000000000000000000000000000000000000000000000000000002"), "\"balance:", false}, - {"balance_0x3", base("balance", "0000000000000000000000000000000000000000000000000000000000000003"), "\"balance:", false}, - {"balance_0x4", base("balance", "0000000000000000000000000000000000000000000000000000000000000004"), "\"balance:", false}, - {"balance_0x5", base("balance", "0000000000000000000000000000000000000000000000000000000000000005"), "\"balance:", false}, - {"balance_0x6", base("balance", "0000000000000000000000000000000000000000000000000000000000000006"), "\"balance:", false}, - {"balance_0x7", base("balance", "0000000000000000000000000000000000000000000000000000000000000007"), "\"balance:", false}, - {"balance_0xA", base("balance", "000000000000000000000000000000000000000000000000000000000000000a"), "\"balance:", false}, - - // 11-15 view - {"view_coin_1", base("view", validAddr), "\"view:", true}, - {"view_coin_2", base("view", unusedAddr), "\"view:", true}, - {"view_coin_3", base("view", "0000000000000000000000000000000000000000000000000000000000000002"), "\"view:", true}, - {"view_coin_4", base("view", "0000000000000000000000000000000000000000000000000000000000000003"), "\"view:", true}, - {"view_coin_5", base("view", "0000000000000000000000000000000000000000000000000000000000000004"), "\"view:", true}, - - // 16-20 tx-by-hash (nonexistent hashes return nil) - {"tx_missing_1", withHash(base("tx-by-hash", validAddr), "0x1111111111111111111111111111111111111111111111111111111111111111"), "\"tx-by-hash:", true}, - {"tx_missing_2", withHash(base("tx-by-hash", validAddr), "0x2222222222222222222222222222222222222222222222222222222222222222"), "\"tx-by-hash:", true}, - {"tx_missing_3", withHash(base("tx-by-hash", validAddr), "0x3333333333333333333333333333333333333333333333333333333333333333"), "\"tx-by-hash:", true}, - {"tx_missing_4", withHash(base("tx-by-hash", validAddr), "0x4444444444444444444444444444444444444444444444444444444444444444"), "\"tx-by-hash:", true}, - {"tx_missing_5", withHash(base("tx-by-hash", validAddr), "0x5555555555555555555555555555555555555555555555555555555555555555"), "\"tx-by-hash:", true}, - - // 21-25 account-transactions - {"acct_tx_1", base("account-transactions", validAddr), "\"account-transactions:", true}, - {"acct_tx_2", base("account-transactions", unusedAddr), "\"account-transactions:", true}, - {"acct_tx_3", base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000002"), "\"account-transactions:", true}, - {"acct_tx_4", base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000003"), "\"account-transactions:", true}, - {"acct_tx_5", base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000004"), "\"account-transactions:", true}, - - // 26-30 additional testnet variations - {"balance_0xB", base("balance", "000000000000000000000000000000000000000000000000000000000000000b"), "\"balance:", false}, - {"view_coin_6", base("view", "000000000000000000000000000000000000000000000000000000000000000c"), "\"view:", true}, - {"tx_missing_6", withHash(base("tx-by-hash", validAddr), "0x6666666666666666666666666666666666666666666666666666666666666666"), "\"tx-by-hash:", true}, - {"acct_tx_6", base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000d"), "\"account-transactions:", true}, - {"balance_0xC", base("balance", "000000000000000000000000000000000000000000000000000000000000000c"), "\"balance:", false}, + // --- 1-10 balance (happy-path address variations) --- + {name: "balance_addr1", cfg: base("balance", validAddr), expect: "\"balance:"}, + {name: "balance_addr2", cfg: base("balance", unusedAddr), expect: "\"balance:"}, + {name: "balance_zero", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000000"), expect: "balance:", mayBeError: true}, + {name: "balance_0x2", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000002"), expect: "\"balance:"}, + {name: "balance_0x3", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000003"), expect: "\"balance:"}, + {name: "balance_0x4", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000004"), expect: "\"balance:"}, + {name: "balance_0x5", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000005"), expect: "\"balance:"}, + {name: "balance_0x6", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000006"), expect: "\"balance:"}, + {name: "balance_0x7", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000007"), expect: "\"balance:"}, + {name: "balance_0xA", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000a"), expect: "\"balance:"}, + + // --- 11-15 view --- + {name: "view_coin_1", cfg: base("view", validAddr), expect: "\"view:", mayBeError: true}, + {name: "view_coin_2", cfg: base("view", unusedAddr), expect: "\"view:", mayBeError: true}, + {name: "view_coin_3", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000002"), expect: "\"view:", mayBeError: true}, + {name: "view_coin_4", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000003"), expect: "\"view:", mayBeError: true}, + {name: "view_coin_5", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000004"), expect: "\"view:", mayBeError: true}, + + // --- 16-20 tx-by-hash (nonexistent hashes → nil) --- + {name: "tx_missing_1", cfg: withHash(base("tx-by-hash", validAddr), "0x1111111111111111111111111111111111111111111111111111111111111111"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_missing_2", cfg: withHash(base("tx-by-hash", validAddr), "0x2222222222222222222222222222222222222222222222222222222222222222"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_missing_3", cfg: withHash(base("tx-by-hash", validAddr), "0x3333333333333333333333333333333333333333333333333333333333333333"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_missing_4", cfg: withHash(base("tx-by-hash", validAddr), "0x4444444444444444444444444444444444444444444444444444444444444444"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_missing_5", cfg: withHash(base("tx-by-hash", validAddr), "0x5555555555555555555555555555555555555555555555555555555555555555"), expect: "\"tx-by-hash:", mayBeError: true}, + + // --- 21-25 account-transactions --- + {name: "acct_tx_1", cfg: base("account-transactions", validAddr), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_2", cfg: base("account-transactions", unusedAddr), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_3", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000002"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_4", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000003"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_5", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000004"), expect: "\"account-transactions:", mayBeError: true}, + + // --- 26-30 additional testnet variations --- + {name: "balance_0xB", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000b"), expect: "\"balance:"}, + {name: "view_coin_6", cfg: base("view", "000000000000000000000000000000000000000000000000000000000000000c"), expect: "\"view:", mayBeError: true}, + {name: "tx_missing_6", cfg: withHash(base("tx-by-hash", validAddr), "0x6666666666666666666666666666666666666666666666666666666666666666"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "acct_tx_6", cfg: base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000d"), expect: "\"account-transactions:", mayBeError: true}, + {name: "balance_0xC", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000c"), expect: "\"balance:"}, + + // --- 31-40 more balance permutations (deterministic routing proof) --- + {name: "balance_0xD", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000d"), expect: "\"balance:"}, + {name: "balance_0xE", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000e"), expect: "\"balance:"}, + {name: "balance_0xF", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000f"), expect: "\"balance:"}, + {name: "balance_high_bit", cfg: base("balance", "8000000000000000000000000000000000000000000000000000000000000000"), expect: "\"balance:"}, + {name: "balance_low_bit", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000001"), expect: "\"balance:"}, + {name: "balance_fan_out_1", cfg: base("balance", "1111111111111111111111111111111111111111111111111111111111111111"), expect: "\"balance:"}, + {name: "balance_fan_out_2", cfg: base("balance", "2222222222222222222222222222222222222222222222222222222222222222"), expect: "\"balance:"}, + {name: "balance_fan_out_3", cfg: base("balance", "3333333333333333333333333333333333333333333333333333333333333333"), expect: "\"balance:"}, + {name: "balance_fan_out_4", cfg: base("balance", "4444444444444444444444444444444444444444444444444444444444444444"), expect: "\"balance:"}, + {name: "balance_max_u256", cfg: base("balance", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), expect: "balance:", mayBeError: true}, + + // --- 41-50 view coin::balance edges --- + {name: "view_all_zero", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000000"), expect: "view:", mayBeError: true}, + {name: "view_all_one", cfg: base("view", "0101010101010101010101010101010101010101010101010101010101010101"), expect: "view:", mayBeError: true}, + {name: "view_all_f", cfg: base("view", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), expect: "view:", mayBeError: true}, + {name: "view_canonical_1", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000010"), expect: "\"view:", mayBeError: true}, + {name: "view_canonical_2", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000020"), expect: "\"view:", mayBeError: true}, + {name: "view_canonical_3", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000030"), expect: "\"view:", mayBeError: true}, + {name: "view_canonical_4", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000040"), expect: "\"view:", mayBeError: true}, + {name: "view_canonical_5", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000050"), expect: "\"view:", mayBeError: true}, + {name: "view_canonical_6", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000060"), expect: "\"view:", mayBeError: true}, + {name: "view_canonical_7", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000070"), expect: "\"view:", mayBeError: true}, + + // --- 51-60 tx-by-hash randomised nonexistent hashes --- + {name: "tx_rand_1", cfg: withHash(base("tx-by-hash", validAddr), "0x7777777777777777777777777777777777777777777777777777777777777777"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_2", cfg: withHash(base("tx-by-hash", validAddr), "0x8888888888888888888888888888888888888888888888888888888888888888"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_3", cfg: withHash(base("tx-by-hash", validAddr), "0x9999999999999999999999999999999999999999999999999999999999999999"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_4", cfg: withHash(base("tx-by-hash", validAddr), "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_5", cfg: withHash(base("tx-by-hash", validAddr), "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_6", cfg: withHash(base("tx-by-hash", validAddr), "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_7", cfg: withHash(base("tx-by-hash", validAddr), "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_8", cfg: withHash(base("tx-by-hash", validAddr), "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_9", cfg: withHash(base("tx-by-hash", validAddr), "0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "tx_rand_10", cfg: withHash(base("tx-by-hash", validAddr), "0xdeadbeefcafebabefacefeeddeadbabedeadbeefcafebabefacefeeddeadbabe"), expect: "\"tx-by-hash:", mayBeError: true}, + + // --- 61-70 account-transactions fan-out --- + {name: "acct_tx_7", cfg: base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000e"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_8", cfg: base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000f"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_9", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000010"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_10", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000020"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_11", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000030"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_12", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000040"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_13", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000050"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_14", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000060"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_15", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000070"), expect: "\"account-transactions:", mayBeError: true}, + {name: "acct_tx_16", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000080"), expect: "\"account-transactions:", mayBeError: true}, + + // --- 71-80 wrong selector / experimental chain rejection --- + // Any selector not in SupportedChains that also isn't wired via + // experimental-chains should surface a configuration error before the + // simulator dispatches to a capability. + {name: "wrong_sel_evm_mainnet", cfg: withSelector(base("balance", validAddr), 5009297550715157269), expect: "", mayBeError: true}, + {name: "wrong_sel_solana", cfg: withSelector(base("balance", validAddr), 124615329519749607), expect: "", mayBeError: true}, + {name: "wrong_sel_zero", cfg: withSelector(base("balance", validAddr), 0), expect: "", mayBeError: true}, + {name: "wrong_sel_one", cfg: withSelector(base("balance", validAddr), 1), expect: "", mayBeError: true}, + {name: "wrong_sel_large", cfg: withSelector(base("balance", validAddr), ^uint64(0)), expect: "", mayBeError: true}, + {name: "wrong_sel_aptos_mainnet_unwired", cfg: withSelector(base("balance", validAddr), 4741433654826277614), expect: "", mayBeError: true}, + {name: "wrong_sel_view_experimental", cfg: withSelector(base("view", validAddr), 99999999), expect: "", mayBeError: true}, + {name: "wrong_sel_tx_experimental", cfg: withSelector(withHash(base("tx-by-hash", validAddr), "0x1"), 99999999), expect: "", mayBeError: true}, + {name: "wrong_sel_acct_experimental", cfg: withSelector(base("account-transactions", validAddr), 99999999), expect: "", mayBeError: true}, + {name: "wrong_sel_testnet_unchanged", cfg: withSelector(base("balance", validAddr), aptosTestnetSel), expect: "\"balance:"}, + + // --- 81-90 UI / limits flag variations --- + {name: "limits_none", cfg: base("balance", validAddr), expect: "\"balance:", args: []string{"--limits", "none"}}, + {name: "limits_default", cfg: base("balance", validAddr), expect: "\"balance:"}, + {name: "non_interactive", cfg: base("balance", validAddr), expect: "\"balance:"}, + {name: "trigger_index_0", cfg: base("balance", validAddr), expect: "\"balance:", args: []string{"--trigger-index", "0"}}, + {name: "trigger_index_invalid", cfg: base("balance", validAddr), expect: "trigger", mustFail: true, args: []string{"--trigger-index", "99"}}, + {name: "help_global", cfg: nil, expect: "cre", args: []string{"--help"}}, + {name: "workflow_simulate_help", cfg: nil, expect: "simulate", args: []string{"workflow", "simulate", "--help"}}, + {name: "missing_wasm", cfg: base("balance", validAddr), expect: "wasm", mustFail: true, args: []string{"--wasm", "/tmp/does-not-exist.wasm"}}, + {name: "missing_config", cfg: nil, expect: "config", mustFail: true, args: []string{"--config", "/tmp/does-not-exist.json"}}, + {name: "empty_target", cfg: base("balance", validAddr), expect: "target", mustFail: true, env: []string{"CRE_TARGET="}}, + + // --- 91-100 broadcast + key edge cases (all must FAIL under dry-run + // binary without a real key/network path) --- + {name: "broadcast_sentinel_key_rejected", cfg: base("balance", validAddr), expect: "sentinel", mustFail: true, + args: []string{"--broadcast"}, + env: []string{"CRE_APTOS_PRIVATE_KEY=0000000000000000000000000000000000000000000000000000000000000001"}}, + {name: "broadcast_unparseable_key_rejected", cfg: base("balance", validAddr), expect: "CRE_APTOS_PRIVATE_KEY", mustFail: true, + args: []string{"--broadcast"}, + env: []string{"CRE_APTOS_PRIVATE_KEY=not-hex"}}, + {name: "broadcast_short_key_rejected", cfg: base("balance", validAddr), expect: "CRE_APTOS_PRIVATE_KEY", mustFail: true, + args: []string{"--broadcast"}, + env: []string{"CRE_APTOS_PRIVATE_KEY=0102"}}, + {name: "dryrun_sentinel_key_warns", cfg: base("balance", validAddr), expect: "default Aptos private key", + env: []string{"CRE_APTOS_PRIVATE_KEY="}}, + {name: "dryrun_valid_key_no_warning", cfg: base("balance", validAddr), expect: "\"balance:", + env: []string{"CRE_APTOS_PRIVATE_KEY=1111111111111111111111111111111111111111111111111111111111111111"}}, + {name: "balance_followup_1", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000101"), expect: "\"balance:"}, + {name: "balance_followup_2", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000202"), expect: "\"balance:"}, + {name: "view_followup", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000303"), expect: "view:", mayBeError: true}, + {name: "tx_followup", cfg: withHash(base("tx-by-hash", validAddr), "0x00000000000000000000000000000000000000000000000000000000000000ff"), expect: "\"tx-by-hash:", mayBeError: true}, + {name: "acct_tx_followup", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000404"), expect: "\"account-transactions:", mayBeError: true}, } - require.Len(t, scenarios, 30, "must have 30 CLI scenarios") + require.Len(t, scenarios, 100, "must have 100 CLI scenarios") for i, s := range scenarios { i, s := i, s - t.Run(fmt.Sprintf("%02d_%s", i+1, s.name), func(t *testing.T) { - cfgPath := fmt.Sprintf("/tmp/apcfg_%02d.json", i+1) - data, err := json.Marshal(s.cfg) - require.NoError(t, err) - require.NoError(t, os.WriteFile(cfgPath, data, 0644)) - defer os.Remove(cfgPath) - - args := []string{ - "-T", "dev-aptos-testnet", - "-R", projectDir, - "workflow", "simulate", projectDir, - "--wasm", wasmPath, - "--config", cfgPath, - "--non-interactive", - "--trigger-index", "0", - "--limits", "none", + t.Run(fmt.Sprintf("%03d_%s", i+1, s.name), func(t *testing.T) { + var args []string + // Scenarios that don't supply cfg (help / purely CLI-arg-driven) + // skip the config-file plumbing entirely. + if s.cfg != nil { + cfgPath := fmt.Sprintf("/tmp/apcfg_%03d.json", i+1) + data, err := json.Marshal(s.cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(cfgPath, data, 0644)) + defer os.Remove(cfgPath) + + args = []string{ + "-T", "dev-aptos-testnet", + "-R", projectDir, + "workflow", "simulate", projectDir, + "--wasm", wasmPath, + "--config", cfgPath, + "--non-interactive", + "--trigger-index", "0", + "--limits", "none", + } } + // Scenario-specific overrides are appended last so they win over + // the defaults above (e.g. a different --wasm path). + args = append(args, s.args...) + cmd := exec.Command(cliBin, args...) cmd.Env = append(os.Environ(), "CRE_API_KEY=test-api", ) + cmd.Env = append(cmd.Env, s.env...) var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = &out - err = cmd.Run() + err := cmd.Run() combined := out.String() t.Logf("cre output:\n%s", combined) - // Every run must reach the simulator init + trigger dispatch + result. + if s.mustFail { + require.Error(t, err, "scenario %q expected to fail", s.name) + require.Contains(t, combined, s.expect, "scenario %q missing expected error substring", s.name) + return + } + + // Help / no-cfg scenarios only need the expected substring — the + // simulator markers are cron-specific and don't apply. + if s.cfg == nil { + require.NoError(t, err, "scenario %q expected to succeed", s.name) + require.Contains(t, combined, s.expect, "scenario %q missing expected substring", s.name) + return + } + + // Every simulator run must reach init + trigger dispatch + result. require.Contains(t, combined, "Simulator Initialized", "scenario %q: simulator did not initialise", s.name) require.Contains(t, combined, "Running trigger trigger=cron-trigger", "scenario %q: cron trigger did not fire", s.name) require.Contains(t, combined, "Workflow Simulation Result:", "scenario %q: workflow did not return", s.name) From 23502ee362139857463b57e1e2d9d559fa31da50 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Wed, 22 Apr 2026 17:40:08 +0100 Subject: [PATCH 05/28] chore(simulate/aptos): address audit feedback - ResolveKey: split hex-decode vs length-mismatch errors; align wording with EVM's CRE_*_PRIVATE_KEY message. - ParseTriggerChainSelector: strict prefix+suffix+ParseUint, rejects trailing garbage after @1.0.0. - --broadcast flag help: chain-agnostic wording (no longer EVM-only). - LimitsSummary: include Aptos gas limit alongside EVM; test updated. - aptos_cli_scenarios_test: use t.TempDir() for per-subtest config paths instead of /tmp/apcfg_%03d.json. --- cmd/workflow/simulate/chain/aptos/chaintype.go | 15 +++++++++++---- cmd/workflow/simulate/limits.go | 3 ++- cmd/workflow/simulate/limits_test.go | 3 ++- cmd/workflow/simulate/simulate.go | 2 +- test/aptos_cli_scenarios_test.go | 8 ++++---- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index 0b8a896a..346ce11d 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "strconv" "strings" "github.com/aptos-labs/aptos-go-sdk/crypto" @@ -71,7 +72,10 @@ func (ct *AptosChainType) ResolveKey(s *settings.Settings, broadcast bool) (inte bytes, err := hex.DecodeString(seed) if err != nil || len(bytes) != 32 { if broadcast { - return nil, fmt.Errorf("CRE_APTOS_PRIVATE_KEY must be 32 hex bytes (64 chars); got len=%d err=%v", len(bytes), err) + if err != nil { + return nil, fmt.Errorf("failed to parse private key, required to broadcast. Please check CRE_APTOS_PRIVATE_KEY in your .env file or system environment: %w", err) + } + return nil, fmt.Errorf("CRE_APTOS_PRIVATE_KEY must be 32 hex bytes (64 chars); got len=%d", len(bytes)) } bytes, _ = hex.DecodeString(defaultSentinelAptosSeed) ui.Warning("Using default Aptos private key for dry-run simulation. Set CRE_APTOS_PRIVATE_KEY to broadcast.") @@ -143,11 +147,14 @@ func (ct *AptosChainType) HasSelector(selector uint64) bool { } func (ct *AptosChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { - if !strings.HasPrefix(triggerID, "aptos:ChainSelector:") { + const prefix = "aptos:ChainSelector:" + const suffix = "@1.0.0" + if !strings.HasPrefix(triggerID, prefix) || !strings.HasSuffix(triggerID, suffix) { return 0, false } - var sel uint64 - if _, err := fmt.Sscanf(triggerID, "aptos:ChainSelector:%d@1.0.0", &sel); err != nil { + mid := triggerID[len(prefix) : len(triggerID)-len(suffix)] + sel, err := strconv.ParseUint(mid, 10, 64) + if err != nil { return 0, false } return sel, true diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index ce7c5285..49bb24c6 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -215,7 +215,7 @@ func (l *SimulationLimits) WASMCompressedBinarySize() int { func (l *SimulationLimits) LimitsSummary() string { w := &l.Workflows return fmt.Sprintf( - "HTTP: req=%s resp=%s timeout=%s | ConfHTTP: req=%s resp=%s timeout=%s | Consensus obs=%s | ChainWrite report=%s gas=%d | WASM binary=%s compressed=%s", + "HTTP: req=%s resp=%s timeout=%s | ConfHTTP: req=%s resp=%s timeout=%s | Consensus obs=%s | ChainWrite report=%s evm_gas=%d aptos_gas=%d | WASM binary=%s compressed=%s", w.HTTPAction.RequestSizeLimit.DefaultValue, w.HTTPAction.ResponseSizeLimit.DefaultValue, w.HTTPAction.ConnectionTimeout.DefaultValue, @@ -225,6 +225,7 @@ func (l *SimulationLimits) LimitsSummary() string { w.Consensus.ObservationSizeLimit.DefaultValue, w.ChainWrite.ReportSizeLimit.DefaultValue, w.ChainWrite.EVM.GasLimit.Default.DefaultValue, + w.ChainWrite.Aptos.GasLimit.Default.DefaultValue, w.WASMBinarySizeLimit.DefaultValue, w.WASMCompressedBinarySizeLimit.DefaultValue, ) diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go index 08389fb3..09459f08 100644 --- a/cmd/workflow/simulate/limits_test.go +++ b/cmd/workflow/simulate/limits_test.go @@ -173,6 +173,7 @@ func TestSimulationLimitsSummaryIncludesKeyLimitValues(t *testing.T) { assert.Contains(t, summary, "HTTP: req=10kb resp=100kb timeout=10s") assert.Contains(t, summary, "ConfHTTP: req=10kb resp=100kb timeout=10s") assert.Contains(t, summary, "Consensus obs=100kb") - assert.Contains(t, summary, "ChainWrite report=5kb gas=5000000") + assert.Contains(t, summary, "ChainWrite report=5kb evm_gas=5000000") + assert.Contains(t, summary, "aptos_gas=") assert.Contains(t, summary, "WASM binary=100mb compressed=20mb") } diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index cf2b48fe..c4020c26 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -96,7 +96,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { } simulateCmd.Flags().BoolP("engine-logs", "g", false, "Enable non-fatal engine logging") - simulateCmd.Flags().Bool("broadcast", false, "Broadcast transactions to the EVM (default: false)") + simulateCmd.Flags().Bool("broadcast", false, "Broadcast transactions to configured chains (requires a valid per-chain-type private key; default: false)") simulateCmd.Flags().String("wasm", "", "Path or URL to a pre-built WASM binary (skips compilation)") simulateCmd.Flags().String("config", "", "Override the config file path from workflow.yaml") simulateCmd.Flags().Bool("no-config", false, "Simulate without a config file") diff --git a/test/aptos_cli_scenarios_test.go b/test/aptos_cli_scenarios_test.go index d1025827..dfa3987e 100644 --- a/test/aptos_cli_scenarios_test.go +++ b/test/aptos_cli_scenarios_test.go @@ -40,8 +40,9 @@ func TestCLIAptosSimulator_100DryRuns(t *testing.T) { cliBin := filepath.Join(repoRoot, "bin", "cre") require.FileExists(t, cliBin, "./bin/cre not built; run `go build -o ./bin/cre .`") - wasmPath := "/tmp/aptos_smoke.wasm" - require.FileExists(t, wasmPath, "WASM not built; run `cd test/test_project/aptos_smoke && GOOS=wasip1 GOARCH=wasm go build -o /tmp/aptos_smoke.wasm .`") + + wasmPath := filepath.Join(t.TempDir(), "aptos_smoke.wasm") + require.FileExists(t, wasmPath, "WASM not built; set APTOS_SMOKE_WASM or run `cd test/test_project/aptos_smoke && GOOS=wasip1 GOARCH=wasm go build -o $APTOS_SMOKE_WASM .`") projectDir := filepath.Join(repoRoot, "test", "test_project", "aptos_smoke") @@ -223,11 +224,10 @@ func TestCLIAptosSimulator_100DryRuns(t *testing.T) { // Scenarios that don't supply cfg (help / purely CLI-arg-driven) // skip the config-file plumbing entirely. if s.cfg != nil { - cfgPath := fmt.Sprintf("/tmp/apcfg_%03d.json", i+1) + cfgPath := filepath.Join(t.TempDir(), fmt.Sprintf("apcfg_%03d.json", i+1)) data, err := json.Marshal(s.cfg) require.NoError(t, err) require.NoError(t, os.WriteFile(cfgPath, data, 0644)) - defer os.Remove(cfgPath) args = []string{ "-T", "dev-aptos-testnet", From 26a299afbaba56e3670e3515a6062af23e5a969d Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 24 Apr 2026 12:58:13 +0100 Subject: [PATCH 06/28] test(aptos): use /tmp for config path to satisfy 97-char limit TempDir() paths exceed ConfigPath validation (max=97) on macOS. Restore /tmp/apcfg_NNN.json with defer os.Remove; add APTOS_SMOKE_WASM env override for WASM location. --- test/aptos_cli_scenarios_test.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/aptos_cli_scenarios_test.go b/test/aptos_cli_scenarios_test.go index dfa3987e..dc283726 100644 --- a/test/aptos_cli_scenarios_test.go +++ b/test/aptos_cli_scenarios_test.go @@ -41,8 +41,11 @@ func TestCLIAptosSimulator_100DryRuns(t *testing.T) { require.FileExists(t, cliBin, "./bin/cre not built; run `go build -o ./bin/cre .`") - wasmPath := filepath.Join(t.TempDir(), "aptos_smoke.wasm") - require.FileExists(t, wasmPath, "WASM not built; set APTOS_SMOKE_WASM or run `cd test/test_project/aptos_smoke && GOOS=wasip1 GOARCH=wasm go build -o $APTOS_SMOKE_WASM .`") + wasmPath := os.Getenv("APTOS_SMOKE_WASM") + if wasmPath == "" { + wasmPath = "/tmp/aptos_smoke.wasm" + } + require.FileExists(t, wasmPath, "WASM not built; set APTOS_SMOKE_WASM or run `cd test/test_project/aptos_smoke && GOOS=wasip1 GOARCH=wasm go build -o /tmp/aptos_smoke.wasm .`") projectDir := filepath.Join(repoRoot, "test", "test_project", "aptos_smoke") @@ -224,10 +227,11 @@ func TestCLIAptosSimulator_100DryRuns(t *testing.T) { // Scenarios that don't supply cfg (help / purely CLI-arg-driven) // skip the config-file plumbing entirely. if s.cfg != nil { - cfgPath := filepath.Join(t.TempDir(), fmt.Sprintf("apcfg_%03d.json", i+1)) + cfgPath := fmt.Sprintf("/tmp/apcfg_%03d.json", i+1) data, err := json.Marshal(s.cfg) require.NoError(t, err) require.NoError(t, os.WriteFile(cfgPath, data, 0644)) + defer os.Remove(cfgPath) args = []string{ "-T", "dev-aptos-testnet", From 1711ad35bfe020473bf022757e0e22469dfaf7c9 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 24 Apr 2026 14:40:22 +0100 Subject: [PATCH 07/28] feat(simulate/aptos): support experimental-chains + align view test expectations - Add chain-type discriminator to ExperimentalChain; empty defaults to evm - Aptos ResolveClients picks up chain-type: aptos entries - EVM skips non-evm experimental entries - Use corekeys.Aptos / ct.Name() for chain-type strings - Update project.yaml.tpl example - Align View/WriteReport test assertions with pinned FakeAptosChain semantics --- .../simulate/chain/aptos/chaintype.go | 45 +++++++++++++++++-- .../chain/aptos/simulator_scenarios_test.go | 31 ++++++------- .../simulate/chain/aptos/supported_chains.go | 3 +- cmd/workflow/simulate/chain/evm/chaintype.go | 4 ++ internal/settings/settings_get.go | 5 ++- internal/settings/template/project.yaml.tpl | 5 ++- 6 files changed, 67 insertions(+), 26 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index a4ea8485..96b38941 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -11,6 +11,7 @@ import ( "github.com/rs/zerolog" "github.com/spf13/viper" + corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" "github.com/smartcontractkit/chainlink-common/pkg/services" aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" @@ -23,7 +24,7 @@ import ( const defaultSentinelAptosSeed = "0000000000000000000000000000000000000000000000000000000000000001" func init() { - chain.Register("aptos", func(lggr *zerolog.Logger) chain.ChainType { + chain.Register(string(corekeys.Aptos), func(lggr *zerolog.Logger) chain.ChainType { return &AptosChainType{log: lggr} }, nil) } @@ -36,12 +37,13 @@ type AptosChainType struct { var _ chain.ChainType = (*AptosChainType)(nil) -func (ct *AptosChainType) Name() string { return "aptos" } +func (ct *AptosChainType) Name() string { return string(corekeys.Aptos) } func (ct *AptosChainType) SupportedChains() []chain.ChainConfig { return SupportedChains } func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, error) { clients := make(map[uint64]chain.ChainClient) forwarders := make(map[uint64]string) + experimental := make(map[uint64]bool) for _, c := range SupportedChains { name, err := settings.GetChainNameByChainSelector(c.Selector) if err != nil { @@ -64,7 +66,44 @@ func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, forwarders[c.Selector] = c.Forwarder } } - return chain.ResolvedChains{Clients: clients, Forwarders: forwarders}, nil + + expChains, err := settings.GetExperimentalChains(v) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("failed to load experimental chains config: %w", err) + } + for _, ec := range expChains { + if !strings.EqualFold(ec.ChainType, ct.Name()) { + continue + } + if ec.ChainSelector == 0 { + return chain.ResolvedChains{}, fmt.Errorf("experimental chain missing chain-selector") + } + if strings.TrimSpace(ec.RPCURL) == "" { + return chain.ResolvedChains{}, fmt.Errorf("experimental aptos chain %d missing rpc-url", ec.ChainSelector) + } + if strings.TrimSpace(ec.Forwarder) == "" { + return chain.ResolvedChains{}, fmt.Errorf("experimental aptos chain %d missing forwarder", ec.ChainSelector) + } + if _, exists := clients[ec.ChainSelector]; exists { + if forwarders[ec.ChainSelector] != ec.Forwarder { + ui.Warning(fmt.Sprintf("Warning: experimental aptos chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", + ec.ChainSelector, forwarders[ec.ChainSelector], ec.Forwarder)) + forwarders[ec.ChainSelector] = ec.Forwarder + } + continue + } + ct.log.Debug().Msgf("Using RPC for experimental aptos chain %d: %s", ec.ChainSelector, chain.RedactURL(ec.RPCURL)) + client, err := aptosfakes.NewAptosClient(ec.RPCURL) + if err != nil { + return chain.ResolvedChains{}, fmt.Errorf("failed to create aptos client for experimental chain %d: %w", ec.ChainSelector, err) + } + clients[ec.ChainSelector] = client + forwarders[ec.ChainSelector] = ec.Forwarder + experimental[ec.ChainSelector] = true + ui.Dim(fmt.Sprintf("Added experimental aptos chain (chain-selector: %d)\n", ec.ChainSelector)) + } + + return chain.ResolvedChains{Clients: clients, Forwarders: forwarders, ExperimentalSelectors: experimental}, nil } func (ct *AptosChainType) ResolveKey(s *settings.Settings, broadcast bool) (interface{}, error) { diff --git a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go index fd67c36d..1d57a6c6 100644 --- a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go +++ b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go @@ -107,7 +107,7 @@ func simulatorScenarios() []simScenario { _, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x01)}) require.NotNil(t, capErr) }}, - {name: "05 View round-trips opaque bytes", run: func(t *testing.T) { + {name: "05 View returns JSON-marshaled result array", run: func(t *testing.T) { rpc := mocks.NewAptosRpcClient(t) rpc.EXPECT().View(mock.Anything).Return([]any{"hello"}, nil).Once() fc := newChain(t, rpc, true, 1) @@ -118,7 +118,7 @@ func simulatorScenarios() []simScenario { }, }) require.Nil(t, capErr) - assert.Equal(t, []byte("hello"), reply.Response.Data) + assert.Equal(t, []byte(`["hello"]`), reply.Response.Data) }}, {name: "06 View rejects nil payload", run: func(t *testing.T) { fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) @@ -252,7 +252,7 @@ func simulatorScenarios() []simScenario { rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(&aptos.RawTransaction{}, nil).Once() rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: false, VmStatus: "Move abort in 0xdead::forwarder: Bad"}}, nil).Once() + Return([]*api.UserTransaction{{Success: false, VmStatus: "Move abort in 0xdead::mock_forwarder: Bad"}}, nil).Once() fc := newChain(t, rpc, true, 1) reply, _ := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), @@ -593,7 +593,7 @@ func simulatorScenarios() []simScenario { require.Nil(t, capErr) assert.Equal(t, ^uint64(0), reply.Response.Value) }}, - {name: "65 View with empty result returns empty Data", run: func(t *testing.T) { + {name: "65 View with empty result returns JSON []", run: func(t *testing.T) { rpc := mocks.NewAptosRpcClient(t) rpc.EXPECT().View(mock.Anything).Return([]any{}, nil).Once() fc := newChain(t, rpc, true, 1) @@ -601,9 +601,9 @@ func simulatorScenarios() []simScenario { Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, }) require.Nil(t, capErr) - assert.Empty(t, reply.Response.Data) + assert.Equal(t, []byte(`[]`), reply.Response.Data) }}, - {name: "66 View keeps only result[0] when multi-return", run: func(t *testing.T) { + {name: "66 View preserves multi-return as JSON array", run: func(t *testing.T) { rpc := mocks.NewAptosRpcClient(t) rpc.EXPECT().View(mock.Anything).Return([]any{"first", "second"}, nil).Once() fc := newChain(t, rpc, true, 1) @@ -611,9 +611,9 @@ func simulatorScenarios() []simScenario { Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, }) require.Nil(t, capErr) - assert.Equal(t, []byte("first"), reply.Response.Data) + assert.Equal(t, []byte(`["first","second"]`), reply.Response.Data) }}, - {name: "67 View integer return stringifies via %v", run: func(t *testing.T) { + {name: "67 View integer return marshaled as JSON", run: func(t *testing.T) { rpc := mocks.NewAptosRpcClient(t) rpc.EXPECT().View(mock.Anything).Return([]any{int64(42)}, nil).Once() fc := newChain(t, rpc, true, 1) @@ -621,7 +621,7 @@ func simulatorScenarios() []simScenario { Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, }) require.Nil(t, capErr) - assert.Equal(t, []byte("42"), reply.Response.Data) + assert.Equal(t, []byte(`[42]`), reply.Response.Data) }}, {name: "68 TransactionByHash SDK error without 404 → Unavailable", run: func(t *testing.T) { rpc := mocks.NewAptosRpcClient(t) @@ -786,19 +786,14 @@ func simulatorScenarios() []simScenario { }) require.Nil(t, capErr) }}, - {name: "82 WriteReport zero MaxGasAmount accepted (default applies)", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) + {name: "82 WriteReport zero MaxGasAmount rejected", run: func(t *testing.T) { + fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ Receiver: mkAddr(0xBB), GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 0, GasUnitPrice: 0}, Report: validReport(), }) - require.Nil(t, capErr) + require.NotNil(t, capErr) }}, // --- LimitedAptosChain edge cases (83-90) --- @@ -890,7 +885,7 @@ func simulatorScenarios() []simScenario { Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, }) require.Nil(t, capErr) - assert.Equal(t, []byte("x"), reply.Response.Data) + assert.Equal(t, []byte(`["x"]`), reply.Response.Data) }}, {name: "90 LimitedAptosChain TransactionByHash delegates to inner", run: func(t *testing.T) { rpc := mocks.NewAptosRpcClient(t) diff --git a/cmd/workflow/simulate/chain/aptos/supported_chains.go b/cmd/workflow/simulate/chain/aptos/supported_chains.go index 6a846b9d..1cfa96c6 100644 --- a/cmd/workflow/simulate/chain/aptos/supported_chains.go +++ b/cmd/workflow/simulate/chain/aptos/supported_chains.go @@ -7,7 +7,8 @@ import ( ) // placeholderForwarder is used until canonical platform_mock addresses are -// published per network. Users override via experimental-chains config. +// published per network. Users override via experimental-chains config +// (chain-type: aptos). const placeholderForwarder = "0x0000000000000000000000000000000000000000000000000000000000000000" // SupportedChains lists Aptos networks cre-cli simulate can target. diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go index 76f8784b..53709bc1 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype.go +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -87,6 +87,10 @@ func (ct *EVMChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, er } for _, ec := range expChains { + // Empty chain-type falls back to this chain type + if ec.ChainType != "" && !strings.EqualFold(ec.ChainType, ct.Name()) { + continue + } if ec.ChainSelector == 0 { return chain.ResolvedChains{}, fmt.Errorf("experimental chain missing chain-selector") } diff --git a/internal/settings/settings_get.go b/internal/settings/settings_get.go index fcd0b0f7..703c1dc2 100644 --- a/internal/settings/settings_get.go +++ b/internal/settings/settings_get.go @@ -38,10 +38,11 @@ type RpcEndpoint struct { Url string `mapstructure:"url" yaml:"url"` } -// ExperimentalChain represents an EVM chain not in official chain-selectors. +// ExperimentalChain represents a chain not in official chain-selectors. // Automatically used by the simulator when present in the target's experimental-chains config. -// The ChainSelector is used as the selector key for EVM clients and forwarders. +// ChainType selects the chain family ("evm", "aptos"); empty defaults to "evm" for backward compat. type ExperimentalChain struct { + ChainType string `mapstructure:"chain-type" yaml:"chain-type"` ChainSelector uint64 `mapstructure:"chain-selector" yaml:"chain-selector"` RPCURL string `mapstructure:"rpc-url" yaml:"rpc-url"` Forwarder string `mapstructure:"forwarder" yaml:"forwarder"` diff --git a/internal/settings/template/project.yaml.tpl b/internal/settings/template/project.yaml.tpl index 8f894a90..3439206b 100644 --- a/internal/settings/template/project.yaml.tpl +++ b/internal/settings/template/project.yaml.tpl @@ -23,10 +23,11 @@ # # Experimental chains (automatically used by the simulator when present): # Use this for chains not yet in official chain-selectors (e.g., hackathons, new chain integrations). -# In your workflow, reference the chain as evm:ChainSelector:@1.0.0 +# In your workflow, reference the chain as :ChainSelector:@1.0.0 # # experimental-chains: -# - chain-selector: 12345 # The chain selector value +# - chain-type: evm # "evm" (default) or "aptos" +# chain-selector: 12345 # The chain selector value # rpc-url: "https://rpc.example.com" # RPC endpoint URL # forwarder: "0x..." # Forwarder contract address on the chain From 8b81488ad1e616457301820215f23fe38b931eff Mon Sep 17 00:00:00 2001 From: Michael Fletcher <36506122+Fletch153@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:55:27 +0100 Subject: [PATCH 08/28] refactor(settings): consolidate chain signing keys behind ChainType (#399) Replace EthPrivateKey / AptosPrivateKey fields on UserSettings with a single PrivateKeys map keyed by ChainType.Name, and introduce a ChainType struct that bundles each family's name + signing-key env var. Names are derived from corekeys.EVM / corekeys.Aptos so the canonical upstream identifiers are the single source of truth. Adding a new chain family is now one entry in AllChainTypes instead of a new field, constant, and loader branch. Remove unused EthUrl and simplify the project.yaml experimental-chains example comment. --- cmd/secrets/common/handler.go | 6 +- cmd/workflow/activate/activate_test.go | 6 +- cmd/workflow/delete/delete_test.go | 6 +- cmd/workflow/pause/pause_test.go | 6 +- .../simulate/chain/aptos/chaintype.go | 2 +- .../simulate/chain/aptos/chaintype_test.go | 8 +-- .../chain/aptos/simulator_scenarios_test.go | 12 ++-- cmd/workflow/simulate/chain/evm/chaintype.go | 2 +- .../simulate/chain/evm/chaintype_test.go | 2 +- cmd/workflow/simulate/simulate_test.go | 6 +- internal/settings/settings.go | 67 ++++++++++++++----- internal/settings/settings_test.go | 10 +-- internal/settings/template/project.yaml.tpl | 2 +- .../workflow_private_registry.go | 2 +- 14 files changed, 84 insertions(+), 53 deletions(-) diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 1f604e3a..acd00600 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -73,13 +73,13 @@ type Handler struct { func NewHandler(ctx *runtime.Context, secretsFilePath string) (*Handler, error) { var pk *ecdsa.PrivateKey var err error - if ctx.Settings.User.EthPrivateKey != "" { - pk, err = crypto.HexToECDSA(ctx.Settings.User.EthPrivateKey) + if ethKey := ctx.Settings.User.PrivateKey(settings.EVM); ethKey != "" { + pk, err = crypto.HexToECDSA(ethKey) if err != nil { return nil, fmt.Errorf("failed to decode the provided private key: %w", err) } } else { - ctx.Logger.Debug().Msg("No EthPrivateKey found in settings; assuming a multisig request.") + ctx.Logger.Debug().Msg("No EVM private key found in settings; assuming a multisig request.") } diff --git a/cmd/workflow/activate/activate_test.go b/cmd/workflow/activate/activate_test.go index f94522aa..6aec1bea 100644 --- a/cmd/workflow/activate/activate_test.go +++ b/cmd/workflow/activate/activate_test.go @@ -20,7 +20,7 @@ func TestNonInteractive_WithoutYes_ReturnsError(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA @@ -47,7 +47,7 @@ func TestNonInteractive_WithYes_PassesGuard(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA @@ -162,7 +162,7 @@ func TestWorkflowActivateCommand(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA diff --git a/cmd/workflow/delete/delete_test.go b/cmd/workflow/delete/delete_test.go index 0146c0cf..17bb9a2c 100644 --- a/cmd/workflow/delete/delete_test.go +++ b/cmd/workflow/delete/delete_test.go @@ -21,7 +21,7 @@ func TestNonInteractive_WithoutYes_ReturnsError(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA @@ -47,7 +47,7 @@ func TestNonInteractive_WithYes_Proceeds(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA @@ -134,7 +134,7 @@ func TestWorkflowDeleteCommand(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA diff --git a/cmd/workflow/pause/pause_test.go b/cmd/workflow/pause/pause_test.go index 3af6e2f6..89c5af9a 100644 --- a/cmd/workflow/pause/pause_test.go +++ b/cmd/workflow/pause/pause_test.go @@ -20,7 +20,7 @@ func TestNonInteractive_WithoutYes_ReturnsError(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA @@ -46,7 +46,7 @@ func TestNonInteractive_WithYes_PassesGuard(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA @@ -140,7 +140,7 @@ func TestWorkflowPauseCommand(t *testing.T) { ctx := simulatedEnvironment.NewRuntimeContext() ctx.Settings = &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: chainsim.TestPrivateKey, + PrivateKeys: map[string]string{settings.EVM.Name: chainsim.TestPrivateKey}, }, } ctx.Settings.Workflow.UserWorkflowSettings.WorkflowOwnerType = constants.WorkflowOwnerTypeEOA diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index 96b38941..c45b0d6a 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -107,7 +107,7 @@ func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, } func (ct *AptosChainType) ResolveKey(s *settings.Settings, broadcast bool) (interface{}, error) { - seed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s.User.AptosPrivateKey)), "0x") + seed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s.User.PrivateKey(settings.Aptos))), "0x") bytes, err := hex.DecodeString(seed) if err != nil || len(bytes) != 32 { if broadcast { diff --git a/cmd/workflow/simulate/chain/aptos/chaintype_test.go b/cmd/workflow/simulate/chain/aptos/chaintype_test.go index 846b7ddd..42c003be 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype_test.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype_test.go @@ -12,7 +12,7 @@ import ( func TestResolveKey_SentinelUnderBroadcastFails(t *testing.T) { t.Parallel() ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "0000000000000000000000000000000000000000000000000000000000000001"}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "0000000000000000000000000000000000000000000000000000000000000001"}}} _, err := ct.ResolveKey(s, true) require.Error(t, err) } @@ -20,7 +20,7 @@ func TestResolveKey_SentinelUnderBroadcastFails(t *testing.T) { func TestResolveKey_UnparseableUnderBroadcastFails(t *testing.T) { t.Parallel() ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "not-hex"}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "not-hex"}}} _, err := ct.ResolveKey(s, true) require.Error(t, err) } @@ -28,7 +28,7 @@ func TestResolveKey_UnparseableUnderBroadcastFails(t *testing.T) { func TestResolveKey_UnparseableNonBroadcastFallsBackToSentinel(t *testing.T) { t.Parallel() ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: ""}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: ""}}} k, err := ct.ResolveKey(s, false) require.NoError(t, err) assert.NotNil(t, k) @@ -37,7 +37,7 @@ func TestResolveKey_UnparseableNonBroadcastFallsBackToSentinel(t *testing.T) { func TestResolveKey_ValidKeyBroadcast(t *testing.T) { t.Parallel() ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "1111111111111111111111111111111111111111111111111111111111111111"}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "1111111111111111111111111111111111111111111111111111111111111111"}}} k, err := ct.ResolveKey(s, true) require.NoError(t, err) assert.NotNil(t, k) diff --git a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go index 1d57a6c6..72d78365 100644 --- a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go +++ b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go @@ -343,13 +343,13 @@ func simulatorScenarios() []simScenario { }}, {name: "28 ResolveKey sentinel OK under dry-run", run: func(t *testing.T) { ct := &AptosChainType{} - k, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{AptosPrivateKey: ""}}, false) + k, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: ""}}}, false) require.NoError(t, err) require.NotNil(t, k) }}, {name: "29 ResolveKey rejects sentinel under --broadcast", run: func(t *testing.T) { ct := &AptosChainType{} - _, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{AptosPrivateKey: defaultSentinelAptosSeed}}, true) + _, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: defaultSentinelAptosSeed}}}, true) require.Error(t, err) }}, // --- chain-type plugin surface (31-45) --- @@ -417,28 +417,28 @@ func simulatorScenarios() []simScenario { }}, {name: "42 ResolveKey parses 0x-prefixed seed", run: func(t *testing.T) { ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "0x2222222222222222222222222222222222222222222222222222222222222222"}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "0x2222222222222222222222222222222222222222222222222222222222222222"}}} k, err := ct.ResolveKey(s, true) require.NoError(t, err) require.NotNil(t, k) }}, {name: "43 ResolveKey parses uppercase hex", run: func(t *testing.T) { ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"}}} k, err := ct.ResolveKey(s, true) require.NoError(t, err) require.NotNil(t, k) }}, {name: "44 ResolveKey trims whitespace", run: func(t *testing.T) { ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: " 1111111111111111111111111111111111111111111111111111111111111111 "}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: " 1111111111111111111111111111111111111111111111111111111111111111 "}}} k, err := ct.ResolveKey(s, true) require.NoError(t, err) require.NotNil(t, k) }}, {name: "45 ResolveKey short seed hard-fails under broadcast", run: func(t *testing.T) { ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{AptosPrivateKey: "0102"}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "0102"}}} _, err := ct.ResolveKey(s, true) require.Error(t, err) assert.Contains(t, err.Error(), "CRE_APTOS_PRIVATE_KEY") diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go index 53709bc1..b05b3231 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype.go +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -225,7 +225,7 @@ func (ct *EVMChainType) RunHealthCheck(resolved chain.ResolvedChains) error { // is true, an invalid or default-sentinel key is a hard error. Otherwise a // sentinel key is used with a warning so non-broadcast simulations can run. func (ct *EVMChainType) ResolveKey(creSettings *settings.Settings, broadcast bool) (interface{}, error) { - pk, err := crypto.HexToECDSA(creSettings.User.EthPrivateKey) + pk, err := crypto.HexToECDSA(creSettings.User.PrivateKey(settings.EVM)) if err != nil { if broadcast { return nil, fmt.Errorf( diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index 976e94b6..433707be 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -163,7 +163,7 @@ func TestEVMChainType_ResolveKey(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ct := newEVMChainType() - s := &settings.Settings{User: settings.UserSettings{EthPrivateKey: tt.pk}} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.EVM.Name: tt.pk}}} var got interface{} var err error diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index 1d24423a..1ae31d78 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -73,7 +73,7 @@ func TestBlankWorkflowSimulation(t *testing.T) { Workflow: workflowSettings, User: settings.UserSettings{ TargetName: "staging-settings", - EthPrivateKey: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + PrivateKeys: map[string]string{settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888"}, }, }, } @@ -125,7 +125,7 @@ func createSimulateTestSettings(workflowName, workflowPath, configPath string) * }, }, User: settings.UserSettings{ - EthPrivateKey: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + PrivateKeys: map[string]string{settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888"}, }, } } @@ -446,7 +446,7 @@ func TestSimulateConfigFlagsMutuallyExclusive(t *testing.T) { Viper: viper.New(), Settings: &settings.Settings{ User: settings.UserSettings{ - EthPrivateKey: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888", + PrivateKeys: map[string]string{settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888"}, }, }, } diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 4937a47d..19b99b7c 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -12,14 +12,37 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" + corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + "github.com/smartcontractkit/cre-cli/internal/ui" ) -// sensitive information (not in configuration file) -const ( - EthPrivateKeyEnvVar = "CRE_ETH_PRIVATE_KEY" - AptosPrivateKeyEnvVar = "CRE_APTOS_PRIVATE_KEY" - CreTargetEnvVar = "CRE_TARGET" +const CreTargetEnvVar = "CRE_TARGET" + +// ChainType describes a chain family and the per-family settings the CLI +// loads from the environment. Add a family by appending to AllChainTypes. +type ChainType struct { + Name string + PrivateKeyEnv string +} + +var ( + EVM = ChainType{ + Name: string(corekeys.EVM), + PrivateKeyEnv: "CRE_ETH_PRIVATE_KEY", + } + Aptos = ChainType{ + Name: string(corekeys.Aptos), + PrivateKeyEnv: "CRE_APTOS_PRIVATE_KEY", + } + + AllChainTypes = []ChainType{EVM, Aptos} +) + +// Backwards-compat aliases; prefer EVM.PrivateKeyEnv / Aptos.PrivateKeyEnv. +var ( + EthPrivateKeyEnvVar = EVM.PrivateKeyEnv + AptosPrivateKeyEnvVar = Aptos.PrivateKeyEnv ) // State tracked by LoadEnv / LoadPublicEnv so downstream code (e.g. build @@ -57,10 +80,16 @@ type Settings struct { // UserSettings stores user-specific configurations. type UserSettings struct { - TargetName string - EthPrivateKey string - EthUrl string - AptosPrivateKey string + TargetName string + PrivateKeys map[string]string // keyed by ChainType.Name +} + +// PrivateKey returns the signing key for the given chain, or "" if unset. +func (u UserSettings) PrivateKey(f ChainType) string { + if u.PrivateKeys == nil { + return "" + } + return u.PrivateKeys[f.Name] } // New initializes and loads settings from YAML config files and the environment. @@ -103,17 +132,15 @@ func New(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Command, registryCha return nil, err } - rawPrivKey := v.GetString(EthPrivateKeyEnvVar) - normPrivKey := NormalizeHexKey(rawPrivKey) - - rawAptosKey := v.GetString(AptosPrivateKeyEnvVar) - normAptosKey := NormalizeHexKey(rawAptosKey) + privateKeys := make(map[string]string, len(AllChainTypes)) + for _, f := range AllChainTypes { + privateKeys[f.Name] = NormalizeHexKey(v.GetString(f.PrivateKeyEnv)) + } return &Settings{ User: UserSettings{ - EthPrivateKey: normPrivKey, - AptosPrivateKey: normAptosKey, - TargetName: target, + TargetName: target, + PrivateKeys: privateKeys, }, Workflow: workflowSettings, StorageSettings: storageSettings, @@ -169,7 +196,11 @@ func LoadEnv(logger *zerolog.Logger, v *viper.Viper, envPath string) { loadedEnvFilePath = "" loadedEnvVars = nil loadedEnvFilePath, loadedEnvVars = loadEnvFile(logger, envPath) - bindAllVars(v, loadedEnvVars, EthPrivateKeyEnvVar, AptosPrivateKeyEnvVar, CreTargetEnvVar) + extras := []string{CreTargetEnvVar} + for _, f := range AllChainTypes { + extras = append(extras, f.PrivateKeyEnv) + } + bindAllVars(v, loadedEnvVars, extras...) } // LoadPublicEnv loads variables from envPath into the process environment diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 3b99133f..9ecd7780 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -136,7 +136,7 @@ func TestLoadEnvAndSettings(t *testing.T) { s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) assert.Equal(t, "staging", s.User.TargetName) - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.EthPrivateKey) + assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) } func TestLoadEnvAndSettingsWithWorkflowSettingsFlag(t *testing.T) { @@ -169,7 +169,7 @@ func TestLoadEnvAndSettingsWithWorkflowSettingsFlag(t *testing.T) { s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) assert.Equal(t, "staging", s.User.TargetName) - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.EthPrivateKey) + assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) } func TestInlineEnvTakesPrecedenceOverDotEnv(t *testing.T) { @@ -199,7 +199,7 @@ func TestInlineEnvTakesPrecedenceOverDotEnv(t *testing.T) { s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) assert.Equal(t, "staging", s.User.TargetName) - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.EthPrivateKey) + assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) } func TestLoadEnvAndMergedSettings(t *testing.T) { @@ -241,7 +241,7 @@ func TestLoadEnvAndMergedSettings(t *testing.T) { rpc2 := s.Workflow.RPCs[1] assert.Equal(t, "https://somethingElse.rpc.org", rpc1.Url, "First RPC URL mismatch") assert.Equal(t, "https://something.rpc.org", rpc2.Url, "Second RPC URL mismatch") - assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.EthPrivateKey) + assert.Equal(t, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", s.User.PrivateKey(settings.EVM)) } // helper to build a command with optional --broadcast flag and parse args @@ -330,7 +330,7 @@ func TestOffChainDeploymentRegistryUsesDerivedOwnerWithoutPrivateKey(t *testing. require.NoError(t, err) assert.Equal(t, derived, s.Workflow.UserWorkflowSettings.WorkflowOwnerAddress) assert.Equal(t, constants.WorkflowOwnerTypeOrgDerived, s.Workflow.UserWorkflowSettings.WorkflowOwnerType) - assert.Empty(t, s.User.EthPrivateKey) + assert.Empty(t, s.User.PrivateKey(settings.EVM)) } func TestOffChainDeploymentRegistryMissingDerivedOwnerReturnsError(t *testing.T) { diff --git a/internal/settings/template/project.yaml.tpl b/internal/settings/template/project.yaml.tpl index 3439206b..8314980d 100644 --- a/internal/settings/template/project.yaml.tpl +++ b/internal/settings/template/project.yaml.tpl @@ -26,7 +26,7 @@ # In your workflow, reference the chain as :ChainSelector:@1.0.0 # # experimental-chains: -# - chain-type: evm # "evm" (default) or "aptos" +# - chain-type: evm # Chain family # chain-selector: 12345 # The chain selector value # rpc-url: "https://rpc.example.com" # RPC endpoint URL # forwarder: "0x..." # Forwarder contract address on the chain diff --git a/test/multi_command_flows/workflow_private_registry.go b/test/multi_command_flows/workflow_private_registry.go index 5ab8c3df..1cf7af72 100644 --- a/test/multi_command_flows/workflow_private_registry.go +++ b/test/multi_command_flows/workflow_private_registry.go @@ -883,7 +883,7 @@ func RunPrivateRegistryAuthAndSettingsFinalize(t *testing.T, envPath, blankWorkf s, err := settings.New(logger, v, cmd, "") require.NoError(t, err) require.NotNil(t, s) - require.Empty(t, s.User.EthPrivateKey, "CRE_ETH_PRIVATE_KEY must be absent") + require.Empty(t, s.User.PrivateKey(settings.EVM), "CRE_ETH_PRIVATE_KEY must be absent") require.Equal(t, "reg-test", s.Workflow.UserWorkflowSettings.DeploymentRegistry) require.Empty(t, s.Workflow.UserWorkflowSettings.WorkflowOwnerAddress, "owner is deferred until finalize when deployment-registry is set") require.Empty(t, s.Workflow.UserWorkflowSettings.WorkflowOwnerType) From 4c6b976cabe3ca066ad100c3759ba92a290dd5a5 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 24 Apr 2026 16:17:23 +0100 Subject: [PATCH 09/28] refactor(settings): dispatch chain name lookup by family prefix chain-selectors has no AptosChainIdFromName; previous fallback scanned the Aptos map with a double lookup. Switch on FamilyAptos prefix and iterate AptosALL directly, reading Name/Selector off the struct. --- internal/settings/settings_get.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/internal/settings/settings_get.go b/internal/settings/settings_get.go index 703c1dc2..89f052a6 100644 --- a/internal/settings/settings_get.go +++ b/internal/settings/settings_get.go @@ -264,21 +264,20 @@ func ChainNameFromSelectorString(raw string) (string, error) { } func GetChainSelectorByChainName(name string) (uint64, error) { - if chainID, err := chainSelectors.ChainIdFromName(name); err == nil { - selector, err := chainSelectors.SelectorFromChainId(chainID) - if err != nil { - return 0, fmt.Errorf("failed to get selector from chain ID %d: %w", chainID, err) + switch { + case strings.HasPrefix(name, chainSelectors.FamilyAptos): + for _, c := range chainSelectors.AptosALL { + if c.Name == name { + return c.Selector, nil + } } - return selector, nil - } - - // Fallback to Aptos: chain-selectors has no AptosChainIdFromName, so scan. - for chainID := range chainSelectors.AptosChainIdToChainSelector() { - if n, err := chainSelectors.AptosNameFromChainId(chainID); err == nil && n == name { - sel, ok := chainSelectors.AptosChainIdToChainSelector()[chainID] - if ok { - return sel, nil + default: + if chainID, err := chainSelectors.ChainIdFromName(name); err == nil { + selector, err := chainSelectors.SelectorFromChainId(chainID) + if err != nil { + return 0, fmt.Errorf("failed to get selector from chain ID %d: %w", chainID, err) } + return selector, nil } } return 0, fmt.Errorf("failed to get chain ID from name %q: chain not found", name) From b444df2b86f08afa30717f04db90dc725f468c50 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Fri, 24 Apr 2026 16:26:35 +0100 Subject: [PATCH 10/28] chore(test): untrack local-only Aptos smoke scaffolding Remove aptos_cli_scenarios_test.go and test/test_project/aptos_smoke/ from version control and ignore them going forward. Files remain on disk for local iteration. --- .gitignore | 4 + test/aptos_cli_scenarios_test.go | 302 -------------------- test/test_project/aptos_smoke/config.json | 7 - test/test_project/aptos_smoke/go.mod | 20 -- test/test_project/aptos_smoke/go.sum | 26 -- test/test_project/aptos_smoke/main.go | 112 -------- test/test_project/aptos_smoke/project.yaml | 4 - test/test_project/aptos_smoke/workflow.yaml | 6 - 8 files changed, 4 insertions(+), 477 deletions(-) delete mode 100644 test/aptos_cli_scenarios_test.go delete mode 100644 test/test_project/aptos_smoke/config.json delete mode 100644 test/test_project/aptos_smoke/go.mod delete mode 100644 test/test_project/aptos_smoke/go.sum delete mode 100644 test/test_project/aptos_smoke/main.go delete mode 100644 test/test_project/aptos_smoke/project.yaml delete mode 100644 test/test_project/aptos_smoke/workflow.yaml diff --git a/.gitignore b/.gitignore index 521b4a59..140cfd1b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ encrypted.secrets.json # Output produced by e2e Anvil tests test/test.yaml + +# Local-only Aptos test scaffolding (untracked) +test/aptos_cli_scenarios_test.go +test/test_project/aptos_smoke/ diff --git a/test/aptos_cli_scenarios_test.go b/test/aptos_cli_scenarios_test.go deleted file mode 100644 index dc283726..00000000 --- a/test/aptos_cli_scenarios_test.go +++ /dev/null @@ -1,302 +0,0 @@ -package test - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/smartcontractkit/cre-cli/internal/credentials" - "github.com/smartcontractkit/cre-cli/internal/testutil" -) - -// TestCLIAptosSimulator_100DryRuns invokes the real cre binary against the -// aptos_smoke fixture 100 times with different config JSON inputs. All runs -// default to dry-run; a final block of scenarios exercises --broadcast error -// paths and UI/limits edges. Each scenario asserts expected stdout substrings -// that prove FakeAptosChain routed the capability call correctly. -// -// Skipped by default (requires live Aptos testnet). Enable with: -// -// CRE_APTOS_CLI_E2E=1 go test -v ./test -run TestCLIAptosSimulator_100DryRuns -// -// The test expects: ./bin/cre, /tmp/aptos_smoke.wasm. -func TestCLIAptosSimulator_100DryRuns(t *testing.T) { - if os.Getenv("CRE_APTOS_CLI_E2E") != "1" { - t.Skip("set CRE_APTOS_CLI_E2E=1 to run CLI e2e scenarios against Aptos testnet") - } - InitLogging() - - repoRoot, err := os.Getwd() - require.NoError(t, err) - repoRoot = filepath.Dir(repoRoot) // test/ -> repo root - - cliBin := filepath.Join(repoRoot, "bin", "cre") - require.FileExists(t, cliBin, "./bin/cre not built; run `go build -o ./bin/cre .`") - - - wasmPath := os.Getenv("APTOS_SMOKE_WASM") - if wasmPath == "" { - wasmPath = "/tmp/aptos_smoke.wasm" - } - require.FileExists(t, wasmPath, "WASM not built; set APTOS_SMOKE_WASM or run `cd test/test_project/aptos_smoke && GOOS=wasip1 GOARCH=wasm go build -o /tmp/aptos_smoke.wasm .`") - - projectDir := filepath.Join(repoRoot, "test", "test_project", "aptos_smoke") - - gql := testutil.NewGraphQLMockServerGetOrganization(t) - defer gql.Close() - t.Setenv(credentials.CreApiKeyVar, "test-api") - - validAddr := "0000000000000000000000000000000000000000000000000000000000000001" - unusedAddr := "0000000000000000000000000000000000000000000000000000000000000042" - - type sc struct { - name string - cfg map[string]any - expect string // substring that must appear in stdout/stderr - mayBeError bool // if true, errors from RPC are acceptable (still proves plumbing) - args []string // extra CLI args appended (nil = standard dry-run) - env []string // extra env vars (e.g. sentinel key override) - mustFail bool // process exit must be non-zero; expect substring then checked in stderr/stdout - } - - base := func(scenario, addr string) map[string]any { - return map[string]any{ - "schedule": "@every 30s", - "chain_selector": uint64(743186221051783445), - "scenario": scenario, - "address_hex": addr, - "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000000", - } - } - - // Expected substring is the workflow's return value (stable, flushed before - // simulator exit). User-log lines can be dropped if the log pipeline hasn't - // flushed before the sim terminates. - aptosTestnetSel := uint64(743186221051783445) - _ = aptosTestnetSel // only referenced in wrong_sel_testnet_unchanged below - scenarios := []sc{ - // --- 1-10 balance (happy-path address variations) --- - {name: "balance_addr1", cfg: base("balance", validAddr), expect: "\"balance:"}, - {name: "balance_addr2", cfg: base("balance", unusedAddr), expect: "\"balance:"}, - {name: "balance_zero", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000000"), expect: "balance:", mayBeError: true}, - {name: "balance_0x2", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000002"), expect: "\"balance:"}, - {name: "balance_0x3", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000003"), expect: "\"balance:"}, - {name: "balance_0x4", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000004"), expect: "\"balance:"}, - {name: "balance_0x5", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000005"), expect: "\"balance:"}, - {name: "balance_0x6", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000006"), expect: "\"balance:"}, - {name: "balance_0x7", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000007"), expect: "\"balance:"}, - {name: "balance_0xA", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000a"), expect: "\"balance:"}, - - // --- 11-15 view --- - {name: "view_coin_1", cfg: base("view", validAddr), expect: "\"view:", mayBeError: true}, - {name: "view_coin_2", cfg: base("view", unusedAddr), expect: "\"view:", mayBeError: true}, - {name: "view_coin_3", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000002"), expect: "\"view:", mayBeError: true}, - {name: "view_coin_4", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000003"), expect: "\"view:", mayBeError: true}, - {name: "view_coin_5", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000004"), expect: "\"view:", mayBeError: true}, - - // --- 16-20 tx-by-hash (nonexistent hashes → nil) --- - {name: "tx_missing_1", cfg: withHash(base("tx-by-hash", validAddr), "0x1111111111111111111111111111111111111111111111111111111111111111"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_missing_2", cfg: withHash(base("tx-by-hash", validAddr), "0x2222222222222222222222222222222222222222222222222222222222222222"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_missing_3", cfg: withHash(base("tx-by-hash", validAddr), "0x3333333333333333333333333333333333333333333333333333333333333333"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_missing_4", cfg: withHash(base("tx-by-hash", validAddr), "0x4444444444444444444444444444444444444444444444444444444444444444"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_missing_5", cfg: withHash(base("tx-by-hash", validAddr), "0x5555555555555555555555555555555555555555555555555555555555555555"), expect: "\"tx-by-hash:", mayBeError: true}, - - // --- 21-25 account-transactions --- - {name: "acct_tx_1", cfg: base("account-transactions", validAddr), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_2", cfg: base("account-transactions", unusedAddr), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_3", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000002"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_4", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000003"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_5", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000004"), expect: "\"account-transactions:", mayBeError: true}, - - // --- 26-30 additional testnet variations --- - {name: "balance_0xB", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000b"), expect: "\"balance:"}, - {name: "view_coin_6", cfg: base("view", "000000000000000000000000000000000000000000000000000000000000000c"), expect: "\"view:", mayBeError: true}, - {name: "tx_missing_6", cfg: withHash(base("tx-by-hash", validAddr), "0x6666666666666666666666666666666666666666666666666666666666666666"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "acct_tx_6", cfg: base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000d"), expect: "\"account-transactions:", mayBeError: true}, - {name: "balance_0xC", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000c"), expect: "\"balance:"}, - - // --- 31-40 more balance permutations (deterministic routing proof) --- - {name: "balance_0xD", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000d"), expect: "\"balance:"}, - {name: "balance_0xE", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000e"), expect: "\"balance:"}, - {name: "balance_0xF", cfg: base("balance", "000000000000000000000000000000000000000000000000000000000000000f"), expect: "\"balance:"}, - {name: "balance_high_bit", cfg: base("balance", "8000000000000000000000000000000000000000000000000000000000000000"), expect: "\"balance:"}, - {name: "balance_low_bit", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000001"), expect: "\"balance:"}, - {name: "balance_fan_out_1", cfg: base("balance", "1111111111111111111111111111111111111111111111111111111111111111"), expect: "\"balance:"}, - {name: "balance_fan_out_2", cfg: base("balance", "2222222222222222222222222222222222222222222222222222222222222222"), expect: "\"balance:"}, - {name: "balance_fan_out_3", cfg: base("balance", "3333333333333333333333333333333333333333333333333333333333333333"), expect: "\"balance:"}, - {name: "balance_fan_out_4", cfg: base("balance", "4444444444444444444444444444444444444444444444444444444444444444"), expect: "\"balance:"}, - {name: "balance_max_u256", cfg: base("balance", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), expect: "balance:", mayBeError: true}, - - // --- 41-50 view coin::balance edges --- - {name: "view_all_zero", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000000"), expect: "view:", mayBeError: true}, - {name: "view_all_one", cfg: base("view", "0101010101010101010101010101010101010101010101010101010101010101"), expect: "view:", mayBeError: true}, - {name: "view_all_f", cfg: base("view", "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), expect: "view:", mayBeError: true}, - {name: "view_canonical_1", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000010"), expect: "\"view:", mayBeError: true}, - {name: "view_canonical_2", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000020"), expect: "\"view:", mayBeError: true}, - {name: "view_canonical_3", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000030"), expect: "\"view:", mayBeError: true}, - {name: "view_canonical_4", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000040"), expect: "\"view:", mayBeError: true}, - {name: "view_canonical_5", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000050"), expect: "\"view:", mayBeError: true}, - {name: "view_canonical_6", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000060"), expect: "\"view:", mayBeError: true}, - {name: "view_canonical_7", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000070"), expect: "\"view:", mayBeError: true}, - - // --- 51-60 tx-by-hash randomised nonexistent hashes --- - {name: "tx_rand_1", cfg: withHash(base("tx-by-hash", validAddr), "0x7777777777777777777777777777777777777777777777777777777777777777"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_2", cfg: withHash(base("tx-by-hash", validAddr), "0x8888888888888888888888888888888888888888888888888888888888888888"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_3", cfg: withHash(base("tx-by-hash", validAddr), "0x9999999999999999999999999999999999999999999999999999999999999999"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_4", cfg: withHash(base("tx-by-hash", validAddr), "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_5", cfg: withHash(base("tx-by-hash", validAddr), "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_6", cfg: withHash(base("tx-by-hash", validAddr), "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_7", cfg: withHash(base("tx-by-hash", validAddr), "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_8", cfg: withHash(base("tx-by-hash", validAddr), "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_9", cfg: withHash(base("tx-by-hash", validAddr), "0x0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "tx_rand_10", cfg: withHash(base("tx-by-hash", validAddr), "0xdeadbeefcafebabefacefeeddeadbabedeadbeefcafebabefacefeeddeadbabe"), expect: "\"tx-by-hash:", mayBeError: true}, - - // --- 61-70 account-transactions fan-out --- - {name: "acct_tx_7", cfg: base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000e"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_8", cfg: base("account-transactions", "000000000000000000000000000000000000000000000000000000000000000f"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_9", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000010"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_10", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000020"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_11", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000030"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_12", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000040"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_13", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000050"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_14", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000060"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_15", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000070"), expect: "\"account-transactions:", mayBeError: true}, - {name: "acct_tx_16", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000080"), expect: "\"account-transactions:", mayBeError: true}, - - // --- 71-80 wrong selector / experimental chain rejection --- - // Any selector not in SupportedChains that also isn't wired via - // experimental-chains should surface a configuration error before the - // simulator dispatches to a capability. - {name: "wrong_sel_evm_mainnet", cfg: withSelector(base("balance", validAddr), 5009297550715157269), expect: "", mayBeError: true}, - {name: "wrong_sel_solana", cfg: withSelector(base("balance", validAddr), 124615329519749607), expect: "", mayBeError: true}, - {name: "wrong_sel_zero", cfg: withSelector(base("balance", validAddr), 0), expect: "", mayBeError: true}, - {name: "wrong_sel_one", cfg: withSelector(base("balance", validAddr), 1), expect: "", mayBeError: true}, - {name: "wrong_sel_large", cfg: withSelector(base("balance", validAddr), ^uint64(0)), expect: "", mayBeError: true}, - {name: "wrong_sel_aptos_mainnet_unwired", cfg: withSelector(base("balance", validAddr), 4741433654826277614), expect: "", mayBeError: true}, - {name: "wrong_sel_view_experimental", cfg: withSelector(base("view", validAddr), 99999999), expect: "", mayBeError: true}, - {name: "wrong_sel_tx_experimental", cfg: withSelector(withHash(base("tx-by-hash", validAddr), "0x1"), 99999999), expect: "", mayBeError: true}, - {name: "wrong_sel_acct_experimental", cfg: withSelector(base("account-transactions", validAddr), 99999999), expect: "", mayBeError: true}, - {name: "wrong_sel_testnet_unchanged", cfg: withSelector(base("balance", validAddr), aptosTestnetSel), expect: "\"balance:"}, - - // --- 81-90 UI / limits flag variations --- - {name: "limits_none", cfg: base("balance", validAddr), expect: "\"balance:", args: []string{"--limits", "none"}}, - {name: "limits_default", cfg: base("balance", validAddr), expect: "\"balance:"}, - {name: "non_interactive", cfg: base("balance", validAddr), expect: "\"balance:"}, - {name: "trigger_index_0", cfg: base("balance", validAddr), expect: "\"balance:", args: []string{"--trigger-index", "0"}}, - {name: "trigger_index_invalid", cfg: base("balance", validAddr), expect: "trigger", mustFail: true, args: []string{"--trigger-index", "99"}}, - {name: "help_global", cfg: nil, expect: "cre", args: []string{"--help"}}, - {name: "workflow_simulate_help", cfg: nil, expect: "simulate", args: []string{"workflow", "simulate", "--help"}}, - {name: "missing_wasm", cfg: base("balance", validAddr), expect: "wasm", mustFail: true, args: []string{"--wasm", "/tmp/does-not-exist.wasm"}}, - {name: "missing_config", cfg: nil, expect: "config", mustFail: true, args: []string{"--config", "/tmp/does-not-exist.json"}}, - {name: "empty_target", cfg: base("balance", validAddr), expect: "target", mustFail: true, env: []string{"CRE_TARGET="}}, - - // --- 91-100 broadcast + key edge cases (all must FAIL under dry-run - // binary without a real key/network path) --- - {name: "broadcast_sentinel_key_rejected", cfg: base("balance", validAddr), expect: "sentinel", mustFail: true, - args: []string{"--broadcast"}, - env: []string{"CRE_APTOS_PRIVATE_KEY=0000000000000000000000000000000000000000000000000000000000000001"}}, - {name: "broadcast_unparseable_key_rejected", cfg: base("balance", validAddr), expect: "CRE_APTOS_PRIVATE_KEY", mustFail: true, - args: []string{"--broadcast"}, - env: []string{"CRE_APTOS_PRIVATE_KEY=not-hex"}}, - {name: "broadcast_short_key_rejected", cfg: base("balance", validAddr), expect: "CRE_APTOS_PRIVATE_KEY", mustFail: true, - args: []string{"--broadcast"}, - env: []string{"CRE_APTOS_PRIVATE_KEY=0102"}}, - {name: "dryrun_sentinel_key_warns", cfg: base("balance", validAddr), expect: "default Aptos private key", - env: []string{"CRE_APTOS_PRIVATE_KEY="}}, - {name: "dryrun_valid_key_no_warning", cfg: base("balance", validAddr), expect: "\"balance:", - env: []string{"CRE_APTOS_PRIVATE_KEY=1111111111111111111111111111111111111111111111111111111111111111"}}, - {name: "balance_followup_1", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000101"), expect: "\"balance:"}, - {name: "balance_followup_2", cfg: base("balance", "0000000000000000000000000000000000000000000000000000000000000202"), expect: "\"balance:"}, - {name: "view_followup", cfg: base("view", "0000000000000000000000000000000000000000000000000000000000000303"), expect: "view:", mayBeError: true}, - {name: "tx_followup", cfg: withHash(base("tx-by-hash", validAddr), "0x00000000000000000000000000000000000000000000000000000000000000ff"), expect: "\"tx-by-hash:", mayBeError: true}, - {name: "acct_tx_followup", cfg: base("account-transactions", "0000000000000000000000000000000000000000000000000000000000000404"), expect: "\"account-transactions:", mayBeError: true}, - } - require.Len(t, scenarios, 100, "must have 100 CLI scenarios") - - for i, s := range scenarios { - i, s := i, s - t.Run(fmt.Sprintf("%03d_%s", i+1, s.name), func(t *testing.T) { - var args []string - // Scenarios that don't supply cfg (help / purely CLI-arg-driven) - // skip the config-file plumbing entirely. - if s.cfg != nil { - cfgPath := fmt.Sprintf("/tmp/apcfg_%03d.json", i+1) - data, err := json.Marshal(s.cfg) - require.NoError(t, err) - require.NoError(t, os.WriteFile(cfgPath, data, 0644)) - defer os.Remove(cfgPath) - - args = []string{ - "-T", "dev-aptos-testnet", - "-R", projectDir, - "workflow", "simulate", projectDir, - "--wasm", wasmPath, - "--config", cfgPath, - "--non-interactive", - "--trigger-index", "0", - "--limits", "none", - } - } - // Scenario-specific overrides are appended last so they win over - // the defaults above (e.g. a different --wasm path). - args = append(args, s.args...) - - cmd := exec.Command(cliBin, args...) - cmd.Env = append(os.Environ(), - "CRE_API_KEY=test-api", - ) - cmd.Env = append(cmd.Env, s.env...) - var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out - err := cmd.Run() - combined := out.String() - t.Logf("cre output:\n%s", combined) - - if s.mustFail { - require.Error(t, err, "scenario %q expected to fail", s.name) - require.Contains(t, combined, s.expect, "scenario %q missing expected error substring", s.name) - return - } - - // Help / no-cfg scenarios only need the expected substring — the - // simulator markers are cron-specific and don't apply. - if s.cfg == nil { - require.NoError(t, err, "scenario %q expected to succeed", s.name) - require.Contains(t, combined, s.expect, "scenario %q missing expected substring", s.name) - return - } - - // Every simulator run must reach init + trigger dispatch + result. - require.Contains(t, combined, "Simulator Initialized", "scenario %q: simulator did not initialise", s.name) - require.Contains(t, combined, "Running trigger trigger=cron-trigger", "scenario %q: cron trigger did not fire", s.name) - require.Contains(t, combined, "Workflow Simulation Result:", "scenario %q: workflow did not return", s.name) - if s.mayBeError { - // Success substring OR an err: string that names the method - // (e.g. "err:...view function") — either proves routing reached - // the Aptos capability. - if strings.Contains(combined, s.expect) || strings.Contains(combined, "\"err:") { - return - } - } - require.Contains(t, combined, s.expect, "scenario %q missing expected substring", s.name) - }) - } -} - -func withHash(m map[string]any, h string) map[string]any { - m["tx_hash"] = h - return m -} - -func withSelector(m map[string]any, s uint64) map[string]any { - m["chain_selector"] = s - return m -} diff --git a/test/test_project/aptos_smoke/config.json b/test/test_project/aptos_smoke/config.json deleted file mode 100644 index 030ecff9..00000000 --- a/test/test_project/aptos_smoke/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schedule": "@every 30s", - "chain_selector": 743186221051783445, - "scenario": "balance", - "address_hex": "0000000000000000000000000000000000000000000000000000000000000001", - "tx_hash": "0x0000000000000000000000000000000000000000000000000000000000000000" -} diff --git a/test/test_project/aptos_smoke/go.mod b/test/test_project/aptos_smoke/go.mod deleted file mode 100644 index bc22d102..00000000 --- a/test/test_project/aptos_smoke/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -module aptos_smoke - -go 1.25.3 - -require ( - github.com/smartcontractkit/cre-sdk-go v1.7.0 - github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37 - github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 -) - -require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/shopspring/decimal v1.4.0 // indirect - github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260312152957-059f906b6597 // indirect - github.com/stretchr/testify v1.11.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/test/test_project/aptos_smoke/go.sum b/test/test_project/aptos_smoke/go.sum deleted file mode 100644 index 81229c39..00000000 --- a/test/test_project/aptos_smoke/go.sum +++ /dev/null @@ -1,26 +0,0 @@ -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260312152957-059f906b6597 h1:0k5sfKsr3rG2l3HS6o6b6BYg4PaamD6HZ9MUAxP+0Ik= -github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260312152957-059f906b6597/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= -github.com/smartcontractkit/cre-sdk-go v1.7.0 h1:MtaJ4jXS/5RcRCrjoza52/g3c0qrGXGB3V5yO9l6tUA= -github.com/smartcontractkit/cre-sdk-go v1.7.0/go.mod h1:yYrQFz1UH7hhRbPO0q4fgo1tfsJNd4yXnI3oCZE0RzM= -github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37 h1:UNem52lhklNEp4VdPBYHN+p1wgG0vDYEKSvonQgV+3o= -github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37/go.mod h1:Ht8wSAAJRJgaZig5kwE5ogRJFngEmfQqBO0e+0y0Zng= -github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 h1:qBZ4y6qlTOynSpU1QAi2Fgr3tUZQ332b6hit9EVZqkk= -github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0/go.mod h1:Rzhy75vD3FqQo/SV6lypnxIwjWac6IOWzI5BYj3tYMU= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/test_project/aptos_smoke/main.go b/test/test_project/aptos_smoke/main.go deleted file mode 100644 index 475a9db7..00000000 --- a/test/test_project/aptos_smoke/main.go +++ /dev/null @@ -1,112 +0,0 @@ -//go:build wasip1 - -package main - -import ( - "encoding/hex" - "fmt" - "log/slog" - - "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos" - "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" - "github.com/smartcontractkit/cre-sdk-go/cre" - "github.com/smartcontractkit/cre-sdk-go/cre/wasm" -) - -// Config drives which aptos capability method the handler exercises. -// -// Scenario values: -// -// balance - AccountAPTBalance -// view - View of coin::balance -// tx-by-hash - TransactionByHash (expect "not found" path) -// account-transactions - AccountTransactions pagination=1 -type Config struct { - Schedule string `json:"schedule"` - ChainSelector uint64 `json:"chain_selector"` - Scenario string `json:"scenario"` - AddressHex string `json:"address_hex"` // 32-byte hex, no 0x prefix - TxHash string `json:"tx_hash"` -} - -func InitWorkflow(cfg *Config, _ *slog.Logger, _ cre.SecretsProvider) (cre.Workflow[*Config], error) { - return cre.Workflow[*Config]{ - cre.Handler(cron.Trigger(&cron.Config{Schedule: cfg.Schedule}), runHandler), - }, nil -} - -func runHandler(cfg *Config, rt cre.Runtime, _ *cron.Payload) (string, error) { - log := rt.Logger() - client := &aptos.Client{ChainSelector: cfg.ChainSelector} - - addr, err := hex.DecodeString(cfg.AddressHex) - if err != nil { - return "", fmt.Errorf("bad address hex: %w", err) - } - - switch cfg.Scenario { - case "balance": - reply, err := client.AccountAPTBalance(rt, &aptos.AccountAPTBalanceRequest{Address: addr}).Await() - if err != nil { - log.Info("aptos-smoke: balance failed", "err", err.Error()) - return "err:" + err.Error(), nil - } - log.Info("aptos-smoke: balance", "octas", reply.Value) - return fmt.Sprintf("balance:%d", reply.Value), nil - - case "view": - payload := &aptos.ViewRequest{ - Payload: &aptos.ViewPayload{ - Module: &aptos.ModuleID{Address: aptosOneAddr(), Name: "coin"}, - Function: "balance", - ArgTypes: nil, - Args: [][]byte{addr}, - }, - } - reply, err := client.View(rt, payload).Await() - if err != nil { - log.Info("aptos-smoke: view failed", "err", err.Error()) - return "err:" + err.Error(), nil - } - log.Info("aptos-smoke: view", "bytes", len(reply.Data)) - return fmt.Sprintf("view:%d", len(reply.Data)), nil - - case "tx-by-hash": - reply, err := client.TransactionByHash(rt, &aptos.TransactionByHashRequest{Hash: cfg.TxHash}).Await() - if err != nil { - log.Info("aptos-smoke: tx-by-hash failed", "err", err.Error()) - return "err:" + err.Error(), nil - } - if reply.Transaction == nil { - log.Info("aptos-smoke: tx-by-hash missing") - return "tx-by-hash:nil", nil - } - log.Info("aptos-smoke: tx-by-hash", "hash", reply.Transaction.Hash) - return "tx-by-hash:" + reply.Transaction.Hash, nil - - case "account-transactions": - var one uint64 = 1 - reply, err := client.AccountTransactions(rt, &aptos.AccountTransactionsRequest{ - Address: addr, - Limit: &one, - }).Await() - if err != nil { - log.Info("aptos-smoke: account-transactions failed", "err", err.Error()) - return "err:" + err.Error(), nil - } - log.Info("aptos-smoke: account-transactions", "count", len(reply.Transactions)) - return fmt.Sprintf("account-transactions:%d", len(reply.Transactions)), nil - } - return "", fmt.Errorf("unknown scenario %q", cfg.Scenario) -} - -// aptosOneAddr returns the 32-byte address 0x01 as required by coin module. -func aptosOneAddr() []byte { - out := make([]byte, 32) - out[31] = 0x01 - return out -} - -func main() { - wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow) -} diff --git a/test/test_project/aptos_smoke/project.yaml b/test/test_project/aptos_smoke/project.yaml deleted file mode 100644 index 395fedb5..00000000 --- a/test/test_project/aptos_smoke/project.yaml +++ /dev/null @@ -1,4 +0,0 @@ -dev-aptos-testnet: - rpcs: - - chain-name: aptos-testnet - url: https://api.testnet.aptoslabs.com/v1 diff --git a/test/test_project/aptos_smoke/workflow.yaml b/test/test_project/aptos_smoke/workflow.yaml deleted file mode 100644 index 251a11e8..00000000 --- a/test/test_project/aptos_smoke/workflow.yaml +++ /dev/null @@ -1,6 +0,0 @@ -dev-aptos-testnet: - user-workflow: - workflow-name: "aptos-smoke" - workflow-artifacts: - workflow-path: "./main.go" - config-path: "./config.json" From 7ebd892dfce343afb7be23658d2a1dcf0818b200 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Sat, 25 Apr 2026 00:43:26 +0100 Subject: [PATCH 11/28] refactor(simulate): centralize chain limits, untrack local Aptos scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move per-family Limits struct definition into chain package as the shared type used by EVM and Aptos plugin wrappers. Each plugin keeps its own ExtractLimits to populate the struct from cresettings.Workflows. Aptos's GasLimit field maps to max_gas_amount. Untrack local Aptos simulator_scenarios_test.go (kept on disk via .gitignore) — these scenarios are local-only smoke fixtures. --- .gitignore | 1 + .../simulate/chain/aptos/capabilities.go | 9 +- .../simulate/chain/aptos/chaintype.go | 8 +- .../chain/aptos/limited_capabilities.go | 14 +- .../chain/aptos/limited_capabilities_test.go | 15 +- cmd/workflow/simulate/chain/aptos/limits.go | 14 + .../simulate/chain/aptos/limits_test.go | 28 + .../chain/aptos/simulator_scenarios_test.go | 1054 ----------------- .../simulate/chain/evm/capabilities.go | 10 +- cmd/workflow/simulate/chain/evm/chaintype.go | 11 +- .../chain/evm/limited_capabilities.go | 26 +- .../chain/evm/limited_capabilities_test.go | 16 +- cmd/workflow/simulate/chain/evm/limits.go | 14 + .../simulate/chain/evm/limits_test.go | 28 + cmd/workflow/simulate/chain/types.go | 18 +- cmd/workflow/simulate/limits.go | 10 - cmd/workflow/simulate/limits_test.go | 15 +- cmd/workflow/simulate/simulate.go | 8 +- 18 files changed, 137 insertions(+), 1162 deletions(-) create mode 100644 cmd/workflow/simulate/chain/aptos/limits.go create mode 100644 cmd/workflow/simulate/chain/aptos/limits_test.go delete mode 100644 cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go create mode 100644 cmd/workflow/simulate/chain/evm/limits.go create mode 100644 cmd/workflow/simulate/chain/evm/limits_test.go diff --git a/.gitignore b/.gitignore index 140cfd1b..8911ff74 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ test/test.yaml # Local-only Aptos test scaffolding (untracked) test/aptos_cli_scenarios_test.go test/test_project/aptos_smoke/ +cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go diff --git a/cmd/workflow/simulate/chain/aptos/capabilities.go b/cmd/workflow/simulate/chain/aptos/capabilities.go index 051af9f3..308debc1 100644 --- a/cmd/workflow/simulate/chain/aptos/capabilities.go +++ b/cmd/workflow/simulate/chain/aptos/capabilities.go @@ -12,6 +12,8 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/capabilities" aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) // AptosChainCapabilities holds the per-selector FakeAptosChain instances @@ -31,7 +33,7 @@ func NewAptosChainCapabilities( forwarders map[uint64]string, privateKey *crypto.Ed25519PrivateKey, dryRunChainWrite bool, - limits AptosChainLimits, + limits chain.Limits, ) (*AptosChainCapabilities, error) { chains := make(map[uint64]*aptosfakes.FakeAptosChain) for sel, client := range clients { @@ -48,10 +50,7 @@ func NewAptosChainCapabilities( if err != nil { return nil, fmt.Errorf("new FakeAptosChain for selector %d: %w", sel, err) } - var capability aptosserver.ClientCapability = fc - if limits != nil { - capability = NewLimitedAptosChain(fc, limits) - } + capability := NewLimitedAptosChain(fc, limits) server := aptosserver.NewClientServer(capability) if err := registry.Add(ctx, server); err != nil { return nil, fmt.Errorf("register aptos capability for selector %d: %w", sel, err) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index c45b0d6a..28eaf47f 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -151,13 +151,9 @@ func (ct *AptosChainType) RegisterCapabilities(ctx context.Context, cfg chain.Ca return nil, fmt.Errorf("aptos: private key is not *crypto.Ed25519PrivateKey (got %T)", cfg.PrivateKey) } } - var lim AptosChainLimits + var lim chain.Limits if cfg.Limits != nil { - al, ok := cfg.Limits.(AptosChainLimits) - if !ok { - return nil, fmt.Errorf("aptos: limits does not implement AptosChainLimits (got %T)", cfg.Limits) - } - lim = al + lim = ExtractLimits(cfg.Limits) } caps, err := NewAptosChainCapabilities(ctx, cfg.Logger, cfg.Registry, typedClients, cfg.Forwarders, pk, !cfg.Broadcast, lim) if err != nil { diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go index 774ef8fb..6c476da2 100644 --- a/cmd/workflow/simulate/chain/aptos/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go @@ -13,27 +13,21 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) -// AptosChainLimits extends chain.Limits with the Aptos gas-amount limit. -type AptosChainLimits interface { - chain.Limits - ChainWriteAptosMaxGasAmount() uint64 -} - // LimitedAptosChain enforces chain-write size + Aptos max_gas_amount. type LimitedAptosChain struct { inner aptosserver.ClientCapability - limits AptosChainLimits + limits chain.Limits } var _ aptosserver.ClientCapability = (*LimitedAptosChain)(nil) -func NewLimitedAptosChain(inner aptosserver.ClientCapability, limits AptosChainLimits) *LimitedAptosChain { +func NewLimitedAptosChain(inner aptosserver.ClientCapability, limits chain.Limits) *LimitedAptosChain { return &LimitedAptosChain{inner: inner, limits: limits} } func (l *LimitedAptosChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *aptoscappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.WriteReportReply], caperrors.Error) { if input != nil && input.Report != nil { - if lim := l.limits.ChainWriteReportSizeLimit(); lim > 0 && len(input.Report.RawReport) > lim { + if lim := l.limits.ReportSize; lim > 0 && len(input.Report.RawReport) > lim { return nil, caperrors.NewPublicUserError( fmt.Errorf("simulation limit exceeded: aptos report size %d > %d", len(input.Report.RawReport), lim), caperrors.ResourceExhausted, @@ -41,7 +35,7 @@ func (l *LimitedAptosChain) WriteReport(ctx context.Context, metadata commonCap. } } if input != nil && input.GasConfig != nil { - if gl := l.limits.ChainWriteAptosMaxGasAmount(); gl > 0 && input.GasConfig.MaxGasAmount > gl { + if gl := l.limits.GasLimit; gl > 0 && input.GasConfig.MaxGasAmount > gl { return nil, caperrors.NewPublicUserError( fmt.Errorf("simulation limit exceeded: aptos max_gas_amount %d > %d", input.GasConfig.MaxGasAmount, gl), caperrors.ResourceExhausted, diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go index 89d2a745..7e461402 100644 --- a/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go @@ -12,15 +12,10 @@ import ( aptoscappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdk "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" -) -type stubLimits struct { - reportSize int - maxGas uint64 -} + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) -func (s stubLimits) ChainWriteReportSizeLimit() int { return s.reportSize } -func (s stubLimits) ChainWriteAptosMaxGasAmount() uint64 { return s.maxGas } type stubCap struct{ writeCalled bool } @@ -54,7 +49,7 @@ func (s *stubCap) Initialise(context.Context, core.StandardCapabilitiesDependenc func TestLimitedAptosChain_WriteReport_ReportTooLarge(t *testing.T) { t.Parallel() inner := &stubCap{} - l := NewLimitedAptosChain(inner, stubLimits{reportSize: 10, maxGas: 1000}) + l := NewLimitedAptosChain(inner, chain.Limits{ReportSize: 10, GasLimit: 1000}) _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ Report: &sdk.ReportResponse{RawReport: make([]byte, 11)}, }) @@ -65,7 +60,7 @@ func TestLimitedAptosChain_WriteReport_ReportTooLarge(t *testing.T) { func TestLimitedAptosChain_WriteReport_MaxGasTooHigh(t *testing.T) { t.Parallel() inner := &stubCap{} - l := NewLimitedAptosChain(inner, stubLimits{reportSize: 100, maxGas: 100}) + l := NewLimitedAptosChain(inner, chain.Limits{ReportSize: 100, GasLimit: 100}) _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ Report: &sdk.ReportResponse{RawReport: []byte("x")}, GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 101}, @@ -77,7 +72,7 @@ func TestLimitedAptosChain_WriteReport_MaxGasTooHigh(t *testing.T) { func TestLimitedAptosChain_WriteReport_Delegates(t *testing.T) { t.Parallel() inner := &stubCap{} - l := NewLimitedAptosChain(inner, stubLimits{reportSize: 100, maxGas: 1000}) + l := NewLimitedAptosChain(inner, chain.Limits{ReportSize: 100, GasLimit: 1000}) _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ Report: &sdk.ReportResponse{RawReport: []byte("x")}, GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 50}, diff --git a/cmd/workflow/simulate/chain/aptos/limits.go b/cmd/workflow/simulate/chain/aptos/limits.go new file mode 100644 index 00000000..e21771e0 --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/limits.go @@ -0,0 +1,14 @@ +package aptos + +import ( + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +func ExtractLimits(w *cresettings.Workflows) chain.Limits { + return chain.Limits{ + ReportSize: int(w.ChainWrite.ReportSizeLimit.DefaultValue), + GasLimit: w.ChainWrite.Aptos.GasLimit.Default.DefaultValue, + } +} diff --git a/cmd/workflow/simulate/chain/aptos/limits_test.go b/cmd/workflow/simulate/chain/aptos/limits_test.go new file mode 100644 index 00000000..8a831907 --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/limits_test.go @@ -0,0 +1,28 @@ +package aptos + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" +) + +func TestExtractLimitsFromDefault(t *testing.T) { + t.Parallel() + w := cresettings.Default.PerWorkflow + lim := ExtractLimits(&w) + assert.Equal(t, 5_000, lim.ReportSize) + assert.Equal(t, uint64(2_000_000), lim.GasLimit) +} + +func TestExtractLimitsAfterJSONOverride(t *testing.T) { + t.Parallel() + w := cresettings.Default.PerWorkflow + require.NoError(t, json.Unmarshal([]byte(`{ + "ChainWrite": {"Aptos": {"GasLimit": {"Default": "456"}}} + }`), &w)) + assert.Equal(t, uint64(456), ExtractLimits(&w).GasLimit) +} diff --git a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go deleted file mode 100644 index 72d78365..00000000 --- a/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go +++ /dev/null @@ -1,1054 +0,0 @@ -package aptos - -// simulator_scenarios_test.go runs 30 dry-run scenarios exercising FakeAptosChain -// via the simulator plumbing. All scenarios are fully in-process: no network I/O, -// no --broadcast. They verify parity with the EVM simulator's behavioural surface -// (success paths, validation errors, limit enforcement, per-selector dispatch, -// key resolution semantics). - -import ( - "context" - "fmt" - "strings" - "sync" - "testing" - - "github.com/aptos-labs/aptos-go-sdk" - "github.com/aptos-labs/aptos-go-sdk/api" - "github.com/aptos-labs/aptos-go-sdk/crypto" - "github.com/rs/zerolog" - chainselectors "github.com/smartcontractkit/chain-selectors" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" - caperrors "github.com/smartcontractkit/chainlink-common/pkg/capabilities/errors" - aptoscappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos" - "github.com/smartcontractkit/chainlink-common/pkg/logger" - "github.com/smartcontractkit/chainlink-common/pkg/types/core" - sdk "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" - "github.com/smartcontractkit/chainlink/v2/core/capabilities" - - aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" - mocks "github.com/smartcontractkit/chainlink-aptos/relayer/monitor/mocks" - - "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" - "github.com/smartcontractkit/cre-cli/internal/settings" -) - -// simScenario is a self-contained dry-run scenario. -type simScenario struct { - name string - run func(t *testing.T) -} - -// mkAddr returns a 32-byte address whose first byte is b. -func mkAddr(b byte) []byte { out := make([]byte, 32); out[0] = b; return out } - -func testAddr(t *testing.T, s string) aptos.AccountAddress { - t.Helper() - var a aptos.AccountAddress - require.NoError(t, a.ParseStringRelaxed(s)) - return a -} - -func newKey(t *testing.T) *crypto.Ed25519PrivateKey { - t.Helper() - k, err := crypto.GenerateEd25519PrivateKey() - require.NoError(t, err) - return k -} - -func newChain(t *testing.T, rpc *mocks.AptosRpcClient, dryRun bool, selector uint64) *aptosfakes.FakeAptosChain { - t.Helper() - fc, err := aptosfakes.NewFakeAptosChain(logger.Test(t), rpc, newKey(t), - testAddr(t, "0xdead"), selector, dryRun) - require.NoError(t, err) - return fc -} - -func simulatorScenarios() []simScenario { - meta := commonCap.RequestMetadata{} - ctx := context.Background() - - validGas := func() *aptoscappb.GasConfig { - return &aptoscappb.GasConfig{MaxGasAmount: 10_000, GasUnitPrice: 100} - } - validReport := func() *sdk.ReportResponse { - return &sdk.ReportResponse{RawReport: []byte("report")} - } - - return []simScenario{ - // --- read-path scenarios (1-10) --- - {name: "01 AccountAPTBalance returns u64", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(12345), nil).Once() - fc := newChain(t, rpc, true, chainselectors.APTOS_TESTNET.Selector) - reply, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0xA1)}) - require.Nil(t, capErr) - assert.Equal(t, uint64(12345), reply.Response.Value) - }}, - {name: "02 AccountAPTBalance rejects nil", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.AccountAPTBalance(ctx, meta, nil) - require.NotNil(t, capErr) - }}, - {name: "03 AccountAPTBalance rejects short address", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: []byte{1, 2, 3}}) - require.NotNil(t, capErr) - }}, - {name: "04 AccountAPTBalance surfaces RPC error", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(0), fmt.Errorf("503")).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x01)}) - require.NotNil(t, capErr) - }}, - {name: "05 View returns JSON-marshaled result array", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().View(mock.Anything).Return([]any{"hello"}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{ - Module: &aptoscappb.ModuleID{Address: mkAddr(0x01), Name: "m"}, - Function: "f", - }, - }) - require.Nil(t, capErr) - assert.Equal(t, []byte(`["hello"]`), reply.Response.Data) - }}, - {name: "06 View rejects nil payload", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{}) - require.NotNil(t, capErr) - }}, - {name: "07 View respects ledger_version", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().View(mock.Anything, mock.Anything).Return([]any{"0"}, nil).Once() - fc := newChain(t, rpc, true, 1) - ledger := uint64(42) - _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, - LedgerVersion: &ledger, - }) - require.Nil(t, capErr) - }}, - {name: "08 TransactionByHash found", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().TransactionByHash("0x1").Return(&api.Transaction{ - Type: api.TransactionVariantUser, Inner: &api.UserTransaction{Hash: "0x1", Version: 1, Success: true}, - }, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0x1"}) - require.Nil(t, capErr) - require.NotNil(t, reply.Response.Transaction) - assert.Equal(t, "0x1", reply.Response.Transaction.Hash) - }}, - {name: "09 TransactionByHash missing returns nil tx", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().TransactionByHash(mock.Anything).Return(nil, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0xnope"}) - require.Nil(t, capErr) - assert.Nil(t, reply.Response.Transaction) - }}, - {name: "10 TransactionByHash empty hash rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: ""}) - require.NotNil(t, capErr) - }}, - - // --- pagination + account list (11-13) --- - {name: "11 AccountTransactions delegates pagination", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountTransactions(mock.Anything, mock.Anything, mock.Anything). - Return([]*api.CommittedTransaction{ - {Type: api.TransactionVariantUser, Inner: &api.UserTransaction{Hash: "0xa"}}, - }, nil).Once() - fc := newChain(t, rpc, true, 1) - s, l := uint64(0), uint64(10) - reply, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{ - Address: mkAddr(0x01), Start: &s, Limit: &l, - }) - require.Nil(t, capErr) - require.Len(t, reply.Response.Transactions, 1) - }}, - {name: "12 AccountTransactions rejects bad address", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: []byte{1}}) - require.NotNil(t, capErr) - }}, - {name: "13 AccountTransactions rpc error surfaced", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountTransactions(mock.Anything, mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("transport")).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: mkAddr(0x01)}) - require.NotNil(t, capErr) - }}, - - // --- WriteReport validation (14-17) --- - {name: "14 WriteReport nil request rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.WriteReport(ctx, meta, nil) - require.NotNil(t, capErr) - }}, - {name: "15 WriteReport nil gas config rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{Receiver: mkAddr(1), Report: validReport()}) - require.NotNil(t, capErr) - }}, - {name: "16 WriteReport nil report rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{Receiver: mkAddr(1), GasConfig: validGas()}) - require.NotNil(t, capErr) - }}, - {name: "17 WriteReport bad receiver len rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: []byte{1}, GasConfig: validGas(), Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - - // --- WriteReport dry-run behaviour (18-22) --- - {name: "18 WriteReport dry-run SUCCESS, no tx hash, zero fee", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.Nil(t, capErr) - assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_SUCCESS, reply.Response.TxStatus) - assert.Nil(t, reply.Response.TxHash) - require.NotNil(t, reply.Response.TransactionFee) - assert.Zero(t, *reply.Response.TransactionFee) - }}, - {name: "19 WriteReport dry-run receiver abort -> REVERTED", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: false, VmStatus: "Move abort in 0xabc::receiver: Reject"}}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, _ := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) - require.NotNil(t, reply.Response.ReceiverContractExecutionStatus) - assert.Equal(t, - aptoscappb.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_REVERTED, - *reply.Response.ReceiverContractExecutionStatus) - }}, - {name: "20 WriteReport dry-run forwarder abort -> no receiver status", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: false, VmStatus: "Move abort in 0xdead::mock_forwarder: Bad"}}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, _ := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) - assert.Nil(t, reply.Response.ReceiverContractExecutionStatus) - }}, - {name: "21 WriteReport dry-run BuildTransaction error surfaces", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("rpc-down")).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - {name: "22 WriteReport dry-run Simulate error surfaces", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("sim-fail")).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - - // --- LimitedAptosChain enforcement (23-26) --- - {name: "23 LimitedAptosChain blocks oversized report", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 5, maxGas: 10_000}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), - Report: &sdk.ReportResponse{RawReport: make([]byte, 999)}, - }) - require.NotNil(t, capErr) - }}, - {name: "24 LimitedAptosChain blocks excessive max_gas_amount", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 50}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 100, GasUnitPrice: 1}, - Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - {name: "25 LimitedAptosChain passes through within limits (dry-run)", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 100_000}) - reply, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.Nil(t, capErr) - assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_SUCCESS, reply.Response.TxStatus) - }}, - {name: "26 LimitedAptosChain delegates reads unconditionally", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(9), nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1, maxGas: 1}) - reply, capErr := l.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x02)}) - require.Nil(t, capErr) - assert.Equal(t, uint64(9), reply.Response.Value) - }}, - - // --- Multi-selector + key-resolution semantics (27-30) --- - {name: "27 Per-selector dispatch isolates chains", run: func(t *testing.T) { - rpcA := mocks.NewAptosRpcClient(t) - rpcB := mocks.NewAptosRpcClient(t) - rpcA.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(100), nil).Once() - rpcB.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(200), nil).Once() - fcA := newChain(t, rpcA, true, chainselectors.APTOS_MAINNET.Selector) - fcB := newChain(t, rpcB, true, chainselectors.APTOS_TESTNET.Selector) - rA, _ := fcA.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x01)}) - rB, _ := fcB.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(0x02)}) - assert.Equal(t, uint64(100), rA.Response.Value) - assert.Equal(t, uint64(200), rB.Response.Value) - }}, - {name: "28 ResolveKey sentinel OK under dry-run", run: func(t *testing.T) { - ct := &AptosChainType{} - k, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: ""}}}, false) - require.NoError(t, err) - require.NotNil(t, k) - }}, - {name: "29 ResolveKey rejects sentinel under --broadcast", run: func(t *testing.T) { - ct := &AptosChainType{} - _, err := ct.ResolveKey(&settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: defaultSentinelAptosSeed}}}, true) - require.Error(t, err) - }}, - // --- chain-type plugin surface (31-45) --- - {name: "31 ChainType.Name returns aptos", run: func(t *testing.T) { - ct := &AptosChainType{} - assert.Equal(t, "aptos", ct.Name()) - }}, - {name: "32 SupportedChains lists mainnet and testnet", run: func(t *testing.T) { - ct := &AptosChainType{} - cfgs := ct.SupportedChains() - selectors := map[uint64]bool{} - for _, c := range cfgs { - selectors[c.Selector] = true - } - assert.True(t, selectors[chainselectors.APTOS_MAINNET.Selector]) - assert.True(t, selectors[chainselectors.APTOS_TESTNET.Selector]) - }}, - {name: "33 Supports false when capabilities unset", run: func(t *testing.T) { - ct := &AptosChainType{} - assert.False(t, ct.Supports(chainselectors.APTOS_TESTNET.Selector)) - }}, - {name: "34 Supports false for evm-shaped selector", run: func(t *testing.T) { - ct := &AptosChainType{} - assert.False(t, ct.Supports(1)) - }}, - {name: "35 ParseTriggerChainSelector accepts aptos prefix", run: func(t *testing.T) { - ct := &AptosChainType{} - sel, ok := ct.ParseTriggerChainSelector("aptos:ChainSelector:4741433654826277614@1.0.0") - require.True(t, ok) - assert.Equal(t, uint64(4741433654826277614), sel) - }}, - {name: "36 ParseTriggerChainSelector rejects evm prefix", run: func(t *testing.T) { - ct := &AptosChainType{} - _, ok := ct.ParseTriggerChainSelector("evm:ChainSelector:1@1.0.0") - assert.False(t, ok) - }}, - {name: "37 ParseTriggerChainSelector rejects malformed id", run: func(t *testing.T) { - ct := &AptosChainType{} - _, ok := ct.ParseTriggerChainSelector("aptos:BadFormat") - assert.False(t, ok) - }}, - {name: "38 CollectCLIInputs returns empty map", run: func(t *testing.T) { - ct := &AptosChainType{} - got := ct.CollectCLIInputs(nil) - assert.Empty(t, got) - }}, - {name: "39 ExecuteTrigger returns explicit no-trigger error", run: func(t *testing.T) { - ct := &AptosChainType{} - err := ct.ExecuteTrigger(ctx, 1, "tid", nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "no trigger surface") - }}, - {name: "40 ResolveTriggerData returns no-trigger error", run: func(t *testing.T) { - ct := &AptosChainType{} - _, err := ct.ResolveTriggerData(ctx, 1, chain.TriggerParams{}) - require.Error(t, err) - }}, - {name: "41 ResolveClients with empty viper returns no clients", run: func(t *testing.T) { - ct := newAptosChainTypeForTest(t) - v := viper.New() - resolved, err := ct.ResolveClients(v) - require.NoError(t, err) - assert.Empty(t, resolved.Clients) - assert.Empty(t, resolved.Forwarders) - }}, - {name: "42 ResolveKey parses 0x-prefixed seed", run: func(t *testing.T) { - ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "0x2222222222222222222222222222222222222222222222222222222222222222"}}} - k, err := ct.ResolveKey(s, true) - require.NoError(t, err) - require.NotNil(t, k) - }}, - {name: "43 ResolveKey parses uppercase hex", run: func(t *testing.T) { - ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"}}} - k, err := ct.ResolveKey(s, true) - require.NoError(t, err) - require.NotNil(t, k) - }}, - {name: "44 ResolveKey trims whitespace", run: func(t *testing.T) { - ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: " 1111111111111111111111111111111111111111111111111111111111111111 "}}} - k, err := ct.ResolveKey(s, true) - require.NoError(t, err) - require.NotNil(t, k) - }}, - {name: "45 ResolveKey short seed hard-fails under broadcast", run: func(t *testing.T) { - ct := &AptosChainType{} - s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "0102"}}} - _, err := ct.ResolveKey(s, true) - require.Error(t, err) - assert.Contains(t, err.Error(), "CRE_APTOS_PRIVATE_KEY") - }}, - - // --- wrong-type / wrong-selector rejections in RegisterCapabilities (46-52) --- - {name: "46 RegisterCapabilities rejects wrong client type", run: func(t *testing.T) { - ct := &AptosChainType{} - _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ - Clients: map[uint64]chain.ChainClient{1: "not-an-aptos-client"}, - Logger: logger.Test(t), - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "not aptosfakes.AptosClient") - }}, - {name: "47 RegisterCapabilities rejects wrong private-key type", run: func(t *testing.T) { - ct := &AptosChainType{} - _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ - Clients: map[uint64]chain.ChainClient{}, - PrivateKey: "this is not an Ed25519PrivateKey", - Logger: logger.Test(t), - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "*crypto.Ed25519PrivateKey") - }}, - {name: "48 RegisterCapabilities rejects wrong limits type", run: func(t *testing.T) { - ct := &AptosChainType{} - _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ - Clients: map[uint64]chain.ChainClient{}, - Limits: badLimits{}, - Logger: logger.Test(t), - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "AptosChainLimits") - }}, - {name: "49 RegisterCapabilities with unknown selector (experimental) wires fake", run: func(t *testing.T) { - // 404040 is not in SupportedChains — still gets a FakeAptosChain because - // ResolveClients is the gatekeeper for selector-vs-supported, not Register. - pk := newKey(t) - rpc := mocks.NewAptosRpcClient(t) - ct := &AptosChainType{} - _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ - Registry: scenarioRegistry(t), - Clients: map[uint64]chain.ChainClient{404040: aptosfakes.AptosClient(rpc)}, - Forwarders: map[uint64]string{404040: "0xdead"}, - PrivateKey: pk, - Broadcast: false, - Logger: logger.Test(t), - }) - require.NoError(t, err) - assert.True(t, ct.Supports(404040)) - }}, - {name: "50 RegisterCapabilities skips selectors without forwarders", run: func(t *testing.T) { - pk := newKey(t) - rpc := mocks.NewAptosRpcClient(t) - ct := &AptosChainType{} - services, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ - Registry: scenarioRegistry(t), - Clients: map[uint64]chain.ChainClient{9999: aptosfakes.AptosClient(rpc)}, - Forwarders: map[uint64]string{}, - PrivateKey: pk, - Logger: logger.Test(t), - }) - require.NoError(t, err) - assert.Empty(t, services, "no forwarder → no capability wired") - assert.False(t, ct.Supports(9999)) - }}, - {name: "51 RegisterCapabilities propagates bad forwarder hex", run: func(t *testing.T) { - pk := newKey(t) - rpc := mocks.NewAptosRpcClient(t) - ct := &AptosChainType{} - _, err := ct.RegisterCapabilities(ctx, chain.CapabilityConfig{ - Registry: scenarioRegistry(t), - Clients: map[uint64]chain.ChainClient{1: aptosfakes.AptosClient(rpc)}, - Forwarders: map[uint64]string{1: "not-hex-at-all"}, - PrivateKey: pk, - Logger: logger.Test(t), - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "parse forwarder") - }}, - {name: "52 AptosChainType implements chain.ChainType", run: func(t *testing.T) { - var _ chain.ChainType = &AptosChainType{} - }}, - - // --- TypeTag coverage via View (53-62) --- - {name: "53 View BOOL TypeTag round-trips", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_BOOL) - }}, - {name: "54 View U8 TypeTag round-trips", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U8) - }}, - {name: "55 View U16 TypeTag round-trips (iter-10 extension)", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U16) - }}, - {name: "56 View U32 TypeTag round-trips (iter-10 extension)", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U32) - }}, - {name: "57 View U64 TypeTag round-trips", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U64) - }}, - {name: "58 View U128 TypeTag round-trips", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U128) - }}, - {name: "59 View U256 TypeTag round-trips (iter-10 extension)", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_U256) - }}, - {name: "60 View ADDRESS TypeTag round-trips", run: func(t *testing.T) { - assertTypeTagRoundTrip(t, aptoscappb.TypeTagKind_TYPE_TAG_KIND_ADDRESS) - }}, - {name: "61 View SIGNER TypeTag rejected (out of scope for view args)", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{ - Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, - Function: "f", - ArgTypes: []*aptoscappb.TypeTag{{Kind: aptoscappb.TypeTagKind_TYPE_TAG_KIND_SIGNER}}, - }, - }) - require.NotNil(t, capErr) - }}, - {name: "62 View VECTOR TypeTag rejected (deferred)", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{ - Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, - Function: "f", - ArgTypes: []*aptoscappb.TypeTag{{Kind: aptoscappb.TypeTagKind_TYPE_TAG_KIND_VECTOR}}, - }, - }) - require.NotNil(t, capErr) - }}, - - // --- more read-path edges (63-72) --- - {name: "63 AccountAPTBalance at all-zero address", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(0), nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: make([]byte, 32)}) - require.Nil(t, capErr) - assert.Equal(t, uint64(0), reply.Response.Value) - }}, - {name: "64 AccountAPTBalance at all-ones address", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(^uint64(0)), nil).Once() - fc := newChain(t, rpc, true, 1) - addr := make([]byte, 32) - for i := range addr { - addr[i] = 0xff - } - reply, capErr := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: addr}) - require.Nil(t, capErr) - assert.Equal(t, ^uint64(0), reply.Response.Value) - }}, - {name: "65 View with empty result returns JSON []", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().View(mock.Anything).Return([]any{}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, - }) - require.Nil(t, capErr) - assert.Equal(t, []byte(`[]`), reply.Response.Data) - }}, - {name: "66 View preserves multi-return as JSON array", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().View(mock.Anything).Return([]any{"first", "second"}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, - }) - require.Nil(t, capErr) - assert.Equal(t, []byte(`["first","second"]`), reply.Response.Data) - }}, - {name: "67 View integer return marshaled as JSON", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().View(mock.Anything).Return([]any{int64(42)}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, - }) - require.Nil(t, capErr) - assert.Equal(t, []byte(`[42]`), reply.Response.Data) - }}, - {name: "68 TransactionByHash SDK error without 404 → Unavailable", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().TransactionByHash(mock.Anything).Return(nil, fmt.Errorf("timeout")).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0xabc"}) - require.NotNil(t, capErr) - }}, - {name: "69 TransactionByHash nil request rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.TransactionByHash(ctx, meta, nil) - require.NotNil(t, capErr) - }}, - {name: "70 AccountTransactions with nil pagination forwards nil pointers", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountTransactions(mock.Anything, (*uint64)(nil), (*uint64)(nil)). - Return([]*api.CommittedTransaction{}, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: mkAddr(1)}) - require.Nil(t, capErr) - assert.Empty(t, reply.Response.Transactions) - }}, - {name: "71 AccountTransactions drops nil committed entries", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountTransactions(mock.Anything, mock.Anything, mock.Anything). - Return([]*api.CommittedTransaction{ - nil, - {Type: api.TransactionVariantUser, Inner: &api.UserTransaction{Hash: "0x1"}}, - nil, - }, nil).Once() - fc := newChain(t, rpc, true, 1) - reply, capErr := fc.AccountTransactions(ctx, meta, &aptoscappb.AccountTransactionsRequest{Address: mkAddr(1)}) - require.Nil(t, capErr) - assert.Len(t, reply.Response.Transactions, 1) - }}, - {name: "72 AccountTransactions nil request rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.AccountTransactions(ctx, meta, nil) - require.NotNil(t, capErr) - }}, - - // --- WriteReport broadcast branches (73-82) --- - {name: "73 WriteReport broadcast success populates TxHash + SUCCESS", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&api.PendingTransaction{Hash: "0xfeed"}, nil).Once() - rpc.EXPECT().WaitForTransaction("0xfeed").Return(&api.UserTransaction{ - Success: true, GasUsed: 10, GasUnitPrice: 1, - }, nil).Once() - fc := newChain(t, rpc, false, 1) - reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.Nil(t, capErr) - assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_SUCCESS, reply.Response.TxStatus) - require.NotNil(t, reply.Response.TxHash) - assert.Equal(t, "0xfeed", *reply.Response.TxHash) - }}, - {name: "74 WriteReport broadcast VM failure → FATAL+vmStatus", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&api.PendingTransaction{Hash: "0xbad"}, nil).Once() - rpc.EXPECT().WaitForTransaction("0xbad").Return(&api.UserTransaction{ - Success: false, VmStatus: "Move abort in 0xreceiver::module: X", GasUsed: 5, GasUnitPrice: 2, - }, nil).Once() - fc := newChain(t, rpc, false, 1) - reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.Nil(t, capErr) - assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) - require.NotNil(t, reply.Response.ErrorMessage) - assert.Contains(t, *reply.Response.ErrorMessage, "Move abort") - }}, - {name: "75 WriteReport broadcast nil pending tx → Internal err", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(nil, nil).Once() - fc := newChain(t, rpc, false, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - {name: "76 WriteReport broadcast forwarder err surfaces Unavailable", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(nil, fmt.Errorf("forwarder refused")).Once() - fc := newChain(t, rpc, false, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - {name: "77 WriteReport broadcast WaitForTransaction err surfaces Unavailable", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&api.PendingTransaction{Hash: "0xhold"}, nil).Once() - rpc.EXPECT().WaitForTransaction("0xhold").Return(nil, fmt.Errorf("timeout")).Once() - fc := newChain(t, rpc, false, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - {name: "78 WriteReport broadcast nil final tx → FATAL with hash", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildSignAndSubmitTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&api.PendingTransaction{Hash: "0xabsent"}, nil).Once() - rpc.EXPECT().WaitForTransaction("0xabsent").Return(nil, nil).Once() - fc := newChain(t, rpc, false, 1) - reply, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), Report: validReport(), - }) - require.Nil(t, capErr) - assert.Equal(t, aptoscappb.TxStatus_TX_STATUS_FATAL, reply.Response.TxStatus) - require.NotNil(t, reply.Response.TxHash) - assert.Equal(t, "0xabsent", *reply.Response.TxHash) - }}, - {name: "79 WriteReport with multi-sig forwards each signature byte", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), - Report: &sdk.ReportResponse{ - RawReport: []byte("r"), - Sigs: []*sdk.AttributedSignature{ - {Signature: []byte{0x01, 0x02}}, - {Signature: []byte{0x03, 0x04}}, - }, - }, - }) - require.Nil(t, capErr) - }}, - {name: "80 WriteReport with empty sig slice is allowed", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), - Report: &sdk.ReportResponse{RawReport: []byte("r"), Sigs: nil}, - }) - require.Nil(t, capErr) - }}, - {name: "81 WriteReport with 64KiB raw report forwarded intact (dry-run)", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), - Report: &sdk.ReportResponse{RawReport: make([]byte, 64*1024)}, - }) - require.Nil(t, capErr) - }}, - {name: "82 WriteReport zero MaxGasAmount rejected", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), true, 1) - _, capErr := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), - GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 0, GasUnitPrice: 0}, - Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - - // --- LimitedAptosChain edge cases (83-90) --- - {name: "83 LimitedAptosChain at exact report-size limit passes", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 10, maxGas: 10_000}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), - Report: &sdk.ReportResponse{RawReport: make([]byte, 10)}, - }) - require.Nil(t, capErr) - }}, - {name: "84 LimitedAptosChain at size+1 blocked", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 10, maxGas: 10_000}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), - Report: &sdk.ReportResponse{RawReport: make([]byte, 11)}, - }) - require.NotNil(t, capErr) - }}, - {name: "85 LimitedAptosChain at exact gas limit passes", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 100}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), - GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 100, GasUnitPrice: 1}, - Report: validReport(), - }) - require.Nil(t, capErr) - }}, - {name: "86 LimitedAptosChain at gas+1 blocked", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1000, maxGas: 100}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), - GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 101, GasUnitPrice: 1}, - Report: validReport(), - }) - require.NotNil(t, capErr) - }}, - {name: "87 LimitedAptosChain zero report-size limit disables size check", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 0, maxGas: 10_000}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), GasConfig: validGas(), - Report: &sdk.ReportResponse{RawReport: make([]byte, 999_999)}, - }) - require.Nil(t, capErr) - }}, - {name: "88 LimitedAptosChain zero gas limit disables gas check", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil).Once() - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 10_000, maxGas: 0}) - _, capErr := l.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(0xBB), - GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 999_999, GasUnitPrice: 1}, - Report: validReport(), - }) - require.Nil(t, capErr) - }}, - {name: "89 LimitedAptosChain View delegates to inner", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().View(mock.Anything).Return([]any{"x"}, nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1, maxGas: 1}) - reply, capErr := l.View(ctx, meta, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, Function: "f"}, - }) - require.Nil(t, capErr) - assert.Equal(t, []byte(`["x"]`), reply.Response.Data) - }}, - {name: "90 LimitedAptosChain TransactionByHash delegates to inner", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().TransactionByHash("0xA").Return(nil, nil).Once() - fc := newChain(t, rpc, true, 1) - l := NewLimitedAptosChain(fc, stubLimits{reportSize: 1, maxGas: 1}) - reply, capErr := l.TransactionByHash(ctx, meta, &aptoscappb.TransactionByHashRequest{Hash: "0xA"}) - require.Nil(t, capErr) - assert.Nil(t, reply.Response.Transaction) - }}, - - // --- lifecycle + info (91-100) --- - {name: "91 FakeAptosChain ChainSelector reflects constructor arg", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 4741433654826277352) - assert.Equal(t, uint64(4741433654826277352), fc.ChainSelector()) - }}, - {name: "92 FakeAptosChain Description non-empty", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) - assert.NotEmpty(t, fc.Description()) - }}, - {name: "93 FakeAptosChain Info ID includes selector", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 42) - info, err := fc.Info(ctx) - require.NoError(t, err) - assert.Contains(t, info.ID, "42") - assert.Contains(t, info.ID, "aptos") - }}, - {name: "94 FakeAptosChain Name embeds selector", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 7) - assert.True(t, strings.Contains(fc.Name(), "7"), "Name=%s should contain selector", fc.Name()) - }}, - {name: "95 FakeAptosChain Initialise is no-op", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) - assert.NoError(t, fc.Initialise(ctx, core.StandardCapabilitiesDependencies{})) - }}, - {name: "96 FakeAptosChain Register+Unregister workflow are no-ops", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) - require.NoError(t, fc.RegisterToWorkflow(ctx, commonCap.RegisterToWorkflowRequest{Metadata: commonCap.RegistrationMetadata{WorkflowID: "w"}})) - require.NoError(t, fc.UnregisterFromWorkflow(ctx, commonCap.UnregisterFromWorkflowRequest{Metadata: commonCap.RegistrationMetadata{WorkflowID: "w"}})) - }}, - {name: "97 FakeAptosChain Execute returns empty response", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) - resp, err := fc.Execute(ctx, commonCap.CapabilityRequest{}) - require.NoError(t, err) - assert.Equal(t, commonCap.CapabilityResponse{}, resp) - }}, - {name: "98 FakeAptosChain HealthReport single entry, no error", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) - require.NoError(t, fc.Start(ctx)) - hr := fc.HealthReport() - require.Len(t, hr, 1) - assert.NoError(t, hr[fc.Name()]) - assert.NoError(t, fc.Close()) - }}, - {name: "99 AptosChainCapabilities Start+Close are idempotent no-ops", run: func(t *testing.T) { - fc := newChain(t, mocks.NewAptosRpcClient(t), false, 1) - caps := &AptosChainCapabilities{AptosChains: map[uint64]*aptosfakes.FakeAptosChain{1: fc}} - require.NoError(t, caps.Start(ctx)) - require.NoError(t, caps.Close()) - }}, - {name: "100 FakeAptosChain construction fails on nil client or key", run: func(t *testing.T) { - _, err := aptosfakes.NewFakeAptosChain(logger.Test(t), nil, newKey(t), testAddr(t, "0xdead"), 1, false) - require.Error(t, err) - _, err = aptosfakes.NewFakeAptosChain(logger.Test(t), mocks.NewAptosRpcClient(t), nil, testAddr(t, "0xdead"), 1, false) - require.Error(t, err) - }}, - - {name: "30 Concurrent reads + writes are race-clean", run: func(t *testing.T) { - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().AccountAPTBalance(mock.Anything).Return(uint64(1), nil) - rpc.EXPECT().BuildTransaction(mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&aptos.RawTransaction{}, nil) - rpc.EXPECT().SimulateTransaction(mock.Anything, mock.Anything). - Return([]*api.UserTransaction{{Success: true}}, nil) - fc := newChain(t, rpc, true, 1) - const n = 10 - var wg sync.WaitGroup - errs := make(chan caperrors.Error, n*2) - for i := 0; i < n; i++ { - wg.Add(2) - go func() { - defer wg.Done() - if _, e := fc.AccountAPTBalance(ctx, meta, &aptoscappb.AccountAPTBalanceRequest{Address: mkAddr(1)}); e != nil { - errs <- e - } - }() - go func() { - defer wg.Done() - if _, e := fc.WriteReport(ctx, meta, &aptoscappb.WriteReportRequest{ - Receiver: mkAddr(1), GasConfig: validGas(), Report: validReport(), - }); e != nil { - errs <- e - } - }() - } - wg.Wait() - close(errs) - for e := range errs { - require.Nil(t, e) - } - }}, - } -} - -// TestSimulatorScenarios_100 runs 100 dry-run scenarios exercising the full -// behavioural surface of FakeAptosChain + the Aptos chain-type plugin: -// read-path happy/error paths, WriteReport broadcast+dry-run, LimitedAptosChain -// size/gas enforcement, TypeTag scalar coverage, chaintype registration edges, -// and lifecycle/Info contracts. -func TestSimulatorScenarios_100(t *testing.T) { - t.Parallel() - cases := simulatorScenarios() - require.Len(t, cases, 100, "must have exactly 100 simulator scenarios") - for _, c := range cases { - c := c - t.Run(c.name, func(t *testing.T) { - t.Parallel() - c.run(t) - }) - } -} - -// --- scenario helpers (kept in this file to avoid leaking to prod builds) --- - -// assertTypeTagRoundTrip wires a minimal View against a mock and asserts -// that the given TypeTag kind is accepted by viewPayloadFromProto + -// typeTagFromProto. A reject manifests as a PublicUserError. -func assertTypeTagRoundTrip(t *testing.T, kind aptoscappb.TypeTagKind) { - t.Helper() - rpc := mocks.NewAptosRpcClient(t) - rpc.EXPECT().View(mock.Anything).Return([]any{"ok"}, nil).Once() - fc, err := aptosfakes.NewFakeAptosChain(logger.Test(t), rpc, newKey(t), - testAddr(t, "0xdead"), 1, true) - require.NoError(t, err) - _, capErr := fc.View(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.ViewRequest{ - Payload: &aptoscappb.ViewPayload{ - Module: &aptoscappb.ModuleID{Address: mkAddr(1), Name: "m"}, - Function: "f", - ArgTypes: []*aptoscappb.TypeTag{{Kind: kind}}, - }, - }) - require.Nil(t, capErr, "kind %v should be accepted", kind) -} - -// badLimits satisfies chain.Limits but not AptosChainLimits, to exercise -// RegisterCapabilities' type-assertion rejection. -type badLimits struct{} - -func (badLimits) ChainWriteReportSizeLimit() int { return 0 } - -// scenarioRegistry returns a capability registry usable in RegisterCapabilities -// scenarios. Matches the EVM sibling's newRegistry helper. -func scenarioRegistry(t *testing.T) *capabilities.Registry { - t.Helper() - return capabilities.NewRegistry(logger.Test(t)) -} - -// newAptosChainTypeForTest returns a zero-value AptosChainType — its log -// field is only read by scenarios that hit ResolveClients/RegisterCapabilities -// when RPCs are configured, and scenarios pass empty viper so the nil log -// never dereferences. -func newAptosChainTypeForTest(t *testing.T) *AptosChainType { - t.Helper() - zl := zerolog.Nop() - return &AptosChainType{log: &zl} -} diff --git a/cmd/workflow/simulate/chain/evm/capabilities.go b/cmd/workflow/simulate/chain/evm/capabilities.go index 22f000b1..0e87bb07 100644 --- a/cmd/workflow/simulate/chain/evm/capabilities.go +++ b/cmd/workflow/simulate/chain/evm/capabilities.go @@ -11,6 +11,8 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink/v2/core/capabilities" "github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) // EVMChainCapabilities holds the EVM chain capability servers created for simulation. @@ -29,7 +31,7 @@ func NewEVMChainCapabilities( forwarders map[uint64]string, privateKey *ecdsa.PrivateKey, dryRunChainWrite bool, - limits EVMChainLimits, + limits chain.Limits, ) (*EVMChainCapabilities, error) { evmChains := make(map[uint64]*fakes.FakeEVMChain) for sel, client := range clients { @@ -48,11 +50,7 @@ func NewEVMChainCapabilities( dryRunChainWrite, ) - // Wrap with limits enforcement if limits are provided - var evmCap evmserver.ClientCapability = evm - if limits != nil { - evmCap = NewLimitedEVMChain(evm, limits) - } + evmCap := NewLimitedEVMChain(evm, limits) evmServer := evmserver.NewClientServer(evmCap) if err := registry.Add(ctx, evmServer); err != nil { diff --git a/cmd/workflow/simulate/chain/evm/chaintype.go b/cmd/workflow/simulate/chain/evm/chaintype.go index b05b3231..6222524a 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype.go +++ b/cmd/workflow/simulate/chain/evm/chaintype.go @@ -155,16 +155,9 @@ func (ct *EVMChainType) RegisterCapabilities(ctx context.Context, cfg chain.Capa dryRun := !cfg.Broadcast - // cfg.Limits is the generic chain.Limits contract. The EVM chain type - // needs the wider EVMChainLimits contract (adds ChainWriteGasLimit). A - // nil cfg.Limits disables enforcement entirely. - var evmLimits EVMChainLimits + var evmLimits chain.Limits 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 + evmLimits = ExtractLimits(cfg.Limits) } evmCaps, err := NewEVMChainCapabilities( diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities.go b/cmd/workflow/simulate/chain/evm/limited_capabilities.go index b7b50e02..f46e3282 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities.go @@ -13,42 +13,30 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) -// EVMChainLimits is the EVM-scoped limit contract LimitedEVMChain enforces. -// It extends chain.Limits with EVM-specific accessors (e.g. gas limit) so -// non-EVM chain types cannot accidentally depend on EVM semantics. -type EVMChainLimits interface { - chain.Limits - ChainWriteGasLimit() uint64 -} - // LimitedEVMChain wraps an evmserver.ClientCapability and enforces chain write -// report size and gas limits. +// report size and gas limits. Zero-value chain.Limits disables enforcement. type LimitedEVMChain struct { inner evmserver.ClientCapability - limits EVMChainLimits + limits chain.Limits } var _ evmserver.ClientCapability = (*LimitedEVMChain)(nil) -func NewLimitedEVMChain(inner evmserver.ClientCapability, limits EVMChainLimits) *LimitedEVMChain { +func NewLimitedEVMChain(inner evmserver.ClientCapability, limits chain.Limits) *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 { + if l.limits.ReportSize > 0 && input.Report != nil && len(input.Report.RawReport) > l.limits.ReportSize { 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), + fmt.Errorf("simulation limit exceeded: chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), l.limits.ReportSize), caperrors.ResourceExhausted, ) } - // Check gas limit - gasLimit := l.limits.ChainWriteGasLimit() - if gasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > gasLimit { + if l.limits.GasLimit > 0 && input.GasConfig != nil && input.GasConfig.GasLimit > l.limits.GasLimit { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, gasLimit), + fmt.Errorf("simulation limit exceeded: EVM gas limit %d exceeds maximum of %d", input.GasConfig.GasLimit, l.limits.GasLimit), caperrors.ResourceExhausted, ) } diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go index 362a3bb4..3ba6f5e1 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -13,15 +13,9 @@ import ( evmserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server" "github.com/smartcontractkit/chainlink-common/pkg/types/core" sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" -) - -type stubEVMLimits struct { - reportSizeLimit int - gasLimit uint64 -} -func (s *stubEVMLimits) ChainWriteReportSizeLimit() int { return s.reportSizeLimit } -func (s *stubEVMLimits) ChainWriteGasLimit() uint64 { return s.gasLimit } + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) type evmCapabilityBaseStub struct{} @@ -94,7 +88,7 @@ func (s *evmClientCapabilityStub) ChainSelector() uint64 { return 0 } func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { t.Parallel() - limits := &stubEVMLimits{reportSizeLimit: 4} + limits := chain.Limits{ReportSize: 4} inner := &evmClientCapabilityStub{} wrapper := NewLimitedEVMChain(inner, limits) @@ -110,7 +104,7 @@ func TestLimitedEVMChainWriteReportRejectsOversizedReport(t *testing.T) { func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { t.Parallel() - limits := &stubEVMLimits{gasLimit: 10} + limits := chain.Limits{GasLimit: 10} inner := &evmClientCapabilityStub{} wrapper := NewLimitedEVMChain(inner, limits) @@ -126,7 +120,7 @@ func TestLimitedEVMChainWriteReportRejectsOversizedGasLimit(t *testing.T) { func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { t.Parallel() - limits := &stubEVMLimits{reportSizeLimit: 4, gasLimit: 10} + limits := chain.Limits{ReportSize: 4, GasLimit: 10} input := &evmcappb.WriteReportRequest{ Report: &sdkpb.ReportResponse{RawReport: []byte("1234")}, diff --git a/cmd/workflow/simulate/chain/evm/limits.go b/cmd/workflow/simulate/chain/evm/limits.go new file mode 100644 index 00000000..b29c624d --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limits.go @@ -0,0 +1,14 @@ +package evm + +import ( + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" + + "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" +) + +func ExtractLimits(w *cresettings.Workflows) chain.Limits { + return chain.Limits{ + ReportSize: int(w.ChainWrite.ReportSizeLimit.DefaultValue), + GasLimit: w.ChainWrite.EVM.GasLimit.Default.DefaultValue, + } +} diff --git a/cmd/workflow/simulate/chain/evm/limits_test.go b/cmd/workflow/simulate/chain/evm/limits_test.go new file mode 100644 index 00000000..b77bdde5 --- /dev/null +++ b/cmd/workflow/simulate/chain/evm/limits_test.go @@ -0,0 +1,28 @@ +package evm + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" +) + +func TestExtractLimitsFromDefault(t *testing.T) { + t.Parallel() + w := cresettings.Default.PerWorkflow + lim := ExtractLimits(&w) + assert.Equal(t, 5_000, lim.ReportSize) + assert.Equal(t, uint64(5_000_000), lim.GasLimit) +} + +func TestExtractLimitsAfterJSONOverride(t *testing.T) { + t.Parallel() + w := cresettings.Default.PerWorkflow + require.NoError(t, json.Unmarshal([]byte(`{ + "ChainWrite": {"EVM": {"GasLimit": {"Default": "123"}}} + }`), &w)) + assert.Equal(t, uint64(123), ExtractLimits(&w).GasLimit) +} diff --git a/cmd/workflow/simulate/chain/types.go b/cmd/workflow/simulate/chain/types.go index 12f8c1cb..fdc52bab 100644 --- a/cmd/workflow/simulate/chain/types.go +++ b/cmd/workflow/simulate/chain/types.go @@ -2,6 +2,7 @@ package chain import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" "github.com/smartcontractkit/chainlink/v2/core/capabilities" ) @@ -15,14 +16,6 @@ type ChainConfig struct { Forwarder string // chain-type-specific forwarding address } -// Limits exposes the chain-write limits that every chain type's capability -// enforcement layer needs. Chain-type-specific accessors (e.g. EVM gas limit) -// live on chain-type-scoped extension interfaces in the family package so -// non-EVM chain types cannot accidentally depend on EVM semantics. -type Limits interface { - ChainWriteReportSizeLimit() int -} - // ResolvedChains is the result of ChainType.ResolveClients: the RPC clients, // forwarders, and any chain-type-agnostic metadata later interface methods // (e.g. RunHealthCheck) depend on. @@ -35,6 +28,13 @@ type ResolvedChains struct { ExperimentalSelectors map[uint64]bool } +// Limits is the common per-family limits contract enforced by the +// LimitedChain wrappers. +type Limits struct { + ReportSize int + GasLimit uint64 +} + // CapabilityConfig holds everything a chain type needs to register capabilities. type CapabilityConfig struct { Registry *capabilities.Registry @@ -42,7 +42,7 @@ type CapabilityConfig struct { Forwarders map[uint64]string PrivateKey interface{} // chain-type-specific key type; EVM uses *ecdsa.PrivateKey Broadcast bool - Limits Limits // nil disables limit enforcement + Limits *cresettings.Workflows // nil disables enforcement Logger logger.Logger } diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index 49bb24c6..1ebe5035 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -191,16 +191,6 @@ func (l *SimulationLimits) ChainWriteReportSizeLimit() int { return int(l.Workflows.ChainWrite.ReportSizeLimit.DefaultValue) } -// ChainWriteGasLimit returns the default EVM gas limit. -func (l *SimulationLimits) ChainWriteGasLimit() uint64 { - return l.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue -} - -// ChainWriteAptosMaxGasAmount returns the default Aptos max_gas_amount per WriteReport. -func (l *SimulationLimits) ChainWriteAptosMaxGasAmount() uint64 { - return l.Workflows.ChainWrite.Aptos.GasLimit.Default.DefaultValue -} - // WASMBinarySize returns the WASM binary size limit in bytes. func (l *SimulationLimits) WASMBinarySize() int { return int(l.Workflows.WASMBinarySizeLimit.DefaultValue) diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go index 09459f08..4ec8718d 100644 --- a/cmd/workflow/simulate/limits_test.go +++ b/cmd/workflow/simulate/limits_test.go @@ -31,7 +31,6 @@ 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.ChainWriteGasLimit()) assert.Equal(t, 100_000_000, limits.WASMBinarySize()) assert.Equal(t, 20_000_000, limits.WASMCompressedBinarySize()) assert.JSONEq(t, string(defaultLimitsJSON), string(ExportDefaultLimitsJSON())) @@ -46,12 +45,10 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing "ConnectionTimeout": "2s" }, "ChainWrite": { - "ReportSizeLimit": "9kb", - "EVM": { - "GasLimit": { - "Default": "123" - } - } + "ReportSizeLimit": "9kb" + }, + "CRONTrigger": { + "FastestScheduleInterval": "45s" } }`) @@ -61,7 +58,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.ChainWriteGasLimit()) + assert.Equal(t, 45*time.Second, limits.Workflows.CRONTrigger.FastestScheduleInterval.DefaultValue) assert.Equal(t, 2*time.Second, limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue) } @@ -95,7 +92,7 @@ func TestResolveLimitsHandlesAllSupportedModes(t *testing.T) { baseline, err := DefaultLimits() require.NoError(t, err) assert.Equal(t, baseline.HTTPRequestSizeLimit(), defaultLimits.HTTPRequestSizeLimit()) - assert.Equal(t, baseline.ChainWriteGasLimit(), defaultLimits.ChainWriteGasLimit()) + assert.Equal(t, baseline.ChainWriteReportSizeLimit(), defaultLimits.ChainWriteReportSizeLimit()) path := writeLimitsFile(t, `{"Consensus":{"ObservationSizeLimit":"2kb"}}`) customLimits, err := ResolveLimits(path) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 162f01ee..6ca2a3e3 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -33,6 +33,7 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/aptos" // register Aptos chain family via package init _ "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" @@ -463,11 +464,10 @@ func run( } srvcs = append(srvcs, manualTriggerCaps.ManualCronTrigger, manualTriggerCaps.ManualHTTPTrigger) - // Only set Limits when non-nil to avoid the typed-nil interface trap - // (a nil *SimulationLimits boxed into chain.Limits compares != nil). - var capLimits chain.Limits + // nil capLimits disables enforcement. + var capLimits *cresettings.Workflows if simLimits != nil { - capLimits = simLimits + capLimits = &simLimits.Workflows } // Register chain-type-specific capabilities From adb043913967735c8cd8ce965d953cbefac236e9 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 13:39:00 +0100 Subject: [PATCH 12/28] refactor(simulate): per-family ChainWrite limits and engine cfg parity Move ReportSizeLimit and gas defaults into per-family EVM/Aptos blocks in limits.json (upstream top-level ChainWrite.ReportSizeLimit is deprecated). Split ChainWriteReportSizeLimit() getter into EVM/AptosChainWriteReportSizeLimit(), update LimitsSummary to render evm_report/evm_gas/aptos_report/aptos_gas, and read per-family fields in chain/{evm,aptos}/limits.go. Apply ChainWrite.{EVM,Aptos} report+gas to engine cfg in both applyEngineLimits and disableEngineLimits for symmetry and future hardening (current sim enforcement runs via Limited{EVM,Aptos}Chain wrappers reading SimulationLimits directly). Drop deprecated EVM.TransactionGasLimit. Tighten broadcast/ChainType doc comments. --- cmd/workflow/simulate/chain/aptos/limits.go | 2 +- cmd/workflow/simulate/chain/evm/limits.go | 2 +- cmd/workflow/simulate/limits.go | 30 ++++++++++++++++----- cmd/workflow/simulate/limits.json | 10 +++++-- cmd/workflow/simulate/limits_test.go | 15 ++++++----- cmd/workflow/simulate/simulate.go | 2 +- internal/settings/settings_get.go | 2 +- 7 files changed, 44 insertions(+), 19 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/limits.go b/cmd/workflow/simulate/chain/aptos/limits.go index e21771e0..a8e919b6 100644 --- a/cmd/workflow/simulate/chain/aptos/limits.go +++ b/cmd/workflow/simulate/chain/aptos/limits.go @@ -8,7 +8,7 @@ import ( func ExtractLimits(w *cresettings.Workflows) chain.Limits { return chain.Limits{ - ReportSize: int(w.ChainWrite.ReportSizeLimit.DefaultValue), + ReportSize: int(w.ChainWrite.Aptos.ReportSizeLimit.DefaultValue), GasLimit: w.ChainWrite.Aptos.GasLimit.Default.DefaultValue, } } diff --git a/cmd/workflow/simulate/chain/evm/limits.go b/cmd/workflow/simulate/chain/evm/limits.go index b29c624d..155d1f79 100644 --- a/cmd/workflow/simulate/chain/evm/limits.go +++ b/cmd/workflow/simulate/chain/evm/limits.go @@ -8,7 +8,7 @@ import ( func ExtractLimits(w *cresettings.Workflows) chain.Limits { return chain.Limits{ - ReportSize: int(w.ChainWrite.ReportSizeLimit.DefaultValue), + ReportSize: int(w.ChainWrite.EVM.ReportSizeLimit.DefaultValue), GasLimit: w.ChainWrite.EVM.GasLimit.Default.DefaultValue, } } diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index 1ebe5035..7c00ca3a 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -94,6 +94,12 @@ func applyEngineLimits(cfg *cresettings.Workflows, limits *SimulationLimits) { cfg.HTTPTrigger = src.HTTPTrigger cfg.LogTrigger = src.LogTrigger + //ChainWrite limits - NOTE these are not applied here, but allows flexibility in the future if we want engine to control limits + cfg.ChainWrite.EVM.ReportSizeLimit = src.ChainWrite.EVM.ReportSizeLimit + cfg.ChainWrite.EVM.GasLimit = src.ChainWrite.EVM.GasLimit + cfg.ChainWrite.Aptos.ReportSizeLimit = src.ChainWrite.Aptos.ReportSizeLimit + cfg.ChainWrite.Aptos.GasLimit = src.ChainWrite.Aptos.GasLimit + // NOTE: ChainAllowed is NOT overridden — simulation keeps allow-all } @@ -103,6 +109,7 @@ func disableEngineLimits(cfg *cresettings.Workflows) { maxInt := settings.Setting[int]{DefaultValue: math.MaxInt32} maxSize := settings.Setting[config.Size]{DefaultValue: math.MaxInt32} maxDuration := settings.Setting[time.Duration]{DefaultValue: 24 * time.Hour} + maxGas := settings.Setting[uint64]{DefaultValue: math.MaxUint64} // Execution limits cfg.ExecutionTimeout = maxDuration @@ -149,9 +156,12 @@ func disableEngineLimits(cfg *cresettings.Workflows) { cfg.Consensus.CallLimit = maxInt cfg.Consensus.ObservationSizeLimit = maxSize - // ChainWrite limits + // ChainWrite limits - NOTE these are not applied here, but allows flexibility in the future if we want engine to control limits cfg.ChainWrite.TargetsLimit = maxInt - cfg.ChainWrite.ReportSizeLimit = maxSize + cfg.ChainWrite.EVM.ReportSizeLimit = maxSize + cfg.ChainWrite.EVM.GasLimit.Default = maxGas + cfg.ChainWrite.Aptos.ReportSizeLimit = maxSize + cfg.ChainWrite.Aptos.GasLimit.Default = maxGas // ChainRead limits cfg.ChainRead.CallLimit = maxInt @@ -186,9 +196,14 @@ func (l *SimulationLimits) ConsensusObservationSizeLimit() int { return int(l.Workflows.Consensus.ObservationSizeLimit.DefaultValue) } -// ChainWriteReportSizeLimit returns the chain write report size limit in bytes. -func (l *SimulationLimits) ChainWriteReportSizeLimit() int { - return int(l.Workflows.ChainWrite.ReportSizeLimit.DefaultValue) +// EVMChainWriteReportSizeLimit returns the EVM chain write report size limit in bytes. +func (l *SimulationLimits) EVMChainWriteReportSizeLimit() int { + return int(l.Workflows.ChainWrite.EVM.ReportSizeLimit.DefaultValue) +} + +// AptosChainWriteReportSizeLimit returns the Aptos chain write report size limit in bytes. +func (l *SimulationLimits) AptosChainWriteReportSizeLimit() int { + return int(l.Workflows.ChainWrite.Aptos.ReportSizeLimit.DefaultValue) } // WASMBinarySize returns the WASM binary size limit in bytes. @@ -205,7 +220,7 @@ func (l *SimulationLimits) WASMCompressedBinarySize() int { func (l *SimulationLimits) LimitsSummary() string { w := &l.Workflows return fmt.Sprintf( - "HTTP: req=%s resp=%s timeout=%s | ConfHTTP: req=%s resp=%s timeout=%s | Consensus obs=%s | ChainWrite report=%s evm_gas=%d aptos_gas=%d | WASM binary=%s compressed=%s", + "HTTP: req=%s resp=%s timeout=%s | ConfHTTP: req=%s resp=%s timeout=%s | Consensus obs=%s | ChainWrite evm_report=%s evm_gas=%d aptos_report=%s aptos_gas=%d | WASM binary=%s compressed=%s", w.HTTPAction.RequestSizeLimit.DefaultValue, w.HTTPAction.ResponseSizeLimit.DefaultValue, w.HTTPAction.ConnectionTimeout.DefaultValue, @@ -213,8 +228,9 @@ func (l *SimulationLimits) LimitsSummary() string { w.ConfidentialHTTP.ResponseSizeLimit.DefaultValue, w.ConfidentialHTTP.ConnectionTimeout.DefaultValue, w.Consensus.ObservationSizeLimit.DefaultValue, - w.ChainWrite.ReportSizeLimit.DefaultValue, + w.ChainWrite.EVM.ReportSizeLimit.DefaultValue, w.ChainWrite.EVM.GasLimit.Default.DefaultValue, + w.ChainWrite.Aptos.ReportSizeLimit.DefaultValue, w.ChainWrite.Aptos.GasLimit.Default.DefaultValue, w.WASMBinarySizeLimit.DefaultValue, w.WASMCompressedBinarySizeLimit.DefaultValue, diff --git a/cmd/workflow/simulate/limits.json b/cmd/workflow/simulate/limits.json index ced46eeb..84f38dcc 100644 --- a/cmd/workflow/simulate/limits.json +++ b/cmd/workflow/simulate/limits.json @@ -32,13 +32,19 @@ }, "ChainWrite": { "TargetsLimit": "10", - "ReportSizeLimit": "5kb", "EVM": { - "TransactionGasLimit": "5000000", + "ReportSizeLimit": "5kb", "GasLimit": { "Default": "5000000", "Values": {} } + }, + "Aptos": { + "ReportSizeLimit": "5kb", + "GasLimit": { + "Default": "2000000", + "Values": {} + } } }, "ChainRead": { diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go index 4ec8718d..d4c8eba8 100644 --- a/cmd/workflow/simulate/limits_test.go +++ b/cmd/workflow/simulate/limits_test.go @@ -30,7 +30,8 @@ func TestDefaultLimitsAndExportDefaultLimitsJSON(t *testing.T) { assert.Equal(t, 10_000, limits.ConfHTTPRequestSizeLimit()) assert.Equal(t, 100_000, limits.ConfHTTPResponseSizeLimit()) assert.Equal(t, 100_000, limits.ConsensusObservationSizeLimit()) - assert.Equal(t, 5_000, limits.ChainWriteReportSizeLimit()) + assert.Equal(t, 5_000, limits.EVMChainWriteReportSizeLimit()) + assert.Equal(t, 5_000, limits.AptosChainWriteReportSizeLimit()) assert.Equal(t, 100_000_000, limits.WASMBinarySize()) assert.Equal(t, 20_000_000, limits.WASMCompressedBinarySize()) assert.JSONEq(t, string(defaultLimitsJSON), string(ExportDefaultLimitsJSON())) @@ -45,7 +46,8 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing "ConnectionTimeout": "2s" }, "ChainWrite": { - "ReportSizeLimit": "9kb" + "EVM": {"ReportSizeLimit": "9kb"}, + "Aptos": {"ReportSizeLimit": "11kb"} }, "CRONTrigger": { "FastestScheduleInterval": "45s" @@ -57,7 +59,8 @@ 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, 9_000, limits.EVMChainWriteReportSizeLimit()) + assert.Equal(t, 11_000, limits.AptosChainWriteReportSizeLimit()) assert.Equal(t, 45*time.Second, limits.Workflows.CRONTrigger.FastestScheduleInterval.DefaultValue) assert.Equal(t, 2*time.Second, limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue) } @@ -92,7 +95,8 @@ func TestResolveLimitsHandlesAllSupportedModes(t *testing.T) { baseline, err := DefaultLimits() require.NoError(t, err) assert.Equal(t, baseline.HTTPRequestSizeLimit(), defaultLimits.HTTPRequestSizeLimit()) - assert.Equal(t, baseline.ChainWriteReportSizeLimit(), defaultLimits.ChainWriteReportSizeLimit()) + assert.Equal(t, baseline.EVMChainWriteReportSizeLimit(), defaultLimits.EVMChainWriteReportSizeLimit()) + assert.Equal(t, baseline.AptosChainWriteReportSizeLimit(), defaultLimits.AptosChainWriteReportSizeLimit()) path := writeLimitsFile(t, `{"Consensus":{"ObservationSizeLimit":"2kb"}}`) customLimits, err := ResolveLimits(path) @@ -170,7 +174,6 @@ func TestSimulationLimitsSummaryIncludesKeyLimitValues(t *testing.T) { assert.Contains(t, summary, "HTTP: req=10kb resp=100kb timeout=10s") assert.Contains(t, summary, "ConfHTTP: req=10kb resp=100kb timeout=10s") assert.Contains(t, summary, "Consensus obs=100kb") - assert.Contains(t, summary, "ChainWrite report=5kb evm_gas=5000000") - assert.Contains(t, summary, "aptos_gas=") + assert.Contains(t, summary, "ChainWrite evm_report=5kb evm_gas=5000000 aptos_report=5kb aptos_gas=2000000") assert.Contains(t, summary, "WASM binary=100mb compressed=20mb") } diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index 6ca2a3e3..1d68f18d 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -97,7 +97,7 @@ func New(runtimeContext *runtime.Context) *cobra.Command { } simulateCmd.Flags().BoolP("engine-logs", "g", false, "Enable non-fatal engine logging") - simulateCmd.Flags().Bool("broadcast", false, "Broadcast transactions to configured chains (requires a valid per-chain-type private key; default: false)") + simulateCmd.Flags().Bool("broadcast", false, "Broadcast transactions to configured chains (default: false)") simulateCmd.Flags().String("wasm", "", "Path or URL to a pre-built WASM binary (skips compilation)") simulateCmd.Flags().String("config", "", "Override the config file path from workflow.yaml") simulateCmd.Flags().Bool("no-config", false, "Simulate without a config file") diff --git a/internal/settings/settings_get.go b/internal/settings/settings_get.go index 89f052a6..24bf0c31 100644 --- a/internal/settings/settings_get.go +++ b/internal/settings/settings_get.go @@ -40,7 +40,7 @@ type RpcEndpoint struct { // ExperimentalChain represents a chain not in official chain-selectors. // Automatically used by the simulator when present in the target's experimental-chains config. -// ChainType selects the chain family ("evm", "aptos"); empty defaults to "evm" for backward compat. +// ChainType selects the chain family; empty defaults to "evm" for backward compat. type ExperimentalChain struct { ChainType string `mapstructure:"chain-type" yaml:"chain-type"` ChainSelector uint64 `mapstructure:"chain-selector" yaml:"chain-selector"` From 82ffb98d6d38e4fa53e757e15118f2d95a3aa792 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 13:54:11 +0100 Subject: [PATCH 13/28] test(simulate): add per-family gas-limit getters and assertions Mirror the per-family ReportSize getters with EVMChainWriteGasLimit and AptosChainWriteGasLimit. Cover them in default, custom-load, resolve, and applyEngineLimits tests for parity with ReportSize. --- cmd/workflow/simulate/limits.go | 10 ++++++++++ cmd/workflow/simulate/limits_test.go | 18 ++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/simulate/limits.go b/cmd/workflow/simulate/limits.go index 7c00ca3a..bc05bb23 100644 --- a/cmd/workflow/simulate/limits.go +++ b/cmd/workflow/simulate/limits.go @@ -206,6 +206,16 @@ func (l *SimulationLimits) AptosChainWriteReportSizeLimit() int { return int(l.Workflows.ChainWrite.Aptos.ReportSizeLimit.DefaultValue) } +// EVMChainWriteGasLimit returns the default EVM chain write gas limit. +func (l *SimulationLimits) EVMChainWriteGasLimit() uint64 { + return l.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue +} + +// AptosChainWriteGasLimit returns the default Aptos chain write gas limit. +func (l *SimulationLimits) AptosChainWriteGasLimit() uint64 { + return l.Workflows.ChainWrite.Aptos.GasLimit.Default.DefaultValue +} + // WASMBinarySize returns the WASM binary size limit in bytes. func (l *SimulationLimits) WASMBinarySize() int { return int(l.Workflows.WASMBinarySizeLimit.DefaultValue) diff --git a/cmd/workflow/simulate/limits_test.go b/cmd/workflow/simulate/limits_test.go index d4c8eba8..cee61044 100644 --- a/cmd/workflow/simulate/limits_test.go +++ b/cmd/workflow/simulate/limits_test.go @@ -32,6 +32,8 @@ func TestDefaultLimitsAndExportDefaultLimitsJSON(t *testing.T) { assert.Equal(t, 100_000, limits.ConsensusObservationSizeLimit()) assert.Equal(t, 5_000, limits.EVMChainWriteReportSizeLimit()) assert.Equal(t, 5_000, limits.AptosChainWriteReportSizeLimit()) + assert.Equal(t, uint64(5_000_000), limits.EVMChainWriteGasLimit()) + assert.Equal(t, uint64(2_000_000), limits.AptosChainWriteGasLimit()) assert.Equal(t, 100_000_000, limits.WASMBinarySize()) assert.Equal(t, 20_000_000, limits.WASMCompressedBinarySize()) assert.JSONEq(t, string(defaultLimitsJSON), string(ExportDefaultLimitsJSON())) @@ -46,8 +48,8 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing "ConnectionTimeout": "2s" }, "ChainWrite": { - "EVM": {"ReportSizeLimit": "9kb"}, - "Aptos": {"ReportSizeLimit": "11kb"} + "EVM": {"ReportSizeLimit": "9kb", "GasLimit": {"Default": "1234567"}}, + "Aptos": {"ReportSizeLimit": "11kb", "GasLimit": {"Default": "7654321"}} }, "CRONTrigger": { "FastestScheduleInterval": "45s" @@ -61,6 +63,8 @@ func TestLoadLimitsParsesCustomFileAndPreservesDefaultsForUnsetFields(t *testing assert.Equal(t, 100_000, limits.HTTPResponseSizeLimit(), "unset values should keep embedded defaults") assert.Equal(t, 9_000, limits.EVMChainWriteReportSizeLimit()) assert.Equal(t, 11_000, limits.AptosChainWriteReportSizeLimit()) + assert.Equal(t, uint64(1_234_567), limits.EVMChainWriteGasLimit()) + assert.Equal(t, uint64(7_654_321), limits.AptosChainWriteGasLimit()) assert.Equal(t, 45*time.Second, limits.Workflows.CRONTrigger.FastestScheduleInterval.DefaultValue) assert.Equal(t, 2*time.Second, limits.Workflows.HTTPAction.ConnectionTimeout.DefaultValue) } @@ -97,6 +101,8 @@ func TestResolveLimitsHandlesAllSupportedModes(t *testing.T) { assert.Equal(t, baseline.HTTPRequestSizeLimit(), defaultLimits.HTTPRequestSizeLimit()) assert.Equal(t, baseline.EVMChainWriteReportSizeLimit(), defaultLimits.EVMChainWriteReportSizeLimit()) assert.Equal(t, baseline.AptosChainWriteReportSizeLimit(), defaultLimits.AptosChainWriteReportSizeLimit()) + assert.Equal(t, baseline.EVMChainWriteGasLimit(), defaultLimits.EVMChainWriteGasLimit()) + assert.Equal(t, baseline.AptosChainWriteGasLimit(), defaultLimits.AptosChainWriteGasLimit()) path := writeLimitsFile(t, `{"Consensus":{"ObservationSizeLimit":"2kb"}}`) customLimits, err := ResolveLimits(path) @@ -131,6 +137,10 @@ func TestApplyEngineLimitsCopiesSupportedFieldsAndPreservesChainAllowed(t *testi limits.Workflows.LogEventLimit.DefaultValue = 25 limits.Workflows.ChainRead.CallLimit.DefaultValue = 3 limits.Workflows.ChainWrite.TargetsLimit.DefaultValue = 4 + limits.Workflows.ChainWrite.EVM.ReportSizeLimit.DefaultValue = 9_000 + limits.Workflows.ChainWrite.EVM.GasLimit.Default.DefaultValue = 1_234_567 + limits.Workflows.ChainWrite.Aptos.ReportSizeLimit.DefaultValue = 11_000 + limits.Workflows.ChainWrite.Aptos.GasLimit.Default.DefaultValue = 7_654_321 limits.Workflows.Consensus.CallLimit.DefaultValue = 5 limits.Workflows.HTTPAction.CallLimit.DefaultValue = 6 limits.Workflows.ConfidentialHTTP.CallLimit.DefaultValue = 7 @@ -159,6 +169,10 @@ func TestApplyEngineLimitsCopiesSupportedFieldsAndPreservesChainAllowed(t *testi assert.Equal(t, 25, cfg.LogEventLimit.DefaultValue) assert.Equal(t, 3, cfg.ChainRead.CallLimit.DefaultValue) assert.Equal(t, 4, cfg.ChainWrite.TargetsLimit.DefaultValue) + assert.Equal(t, 9_000, int(cfg.ChainWrite.EVM.ReportSizeLimit.DefaultValue)) + assert.Equal(t, uint64(1_234_567), cfg.ChainWrite.EVM.GasLimit.Default.DefaultValue) + assert.Equal(t, 11_000, int(cfg.ChainWrite.Aptos.ReportSizeLimit.DefaultValue)) + assert.Equal(t, uint64(7_654_321), cfg.ChainWrite.Aptos.GasLimit.Default.DefaultValue) assert.Equal(t, 5, cfg.Consensus.CallLimit.DefaultValue) assert.Equal(t, 6, cfg.HTTPAction.CallLimit.DefaultValue) assert.Equal(t, 7, cfg.ConfidentialHTTP.CallLimit.DefaultValue) From 58a873ae7f4a815e06b101a458bfc905b6159ce2 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 13:57:40 +0100 Subject: [PATCH 14/28] test(simulate): drop redundant chain ExtractLimits override tests Override path is covered by simulate/limits_test.go via the EVM/AptosChainWriteGasLimit getters which read the same fields. Keep the default-mapping smoke tests as chain-package unit anchors. --- cmd/workflow/simulate/chain/aptos/limits_test.go | 11 ----------- cmd/workflow/simulate/chain/evm/limits_test.go | 11 ----------- 2 files changed, 22 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/limits_test.go b/cmd/workflow/simulate/chain/aptos/limits_test.go index 8a831907..e7ecd506 100644 --- a/cmd/workflow/simulate/chain/aptos/limits_test.go +++ b/cmd/workflow/simulate/chain/aptos/limits_test.go @@ -1,11 +1,9 @@ package aptos import ( - "encoding/json" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" ) @@ -17,12 +15,3 @@ func TestExtractLimitsFromDefault(t *testing.T) { assert.Equal(t, 5_000, lim.ReportSize) assert.Equal(t, uint64(2_000_000), lim.GasLimit) } - -func TestExtractLimitsAfterJSONOverride(t *testing.T) { - t.Parallel() - w := cresettings.Default.PerWorkflow - require.NoError(t, json.Unmarshal([]byte(`{ - "ChainWrite": {"Aptos": {"GasLimit": {"Default": "456"}}} - }`), &w)) - assert.Equal(t, uint64(456), ExtractLimits(&w).GasLimit) -} diff --git a/cmd/workflow/simulate/chain/evm/limits_test.go b/cmd/workflow/simulate/chain/evm/limits_test.go index b77bdde5..4df914bc 100644 --- a/cmd/workflow/simulate/chain/evm/limits_test.go +++ b/cmd/workflow/simulate/chain/evm/limits_test.go @@ -1,11 +1,9 @@ package evm import ( - "encoding/json" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings" ) @@ -17,12 +15,3 @@ func TestExtractLimitsFromDefault(t *testing.T) { assert.Equal(t, 5_000, lim.ReportSize) assert.Equal(t, uint64(5_000_000), lim.GasLimit) } - -func TestExtractLimitsAfterJSONOverride(t *testing.T) { - t.Parallel() - w := cresettings.Default.PerWorkflow - require.NoError(t, json.Unmarshal([]byte(`{ - "ChainWrite": {"EVM": {"GasLimit": {"Default": "123"}}} - }`), &w)) - assert.Equal(t, uint64(123), ExtractLimits(&w).GasLimit) -} From d331c5ab88716314fff0bb2af94b025320625fee Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 15:15:00 +0100 Subject: [PATCH 15/28] test(simulate/aptos): align supported_chains tests with EVM parity Add selector non-zero/unique, forwarder hex validity (64-hex for Aptos object addresses), selector family resolution, no-empty-forwarder, and SupportedChains() return-parity tests. Keep existing mainnet/testnet presence check. --- .../chain/aptos/supported_chains_test.go | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/cmd/workflow/simulate/chain/aptos/supported_chains_test.go b/cmd/workflow/simulate/chain/aptos/supported_chains_test.go index f0536d6b..68e8399f 100644 --- a/cmd/workflow/simulate/chain/aptos/supported_chains_test.go +++ b/cmd/workflow/simulate/chain/aptos/supported_chains_test.go @@ -1,12 +1,70 @@ package aptos import ( + "regexp" "testing" chainselectors "github.com/smartcontractkit/chain-selectors" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// Aptos forwarders are 32-byte object addresses encoded as 64 hex chars. +var forwarderRe = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`) + +func TestSupportedChains_AllSelectorsNonZero(t *testing.T) { + t.Parallel() + for i, c := range SupportedChains { + require.NotZerof(t, c.Selector, "index %d has zero selector", i) + } +} + +func TestSupportedChains_AllSelectorsUnique(t *testing.T) { + t.Parallel() + seen := map[uint64]int{} + for i, c := range SupportedChains { + if prev, ok := seen[c.Selector]; ok { + t.Fatalf("duplicate selector %d at indices %d and %d", c.Selector, prev, i) + } + seen[c.Selector] = i + } +} + +func TestSupportedChains_AllForwardersValidHexAddress(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + assert.True(t, forwarderRe.MatchString(c.Forwarder), + "selector %d: invalid forwarder hex %q", c.Selector, c.Forwarder) + } +} + +func TestSupportedChains_AllSelectorsResolveToChainName(t *testing.T) { + t.Parallel() + for _, c := range SupportedChains { + info, err := chainselectors.GetSelectorFamily(c.Selector) + require.NoErrorf(t, err, "selector %d missing family", c.Selector) + assert.NotEmpty(t, info) + } +} + +func TestSupportedChains_NoForwarderEmpty(t *testing.T) { + t.Parallel() + for i, c := range SupportedChains { + require.NotEmpty(t, c.Forwarder, "supported chain at index %d has empty forwarder", i) + } +} + +func TestSupportedChains_ReturnedByChainType(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + ret := ct.SupportedChains() + require.Equal(t, len(SupportedChains), len(ret)) + 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_MainnetAndTestnet(t *testing.T) { t.Parallel() var hasMainnet, hasTestnet bool From c28df352290d191754e97498eaaa2a50be619779 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 15:17:27 +0100 Subject: [PATCH 16/28] test(simulate): cover zero-limit and nil sub-message passthrough Add parity coverage to LimitedAptosChain and LimitedEVMChain wrappers for the --limits none equivalent (zero chain.Limits delegates oversized input) and nil GasConfig / nil Report inputs (no panic, delegates). --- .../chain/aptos/limited_capabilities_test.go | 34 ++++++++++++++++ .../chain/evm/limited_capabilities_test.go | 40 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go index 7e461402..fa32085b 100644 --- a/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go @@ -80,3 +80,37 @@ func TestLimitedAptosChain_WriteReport_Delegates(t *testing.T) { require.Nil(t, capErr) assert.True(t, inner.writeCalled) } + +func TestLimitedAptosChain_WriteReport_ZeroLimitsDelegate(t *testing.T) { + t.Parallel() + inner := &stubCap{} + l := NewLimitedAptosChain(inner, chain.Limits{}) + _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ + Report: &sdk.ReportResponse{RawReport: make([]byte, 1_000_000)}, + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 1_000_000_000}, + }) + require.Nil(t, capErr) + assert.True(t, inner.writeCalled) +} + +func TestLimitedAptosChain_WriteReport_NilGasConfigDelegates(t *testing.T) { + t.Parallel() + inner := &stubCap{} + l := NewLimitedAptosChain(inner, chain.Limits{ReportSize: 100, GasLimit: 1000}) + _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ + Report: &sdk.ReportResponse{RawReport: []byte("x")}, + }) + require.Nil(t, capErr) + assert.True(t, inner.writeCalled) +} + +func TestLimitedAptosChain_WriteReport_NilReportDelegates(t *testing.T) { + t.Parallel() + inner := &stubCap{} + l := NewLimitedAptosChain(inner, chain.Limits{ReportSize: 100, GasLimit: 1000}) + _, capErr := l.WriteReport(context.Background(), commonCap.RequestMetadata{}, &aptoscappb.WriteReportRequest{ + GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 50}, + }) + require.Nil(t, capErr) + assert.True(t, inner.writeCalled) +} diff --git a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go index 3ba6f5e1..8b3eb916 100644 --- a/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/evm/limited_capabilities_test.go @@ -141,3 +141,43 @@ func TestLimitedEVMChainWriteReportDelegatesOnBoundaryValues(t *testing.T) { assert.Same(t, expectedResp, resp) assert.Equal(t, 1, inner.writeReportCalls) } + +func TestLimitedEVMChainWriteReportZeroLimitsDelegate(t *testing.T) { + t.Parallel() + + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, chain.Limits{}) + + _, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: make([]byte, 1_000_000)}, + GasConfig: &evmcappb.GasConfig{GasLimit: 1_000_000_000}, + }) + require.NoError(t, err) + assert.Equal(t, 1, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportNilGasConfigDelegates(t *testing.T) { + t.Parallel() + + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, chain.Limits{ReportSize: 100, GasLimit: 10}) + + _, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + Report: &sdkpb.ReportResponse{RawReport: []byte("x")}, + }) + require.NoError(t, err) + assert.Equal(t, 1, inner.writeReportCalls) +} + +func TestLimitedEVMChainWriteReportNilReportDelegates(t *testing.T) { + t.Parallel() + + inner := &evmClientCapabilityStub{} + wrapper := NewLimitedEVMChain(inner, chain.Limits{ReportSize: 100, GasLimit: 100}) + + _, err := wrapper.WriteReport(context.Background(), commonCap.RequestMetadata{}, &evmcappb.WriteReportRequest{ + GasConfig: &evmcappb.GasConfig{GasLimit: 50}, + }) + require.NoError(t, err) + assert.Equal(t, 1, inner.writeReportCalls) +} From 2d8f3ae4dfc02ce46b048a757a6225c70630816e Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 15:28:01 +0100 Subject: [PATCH 17/28] refactor(simulate/aptos): drop dead-defensive nil input guard gRPC server stubs guarantee a non-nil top-level WriteReportRequest, so 'input != nil' was unreachable. Align with the EVM wrapper which only guards on the optional Report and GasConfig sub-messages. --- cmd/workflow/simulate/chain/aptos/limited_capabilities.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go index 6c476da2..bb37c5b9 100644 --- a/cmd/workflow/simulate/chain/aptos/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go @@ -26,7 +26,7 @@ func NewLimitedAptosChain(inner aptosserver.ClientCapability, limits chain.Limit } func (l *LimitedAptosChain) WriteReport(ctx context.Context, metadata commonCap.RequestMetadata, input *aptoscappb.WriteReportRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.WriteReportReply], caperrors.Error) { - if input != nil && input.Report != nil { + if input.Report != nil { if lim := l.limits.ReportSize; lim > 0 && len(input.Report.RawReport) > lim { return nil, caperrors.NewPublicUserError( fmt.Errorf("simulation limit exceeded: aptos report size %d > %d", len(input.Report.RawReport), lim), @@ -34,7 +34,7 @@ func (l *LimitedAptosChain) WriteReport(ctx context.Context, metadata commonCap. ) } } - if input != nil && input.GasConfig != nil { + if input.GasConfig != nil { if gl := l.limits.GasLimit; gl > 0 && input.GasConfig.MaxGasAmount > gl { return nil, caperrors.NewPublicUserError( fmt.Errorf("simulation limit exceeded: aptos max_gas_amount %d > %d", input.GasConfig.MaxGasAmount, gl), From 3c852d83c5c28f5e419ac7d3b68f61b8937885a9 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 15:40:35 +0100 Subject: [PATCH 18/28] test(simulate/aptos): cover mixed known + experimental health check Mirror EVM TestHealthCheck_MixedKnownAndExperimental: a healthy named selector plus an experimental selector that errors should report the experimental label and omit the healthy one. --- .../simulate/chain/aptos/health_test.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmd/workflow/simulate/chain/aptos/health_test.go b/cmd/workflow/simulate/chain/aptos/health_test.go index 75f68f2f..7f7d7636 100644 --- a/cmd/workflow/simulate/chain/aptos/health_test.go +++ b/cmd/workflow/simulate/chain/aptos/health_test.go @@ -98,4 +98,26 @@ func TestRunRPCHealthCheck_AggregatesMultiple(t *testing.T) { assert.Contains(t, err.Error(), "zero chain ID") } +func TestRunRPCHealthCheck_MixedKnownAndExperimental(t *testing.T) { + t.Parallel() + healthy := mocks.NewAptosRpcClient(t) + healthy.EXPECT().GetChainId().Return(uint8(1), nil).Once() + bad := mocks.NewAptosRpcClient(t) + bad.EXPECT().GetChainId().Return(uint8(0), errors.New("boom")).Once() + + const expSel uint64 = 99999999 + err := RunRPCHealthCheck( + map[uint64]chain.ChainClient{ + chainselectors.APTOS_TESTNET.Selector: healthy, + expSel: bad, + }, + map[uint64]bool{expSel: true}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "[experimental chain 99999999]") + assert.Contains(t, err.Error(), "boom") + // healthy named chain must not appear in errors. + assert.NotContains(t, err.Error(), "[aptos-testnet]") +} + type stubNonAptosClient struct{} From b4d2025f6d7880e2f8e39375c1b449c08b03ec86 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 15:46:41 +0100 Subject: [PATCH 19/28] test(simulate/aptos): align chaintype coverage with EVM Add parity tests: ResolveTriggerData/ExecuteTrigger return the 'no trigger surface' contract, RegisterCapabilities surfaces wrong client type and constructs empty on no clients, RunHealthCheck propagates invalid client type, init() registers under chain.Names, CollectCLIInputs returns empty. --- .../simulate/chain/aptos/chaintype_test.go | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype_test.go b/cmd/workflow/simulate/chain/aptos/chaintype_test.go index 42c003be..5060cc78 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype_test.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype_test.go @@ -1,14 +1,28 @@ package aptos import ( + "context" + "io" "testing" + "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 nopCommonLogger() logger.Logger { return logger.NewWithSync(io.Discard) } + +func newRegistry(t *testing.T) *capabilities.Registry { + t.Helper() + return capabilities.NewRegistry(logger.Test(t)) +} + func TestResolveKey_SentinelUnderBroadcastFails(t *testing.T) { t.Parallel() ct := &AptosChainType{} @@ -58,3 +72,82 @@ func TestSupports_False(t *testing.T) { ct := &AptosChainType{} assert.False(t, ct.Supports(1)) } + +func TestResolveTriggerData_ReturnsNoTriggerSurface(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + _, err := ct.ResolveTriggerData(context.Background(), 1, chain.TriggerParams{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no trigger surface") +} + +func TestExecuteTrigger_ReturnsNoTriggerSurface(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + err := ct.ExecuteTrigger(context.Background(), 1, "tid", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no trigger surface") +} + +func TestRegisterCapabilities_WrongClientType(t *testing.T) { + t.Parallel() + lg := zerolog.Nop() + ct := &AptosChainType{log: &lg} + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{1: "not-an-aptos-client"}, + Forwarders: map[uint64]string{1: "0x1"}, + } + _, err := ct.RegisterCapabilities(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "client for selector 1 is not aptosfakes.AptosClient") +} + +func TestRegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { + t.Parallel() + lg := zerolog.Nop() + ct := &AptosChainType{log: &lg} + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + Forwarders: map[uint64]string{}, + Logger: nopCommonLogger(), + Registry: newRegistry(t), + } + srvcs, err := ct.RegisterCapabilities(context.Background(), cfg) + require.NoError(t, err) + assert.Empty(t, srvcs) + assert.False(t, ct.Supports(1)) +} + +func TestRunHealthCheck_PropagatesInvalidClientType(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + err := ct.RunHealthCheck(chain.ResolvedChains{ + Clients: map[uint64]chain.ChainClient{1: "not-aptos"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client type for Aptos chain type") +} + +func TestRegisteredInFactoryRegistry(t *testing.T) { + t.Parallel() + lg := zerolog.Nop() + chain.Build(&lg) + found := false + for _, n := range chain.Names() { + if n == "aptos" { + found = true + break + } + } + require.True(t, found, "aptos chain type should be registered at init; got %v", chain.Names()) + + ct, err := chain.Get("aptos") + require.NoError(t, err) + require.Equal(t, "aptos", ct.Name()) +} + +func TestCollectCLIInputs_ReturnsEmpty(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + assert.Empty(t, ct.CollectCLIInputs(nil)) +} From b539d726d9f3bb0ad41d97efd6c0e84c4b74da5a Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 15:54:28 +0100 Subject: [PATCH 20/28] chore(simulate/aptos): log when experimental forwarder matches supported Mirror EVM's debug log in ResolveClients so the no-override branch is observable; aligns Aptos with the existing EVM behaviour. --- cmd/workflow/simulate/chain/aptos/chaintype.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index 28eaf47f..6c6044c9 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -89,6 +89,8 @@ func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, ui.Warning(fmt.Sprintf("Warning: experimental aptos chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", ec.ChainSelector, forwarders[ec.ChainSelector], ec.Forwarder)) forwarders[ec.ChainSelector] = ec.Forwarder + } else { + ct.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") } continue } From 4354b95c6b3ac7dfcadb30883eb2f98d32b70fce Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 15:58:33 +0100 Subject: [PATCH 21/28] refactor(simulate/aptos): delegate ParseTriggerChainSelector to shared helper Use chain.ParseTriggerChainSelector(ct.Name(), triggerID) like EVM, removing the bespoke prefix/suffix parsing and its local test (already covered by chain/trigger_test.go's Aptos cases). --- cmd/workflow/simulate/chain/aptos/chaintype.go | 13 +------------ cmd/workflow/simulate/chain/aptos/chaintype_test.go | 10 ---------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index 6c6044c9..587c1aa4 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -4,7 +4,6 @@ import ( "context" "encoding/hex" "fmt" - "strconv" "strings" "github.com/aptos-labs/aptos-go-sdk/crypto" @@ -184,17 +183,7 @@ func (ct *AptosChainType) Supports(selector uint64) bool { } func (ct *AptosChainType) ParseTriggerChainSelector(triggerID string) (uint64, bool) { - const prefix = "aptos:ChainSelector:" - const suffix = "@1.0.0" - if !strings.HasPrefix(triggerID, prefix) || !strings.HasSuffix(triggerID, suffix) { - return 0, false - } - mid := triggerID[len(prefix) : len(triggerID)-len(suffix)] - sel, err := strconv.ParseUint(mid, 10, 64) - if err != nil { - return 0, false - } - return sel, true + return chain.ParseTriggerChainSelector(ct.Name(), triggerID) } func (ct *AptosChainType) RunHealthCheck(resolved chain.ResolvedChains) error { diff --git a/cmd/workflow/simulate/chain/aptos/chaintype_test.go b/cmd/workflow/simulate/chain/aptos/chaintype_test.go index 5060cc78..a355f61f 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype_test.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype_test.go @@ -57,16 +57,6 @@ func TestResolveKey_ValidKeyBroadcast(t *testing.T) { assert.NotNil(t, k) } -func TestParseTriggerChainSelector(t *testing.T) { - t.Parallel() - ct := &AptosChainType{} - sel, ok := ct.ParseTriggerChainSelector("aptos:ChainSelector:4741433654826277614@1.0.0") - require.True(t, ok) - assert.Equal(t, uint64(4741433654826277614), sel) - _, ok = ct.ParseTriggerChainSelector("evm:ChainSelector:1@1.0.0") - assert.False(t, ok) -} - func TestSupports_False(t *testing.T) { t.Parallel() ct := &AptosChainType{} From f02f4d07d7ec26952dbef4b9f06991c7e32fc879 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 16:11:12 +0100 Subject: [PATCH 22/28] chore(simulate/evm): wrap registry.Add error with selector context Match the Aptos error wrap so capability-registration failures point at the offending chain selector instead of bubbling a bare error. --- cmd/workflow/simulate/chain/evm/capabilities.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/workflow/simulate/chain/evm/capabilities.go b/cmd/workflow/simulate/chain/evm/capabilities.go index 0e87bb07..88348b2a 100644 --- a/cmd/workflow/simulate/chain/evm/capabilities.go +++ b/cmd/workflow/simulate/chain/evm/capabilities.go @@ -3,6 +3,7 @@ package evm import ( "context" "crypto/ecdsa" + "fmt" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" @@ -54,7 +55,7 @@ func NewEVMChainCapabilities( evmServer := evmserver.NewClientServer(evmCap) if err := registry.Add(ctx, evmServer); err != nil { - return nil, err + return nil, fmt.Errorf("register evm capability for selector %d: %w", sel, err) } evmChains[sel] = evm From 260f19dc0730209859a7befcb638b0d8a4f247c7 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 18:41:02 +0100 Subject: [PATCH 23/28] fix(simulate/aptos): improve Aptos config guidance Clarify Aptos simulator errors and generated environment setup so users get the same remediation cues as EVM without adding extra address normalization. Made-with: Cursor --- .../simulate/chain/aptos/chaintype.go | 21 +++++++++--------- .../simulate/chain/aptos/chaintype_test.go | 22 +++++++++++++++++++ cmd/workflow/simulate/chain/aptos/health.go | 2 +- .../simulate/chain/aptos/health_test.go | 2 +- .../chain/aptos/limited_capabilities.go | 4 ++-- .../chain/aptos/limited_capabilities_test.go | 4 +++- .../simulate/chain/evm/chaintype_test.go | 13 +++++++++++ internal/settings/settings_generate.go | 12 +++++----- internal/settings/settings_generate_test.go | 16 ++++++++++++++ internal/settings/template/.env.tpl | 3 +++ 10 files changed, 79 insertions(+), 20 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index 587c1aa4..ff084aa1 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -80,14 +80,15 @@ func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, if strings.TrimSpace(ec.RPCURL) == "" { return chain.ResolvedChains{}, fmt.Errorf("experimental aptos chain %d missing rpc-url", ec.ChainSelector) } - if strings.TrimSpace(ec.Forwarder) == "" { + forwarder := strings.TrimSpace(ec.Forwarder) + if forwarder == "" { return chain.ResolvedChains{}, fmt.Errorf("experimental aptos chain %d missing forwarder", ec.ChainSelector) } if _, exists := clients[ec.ChainSelector]; exists { - if forwarders[ec.ChainSelector] != ec.Forwarder { + if forwarders[ec.ChainSelector] != forwarder { ui.Warning(fmt.Sprintf("Warning: experimental aptos chain %d overrides supported chain forwarder (supported: %s, experimental: %s)\n", - ec.ChainSelector, forwarders[ec.ChainSelector], ec.Forwarder)) - forwarders[ec.ChainSelector] = ec.Forwarder + ec.ChainSelector, forwarders[ec.ChainSelector], forwarder)) + forwarders[ec.ChainSelector] = forwarder } else { ct.log.Debug().Uint64("chain-selector", ec.ChainSelector).Msg("Experimental chain matches supported chain config") } @@ -99,7 +100,7 @@ func (ct *AptosChainType) ResolveClients(v *viper.Viper) (chain.ResolvedChains, return chain.ResolvedChains{}, fmt.Errorf("failed to create aptos client for experimental chain %d: %w", ec.ChainSelector, err) } clients[ec.ChainSelector] = client - forwarders[ec.ChainSelector] = ec.Forwarder + forwarders[ec.ChainSelector] = forwarder experimental[ec.ChainSelector] = true ui.Dim(fmt.Sprintf("Added experimental aptos chain (chain-selector: %d)\n", ec.ChainSelector)) } @@ -115,14 +116,14 @@ func (ct *AptosChainType) ResolveKey(s *settings.Settings, broadcast bool) (inte if err != nil { return nil, fmt.Errorf("failed to parse private key, required to broadcast. Please check CRE_APTOS_PRIVATE_KEY in your .env file or system environment: %w", err) } - return nil, fmt.Errorf("CRE_APTOS_PRIVATE_KEY must be 32 hex bytes (64 chars); got len=%d", len(bytes)) + return nil, fmt.Errorf("CRE_APTOS_PRIVATE_KEY must be 32 hex bytes (64 chars); got len=%d. Please check CRE_APTOS_PRIVATE_KEY in your .env file or system environment", len(bytes)) } bytes, _ = hex.DecodeString(defaultSentinelAptosSeed) - ui.Warning("Using default Aptos private key for dry-run simulation. Set CRE_APTOS_PRIVATE_KEY to broadcast.") + ui.Warning("Using default Aptos private key for chain write simulation. To use your own key, set CRE_APTOS_PRIVATE_KEY in your .env file or system environment.") } sentinel, _ := hex.DecodeString(defaultSentinelAptosSeed) if broadcast && hex.EncodeToString(bytes) == hex.EncodeToString(sentinel) { - return nil, fmt.Errorf("CRE_APTOS_PRIVATE_KEY must not be the sentinel seed under --broadcast") + return nil, fmt.Errorf("you must configure a valid Aptos private key to perform on-chain writes. Please set CRE_APTOS_PRIVATE_KEY in your .env file or system environment before using the --broadcast flag") } k := &crypto.Ed25519PrivateKey{} if err := k.FromBytes(bytes); err != nil { @@ -140,7 +141,7 @@ func (ct *AptosChainType) RegisterCapabilities(ctx context.Context, cfg chain.Ca for sel, c := range cfg.Clients { ac, ok := c.(aptosfakes.AptosClient) if !ok { - return nil, fmt.Errorf("aptos: client for selector %d is not aptosfakes.AptosClient (got %T)", sel, c) + return nil, fmt.Errorf("aptos: client for selector %d is not aptosfakes.AptosClient", sel) } typedClients[sel] = ac } @@ -149,7 +150,7 @@ func (ct *AptosChainType) RegisterCapabilities(ctx context.Context, cfg chain.Ca var ok bool pk, ok = cfg.PrivateKey.(*crypto.Ed25519PrivateKey) if !ok { - return nil, fmt.Errorf("aptos: private key is not *crypto.Ed25519PrivateKey (got %T)", cfg.PrivateKey) + return nil, fmt.Errorf("aptos: private key is not *crypto.Ed25519PrivateKey") } } var lim chain.Limits diff --git a/cmd/workflow/simulate/chain/aptos/chaintype_test.go b/cmd/workflow/simulate/chain/aptos/chaintype_test.go index a355f61f..a2d5bc72 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype_test.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype_test.go @@ -39,6 +39,14 @@ func TestResolveKey_UnparseableUnderBroadcastFails(t *testing.T) { require.Error(t, err) } +func TestResolveKey_ShortKeyUnderBroadcastFails(t *testing.T) { + t.Parallel() + ct := &AptosChainType{} + s := &settings.Settings{User: settings.UserSettings{PrivateKeys: map[string]string{settings.Aptos.Name: "1111"}}} + _, err := ct.ResolveKey(s, true) + require.Error(t, err) +} + func TestResolveKey_UnparseableNonBroadcastFallsBackToSentinel(t *testing.T) { t.Parallel() ct := &AptosChainType{} @@ -92,6 +100,20 @@ func TestRegisterCapabilities_WrongClientType(t *testing.T) { assert.Contains(t, err.Error(), "client for selector 1 is not aptosfakes.AptosClient") } +func TestRegisterCapabilities_WrongPrivateKeyType(t *testing.T) { + t.Parallel() + lg := zerolog.Nop() + ct := &AptosChainType{log: &lg} + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + Forwarders: map[uint64]string{}, + PrivateKey: "not-an-ed25519-key", + } + _, err := ct.RegisterCapabilities(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "private key is not *crypto.Ed25519PrivateKey") +} + func TestRegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { t.Parallel() lg := zerolog.Nop() diff --git a/cmd/workflow/simulate/chain/aptos/health.go b/cmd/workflow/simulate/chain/aptos/health.go index dbe93733..54bc9bd7 100644 --- a/cmd/workflow/simulate/chain/aptos/health.go +++ b/cmd/workflow/simulate/chain/aptos/health.go @@ -14,7 +14,7 @@ import ( // experimentalSelectors identifies chains sourced from experimental-chains config. func RunRPCHealthCheck(clients map[uint64]chain.ChainClient, experimentalSelectors map[uint64]bool) error { if len(clients) == 0 { - return fmt.Errorf("no Aptos RPC URLs found for supported or experimental chains") + return fmt.Errorf("check your settings: no Aptos RPC URLs found for supported or experimental chains") } var errs []error for sel, c := range clients { diff --git a/cmd/workflow/simulate/chain/aptos/health_test.go b/cmd/workflow/simulate/chain/aptos/health_test.go index 7f7d7636..81673942 100644 --- a/cmd/workflow/simulate/chain/aptos/health_test.go +++ b/cmd/workflow/simulate/chain/aptos/health_test.go @@ -17,7 +17,7 @@ func TestRunRPCHealthCheck_NoClients(t *testing.T) { t.Parallel() err := RunRPCHealthCheck(map[uint64]chain.ChainClient{}, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "no Aptos RPC URLs") + assert.Contains(t, err.Error(), "check your settings: no Aptos RPC URLs") } func TestRunRPCHealthCheck_InvalidClientType(t *testing.T) { diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go index bb37c5b9..30591298 100644 --- a/cmd/workflow/simulate/chain/aptos/limited_capabilities.go +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities.go @@ -29,7 +29,7 @@ func (l *LimitedAptosChain) WriteReport(ctx context.Context, metadata commonCap. if input.Report != nil { if lim := l.limits.ReportSize; lim > 0 && len(input.Report.RawReport) > lim { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: aptos report size %d > %d", len(input.Report.RawReport), lim), + fmt.Errorf("simulation limit exceeded: Aptos chain write report size %d bytes exceeds limit of %d bytes", len(input.Report.RawReport), lim), caperrors.ResourceExhausted, ) } @@ -37,7 +37,7 @@ func (l *LimitedAptosChain) WriteReport(ctx context.Context, metadata commonCap. if input.GasConfig != nil { if gl := l.limits.GasLimit; gl > 0 && input.GasConfig.MaxGasAmount > gl { return nil, caperrors.NewPublicUserError( - fmt.Errorf("simulation limit exceeded: aptos max_gas_amount %d > %d", input.GasConfig.MaxGasAmount, gl), + fmt.Errorf("simulation limit exceeded: Aptos max_gas_amount %d exceeds maximum of %d", input.GasConfig.MaxGasAmount, gl), caperrors.ResourceExhausted, ) } diff --git a/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go index fa32085b..8bbc4ddc 100644 --- a/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go +++ b/cmd/workflow/simulate/chain/aptos/limited_capabilities_test.go @@ -2,6 +2,7 @@ package aptos import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -16,7 +17,6 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) - type stubCap struct{ writeCalled bool } func (s *stubCap) AccountAPTBalance(context.Context, commonCap.RequestMetadata, *aptoscappb.AccountAPTBalanceRequest) (*commonCap.ResponseAndMetadata[*aptoscappb.AccountAPTBalanceReply], caperrors.Error) { @@ -54,6 +54,7 @@ func TestLimitedAptosChain_WriteReport_ReportTooLarge(t *testing.T) { Report: &sdk.ReportResponse{RawReport: make([]byte, 11)}, }) require.NotNil(t, capErr) + assert.Contains(t, fmt.Sprint(capErr), "Aptos chain write report size 11 bytes exceeds limit of 10 bytes") assert.False(t, inner.writeCalled) } @@ -66,6 +67,7 @@ func TestLimitedAptosChain_WriteReport_MaxGasTooHigh(t *testing.T) { GasConfig: &aptoscappb.GasConfig{MaxGasAmount: 101}, }) require.NotNil(t, capErr) + assert.Contains(t, fmt.Sprint(capErr), "Aptos max_gas_amount 101 exceeds maximum of 100") assert.False(t, inner.writeCalled) } diff --git a/cmd/workflow/simulate/chain/evm/chaintype_test.go b/cmd/workflow/simulate/chain/evm/chaintype_test.go index 433707be..0a1bd1fa 100644 --- a/cmd/workflow/simulate/chain/evm/chaintype_test.go +++ b/cmd/workflow/simulate/chain/evm/chaintype_test.go @@ -261,6 +261,19 @@ func TestEVMChainType_RegisterCapabilities_WrongClientType(t *testing.T) { assert.Contains(t, err.Error(), "client for selector 1 is not *ethclient.Client") } +func TestEVMChainType_RegisterCapabilities_WrongPrivateKeyType(t *testing.T) { + t.Parallel() + ct := newEVMChainType() + cfg := chain.CapabilityConfig{ + Clients: map[uint64]chain.ChainClient{}, + Forwarders: map[uint64]string{}, + PrivateKey: "not-an-ecdsa-key", + } + _, err := ct.RegisterCapabilities(context.Background(), cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "private key is not *ecdsa.PrivateKey") +} + // With no clients the caps should still construct, no type-assertion error. func TestEVMChainType_RegisterCapabilities_NoClients_ConstructsEmpty(t *testing.T) { t.Parallel() diff --git a/internal/settings/settings_generate.go b/internal/settings/settings_generate.go index 1651cbdc..a4fcbdd0 100644 --- a/internal/settings/settings_generate.go +++ b/internal/settings/settings_generate.go @@ -28,9 +28,10 @@ var gitIgnoreTemplateContent string var workflowSettingsTemplateContent string type ProjectEnv struct { - FilePath string - GitHubAPIToken string - EthPrivateKey string + FilePath string + GitHubAPIToken string + EthPrivateKey string + AptosPrivateKey string } func GetDefaultReplacements() map[string]string { @@ -118,8 +119,9 @@ func GenerateProjectEnvFile(workingDirectory string) (string, error) { } replacements := map[string]string{ - "GithubApiToken": "your-github-token", - "EthPrivateKey": "your-eth-private-key", + "GithubApiToken": "your-github-token", + "EthPrivateKey": "your-eth-private-key", + "AptosPrivateKey": "your-aptos-private-key", } if err := GenerateFileFromTemplate(outputPath, ProjectEnvironmentTemplateContent, replacements); err != nil { diff --git a/internal/settings/settings_generate_test.go b/internal/settings/settings_generate_test.go index d612f66e..359428fc 100644 --- a/internal/settings/settings_generate_test.go +++ b/internal/settings/settings_generate_test.go @@ -3,6 +3,7 @@ package settings import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -68,6 +69,21 @@ func TestGetReplacementsWithNetworks(t *testing.T) { assert.Contains(t, repl, "ConfigPathStaging") } +func TestProjectEnvironmentTemplateIncludesAptosPrivateKey(t *testing.T) { + replacements := map[string]string{ + "EthPrivateKey": "eth-key", + "AptosPrivateKey": "aptos-key", + } + + content := ProjectEnvironmentTemplateContent + for key, value := range replacements { + content = strings.ReplaceAll(content, "{{"+key+"}}", value) + } + + assert.Contains(t, content, "CRE_ETH_PRIVATE_KEY=eth-key") + assert.Contains(t, content, "CRE_APTOS_PRIVATE_KEY=aptos-key") +} + func TestPatchProjectRPCs(t *testing.T) { t.Run("patches matching chain URLs", func(t *testing.T) { tmpDir := t.TempDir() diff --git a/internal/settings/template/.env.tpl b/internal/settings/template/.env.tpl index 0f17f640..3a3ba339 100644 --- a/internal/settings/template/.env.tpl +++ b/internal/settings/template/.env.tpl @@ -6,6 +6,9 @@ # Ethereum private key or 1Password reference (e.g. op://vault/item/field) CRE_ETH_PRIVATE_KEY={{EthPrivateKey}} +# Aptos private key or 1Password reference (32-byte Ed25519 seed hex) +CRE_APTOS_PRIVATE_KEY={{AptosPrivateKey}} + # RPC secret keys — referenced in project.yaml via ${VAR_NAME} syntax. # Example: # CRE_SECRET_RPC_SEPOLIA=my-secret-api-key From e5d7e5e8811778aa7ab5c8fab761b207c639461e Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 19:00:07 +0100 Subject: [PATCH 24/28] Reset gitignore --- .gitignore | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitignore b/.gitignore index 8911ff74..521b4a59 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,3 @@ encrypted.secrets.json # Output produced by e2e Anvil tests test/test.yaml - -# Local-only Aptos test scaffolding (untracked) -test/aptos_cli_scenarios_test.go -test/test_project/aptos_smoke/ -cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go From c689b1b244127c97f07e3ed25c7740030a181c6f Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 27 Apr 2026 19:03:43 +0100 Subject: [PATCH 25/28] chore: tidy go.sum (drop stale chainlink-aptos entry) Made-with: Cursor --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index cfc7622f..ed6f287d 100644 --- a/go.sum +++ b/go.sum @@ -1307,8 +1307,6 @@ github.com/smartcontractkit/ccip-owner-contracts v0.1.0 h1:GiBDtlx7539o7AKlDV+9L github.com/smartcontractkit/ccip-owner-contracts v0.1.0/go.mod h1:NnT6w4Kj42OFFXhSx99LvJZWPpMjmo4+CpDEWfw61xY= github.com/smartcontractkit/chain-selectors v1.0.98 h1:fuI7CQ1o5cX64eO4/LvwtfhdpGFH5vnsM/bFHRwEiww= github.com/smartcontractkit/chain-selectors v1.0.98/go.mod h1:qy7whtgG5g+7z0jt0nRyii9bLND9m15NZTzuQPkMZ5w= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260407161350-a86b1969da65 h1:b6+ZvoZxXSj7HywoZ0CfWtC6k47eBSaxNzc2LqtiXBA= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260407161350-a86b1969da65/go.mod h1:BbVsx2VcwSVWkd0C5TcAkQBnFaeYFnogJgUa9BUla18= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260422165416-b56c9c2b5867 h1:DoFHH4hMm1aGNiUQVZGRziMdwGByy4C+Inm5mOlxTYc= github.com/smartcontractkit/chainlink-aptos v0.0.0-20260422165416-b56c9c2b5867/go.mod h1:ZU57FhGIb+m20yysn2fw+vLh3qB5hcgd06RXEUEDBck= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= From 13e59e7e0a29236f86f341c2edd51d8d1aab2306 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 11 May 2026 13:24:42 +0100 Subject: [PATCH 26/28] fix(ci): post-merge fixes for ci-test-unit, ci-lint, gendoc - handler_test.go: replace removed UserSettings.EthPrivateKey with PrivateKeys map (test build was failing typecheck) - docs/cre_workflow_simulate.md: regenerate via 'make gendoc' to match updated --broadcast flag help text --- cmd/secrets/common/handler_test.go | 2 +- docs/cre_workflow_simulate.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/secrets/common/handler_test.go b/cmd/secrets/common/handler_test.go index 998be9ec..d7d859b2 100644 --- a/cmd/secrets/common/handler_test.go +++ b/cmd/secrets/common/handler_test.go @@ -349,7 +349,7 @@ func TestNewHandler_WorkflowRegistryClient(t *testing.T) { Logger: &logger, ClientFactory: cf, Settings: &settings.Settings{ - User: settings.UserSettings{EthPrivateKey: ""}, + User: settings.UserSettings{PrivateKeys: map[string]string{settings.EVM.Name: ""}}, Workflow: settings.WorkflowSettings{}, }, EnvironmentSet: &environments.EnvironmentSet{GatewayURL: "http://localhost"}, diff --git a/docs/cre_workflow_simulate.md b/docs/cre_workflow_simulate.md index 8e95a535..158240a4 100644 --- a/docs/cre_workflow_simulate.md +++ b/docs/cre_workflow_simulate.md @@ -19,7 +19,7 @@ cre workflow simulate ./my-workflow ### Options ``` - --broadcast Broadcast transactions to the EVM (default: false) + --broadcast Broadcast transactions to configured chains (default: false) --config string Override the config file path from workflow.yaml --default-config Use the config path from workflow.yaml settings (default behavior) -g, --engine-logs Enable non-fatal engine logging From 1b3023f8c40c272635460b381d6e6c2235003160 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 11 May 2026 14:26:57 +0100 Subject: [PATCH 27/28] fix(lint): gci import grouping in simulate pkg --- cmd/workflow/simulate/chain/aptos/capabilities.go | 3 +-- cmd/workflow/simulate/simulate.go | 1 - cmd/workflow/simulate/simulate_test.go | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/capabilities.go b/cmd/workflow/simulate/chain/aptos/capabilities.go index 308debc1..a44358a1 100644 --- a/cmd/workflow/simulate/chain/aptos/capabilities.go +++ b/cmd/workflow/simulate/chain/aptos/capabilities.go @@ -7,12 +7,11 @@ import ( "github.com/aptos-labs/aptos-go-sdk" "github.com/aptos-labs/aptos-go-sdk/crypto" + aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" aptosserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/aptos/server" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink/v2/core/capabilities" - aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" - "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" ) diff --git a/cmd/workflow/simulate/simulate.go b/cmd/workflow/simulate/simulate.go index ff8afec1..b4c23b27 100644 --- a/cmd/workflow/simulate/simulate.go +++ b/cmd/workflow/simulate/simulate.go @@ -33,7 +33,6 @@ import ( "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" _ "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain/aptos" // register Aptos chain family via package init _ "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" diff --git a/cmd/workflow/simulate/simulate_test.go b/cmd/workflow/simulate/simulate_test.go index 1ae31d78..b6c87a95 100644 --- a/cmd/workflow/simulate/simulate_test.go +++ b/cmd/workflow/simulate/simulate_test.go @@ -72,7 +72,7 @@ func TestBlankWorkflowSimulation(t *testing.T) { Settings: &settings.Settings{ Workflow: workflowSettings, User: settings.UserSettings{ - TargetName: "staging-settings", + TargetName: "staging-settings", PrivateKeys: map[string]string{settings.EVM.Name: "88888845d8761ca4a8cefb324c89702f12114ffbd0c47222f12aac0ad6538888"}, }, }, From d43a926107e5e5ac3019894a8ac1000a70bc5eb8 Mon Sep 17 00:00:00 2001 From: Michael Fletcher Date: Mon, 11 May 2026 15:28:27 +0100 Subject: [PATCH 28/28] fix(lint): gci import grouping in simulate/chain/aptos --- cmd/workflow/simulate/chain/aptos/chaintype.go | 3 +-- cmd/workflow/simulate/chain/aptos/supported_chains_test.go | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/workflow/simulate/chain/aptos/chaintype.go b/cmd/workflow/simulate/chain/aptos/chaintype.go index ff084aa1..e5dd0aa7 100644 --- a/cmd/workflow/simulate/chain/aptos/chaintype.go +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -10,11 +10,10 @@ import ( "github.com/rs/zerolog" "github.com/spf13/viper" + aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" corekeys "github.com/smartcontractkit/chainlink-common/keystore/corekeys" "github.com/smartcontractkit/chainlink-common/pkg/services" - aptosfakes "github.com/smartcontractkit/chainlink-aptos/fakes" - "github.com/smartcontractkit/cre-cli/cmd/workflow/simulate/chain" "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/ui" diff --git a/cmd/workflow/simulate/chain/aptos/supported_chains_test.go b/cmd/workflow/simulate/chain/aptos/supported_chains_test.go index 68e8399f..0bc7e57d 100644 --- a/cmd/workflow/simulate/chain/aptos/supported_chains_test.go +++ b/cmd/workflow/simulate/chain/aptos/supported_chains_test.go @@ -4,9 +4,10 @@ import ( "regexp" "testing" - chainselectors "github.com/smartcontractkit/chain-selectors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" ) // Aptos forwarders are 32-byte object addresses encoded as 64 hex chars.