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..346ce11d --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/chaintype.go @@ -0,0 +1,169 @@ +package aptos + +import ( + "context" + "encoding/hex" + "fmt" + "strconv" + "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 { + 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.") + } + 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) { + 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 +} + +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..8fb74523 --- /dev/null +++ b/cmd/workflow/simulate/chain/aptos/simulator_scenarios_test.go @@ -0,0 +1,1059 @@ +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 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) + }}, + // --- 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) + 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/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..49bb24c6 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) @@ -210,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, @@ -220,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 487adbf4..09459f08 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) @@ -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 cdd372b6..c4020c26 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. @@ -97,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") @@ -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..1b897a14 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,14 @@ 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 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 +25,8 @@ 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-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 github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260326111235-8c09d1a4491f @@ -42,7 +44,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 +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-0.20260318141106-21b6ef4ed363 // 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 +133,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 +196,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 +238,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 +276,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 @@ -306,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 @@ -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 diff --git a/go.sum b/go.sum index 9d30ca40..7e0d0898 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,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-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-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= @@ -1295,8 +1296,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 +1579,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 +1604,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 +1616,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 +1680,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 +1718,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 +1767,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 +1864,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 +1882,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 +1897,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 +1951,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..dfa3987e --- /dev/null +++ b/test/aptos_cli_scenarios_test.go @@ -0,0 +1,298 @@ +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 := 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") + + 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 := 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)) + + 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 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"