diff --git a/.github/workflows/cre-system-tests.yaml b/.github/workflows/cre-system-tests.yaml index ba27f356917..d9d07a06c7e 100644 --- a/.github/workflows/cre-system-tests.yaml +++ b/.github/workflows/cre-system-tests.yaml @@ -75,6 +75,9 @@ jobs: # Add list of tests with certain topologies PER_TEST_TOPOLOGIES_JSON=${PER_TEST_TOPOLOGIES_JSON:-'{ + "Test_CRE_V2_Aptos_Suite": [ + {"topology":"workflow-gateway-aptos","configs":"configs/workflow-gateway-don-aptos.toml"} + ], "Test_CRE_V2_Solana_Suite": [ {"topology":"workflow","configs":"configs/workflow-don-solana.toml"} ], @@ -213,6 +216,35 @@ jobs: chmod +x bin/ctf echo "::endgroup::" + - name: Install Aptos CLI + if: ${{ matrix.tests.test_name == 'Test_CRE_V2_Aptos_Suite' }} + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + APTOS_CLI_TAG: "aptos-cli-v7.8.0" + run: | + echo "::startgroup::Install Aptos CLI" + bin_dir="$HOME/.local/bin" + mkdir -p "$bin_dir" + + gh release download "${APTOS_CLI_TAG}" \ + --pattern "aptos-cli-*-Ubuntu-24.04-x86_64.zip" \ + --clobber \ + --repo aptos-labs/aptos-core \ + -O aptos-cli.zip + + unzip -o aptos-cli.zip -d aptos-cli-extract >/dev/null + aptos_path="$(find aptos-cli-extract -type f -name aptos | head -n1)" + if [[ -z "$aptos_path" ]]; then + echo "failed to locate aptos binary in release archive" + exit 1 + fi + + install -m 0755 "$aptos_path" "$bin_dir/aptos" + echo "$bin_dir" >> "$GITHUB_PATH" + "$bin_dir/aptos" --version + echo "::endgroup::" + - name: Start local CRE${{ matrix.tests.cre_version }} shell: bash id: start-local-cre diff --git a/core/capabilities/fakes/register.go b/core/capabilities/fakes/register.go new file mode 100644 index 00000000000..522c0a7bc50 --- /dev/null +++ b/core/capabilities/fakes/register.go @@ -0,0 +1,20 @@ +package fakes + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/types/core" +) + +const EnableFakeStreamsTriggerEnvVar = "CL_ENABLE_FAKE_STREAMS_TRIGGER" + +func RegisterFakeStreamsTrigger(ctx context.Context, lggr logger.Logger, registry core.CapabilitiesRegistry, nSigners int) (*fakeStreamsTrigger, error) { + trigger := NewFakeStreamsTrigger(lggr, nSigners) + if err := registry.Add(ctx, trigger); err != nil { + return nil, fmt.Errorf("add fake streams trigger: %w", err) + } + + return trigger, nil +} diff --git a/core/capabilities/fakes/register_test.go b/core/capabilities/fakes/register_test.go new file mode 100644 index 00000000000..b71429e0977 --- /dev/null +++ b/core/capabilities/fakes/register_test.go @@ -0,0 +1,33 @@ +package fakes + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + corecaps "github.com/smartcontractkit/chainlink/v2/core/capabilities" +) + +func TestRegisterFakeStreamsTrigger(t *testing.T) { + registry := corecaps.NewRegistry(logger.Test(t)) + + trigger, err := RegisterFakeStreamsTrigger(t.Context(), logger.Test(t), registry, 4) + require.NoError(t, err) + require.NotNil(t, trigger) + + capability, err := registry.Get(t.Context(), "streams-trigger@1.0.0") + require.NoError(t, err) + + info, err := capability.Info(t.Context()) + require.NoError(t, err) + require.Equal(t, "streams-trigger@1.0.0", info.ID) +} + +func TestNewFakeStreamsTrigger_UsesDeterministicSigners(t *testing.T) { + triggerA := NewFakeStreamsTrigger(logger.Test(t), 4) + triggerB := NewFakeStreamsTrigger(logger.Test(t), 4) + + require.Equal(t, triggerA.meta.Signers, triggerB.meta.Signers) + require.Equal(t, triggerA.meta.MinRequiredSignatures, triggerB.meta.MinRequiredSignatures) +} diff --git a/core/capabilities/fakes/streams_trigger.go b/core/capabilities/fakes/streams_trigger.go index ac2ae56346d..278a339cf25 100644 --- a/core/capabilities/fakes/streams_trigger.go +++ b/core/capabilities/fakes/streams_trigger.go @@ -2,6 +2,7 @@ package fakes import ( "context" + "crypto/ecdsa" "encoding/hex" "errors" "fmt" @@ -9,11 +10,12 @@ import ( "sync" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/smartcontractkit/libocr/offchainreporting2/chains/evmutil" ocrTypes "github.com/smartcontractkit/libocr/offchainreporting2plus/types" - "github.com/smartcontractkit/chainlink-common/keystore/corekeys" commonCap "github.com/smartcontractkit/chainlink-common/pkg/capabilities" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/datastreams" "github.com/smartcontractkit/chainlink-common/pkg/capabilities/triggers" @@ -23,7 +25,6 @@ import ( v3 "github.com/smartcontractkit/chainlink-common/pkg/types/mercury/v3" "github.com/smartcontractkit/chainlink-evm/pkg/mercury/v3/reportcodec" - "github.com/smartcontractkit/chainlink-common/keystore/corekeys/ocr2key" "github.com/smartcontractkit/chainlink/v2/core/capabilities/streams" ) @@ -32,7 +33,7 @@ type fakeStreamsTrigger struct { eng *services.Engine lggr logger.Logger - signers []ocr2key.KeyBundle + signers []fakeStreamsTriggerSigner codec datastreams.ReportCodec meta datastreams.Metadata @@ -47,6 +48,10 @@ type regState struct { eventCh chan commonCap.TriggerResponse } +type fakeStreamsTriggerSigner struct { + privateKey *ecdsa.PrivateKey +} + var _ services.Service = (*fakeStreamsTrigger)(nil) var _ commonCap.TriggerCapability = (*fakeStreamsTrigger)(nil) @@ -108,10 +113,16 @@ func (st *fakeStreamsTrigger) UnregisterTrigger(ctx context.Context, request com } func NewFakeStreamsTrigger(lggr logger.Logger, nSigners int) *fakeStreamsTrigger { - signers := make([]ocr2key.KeyBundle, nSigners) + signers := make([]fakeStreamsTriggerSigner, nSigners) rawSigners := make([][]byte, nSigners) for i := range nSigners { - signers[i], _ = ocr2key.New(corekeys.EVM) + keyMaterial := make([]byte, 32) + keyMaterial[31] = byte(i + 1) + privateKey, err := crypto.ToECDSA(keyMaterial) + if err != nil { + panic(err) + } + signers[i] = fakeStreamsTriggerSigner{privateKey: privateKey} rawSigners[i] = signers[i].PublicKey() } @@ -225,6 +236,20 @@ func newReport(ctx context.Context, lggr logger.Logger, feedID [32]byte, price i return raw } +func (s fakeStreamsTriggerSigner) PublicKey() ocrTypes.OnchainPublicKey { + address := crypto.PubkeyToAddress(s.privateKey.PublicKey) + return common.CopyBytes(address[:]) +} + +func (s fakeStreamsTriggerSigner) Sign(reportCtx ocrTypes.ReportContext, report ocrTypes.Report) ([]byte, error) { + rawReportContext := evmutil.RawReportContext(reportCtx) + sigData := crypto.Keccak256(report) + sigData = append(sigData, rawReportContext[0][:]...) + sigData = append(sigData, rawReportContext[1][:]...) + sigData = append(sigData, rawReportContext[2][:]...) + return crypto.Sign(crypto.Keccak256(sigData), s.privateKey) +} + func rawReportContext(reportCtx ocrTypes.ReportContext) []byte { rc := evmutil.RawReportContext(reportCtx) flat := []byte{} diff --git a/core/cmd/shell_local.go b/core/cmd/shell_local.go index d9ccf99f9e2..6184384ed2a 100644 --- a/core/cmd/shell_local.go +++ b/core/cmd/shell_local.go @@ -30,6 +30,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/custmsg" "github.com/smartcontractkit/chainlink-common/pkg/logger/otelzap" "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + coreconfig "github.com/smartcontractkit/chainlink/v2/core/config" "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" "github.com/smartcontractkit/chainlink-evm/pkg/assets" @@ -505,6 +506,10 @@ func (s *Shell) runNode(c *cli.Context) error { } } + type importedAptosKeyConfig interface { + ImportedAptosKey() coreconfig.ImportableKey + } + if s.Config.P2P().Enabled() { if s.Config.ImportedP2PKey().JSON() != "" { lggr.Debugf("Importing p2p key %s", s.Config.ImportedP2PKey().JSON()) @@ -552,6 +557,18 @@ func (s *Shell) runNode(c *cli.Context) error { } } if s.Config.AptosEnabled() { + if cfg, ok := s.Config.(importedAptosKeyConfig); ok { + if k := cfg.ImportedAptosKey(); k != nil && k.JSON() != "" { + lggr.Debug("Importing aptos key") + _, err2 := app.GetKeyStore().Aptos().Import(rootCtx, []byte(k.JSON()), k.Password()) + if errors.Is(err2, keystore.ErrKeyExists) { + lggr.Debugf("Aptos key already exists %s", k.JSON()) + } else if err2 != nil { + return s.errorOut(fmt.Errorf("error importing aptos key: %w", err2)) + } + } + } + err2 := app.GetKeyStore().Aptos().EnsureKey(rootCtx) if err2 != nil { return fmt.Errorf("failed to ensure aptos key: %w", err2) diff --git a/core/config/toml/types.go b/core/config/toml/types.go index 74984ec5db9..e47f7c16b83 100644 --- a/core/config/toml/types.go +++ b/core/config/toml/types.go @@ -146,6 +146,7 @@ type Secrets struct { Threshold ThresholdKeyShareSecrets `toml:",omitempty"` EVM EthKeys `toml:",omitempty"` // choose EVM as the TOML field name to align with relayer config convention Solana SolKeys `toml:",omitempty"` // choose Solana as the TOML field name to align with relayer config convention + Aptos AptosKey `toml:",omitempty"` P2PKey P2PKey `toml:",omitempty"` DKGRecipientKey DKGRecipientKey `toml:",omitempty"` @@ -164,6 +165,11 @@ type SolKey struct { Password *models.Secret } +type AptosKey struct { + JSON *models.Secret + Password *models.Secret +} + func (s *SolKeys) SetFrom(f *SolKeys) error { err := s.validateMerge(f) if err != nil { @@ -245,6 +251,37 @@ func (e *SolKey) ValidateConfig() (err error) { return err } +func (p *AptosKey) SetFrom(f *AptosKey) (err error) { + err = p.validateMerge(f) + if err != nil { + return err + } + if v := f.JSON; v != nil { + p.JSON = v + } + if v := f.Password; v != nil { + p.Password = v + } + return nil +} + +func (p *AptosKey) validateMerge(f *AptosKey) (err error) { + if p.JSON != nil && f.JSON != nil { + err = errors.Join(err, configutils.ErrOverride{Name: "JSON"}) + } + if p.Password != nil && f.Password != nil { + err = errors.Join(err, configutils.ErrOverride{Name: "Password"}) + } + return err +} + +func (p *AptosKey) ValidateConfig() (err error) { + if (p.JSON != nil) != (p.Password != nil) { + err = errors.Join(err, configutils.ErrInvalid{Name: "AptosKey", Value: p.JSON, Msg: "all fields must be nil or non-nil"}) + } + return err +} + type EthKeys struct { Keys []*EthKey } diff --git a/core/scripts/cre/environment/configs/capability_defaults.toml b/core/scripts/cre/environment/configs/capability_defaults.toml index fad176d9741..6e46cd8d894 100644 --- a/core/scripts/cre/environment/configs/capability_defaults.toml +++ b/core/scripts/cre/environment/configs/capability_defaults.toml @@ -130,6 +130,16 @@ # FromAddress = "0x0000000000000000000000000000000000000000" # ForwarderAddress = "0x0000000000000000000000000000000000000000" +# Aptos chain capability plugin (View + WriteReport). Runtime values are injected per chain. +[capability_configs.write-aptos] + binary_name = "aptos" + +[capability_configs.write-aptos.values] + # ChainID and forwarder address are injected at job proposal time. + RequestTimeout = "30s" + TransmissionSchedule = "allAtOnce" + DeltaStage = "1500ms" + [capability_configs.solana.values] TxAcceptanceState = 3 TxRetentonTimeout = "120s" diff --git a/core/scripts/cre/environment/configs/workflow-gateway-don-aptos.toml b/core/scripts/cre/environment/configs/workflow-gateway-don-aptos.toml new file mode 100644 index 00000000000..db91c3d170f --- /dev/null +++ b/core/scripts/cre/environment/configs/workflow-gateway-don-aptos.toml @@ -0,0 +1,76 @@ +# Same as workflow-gateway-don.toml but with Aptos chain and Aptos read capability plumbing. +# Anvil 1337: registry and gateway. Aptos: local devnet (chain_id 4). Run: env config path , then env start. + +[[blockchains]] + type = "anvil" + chain_id = "1337" + container_name = "anvil-1337" + docker_cmd_params = ["-b", "0.5", "--mixed-mining"] + +[[blockchains]] + type = "aptos" + chain_id = "4" + +[jd] + csa_encryption_key = "d1093c0060d50a3c89c189b2e485da5a3ce57f3dcb38ab7e2c0d5f0bb2314a44" + # change to your version + image = "job-distributor:0.22.1" + +#[s3provider] +# # use all defaults +# port = 9000 +# console_port = 9001 + +[infra] + # either "docker" or "kubernetes" + type = "docker" + +[[nodesets]] + nodes = 4 + name = "workflow" + don_types = ["workflow"] + override_mode = "all" + http_port_range_start = 10100 + + supported_evm_chains = [1337] + env_vars = { CL_CRE_SETTINGS_DEFAULT = '{"PerWorkflow":{"CapabilityCallTimeout":"5m0s","ChainAllowed":{"Default":"false","Values":{"1337":"true","4457093679053095497":"true"}},"ChainWrite":{"EVM":{"GasLimit":{"Default":"5000000","Values":{"1337":"10000000"}}}}}}' } + capabilities = ["cron", "consensus", "read-contract-4", "write-aptos-4"] + registry_based_launch_allowlist = ["cron-trigger@1.0.0"] + + [nodesets.db] + image = "postgres:12.0" + port = 13000 + + [[nodesets.node_specs]] + roles = ["plugin"] + [nodesets.node_specs.node] + docker_ctx = "../../../.." + docker_file = "core/chainlink.Dockerfile" + docker_build_args = { "CL_IS_PROD_BUILD" = "false" } + # image = "chainlink-tmp:latest" + user_config_overrides = "" + +[[nodesets]] + nodes = 1 + name = "bootstrap-gateway" + don_types = ["bootstrap", "gateway"] + override_mode = "each" + http_port_range_start = 10300 + + supported_evm_chains = [1337] + + [nodesets.db] + image = "postgres:12.0" + port = 13200 + + [[nodesets.node_specs]] + roles = ["bootstrap", "gateway"] + [nodesets.node_specs.node] + docker_ctx = "../../../.." + docker_file = "core/chainlink.Dockerfile" + docker_build_args = { "CL_IS_PROD_BUILD" = "false" } + # 5002 is the web API capabilities port for incoming requests + # 15002 is the vault port for incoming requests + custom_ports = ["5002:5002", "15002:15002"] + # image = "chainlink-tmp:latest" + user_config_overrides = "" diff --git a/core/scripts/cre/environment/environment/environment.go b/core/scripts/cre/environment/environment/environment.go index 58f11f11353..e39de360694 100644 --- a/core/scripts/cre/environment/environment/environment.go +++ b/core/scripts/cre/environment/environment/environment.go @@ -330,8 +330,16 @@ func startCmd() *cobra.Command { } features := feature_set.New() + extraAllowedPorts := append([]int(nil), extraAllowedGatewayPorts...) + if in.Fake != nil { + extraAllowedPorts = append(extraAllowedPorts, in.Fake.Port) + } + if in.FakeHTTP != nil { + extraAllowedPorts = append(extraAllowedPorts, in.FakeHTTP.Port) + } + gatewayWhitelistConfig := gateway.WhitelistConfig{ - ExtraAllowedPorts: append(extraAllowedGatewayPorts, in.Fake.Port, in.FakeHTTP.Port), + ExtraAllowedPorts: extraAllowedPorts, ExtraAllowedIPsCIDR: []string{"0.0.0.0/0"}, } output, startErr := StartCLIEnvironment(cmdContext, relativePathToRepoRoot, in, nil, features, nil, envDependencies, gatewayWhitelistConfig) diff --git a/core/scripts/cre/environment/mock/trigger_types.go b/core/scripts/cre/environment/mock/trigger_types.go index 5725b892a2f..9e747c76586 100644 --- a/core/scripts/cre/environment/mock/trigger_types.go +++ b/core/scripts/cre/environment/mock/trigger_types.go @@ -5,9 +5,10 @@ import ( "time" "github.com/google/uuid" - cron2 "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" "google.golang.org/protobuf/types/known/anypb" + crontypedapi "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/triggers/cron" + pb2 "github.com/smartcontractkit/chainlink/system-tests/lib/cre/mock/pb" ) @@ -24,7 +25,7 @@ func getTriggerRequest(triggerType TriggerType) (*pb2.SendTriggerEventRequest, e switch triggerType { case TriggerTypeCron: // First create the payload - payload := &cron2.LegacyPayload{ //nolint:staticcheck // legacy + payload := &crontypedapi.LegacyPayload{ //nolint:staticcheck // legacy ScheduledExecutionTime: time.Now().Format(time.RFC3339Nano), } diff --git a/core/scripts/go.mod b/core/scripts/go.mod index bb9d7fac716..249792562db 100644 --- a/core/scripts/go.mod +++ b/core/scripts/go.mod @@ -13,10 +13,7 @@ replace github.com/smartcontractkit/chainlink/core/scripts/cre/environment/examp // Using a separate `require` here to avoid surrounding line changes // creating potential merge conflicts. -require ( - github.com/smartcontractkit/chainlink/deployment v0.0.0-20251021194914-c0e3fec1a97c - github.com/smartcontractkit/chainlink/v2 v2.32.0 -) +require github.com/smartcontractkit/chainlink/v2 v2.32.0 require ( github.com/Masterminds/semver/v3 v3.4.0 @@ -58,8 +55,8 @@ require ( github.com/smartcontractkit/chainlink-testing-framework/lib v1.54.5 github.com/smartcontractkit/chainlink-testing-framework/seth v1.51.5 github.com/smartcontractkit/chainlink/core/scripts/cre/environment/examples/workflows/v2/proof-of-reserve/cron-based v0.0.0-00010101000000-000000000000 - github.com/smartcontractkit/chainlink/system-tests/lib v0.0.0-20251020210257-0a6ec41648b4 - github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v1.3.0 + github.com/smartcontractkit/chainlink/deployment v0.0.0-20251021194914-c0e3fec1a97c + github.com/smartcontractkit/chainlink/system-tests/lib v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/libocr v0.0.0-20260304194147-a03701e2c02e github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -483,7 +480,7 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20260129135848-c86808ba5cb9 // indirect github.com/smartcontractkit/ccip-owner-contracts v0.1.0 // indirect - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 // indirect + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 // indirect github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260323224438-d819cb3228e1 // indirect github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288 // indirect @@ -519,7 +516,6 @@ require ( github.com/smartcontractkit/chainlink-ton v0.0.0-20260326230916-bcfdbe85f221 // indirect github.com/smartcontractkit/chainlink-ton/deployment v0.0.0-20260326230916-bcfdbe85f221 // indirect github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20260218133534-cbd44da2856b // indirect - github.com/smartcontractkit/cre-sdk-go v1.5.0 // indirect github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad // indirect github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 // indirect github.com/smartcontractkit/mcms v0.38.2 // indirect diff --git a/core/scripts/go.sum b/core/scripts/go.sum index 97c3f8d91d7..6b7e62398f0 100644 --- a/core/scripts/go.sum +++ b/core/scripts/go.sum @@ -1616,8 +1616,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-20260318173523-755cafb24200 h1:hXteBGuRfdFA5Zj3f07la22ttq6NohB3g5d4vsHFJZ0= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200/go.mod h1:CLrLo4q6s25t9IGSMn4P1tRkrZFGjRiLOWskwPJEXrc= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= @@ -1724,8 +1724,6 @@ github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.202602181 github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20260218133534-cbd44da2856b/go.mod h1:ea1LESxlSSOgc2zZBqf1RTkXTMthHaspdqUHd7W4lF0= github.com/smartcontractkit/cre-sdk-go v1.5.0 h1:kepW3QDKARrOOHjXwWAZ9j5KLk6bxLzvi6OMrLsFwVo= github.com/smartcontractkit/cre-sdk-go v1.5.0/go.mod h1:yYrQFz1UH7hhRbPO0q4fgo1tfsJNd4yXnI3oCZE0RzM= -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/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad h1:lgHxTHuzJIF3Vj6LSMOnjhqKgRqYW+0MV2SExtCYL1Q= github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad/go.mod h1:T4zH9R8R8lVWKfU7tUvYz2o2jMv1OpGCdpY2j2QZXzU= github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12ijqMM9tvYVEm+nR826WsrNi6zCKpwBhuApq127wHs= diff --git a/core/services/chainlink/application.go b/core/services/chainlink/application.go index 4803a14faa1..581584d8087 100644 --- a/core/services/chainlink/application.go +++ b/core/services/chainlink/application.go @@ -7,6 +7,7 @@ import ( "fmt" "math/big" "net/http" + "os" "strconv" "sync" "time" @@ -56,6 +57,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/build" "github.com/smartcontractkit/chainlink/v2/core/capabilities" "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/fakes" "github.com/smartcontractkit/chainlink/v2/core/config" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/logger/audit" @@ -241,6 +243,20 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err // for tests only, in prod Registry should always be set at this point opts.CapabilitiesRegistry = capabilities.NewRegistry(globalLogger) } + if raw := os.Getenv(fakes.EnableFakeStreamsTriggerEnvVar); raw != "" { + enabled, parseErr := strconv.ParseBool(raw) + if parseErr != nil { + return nil, fmt.Errorf("failed to parse %s: %w", fakes.EnableFakeStreamsTriggerEnvVar, parseErr) + } + if enabled { + trigger, registerErr := fakes.RegisterFakeStreamsTrigger(ctx, globalLogger, opts.CapabilitiesRegistry, 4) + if registerErr != nil { + return nil, fmt.Errorf("failed to register fake streams trigger: %w", registerErr) + } + srvcs = append(srvcs, trigger) + globalLogger.Infow("enabled fake streams trigger", "envVar", fakes.EnableFakeStreamsTriggerEnvVar) + } + } if opts.DonTimeStore == nil { opts.DonTimeStore = dontime.NewStore(dontime.DefaultRequestTimeout) diff --git a/core/services/chainlink/config.go b/core/services/chainlink/config.go index 020f74a7f57..160beb95e30 100644 --- a/core/services/chainlink/config.go +++ b/core/services/chainlink/config.go @@ -417,6 +417,10 @@ func (s *Secrets) SetFrom(f *Secrets) (err error) { err = errors.Join(err, commonconfig.NamedMultiErrorList(err2, "Solana")) } + if err2 := s.Aptos.SetFrom(&f.Aptos); err2 != nil { + err = errors.Join(err, commonconfig.NamedMultiErrorList(err2, "Aptos")) + } + if err2 := s.DKGRecipientKey.SetFrom(&f.DKGRecipientKey); err2 != nil { err = errors.Join(err, commonconfig.NamedMultiErrorList(err2, "DKGRecipientKey")) } diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go index 38a50faacfd..fc0f2a0d1ea 100644 --- a/core/services/chainlink/config_general.go +++ b/core/services/chainlink/config_general.go @@ -565,6 +565,10 @@ func (g *generalConfig) ImportedSolKeys() coreconfig.ImportableChainKeyLister { return &importedSolKeyConfigs{s: g.secrets.Solana} } +func (g *generalConfig) ImportedAptosKey() coreconfig.ImportableKey { + return &importedAptosKeyConfig{s: g.secrets.Aptos} +} + func (g *generalConfig) ImportedDKGRecipientKey() coreconfig.ImportableKey { return &importedDKGRecipientKeyConfig{s: g.secrets.DKGRecipientKey} } diff --git a/core/services/chainlink/config_imported_aptos_key.go b/core/services/chainlink/config_imported_aptos_key.go new file mode 100644 index 00000000000..cadf5bb1b43 --- /dev/null +++ b/core/services/chainlink/config_imported_aptos_key.go @@ -0,0 +1,21 @@ +package chainlink + +import "github.com/smartcontractkit/chainlink/v2/core/config/toml" + +type importedAptosKeyConfig struct { + s toml.AptosKey +} + +func (t *importedAptosKeyConfig) JSON() string { + if t.s.JSON == nil { + return "" + } + return string(*t.s.JSON) +} + +func (t *importedAptosKeyConfig) Password() string { + if t.s.Password == nil { + return "" + } + return string(*t.s.Password) +} diff --git a/core/services/standardcapabilities/conversions/conversions.go b/core/services/standardcapabilities/conversions/conversions.go index 3f2130a7b19..257d7c025fd 100644 --- a/core/services/standardcapabilities/conversions/conversions.go +++ b/core/services/standardcapabilities/conversions/conversions.go @@ -25,6 +25,22 @@ func GetCapabilityIDFromCommand(command string, config string) string { return "" } return "evm:ChainSelector:" + strconv.FormatUint(selector, 10) + "@1.0.0" + case "aptos": + var cfg struct { + ChainID string `json:"chainId"` + } + if err := json.Unmarshal([]byte(config), &cfg); err != nil { + return "" + } + chainID, err := strconv.ParseUint(cfg.ChainID, 10, 64) + if err != nil { + return "" + } + selector, ok := chainselectors.AptosChainIdToChainSelector()[chainID] + if !ok { + return "" + } + return "aptos:ChainSelector:" + strconv.FormatUint(selector, 10) + "@1.0.0" case "consensus": return "consensus@1.0.0-alpha" case "cron": @@ -44,6 +60,8 @@ func GetCommandFromCapabilityID(capabilityID string) string { switch { case strings.HasPrefix(capabilityID, "evm"): return "evm" + case strings.HasPrefix(capabilityID, "aptos:ChainSelector:"): + return "aptos" case strings.HasPrefix(capabilityID, "consensus"): return "consensus" case strings.HasPrefix(capabilityID, "cron-trigger"): diff --git a/core/services/standardcapabilities/conversions/conversions_test.go b/core/services/standardcapabilities/conversions/conversions_test.go index f5de925893a..b410cce9ba6 100644 --- a/core/services/standardcapabilities/conversions/conversions_test.go +++ b/core/services/standardcapabilities/conversions/conversions_test.go @@ -43,6 +43,24 @@ func Test_GetCapabilityIDFromCommand(t *testing.T) { config: `{"chainId": 1, "network": "mainnet", "otherField": "value"}`, expected: "evm:ChainSelector:5009297550715157269@1.0.0", }, + { + name: "aptos command with valid config - localnet", + command: "/usr/local/bin/aptos", + config: `{"chainId":"4","network":"aptos"}`, + expected: "aptos:ChainSelector:4457093679053095497@1.0.0", + }, + { + name: "aptos command with invalid chainId", + command: "/usr/local/bin/aptos", + config: `{"chainId":"not-a-number","network":"aptos"}`, + expected: "", + }, + { + name: "aptos command with unknown chainId", + command: "/usr/local/bin/aptos", + config: `{"chainId":"999999","network":"aptos"}`, + expected: "", + }, { name: "evm command with invalid JSON", command: "/usr/local/bin/evm", @@ -173,6 +191,16 @@ func Test_GetCommandFromCapabilityID(t *testing.T) { capabilityID: "evm:ChainSelector:5009297550715157269@2.0.0", expected: "evm", }, + { + name: "aptos localnet capability", + capabilityID: "aptos:ChainSelector:4457093679053095497@1.0.0", + expected: "aptos", + }, + { + name: "aptos capability - different version", + capabilityID: "aptos:ChainSelector:4457093679053095497@2.0.0", + expected: "aptos", + }, { name: "unknown capability", capabilityID: "unknown@1.0.0", @@ -207,4 +235,8 @@ func Test_roundTrip(t *testing.T) { // EVM round-trip: command base name is preserved evmCapID := GetCapabilityIDFromCommand("/usr/local/bin/evm", `{"chainId": 1}`) assert.Equal(t, "evm", GetCommandFromCapabilityID(evmCapID)) + + // Aptos round-trip: command base name is preserved + aptosCapID := GetCapabilityIDFromCommand("/usr/local/bin/aptos", `{"chainId":"4","network":"aptos"}`) + assert.Equal(t, "aptos", GetCommandFromCapabilityID(aptosCapID)) } diff --git a/deployment/cre/jobs/aptos.go b/deployment/cre/jobs/aptos.go new file mode 100644 index 00000000000..a9a06a4d01d --- /dev/null +++ b/deployment/cre/jobs/aptos.go @@ -0,0 +1,42 @@ +package jobs + +import ( + "errors" + "strings" + + "github.com/smartcontractkit/chainlink/deployment/cre/jobs/pkg" + job_types "github.com/smartcontractkit/chainlink/deployment/cre/jobs/types" + "github.com/smartcontractkit/chainlink/v2/core/services/ocrcommon" +) + +func verifyAptosJobSpecInputs(inputs job_types.JobSpecInput) error { + scj := &pkg.StandardCapabilityJob{} + if err := inputs.UnmarshalTo(scj); err != nil { + return errors.New("failed to unmarshal job spec input to StandardCapabilityJob: " + err.Error()) + } + + if strings.TrimSpace(scj.Command) == "" { + return errors.New("command is required and must be a string") + } + + if strings.TrimSpace(scj.Config) == "" { + return errors.New("config is required and must be a string") + } + + if scj.ChainSelectorEVM == 0 { + return errors.New("chainSelectorEVM is required") + } + + if scj.ChainSelectorAptos == 0 { + return errors.New("chainSelectorAptos is required") + } + + if len(scj.BootstrapPeers) == 0 { + return errors.New("bootstrapPeers is required") + } + if _, err := ocrcommon.ParseBootstrapPeers(scj.BootstrapPeers); err != nil { + return errors.New("bootstrapPeers is invalid: " + err.Error()) + } + + return nil +} diff --git a/deployment/cre/jobs/propose_job_spec.go b/deployment/cre/jobs/propose_job_spec.go index fce94a5fbf9..3a80678fdae 100644 --- a/deployment/cre/jobs/propose_job_spec.go +++ b/deployment/cre/jobs/propose_job_spec.go @@ -62,9 +62,13 @@ func (u ProposeJobSpec) VerifyPreconditions(_ cldf.Environment, config ProposeJo if err := verifyEVMJobSpecInputs(config.Inputs); err != nil { return fmt.Errorf("invalid inputs for EVM job spec: %w", err) } + case job_types.Aptos: + if err := verifyAptosJobSpecInputs(config.Inputs); err != nil { + return fmt.Errorf("invalid inputs for Aptos job spec: %w", err) + } case job_types.Solana: if err := verifySolanaJobSpecInputs(config.Inputs); err != nil { - return fmt.Errorf("invalid inputs for EVM job spec: %w", err) + return fmt.Errorf("invalid inputs for Solana job spec: %w", err) } case job_types.Cron, job_types.BootstrapOCR3, job_types.OCR3, job_types.Gateway, job_types.HTTPTrigger, job_types.HTTPAction, job_types.ConfidentialHTTP, job_types.BootstrapVault, job_types.Consensus, job_types.WebAPITrigger, job_types.WebAPITarget, job_types.CustomCompute, job_types.LogEventTrigger, job_types.ReadContract: case job_types.CRESettings: @@ -90,12 +94,12 @@ func (u ProposeJobSpec) Apply(e cldf.Environment, input ProposeJobSpecInput) (cl var report operations.Report[any, any] switch input.Template { // This will hold all standard capabilities jobs as we add support for them. - case job_types.EVM, job_types.Cron, job_types.HTTPTrigger, job_types.HTTPAction, job_types.ConfidentialHTTP, job_types.Consensus, job_types.WebAPITrigger, job_types.WebAPITarget, job_types.CustomCompute, job_types.LogEventTrigger, job_types.ReadContract, job_types.Solana: - // Only consensus generates an oracle factory, for now... - job, err := input.Inputs.ToStandardCapabilityJob(input.JobName, input.Template == job_types.Consensus) + case job_types.EVM, job_types.Aptos, job_types.Cron, job_types.HTTPTrigger, job_types.HTTPAction, job_types.ConfidentialHTTP, job_types.Consensus, job_types.WebAPITrigger, job_types.WebAPITarget, job_types.CustomCompute, job_types.LogEventTrigger, job_types.ReadContract, job_types.Solana: + job, err := input.Inputs.ToStandardCapabilityJob(input.JobName) if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to convert inputs to standard capability job: %w", err) } + job.GenerateOracleFactory = requiresOracleFactory(input.Template) r, rErr := operations.ExecuteSequence( e.OperationsBundle, @@ -328,3 +332,15 @@ func (u ProposeJobSpec) Apply(e cldf.Environment, input ProposeJobSpecInput) (cl Reports: []operations.Report[any, any]{report}, }, nil } + +func requiresOracleFactory(template job_types.JobSpecTemplate) bool { + if template == job_types.Consensus { + return true + } + + if template == job_types.Aptos { + return true + } + + return false +} diff --git a/deployment/cre/jobs/propose_job_spec_test.go b/deployment/cre/jobs/propose_job_spec_test.go index 37b7a97e51a..6086e29273e 100644 --- a/deployment/cre/jobs/propose_job_spec_test.go +++ b/deployment/cre/jobs/propose_job_spec_test.go @@ -440,6 +440,74 @@ func TestProposeJobSpec_VerifyPreconditions_EVM(t *testing.T) { } } +func TestProposeJobSpec_VerifyPreconditions_Aptos(t *testing.T) { + j := jobs.ProposeJobSpec{} + var env cldf.Environment + + base := jobs.ProposeJobSpecInput{ + Environment: "test", + Domain: "cre", + DONName: "test-don", + JobName: "aptos-test", + DONFilters: []offchain.TargetDONFilter{ + {Key: offchain.FilterKeyDONName, Value: "d"}, + {Key: "environment", Value: "e"}, + {Key: "product", Value: offchain.ProductLabel}, + }, + Template: job_types.Aptos, + } + + validAptosInputs := func() job_types.JobSpecInput { + return job_types.JobSpecInput{ + "command": "/usr/local/bin/aptos", + "config": `{"chainId":"4","network":"aptos","creForwarderAddress":"0x1111111111111111111111111111111111111111111111111111111111111111"}`, + "chainSelectorEVM": "3379446385462418246", + "chainSelectorAptos": "4457093679053095497", + "bootstrapPeers": []string{ + "12D3KooWHfYFQ8hGttAYbMCevQVESEQhzJAqFZokMVtom8bNxwGq@127.0.0.1:5001", + }, + "useCapRegOCRConfig": true, + "capRegVersion": "2.0.0", + } + } + + t.Run("valid aptos spec passes", func(t *testing.T) { + in := base + in.Inputs = validAptosInputs() + require.NoError(t, j.VerifyPreconditions(env, in)) + }) + + type negCase struct { + name string + mutate func(job_types.JobSpecInput) + wantEnd string + } + + const prefix = "invalid inputs for Aptos job spec: " + + cases := []negCase{ + {"missing command", func(m job_types.JobSpecInput) { delete(m, "command") }, "command is required and must be a string"}, + {"missing config", func(m job_types.JobSpecInput) { delete(m, "config") }, "config is required and must be a string"}, + {"missing chainSelectorEVM", func(m job_types.JobSpecInput) { delete(m, "chainSelectorEVM") }, "chainSelectorEVM is required"}, + {"missing chainSelectorAptos", func(m job_types.JobSpecInput) { delete(m, "chainSelectorAptos") }, "chainSelectorAptos is required"}, + {"missing bootstrapPeers", func(m job_types.JobSpecInput) { delete(m, "bootstrapPeers") }, "bootstrapPeers is required"}, + {"invalid bootstrapPeers", func(m job_types.JobSpecInput) { m["bootstrapPeers"] = []string{"not-a-peer"} }, "bootstrapPeers is invalid"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := base + in.Inputs = validAptosInputs() + tc.mutate(in.Inputs) + + err := j.VerifyPreconditions(env, in) + require.Error(t, err) + assert.Contains(t, err.Error(), prefix) + assert.Contains(t, err.Error(), tc.wantEnd) + }) + } +} + func TestProposeJobSpec_Apply(t *testing.T) { testEnv := test.SetupEnvV2(t, false) env := testEnv.Env @@ -766,6 +834,71 @@ PerSenderBurst = 100 assert.Contains(t, req.Spec, `command = "/usr/bin/read-contract"`) assert.Contains(t, req.Spec, `config = """{"chainId":1337,"network":"evm"}"""`) assert.Contains(t, req.Spec, `externalJobID = "a-readcontract-job-id"`) + assert.NotContains(t, req.Spec, `[oracle_factory]`) + } + }) + + t.Run("successful aptos job distribution includes oracle factory", func(t *testing.T) { + chainSelector := testEnv.RegistrySelector + ds := datastore.NewMemoryDataStore() + + err := ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Type: datastore.ContractType("CapabilitiesRegistry"), + Version: semver.MustParse("2.0.0"), + Address: "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", + Qualifier: "", + }) + require.NoError(t, err) + + env.DataStore = ds.Seal() + + input := jobs.ProposeJobSpecInput{ + Environment: "test", + Domain: "cre", + JobName: "aptos-cap-job", + DONName: test.DONName, + Template: job_types.Aptos, + DONFilters: []offchain.TargetDONFilter{ + {Key: offchain.FilterKeyDONName, Value: test.DONName}, + {Key: "environment", Value: "test"}, + {Key: "product", Value: offchain.ProductLabel}, + }, + Inputs: job_types.JobSpecInput{ + "command": "/usr/bin/aptos", + "config": `{"chainId":"4","network":"aptos","creForwarderAddress":"0x1111111111111111111111111111111111111111111111111111111111111111"}`, + "chainSelectorEVM": strconv.FormatUint(chainSelector, 10), + "chainSelectorAptos": strconv.FormatUint( + testEnv.AptosSelector, + 10, + ), + "bootstrapPeers": []string{ + "12D3KooWHfYFQ8hGttAYbMCevQVESEQhzJAqFZokMVtom8bNxwGq@127.0.0.1:5001", + }, + "useCapRegOCRConfig": true, + "capRegVersion": "2.0.0", + }, + } + + out, err := jobs.ProposeJobSpec{}.Apply(*env, input) + require.NoError(t, err) + assert.Len(t, out.Reports, 1) + + reqs, err := testEnv.TestJD.ListProposedJobRequests() + require.NoError(t, err) + + filteredReqs := slices.DeleteFunc(reqs, func(s *job.ProposeJobRequest) bool { + return !strings.Contains(s.Spec, `name = "aptos-cap-job"`) + }) + assert.Len(t, filteredReqs, 4) + + for _, req := range filteredReqs { + assert.Contains(t, req.Spec, `name = "aptos-cap-job"`) + assert.Contains(t, req.Spec, `command = "/usr/bin/aptos"`) + assert.Contains(t, req.Spec, `[oracle_factory]`) + assert.Contains(t, req.Spec, `enabled = true`) + assert.Contains(t, req.Spec, `strategyName = "multi-chain"`) + assert.Contains(t, req.Spec, `aptos = "fake_orc_bundle_aptos"`) } }) diff --git a/deployment/cre/jobs/types/job_spec.go b/deployment/cre/jobs/types/job_spec.go index ed03ba6a336..94bf847197c 100644 --- a/deployment/cre/jobs/types/job_spec.go +++ b/deployment/cre/jobs/types/job_spec.go @@ -30,10 +30,9 @@ func (j JobSpecInput) UnmarshalFrom(source any) error { return yaml.Unmarshal(bytes, &j) } -func (j JobSpecInput) ToStandardCapabilityJob(jobName string, generateOracleFactory bool) (pkg.StandardCapabilityJob, error) { +func (j JobSpecInput) ToStandardCapabilityJob(jobName string) (pkg.StandardCapabilityJob, error) { out := pkg.StandardCapabilityJob{ - JobName: jobName, - GenerateOracleFactory: generateOracleFactory, + JobName: jobName, } err := j.UnmarshalTo(&out) if err != nil { diff --git a/deployment/cre/jobs/types/job_spec_template.go b/deployment/cre/jobs/types/job_spec_template.go index d9a7d25e2ec..6d5e6ede7be 100644 --- a/deployment/cre/jobs/types/job_spec_template.go +++ b/deployment/cre/jobs/types/job_spec_template.go @@ -19,6 +19,7 @@ const ( HTTPAction ConfidentialHTTP EVM + Aptos Solana Gateway BootstrapVault @@ -48,6 +49,8 @@ func (jt JobSpecTemplate) String() string { return "confidential-http" case EVM: return "evm" + case Aptos: + return "aptos" case Solana: return "solana" case Gateway: @@ -92,6 +95,8 @@ func parseJobSpecTemplate(s string) (JobSpecTemplate, error) { return ConfidentialHTTP, nil case "evm": return EVM, nil + case "aptos": + return Aptos, nil case "solana": return Solana, nil case "gateway": diff --git a/deployment/cre/jobs/types/job_spec_template_test.go b/deployment/cre/jobs/types/job_spec_template_test.go index 3d9c8219e4e..0bcc2f1d181 100644 --- a/deployment/cre/jobs/types/job_spec_template_test.go +++ b/deployment/cre/jobs/types/job_spec_template_test.go @@ -19,6 +19,13 @@ func TestJobSpecTemplate_UnmarshalJSON(t *testing.T) { require.Equal(t, job_types.Cron, in.Template) }) + t.Run("aptos string", func(t *testing.T) { + var in jobs.ProposeJobSpecInput + js := `{"environment":"e","domain":"d","don_name":"don","don_filters":[],"job_name":"j","template":"aptos","inputs":{}}` + require.NoError(t, json.Unmarshal([]byte(js), &in)) + require.Equal(t, job_types.Aptos, in.Template) + }) + t.Run("invalid string", func(t *testing.T) { var in jobs.ProposeJobSpecInput js := `{"environment":"e","domain":"d","don_name":"don","don_filters":[],"job_name":"j","template":"nope","inputs":{}}` @@ -42,6 +49,13 @@ func TestJobSpecTemplate_UnmarshalYAML(t *testing.T) { require.Equal(t, job_types.Cron, in.Template) }) + t.Run("aptos string", func(t *testing.T) { + var in jobs.ProposeJobSpecInput + yml := "environment: e\ndomain: d\ndon_name: don\ndon_filters: []\njob_name: j\ntemplate: aptos\ninputs: {}\n" + require.NoError(t, yaml.Unmarshal([]byte(yml), &in)) + require.Equal(t, job_types.Aptos, in.Template) + }) + t.Run("invalid string", func(t *testing.T) { var in jobs.ProposeJobSpecInput yml := "environment: e\ndomain: d\ndon_name: don\ndon_filters: []\njob_name: j\ntemplate: nope\ninputs: {}\n" diff --git a/deployment/cre/jobs/types/job_spec_test.go b/deployment/cre/jobs/types/job_spec_test.go index 1cacb68e02f..1ad9ae9a960 100644 --- a/deployment/cre/jobs/types/job_spec_test.go +++ b/deployment/cre/jobs/types/job_spec_test.go @@ -34,7 +34,7 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { }, } - job, err := input.ToStandardCapabilityJob(jobName, false) + job, err := input.ToStandardCapabilityJob(jobName) require.NoError(t, err) assert.Equal(t, jobName, job.JobName) assert.Equal(t, "run", job.Command) @@ -56,7 +56,7 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { "externalJobID": "123", "oracleFactory": pkg.OracleFactory{}, } - _, err := input.ToStandardCapabilityJob(jobName, false) + _, err := input.ToStandardCapabilityJob(jobName) require.Error(t, err) assert.Contains(t, err.Error(), "command is required") }) @@ -68,7 +68,7 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { "externalJobID": "123", "oracleFactory": pkg.OracleFactory{}, } - _, err := input.ToStandardCapabilityJob(jobName, false) + _, err := input.ToStandardCapabilityJob(jobName) require.Error(t, err) assert.Contains(t, err.Error(), "command is required and must be a string") }) @@ -80,7 +80,7 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { "externalJobID": "123", "oracleFactory": pkg.OracleFactory{}, } - _, err := input.ToStandardCapabilityJob(jobName, false) + _, err := input.ToStandardCapabilityJob(jobName) require.NoError(t, err) }) @@ -91,7 +91,7 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { "externalJobID": "123", "oracleFactory": pkg.OracleFactory{}, } - _, err := input.ToStandardCapabilityJob(jobName, false) + _, err := input.ToStandardCapabilityJob(jobName) require.Error(t, err) assert.Contains(t, err.Error(), "cannot unmarshal !!map into string") }) @@ -103,7 +103,7 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { "externalJobID": struct{}{}, "oracleFactory": pkg.OracleFactory{}, } - _, err := input.ToStandardCapabilityJob(jobName, false) + _, err := input.ToStandardCapabilityJob(jobName) require.Error(t, err) assert.Contains(t, err.Error(), "cannot unmarshal !!map into string") }) @@ -115,7 +115,7 @@ func TestJobSpecInput_ToStandardCapabilityJob(t *testing.T) { "externalJobID": "123", "oracleFactory": "not a factory", } - _, err := input.ToStandardCapabilityJob(jobName, false) + _, err := input.ToStandardCapabilityJob(jobName) require.Error(t, err) assert.Contains(t, err.Error(), "cannot unmarshal !!str") }) diff --git a/deployment/cre/ocr3/config.go b/deployment/cre/ocr3/config.go index 1182e3a5ea0..e96c73b00cd 100644 --- a/deployment/cre/ocr3/config.go +++ b/deployment/cre/ocr3/config.go @@ -299,6 +299,7 @@ type ConfigureOCR3Config struct { DryRun bool ReportingPluginConfigOverride []byte + ExtraSignerFamilies []string UseMCMS bool Strategy strategies.TransactionStrategy @@ -327,7 +328,7 @@ func ConfigureOCR3ContractFromJD(env *cldf.Environment, cfg ConfigureOCR3Config) return nil, err } - config, err := GenerateOCR3ConfigFromNodes(*cfg.OCR3Config, nodes, cfg.ChainSel, env.OCRSecrets, cfg.ReportingPluginConfigOverride, nil) + config, err := GenerateOCR3ConfigFromNodes(*cfg.OCR3Config, nodes, cfg.ChainSel, env.OCRSecrets, cfg.ReportingPluginConfigOverride, cfg.ExtraSignerFamilies) if err != nil { return nil, err } diff --git a/deployment/cre/ocr3/v2/changeset/configure_ocr3.go b/deployment/cre/ocr3/v2/changeset/configure_ocr3.go index 7ecebe544c0..6971dfeb7bb 100644 --- a/deployment/cre/ocr3/v2/changeset/configure_ocr3.go +++ b/deployment/cre/ocr3/v2/changeset/configure_ocr3.go @@ -25,9 +25,10 @@ type ConfigureOCR3Input struct { ContractChainSelector uint64 `json:"contractChainSelector" yaml:"contractChainSelector"` ContractQualifier string `json:"contractQualifier" yaml:"contractQualifier"` - DON contracts.DonNodeSet `json:"don" yaml:"don"` - OracleConfig *ocr3.OracleConfig `json:"oracleConfig" yaml:"oracleConfig"` - DryRun bool `json:"dryRun" yaml:"dryRun"` + DON contracts.DonNodeSet `json:"don" yaml:"don"` + OracleConfig *ocr3.OracleConfig `json:"oracleConfig" yaml:"oracleConfig"` + DryRun bool `json:"dryRun" yaml:"dryRun"` + ExtraSignerFamilies []string `json:"extraSignerFamilies,omitempty" yaml:"extraSignerFamilies,omitempty"` MCMSConfig *crecontracts.MCMSConfig `json:"mcmsConfig" yaml:"mcmsConfig"` } @@ -50,6 +51,9 @@ func (l ConfigureOCR3) VerifyPreconditions(_ cldf.Environment, input ConfigureOC if input.OracleConfig == nil { return errors.New("oracle config is required") } + if err := ocr3.ValidateExtraSignerFamilies(input.ExtraSignerFamilies); err != nil { + return fmt.Errorf("invalid extra signer families: %w", err) + } return nil } @@ -93,12 +97,13 @@ func (l ConfigureOCR3) Apply(e cldf.Environment, input ConfigureOCR3Input) (cldf Env: &e, Strategy: strategy, }, contracts.ConfigureOCR3Input{ - ContractAddress: &contractAddr, - ChainSelector: input.ContractChainSelector, - DON: input.DON, - Config: input.OracleConfig, - DryRun: input.DryRun, - MCMSConfig: input.MCMSConfig, + ContractAddress: &contractAddr, + ChainSelector: input.ContractChainSelector, + DON: input.DON, + Config: input.OracleConfig, + DryRun: input.DryRun, + ExtraSignerFamilies: input.ExtraSignerFamilies, + MCMSConfig: input.MCMSConfig, }) if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to configure OCR3 contract: %w", err) diff --git a/deployment/cre/ocr3/v2/changeset/operations/contracts/configure_ocr3.go b/deployment/cre/ocr3/v2/changeset/operations/contracts/configure_ocr3.go index ad59fc4f6d0..fd1b115bc18 100644 --- a/deployment/cre/ocr3/v2/changeset/operations/contracts/configure_ocr3.go +++ b/deployment/cre/ocr3/v2/changeset/operations/contracts/configure_ocr3.go @@ -35,6 +35,7 @@ type ConfigureOCR3Input struct { DryRun bool ReportingPluginConfigOverride []byte + ExtraSignerFamilies []string MCMSConfig *contracts.MCMSConfig } @@ -72,6 +73,7 @@ var ConfigureOCR3 = operations.NewOperation[ConfigureOCR3Input, ConfigureOCR3OpO OCR3Config: input.Config, Contract: contract.Contract, DryRun: input.DryRun, + ExtraSignerFamilies: input.ExtraSignerFamilies, UseMCMS: input.UseMCMS(), Strategy: deps.Strategy, ReportingPluginConfigOverride: input.ReportingPluginConfigOverride, diff --git a/deployment/go.mod b/deployment/go.mod index 1c9d573e7f4..520fa258311 100644 --- a/deployment/go.mod +++ b/deployment/go.mod @@ -38,7 +38,7 @@ require ( github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20260129135848-c86808ba5cb9 github.com/smartcontractkit/ccip-owner-contracts v0.1.0 github.com/smartcontractkit/chain-selectors v1.0.97 - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment v0.0.0-20260317185256-d5f7db87ae70 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260224214816-cb23ec38649f diff --git a/deployment/go.sum b/deployment/go.sum index 195a9933489..19cdd00d5c5 100644 --- a/deployment/go.sum +++ b/deployment/go.sum @@ -1369,8 +1369,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-20260318173523-755cafb24200 h1:hXteBGuRfdFA5Zj3f07la22ttq6NohB3g5d4vsHFJZ0= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200/go.mod h1:CLrLo4q6s25t9IGSMn4P1tRkrZFGjRiLOWskwPJEXrc= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= diff --git a/go.md b/go.md index 430c3a96691..d63f2f246cf 100644 --- a/go.md +++ b/go.md @@ -478,6 +478,8 @@ flowchart LR chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/http chainlink/system-tests/tests --> chainlink/system-tests/tests/regression/cre/httpaction-negative + chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite + chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/evm/evmread chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/evm/logtrigger chainlink/system-tests/tests --> chainlink/system-tests/tests/smoke/cre/evmread @@ -501,6 +503,15 @@ flowchart LR chainlink/system-tests/tests/regression/cre/httpaction-negative --> cre-sdk-go/capabilities/networking/http chainlink/system-tests/tests/regression/cre/httpaction-negative --> cre-sdk-go/capabilities/scheduler/cron click chainlink/system-tests/tests/regression/cre/httpaction-negative href "https://github.com/smartcontractkit/chainlink" + chainlink/system-tests/tests/smoke/cre/aptos/aptosread --> cre-sdk-go/capabilities/blockchain/aptos + chainlink/system-tests/tests/smoke/cre/aptos/aptosread --> cre-sdk-go/capabilities/scheduler/cron + click chainlink/system-tests/tests/smoke/cre/aptos/aptosread href "https://github.com/smartcontractkit/chainlink" + chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite --> cre-sdk-go/capabilities/blockchain/aptos + chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite --> cre-sdk-go/capabilities/scheduler/cron + click chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite href "https://github.com/smartcontractkit/chainlink" + chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip --> cre-sdk-go/capabilities/blockchain/aptos + chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip --> cre-sdk-go/capabilities/scheduler/cron + click chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip href "https://github.com/smartcontractkit/chainlink" chainlink/system-tests/tests/smoke/cre/evm/evmread --> cre-sdk-go/capabilities/blockchain/evm chainlink/system-tests/tests/smoke/cre/evm/evmread --> cre-sdk-go/capabilities/scheduler/cron click chainlink/system-tests/tests/smoke/cre/evm/evmread href "https://github.com/smartcontractkit/chainlink" @@ -531,6 +542,8 @@ flowchart LR click chainlink/v2 href "https://github.com/smartcontractkit/chainlink" cre-sdk-go --> chainlink-protos/cre/go click cre-sdk-go href "https://github.com/smartcontractkit/cre-sdk-go" + cre-sdk-go/capabilities/blockchain/aptos --> cre-sdk-go + click cre-sdk-go/capabilities/blockchain/aptos href "https://github.com/smartcontractkit/cre-sdk-go" cre-sdk-go/capabilities/blockchain/evm --> chainlink-common/pkg/workflows/sdk/v2/pb cre-sdk-go/capabilities/blockchain/evm --> cre-sdk-go click cre-sdk-go/capabilities/blockchain/evm href "https://github.com/smartcontractkit/cre-sdk-go" @@ -584,6 +597,9 @@ flowchart LR chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative chainlink/system-tests/tests/regression/cre/http chainlink/system-tests/tests/regression/cre/httpaction-negative + chainlink/system-tests/tests/smoke/cre/aptos/aptosread + chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite + chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip chainlink/system-tests/tests/smoke/cre/evm/evmread chainlink/system-tests/tests/smoke/cre/evm/logtrigger chainlink/system-tests/tests/smoke/cre/evmread @@ -684,6 +700,7 @@ flowchart LR subgraph cre-sdk-go-repo[cre-sdk-go] cre-sdk-go + cre-sdk-go/capabilities/blockchain/aptos cre-sdk-go/capabilities/blockchain/evm cre-sdk-go/capabilities/blockchain/solana cre-sdk-go/capabilities/networking/http diff --git a/go.mod b/go.mod index c5f44b12223..206a56f7de0 100644 --- a/go.mod +++ b/go.mod @@ -79,7 +79,7 @@ require ( github.com/shirou/gopsutil/v3 v3.24.3 github.com/shopspring/decimal v1.4.0 github.com/smartcontractkit/chain-selectors v1.0.97 - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 github.com/smartcontractkit/chainlink-automation v0.8.1 github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260224214816-cb23ec38649f diff --git a/go.sum b/go.sum index e0ae18005fa..911c076d66f 100644 --- a/go.sum +++ b/go.sum @@ -1221,8 +1221,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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-20260318173523-755cafb24200 h1:hXteBGuRfdFA5Zj3f07la22ttq6NohB3g5d4vsHFJZ0= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200/go.mod h1:CLrLo4q6s25t9IGSMn4P1tRkrZFGjRiLOWskwPJEXrc= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= diff --git a/integration-tests/go.mod b/integration-tests/go.mod index 1d550a92e54..488921c4729 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -34,7 +34,7 @@ require ( github.com/segmentio/ksuid v1.0.4 github.com/slack-go/slack v0.15.0 github.com/smartcontractkit/chain-selectors v1.0.97 - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288 diff --git a/integration-tests/go.sum b/integration-tests/go.sum index 6e126ef7ed2..c6bea586dcd 100644 --- a/integration-tests/go.sum +++ b/integration-tests/go.sum @@ -1356,8 +1356,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-20260318173523-755cafb24200 h1:hXteBGuRfdFA5Zj3f07la22ttq6NohB3g5d4vsHFJZ0= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200/go.mod h1:CLrLo4q6s25t9IGSMn4P1tRkrZFGjRiLOWskwPJEXrc= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= diff --git a/integration-tests/load/go.mod b/integration-tests/load/go.mod index 3f8c8cc6b8e..ba751fec833 100644 --- a/integration-tests/load/go.mod +++ b/integration-tests/load/go.mod @@ -23,7 +23,7 @@ require ( github.com/gagliardetto/solana-go v1.13.0 github.com/rs/zerolog v1.34.0 github.com/smartcontractkit/chain-selectors v1.0.97 - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288 diff --git a/integration-tests/load/go.sum b/integration-tests/load/go.sum index eb564e5975f..4929e15cba7 100644 --- a/integration-tests/load/go.sum +++ b/integration-tests/load/go.sum @@ -1570,8 +1570,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-20260318173523-755cafb24200 h1:hXteBGuRfdFA5Zj3f07la22ttq6NohB3g5d4vsHFJZ0= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200/go.mod h1:CLrLo4q6s25t9IGSMn4P1tRkrZFGjRiLOWskwPJEXrc= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= diff --git a/integration-tests/smoke/ccip/ccip_reader_test.go b/integration-tests/smoke/ccip/ccip_reader_test.go index 9b134627b8d..b652786589c 100644 --- a/integration-tests/smoke/ccip/ccip_reader_test.go +++ b/integration-tests/smoke/ccip/ccip_reader_test.go @@ -746,14 +746,14 @@ func TestCCIPReader_Nonces(t *testing.T) { Auth: auth, }) - // Add some nonces. + // Commit each simulated transaction so bind does not reuse a stale pending nonce. for chain, addrs := range nonces { for addr, nonce := range addrs { _, err := s.contract.SetInboundNonce(s.auth, uint64(chain), nonce, common.LeftPadBytes(addr.Bytes(), 32)) require.NoError(t, err) + s.sb.Commit() } } - s.sb.Commit() request := make(map[cciptypes.ChainSelector][]string) for chain, addresses := range nonces { diff --git a/plugins/plugins.private.yaml b/plugins/plugins.private.yaml index a58595a9b03..99533f2d56b 100644 --- a/plugins/plugins.private.yaml +++ b/plugins/plugins.private.yaml @@ -23,7 +23,7 @@ plugins: installPath: "." consensus: - moduleURI: "github.com/smartcontractkit/capabilities/consensus" - gitRef: "9382dd467b74fe964497d84ac134162eac150322" + gitRef: "4bc25dd3e53308c15caa4f34a60d39dee2a6400c" installPath: "." workflowevent: - enabled: false @@ -46,6 +46,10 @@ plugins: - moduleURI: "github.com/smartcontractkit/capabilities/chain_capabilities/solana" gitRef: "6d553b1b59f12d948f163012c822f7417d66d996" installPath: "." + aptos: + - moduleURI: "github.com/smartcontractkit/capabilities/chain_capabilities/aptos" + gitRef: "a410cc7b314110c6793e80aa6cb3dcff1c44b881" + installPath: "." mock: - moduleURI: "github.com/smartcontractkit/capabilities/mock" gitRef: "9382dd467b74fe964497d84ac134162eac150322" diff --git a/plugins/plugins.public.yaml b/plugins/plugins.public.yaml index 85ea380e43b..861179d56b2 100644 --- a/plugins/plugins.public.yaml +++ b/plugins/plugins.public.yaml @@ -10,7 +10,7 @@ defaults: plugins: aptos: - moduleURI: "github.com/smartcontractkit/chainlink-aptos" - gitRef: "v0.0.0-20260318173523-755cafb24200" + gitRef: "v0.0.0-20260324144720-484863604698" installPath: "./cmd/chainlink-aptos" sui: diff --git a/system-tests/lib/cre/contracts/keystone.go b/system-tests/lib/cre/contracts/keystone.go index 97fce670aad..a9cd9496c01 100644 --- a/system-tests/lib/cre/contracts/keystone.go +++ b/system-tests/lib/cre/contracts/keystone.go @@ -178,7 +178,7 @@ func (d *dons) embedOCR3Config(capConfig *capabilitiespb.CapabilityConfig, don d return nil } -func (d *dons) mustToV2ConfigureInput(chainSelector uint64, contractAddress string, capabilityToOCR3Config map[string]*ocr3.OracleConfig, extraSignerFamilies []string) cap_reg_v2_seq.ConfigureCapabilitiesRegistryInput { +func (d *dons) mustToV2ConfigureInput(chainSelector uint64, contractAddress string, capabilityToOCR3Config map[string]*ocr3.OracleConfig, capabilityToExtraSignerFamilies map[string][]string) cap_reg_v2_seq.ConfigureCapabilitiesRegistryInput { nops := make([]capabilities_registry_v2.CapabilitiesRegistryNodeOperatorParams, 0) nodes := make([]contracts.NodesInput, 0) capabilities := make([]contracts.RegisterableCapability, 0) @@ -213,16 +213,16 @@ func (d *dons) mustToV2ConfigureInput(chainSelector uint64, contractAddress stri } for i, nop := range don.Nops { nopName := nop.Name - if _, exists := nopMap[nopName]; !exists { - nopMap[nopName] = capabilities_registry_v2.CapabilitiesRegistryNodeOperatorParams{ - Admin: adminAddrs[i], - Name: nopName, - } + if _, exists := nopMap[nopName]; !exists { ns, err := deployment.NodeInfo(nop.Nodes, d.offChain) if err != nil { panic(err) } + nopMap[nopName] = capabilities_registry_v2.CapabilitiesRegistryNodeOperatorParams{ + Admin: adminAddrs[i], + Name: nopName, + } // Add nodes for this NOP for _, n := range ns { @@ -258,17 +258,26 @@ func (d *dons) mustToV2ConfigureInput(chainSelector uint64, contractAddress stri for _, cap := range don.Capabilities { capID := fmt.Sprintf("%s@%s", cap.Capability.LabelledName, cap.Capability.Version) configBytes := []byte("{}") - if cap.Config != nil { - if cap.UseCapRegOCRConfig { - ocrConfig := capabilityToOCR3Config[cap.Capability.LabelledName] - if ocrConfig == nil { - panic("no OCR3 config found for capability " + cap.Capability.LabelledName) - } - if err := d.embedOCR3Config(cap.Config, don, chainSelector, ocrConfig, extraSignerFamilies); err != nil { - panic(fmt.Sprintf("failed to embed OCR3 config for capability %s: %s", cap.Capability.LabelledName, err)) - } + + capConfig := cap.Config + shouldMarshalProtoConfig := capConfig != nil + if cap.UseCapRegOCRConfig { + if capConfig == nil { + capConfig = &capabilitiespb.CapabilityConfig{} } - if protoBytes, err := proto.Marshal(cap.Config); err == nil { + shouldMarshalProtoConfig = true + + ocrConfig := capabilityToOCR3Config[cap.Capability.LabelledName] + if ocrConfig == nil { + panic("no OCR3 config found for capability " + cap.Capability.LabelledName) + } + if err := d.embedOCR3Config(capConfig, don, chainSelector, ocrConfig, capabilityToExtraSignerFamilies[cap.Capability.LabelledName]); err != nil { + panic(fmt.Sprintf("failed to embed OCR3 config for capability %s: %s", cap.Capability.LabelledName, err)) + } + } + + if shouldMarshalProtoConfig { + if protoBytes, err := proto.Marshal(capConfig); err == nil { configBytes = protoBytes } } @@ -387,7 +396,7 @@ func toDons(input cre.ConfigureCapabilityRegistryInput) (*dons, error) { capabilities = append(capabilities, enabledCapabilities...) } - // add capabilities that were passed directly via the input (from the PostDONStartup of features) + // add capabilities that were passed directly via feature startup hooks if input.DONCapabilityWithConfigs != nil && input.DONCapabilityWithConfigs[donMetadata.ID] != nil { capabilities = append(capabilities, input.DONCapabilityWithConfigs[donMetadata.ID]...) } @@ -455,7 +464,7 @@ func ConfigureCapabilityRegistry(input cre.ConfigureCapabilityRegistryInput) (Ca if ocrConfig == nil { return nil, fmt.Errorf("no OCR3 config found for capability %s", cap.Capability.LabelledName) } - if err := dons.embedOCR3Config(don.Capabilities[i].Config, don, input.ChainSelector, ocrConfig, input.ExtraSignerFamilies); err != nil { + if err := dons.embedOCR3Config(don.Capabilities[i].Config, don, input.ChainSelector, ocrConfig, input.CapabilityToExtraSignerFamilies[cap.Capability.LabelledName]); err != nil { return nil, fmt.Errorf("failed to embed OCR3 config for capability %s: %w", cap.Capability.LabelledName, err) } } @@ -490,7 +499,7 @@ func ConfigureCapabilityRegistry(input cre.ConfigureCapabilityRegistryInput) (Ca } // Transform dons data to V2 sequence input format - v2Input := dons.mustToV2ConfigureInput(input.ChainSelector, input.CapabilitiesRegistryAddress.Hex(), input.CapabilityToOCR3Config, input.ExtraSignerFamilies) + v2Input := dons.mustToV2ConfigureInput(input.ChainSelector, input.CapabilitiesRegistryAddress.Hex(), input.CapabilityToOCR3Config, input.CapabilityToExtraSignerFamilies) _, seqErr := operations.ExecuteSequence( input.CldEnv.OperationsBundle, cap_reg_v2_seq.ConfigureCapabilitiesRegistry, diff --git a/system-tests/lib/cre/contracts/ocr3.go b/system-tests/lib/cre/contracts/ocr3.go index 0916a83e607..9bf7de89d6c 100644 --- a/system-tests/lib/cre/contracts/ocr3.go +++ b/system-tests/lib/cre/contracts/ocr3.go @@ -2,6 +2,7 @@ package contracts import ( "fmt" + "time" "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" @@ -71,6 +72,7 @@ func DefaultOCR3Config() *ocr3.OracleConfig { MaxOutcomeLengthBytes: 1000000, MaxReportLengthBytes: 1000000, MaxBatchSize: 1000, + RequestTimeout: 30 * time.Second, }, UniqueReports: true, } diff --git a/system-tests/lib/cre/don.go b/system-tests/lib/cre/don.go index 9f0c6c7073d..0449a9c6d2a 100644 --- a/system-tests/lib/cre/don.go +++ b/system-tests/lib/cre/don.go @@ -3,6 +3,7 @@ package cre import ( "context" "fmt" + "net/http" "net/url" "slices" "strconv" @@ -437,8 +438,6 @@ type JobDistributorDetails struct { type Addresses struct { AdminAddress string `toml:"admin_address" json:"admin_address"` // address used to pay for transactions, applicable only for worker nodes MultiAddress string `toml:"multi_address" json:"multi_address"` // multi address used by OCR2, applicable only for bootstrap nodes - - // maybe in the future add public addresses per chain to avoid the need to access node's keys every time? } type NodeClients struct { @@ -452,8 +451,11 @@ type JDChainConfigInput struct { } func createJDChainConfigs(ctx context.Context, n *Node, supportedChains []blockchains.Blockchain, jd *jd.JobDistributor) error { + // Dedupe by (chain ID, chain type) so we never create the same config twice (avoids unique constraint violation). + seen := make(map[string]struct{}) for _, chain := range supportedChains { var account string + var accountAddrPubKey string chainIDStr := strconv.FormatUint(chain.ChainID(), 10) switch strings.ToLower(chain.ChainFamily()) { @@ -490,15 +492,14 @@ func createJDChainConfigs(ctx context.Context, n *Node, supportedChains []blockc account = accounts[0] } case chainselectors.FamilyAptos: - // always fetch; currently Node doesn't have Aptos keys - accounts, err := n.Clients.GQLClient.FetchKeys(ctx, strings.ToUpper(chain.ChainFamily())) - if err != nil { - return fmt.Errorf("failed to fetch account address for node %s and chain %s: %w", n.Name, chain.ChainFamily(), err) - } - if len(accounts) == 0 { - return fmt.Errorf("failed to fetch account address for node %s and chain %s", n.Name, chain.ChainFamily()) + aptosAccount, aptosErr := aptosAccountForNode(ctx, n) + if aptosErr != nil { + return fmt.Errorf("failed to fetch aptos account address for node %s: %w", n.Name, aptosErr) } - account = accounts[0] + account = aptosAccount + // Deployment parsing prefers AccountAddressPublicKey for Aptos chain configs. + // Mirror transmitter into this field so OCRConfigForChainSelector always resolves it. + accountAddrPubKey = account default: return fmt.Errorf("unsupported chainType %v", chain.ChainFamily()) } @@ -507,12 +508,18 @@ func createJDChainConfigs(ctx context.Context, n *Node, supportedChains []blockc if chain.IsFamily(blockchain.FamilyTron) { chainType = strings.ToUpper(blockchain.FamilyEVM) } + dedupeKey := chainIDStr + "\x00" + chainType + if _, exists := seen[dedupeKey]; exists { + continue + } + seen[dedupeKey] = struct{}{} + ocr2BundleID, createErr := n.Clients.GQLClient.FetchOCR2KeyBundleID(ctx, chainType) if createErr != nil { return fmt.Errorf("failed to fetch OCR2 key bundle id for node %s: %w", n.Name, createErr) } if ocr2BundleID == "" { - return fmt.Errorf("no OCR2 key bundle id found for node %s", n.Name) + return fmt.Errorf("no OCR2 key bundle id found for node %s (chainType=%s)", n.Name, chainType) } if n.Keys.OCR2BundleIDs == nil { @@ -542,20 +549,24 @@ func createJDChainConfigs(ctx context.Context, n *Node, supportedChains []blockc // we need to create JD chain config for each chain, because later on changestes ask the node for that chain data // each node needs to have OCR2 enabled, because p2pIDs are used by some contracts to identify nodes (e.g. capability registry) _, createErr = n.Clients.GQLClient.CreateJobDistributorChainConfig(ctx, client.JobDistributorChainConfigInput{ - JobDistributorID: n.JobDistributorDetails.JDID, - ChainID: chainIDStr, - ChainType: chainType, - AccountAddr: account, - AdminAddr: n.Addresses.AdminAddress, - Ocr2Enabled: true, - Ocr2IsBootstrap: n.HasRole(RoleBootstrap), - Ocr2Multiaddr: n.Addresses.MultiAddress, - Ocr2P2PPeerID: n.Keys.P2PKey.PeerID.String(), - Ocr2KeyBundleID: ocr2BundleID, - Ocr2Plugins: `{}`, + JobDistributorID: n.JobDistributorDetails.JDID, + ChainID: chainIDStr, + ChainType: chainType, + AccountAddr: account, + AccountAddrPubKey: accountAddrPubKey, + AdminAddr: n.Addresses.AdminAddress, + Ocr2Enabled: true, + Ocr2IsBootstrap: n.HasRole(RoleBootstrap), + Ocr2Multiaddr: n.Addresses.MultiAddress, + Ocr2P2PPeerID: n.Keys.P2PKey.PeerID.String(), + Ocr2KeyBundleID: ocr2BundleID, + Ocr2Plugins: `{}`, }) - // TODO: add a check if the chain config failed because of a duplicate in that case, should we update or return success? if createErr != nil { + // Config may already exist (e.g. duplicate key from prior run or concurrent node registration); treat as success. + if strings.Contains(createErr.Error(), "duplicate key") || strings.Contains(createErr.Error(), "23505") { + return nil + } return createErr } @@ -571,6 +582,49 @@ func createJDChainConfigs(ctx context.Context, n *Node, supportedChains []blockc return nil } +func aptosAccountForNode(ctx context.Context, n *Node) (string, error) { + if n.Keys != nil && n.Keys.AptosAccount() != "" { + return n.Keys.AptosAccount(), nil + } + + var runtimeKeys struct { + Data []struct { + Attributes struct { + Account string `json:"account"` + PublicKey string `json:"publicKey"` + } `json:"attributes"` + } `json:"data"` + } + resp, err := n.Clients.RestClient.APIClient.R(). + SetContext(ctx). + SetResult(&runtimeKeys). + Get("/v2/keys/aptos") + if err != nil { + return "", fmt.Errorf("failed to read Aptos keys from node API: %w", err) + } + if resp.StatusCode() != http.StatusOK { + return "", fmt.Errorf("aptos keys endpoint returned status %d", resp.StatusCode()) + } + if len(runtimeKeys.Data) == 0 { + return "", fmt.Errorf("no Aptos keys found on node %s", n.Name) + } + + account, err := crypto.NormalizeAptosAccount(runtimeKeys.Data[0].Attributes.Account) + if err != nil { + return "", fmt.Errorf("invalid Aptos account returned by node API: %w", err) + } + + if n.Keys != nil { + if n.Keys.Aptos == nil { + n.Keys.Aptos = &crypto.AptosKey{} + } + n.Keys.Aptos.Account = account + n.Keys.Aptos.PublicKey = runtimeKeys.Data[0].Attributes.PublicKey + } + + return account, nil +} + // AcceptJob accepts the job proposal for the given job proposal spec func (n *Node) AcceptJob(ctx context.Context, spec string) error { // fetch JD to get the job proposals @@ -809,12 +863,16 @@ func HasFlag(values []string, capability string) bool { func findDonSupportedChains(donMetadata *DonMetadata, bcs []blockchains.Blockchain) ([]blockchains.Blockchain, error) { chains := make([]blockchains.Blockchain, 0) + chainCapabilityIDs := donMetadata.MustNodeSet().ChainCapabilityChainIDs() for _, bc := range bcs { hasEVMChainEnabled := slices.Contains(donMetadata.EVMChains(), bc.ChainID()) + hasChainCapabilityEnabled := slices.Contains(chainCapabilityIDs, bc.ChainID()) chainIsSolana := bc.IsFamily(chainselectors.FamilySolana) - if !hasEVMChainEnabled && (!chainIsSolana) { + // Include all Solana chains (legacy behavior), and include any chain that is + // explicitly referenced by chain-scoped capabilities (e.g. write-aptos-4). + if !hasEVMChainEnabled && !hasChainCapabilityEnabled && !chainIsSolana { continue } diff --git a/system-tests/lib/cre/don/config/config.go b/system-tests/lib/cre/don/config/config.go index 9e17161b6b3..53ade9ae1eb 100644 --- a/system-tests/lib/cre/don/config/config.go +++ b/system-tests/lib/cre/don/config/config.go @@ -35,6 +35,7 @@ import ( "github.com/smartcontractkit/chainlink/system-tests/lib/cre" crecontracts "github.com/smartcontractkit/chainlink/system-tests/lib/cre/contracts" creblockchains "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" + aptoschain "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/aptos" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/solana" "github.com/smartcontractkit/chainlink/system-tests/lib/infra" ) @@ -378,6 +379,18 @@ func addBootstrapNodeConfig( appendSolanaChain(&existingConfig.Solana, commonInputs.solanaChain) } + for _, ac := range commonInputs.aptosChains { + existingConfig.Aptos = append(existingConfig.Aptos, corechainlink.RawConfig{ + "ChainID": ac.ChainID, + "Enabled": true, + "Workflow": map[string]any{"ForwarderAddress": ac.ForwarderAddress}, + "Nodes": []map[string]any{{"Name": "default", "URL": ac.NodeURL}}, + }) + } + + // Set external registry only (local EVM capability registry). We do not set [Capabilities.Local]; + // capabilities (e.g. cron) are registered on the on-chain capability registry via Features (e.g. Cron + // feature PreEnvStartup), same as workflow-don-solana.toml, workflow-gateway-don.toml, workflow-don-tron.toml. if existingConfig.Capabilities.ExternalRegistry.Address == nil { existingConfig.Capabilities.ExternalRegistry = coretoml.ExternalRegistry{ Address: ptr.Ptr(commonInputs.capabilityRegistry.address), @@ -434,8 +447,9 @@ func addWorkerNodeConfig( } // Preserve existing WorkflowRegistry config (e.g., AdditionalSourcesConfig from user_config_overrides) - // before resetting Capabilities struct + // and Local capabilities config before resetting Capabilities struct. existingWorkflowRegistry := existingConfig.Capabilities.WorkflowRegistry + existingLocalCapabilities := existingConfig.Capabilities.Local existingConfig.Capabilities = coretoml.Capabilities{ Peering: coretoml.P2P{ @@ -450,6 +464,7 @@ func addWorkerNodeConfig( SendToSharedPeer: ptr.Ptr(true), }, WorkflowRegistry: existingWorkflowRegistry, + Local: existingLocalCapabilities, } if len(donMetadata.RegistryBasedLaunchAllowlist) > 0 { @@ -466,6 +481,15 @@ func addWorkerNodeConfig( appendSolanaChain(&existingConfig.Solana, commonInputs.solanaChain) } + for _, ac := range commonInputs.aptosChains { + existingConfig.Aptos = append(existingConfig.Aptos, corechainlink.RawConfig{ + "ChainID": ac.ChainID, + "Enabled": true, + "Workflow": map[string]any{"ForwarderAddress": ac.ForwarderAddress}, + "Nodes": []map[string]any{{"Name": "default", "URL": ac.NodeURL}}, + }) + } + if existingConfig.Capabilities.ExternalRegistry.Address == nil { existingConfig.Capabilities.ExternalRegistry = coretoml.ExternalRegistry{ Address: ptr.Ptr(commonInputs.capabilityRegistry.address), @@ -519,7 +543,7 @@ func addWorkerNodeConfig( } gateways := []coretoml.ConnectorGateway{} - if topology != nil && len(topology.GatewayConnectors.Configurations) > 0 { + if topology != nil && topology.GatewayConnectors != nil && len(topology.GatewayConnectors.Configurations) > 0 { for _, gateway := range topology.GatewayConnectors.Configurations { gateways = append(gateways, coretoml.ConnectorGateway{ ID: ptr.Ptr(gateway.AuthGatewayID), @@ -623,6 +647,12 @@ type versionedAddress struct { version *semver.Version } +type aptosChain struct { + ChainID string + NodeURL string + ForwarderAddress string +} + type commonInputs struct { registryChainID uint64 registryChainSelector uint64 @@ -632,6 +662,7 @@ type commonInputs struct { evmChains []*evmChain solanaChain *solanaChain + aptosChains []*aptosChain provider infra.Provider } @@ -651,6 +682,11 @@ func gatherCommonInputs(input cre.GenerateConfigsInput) (*commonInputs, error) { capabilitiesRegistryAddress := crecontracts.MustGetAddressFromDataStore(input.Datastore, input.RegistryChainSelector, keystone_changeset.CapabilitiesRegistry.String(), input.ContractVersions[keystone_changeset.CapabilitiesRegistry.String()], "") workflowRegistryAddress := crecontracts.MustGetAddressFromDataStore(input.Datastore, input.RegistryChainSelector, keystone_changeset.WorkflowRegistry.String(), input.ContractVersions[keystone_changeset.WorkflowRegistry.String()], "") + aptosChains, aptosErr := findAptosChains(input) + if aptosErr != nil { + return nil, errors.Wrap(aptosErr, "failed to find Aptos chains in the environment configuration") + } + return &commonInputs{ registryChainID: registryChainID, registryChainSelector: input.RegistryChainSelector, @@ -660,6 +696,7 @@ func gatherCommonInputs(input cre.GenerateConfigsInput) (*commonInputs, error) { }, evmChains: evmChains, solanaChain: solanaChain, + aptosChains: aptosChains, capabilityRegistry: versionedAddress{ address: capabilitiesRegistryAddress, version: input.ContractVersions[keystone_changeset.CapabilitiesRegistry.String()], @@ -677,8 +714,8 @@ type evmChain struct { func findEVMChains(input cre.GenerateConfigsInput) []*evmChain { evmChains := make([]*evmChain, 0) - for chainSelector, bcOut := range input.Blockchains { - if bcOut.IsFamily(chain_selectors.FamilySolana) { + for _, bcOut := range input.Blockchains { + if bcOut.IsFamily(chain_selectors.FamilySolana) || bcOut.IsFamily(chain_selectors.FamilyAptos) { continue } @@ -688,7 +725,7 @@ func findEVMChains(input cre.GenerateConfigsInput) []*evmChain { } evmChains = append(evmChains, &evmChain{ - Name: fmt.Sprintf("node-%d", chainSelector), + Name: fmt.Sprintf("node-%d", bcOut.ChainSelector()), ChainID: bcOut.ChainID(), HTTPRPC: bcOut.CtfOutput().Nodes[0].InternalHTTPUrl, WSRPC: bcOut.CtfOutput().Nodes[0].InternalWSUrl, @@ -737,6 +774,34 @@ func findOneSolanaChain(input cre.GenerateConfigsInput) (*solanaChain, error) { return solChain, nil } +const aptosZeroForwarderHex = "0x0000000000000000000000000000000000000000000000000000000000000000" + +func findAptosChains(input cre.GenerateConfigsInput) ([]*aptosChain, error) { + capabilityChainIDs := input.DonMetadata.MustNodeSet().ChainCapabilityChainIDs() + out := make([]*aptosChain, 0) + for _, bcOut := range input.Blockchains { + if !bcOut.IsFamily(chain_selectors.FamilyAptos) { + continue + } + if len(capabilityChainIDs) > 0 && !slices.Contains(capabilityChainIDs, bcOut.ChainID()) { + continue + } + + aptosBC := bcOut.(*aptoschain.Blockchain) + nodeURL, err := aptosBC.InternalNodeURL() + if err != nil { + return nil, errors.Wrapf(err, "failed to get Aptos internal node URL for chain %d", bcOut.ChainID()) + } + + out = append(out, &aptosChain{ + ChainID: strconv.FormatUint(bcOut.ChainID(), 10), + NodeURL: nodeURL, + ForwarderAddress: aptosZeroForwarderHex, + }) + } + return out, nil +} + func buildTronEVMConfig(evmChain *evmChain) evmconfigtoml.EVMConfig { tronRPC := strings.Replace(evmChain.HTTPRPC, "jsonrpc", "wallet", 1) return evmconfigtoml.EVMConfig{ diff --git a/system-tests/lib/cre/don/secrets/secrets.go b/system-tests/lib/cre/don/secrets/secrets.go index bc98c8aadf8..905fb197054 100644 --- a/system-tests/lib/cre/don/secrets/secrets.go +++ b/system-tests/lib/cre/don/secrets/secrets.go @@ -3,6 +3,7 @@ package secrets import ( "encoding/hex" "encoding/json" + "strings" "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" @@ -11,6 +12,7 @@ import ( "github.com/smartcontractkit/smdkg/dkgocr/dkgocrtypes" + "github.com/smartcontractkit/chainlink-common/keystore/corekeys/aptoskey" "github.com/smartcontractkit/chainlink-common/keystore/corekeys/p2pkey" "github.com/smartcontractkit/chainlink/system-tests/lib/crypto" ) @@ -18,6 +20,7 @@ import ( type nodeSecret struct { EthKeys nodeEthKeyWrapper `toml:"EVM"` SolKeys nodeSolKeyWrapper `toml:"Solana"` + AptosKey nodeAptosKey `toml:"Aptos"` P2PKey nodeP2PKey `toml:"P2PKey"` DKGRecipientKey nodeDKGRecipientKey `toml:"DKGRecipientKey"` @@ -42,6 +45,11 @@ type nodeP2PKey struct { Password string `toml:"Password"` } +type nodeAptosKey struct { + JSON string `toml:"JSON"` + Password string `toml:"Password"` +} + type nodeDKGRecipientKey struct { JSON string `toml:"JSON"` Password string `toml:"Password"` @@ -61,6 +69,7 @@ type NodeKeys struct { CSAKey *crypto.CSAKey EVM map[uint64]*crypto.EVMKey Solana map[string]*crypto.SolKey + Aptos *crypto.AptosKey P2PKey *crypto.P2PKey DKGKey *crypto.DKGRecipientKey OCR2BundleIDs map[ChainFamily]string @@ -73,6 +82,13 @@ func (n NodeKeys) PeerID() string { return n.P2PKey.PeerID.String() } +func (n NodeKeys) AptosAccount() string { + if n.Aptos == nil { + return "" + } + return n.Aptos.Account +} + func (n *NodeKeys) ToNodeSecretsTOML() (string, error) { ns := nodeSecret{} @@ -83,6 +99,13 @@ func (n *NodeKeys) ToNodeSecretsTOML() (string, error) { } } + if n.Aptos != nil { + ns.AptosKey = nodeAptosKey{ + JSON: string(n.Aptos.EncryptedJSON), + Password: n.Aptos.Password, + } + } + if n.DKGKey != nil { ns.DKGRecipientKey = nodeDKGRecipientKey{ JSON: string(n.DKGKey.EncryptedJSON), @@ -125,6 +148,7 @@ type secrets struct { EVM ethKeys `toml:",omitempty"` // choose EVM as the TOML field name to align with relayer config convention P2PKey p2PKey `toml:",omitempty"` Solana solKeys `toml:",omitempty"` + Aptos aptosKey `toml:",omitempty"` DKGRecipientKey dkgRecipientKey `toml:",omitempty"` } @@ -138,6 +162,11 @@ type dkgRecipientKey struct { Password *string } +type aptosKey struct { + JSON *string + Password *string +} + type ethKeys struct { Keys []*ethKey } @@ -260,6 +289,41 @@ func ImportNodeKeys(secretsToml string) (*NodeKeys, error) { PeerID: *p, } + if sSecrets.Aptos.JSON != nil { + aptosJSON := strings.TrimSpace(*sSecrets.Aptos.JSON) + if aptosJSON == "" { + sSecrets.Aptos.JSON = nil + sSecrets.Aptos.Password = nil + } + } + + if sSecrets.Aptos.JSON != nil { + if sSecrets.Aptos.Password == nil { + return nil, errors.New("aptos key password is nil") + } + aptosPassword := strings.TrimSpace(*sSecrets.Aptos.Password) + if aptosPassword == "" { + return nil, errors.New("aptos key password is empty") + } + + aptosKeyValue, err := aptoskey.FromEncryptedJSON([]byte(*sSecrets.Aptos.JSON), aptosPassword) + if err != nil { + return nil, errors.Wrap(err, "failed to decrypt aptos key from encrypted JSON") + } + + account, err := crypto.NormalizeAptosAccount(aptosKeyValue.Account()) + if err != nil { + return nil, errors.Wrap(err, "failed to normalize aptos account") + } + + keys.Aptos = &crypto.AptosKey{ + EncryptedJSON: []byte(*sSecrets.Aptos.JSON), + PublicKey: aptosKeyValue.PublicKeyStr(), + Account: account, + Password: aptosPassword, + } + } + if sSecrets.DKGRecipientKey.JSON != nil { keys.DKGKey = &crypto.DKGRecipientKey{ EncryptedJSON: []byte(*sSecrets.DKGRecipientKey.JSON), diff --git a/system-tests/lib/cre/don/secrets/secrets_test.go b/system-tests/lib/cre/don/secrets/secrets_test.go new file mode 100644 index 00000000000..7b2744d3e7c --- /dev/null +++ b/system-tests/lib/cre/don/secrets/secrets_test.go @@ -0,0 +1,65 @@ +package secrets + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/system-tests/lib/crypto" +) + +func TestNodeKeysAptosSecretsRoundTrip(t *testing.T) { + t.Parallel() + + aptosKey, err := crypto.NewAptosKey("dev-password") + require.NoError(t, err) + p2pKey, err := crypto.NewP2PKey("dev-password") + require.NoError(t, err) + dkgKey, err := crypto.NewDKGRecipientKey("dev-password") + require.NoError(t, err) + + keys := &NodeKeys{ + Aptos: aptosKey, + P2PKey: p2pKey, + DKGKey: dkgKey, + EVM: map[uint64]*crypto.EVMKey{}, + Solana: map[string]*crypto.SolKey{}, + } + + secretsTOML, err := keys.ToNodeSecretsTOML() + require.NoError(t, err) + + imported, err := ImportNodeKeys(secretsTOML) + require.NoError(t, err) + require.NotNil(t, imported.Aptos) + require.Equal(t, aptosKey.Account, imported.Aptos.Account) + require.Equal(t, aptosKey.PublicKey, imported.Aptos.PublicKey) + require.Equal(t, aptosKey.Password, imported.Aptos.Password) + require.Equal(t, p2pKey.PeerID, imported.P2PKey.PeerID) + require.Equal(t, dkgKey.PubKey, imported.DKGKey.PubKey) +} + +func TestImportNodeKeys_IgnoresEmptyAptosSecret(t *testing.T) { + t.Parallel() + + p2pKey, err := crypto.NewP2PKey("dev-password") + require.NoError(t, err) + dkgKey, err := crypto.NewDKGRecipientKey("dev-password") + require.NoError(t, err) + + keys := &NodeKeys{ + P2PKey: p2pKey, + DKGKey: dkgKey, + EVM: map[uint64]*crypto.EVMKey{}, + Solana: map[string]*crypto.SolKey{}, + } + + secretsTOML, err := keys.ToNodeSecretsTOML() + require.NoError(t, err) + + imported, err := ImportNodeKeys(secretsTOML) + require.NoError(t, err) + require.Nil(t, imported.Aptos) + require.Equal(t, p2pKey.PeerID, imported.P2PKey.PeerID) + require.Equal(t, dkgKey.PubKey, imported.DKGKey.PubKey) +} diff --git a/system-tests/lib/cre/don_test.go b/system-tests/lib/cre/don_test.go new file mode 100644 index 00000000000..7c70dab226d --- /dev/null +++ b/system-tests/lib/cre/don_test.go @@ -0,0 +1,82 @@ +package cre + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-testing-framework/framework/clclient" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don/secrets" + crecrypto "github.com/smartcontractkit/chainlink/system-tests/lib/crypto" +) + +func TestAptosAccountForNode_UsesMetadataKeyWithoutCallingNodeAPI(t *testing.T) { + t.Parallel() + + var hits atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + + expected, err := crecrypto.NormalizeAptosAccount("0x1") + require.NoError(t, err) + + node := &Node{ + Name: "node-1", + Keys: &secrets.NodeKeys{ + Aptos: &crecrypto.AptosKey{Account: expected}, + }, + Clients: NodeClients{ + RestClient: &clclient.ChainlinkClient{APIClient: resty.New().SetBaseURL(server.URL)}, + }, + } + + account, err := aptosAccountForNode(context.Background(), node) + require.NoError(t, err) + require.Equal(t, expected, account) + require.Zero(t, hits.Load(), "node API must not be called when metadata already has the Aptos key") +} + +func TestAptosAccountForNode_FallsBackToNodeAPIAndCachesKey(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/keys/aptos" { + t.Errorf("unexpected path: got %q want %q", r.URL.Path, "/v2/keys/aptos") + http.Error(w, fmt.Sprintf("unexpected path %q", r.URL.Path), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"data":[{"attributes":{"account":"0x1","publicKey":"0xabc123"}}]}`)) + if err != nil { + t.Errorf("failed to write response: %v", err) + } + })) + t.Cleanup(server.Close) + + node := &Node{ + Name: "node-1", + Keys: &secrets.NodeKeys{}, + Clients: NodeClients{ + RestClient: &clclient.ChainlinkClient{APIClient: resty.New().SetBaseURL(server.URL)}, + }, + } + + account, err := aptosAccountForNode(context.Background(), node) + require.NoError(t, err) + + expected, err := crecrypto.NormalizeAptosAccount("0x1") + require.NoError(t, err) + require.Equal(t, expected, account) + require.NotNil(t, node.Keys.Aptos) + require.Equal(t, expected, node.Keys.Aptos.Account) + require.Equal(t, "0xabc123", node.Keys.Aptos.PublicKey) +} diff --git a/system-tests/lib/cre/environment/blockchains/aptos/aptos.go b/system-tests/lib/cre/environment/blockchains/aptos/aptos.go new file mode 100644 index 00000000000..461a86ea4b4 --- /dev/null +++ b/system-tests/lib/cre/environment/blockchains/aptos/aptos.go @@ -0,0 +1,357 @@ +package aptos + +import ( + "context" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + aptoslib "github.com/aptos-labs/aptos-go-sdk" + aptoscrypto "github.com/aptos-labs/aptos-go-sdk/crypto" + pkgerrors "github.com/pkg/errors" + "github.com/rs/zerolog" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_aptos "github.com/smartcontractkit/chainlink-deployments-framework/chain/aptos" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" + "github.com/smartcontractkit/chainlink/system-tests/lib/infra" +) + +type Deployer struct { + provider infra.Provider + testLogger zerolog.Logger +} + +func NewDeployer(testLogger zerolog.Logger, provider *infra.Provider) *Deployer { + return &Deployer{ + provider: *provider, + testLogger: testLogger, + } +} + +type Blockchain struct { + testLogger zerolog.Logger + chainSelector uint64 + chainID uint64 + ctfOutput *blockchain.Output +} + +func (a *Blockchain) ChainSelector() uint64 { + return a.chainSelector +} + +func (a *Blockchain) ChainID() uint64 { + return a.chainID +} + +func (a *Blockchain) CtfOutput() *blockchain.Output { + return a.ctfOutput +} + +func (a *Blockchain) NodeURL() (string, error) { + if a.ctfOutput == nil || len(a.ctfOutput.Nodes) == 0 { + return "", fmt.Errorf("no nodes found for Aptos chain %s-%d", a.ChainFamily(), a.chainID) + } + return NormalizeNodeURL(a.ctfOutput.Nodes[0].ExternalHTTPUrl) +} + +func (a *Blockchain) InternalNodeURL() (string, error) { + if a.ctfOutput == nil || len(a.ctfOutput.Nodes) == 0 { + return "", fmt.Errorf("no nodes found for Aptos chain %s-%d", a.ChainFamily(), a.chainID) + } + return NormalizeNodeURL(a.ctfOutput.Nodes[0].InternalHTTPUrl) +} + +func (a *Blockchain) NodeClient() (*aptoslib.NodeClient, error) { + nodeURL, err := a.NodeURL() + if err != nil { + return nil, err + } + chainID, err := aptosChainIDUint8(a.chainID) + if err != nil { + return nil, err + } + return aptoslib.NewNodeClient(nodeURL, chainID) +} + +func (a *Blockchain) LocalDeployerAccount() (*aptoslib.Account, error) { + var deployerPrivateKey aptoscrypto.Ed25519PrivateKey + if err := deployerPrivateKey.FromHex(blockchain.DefaultAptosPrivateKey); err != nil { + return nil, fmt.Errorf("failed to parse default Aptos deployer private key: %w", err) + } + deployerAccount, err := aptoslib.NewAccountFromSigner(&deployerPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to create default Aptos deployer signer: %w", err) + } + return deployerAccount, nil +} + +func (a *Blockchain) LocalDeploymentChain() (cldf_aptos.Chain, error) { + nodeURL, err := a.NodeURL() + if err != nil { + return cldf_aptos.Chain{}, err + } + client, err := a.NodeClient() + if err != nil { + return cldf_aptos.Chain{}, err + } + deployerAccount, err := a.LocalDeployerAccount() + if err != nil { + return cldf_aptos.Chain{}, err + } + return cldf_aptos.Chain{ + Selector: a.chainSelector, + Client: client, + DeployerSigner: deployerAccount, + URL: nodeURL, + Confirm: func(txHash string, opts ...any) error { + tx, err := client.WaitForTransaction(txHash, opts...) + if err != nil { + return err + } + if !tx.Success { + return fmt.Errorf("transaction failed: %s", tx.VmStatus) + } + return nil + }, + }, nil +} + +func (a *Blockchain) IsFamily(chainFamily string) bool { + return strings.EqualFold(a.ctfOutput.Family, chainFamily) +} + +func (a *Blockchain) ChainFamily() string { + return a.ctfOutput.Family +} + +func (a *Blockchain) Fund(ctx context.Context, address string, amount uint64) error { + client, err := a.NodeClient() + if err != nil { + return fmt.Errorf("cannot fund Aptos address %s: create node client: %w", address, err) + } + + var account aptoslib.AccountAddress + if parseErr := account.ParseStringRelaxed(address); parseErr != nil { + return fmt.Errorf("cannot fund Aptos address %q: parse error: %w", address, parseErr) + } + + faucetURL, err := a.faucetURL() + if err != nil { + return fmt.Errorf("failed to derive Aptos faucet URL for %s: %w", address, err) + } + faucetClient, err := aptoslib.NewFaucetClient(client, faucetURL) + if err != nil { + return fmt.Errorf("failed to create Aptos faucet client for %s: %w", address, err) + } + if err := faucetClient.Fund(account, amount); err != nil { + return fmt.Errorf("failed to fund Aptos address %s via host faucet: %w", address, err) + } + if err := waitForAptosAccountVisible(ctx, client, account, 15*time.Second); err != nil { + return fmt.Errorf("aptos funding request completed but account is still not visible: %w", err) + } + + a.testLogger.Info().Msgf("Funded Aptos account %s via host faucet (%d octas)", account.StringLong(), amount) + return nil +} + +// ToCldfChain returns the chainlink-deployments-framework aptos.Chain for this blockchain +// so that BlockChains.AptosChains() and saved state work like EVM/Solana. +func (a *Blockchain) ToCldfChain() (cldf_chain.BlockChain, error) { + nodeURL, err := a.NodeURL() + if err != nil { + return nil, fmt.Errorf("invalid Aptos ExternalHTTPUrl for chain %d: %w", a.chainID, err) + } + if nodeURL == "" { + return nil, fmt.Errorf("aptos node has no ExternalHTTPUrl for chain %d", a.chainID) + } + client, err := a.NodeClient() + if err != nil { + return nil, pkgerrors.Wrapf(err, "create Aptos RPC client for chain %d", a.chainID) + } + return cldf_aptos.Chain{ + Selector: a.chainSelector, + Client: client, + DeployerSigner: nil, // CRE read-only use; deployer not required for View calls + URL: nodeURL, + Confirm: func(txHash string, opts ...any) error { + tx, err := client.WaitForTransaction(txHash, opts...) + if err != nil { + return err + } + if !tx.Success { + return fmt.Errorf("transaction failed: %s", tx.VmStatus) + } + return nil + }, + }, nil +} + +func (a *Deployer) Deploy(ctx context.Context, input *blockchain.Input) (blockchains.Blockchain, error) { + var bcOut *blockchain.Output + var err error + + switch { + case a.provider.IsKubernetes(): + if err = blockchains.ValidateKubernetesBlockchainOutput(input); err != nil { + return nil, err + } + a.testLogger.Info().Msgf("Using configured Kubernetes blockchain URLs for %s (chain_id: %s)", input.Type, input.ChainID) + bcOut = input.Out + case input.Out != nil: + bcOut = input.Out + default: + bcOut, err = blockchain.NewWithContext(ctx, input) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to deploy blockchain %s chainID: %s", input.Type, input.ChainID) + } + } + + // Framework Aptos output may have empty ChainID; use config input.ChainID (e.g. "4" for local devnet) + chainIDStr := bcOut.ChainID + if chainIDStr == "" { + chainIDStr = input.ChainID + } + if chainIDStr == "" { + return nil, pkgerrors.New("aptos chain id is empty (set chain_id in [[blockchains]] in TOML)") + } + chainID, err := strconv.ParseUint(chainIDStr, 10, 64) + if err != nil { + return nil, pkgerrors.Wrapf(err, "failed to parse chain id %s", chainIDStr) + } + + selector, err := aptosChainSelector(chainIDStr, chainID) + if err != nil { + return nil, err + } + + // Ensure ctfOutput has ChainID set for downstream (e.g. findAptosChains) + bcOut.ChainID = chainIDStr + + return &Blockchain{ + testLogger: a.testLogger, + chainSelector: selector, + chainID: chainID, + ctfOutput: bcOut, + }, nil +} + +// aptosChainSelector returns the chain selector for the given Aptos chain ID. +// Uses chain-selectors when available; falls back to known Aptos localnet selector for chain_id 4. +func aptosChainSelector(chainIDStr string, chainID uint64) (uint64, error) { + chainDetails, err := chainselectors.GetChainDetailsByChainIDAndFamily(chainIDStr, chainselectors.FamilyAptos) + if err == nil { + return chainDetails.ChainSelector, nil + } + // Fallback: Aptos local devnet (aptos node run-local-testnet) uses chain_id 4 and this selector + if chainID == 4 { + const aptosLocalnetSelector = 4457093679053095497 + return aptosLocalnetSelector, nil + } + return 0, pkgerrors.Wrapf(err, "failed to get chain selector for Aptos chain id %s", chainIDStr) +} + +func aptosNodeURLWithV1(rawURL string) (string, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "", err + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("invalid url %q", rawURL) + } + path := strings.TrimRight(u.Path, "/") + if path == "" || path != "/v1" { + u.Path = "/v1" + } + u.RawPath = "" + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} + +func NormalizeNodeURL(rawURL string) (string, error) { + return aptosNodeURLWithV1(rawURL) +} + +func aptosFaucetURLFromNodeURL(nodeURL string) (string, error) { + u, err := url.Parse(nodeURL) + if err != nil { + return "", err + } + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("empty host in node url %q", nodeURL) + } + u.Host = host + ":8081" + u.Path = "" + u.RawPath = "" + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} + +func FaucetURLFromNodeURL(nodeURL string) (string, error) { + return aptosFaucetURLFromNodeURL(nodeURL) +} + +func (a *Blockchain) faucetURL() (string, error) { + if a.ctfOutput == nil || len(a.ctfOutput.Nodes) == 0 { + return "", errors.New("missing chain nodes output") + } + nodeURL, err := NormalizeNodeURL(a.ctfOutput.Nodes[0].ExternalHTTPUrl) + if err != nil { + return "", err + } + return FaucetURLFromNodeURL(nodeURL) +} + +func waitForAptosAccountVisible(ctx context.Context, client *aptoslib.NodeClient, account aptoslib.AccountAddress, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var lastErr error + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + _, accountErr := client.Account(account) + if accountErr == nil { + return nil + } + lastErr = accountErr + time.Sleep(1 * time.Second) + } + if lastErr != nil { + return fmt.Errorf("account %s not visible after funding attempt: %w", account.StringLong(), lastErr) + } + return fmt.Errorf("account %s not visible after funding attempt", account.StringLong()) +} + +func aptosChainIDUint8(chainID uint64) (uint8, error) { + if chainID > uint64(^uint8(0)) { + return 0, fmt.Errorf("aptos chain id %d does not fit in uint8", chainID) + } + + return uint8(chainID), nil +} + +func ChainIDUint8(chainID uint64) (uint8, error) { + return aptosChainIDUint8(chainID) +} + +func WaitForTransactionSuccess(client *aptoslib.NodeClient, txHash, label string) error { + tx, err := client.WaitForTransaction(txHash) + if err != nil { + return fmt.Errorf("failed waiting for Aptos tx %s: %w", label, err) + } + if !tx.Success { + return fmt.Errorf("aptos tx failed: %s vm_status=%s", label, tx.VmStatus) + } + return nil +} diff --git a/system-tests/lib/cre/environment/blockchains/aptos/aptos_test.go b/system-tests/lib/cre/environment/blockchains/aptos/aptos_test.go new file mode 100644 index 00000000000..246e1904f6c --- /dev/null +++ b/system-tests/lib/cre/environment/blockchains/aptos/aptos_test.go @@ -0,0 +1,36 @@ +package aptos + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeNodeURL(t *testing.T) { + t.Run("adds v1 when path is empty", func(t *testing.T) { + got, err := NormalizeNodeURL("http://127.0.0.1:8080") + require.NoError(t, err) + require.Equal(t, "http://127.0.0.1:8080/v1", got) + }) + + t.Run("preserves v1 path", func(t *testing.T) { + got, err := NormalizeNodeURL("http://127.0.0.1:8080/v1") + require.NoError(t, err) + require.Equal(t, "http://127.0.0.1:8080/v1", got) + }) +} + +func TestFaucetURLFromNodeURL(t *testing.T) { + got, err := FaucetURLFromNodeURL("http://127.0.0.1:8080/v1") + require.NoError(t, err) + require.Equal(t, "http://127.0.0.1:8081", got) +} + +func TestChainIDUint8(t *testing.T) { + got, err := ChainIDUint8(4) + require.NoError(t, err) + require.Equal(t, uint8(4), got) + + _, err = ChainIDUint8(256) + require.Error(t, err) +} diff --git a/system-tests/lib/cre/environment/blockchains/sets/sets.go b/system-tests/lib/cre/environment/blockchains/sets/sets.go index c450f607300..ff73c1c9d87 100644 --- a/system-tests/lib/cre/environment/blockchains/sets/sets.go +++ b/system-tests/lib/cre/environment/blockchains/sets/sets.go @@ -5,6 +5,7 @@ import ( "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/aptos" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/evm" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/solana" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/tron" @@ -16,5 +17,6 @@ func NewDeployerSet(testLogger zerolog.Logger, provider *infra.Provider) map[blo blockchain.FamilyEVM: evm.NewDeployer(testLogger, provider), blockchain.FamilySolana: solana.NewDeployer(testLogger, provider), blockchain.FamilyTron: tron.NewDeployer(testLogger, provider), + blockchain.FamilyAptos: aptos.NewDeployer(testLogger, provider), } } diff --git a/system-tests/lib/cre/environment/config/config.go b/system-tests/lib/cre/environment/config/config.go index 1339c7891e2..b31df9e1b6b 100644 --- a/system-tests/lib/cre/environment/config/config.go +++ b/system-tests/lib/cre/environment/config/config.go @@ -61,8 +61,8 @@ type Config struct { NodeSets []*cre.NodeSet `toml:"nodesets" validate:"required"` JD *jd.Input `toml:"jd" validate:"required"` Infra *infra.Provider `toml:"infra" validate:"required"` - Fake *fake.Input `toml:"fake" validate:"required"` - FakeHTTP *fake.Input `toml:"fake_http" validate:"required"` + Fake *fake.Input `toml:"fake"` + FakeHTTP *fake.Input `toml:"fake_http"` S3ProviderInput *s3provider.Input `toml:"s3provider"` CapabilityConfigs map[string]cre.CapabilityConfig `toml:"capability_configs"` // capability flag -> capability config Addresses []string `toml:"addresses"` diff --git a/system-tests/lib/cre/environment/dons.go b/system-tests/lib/cre/environment/dons.go index d395cbc25b0..e1bdbb6856c 100644 --- a/system-tests/lib/cre/environment/dons.go +++ b/system-tests/lib/cre/environment/dons.go @@ -214,7 +214,7 @@ func FundNodes(ctx context.Context, testLogger zerolog.Logger, dons *cre.Dons, b } for _, node := range don.Nodes { - address, addrErr := nodeAddress(node, chainFamily, bc) + address, addrErr := nodeAddress(ctx, node, chainFamily, bc) if addrErr != nil { return pkgerrors.Wrapf(addrErr, "failed to get address for node %s on chain family %s and chain %d", node.Name, chainFamily, bc.ChainID()) } @@ -237,7 +237,7 @@ func FundNodes(ctx context.Context, testLogger zerolog.Logger, dons *cre.Dons, b return nil } -func nodeAddress(node *cre.Node, chainFamily string, bc blockchains.Blockchain) (string, error) { +func nodeAddress(ctx context.Context, node *cre.Node, chainFamily string, bc blockchains.Blockchain) (string, error) { switch chainFamily { case chainselectors.FamilyEVM, chainselectors.FamilyTron: evmKey, ok := node.Keys.EVM[bc.ChainID()] @@ -253,6 +253,11 @@ func nodeAddress(node *cre.Node, chainFamily string, bc blockchains.Blockchain) return "", nil // Skip nodes without Solana keys for this chain } return solKey.PublicAddress.String(), nil + case chainselectors.FamilyAptos: + if node.Keys != nil && node.Keys.AptosAccount() != "" { + return node.Keys.AptosAccount(), nil + } + return "", nil // Skip nodes without Aptos keys for this chain default: return "", fmt.Errorf("unsupported chain family %s", chainFamily) } diff --git a/system-tests/lib/cre/environment/environment.go b/system-tests/lib/cre/environment/environment.go index f3a1ccaa2a8..925a4e53faa 100644 --- a/system-tests/lib/cre/environment/environment.go +++ b/system-tests/lib/cre/environment/environment.go @@ -189,7 +189,7 @@ func SetupTestEnvironment( fmt.Print(libformat.PurpleText("%s", input.StageGen.Wrap("Applying Features before environment startup"))) var donsCapabilities = make(map[uint64][]keystone_changeset.DONCapabilityWithConfig) var capabilityToOCR3Config = make(map[string]*ocr3.OracleConfig) - extraSignerFamiliesSet := make(map[string]bool) + capabilityToExtraSignerFamilies := make(map[string][]string) for _, feature := range input.Features.List() { for _, donMetadata := range topology.DonsMetadataWithFlag(feature.Flag()) { testLogger.Info().Msgf("Executing PreEnvStartup for feature %s for don '%s'", feature.Flag(), donMetadata.Name) @@ -209,17 +209,13 @@ func SetupTestEnvironment( } donsCapabilities[donMetadata.ID] = append(donsCapabilities[donMetadata.ID], output.DONCapabilityWithConfig...) maps.Copy(capabilityToOCR3Config, output.CapabilityToOCR3Config) - for _, f := range output.ExtraSignerFamilies { - extraSignerFamiliesSet[f] = true + for capability, families := range output.CapabilityToExtraSignerFamilies { + capabilityToExtraSignerFamilies[capability] = append([]string(nil), families...) } } testLogger.Info().Msgf("PreEnvStartup for feature %s executed successfully", feature.Flag()) } } - extraSignerFamilies := make([]string, 0, len(extraSignerFamiliesSet)) - for f := range extraSignerFamiliesSet { - extraSignerFamilies = append(extraSignerFamilies, f) - } fmt.Print(libformat.PurpleText("%s", input.StageGen.WrapAndNext("Applied Features in %.2f seconds", input.StageGen.Elapsed().Seconds()))) @@ -283,6 +279,7 @@ func SetupTestEnvironment( } fmt.Print(libformat.PurpleText("%s", input.StageGen.WrapAndNext("DONs and Job Distributor started and linked in %.2f seconds", input.StageGen.Elapsed().Seconds()))) + fmt.Print(libformat.PurpleText("%s", input.StageGen.Wrap("Creating Jobs with Job Distributor"))) gJobErr := gateway.CreateJobs(ctx, creEnvironment, dons, topology.GatewayServiceConfigs, input.GatewayWhitelistConfig) @@ -321,6 +318,7 @@ func SetupTestEnvironment( chainselectors.FamilyEVM: 10000000000000000, // 0.01 ETH chainselectors.FamilySolana: 50_000_000_000, // 50 SOL chainselectors.FamilyTron: 100_000_000, // 100 TRX in SUN + chainselectors.FamilyAptos: 1_000_000_000_000, // 1,000 APT (octas) for local devnet sender accounts } fErr := FundNodes( @@ -337,7 +335,9 @@ func SetupTestEnvironment( fmt.Print(libformat.PurpleText("%s", input.StageGen.Wrap("Configuring Workflow and Capability Registry contracts"))) - // Configure Capabilities Registry first so we can resolve actual contract donIDs + // Configure Capabilities Registry first so we can resolve actual contract DON IDs + // before wiring the workflow registry. Some downstream changesets read DON info + // from CapReg state rather than the pre-contract topology shape. capRegInput := cre.ConfigureCapabilityRegistryInput{ ChainSelector: deployedBlockchains.RegistryChain().ChainSelector(), CldEnv: creEnvironment.CldfEnvironment, @@ -350,11 +350,11 @@ func SetupTestEnvironment( input.ContractVersions[keystone_changeset.CapabilitiesRegistry.String()], ""), ), - NodeSets: input.NodeSets, - WithV2Registries: input.WithV2Registries, - DONCapabilityWithConfigs: make(map[uint64][]keystone_changeset.DONCapabilityWithConfig), - CapabilityToOCR3Config: capabilityToOCR3Config, - ExtraSignerFamilies: extraSignerFamilies, + NodeSets: input.NodeSets, + WithV2Registries: input.WithV2Registries, + DONCapabilityWithConfigs: make(map[uint64][]keystone_changeset.DONCapabilityWithConfig), + CapabilityToOCR3Config: capabilityToOCR3Config, + CapabilityToExtraSignerFamilies: capabilityToExtraSignerFamilies, } for _, capability := range input.Capabilities { @@ -373,7 +373,6 @@ func SetupTestEnvironment( if err := crecontracts.ResolveAndApplyContractDonIDs(capReg, dons, topology, input.NodeSets, input.WithV2Registries); err != nil { return nil, pkgerrors.Wrap(err, "failed to resolve and apply contract donIDs") } - wfRegVersion := input.ContractVersions[keystone_changeset.WorkflowRegistry.String()] workflowRegistryConfigurationOutput, wfErr := workflow.ConfigureWorkflowRegistry( ctx, diff --git a/system-tests/lib/cre/features/aptos/aptos.go b/system-tests/lib/cre/features/aptos/aptos.go new file mode 100644 index 00000000000..fad29a05799 --- /dev/null +++ b/system-tests/lib/cre/features/aptos/aptos.go @@ -0,0 +1,949 @@ +package aptos + +import ( + "context" + "encoding/hex" + "encoding/json" + stderrors "errors" + "fmt" + "strconv" + "strings" + "time" + + "dario.cat/mergo" + "github.com/Masterminds/semver/v3" + aptossdk "github.com/aptos-labs/aptos-go-sdk" + "github.com/pelletier/go-toml/v2" + pkgerrors "github.com/pkg/errors" + "github.com/rs/zerolog" + "github.com/sethvargo/go-retry" + "google.golang.org/protobuf/types/known/durationpb" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-aptos/bindings/bind" + aptosplatform "github.com/smartcontractkit/chainlink-aptos/bindings/platform" + capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + kcr "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/capabilities_registry_1_1_0" + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + "github.com/smartcontractkit/chainlink/deployment/cre/jobs" + crejobops "github.com/smartcontractkit/chainlink/deployment/cre/jobs/operations" + jobtypes "github.com/smartcontractkit/chainlink/deployment/cre/jobs/types" + "github.com/smartcontractkit/chainlink/deployment/cre/ocr3" + creocr3changeset "github.com/smartcontractkit/chainlink/deployment/cre/ocr3/v2/changeset" + creocr3contracts "github.com/smartcontractkit/chainlink/deployment/cre/ocr3/v2/changeset/operations/contracts" + "github.com/smartcontractkit/chainlink/deployment/cre/pkg/offchain" + aptoschangeset "github.com/smartcontractkit/chainlink/deployment/data-feeds/changeset/aptos" + keystone_changeset "github.com/smartcontractkit/chainlink/deployment/keystone/changeset" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + crecontracts "github.com/smartcontractkit/chainlink/system-tests/lib/cre/contracts" + crejobs "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don/jobs" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don/jobs/standardcapability" + creblockchains "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" + aptoschain "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/aptos" + corechainlink "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" +) + +const ( + flag = cre.WriteAptosCapability + forwarderContractType = "AptosForwarder" + forwarderConfigVersion = 1 + capabilityVersion = "1.0.0" + capabilityLabelPrefix = "aptos:ChainSelector:" + specConfigP2PMapKey = "p2pToTransmitterMap" + specConfigScheduleKey = "transmissionSchedule" + specConfigDeltaStageKey = "deltaStage" + legacyTransmittersKey = "aptosTransmitters" + requestTimeoutKey = "RequestTimeout" + deltaStageKey = "DeltaStage" + transmissionScheduleKey = "TransmissionSchedule" + forwarderQualifier = "" + ocr3ContractQualifier = "aptos_capability_ocr3" + zeroForwarderHex = "0x0000000000000000000000000000000000000000000000000000000000000000" + defaultWriteDeltaStage = 500*time.Millisecond + 1*time.Second + defaultRequestTimeout = 30 * time.Second +) + +var forwarderContractVersion = semver.MustParse("1.0.0") + +type Aptos struct{} + +type methodConfigSettings struct { + RequestTimeout time.Duration + DeltaStage time.Duration + TransmissionSchedule capabilitiespb.TransmissionSchedule +} + +func (a *Aptos) Flag() cre.CapabilityFlag { + return flag +} + +func CapabilityLabel(chainSelector uint64) string { + return capabilityLabelPrefix + strconv.FormatUint(chainSelector, 10) +} + +func (a *Aptos) PreEnvStartup( + ctx context.Context, + testLogger zerolog.Logger, + don *cre.DonMetadata, + _ *cre.Topology, + creEnv *cre.Environment, +) (*cre.PreEnvStartupOutput, error) { + enabledChainIDs, err := don.MustNodeSet().GetEnabledChainIDsForCapability(flag) + if err != nil { + return nil, fmt.Errorf("could not find enabled chainIDs for '%s' in don '%s': %w", flag, don.Name, err) + } + if len(enabledChainIDs) == 0 { + return nil, nil + } + + forwardersByChainID := make(map[uint64]string, len(enabledChainIDs)) + for _, chainID := range enabledChainIDs { + aptosChain, findErr := findAptosChainByChainID(creEnv.Blockchains, chainID) + if findErr != nil { + return nil, findErr + } + + forwarderAddress, ensureErr := ensureForwarder(ctx, testLogger, creEnv, aptosChain) + if ensureErr != nil { + return nil, ensureErr + } + forwardersByChainID[chainID] = forwarderAddress + } + + if patchErr := patchNodeTOML(don, forwardersByChainID); patchErr != nil { + return nil, patchErr + } + + workers, err := don.Workers() + if err != nil { + return nil, err + } + p2pToTransmitterMap, err := p2pToTransmitterMapForWorkers(workers) + if err != nil { + return nil, fmt.Errorf("failed to collect Aptos worker transmitters for DON %q from metadata: %w", don.Name, err) + } + + caps := make([]keystone_changeset.DONCapabilityWithConfig, 0, len(enabledChainIDs)) + capabilityToOCR3Config := make(map[string]*ocr3.OracleConfig, len(enabledChainIDs)) + capabilityLabels := make([]string, 0, len(enabledChainIDs)) + for _, chainID := range enabledChainIDs { + aptosChain, err := findAptosChainByChainID(creEnv.Blockchains, chainID) + if err != nil { + return nil, err + } + labelledName := CapabilityLabel(aptosChain.ChainSelector()) + capabilityConfig, err := cre.ResolveCapabilityConfig(don.MustNodeSet(), flag, cre.ChainCapabilityScope(chainID)) + if err != nil { + return nil, fmt.Errorf("could not resolve capability config for '%s' on chain %d: %w", flag, chainID, err) + } + capConfig, err := BuildCapabilityConfig(capabilityConfig.Values, p2pToTransmitterMap, don.HasOnlyLocalCapabilities()) + if err != nil { + return nil, fmt.Errorf("failed to build Aptos capability config for capability %s: %w", labelledName, err) + } + + caps = append(caps, keystone_changeset.DONCapabilityWithConfig{ + Capability: kcr.CapabilitiesRegistryCapability{ + LabelledName: labelledName, + Version: capabilityVersion, + CapabilityType: 1, + }, + Config: capConfig, + UseCapRegOCRConfig: false, + }) + capabilityLabels = append(capabilityLabels, labelledName) + capabilityToOCR3Config[labelledName] = crecontracts.DefaultChainCapabilityOCR3Config() + } + + return &cre.PreEnvStartupOutput{ + DONCapabilityWithConfig: caps, + CapabilityToOCR3Config: capabilityToOCR3Config, + CapabilityToExtraSignerFamilies: cre.CapabilityToExtraSignerFamilies( + cre.OCRExtraSignerFamilies(creEnv.Blockchains), + capabilityLabels..., + ), + }, nil +} + +func (a *Aptos) PostEnvStartup( + ctx context.Context, + testLogger zerolog.Logger, + don *cre.Don, + dons *cre.Dons, + creEnv *cre.Environment, +) error { + specs := make(map[string][]string) + + var nodeSet cre.NodeSetWithCapabilityConfigs + for _, ns := range dons.AsNodeSetWithChainCapabilities() { + if ns.GetName() == don.Name { + nodeSet = ns + break + } + } + if nodeSet == nil { + return fmt.Errorf("could not find node set for Don named '%s'", don.Name) + } + + enabledChainIDs, err := nodeSet.GetEnabledChainIDsForCapability(flag) + if err != nil { + return fmt.Errorf("could not find enabled chainIDs for '%s' in don '%s': %w", flag, don.Name, err) + } + if len(enabledChainIDs) == 0 { + return nil + } + + if configureErr := configureForwarders(ctx, testLogger, don, creEnv, enabledChainIDs); configureErr != nil { + return configureErr + } + + bootstrapNode, ok := dons.Bootstrap() + if !ok { + return pkgerrors.New("bootstrap node not found; required for Aptos OCR bootstrap peers") + } + bootstrapPeers := []string{ + fmt.Sprintf("%s@%s:%d", strings.TrimPrefix(bootstrapNode.Keys.PeerID(), "p2p_"), bootstrapNode.Host, cre.OCRPeeringPort), + } + + if _, _, deployErr := crecontracts.DeployOCR3Contract(testLogger, ocr3ContractQualifier, creEnv.RegistryChainSelector, creEnv.CldfEnvironment, creEnv.ContractVersions); deployErr != nil { + return fmt.Errorf("failed to deploy Aptos OCR3 contract: %w", deployErr) + } + + for _, chainID := range enabledChainIDs { + aptosChain, chainErr := findAptosChainByChainID(creEnv.Blockchains, chainID) + if chainErr != nil { + return chainErr + } + + capabilityConfig, resolveErr := cre.ResolveCapabilityConfig(nodeSet, flag, cre.ChainCapabilityScope(chainID)) + if resolveErr != nil { + return fmt.Errorf("could not resolve capability config for '%s' on chain %d: %w", flag, chainID, resolveErr) + } + command, cErr := standardcapability.GetCommand(capabilityConfig.BinaryName) + if cErr != nil { + return pkgerrors.Wrap(cErr, "failed to get command for Aptos capability") + } + + forwarderAddress := mustForwarderAddress(creEnv.CldfEnvironment.DataStore, aptosChain.ChainSelector()) + workerMetadata, metadataErr := don.Metadata().Workers() + if metadataErr != nil { + return fmt.Errorf("failed to collect Aptos worker metadata for DON %q: %w", don.Name, metadataErr) + } + p2pToTransmitterMap, mapErr := p2pToTransmitterMapForWorkers(workerMetadata) + if mapErr != nil { + return fmt.Errorf("failed to collect Aptos worker transmitters for DON %q: %w", don.Name, mapErr) + } + methodSettings, settingsErr := resolveMethodConfigSettings(capabilityConfig.Values) + if settingsErr != nil { + return fmt.Errorf("failed to resolve Aptos method config settings for chain %d: %w", chainID, settingsErr) + } + configStr, configErr := buildWorkerConfigJSON(chainID, forwarderAddress, methodSettings, p2pToTransmitterMap, true) + if configErr != nil { + return fmt.Errorf("failed to build Aptos worker config: %w", configErr) + } + + workerInput := jobs.ProposeJobSpecInput{ + Domain: offchain.ProductLabel, + Environment: cre.EnvironmentName, + DONName: don.Name, + JobName: "write-aptos-worker-" + strconv.FormatUint(chainID, 10), + ExtraLabels: map[string]string{cre.CapabilityLabelKey: flag}, + DONFilters: []offchain.TargetDONFilter{ + {Key: offchain.FilterKeyDONName, Value: don.Name}, + }, + Template: jobtypes.Aptos, + Inputs: jobtypes.JobSpecInput{ + "command": command, + "config": configStr, + "chainSelectorEVM": creEnv.RegistryChainSelector, + "chainSelectorAptos": aptosChain.ChainSelector(), + "bootstrapPeers": bootstrapPeers, + "useCapRegOCRConfig": false, + "contractQualifier": ocr3ContractQualifier, + }, + } + + proposer := jobs.ProposeJobSpec{} + if verifyErr := proposer.VerifyPreconditions(*creEnv.CldfEnvironment, workerInput); verifyErr != nil { + return fmt.Errorf("precondition verification failed for Aptos worker job: %w", verifyErr) + } + workerReport, applyErr := proposer.Apply(*creEnv.CldfEnvironment, workerInput) + if applyErr != nil { + return fmt.Errorf("failed to propose Aptos worker job spec: %w", applyErr) + } + + for _, report := range workerReport.Reports { + out, ok := report.Output.(crejobops.ProposeStandardCapabilityJobOutput) + if !ok { + return fmt.Errorf("unable to cast to ProposeStandardCapabilityJobOutput, actual type: %T", report.Output) + } + if mergeErr := mergo.Merge(&specs, out.Specs, mergo.WithAppendSlice); mergeErr != nil { + return fmt.Errorf("failed to merge Aptos worker job specs: %w", mergeErr) + } + } + } + + if len(specs) == 0 { + return nil + } + if approveErr := crejobs.Approve(ctx, creEnv.CldfEnvironment.Offchain, dons, specs); approveErr != nil { + return fmt.Errorf("failed to approve Aptos jobs: %w", approveErr) + } + + workers, err := don.Workers() + if err != nil { + return fmt.Errorf("failed to collect Aptos worker nodes for OCR3 config: %w", err) + } + workerNodeIDs := make([]string, 0, len(workers)) + for _, worker := range workers { + if worker.JobDistributorDetails == nil { + return fmt.Errorf("worker %q is missing job distributor details", worker.Name) + } + workerNodeIDs = append(workerNodeIDs, worker.JobDistributorDetails.NodeID) + } + + _, err = creocr3changeset.ConfigureOCR3{}.Apply(*creEnv.CldfEnvironment, creocr3changeset.ConfigureOCR3Input{ + ContractChainSelector: creEnv.RegistryChainSelector, + ContractQualifier: ocr3ContractQualifier, + DON: creocr3contracts.DonNodeSet{ + Name: don.Name, + NodeIDs: workerNodeIDs, + }, + OracleConfig: don.ResolveORC3Config(crecontracts.DefaultChainCapabilityOCR3Config()), + DryRun: false, + ExtraSignerFamilies: cre.OCRExtraSignerFamilies(creEnv.Blockchains), + }) + if err != nil { + return fmt.Errorf("failed to configure Aptos OCR3 contract: %w", err) + } + return nil +} + +func forwarderAddress(ds datastore.DataStore, chainSelector uint64) (string, bool) { + key := datastore.NewAddressRefKey( + chainSelector, + datastore.ContractType(forwarderContractType), + forwarderContractVersion, + forwarderQualifier, + ) + ref, err := ds.Addresses().Get(key) + if err != nil { + return "", false + } + return ref.Address, true +} + +func mustForwarderAddress(ds datastore.DataStore, chainSelector uint64) string { + addr, ok := forwarderAddress(ds, chainSelector) + if !ok { + panic(fmt.Sprintf("missing Aptos forwarder address for chain selector %d", chainSelector)) + } + return addr +} + +// BuildCapabilityConfig builds the Aptos capability config passed directly +// through the capability manager: method execution policy in MethodConfigs and +// Aptos-specific runtime inputs in SpecConfig. +func BuildCapabilityConfig(values map[string]any, p2pToTransmitterMap map[string]string, localOnly bool) (*capabilitiespb.CapabilityConfig, error) { + methodSettings, err := resolveMethodConfigSettings(values) + if err != nil { + return nil, err + } + + capConfig := &capabilitiespb.CapabilityConfig{ + MethodConfigs: methodConfigs(methodSettings), + LocalOnly: localOnly, + } + if err := setRuntimeSpecConfig(capConfig, methodSettings, p2pToTransmitterMap); err != nil { + return nil, err + } + return capConfig, nil +} + +func buildWorkerConfigJSON(chainID uint64, forwarderAddress string, settings methodConfigSettings, p2pToTransmitterMap map[string]string, isLocal bool) (string, error) { + cfg := map[string]any{ + "chainId": strconv.FormatUint(chainID, 10), + "network": "aptos", + "creForwarderAddress": forwarderAddress, + "isLocal": isLocal, + "deltaStage": settings.DeltaStage, + } + if len(p2pToTransmitterMap) > 0 { + cfg[specConfigP2PMapKey] = p2pToTransmitterMap + } + + raw, err := json.Marshal(cfg) + if err != nil { + return "", fmt.Errorf("failed to marshal Aptos worker config: %w", err) + } + return string(raw), nil +} + +func methodConfigs(settings methodConfigSettings) map[string]*capabilitiespb.CapabilityMethodConfig { + return map[string]*capabilitiespb.CapabilityMethodConfig{ + "View": { + RemoteConfig: &capabilitiespb.CapabilityMethodConfig_RemoteExecutableConfig{ + RemoteExecutableConfig: &capabilitiespb.RemoteExecutableConfig{ + TransmissionSchedule: capabilitiespb.TransmissionSchedule_AllAtOnce, + RequestTimeout: durationpb.New(settings.RequestTimeout), + ServerMaxParallelRequests: 10, + RequestHasherType: capabilitiespb.RequestHasherType_Simple, + }, + }, + }, + "WriteReport": { + RemoteConfig: &capabilitiespb.CapabilityMethodConfig_RemoteExecutableConfig{ + RemoteExecutableConfig: &capabilitiespb.RemoteExecutableConfig{ + TransmissionSchedule: settings.TransmissionSchedule, + DeltaStage: durationpb.New(settings.DeltaStage), + RequestTimeout: durationpb.New(settings.RequestTimeout), + ServerMaxParallelRequests: 10, + RequestHasherType: capabilitiespb.RequestHasherType_WriteReportExcludeSignatures, + }, + }, + }, + } +} + +func resolveMethodConfigSettings(values map[string]any) (methodConfigSettings, error) { + settings := methodConfigSettings{ + RequestTimeout: defaultRequestTimeout, + DeltaStage: defaultWriteDeltaStage, + TransmissionSchedule: capabilitiespb.TransmissionSchedule_AllAtOnce, + } + + if values == nil { + return settings, nil + } + + requestTimeout, ok, err := durationValue(values, requestTimeoutKey) + if err != nil { + return methodConfigSettings{}, err + } + if ok { + settings.RequestTimeout = requestTimeout + } + + deltaStage, ok, err := durationValue(values, deltaStageKey) + if err != nil { + return methodConfigSettings{}, err + } + if ok { + settings.DeltaStage = deltaStage + } + + transmissionSchedule, ok, err := transmissionScheduleValue(values, transmissionScheduleKey) + if err != nil { + return methodConfigSettings{}, err + } + if ok { + settings.TransmissionSchedule = transmissionSchedule + } + + return settings, nil +} + +func transmissionScheduleValue(values map[string]any, key string) (capabilitiespb.TransmissionSchedule, bool, error) { + raw, ok := values[key] + if !ok { + return 0, false, nil + } + + schedule, ok := raw.(string) + if !ok { + return 0, false, fmt.Errorf("%s must be a string, got %T", key, raw) + } + + switch strings.TrimSpace(schedule) { + case "allAtOnce": + return capabilitiespb.TransmissionSchedule_AllAtOnce, true, nil + case "oneAtATime": + return capabilitiespb.TransmissionSchedule_OneAtATime, true, nil + default: + return 0, false, fmt.Errorf("%s must be allAtOnce or oneAtATime, got %q", key, schedule) + } +} + +func durationValue(values map[string]any, key string) (time.Duration, bool, error) { + raw, ok := values[key] + if !ok { + return 0, false, nil + } + + switch v := raw.(type) { + case string: + parsed, err := time.ParseDuration(strings.TrimSpace(v)) + if err != nil { + return 0, false, fmt.Errorf("%s must be a valid duration string: %w", key, err) + } + return parsed, true, nil + case time.Duration: + return v, true, nil + default: + return 0, false, fmt.Errorf("%s must be a duration string, got %T", key, raw) + } +} + +func patchNodeTOML(don *cre.DonMetadata, forwardersByChainID map[uint64]string) error { + for nodeIndex := range don.MustNodeSet().NodeSpecs { + currentConfig := don.MustNodeSet().NodeSpecs[nodeIndex].Node.TestConfigOverrides + if strings.TrimSpace(currentConfig) == "" { + return fmt.Errorf("missing node config for node index %d in DON %q", nodeIndex, don.Name) + } + + var typedConfig corechainlink.Config + if err := toml.Unmarshal([]byte(currentConfig), &typedConfig); err != nil { + return fmt.Errorf("failed to unmarshal config for node index %d: %w", nodeIndex, err) + } + + for chainID, forwarderAddress := range forwardersByChainID { + if err := setForwarderAddress(&typedConfig, strconv.FormatUint(chainID, 10), forwarderAddress); err != nil { + return fmt.Errorf("failed to patch Aptos forwarder address for node index %d: %w", nodeIndex, err) + } + } + + stringifiedConfig, err := toml.Marshal(typedConfig) + if err != nil { + return fmt.Errorf("failed to marshal patched config for node index %d: %w", nodeIndex, err) + } + don.MustNodeSet().NodeSpecs[nodeIndex].Node.TestConfigOverrides = string(stringifiedConfig) + } + + return nil +} + +func setForwarderAddress(cfg *corechainlink.Config, chainID, forwarderAddress string) error { + for i := range cfg.Aptos { + raw := map[string]any(cfg.Aptos[i]) + if fmt.Sprint(raw["ChainID"]) != chainID { + continue + } + + workflow := make(map[string]any) + switch existing := raw["Workflow"].(type) { + case map[string]any: + for k, v := range existing { + workflow[k] = v + } + case corechainlink.RawConfig: + for k, v := range existing { + workflow[k] = v + } + case nil: + default: + return fmt.Errorf("unexpected Aptos workflow config type %T", existing) + } + workflow["ForwarderAddress"] = forwarderAddress + raw["Workflow"] = workflow + cfg.Aptos[i] = corechainlink.RawConfig(raw) + return nil + } + + return fmt.Errorf("Aptos chain %s not found in node config", chainID) +} + +// ensureForwarder makes sure a forwarder exists for the Aptos chain selector and +// returns its address. In local Docker environments it will deploy the forwarder +// once and cache the resulting address in the CRE datastore; in non-Docker +// environments it only reuses an address that has already been injected. +func ensureForwarder( + ctx context.Context, + testLogger zerolog.Logger, + creEnv *cre.Environment, + chain *aptoschain.Blockchain, +) (string, error) { + if addr, ok := forwarderAddress(creEnv.CldfEnvironment.DataStore, chain.ChainSelector()); ok { + return addr, nil + } + if !creEnv.Provider.IsDocker() { + return "", fmt.Errorf("missing Aptos forwarder address for chain selector %d", chain.ChainSelector()) + } + + nodeURL, err := chain.NodeURL() + if err != nil { + return "", fmt.Errorf("invalid Aptos node URL for chain selector %d: %w", chain.ChainSelector(), err) + } + client, err := chain.NodeClient() + if err != nil { + return "", fmt.Errorf("failed to create Aptos client for chain selector %d (%s): %w", chain.ChainSelector(), nodeURL, err) + } + deployerAccount, err := chain.LocalDeployerAccount() + if err != nil { + return "", fmt.Errorf("failed to create Aptos deployer signer: %w", err) + } + deploymentChain, err := chain.LocalDeploymentChain() + if err != nil { + return "", fmt.Errorf("failed to build Aptos deployment chain for chain selector %d: %w", chain.ChainSelector(), err) + } + + owner := deployerAccount.AccountAddress() + if _, accountErr := client.Account(owner); accountErr != nil { + if fundErr := chain.Fund(ctx, owner.StringLong(), 100_000_000); fundErr != nil { + testLogger.Warn(). + Uint64("chainSelector", chain.ChainSelector()). + Str("nodeURL", nodeURL). + Err(fundErr). + Msg("Aptos deployer account not confirmed visible yet; proceeding with deploy retries") + } + } + + var deployedAddress string + var pendingTxHash string + var lastDeployErr error + if retryErr := retry.Do(ctx, retry.WithMaxDuration(3*time.Minute, retry.NewFibonacci(500*time.Millisecond)), func(ctx context.Context) error { + deploymentResp, deployErr := aptoschangeset.DeployPlatform(deploymentChain, owner, nil) + if deployErr != nil { + lastDeployErr = deployErr + if fundErr := chain.Fund(ctx, owner.StringLong(), 1_000_000_000_000); fundErr != nil { + testLogger.Warn(). + Uint64("chainSelector", chain.ChainSelector()). + Err(fundErr). + Msg("failed to re-fund Aptos deployer account during deploy retry") + } + return retry.RetryableError(fmt.Errorf("deploy-to-object failed: %w", deployErr)) + } + if deploymentResp == nil { + lastDeployErr = pkgerrors.New("nil deployment response") + return retry.RetryableError(pkgerrors.New("DeployPlatform returned nil response")) + } + deployedAddress = deploymentResp.Address.StringLong() + pendingTxHash = deploymentResp.Tx + return nil + }); retryErr != nil { + if lastDeployErr != nil { + return "", fmt.Errorf("failed to deploy Aptos platform forwarder for chain selector %d after retries: %w", chain.ChainSelector(), stderrors.Join(lastDeployErr, retryErr)) + } + return "", fmt.Errorf("failed to deploy Aptos platform forwarder for chain selector %d after retries: %w", chain.ChainSelector(), retryErr) + } + + addr, err := normalizeForwarderAddress(deployedAddress) + if err != nil { + return "", fmt.Errorf("invalid Aptos forwarder address parsed from deployment output for chain selector %d: %w", chain.ChainSelector(), err) + } + + if err := addForwarderToDataStore(creEnv, chain.ChainSelector(), addr); err != nil { + return "", err + } + + testLogger.Info(). + Uint64("chainSelector", chain.ChainSelector()). + Str("nodeURL", nodeURL). + Str("txHash", pendingTxHash). + Str("forwarderAddress", addr). + Msg("Aptos platform forwarder deployed") + + return addr, nil +} + +// addForwarderToDataStore seals a new datastore snapshot with the Aptos +// forwarder address so later setup phases can reuse it without redeploying. +func addForwarderToDataStore(creEnv *cre.Environment, chainSelector uint64, address string) error { + memoryDatastore, err := crecontracts.NewDataStoreFromExisting(creEnv.CldfEnvironment.DataStore) + if err != nil { + return fmt.Errorf("failed to create memory datastore: %w", err) + } + + err = memoryDatastore.AddressRefStore.Add(datastore.AddressRef{ + Address: address, + ChainSelector: chainSelector, + Type: datastore.ContractType(forwarderContractType), + Version: forwarderContractVersion, + Qualifier: forwarderQualifier, + }) + if err != nil && !stderrors.Is(err, datastore.ErrAddressRefExists) { + return fmt.Errorf("failed to add Aptos forwarder address to datastore: %w", err) + } + + creEnv.CldfEnvironment.DataStore = memoryDatastore.Seal() + return nil +} + +// configureForwarders writes the final DON membership and signer set to each +// Aptos forwarder after the DON has started and contract DON IDs are known. +func configureForwarders( + ctx context.Context, + testLogger zerolog.Logger, + don *cre.Don, + creEnv *cre.Environment, + chainIDs []uint64, +) error { + workers, err := don.Workers() + if err != nil { + return fmt.Errorf("failed to get worker nodes for DON %q: %w", don.Name, err) + } + f := (len(workers) - 1) / 3 + if f <= 0 { + return fmt.Errorf("invalid Aptos DON %q fault tolerance F=%d (workers=%d)", don.Name, f, len(workers)) + } + if f > 255 { + return fmt.Errorf("aptos DON %q fault tolerance F=%d exceeds u8", don.Name, f) + } + + donIDUint32, err := aptosDonIDUint32(don.ID) + if err != nil { + return fmt.Errorf("invalid DON id for Aptos forwarder config: %w", err) + } + + oracles, err := donOraclePublicKeys(ctx, don) + if err != nil { + return err + } + + for _, chainID := range chainIDs { + aptosChain, err := findAptosChainByChainID(creEnv.Blockchains, chainID) + if err != nil { + return err + } + + nodeURL, err := aptosChain.NodeURL() + if err != nil { + return fmt.Errorf("invalid Aptos node URL for chain selector %d: %w", aptosChain.ChainSelector(), err) + } + client, err := aptosChain.NodeClient() + if err != nil { + return fmt.Errorf("failed to create Aptos client for chain selector %d (%s): %w", aptosChain.ChainSelector(), nodeURL, err) + } + deployerAccount, err := aptosChain.LocalDeployerAccount() + if err != nil { + return fmt.Errorf("failed to create Aptos deployer signer for forwarder config: %w", err) + } + deployerAddress := deployerAccount.AccountAddress() + + if _, accountErr := client.Account(deployerAddress); accountErr != nil { + if fundErr := aptosChain.Fund(ctx, deployerAddress.StringLong(), 100_000_000); fundErr != nil { + testLogger.Warn(). + Uint64("chainSelector", aptosChain.ChainSelector()). + Str("nodeURL", nodeURL). + Err(fundErr). + Msg("Aptos deployer account not confirmed visible yet; proceeding with forwarder set_config retries") + } + } + + forwarderHex := mustForwarderAddress(creEnv.CldfEnvironment.DataStore, aptosChain.ChainSelector()) + var forwarderAddr aptossdk.AccountAddress + if err := forwarderAddr.ParseStringRelaxed(forwarderHex); err != nil { + return fmt.Errorf("invalid Aptos forwarder address for chain selector %d: %w", aptosChain.ChainSelector(), err) + } + forwarderContract := aptosplatform.Bind(forwarderAddr, client).Forwarder() + + var pendingTxHash string + var lastSetConfigErr error + if err := retry.Do(ctx, retry.WithMaxDuration(2*time.Minute, retry.NewFibonacci(500*time.Millisecond)), func(ctx context.Context) error { + pendingTx, err := forwarderContract.SetConfig(&bind.TransactOpts{Signer: deployerAccount}, donIDUint32, forwarderConfigVersion, byte(f), oracles) + if err != nil { + lastSetConfigErr = err + if fundErr := aptosChain.Fund(ctx, deployerAddress.StringLong(), 1_000_000_000_000); fundErr != nil { + testLogger.Warn(). + Uint64("chainSelector", aptosChain.ChainSelector()). + Err(fundErr). + Msg("failed to fund Aptos deployer account during set_config retry") + } + return retry.RetryableError(fmt.Errorf("set_config transaction submit failed: %w", err)) + } + pendingTxHash = pendingTx.Hash + receipt, err := client.WaitForTransaction(pendingTxHash) + if err != nil { + lastSetConfigErr = err + return retry.RetryableError(fmt.Errorf("waiting for set_config transaction failed: %w", err)) + } + if !receipt.Success { + lastSetConfigErr = fmt.Errorf("vm status: %s", receipt.VmStatus) + return retry.RetryableError(fmt.Errorf("set_config transaction failed: %s", receipt.VmStatus)) + } + return nil + }); err != nil { + if lastSetConfigErr != nil { + return fmt.Errorf("failed to configure Aptos forwarder %s for DON %q on chain selector %d: %w", forwarderHex, don.Name, aptosChain.ChainSelector(), stderrors.Join(lastSetConfigErr, err)) + } + return fmt.Errorf("failed to configure Aptos forwarder %s for DON %q on chain selector %d: %w", forwarderHex, don.Name, aptosChain.ChainSelector(), err) + } + + testLogger.Info(). + Str("donName", don.Name). + Uint64("donID", don.ID). + Uint64("chainSelector", aptosChain.ChainSelector()). + Str("txHash", pendingTxHash). + Str("forwarderAddress", forwarderHex). + Msg("configured Aptos forwarder set_config") + } + + return nil +} + +func donOraclePublicKeys(ctx context.Context, don *cre.Don) ([][]byte, error) { + workers, err := don.Workers() + if err != nil { + return nil, fmt.Errorf("failed to list worker nodes for DON %q: %w", don.Name, err) + } + + oracles := make([][]byte, 0, len(workers)) + for _, worker := range workers { + ocr2ID := "" + if worker.Keys != nil && worker.Keys.OCR2BundleIDs != nil { + ocr2ID = worker.Keys.OCR2BundleIDs[chainselectors.FamilyAptos] + } + if ocr2ID == "" { + fetchedID, err := worker.Clients.GQLClient.FetchOCR2KeyBundleID(ctx, strings.ToUpper(chainselectors.FamilyAptos)) + if err != nil { + return nil, fmt.Errorf("missing Aptos OCR2 bundle id for worker %q in DON %q and fallback fetch failed: %w", worker.Name, don.Name, err) + } + if fetchedID == "" { + return nil, fmt.Errorf("missing Aptos OCR2 bundle id for worker %q in DON %q", worker.Name, don.Name) + } + ocr2ID = fetchedID + if worker.Keys != nil { + if worker.Keys.OCR2BundleIDs == nil { + worker.Keys.OCR2BundleIDs = make(map[string]string) + } + worker.Keys.OCR2BundleIDs[chainselectors.FamilyAptos] = ocr2ID + } + } + + exported, err := worker.ExportOCR2Keys(ocr2ID) + if err != nil { + return nil, fmt.Errorf("failed to export Aptos OCR2 key for worker %q (bundle %s): %w", worker.Name, ocr2ID, err) + } + pubkey, err := parseOCR2OnchainPublicKey(exported.OnchainPublicKey) + if err != nil { + return nil, fmt.Errorf("invalid Aptos OCR2 onchain public key for worker %q: %w", worker.Name, err) + } + oracles = append(oracles, pubkey) + } + + return oracles, nil +} + +func p2pToTransmitterMapForWorkers(workers []*cre.NodeMetadata) (map[string]string, error) { + if len(workers) == 0 { + return nil, pkgerrors.New("no DON worker nodes provided") + } + + p2pToTransmitterMap := make(map[string]string) + for _, worker := range workers { + if worker.Keys == nil || worker.Keys.P2PKey == nil { + return nil, fmt.Errorf("missing P2P key for worker index %d", worker.Index) + } + + account := worker.Keys.AptosAccount() + if account == "" { + return nil, fmt.Errorf("missing Aptos account for worker index %d", worker.Index) + } + + transmitter, err := normalizeTransmitter(account) + if err != nil { + return nil, fmt.Errorf("invalid Aptos transmitter for worker index %d: %w", worker.Index, err) + } + + peerKey := hex.EncodeToString(worker.Keys.P2PKey.PeerID[:]) + p2pToTransmitterMap[peerKey] = transmitter + } + + if len(p2pToTransmitterMap) == 0 { + return nil, pkgerrors.New("no Aptos transmitters found for DON workers") + } + + return p2pToTransmitterMap, nil +} + +func setRuntimeSpecConfig(capConfig *capabilitiespb.CapabilityConfig, settings methodConfigSettings, p2pToTransmitterMap map[string]string) error { + if capConfig == nil { + return pkgerrors.New("capability config is nil") + } + + specConfig, err := values.FromMapValueProto(capConfig.SpecConfig) + if err != nil { + return fmt.Errorf("failed to decode existing spec config: %w", err) + } + if specConfig == nil { + specConfig = values.EmptyMap() + } + + delete(specConfig.Underlying, legacyTransmittersKey) + + scheduleValue, err := values.Wrap(remoteTransmissionScheduleString(settings.TransmissionSchedule)) + if err != nil { + return fmt.Errorf("failed to wrap transmission schedule: %w", err) + } + specConfig.Underlying[specConfigScheduleKey] = scheduleValue + + deltaStageValue, err := values.Wrap(settings.DeltaStage) + if err != nil { + return fmt.Errorf("failed to wrap delta stage: %w", err) + } + specConfig.Underlying[specConfigDeltaStageKey] = deltaStageValue + + if len(p2pToTransmitterMap) > 0 { + mapValue, err := values.Wrap(p2pToTransmitterMap) + if err != nil { + return fmt.Errorf("failed to wrap p2p transmitter map: %w", err) + } + specConfig.Underlying[specConfigP2PMapKey] = mapValue + } + + capConfig.SpecConfig = values.ProtoMap(specConfig) + return nil +} + +func remoteTransmissionScheduleString(schedule capabilitiespb.TransmissionSchedule) string { + switch schedule { + case capabilitiespb.TransmissionSchedule_OneAtATime: + return "oneAtATime" + default: + return "allAtOnce" + } +} + +func normalizeTransmitter(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", pkgerrors.New("empty Aptos transmitter") + } + + var addr aptossdk.AccountAddress + if err := addr.ParseStringRelaxed(s); err != nil { + return "", err + } + return addr.StringLong(), nil +} + +func normalizeForwarderAddress(raw string) (string, error) { + var addr aptossdk.AccountAddress + if err := addr.ParseStringRelaxed(strings.TrimSpace(raw)); err != nil { + return "", err + } + return addr.StringLong(), nil +} + +func findAptosChainByChainID(chains []creblockchains.Blockchain, chainID uint64) (*aptoschain.Blockchain, error) { + for _, bc := range chains { + if bc.IsFamily(chainselectors.FamilyAptos) && bc.ChainID() == chainID { + aptosBlockchain, ok := bc.(*aptoschain.Blockchain) + if !ok { + return nil, fmt.Errorf("Aptos blockchain for chain id %d has unexpected type %T", chainID, bc) + } + return aptosBlockchain, nil + } + } + return nil, fmt.Errorf("Aptos blockchain for chain id %d not found", chainID) +} + +func aptosDonIDUint32(donID uint64) (uint32, error) { + if donID > uint64(^uint32(0)) { + return 0, fmt.Errorf("don id %d exceeds u32", donID) + } + return uint32(donID), nil +} + +func parseOCR2OnchainPublicKey(hexValue string) ([]byte, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(hexValue), "ocr2on_aptos_") + decoded, err := hex.DecodeString(trimmed) + if err != nil { + return nil, err + } + return decoded, nil +} + +var ( + _ cre.Feature = (*Aptos)(nil) +) diff --git a/system-tests/lib/cre/features/aptos/aptos_test.go b/system-tests/lib/cre/features/aptos/aptos_test.go new file mode 100644 index 00000000000..0b00cb9eb08 --- /dev/null +++ b/system-tests/lib/cre/features/aptos/aptos_test.go @@ -0,0 +1,234 @@ +package aptos + +import ( + "encoding/hex" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/keystore/corekeys/p2pkey" + capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" + "github.com/smartcontractkit/chainlink-protos/cre/go/values" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don/secrets" + "github.com/smartcontractkit/chainlink/system-tests/lib/crypto" +) + +func TestSetRuntimeSpecConfig_ReplacesLegacyKey(t *testing.T) { + specConfig := values.EmptyMap() + legacy, err := values.Wrap([]string{"0x1"}) + require.NoError(t, err) + specConfig.Underlying[legacyTransmittersKey] = legacy + + capConfig := &capabilitiespb.CapabilityConfig{ + SpecConfig: values.ProtoMap(specConfig), + } + + expectedMap := map[string]string{ + "peer-a": "0x000000000000000000000000000000000000000000000000000000000000000a", + } + require.NoError(t, setRuntimeSpecConfig(capConfig, methodConfigSettings{ + TransmissionSchedule: capabilitiespb.TransmissionSchedule_AllAtOnce, + DeltaStage: 1500 * time.Millisecond, + }, expectedMap)) + + decoded, err := values.FromMapValueProto(capConfig.SpecConfig) + require.NoError(t, err) + require.NotNil(t, decoded) + require.NotContains(t, decoded.Underlying, legacyTransmittersKey) + + rawSchedule, ok := decoded.Underlying[specConfigScheduleKey] + require.True(t, ok) + schedule, err := rawSchedule.Unwrap() + require.NoError(t, err) + require.Equal(t, "allAtOnce", schedule) + + rawDeltaStage, ok := decoded.Underlying[specConfigDeltaStageKey] + require.True(t, ok) + deltaStage, err := rawDeltaStage.Unwrap() + require.NoError(t, err) + require.EqualValues(t, 1500*time.Millisecond, deltaStage) + + rawMap, ok := decoded.Underlying[specConfigP2PMapKey] + require.True(t, ok) + unwrapped, err := rawMap.Unwrap() + require.NoError(t, err) + require.Equal(t, map[string]any{ + "peer-a": "0x000000000000000000000000000000000000000000000000000000000000000a", + }, unwrapped) +} + +func TestBuildCapabilityConfig_UsesMethodConfigsAndSpecConfig(t *testing.T) { + capConfig, err := BuildCapabilityConfig( + map[string]any{ + requestTimeoutKey: "45s", + deltaStageKey: "2500ms", + }, + map[string]string{ + "peer-a": "0x000000000000000000000000000000000000000000000000000000000000000a", + }, + false, + ) + require.NoError(t, err) + require.False(t, capConfig.LocalOnly) + require.Nil(t, capConfig.Ocr3Configs) + require.Contains(t, capConfig.MethodConfigs, "View") + require.Contains(t, capConfig.MethodConfigs, "WriteReport") + + writeCfg := capConfig.MethodConfigs["WriteReport"].GetRemoteExecutableConfig() + require.NotNil(t, writeCfg) + require.Equal(t, capabilitiespb.TransmissionSchedule_AllAtOnce, writeCfg.TransmissionSchedule) + require.Equal(t, 2500*time.Millisecond, writeCfg.DeltaStage.AsDuration()) + require.Equal(t, 45*time.Second, writeCfg.RequestTimeout.AsDuration()) + + specConfig, err := values.FromMapValueProto(capConfig.SpecConfig) + require.NoError(t, err) + require.NotNil(t, specConfig) + + rawSchedule, ok := specConfig.Underlying[specConfigScheduleKey] + require.True(t, ok) + schedule, err := rawSchedule.Unwrap() + require.NoError(t, err) + require.Equal(t, "allAtOnce", schedule) + + rawDeltaStage, ok := specConfig.Underlying[specConfigDeltaStageKey] + require.True(t, ok) + deltaStage, err := rawDeltaStage.Unwrap() + require.NoError(t, err) + require.EqualValues(t, 2500*time.Millisecond, deltaStage) + + rawMap, ok := specConfig.Underlying[specConfigP2PMapKey] + require.True(t, ok) + unwrapped, err := rawMap.Unwrap() + require.NoError(t, err) + require.Equal(t, map[string]any{ + "peer-a": "0x000000000000000000000000000000000000000000000000000000000000000a", + }, unwrapped) +} + +func TestBuildCapabilityConfig_WithoutP2PMap_StillSetsRuntimeSpecConfig(t *testing.T) { + capConfig, err := BuildCapabilityConfig(nil, nil, true) + require.NoError(t, err) + require.True(t, capConfig.LocalOnly) + require.Nil(t, capConfig.Ocr3Configs) + require.Contains(t, capConfig.MethodConfigs, "View") + require.Contains(t, capConfig.MethodConfigs, "WriteReport") + + specConfig, err := values.FromMapValueProto(capConfig.SpecConfig) + require.NoError(t, err) + require.NotNil(t, specConfig) + require.NotContains(t, specConfig.Underlying, specConfigP2PMapKey) + require.Contains(t, specConfig.Underlying, specConfigScheduleKey) + require.Contains(t, specConfig.Underlying, specConfigDeltaStageKey) +} + +func TestBuildWorkerConfigJSON_IncludesLocalRuntimeValues(t *testing.T) { + configStr, err := buildWorkerConfigJSON( + 4, + "0x000000000000000000000000000000000000000000000000000000000000000a", + methodConfigSettings{DeltaStage: 2500 * time.Millisecond}, + map[string]string{"peer-a": "0x1"}, + true, + ) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(configStr), &got)) + require.Equal(t, "4", got["chainId"]) + require.Equal(t, "aptos", got["network"]) + require.Equal(t, true, got["isLocal"]) + require.EqualValues(t, (2500 * time.Millisecond).Nanoseconds(), got["deltaStage"]) + require.Equal(t, map[string]any{"peer-a": "0x1"}, got[specConfigP2PMapKey]) +} + +func TestNormalizeTransmitter(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "short address is normalized", + input: "0xa", + want: "0x000000000000000000000000000000000000000000000000000000000000000a", + }, + { + name: "whitespace is trimmed", + input: " 0xB ", + want: "0x000000000000000000000000000000000000000000000000000000000000000b", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := normalizeTransmitter(tc.input) + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } + + _, err := normalizeTransmitter("not-an-address") + require.Error(t, err) +} + +func TestP2PToTransmitterMapForWorkers(t *testing.T) { + key := p2pkey.MustNewV2XXXTestingOnly(big.NewInt(1)) + workers := []*cre.NodeMetadata{ + { + Keys: &secrets.NodeKeys{ + P2PKey: &crypto.P2PKey{ + PeerID: key.PeerID(), + }, + Aptos: &crypto.AptosKey{ + Account: "0xa", + }, + }, + }, + } + + got, err := p2pToTransmitterMapForWorkers(workers) + require.NoError(t, err) + + peerID := key.PeerID() + expectedPeerKey := hex.EncodeToString(peerID[:]) + require.Equal(t, map[string]string{ + expectedPeerKey: "0x000000000000000000000000000000000000000000000000000000000000000a", + }, got) +} + +func TestResolveMethodConfigSettings_Defaults(t *testing.T) { + settings, err := resolveMethodConfigSettings(nil) + require.NoError(t, err) + require.Equal(t, defaultRequestTimeout, settings.RequestTimeout) + require.Equal(t, defaultWriteDeltaStage, settings.DeltaStage) + require.Equal(t, capabilitiespb.TransmissionSchedule_AllAtOnce, settings.TransmissionSchedule) +} + +func TestResolveMethodConfigSettings_Overrides(t *testing.T) { + settings, err := resolveMethodConfigSettings(map[string]any{ + requestTimeoutKey: "45s", + deltaStageKey: "2500ms", + transmissionScheduleKey: "oneAtATime", + }) + require.NoError(t, err) + require.Equal(t, 45*time.Second, settings.RequestTimeout) + require.Equal(t, 2500*time.Millisecond, settings.DeltaStage) + require.Equal(t, capabilitiespb.TransmissionSchedule_OneAtATime, settings.TransmissionSchedule) +} + +func TestResolveMethodConfigSettings_InvalidDuration(t *testing.T) { + _, err := resolveMethodConfigSettings(map[string]any{ + requestTimeoutKey: "not-a-duration", + }) + require.Error(t, err) +} + +func TestResolveMethodConfigSettings_InvalidTransmissionSchedule(t *testing.T) { + _, err := resolveMethodConfigSettings(map[string]any{ + transmissionScheduleKey: "staggered", + }) + require.Error(t, err) +} diff --git a/system-tests/lib/cre/features/consensus/v2/consensus.go b/system-tests/lib/cre/features/consensus/v2/consensus.go index ee1ca7e5e6f..1936400a657 100644 --- a/system-tests/lib/cre/features/consensus/v2/consensus.go +++ b/system-tests/lib/cre/features/consensus/v2/consensus.go @@ -65,6 +65,10 @@ func (c *Consensus) PreEnvStartup( CapabilityToOCR3Config: map[string]*ocr3.OracleConfig{ consensusLabelledName: contracts.DefaultOCR3Config(), }, + CapabilityToExtraSignerFamilies: cre.CapabilityToExtraSignerFamilies( + cre.OCRExtraSignerFamilies(creEnv.Blockchains), + consensusLabelledName, + ), }, nil } @@ -221,8 +225,13 @@ func proposeNodeJob(creEnv *cre.Environment, don *cre.Don, command string, boots inputs["config"] = configStr } - // Add Solana chain selector if present + // Add non-EVM OCR selectors when present so consensus can select the correct + // offchain key bundle path for report generation. for _, blockchain := range creEnv.Blockchains { + if blockchain.IsFamily(chainselectors.FamilyAptos) { + inputs["chainSelectorAptos"] = blockchain.ChainSelector() + continue + } if blockchain.IsFamily(chainselectors.FamilySolana) { inputs["chainSelectorSolana"] = blockchain.ChainSelector() break @@ -249,6 +258,12 @@ func proposeNodeJob(creEnv *cre.Environment, don *cre.Don, command string, boots report, applyErr := proposer.Apply(*creEnv.CldfEnvironment, input) if applyErr != nil { + if strings.Contains(applyErr.Error(), "no aptos ocr2 config for node") { + return nil, fmt.Errorf( + "failed to propose Consensus v2 node job spec: %w; Aptos workflows require Aptos OCR2 key bundles on all workflow DON nodes", + applyErr, + ) + } return nil, fmt.Errorf("failed to propose Consensus v2 node job spec: %w", applyErr) } diff --git a/system-tests/lib/cre/features/evm/v2/evm.go b/system-tests/lib/cre/features/evm/v2/evm.go index bfd2602feff..4cb06e319fb 100644 --- a/system-tests/lib/cre/features/evm/v2/evm.go +++ b/system-tests/lib/cre/features/evm/v2/evm.go @@ -127,13 +127,19 @@ func (o *EVM) PreEnvStartup( } capabilityToOCR3Config := make(map[string]*ocr3.OracleConfig, len(capabilities)) + capabilityLabels := make([]string, 0, len(capabilities)) for _, cap := range capabilities { capabilityToOCR3Config[cap.Capability.LabelledName] = contracts.DefaultChainCapabilityOCR3Config() + capabilityLabels = append(capabilityLabels, cap.Capability.LabelledName) } return &cre.PreEnvStartupOutput{ DONCapabilityWithConfig: capabilities, CapabilityToOCR3Config: capabilityToOCR3Config, + CapabilityToExtraSignerFamilies: cre.CapabilityToExtraSignerFamilies( + cre.OCRExtraSignerFamilies(creEnv.Blockchains), + capabilityLabels..., + ), }, nil } diff --git a/system-tests/lib/cre/features/read_contract/read_contract.go b/system-tests/lib/cre/features/read_contract/read_contract.go index 05aed9fa941..03334817659 100644 --- a/system-tests/lib/cre/features/read_contract/read_contract.go +++ b/system-tests/lib/cre/features/read_contract/read_contract.go @@ -12,6 +12,7 @@ import ( capabilitiespb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/pb" kcr "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/capabilities_registry_1_1_0" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" cre_jobs "github.com/smartcontractkit/chainlink/deployment/cre/jobs" cre_jobs_ops "github.com/smartcontractkit/chainlink/deployment/cre/jobs/operations" job_types "github.com/smartcontractkit/chainlink/deployment/cre/jobs/types" @@ -22,6 +23,8 @@ import ( credon "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don/jobs" "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don/jobs/standardcapability" + creblockchains "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" + aptosfeature "github.com/smartcontractkit/chainlink/system-tests/lib/cre/features/aptos" ) const flag = cre.ReadContractCapability @@ -46,15 +49,40 @@ func (o *ReadContract) PreEnvStartup( } for _, chainID := range enabledChainIDs { + bc, findErr := findBlockchainByChainID(creEnv, chainID) + if findErr != nil { + return nil, findErr + } + + labelledName, skip, labelErr := capabilityLabelForChain(don, creEnv, chainID) + if labelErr != nil { + return nil, labelErr + } + if skip { + continue + } + + capConfig := &capabilitiespb.CapabilityConfig{ + LocalOnly: don.HasOnlyLocalCapabilities(), + } + if bc.IsFamily(blockchain.FamilyAptos) { + capabilityConfig, resolveErr := cre.ResolveCapabilityConfig(don.MustNodeSet(), flag, cre.ChainCapabilityScope(chainID)) + if resolveErr != nil { + return nil, fmt.Errorf("could not resolve capability config for '%s' on chain %d: %w", flag, chainID, resolveErr) + } + capConfig, err = aptosfeature.BuildCapabilityConfig(capabilityConfig.Values, nil, don.HasOnlyLocalCapabilities()) + if err != nil { + return nil, fmt.Errorf("failed to build Aptos read capability config for chain %d: %w", chainID, err) + } + } + capabilities = append(capabilities, keystone_changeset.DONCapabilityWithConfig{ Capability: kcr.CapabilitiesRegistryCapability{ - LabelledName: fmt.Sprintf("read-contract-evm-%d", chainID), + LabelledName: labelledName, Version: "1.0.0", CapabilityType: 1, // ACTION }, - Config: &capabilitiespb.CapabilityConfig{ - LocalOnly: don.HasOnlyLocalCapabilities(), - }, + Config: capConfig, }) } @@ -63,6 +91,38 @@ func (o *ReadContract) PreEnvStartup( }, nil } +func capabilityLabelForChain(don *cre.DonMetadata, creEnv *cre.Environment, chainID uint64) (string, bool, error) { + for _, bc := range creEnv.Blockchains { + if bc.ChainID() != chainID { + continue + } + + switch { + case bc.IsFamily(blockchain.FamilyAptos): + return aptosCapabilityLabel(don, bc) + case bc.IsFamily(blockchain.FamilyEVM), bc.IsFamily(blockchain.FamilyTron): + return fmt.Sprintf("read-contract-evm-%d", chainID), false, nil + default: + return "", false, fmt.Errorf("read-contract is not supported for chain family %s on chainID %d", bc.ChainFamily(), chainID) + } + } + + return "", false, fmt.Errorf("could not find blockchain for read-contract chainID %d", chainID) +} + +func aptosCapabilityLabel(don *cre.DonMetadata, bc blockchainOutput) (string, bool, error) { + // The Aptos feature owns capability registration when Aptos write is enabled on the DON. + if don.HasFlag(cre.WriteAptosCapability) { + return "", true, nil + } + return aptosfeature.CapabilityLabel(bc.ChainSelector()), false, nil +} + +type blockchainOutput interface { + ChainSelector() uint64 + ChainFamily() string +} + const configTemplate = `{"chainId":{{printf "%d" .ChainID}},"network":"{{.NetworkFamily}}"}` func (o *ReadContract) PostEnvStartup( @@ -91,6 +151,17 @@ func (o *ReadContract) PostEnvStartup( } for _, chainID := range enabledChainIDs { + blockchainOutput, findErr := findBlockchainByChainID(creEnv, chainID) + if findErr != nil { + return findErr + } + // Aptos write owns the Aptos ReadContract worker jobs because it needs the + // Aptos-specific OCR/bootstrap inputs that the generic read-contract path + // does not supply. Skip the duplicate generic proposal on those DONs. + if blockchainOutput.IsFamily(blockchain.FamilyAptos) && don.HasFlag(cre.WriteAptosCapability) { + continue + } + capabilityConfig, resolveErr := cre.ResolveCapabilityConfig(nodeSet, flag, cre.ChainCapabilityScope(chainID)) if resolveErr != nil { return fmt.Errorf("could not resolve capability config for '%s' on chain %d: %w", flag, chainID, resolveErr) @@ -157,10 +228,22 @@ func (o *ReadContract) PostEnvStartup( } } - approveErr := jobs.Approve(ctx, creEnv.CldfEnvironment.Offchain, dons, specs) - if approveErr != nil { - return fmt.Errorf("failed to approve Read Contract jobs: %w", approveErr) + if len(specs) > 0 { + approveErr := jobs.Approve(ctx, creEnv.CldfEnvironment.Offchain, dons, specs) + if approveErr != nil { + return fmt.Errorf("failed to approve Read Contract jobs: %w", approveErr) + } } return nil } + +func findBlockchainByChainID(creEnv *cre.Environment, chainID uint64) (creblockchains.Blockchain, error) { + for _, bc := range creEnv.Blockchains { + if bc.ChainID() == chainID { + return bc, nil + } + } + + return nil, fmt.Errorf("could not find blockchain for read-contract chainID %d", chainID) +} diff --git a/system-tests/lib/cre/features/read_contract/read_contract_test.go b/system-tests/lib/cre/features/read_contract/read_contract_test.go new file mode 100644 index 00000000000..ece08b19057 --- /dev/null +++ b/system-tests/lib/cre/features/read_contract/read_contract_test.go @@ -0,0 +1,45 @@ +package readcontract + +import ( + "testing" + + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + aptosfeature "github.com/smartcontractkit/chainlink/system-tests/lib/cre/features/aptos" +) + +type blockchainOutputStub struct { + chainSelector uint64 + chainFamily string +} + +func (s blockchainOutputStub) ChainSelector() uint64 { + return s.chainSelector +} + +func (s blockchainOutputStub) ChainFamily() string { + return s.chainFamily +} + +func TestAptosCapabilityLabel(t *testing.T) { + bc := blockchainOutputStub{chainSelector: 1, chainFamily: chainselectors.FamilyAptos} + + t.Run("skips aptos when write aptos feature owns the don", func(t *testing.T) { + don := &cre.DonMetadata{Flags: []string{cre.ReadContractCapability, cre.WriteAptosCapability}} + label, skip, err := aptosCapabilityLabel(don, bc) + require.NoError(t, err) + require.Empty(t, label) + require.True(t, skip) + }) + + t.Run("uses aptos label for read-only dons", func(t *testing.T) { + don := &cre.DonMetadata{Flags: []string{cre.ReadContractCapability}} + label, skip, err := aptosCapabilityLabel(don, bc) + require.NoError(t, err) + require.Equal(t, aptosfeature.CapabilityLabel(1), label) + require.False(t, skip) + }) +} diff --git a/system-tests/lib/cre/features/sets/sets.go b/system-tests/lib/cre/features/sets/sets.go index f29a5ad9be8..73d4c5e373e 100644 --- a/system-tests/lib/cre/features/sets/sets.go +++ b/system-tests/lib/cre/features/sets/sets.go @@ -2,6 +2,7 @@ package sets import ( "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + aptos_feature "github.com/smartcontractkit/chainlink/system-tests/lib/cre/features/aptos" consensus_v1_feature "github.com/smartcontractkit/chainlink/system-tests/lib/cre/features/consensus/v1" consensus_v2_feature "github.com/smartcontractkit/chainlink/system-tests/lib/cre/features/consensus/v2" cron_feature "github.com/smartcontractkit/chainlink/system-tests/lib/cre/features/cron" @@ -33,6 +34,7 @@ func New() cre.Features { &http_trigger_feature.HTTPTrigger{}, &log_event_trigger_feature.LogEventTrigger{}, &mock_feature.Mock{}, + &aptos_feature.Aptos{}, &read_contract_feature.ReadContract{}, &web_api_target_feature.WebAPITarget{}, &web_api_trigger_feature.WebAPITrigger{}, diff --git a/system-tests/lib/cre/features/solana/v2/solana.go b/system-tests/lib/cre/features/solana/v2/solana.go index 91b2519ec70..e333f061aab 100644 --- a/system-tests/lib/cre/features/solana/v2/solana.go +++ b/system-tests/lib/cre/features/solana/v2/solana.go @@ -103,10 +103,14 @@ func (s *Solana) PreEnvStartup( } // 4. Register Solana capability & its methods with Keystone capabilities := registerSolanaCapability(solChain.ChainSelector()) + capabilityToExtraSignerFamilies := make(map[string][]string, len(capabilities)) + for _, capability := range capabilities { + capabilityToExtraSignerFamilies[capability.Capability.LabelledName] = []string{chainselectors.FamilySolana} + } return &cre.PreEnvStartupOutput{ - DONCapabilityWithConfig: capabilities, - ExtraSignerFamilies: []string{chainselectors.FamilySolana}, + DONCapabilityWithConfig: capabilities, + CapabilityToExtraSignerFamilies: capabilityToExtraSignerFamilies, }, nil } diff --git a/system-tests/lib/cre/flags/flags.go b/system-tests/lib/cre/flags/flags.go index 9640af140f9..ab3d568bf1b 100644 --- a/system-tests/lib/cre/flags/flags.go +++ b/system-tests/lib/cre/flags/flags.go @@ -42,7 +42,10 @@ func HasFlagForAnyChain(values []string, capability string) bool { } func RequiresForwarderContract(values []string, chainID uint64) bool { - return HasFlagForChain(values, cre.EVMCapability, chainID) || HasFlagForChain(values, cre.WriteEVMCapability, chainID) || HasFlagForAnyChain(values, cre.SolanaCapability) + return HasFlagForChain(values, cre.EVMCapability, chainID) || + HasFlagForChain(values, cre.WriteEVMCapability, chainID) || + HasFlagForChain(values, cre.WriteAptosCapability, chainID) || + HasFlagForAnyChain(values, cre.SolanaCapability) } func DonMetadataWithFlag(donTopologies []*cre.DonMetadata, flag string) []*cre.DonMetadata { diff --git a/system-tests/lib/cre/flags/flags_test.go b/system-tests/lib/cre/flags/flags_test.go new file mode 100644 index 00000000000..32593fc67b4 --- /dev/null +++ b/system-tests/lib/cre/flags/flags_test.go @@ -0,0 +1,25 @@ +package flags + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/system-tests/lib/cre" +) + +func TestRequiresForwarderContract(t *testing.T) { + t.Run("returns true for aptos write capability", func(t *testing.T) { + require.True(t, RequiresForwarderContract([]string{cre.WriteAptosCapability + "-4"}, 4)) + }) + + t.Run("returns true for evm and solana write paths", func(t *testing.T) { + require.True(t, RequiresForwarderContract([]string{cre.EVMCapability + "-1337"}, 1337)) + require.True(t, RequiresForwarderContract([]string{cre.WriteEVMCapability + "-1337"}, 1337)) + require.True(t, RequiresForwarderContract([]string{cre.SolanaCapability + "-1"}, 1)) + }) + + t.Run("returns false for read-only aptos capability set", func(t *testing.T) { + require.False(t, RequiresForwarderContract([]string{cre.ReadContractCapability + "-4"}, 4)) + }) +} diff --git a/system-tests/lib/cre/flags/provider.go b/system-tests/lib/cre/flags/provider.go index 7e38245f207..151123f1fa2 100644 --- a/system-tests/lib/cre/flags/provider.go +++ b/system-tests/lib/cre/flags/provider.go @@ -25,6 +25,7 @@ func NewDefaultCapabilityFlagsProvider() *DefaultCapbilityFlagsProvider { cre.WriteEVMCapability, cre.ReadContractCapability, cre.LogEventTriggerCapability, + cre.WriteAptosCapability, }, } } @@ -58,6 +59,7 @@ func NewExtensibleCapabilityFlagsProvider(extraGlobalFlags []string) *Extensible cre.SolanaCapability, cre.ReadContractCapability, cre.LogEventTriggerCapability, + cre.WriteAptosCapability, }, } } @@ -89,6 +91,7 @@ func NewSwappableCapabilityFlagsProvider() *DefaultCapbilityFlagsProvider { cre.ReadContractCapability, cre.LogEventTriggerCapability, cre.SolanaCapability, + cre.WriteAptosCapability, }, } } diff --git a/system-tests/lib/cre/ocr_extra_signer_families.go b/system-tests/lib/cre/ocr_extra_signer_families.go new file mode 100644 index 00000000000..ba9e22255dd --- /dev/null +++ b/system-tests/lib/cre/ocr_extra_signer_families.go @@ -0,0 +1,44 @@ +package cre + +import ( + "slices" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" +) + +// OCRExtraSignerFamilies returns the additional signer families that should be +// included in OCR3 config generation beyond the default EVM signer family. +func OCRExtraSignerFamilies(blockchains []blockchains.Blockchain) []string { + familiesSet := make(map[string]struct{}) + for _, blockchain := range blockchains { + switch { + case blockchain.IsFamily(chainselectors.FamilyAptos): + familiesSet[chainselectors.FamilyAptos] = struct{}{} + case blockchain.IsFamily(chainselectors.FamilySolana): + familiesSet[chainselectors.FamilySolana] = struct{}{} + } + } + + families := make([]string, 0, len(familiesSet)) + for family := range familiesSet { + families = append(families, family) + } + slices.Sort(families) + + return families +} + +func CapabilityToExtraSignerFamilies(families []string, labelledNames ...string) map[string][]string { + if len(families) == 0 || len(labelledNames) == 0 { + return nil + } + + capabilityToFamilies := make(map[string][]string, len(labelledNames)) + for _, labelledName := range labelledNames { + capabilityToFamilies[labelledName] = append([]string(nil), families...) + } + + return capabilityToFamilies +} diff --git a/system-tests/lib/cre/types.go b/system-tests/lib/cre/types.go index 84a9da7074d..efc0ca18aa4 100644 --- a/system-tests/lib/cre/types.go +++ b/system-tests/lib/cre/types.go @@ -70,6 +70,7 @@ const ( HTTPTriggerCapability CapabilityFlag = "http-trigger" HTTPActionCapability CapabilityFlag = "http-action" SolanaCapability CapabilityFlag = "solana" + WriteAptosCapability CapabilityFlag = "write-aptos" // Add more capabilities as needed ) @@ -229,9 +230,10 @@ type CapabilityConfig struct { } // mergeCapabilityConfigs copies entries from src to dst only for keys that -// do not already exist in dst. This is NOT a deep merge - if a key exists -// in dst, its entire CapabilityConfig is preserved without modification. -// Users who override a capability config must provide all required values. +// do not already exist in dst. This is NOT a deep merge - when a key exists +// in dst, only BinaryName may be backfilled from src and Values are preserved +// exactly as provided by the override. Users who override a capability config +// must still provide all required Values. func mergeCapabilityConfigs(dst, src CapabilityConfigs) { for srcKey, srcValue := range src { if dstValue, exists := dst[srcKey]; !exists { @@ -395,9 +397,10 @@ type ConfigureCapabilityRegistryInput struct { // keyed by LabelledName CapabilityToOCR3Config map[string]*ocr3.OracleConfig - // Non-EVM chain families whose signing keys should be included in OCR3 - // config signers (e.g. ["solana"]). EVM is always included. - ExtraSignerFamilies []string + // keyed by LabelledName. Non-EVM chain families whose signing keys should be + // included in OCR3 config signers for that capability (e.g. ["solana"]). + // EVM is always included. + CapabilityToExtraSignerFamilies map[string][]string } func (c *ConfigureCapabilityRegistryInput) Validate() error { @@ -559,11 +562,16 @@ type DonMetadata struct { func NewDonMetadata(c *NodeSet, id uint64, provider infra.Provider, capabilityConfigs map[CapabilityFlag]CapabilityConfig) (*DonMetadata, error) { cfgs := make([]NodeMetadataConfig, len(c.NodeSpecs)) + aptosChainIDs, err := c.GetEnabledChainIDsForCapability(WriteAptosCapability) + if err != nil { + return nil, fmt.Errorf("failed to resolve Aptos chain ids for node metadata: %w", err) + } for i, nodeSpec := range c.NodeSpecs { cfg := NodeMetadataConfig{ Keys: NodeKeyInput{ EVMChainIDs: c.EVMChains(), SolanaChainIDs: c.SupportedSolChains, + AptosChainIDs: aptosChainIDs, Password: "dev-password", ImportedSecrets: nodeSpec.Node.TestSecretsOverrides, }, @@ -1286,6 +1294,11 @@ func (c *NodeSet) chainCapabilityIDs() []uint64 { return out } +// ChainCapabilityChainIDs returns the set of chain IDs supported by this node set's chain-scoped capabilities (e.g. read-contract-4, write-aptos-4). +func (c *NodeSet) ChainCapabilityChainIDs() []uint64 { + return c.chainCapabilityIDs() +} + func (c *NodeSet) Flags() []string { var stringCaps []string @@ -1418,6 +1431,7 @@ func (c *NodeSet) MaxFaultyNodes() (uint32, error) { type NodeKeyInput struct { EVMChainIDs []uint64 SolanaChainIDs []string + AptosChainIDs []uint64 Password string ImportedSecrets string // raw JSON string of secrets to import (usually from a previous run) @@ -1434,6 +1448,9 @@ func NewNodeKeys(input NodeKeyInput) (*secrets.NodeKeys, error) { if err != nil { return nil, errors.Wrap(err, "failed to parse imported secrets") } + if len(input.AptosChainIDs) > 0 && importedKeys.Aptos == nil { + return nil, errors.New("imported secrets are missing an Aptos key; regenerate node secrets with Aptos support") + } return importedKeys, nil } @@ -1467,6 +1484,13 @@ func NewNodeKeys(input NodeKeyInput) (*secrets.NodeKeys, error) { } out.Solana[chainID] = k } + if len(input.AptosChainIDs) > 0 { + k, err := crypto.NewAptosKey(input.Password) + if err != nil { + return nil, fmt.Errorf("failed to generate Aptos key: %w", err) + } + out.Aptos = k + } return out, nil } @@ -1633,7 +1657,8 @@ type PreEnvStartupOutput struct { DONCapabilityWithConfig []keystone_changeset.DONCapabilityWithConfig // keyed by LabelledName CapabilityToOCR3Config map[string]*ocr3.OracleConfig - // Non-EVM chain families whose signing keys should be included in OCR3 - // config signers (e.g. ["solana"]). EVM is always included. - ExtraSignerFamilies []string + // keyed by LabelledName. Non-EVM chain families whose signing keys should be + // included in OCR3 config signers for that capability (e.g. ["solana"]). + // EVM is always included. + CapabilityToExtraSignerFamilies map[string][]string } diff --git a/system-tests/lib/cre/types_nodekeys_test.go b/system-tests/lib/cre/types_nodekeys_test.go new file mode 100644 index 00000000000..662a288fb04 --- /dev/null +++ b/system-tests/lib/cre/types_nodekeys_test.go @@ -0,0 +1,58 @@ +package cre + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/don/secrets" + "github.com/smartcontractkit/chainlink/system-tests/lib/crypto" +) + +func TestNewNodeKeys_IgnoresEmptyImportedAptosSecretWhenAptosDisabled(t *testing.T) { + t.Parallel() + + p2pKey, err := crypto.NewP2PKey("dev-password") + require.NoError(t, err) + dkgKey, err := crypto.NewDKGRecipientKey("dev-password") + require.NoError(t, err) + + baseSecrets, err := (&secrets.NodeKeys{ + P2PKey: p2pKey, + DKGKey: dkgKey, + EVM: map[uint64]*crypto.EVMKey{}, + Solana: map[string]*crypto.SolKey{}, + }).ToNodeSecretsTOML() + require.NoError(t, err) + + keys, err := NewNodeKeys(NodeKeyInput{ + ImportedSecrets: baseSecrets, + AptosChainIDs: nil, + }) + require.NoError(t, err) + require.Nil(t, keys.Aptos) + require.Equal(t, p2pKey.PeerID, keys.P2PKey.PeerID) +} + +func TestNewNodeKeys_RejectsMissingImportedAptosSecretWhenAptosEnabled(t *testing.T) { + t.Parallel() + + p2pKey, err := crypto.NewP2PKey("dev-password") + require.NoError(t, err) + dkgKey, err := crypto.NewDKGRecipientKey("dev-password") + require.NoError(t, err) + + baseSecrets, err := (&secrets.NodeKeys{ + P2PKey: p2pKey, + DKGKey: dkgKey, + EVM: map[uint64]*crypto.EVMKey{}, + Solana: map[string]*crypto.SolKey{}, + }).ToNodeSecretsTOML() + require.NoError(t, err) + + _, err = NewNodeKeys(NodeKeyInput{ + ImportedSecrets: baseSecrets, + AptosChainIDs: []uint64{4}, + }) + require.ErrorContains(t, err, "missing an Aptos key") +} diff --git a/system-tests/lib/crypto/aptos.go b/system-tests/lib/crypto/aptos.go new file mode 100644 index 00000000000..a3c76b42768 --- /dev/null +++ b/system-tests/lib/crypto/aptos.go @@ -0,0 +1,49 @@ +package crypto + +import ( + "fmt" + + aptossdk "github.com/aptos-labs/aptos-go-sdk" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/smartcontractkit/chainlink-common/keystore/corekeys/aptoskey" +) + +type AptosKey struct { + EncryptedJSON []byte + PublicKey string + Account string + Password string +} + +func NewAptosKey(password string) (*AptosKey, error) { + key, err := aptoskey.New() + if err != nil { + return nil, fmt.Errorf("failed to create aptos key: %w", err) + } + + enc, err := key.ToEncryptedJSON(password, keystore.DefaultScryptParams) + if err != nil { + return nil, fmt.Errorf("failed to encrypt aptos key: %w", err) + } + + account, err := NormalizeAptosAccount(key.Account()) + if err != nil { + return nil, fmt.Errorf("failed to normalize aptos account: %w", err) + } + + return &AptosKey{ + EncryptedJSON: enc, + PublicKey: key.PublicKeyStr(), + Account: account, + Password: password, + }, nil +} + +func NormalizeAptosAccount(raw string) (string, error) { + var addr aptossdk.AccountAddress + if err := addr.ParseStringRelaxed(raw); err != nil { + return "", err + } + return addr.StringLong(), nil +} diff --git a/system-tests/lib/go.mod b/system-tests/lib/go.mod index b122b79ca02..7db360c4f4f 100644 --- a/system-tests/lib/go.mod +++ b/system-tests/lib/go.mod @@ -16,12 +16,14 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/alitto/pond/v2 v2.5.0 github.com/andybalholm/brotli v1.2.0 + github.com/aptos-labs/aptos-go-sdk v1.12.1-0.20260318141106-21b6ef4ed363 github.com/cockroachdb/errors v1.11.3 github.com/cosmos/gogoproto v1.7.0 github.com/docker/docker v28.5.2+incompatible github.com/ethereum/go-ethereum v1.17.1 github.com/fbsobreira/gotron-sdk v0.0.0-20250403083053-2943ce8c759b github.com/gagliardetto/solana-go v1.13.0 + github.com/go-resty/resty/v2 v2.17.2 github.com/goccy/go-yaml v1.19.2 github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 @@ -31,6 +33,7 @@ require ( github.com/scylladb/go-reflectx v1.0.1 github.com/sethvargo/go-retry v0.3.0 github.com/smartcontractkit/chain-selectors v1.0.97 + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288 github.com/smartcontractkit/chainlink-common v0.11.2-0.20260326163134-c8e0d77df421 github.com/smartcontractkit/chainlink-common/keystore v1.0.2 @@ -94,7 +97,6 @@ require ( github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/apache/arrow-go/v18 v18.3.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // 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/avast/retry-go v3.0.0+incompatible // indirect github.com/avast/retry-go/v4 v4.7.0 // indirect @@ -262,7 +264,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect - github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/webauthn v0.9.4 // indirect github.com/go-webauthn/x v0.1.5 // indirect @@ -449,7 +450,6 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20260129135848-c86808ba5cb9 // indirect github.com/smartcontractkit/ccip-owner-contracts v0.1.0 // indirect - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 // 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/ccv/chains/evm v0.0.0-20260323224438-d819cb3228e1 // indirect diff --git a/system-tests/lib/go.sum b/system-tests/lib/go.sum index 05aaa3292e5..167c2033006 100644 --- a/system-tests/lib/go.sum +++ b/system-tests/lib/go.sum @@ -1583,8 +1583,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-20260318173523-755cafb24200 h1:hXteBGuRfdFA5Zj3f07la22ttq6NohB3g5d4vsHFJZ0= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200/go.mod h1:CLrLo4q6s25t9IGSMn4P1tRkrZFGjRiLOWskwPJEXrc= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod index d5766e73fad..a2d33fd5083 100644 --- a/system-tests/tests/go.mod +++ b/system-tests/tests/go.mod @@ -23,6 +23,12 @@ replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/e replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/logtrigger => ./smoke/cre/evm/logtrigger +replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptosread => ./smoke/cre/aptos/aptosread + +replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite => ./smoke/cre/aptos/aptoswrite + +replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip => ./smoke/cre/aptos/aptoswriteroundtrip + replace github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/httpaction => ./smoke/cre/httpaction replace github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/consensus => ./regression/cre/consensus @@ -82,6 +88,8 @@ require ( github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/evmwrite-negative v0.0.0-20251008094352-f74459c46e8c github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/http v0.0.0-20251008094352-f74459c46e8c + github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite v0.0.0-00010101000000-000000000000 + github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/evmread v0.0.0-20251008094352-f74459c46e8c github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/logtrigger v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evmread v0.0.0-20250917232237-c4ecf802c6f8 @@ -192,7 +200,7 @@ require ( github.com/alitto/pond/v2 v2.5.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/apache/arrow-go/v18 v18.4.0 // indirect - github.com/aptos-labs/aptos-go-sdk v1.12.1-0.20260318141106-21b6ef4ed363 // indirect + github.com/aptos-labs/aptos-go-sdk v1.12.1-0.20260318141106-21b6ef4ed363 github.com/armon/go-metrics v0.4.1 // indirect github.com/atombender/go-jsonschema v0.16.1-0.20240916205339-a74cd4e2851c // indirect github.com/avast/retry-go v3.0.0+incompatible // indirect @@ -585,7 +593,7 @@ require ( github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20260129135848-c86808ba5cb9 // indirect github.com/smartcontractkit/ccip-owner-contracts v0.1.0 // indirect - github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 // indirect + github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 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-common/pkg/chipingress v0.0.11-0.20251211140724-319861e514c4 diff --git a/system-tests/tests/go.sum b/system-tests/tests/go.sum index 87696ed1917..ccf60adbdbb 100644 --- a/system-tests/tests/go.sum +++ b/system-tests/tests/go.sum @@ -1767,8 +1767,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-20260318173523-755cafb24200 h1:hXteBGuRfdFA5Zj3f07la22ttq6NohB3g5d4vsHFJZ0= -github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200/go.mod h1:CLrLo4q6s25t9IGSMn4P1tRkrZFGjRiLOWskwPJEXrc= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698 h1:goYYvVcQ3UV/Fw5R0V1jLJht/VXUxk5F8o+RwgSX9jo= +github.com/smartcontractkit/chainlink-aptos v0.0.0-20260324144720-484863604698/go.mod h1:khONNr+qqHzpZBjyTIXJEkcptm3pIOvke03ftAQibRw= github.com/smartcontractkit/chainlink-automation v0.8.1 h1:sTc9LKpBvcKPc1JDYAmgBc2xpDKBco/Q4h4ydl6+UUU= github.com/smartcontractkit/chainlink-automation v0.8.1/go.mod h1:Iij36PvWZ6blrdC5A/nrQUBuf3MH3JvsBB9sSyc9W08= github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 h1:bpzTG/8qwnbnIQPcilnM8lPd/Or4Q22cnakzawds2NQ= diff --git a/system-tests/tests/smoke/cre/aptos/aptosread/config/config.go b/system-tests/tests/smoke/cre/aptos/aptosread/config/config.go new file mode 100644 index 00000000000..a6b16c256db --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptosread/config/config.go @@ -0,0 +1,8 @@ +package config + +// Config for the Aptos read consensus workflow (reads 0x1::coin::name() on local devnet). +type Config struct { + ChainSelector uint64 `yaml:"chainSelector"` + WorkflowName string `yaml:"workflowName"` + ExpectedCoinName string `yaml:"expectedCoinName"` // expected exact value in the View reply data (e.g. "Aptos Coin" for 0x1::coin::name()) +} diff --git a/system-tests/tests/smoke/cre/aptos/aptosread/go.mod b/system-tests/tests/smoke/cre/aptos/aptosread/go.mod new file mode 100644 index 00000000000..92eaeaa1e11 --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptosread/go.mod @@ -0,0 +1,20 @@ +module github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptosread + +go 1.25.5 + +require ( + github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v0.10.0 + gopkg.in/yaml.v3 v3.0.1 +) + +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-20260325181729-0cac87f98cd4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/system-tests/tests/smoke/cre/aptos/aptosread/go.sum b/system-tests/tests/smoke/cre/aptos/aptosread/go.sum new file mode 100644 index 00000000000..32c4532781c --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptosread/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-20260325181729-0cac87f98cd4 h1:kqdSsgt2OzJnAk0io8GsA2lJE5hKlLM2EY4uy+R6H9Y= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260325181729-0cac87f98cd4/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37 h1:+awsWWPj1CWtvcDwU8QAkUvljo/YYpnKGDrZc2afYls= +github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37/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 v0.10.0 h1:g7UrVaNKVEmIhVkJTk4f8raCM8Kp/RTFnAT64wqNmTY= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v0.10.0/go.mod h1:PWyrIw16It4TSyq6mDXqmSR0jF2evZRKuBxu7pK1yDw= +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/system-tests/tests/smoke/cre/aptos/aptosread/main.go b/system-tests/tests/smoke/cre/aptos/aptosread/main.go new file mode 100644 index 00000000000..9a3ad82fc1d --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptosread/main.go @@ -0,0 +1,113 @@ +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos" + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + sdk "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + + "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptosread/config" +) + +var aptosCoinTypeTag = &aptos.TypeTag{ + Kind: aptos.TypeTagKind_TYPE_TAG_KIND_STRUCT, + Value: &aptos.TypeTag_Struct{ + Struct: &aptos.StructTag{ + Address: []byte{0x1}, + Module: "aptos_coin", + Name: "AptosCoin", + }, + }, +} + +func main() { + wasm.NewRunner(func(b []byte) (config.Config, error) { + cfg := config.Config{} + if err := yaml.Unmarshal(b, &cfg); err != nil { + return config.Config{}, fmt.Errorf("unmarshal config: %w", err) + } + return cfg, nil + }).Run(RunReadWorkflow) +} + +func RunReadWorkflow(cfg config.Config, logger *slog.Logger, secretsProvider sdk.SecretsProvider) (sdk.Workflow[config.Config], error) { + return sdk.Workflow[config.Config]{ + sdk.Handler( + cron.Trigger(&cron.Config{Schedule: "*/30 * * * * *"}), + onAptosReadTrigger, + ), + }, nil +} + +func onAptosReadTrigger(cfg config.Config, runtime sdk.Runtime, payload *cron.Payload) (_ any, err error) { + runtime.Logger().Info("onAptosReadTrigger called", "workflow", cfg.WorkflowName) + defer func() { + if r := recover(); r != nil { + runtime.Logger().Info("Aptos read failed: panic in onAptosReadTrigger", "workflow", cfg.WorkflowName, "panic", fmt.Sprintf("%v", r)) + err = fmt.Errorf("panic: %v", r) + } + }() + + client := aptos.Client{ChainSelector: cfg.ChainSelector} + reply, err := client.View(runtime, &aptos.ViewRequest{ + Payload: &aptos.ViewPayload{ + Module: &aptos.ModuleID{ + Address: []byte{0x1}, + Name: "coin", + }, + Function: "name", + ArgTypes: []*aptos.TypeTag{aptosCoinTypeTag}, + }, + }).Await() + if err != nil { + msg := fmt.Sprintf("Aptos read failed: View error: %v", err) + runtime.Logger().Info(msg, "workflow", cfg.WorkflowName, "chainSelector", cfg.ChainSelector) + return nil, fmt.Errorf("Aptos View(0x1::coin::name): %w", err) + } + if reply == nil { + runtime.Logger().Info("Aptos read failed: View reply is nil", "workflow", cfg.WorkflowName) + return nil, errors.New("View reply is nil") + } + if len(reply.Data) == 0 { + runtime.Logger().Info("Aptos read failed: View reply data is empty", "workflow", cfg.WorkflowName) + return nil, errors.New("View reply data is empty") + } + + onchainValue, parseErr := parseSingleStringViewReply(reply.Data) + if parseErr != nil { + msg := fmt.Sprintf("Aptos read failed: cannot parse view reply data %q: %v", string(reply.Data), parseErr) + runtime.Logger().Info(msg, "workflow", cfg.WorkflowName) + return nil, fmt.Errorf("invalid Aptos view reply payload: %w", parseErr) + } + + if onchainValue != cfg.ExpectedCoinName { + msg := fmt.Sprintf("Aptos read failed: onchain value %q does not match expected %q", onchainValue, cfg.ExpectedCoinName) + runtime.Logger().Info(msg, "workflow", cfg.WorkflowName) + return nil, fmt.Errorf("onchain value %q does not match expected %q", onchainValue, cfg.ExpectedCoinName) + } + + msg := "Aptos read consensus succeeded" + runtime.Logger().Info(msg, "onchain_value", strings.TrimSpace(onchainValue), "workflow", cfg.WorkflowName) + return nil, nil +} + +func parseSingleStringViewReply(data []byte) (string, error) { + var values []string + if err := json.Unmarshal(data, &values); err != nil { + return "", fmt.Errorf("decode json string array: %w", err) + } + if len(values) == 0 { + return "", errors.New("empty json array") + } + return values[0], nil +} diff --git a/system-tests/tests/smoke/cre/aptos/aptoswrite/config/config.go b/system-tests/tests/smoke/cre/aptos/aptoswrite/config/config.go new file mode 100644 index 00000000000..bffa8499395 --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswrite/config/config.go @@ -0,0 +1,18 @@ +package config + +// Config for Aptos write workflow (submits a report via the Aptos write capability). +type Config struct { + ChainSelector uint64 `yaml:"chainSelector"` + WorkflowName string `yaml:"workflowName"` + ReceiverHex string `yaml:"receiverHex"` + ReportMessage string `yaml:"reportMessage"` + // When true, the workflow expects WriteReport to return a non-success tx status and treats that as success. + ExpectFailure bool `yaml:"expectFailure"` + // Number of OCR signatures to include in the submitted report (forwarder expects f+1). + RequiredSignatures int `yaml:"requiredSignatures"` + // Optional hex-encoded payload to pass through OCR report generation. + // If empty, ReportMessage bytes are used. + ReportPayloadHex string `yaml:"reportPayloadHex"` + MaxGasAmount uint64 `yaml:"maxGasAmount"` + GasUnitPrice uint64 `yaml:"gasUnitPrice"` +} diff --git a/system-tests/tests/smoke/cre/aptos/aptoswrite/go.mod b/system-tests/tests/smoke/cre/aptos/aptoswrite/go.mod new file mode 100644 index 00000000000..588331939b0 --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswrite/go.mod @@ -0,0 +1,20 @@ +module github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite + +go 1.25.5 + +require ( + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260325181729-0cac87f98cd4 + github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v0.10.0 + gopkg.in/yaml.v3 v3.0.1 +) + +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/stretchr/testify v1.11.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/system-tests/tests/smoke/cre/aptos/aptoswrite/go.sum b/system-tests/tests/smoke/cre/aptos/aptoswrite/go.sum new file mode 100644 index 00000000000..32c4532781c --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswrite/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-20260325181729-0cac87f98cd4 h1:kqdSsgt2OzJnAk0io8GsA2lJE5hKlLM2EY4uy+R6H9Y= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260325181729-0cac87f98cd4/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37 h1:+awsWWPj1CWtvcDwU8QAkUvljo/YYpnKGDrZc2afYls= +github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37/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 v0.10.0 h1:g7UrVaNKVEmIhVkJTk4f8raCM8Kp/RTFnAT64wqNmTY= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v0.10.0/go.mod h1:PWyrIw16It4TSyq6mDXqmSR0jF2evZRKuBxu7pK1yDw= +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/system-tests/tests/smoke/cre/aptos/aptoswrite/main.go b/system-tests/tests/smoke/cre/aptos/aptoswrite/main.go new file mode 100644 index 00000000000..41275dcbb26 --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswrite/main.go @@ -0,0 +1,285 @@ +//go:build wasip1 + +package main + +import ( + "encoding/hex" + "fmt" + "log/slog" + "regexp" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos" + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + sdk "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" + "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite/config" +) + +func main() { + wasm.NewRunner(func(b []byte) (config.Config, error) { + cfg := config.Config{} + if err := yaml.Unmarshal(b, &cfg); err != nil { + return config.Config{}, fmt.Errorf("unmarshal config: %w", err) + } + return cfg, nil + }).Run(RunAptosWriteWorkflow) +} + +func RunAptosWriteWorkflow(cfg config.Config, logger *slog.Logger, secretsProvider sdk.SecretsProvider) (sdk.Workflow[config.Config], error) { + return sdk.Workflow[config.Config]{ + sdk.Handler( + cron.Trigger(&cron.Config{Schedule: "*/30 * * * * *"}), + onAptosWriteTrigger, + ), + }, nil +} + +func onAptosWriteTrigger(cfg config.Config, runtime sdk.Runtime, payload *cron.Payload) (_ any, err error) { + runtime.Logger().Info("onAptosWriteTrigger called", "workflow", cfg.WorkflowName) + + receiver, err := decodeAptosAddressHex(cfg.ReceiverHex) + if err != nil { + msg := fmt.Sprintf("Aptos write failed: invalid receiver address: %v", err) + runtime.Logger().Info(msg, "workflow", cfg.WorkflowName) + return nil, err + } + + reportPayload, err := resolveReportPayload(cfg) + if err != nil { + failMsg := fmt.Sprintf("Aptos write failed: invalid report payload: %v", err) + runtime.Logger().Info(failMsg, "workflow", cfg.WorkflowName) + return nil, err + } + + report, err := runtime.GenerateReport(&sdkpb.ReportRequest{ + EncodedPayload: reportPayload, + // Select Aptos key bundle path in consensus report generation. + EncoderName: "aptos", + // Aptos forwarder verifies ed25519 signatures over blake2b_256(raw_report). + SigningAlgo: "ed25519", + HashingAlgo: "blake2b_256", + }).Await() + if err != nil { + failMsg := fmt.Sprintf("Aptos write failed: generate report error: %v", err) + runtime.Logger().Info(failMsg, "workflow", cfg.WorkflowName) + return nil, err + } + reportResp := report.X_GeneratedCodeOnly_Unwrap() + if len(reportResp.ReportContext) == 0 { + err := fmt.Errorf("missing report context from generated report") + runtime.Logger().Info("Aptos write failed: missing report context", "workflow", cfg.WorkflowName, "error", err.Error()) + return nil, err + } + if len(reportResp.ReportContext) != 96 { + err := fmt.Errorf("unexpected report context length: got=%d want=96", len(reportResp.ReportContext)) + runtime.Logger().Info("Aptos write failed: invalid report context length", "workflow", cfg.WorkflowName, "error", err.Error()) + return nil, err + } + if len(reportResp.RawReport) == 0 { + err := fmt.Errorf("missing raw report from generated report") + runtime.Logger().Info("Aptos write failed: missing raw report", "workflow", cfg.WorkflowName, "error", err.Error()) + return nil, err + } + // Preserve generated report bytes as-is; Aptos capability handles wire-format packing. + reportVersion := int(reportResp.RawReport[0]) + runtime.Logger().Info( + "Aptos write: generated report details", + "workflow", cfg.WorkflowName, + "reportContextLen", len(reportResp.ReportContext), + "rawReportLen", len(reportResp.RawReport), + "reportVersion", reportVersion, + ) + + runtime.Logger().Info( + "Aptos write: generated report", + "workflow", cfg.WorkflowName, + "sigCount", len(reportResp.Sigs), + ) + if len(reportResp.Sigs) > 0 { + runtime.Logger().Info( + "Aptos write: first signature details", + "workflow", cfg.WorkflowName, + "firstSigLen", len(reportResp.Sigs[0].Signature), + "firstSignerID", reportResp.Sigs[0].SignerId, + ) + } + requiredSignatures := cfg.RequiredSignatures + if requiredSignatures <= 0 { + requiredSignatures = len(reportResp.Sigs) + } + if len(reportResp.Sigs) > requiredSignatures { + reportResp.Sigs = reportResp.Sigs[:requiredSignatures] + runtime.Logger().Info( + "Aptos write: trimmed report signatures for forwarder", + "workflow", cfg.WorkflowName, + "requiredSignatures", requiredSignatures, + "sigCount", len(reportResp.Sigs), + ) + } + if len(reportResp.Sigs) < requiredSignatures { + err := fmt.Errorf("insufficient report signatures: have=%d need=%d", len(reportResp.Sigs), requiredSignatures) + runtime.Logger().Info("Aptos write failed: report has fewer signatures than required", "workflow", cfg.WorkflowName, "error", err.Error()) + return nil, err + } + + client := aptos.Client{ChainSelector: cfg.ChainSelector} + runtime.Logger().Info( + "Aptos write: using gas config", + "workflow", cfg.WorkflowName, + "chainSelector", cfg.ChainSelector, + "maxGasAmount", cfg.MaxGasAmount, + "gasUnitPrice", cfg.GasUnitPrice, + ) + reply, err := client.WriteReport(runtime, &aptos.WriteReportRequest{ + Receiver: receiver, + Report: reportResp, + GasConfig: &aptos.GasConfig{ + MaxGasAmount: cfg.MaxGasAmount, + GasUnitPrice: cfg.GasUnitPrice, + }, + }).Await() + if err != nil { + if cfg.ExpectFailure { + runtime.Logger().Info( + "Aptos write failed: expected failure path requires non-empty failed tx hash", + "workflow", cfg.WorkflowName, + "txStatus", "call_error", + "txHash", "", + "error", err.Error(), + ) + return nil, fmt.Errorf("expected failed tx hash in WriteReport reply, got error instead: %w", err) + } + failMsg := fmt.Sprintf("Aptos write failed: WriteReport error: %v", err) + runtime.Logger().Info(failMsg, "workflow", cfg.WorkflowName, "chainSelector", cfg.ChainSelector) + return nil, err + } + if reply == nil { + runtime.Logger().Info("Aptos write failed: WriteReport reply is nil", "workflow", cfg.WorkflowName) + return nil, fmt.Errorf("nil WriteReport reply") + } + if cfg.ExpectFailure { + if reply.TxStatus == aptos.TxStatus_TX_STATUS_SUCCESS { + errorMsg := "" + if reply.ErrorMessage != nil { + errorMsg = *reply.ErrorMessage + } + runtime.Logger().Info( + "Aptos write failed: expected non-success tx status", + "workflow", cfg.WorkflowName, + "txStatus", reply.TxStatus.String(), + "error", errorMsg, + ) + return nil, fmt.Errorf("expected non-success tx status, got %s", reply.TxStatus.String()) + } + txHashRaw := reply.GetTxHash() + if txHashRaw == "" { + runtime.Logger().Info( + "Aptos write failed: expected failed tx hash but got empty hash", + "workflow", cfg.WorkflowName, + ) + return nil, fmt.Errorf("expected failed tx hash in WriteReport reply") + } + + txHash, err := normalizeTxHash(txHashRaw) + if err != nil { + runtime.Logger().Info("Aptos write failed: invalid failed tx hash format", "workflow", cfg.WorkflowName, "error", err.Error()) + return nil, fmt.Errorf("invalid failed tx hash format: %w", err) + } + + errorMsg := "" + if reply.ErrorMessage != nil { + errorMsg = *reply.ErrorMessage + } + runtime.Logger().Info( + fmt.Sprintf("Aptos write failure observed as expected txHash=%s", txHash), + "workflow", cfg.WorkflowName, + "txStatus", reply.TxStatus.String(), + "txHash", txHash, + "error", errorMsg, + ) + return nil, nil + } + if reply.TxStatus != aptos.TxStatus_TX_STATUS_SUCCESS { + errorMsg := "" + if reply.ErrorMessage != nil { + errorMsg = *reply.ErrorMessage + } + failMsg := fmt.Sprintf("Aptos write failed: tx status=%s error=%s", reply.TxStatus.String(), errorMsg) + runtime.Logger().Info(failMsg, "workflow", cfg.WorkflowName) + return nil, fmt.Errorf("unexpected tx status: %s", reply.TxStatus.String()) + } + txHashRaw := reply.GetTxHash() + if txHashRaw == "" { + runtime.Logger().Info( + "Aptos write failed: expected successful tx hash but got empty hash", + "workflow", cfg.WorkflowName, + "txStatus", reply.TxStatus.String(), + ) + return nil, fmt.Errorf("expected non-empty tx hash in successful WriteReport reply") + } + + txHash, err := normalizeTxHash(txHashRaw) + if err != nil { + runtime.Logger().Info("Aptos write failed: invalid tx hash format", "workflow", cfg.WorkflowName, "error", err.Error()) + return nil, fmt.Errorf("invalid tx hash format: %w", err) + } + + runtime.Logger().Info("Aptos write capability succeeded", "workflow", cfg.WorkflowName, "txHash", txHash) + return nil, nil +} + +func resolveReportPayload(cfg config.Config) ([]byte, error) { + if strings.TrimSpace(cfg.ReportPayloadHex) != "" { + trimmed := strings.TrimPrefix(strings.TrimSpace(cfg.ReportPayloadHex), "0x") + if trimmed == "" { + return nil, fmt.Errorf("empty hex payload") + } + raw, err := hex.DecodeString(trimmed) + if err != nil { + return nil, fmt.Errorf("decode hex payload: %w", err) + } + return raw, nil + } + + msg := cfg.ReportMessage + if msg == "" { + msg = "Aptos write workflow executed successfully" + } + return []byte(msg), nil +} + +func decodeAptosAddressHex(in string) ([]byte, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(in), "0x") + if trimmed == "" { + return nil, fmt.Errorf("empty address") + } + if len(trimmed)%2 != 0 { + trimmed = "0" + trimmed + } + raw, err := hex.DecodeString(trimmed) + if err != nil { + return nil, fmt.Errorf("decode hex address: %w", err) + } + if len(raw) > 32 { + return nil, fmt.Errorf("address too long: %d bytes", len(raw)) + } + out := make([]byte, 32) + copy(out[32-len(raw):], raw) + return out, nil +} + +var aptosHashRe = regexp.MustCompile(`^[0-9a-fA-F]{64}$`) + +func normalizeTxHash(raw string) (string, error) { + s := strings.TrimSpace(raw) + s = strings.TrimPrefix(strings.ToLower(s), "0x") + if !aptosHashRe.MatchString(s) { + return "", fmt.Errorf("expected 32-byte tx hash, got %q", raw) + } + return "0x" + s, nil +} diff --git a/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/config/config.go b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/config/config.go new file mode 100644 index 00000000000..0e1987859c7 --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/config/config.go @@ -0,0 +1,15 @@ +package config + +// Config for Aptos write->read roundtrip workflow. +// The workflow writes a benchmark report, then reads back get_feeds and validates the value. +type Config struct { + ChainSelector uint64 `yaml:"chainSelector"` + WorkflowName string `yaml:"workflowName"` + ReceiverHex string `yaml:"receiverHex"` + RequiredSignatures int `yaml:"requiredSignatures"` + ReportPayloadHex string `yaml:"reportPayloadHex"` + MaxGasAmount uint64 `yaml:"maxGasAmount"` + GasUnitPrice uint64 `yaml:"gasUnitPrice"` + FeedIDHex string `yaml:"feedIDHex"` + ExpectedBenchmark uint64 `yaml:"expectedBenchmark"` +} diff --git a/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/go.mod b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/go.mod new file mode 100644 index 00000000000..3acde03e5a6 --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/go.mod @@ -0,0 +1,20 @@ +module github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip + +go 1.25.5 + +require ( + github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260325181729-0cac87f98cd4 + github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37 + github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos v0.0.0-20260312154349-ecb4cb615f37 + github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v0.10.0 + gopkg.in/yaml.v3 v3.0.1 +) + +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/stretchr/testify v1.11.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/go.sum b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/go.sum new file mode 100644 index 00000000000..32c4532781c --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/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-20260325181729-0cac87f98cd4 h1:kqdSsgt2OzJnAk0io8GsA2lJE5hKlLM2EY4uy+R6H9Y= +github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260325181729-0cac87f98cd4/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37 h1:+awsWWPj1CWtvcDwU8QAkUvljo/YYpnKGDrZc2afYls= +github.com/smartcontractkit/cre-sdk-go v1.4.1-0.20260312154349-ecb4cb615f37/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 v0.10.0 h1:g7UrVaNKVEmIhVkJTk4f8raCM8Kp/RTFnAT64wqNmTY= +github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron v0.10.0/go.mod h1:PWyrIw16It4TSyq6mDXqmSR0jF2evZRKuBxu7pK1yDw= +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/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/main.go b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/main.go new file mode 100644 index 00000000000..5ca3cfa592b --- /dev/null +++ b/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/main.go @@ -0,0 +1,224 @@ +//go:build wasip1 + +package main + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/aptos" + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + sdk "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + + sdkpb "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" + "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/config" +) + +type feedEntry struct { + FeedID string `json:"feed_id"` + Feed struct { + Benchmark string `json:"benchmark"` + } `json:"feed"` +} + +func main() { + wasm.NewRunner(func(b []byte) (config.Config, error) { + cfg := config.Config{} + if err := yaml.Unmarshal(b, &cfg); err != nil { + return config.Config{}, fmt.Errorf("unmarshal config: %w", err) + } + return cfg, nil + }).Run(RunAptosWriteReadRoundtripWorkflow) +} + +func RunAptosWriteReadRoundtripWorkflow(cfg config.Config, logger *slog.Logger, secretsProvider sdk.SecretsProvider) (sdk.Workflow[config.Config], error) { + return sdk.Workflow[config.Config]{ + sdk.Handler( + cron.Trigger(&cron.Config{Schedule: "*/30 * * * * *"}), + onAptosWriteReadRoundtripTrigger, + ), + }, nil +} + +func onAptosWriteReadRoundtripTrigger(cfg config.Config, runtime sdk.Runtime, payload *cron.Payload) (_ any, err error) { + runtime.Logger().Info("onAptosWriteReadRoundtripTrigger called", "workflow", cfg.WorkflowName) + + receiverBytes, err := decodeAptosAddressHex(cfg.ReceiverHex) + if err != nil { + return nil, fmt.Errorf("invalid receiver address: %w", err) + } + + reportPayload, err := resolveReportPayload(cfg.ReportPayloadHex) + if err != nil { + return nil, fmt.Errorf("invalid report payload: %w", err) + } + + report, err := runtime.GenerateReport(&sdkpb.ReportRequest{ + EncodedPayload: reportPayload, + EncoderName: "aptos", + SigningAlgo: "ed25519", + HashingAlgo: "blake2b_256", + }).Await() + if err != nil { + return nil, fmt.Errorf("generate report error: %w", err) + } + reportResp := report.X_GeneratedCodeOnly_Unwrap() + if len(reportResp.ReportContext) != 96 { + return nil, fmt.Errorf("invalid report context length: got=%d want=96", len(reportResp.ReportContext)) + } + if len(reportResp.RawReport) == 0 { + return nil, fmt.Errorf("missing raw report") + } + + requiredSignatures := cfg.RequiredSignatures + if requiredSignatures <= 0 { + requiredSignatures = len(reportResp.Sigs) + } + if len(reportResp.Sigs) < requiredSignatures { + return nil, fmt.Errorf("insufficient report signatures: have=%d need=%d", len(reportResp.Sigs), requiredSignatures) + } + if len(reportResp.Sigs) > requiredSignatures { + reportResp.Sigs = reportResp.Sigs[:requiredSignatures] + } + + client := aptos.Client{ChainSelector: cfg.ChainSelector} + reply, err := client.WriteReport(runtime, &aptos.WriteReportRequest{ + Receiver: receiverBytes, + Report: reportResp, + GasConfig: &aptos.GasConfig{ + MaxGasAmount: cfg.MaxGasAmount, + GasUnitPrice: cfg.GasUnitPrice, + }, + }).Await() + if err != nil { + return nil, fmt.Errorf("WriteReport error: %w", err) + } + if reply == nil { + return nil, fmt.Errorf("nil WriteReport reply") + } + if reply.TxStatus != aptos.TxStatus_TX_STATUS_SUCCESS { + return nil, fmt.Errorf("unexpected tx status: %s", reply.TxStatus.String()) + } + + viewReply, err := client.View(runtime, &aptos.ViewRequest{ + Payload: &aptos.ViewPayload{ + Module: &aptos.ModuleID{ + Address: receiverBytes, + Name: "registry", + }, + Function: "get_feeds", + }, + }).Await() + if err != nil { + return nil, fmt.Errorf("Aptos View(%s::registry::get_feeds): %w", normalizeHex(cfg.ReceiverHex), err) + } + if viewReply == nil || len(viewReply.Data) == 0 { + return nil, fmt.Errorf("empty view reply for %s::registry::get_feeds", normalizeHex(cfg.ReceiverHex)) + } + + benchmark, found, parseErr := parseBenchmark(viewReply.Data, cfg.FeedIDHex) + if parseErr != nil { + return nil, fmt.Errorf("parse benchmark view reply: %w", parseErr) + } + if !found { + return nil, fmt.Errorf("feed %s not found in get_feeds reply", cfg.FeedIDHex) + } + if benchmark != cfg.ExpectedBenchmark { + return nil, fmt.Errorf("benchmark mismatch: got=%d want=%d", benchmark, cfg.ExpectedBenchmark) + } + + runtime.Logger().Info( + "Aptos write/read consensus succeeded", + "workflow", cfg.WorkflowName, + "benchmark", benchmark, + "feedID", normalizeHex(cfg.FeedIDHex), + ) + return nil, nil +} + +func resolveReportPayload(reportPayloadHex string) ([]byte, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(reportPayloadHex), "0x") + if trimmed == "" { + return nil, fmt.Errorf("empty hex payload") + } + raw, err := hex.DecodeString(trimmed) + if err != nil { + return nil, fmt.Errorf("decode hex payload: %w", err) + } + return raw, nil +} + +func decodeAptosAddressHex(in string) ([]byte, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(in), "0x") + if trimmed == "" { + return nil, fmt.Errorf("empty address") + } + if len(trimmed)%2 != 0 { + trimmed = "0" + trimmed + } + raw, err := hex.DecodeString(trimmed) + if err != nil { + return nil, fmt.Errorf("decode hex address: %w", err) + } + if len(raw) > 32 { + return nil, fmt.Errorf("address too long: %d bytes", len(raw)) + } + out := make([]byte, 32) + copy(out[32-len(raw):], raw) + return out, nil +} + +func parseBenchmark(data []byte, feedIDHex string) (uint64, bool, error) { + normalizedFeedID := normalizeHex(feedIDHex) + if normalizedFeedID == "" { + return 0, false, fmt.Errorf("empty feed id") + } + + var wrapped [][]feedEntry + if err := json.Unmarshal(data, &wrapped); err == nil && len(wrapped) > 0 { + for _, entry := range wrapped[0] { + if normalizeHex(entry.FeedID) != normalizedFeedID { + continue + } + v, convErr := strconv.ParseUint(strings.TrimSpace(entry.Feed.Benchmark), 10, 64) + if convErr != nil { + return 0, false, fmt.Errorf("parse benchmark %q: %w", entry.Feed.Benchmark, convErr) + } + return v, true, nil + } + return 0, false, nil + } + + var direct []feedEntry + if err := json.Unmarshal(data, &direct); err != nil { + return 0, false, fmt.Errorf("decode get_feeds payload: %w", err) + } + for _, entry := range direct { + if normalizeHex(entry.FeedID) != normalizedFeedID { + continue + } + v, convErr := strconv.ParseUint(strings.TrimSpace(entry.Feed.Benchmark), 10, 64) + if convErr != nil { + return 0, false, fmt.Errorf("parse benchmark %q: %w", entry.Feed.Benchmark, convErr) + } + return v, true, nil + } + return 0, false, nil +} + +func normalizeHex(in string) string { + s := strings.TrimSpace(strings.ToLower(in)) + s = strings.TrimPrefix(s, "0x") + s = strings.TrimLeft(s, "0") + if s == "" { + return "0x0" + } + return "0x" + s +} diff --git a/system-tests/tests/smoke/cre/cre_suite_test.go b/system-tests/tests/smoke/cre/cre_suite_test.go index 8afebf4a006..6a92d66427e 100644 --- a/system-tests/tests/smoke/cre/cre_suite_test.go +++ b/system-tests/tests/smoke/cre/cre_suite_test.go @@ -242,6 +242,12 @@ func Test_CRE_V2_Solana_Suite(t *testing.T) { }) } +func Test_CRE_V2_Aptos_Suite(t *testing.T) { + testEnv := t_helpers.SetupTestEnvironmentWithConfig(t, t_helpers.GetTestConfig(t, "/configs/workflow-gateway-don-aptos.toml")) + t.Run("[v2] Aptos", func(t *testing.T) { + ExecuteAptosTest(t, testEnv) + }) +} func Test_CRE_V2_HTTP_Action_Regression_Suite(t *testing.T) { testEnv := t_helpers.SetupTestEnvironmentWithConfig(t, t_helpers.GetDefaultTestConfig(t)) diff --git a/system-tests/tests/smoke/cre/v2_aptos_capability_test.go b/system-tests/tests/smoke/cre/v2_aptos_capability_test.go new file mode 100644 index 00000000000..2a5a614de13 --- /dev/null +++ b/system-tests/tests/smoke/cre/v2_aptos_capability_test.go @@ -0,0 +1,755 @@ +package cre + +import ( + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + aptoslib "github.com/aptos-labs/aptos-go-sdk" + aptoscrypto "github.com/aptos-labs/aptos-go-sdk/crypto" + "github.com/ethereum/go-ethereum/common" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + aptosbind "github.com/smartcontractkit/chainlink-aptos/bindings/bind" + aptosdatafeeds "github.com/smartcontractkit/chainlink-aptos/bindings/data_feeds" + aptosplatformsecondary "github.com/smartcontractkit/chainlink-aptos/bindings/platform_secondary" + "github.com/smartcontractkit/chainlink-testing-framework/framework" + "github.com/smartcontractkit/chainlink-testing-framework/framework/components/blockchain" + + commonevents "github.com/smartcontractkit/chainlink-protos/workflows/go/common" + workflowevents "github.com/smartcontractkit/chainlink-protos/workflows/go/events" + + crelib "github.com/smartcontractkit/chainlink/system-tests/lib/cre" + crecontracts "github.com/smartcontractkit/chainlink/system-tests/lib/cre/contracts" + "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains" + blockchains_aptos "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/aptos" + blockchains_evm "github.com/smartcontractkit/chainlink/system-tests/lib/cre/environment/blockchains/evm" + crecrypto "github.com/smartcontractkit/chainlink/system-tests/lib/crypto" + aptoswrite_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite/config" + aptoswriteroundtrip_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/config" + t_helpers "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers" + "github.com/smartcontractkit/chainlink/system-tests/tests/test-helpers/configuration" +) + +const aptosLocalMaxGasAmount uint64 = 200_000 +const aptosWorkerFundingAmountOctas uint64 = 1_000_000_000_000 + +var aptosForwarderVersion = semver.MustParse("1.0.0") + +// ExecuteAptosTest runs the Aptos CRE suite with the read path only. The write +// scenarios stay available for local/manual execution until the write-report CI +// path is ready again. +func ExecuteAptosTest(t *testing.T, tenv *configuration.TestEnvironment) { + executeAptosScenarios(t, tenv, aptosDefaultScenarios()) +} + +type aptosScenario struct { + name string + run func( + t *testing.T, + tenv *configuration.TestEnvironment, + aptosChain blockchains.Blockchain, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, + ) +} + +func aptosDefaultScenarios() []aptosScenario { + return []aptosScenario{ + {name: "Aptos Read", run: ExecuteAptosReadTest}, + } +} + +func executeAptosScenarios(t *testing.T, tenv *configuration.TestEnvironment, scenarios []aptosScenario) { + creEnv := tenv.CreEnvironment + require.NotEmpty(t, creEnv.Blockchains, "Aptos suite expects at least one blockchain in the environment") + + var aptosChain blockchains.Blockchain + for _, bc := range creEnv.Blockchains { + if bc.IsFamily(blockchain.FamilyAptos) { + aptosChain = bc + break + } + } + require.NotNil(t, aptosChain, "Aptos suite expects an Aptos chain in the environment (use config workflow-gateway-don-aptos.toml)") + + lggr := framework.L + userLogsCh := make(chan *workflowevents.UserLogs, 1000) + baseMessageCh := make(chan *commonevents.BaseMessage, 1000) + + writeDon := findWriteAptosDonForChain(t, tenv, aptosChain.ChainID()) + assertAptosWorkerRuntimeKeysMatchMetadata(t, writeDon) + + server := t_helpers.StartChipTestSink(t, t_helpers.GetPublishFn(lggr, userLogsCh, baseMessageCh)) + t.Cleanup(func() { + server.Shutdown(t.Context()) + close(userLogsCh) + close(baseMessageCh) + }) + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + scenario.run(t, tenv, aptosChain, userLogsCh, baseMessageCh) + }) + } +} + +func assertAptosWorkerRuntimeKeysMatchMetadata(t *testing.T, writeDon *crelib.Don) { + t.Helper() + + workers, err := writeDon.Workers() + require.NoError(t, err, "failed to list Aptos write DON workers") + require.NotEmpty(t, workers, "Aptos write DON workers list is empty") + + for _, worker := range workers { + require.NotNil(t, worker.Keys, "worker %q is missing metadata keys", worker.Name) + require.NotNil(t, worker.Keys.Aptos, "worker %q is missing metadata Aptos key", worker.Name) + + expectedAccount, err := crecrypto.NormalizeAptosAccount(worker.Keys.Aptos.Account) + require.NoError(t, err, "worker %q has invalid metadata Aptos account", worker.Name) + expectedPublicKey := normalizeHexValue(worker.Keys.Aptos.PublicKey) + require.NotEmpty(t, expectedPublicKey, "worker %q is missing metadata Aptos public key", worker.Name) + + var runtimeKeys struct { + Data []struct { + Attributes struct { + Account string `json:"account"` + PublicKey string `json:"publicKey"` + } `json:"attributes"` + } `json:"data"` + } + resp, err := worker.Clients.RestClient.APIClient.R(). + SetResult(&runtimeKeys). + Get("/v2/keys/aptos") + require.NoError(t, err, "failed to read runtime Aptos keys for worker %q", worker.Name) + require.Equal(t, http.StatusOK, resp.StatusCode(), "worker %q Aptos keys endpoint returned unexpected status", worker.Name) + require.Len(t, runtimeKeys.Data, 1, "worker %q must expose exactly one Aptos runtime key", worker.Name) + + runtimeKey := runtimeKeys.Data[0].Attributes + actualAccount, err := crecrypto.NormalizeAptosAccount(runtimeKey.Account) + require.NoError(t, err, "worker %q exposed invalid runtime Aptos account", worker.Name) + require.Equal(t, expectedAccount, actualAccount, "worker %q runtime Aptos account does not match metadata-generated account", worker.Name) + require.Equal(t, expectedPublicKey, normalizeHexValue(runtimeKey.PublicKey), "worker %q runtime Aptos public key does not match metadata-generated key", worker.Name) + } +} + +// ExecuteAptosReadTest deploys a workflow that reads 0x1::coin::name() on Aptos local devnet +// in a consensus read step and asserts the expected value. +func ExecuteAptosReadTest( + t *testing.T, + tenv *configuration.TestEnvironment, + aptosChain blockchains.Blockchain, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, +) { + lggr := framework.L + + // Fixed name so re-runs against the same DON overwrite the same workflow instead of accumulating multiple (e.g. aptos-read-workflow-4838 and aptos-read-workflow-5736). + const workflowName = "aptos-read-workflow" + workflowConfig := t_helpers.AptosReadWorkflowConfig{ + ChainSelector: aptosChain.ChainSelector(), + WorkflowName: workflowName, + ExpectedCoinName: "Aptos Coin", // 0x1::coin::name<0x1::aptos_coin::AptosCoin>() on local devnet + } + + const workflowFileLocation = "./aptos/aptosread/main.go" + t_helpers.CompileAndDeployWorkflow(t, tenv, lggr, workflowName, &workflowConfig, workflowFileLocation) + + expectedLog := "Aptos read consensus succeeded" + t_helpers.WatchWorkflowLogs(t, lggr, userLogsCh, baseMessageCh, t_helpers.WorkflowEngineInitErrorLog, expectedLog, 4*time.Minute) + lggr.Info().Str("expected_log", expectedLog).Msg("Aptos read capability test passed") +} + +func ExecuteAptosWriteTest( + t *testing.T, + tenv *configuration.TestEnvironment, + aptosChain blockchains.Blockchain, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, +) { + lggr := framework.L + scenario := prepareAptosWriteScenario(t, tenv, aptosChain) + + const workflowName = "aptos-write-workflow" + workflowConfig := aptoswrite_config.Config{ + ChainSelector: scenario.chainSelector, + WorkflowName: workflowName, + ReceiverHex: scenario.receiverHex, + RequiredSignatures: scenario.requiredSignatures, + ReportPayloadHex: scenario.reportPayloadHex, + // Keep within the current local Aptos transaction max-gas bound. + MaxGasAmount: aptosLocalMaxGasAmount, + GasUnitPrice: 100, + } + + const workflowFileLocation = "./aptos/aptoswrite/main.go" + ensureAptosWriteWorkersFunded(t, aptosChain, scenario.writeDon) + t_helpers.CompileAndDeployWorkflow(t, tenv, lggr, workflowName, &workflowConfig, workflowFileLocation) + + txHash := waitForAptosWriteSuccessLogAndTxHash(t, lggr, userLogsCh, baseMessageCh, 4*time.Minute) + assertAptosReceiverUpdatedOnChain(t, aptosChain, scenario.receiverHex, scenario.expectedBenchmarkValue) + assertAptosWriteTxOnChain(t, aptosChain, txHash, scenario.receiverHex) + lggr.Info(). + Str("tx_hash", txHash). + Str("receiver", scenario.receiverHex). + Msg("Aptos write capability test passed with onchain verification") +} + +func ExecuteAptosWriteReadRoundtripTest( + t *testing.T, + tenv *configuration.TestEnvironment, + aptosChain blockchains.Blockchain, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, +) { + lggr := framework.L + scenario := prepareAptosRoundtripScenario(t, tenv, aptosChain) + + const workflowName = "aptos-write-read-roundtrip-workflow" + roundtripCfg := aptoswriteroundtrip_config.Config{ + ChainSelector: scenario.chainSelector, + WorkflowName: workflowName, + ReceiverHex: scenario.receiverHex, + RequiredSignatures: scenario.requiredSignatures, + ReportPayloadHex: scenario.reportPayloadHex, + MaxGasAmount: aptosLocalMaxGasAmount, + GasUnitPrice: 100, + FeedIDHex: scenario.feedIDHex, + ExpectedBenchmark: scenario.expectedBenchmarkValue, + } + + ensureAptosWriteWorkersFunded(t, aptosChain, scenario.writeDon) + t_helpers.CompileAndDeployWorkflow(t, tenv, lggr, workflowName, &roundtripCfg, "./aptos/aptoswriteroundtrip/main.go") + t_helpers.WatchWorkflowLogs( + t, + lggr, + userLogsCh, + baseMessageCh, + t_helpers.WorkflowEngineInitErrorLog, + "Aptos write/read consensus succeeded", + 4*time.Minute, + ) + lggr.Info(). + Str("receiver", scenario.receiverHex). + Uint64("expected_benchmark", scenario.expectedBenchmarkValue). + Str("feed_id", scenario.feedIDHex). + Msg("Aptos write/read roundtrip capability test passed") +} + +func ExecuteAptosWriteExpectedFailureTest( + t *testing.T, + tenv *configuration.TestEnvironment, + aptosChain blockchains.Blockchain, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, +) { + lggr := framework.L + scenario := prepareAptosWriteScenario(t, tenv, aptosChain) + + const workflowName = "aptos-write-expected-failure-workflow" + workflowConfig := aptoswrite_config.Config{ + ChainSelector: scenario.chainSelector, + WorkflowName: workflowName, + ReceiverHex: "0x0", // Intentionally invalid write receiver to force onchain failure path. + RequiredSignatures: scenario.requiredSignatures, + ReportPayloadHex: scenario.reportPayloadHex, + MaxGasAmount: aptosLocalMaxGasAmount, + GasUnitPrice: 100, + ExpectFailure: true, + } + + const workflowFileLocation = "./aptos/aptoswrite/main.go" + ensureAptosWriteWorkersFunded(t, aptosChain, scenario.writeDon) + t_helpers.CompileAndDeployWorkflow(t, tenv, lggr, workflowName, &workflowConfig, workflowFileLocation) + + txHash := waitForAptosWriteExpectedFailureLogAndTxHash(t, lggr, userLogsCh, baseMessageCh, 4*time.Minute) + assertAptosWriteFailureTxOnChain(t, aptosChain, txHash) + + lggr.Info(). + Str("tx_hash", txHash). + Msg("Aptos expected write-failure workflow test passed") +} + +type aptosWriteScenario struct { + chainSelector uint64 + receiverHex string + reportPayloadHex string + feedIDHex string + expectedBenchmarkValue uint64 + requiredSignatures int + writeDon *crelib.Don +} + +func prepareAptosWriteScenario(t *testing.T, tenv *configuration.TestEnvironment, aptosChain blockchains.Blockchain) aptosWriteScenario { + return prepareAptosWriteScenarioWithBenchmark(t, tenv, aptosChain, aptosBenchmarkFeedID(), 123456789) +} + +func prepareAptosRoundtripScenario(t *testing.T, tenv *configuration.TestEnvironment, aptosChain blockchains.Blockchain) aptosWriteScenario { + return prepareAptosWriteScenarioWithBenchmark(t, tenv, aptosChain, aptosRoundtripFeedID(), 987654321) +} + +func prepareAptosWriteScenarioWithBenchmark( + t *testing.T, + tenv *configuration.TestEnvironment, + aptosChain blockchains.Blockchain, + feedID []byte, + expectedBenchmark uint64, +) aptosWriteScenario { + t.Helper() + + forwarderHex := aptosForwarderAddress(tenv, aptosChain.ChainSelector()) + require.NotEmpty(t, forwarderHex, "Aptos write test requires forwarder address for chainSelector=%d", aptosChain.ChainSelector()) + require.False(t, isZeroAptosAddress(forwarderHex), "Aptos write test requires non-zero forwarder address for chainSelector=%d", aptosChain.ChainSelector()) + + writeDon := findWriteAptosDonForChain(t, tenv, aptosChain.ChainID()) + workers, workerErr := writeDon.Workers() + require.NoError(t, workerErr, "failed to list Aptos write DON workers") + f := (len(workers) - 1) / 3 + require.GreaterOrEqual(t, f, 1, "Aptos write DON requires f>=1") + + return aptosWriteScenario{ + chainSelector: aptosChain.ChainSelector(), + receiverHex: deployAptosDataFeedsReceiverForWrite(t, tenv, aptosChain, forwarderHex, feedID), + reportPayloadHex: hex.EncodeToString(buildAptosDataFeedsBenchmarkPayloadFor(feedID, expectedBenchmark)), + feedIDHex: hex.EncodeToString(feedID), + expectedBenchmarkValue: expectedBenchmark, + requiredSignatures: f + 1, + writeDon: writeDon, + } +} + +func findWriteAptosDonForChain(t *testing.T, tenv *configuration.TestEnvironment, chainID uint64) *crelib.Don { + t.Helper() + require.NotNil(t, tenv.Dons, "test environment DON metadata is required") + + for _, don := range tenv.Dons.List() { + if !don.HasFlag("write-aptos") { + continue + } + chainIDs, err := don.GetEnabledChainIDsForCapability("write-aptos") + require.NoError(t, err, "failed to read enabled chain ids for DON %q", don.Name) + for _, id := range chainIDs { + if id == chainID { + return don + } + } + } + + require.FailNowf(t, "missing Aptos write DON", "could not find write-aptos DON for chainID=%d", chainID) + return nil +} + +func isZeroAptosAddress(addr string) bool { + trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(addr)), "0x") + if trimmed == "" { + return true + } + for _, ch := range trimmed { + if ch != '0' { + return false + } + } + return true +} + +func aptosForwarderAddress(tenv *configuration.TestEnvironment, chainSelector uint64) string { + return crecontracts.MustGetAddressFromDataStore( + tenv.CreEnvironment.CldfEnvironment.DataStore, + chainSelector, + "AptosForwarder", + aptosForwarderVersion, + "", + ) +} + +var aptosTxHashInLogRe = regexp.MustCompile(`txHash=([^\s"]+)`) + +func waitForAptosWriteSuccessLogAndTxHash( + t *testing.T, + lggr zerolog.Logger, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, + timeout time.Duration, +) string { + t.Helper() + return waitForAptosLogAndTxHash(t, lggr, userLogsCh, baseMessageCh, "Aptos write capability succeeded", timeout) +} + +func waitForAptosWriteExpectedFailureLogAndTxHash( + t *testing.T, + lggr zerolog.Logger, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, + timeout time.Duration, +) string { + t.Helper() + return waitForAptosLogAndTxHash(t, lggr, userLogsCh, baseMessageCh, "Aptos write failure observed as expected", timeout) +} + +func waitForAptosLogAndTxHash( + t *testing.T, + lggr zerolog.Logger, + userLogsCh <-chan *workflowevents.UserLogs, + baseMessageCh <-chan *commonevents.BaseMessage, + expectedLog string, + timeout time.Duration, +) string { + t.Helper() + + ctx, cancelFn := context.WithTimeoutCause(t.Context(), timeout, fmt.Errorf("failed to find Aptos workflow log with non-empty tx hash: %s", expectedLog)) + defer cancelFn() + + cancelCtx, cancelCauseFn := context.WithCancelCause(ctx) + defer cancelCauseFn(nil) + + go func() { + t_helpers.FailOnBaseMessage(cancelCtx, cancelCauseFn, t, lggr, baseMessageCh, t_helpers.WorkflowEngineInitErrorLog) + }() + + mismatchCount := 0 + for { + select { + case <-cancelCtx.Done(): + require.NoError(t, context.Cause(cancelCtx), "failed to observe Aptos log with non-empty tx hash: %s", expectedLog) + return "" + case logs := <-userLogsCh: + for _, line := range logs.LogLines { + if !strings.Contains(line.Message, expectedLog) { + mismatchCount++ + if mismatchCount%20 == 0 { + lggr.Warn(). + Str("expected_log", expectedLog). + Str("found_message", strings.TrimSpace(line.Message)). + Int("mismatch_count", mismatchCount). + Msg("[soft assertion] Received UserLogs messages, but none match expected log yet") + } + continue + } + + matches := aptosTxHashInLogRe.FindStringSubmatch(line.Message) + if len(matches) == 2 { + txHash := normalizeTxHash(matches[1]) + if txHash != "" { + return txHash + } + } + + lggr.Warn(). + Str("message", strings.TrimSpace(line.Message)). + Str("expected_log", expectedLog). + Msg("[soft assertion] Matched Aptos log without non-empty tx hash; waiting for another match") + } + } + } +} + +func assertAptosWriteFailureTxOnChain(t *testing.T, aptosChain blockchains.Blockchain, txHash string) { + t.Helper() + + bc, ok := aptosChain.(*blockchains_aptos.Blockchain) + require.True(t, ok, "expected aptos blockchain type") + + nodeURL := bc.CtfOutput().Nodes[0].ExternalHTTPUrl + require.NotEmpty(t, nodeURL, "Aptos node URL is required for onchain verification") + nodeURL, err := blockchains_aptos.NormalizeNodeURL(nodeURL) + require.NoError(t, err, "failed to normalize Aptos node URL for onchain verification") + + chainID := bc.ChainID() + require.LessOrEqual(t, chainID, uint64(255), "Aptos chain id must fit in uint8") + chainIDUint8, err := blockchains_aptos.ChainIDUint8(chainID) + require.NoError(t, err, "failed to convert Aptos chain id") + + client, err := aptoslib.NewNodeClient(nodeURL, chainIDUint8) + require.NoError(t, err, "failed to create Aptos client") + + tx, err := client.WaitForTransaction(txHash) + require.NoError(t, err, "failed waiting for Aptos tx by hash") + require.False(t, tx.Success, "Aptos tx must fail in expected-failure workflow; vm_status=%s", tx.VmStatus) +} + +func assertAptosWriteTxOnChain(t *testing.T, aptosChain blockchains.Blockchain, txHash string, expectedReceiver string) { + t.Helper() + + bc, ok := aptosChain.(*blockchains_aptos.Blockchain) + require.True(t, ok, "expected aptos blockchain type") + + nodeURL := bc.CtfOutput().Nodes[0].ExternalHTTPUrl + require.NotEmpty(t, nodeURL, "Aptos node URL is required for onchain verification") + nodeURL, err := blockchains_aptos.NormalizeNodeURL(nodeURL) + require.NoError(t, err, "failed to normalize Aptos node URL for onchain verification") + + chainID := bc.ChainID() + require.LessOrEqual(t, chainID, uint64(255), "Aptos chain id must fit in uint8") + chainIDUint8, err := blockchains_aptos.ChainIDUint8(chainID) + require.NoError(t, err, "failed to convert Aptos chain id") + + client, err := aptoslib.NewNodeClient(nodeURL, chainIDUint8) + require.NoError(t, err, "failed to create Aptos client") + + tx, err := client.WaitForTransaction(txHash) + require.NoError(t, err, "failed waiting for Aptos tx by hash") + require.True(t, tx.Success, "Aptos tx must be successful; vm_status=%s", tx.VmStatus) + + expectedReceiverNorm := normalizeTxHashLikeHex(expectedReceiver) + found := false + for _, evt := range tx.Events { + if !strings.HasSuffix(evt.Type, "::forwarder::ReportProcessed") { + continue + } + receiverVal, ok := evt.Data["receiver"].(string) + require.True(t, ok, "ReportProcessed event receiver field must be a string") + if normalizeTxHashLikeHex(receiverVal) != expectedReceiverNorm { + continue + } + _, hasExecutionID := evt.Data["workflow_execution_id"] + _, hasReportID := evt.Data["report_id"] + require.True(t, hasExecutionID, "ReportProcessed must include workflow_execution_id") + require.True(t, hasReportID, "ReportProcessed must include report_id") + found = true + break + } + require.True(t, found, "expected ReportProcessed event for receiver %s in tx %s", expectedReceiverNorm, txHash) +} + +func assertAptosReceiverUpdatedOnChain( + t *testing.T, + aptosChain blockchains.Blockchain, + receiverHex string, + expectedBenchmark uint64, +) { + t.Helper() + + aptosBC, ok := aptosChain.(*blockchains_aptos.Blockchain) + require.True(t, ok, "expected aptos blockchain type") + nodeURL := aptosBC.CtfOutput().Nodes[0].ExternalHTTPUrl + require.NotEmpty(t, nodeURL, "Aptos node URL is required for onchain verification") + nodeURL, err := blockchains_aptos.NormalizeNodeURL(nodeURL) + require.NoError(t, err, "failed to normalize Aptos node URL for onchain verification") + + chainID := aptosBC.ChainID() + require.LessOrEqual(t, chainID, uint64(255), "Aptos chain id must fit in uint8") + chainIDUint8, err := blockchains_aptos.ChainIDUint8(chainID) + require.NoError(t, err, "failed to convert Aptos chain id") + client, err := aptoslib.NewNodeClient(nodeURL, chainIDUint8) + require.NoError(t, err, "failed to create Aptos client") + + var receiverAddr aptoslib.AccountAddress + err = receiverAddr.ParseStringRelaxed(receiverHex) + require.NoError(t, err, "failed to parse Aptos receiver address") + + dataFeeds := aptosdatafeeds.Bind(receiverAddr, client) + feedID := aptosBenchmarkFeedID() + feedIDHex := hex.EncodeToString(feedID) + + require.Eventually(t, func() bool { + feeds, bErr := dataFeeds.Registry().GetFeeds(&aptosbind.CallOpts{}) + if bErr != nil || len(feeds) == 0 { + return false + } + for _, feed := range feeds { + if hex.EncodeToString(feed.FeedId) != feedIDHex { + continue + } + if feed.Feed.Benchmark == nil { + return false + } + return feed.Feed.Benchmark.Uint64() == expectedBenchmark + } + return false + }, 2*time.Minute, 3*time.Second, "expected benchmark value %d not observed onchain for receiver %s", expectedBenchmark, receiverHex) +} + +func normalizeTxHash(input string) string { + s := strings.TrimSpace(strings.ToLower(input)) + if s == "" { + return "" + } + if strings.HasPrefix(s, "0x") { + return s + } + return "0x" + s +} + +func normalizeTxHashLikeHex(input string) string { + s := strings.TrimSpace(strings.ToLower(input)) + s = strings.TrimPrefix(s, "0x") + s = strings.TrimLeft(s, "0") + if s == "" { + return "0x0" + } + return "0x" + s +} + +func normalizeHexValue(input string) string { + return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input)), "0x") +} + +func deployAptosDataFeedsReceiverForWrite( + t *testing.T, + tenv *configuration.TestEnvironment, + aptosChain blockchains.Blockchain, + primaryForwarderHex string, + feedID []byte, +) string { + t.Helper() + + aptosBC, ok := aptosChain.(*blockchains_aptos.Blockchain) + require.True(t, ok, "expected aptos blockchain type") + nodeURL := aptosBC.CtfOutput().Nodes[0].ExternalHTTPUrl + require.NotEmpty(t, nodeURL, "Aptos node URL is required for receiver deployment") + nodeURL, err := blockchains_aptos.NormalizeNodeURL(nodeURL) + require.NoError(t, err, "failed to normalize Aptos node URL for receiver deployment") + + chainID := aptosBC.ChainID() + require.LessOrEqual(t, chainID, uint64(255), "Aptos chain id must fit in uint8") + chainIDUint8, err := blockchains_aptos.ChainIDUint8(chainID) + require.NoError(t, err, "failed to convert Aptos chain id") + client, err := aptoslib.NewNodeClient(nodeURL, chainIDUint8) + require.NoError(t, err, "failed to create Aptos client") + + deployer, err := aptosDeployerAccount() + require.NoError(t, err, "failed to create Aptos deployer account") + deployerAddress := deployer.AccountAddress() + require.NoError(t, aptosBC.Fund(t.Context(), deployerAddress.StringLong(), aptosWorkerFundingAmountOctas), "failed to fund Aptos deployer account") + + var primaryForwarderAddr aptoslib.AccountAddress + err = primaryForwarderAddr.ParseStringRelaxed(primaryForwarderHex) + require.NoError(t, err, "failed to parse primary forwarder address") + + owner := deployerAddress + secondaryAddress, secondaryTx, _, err := aptosplatformsecondary.DeployToObject(deployer, client, owner) + require.NoError(t, err, "failed to deploy Aptos secondary platform package") + require.NoError(t, blockchains_aptos.WaitForTransactionSuccess(client, secondaryTx.Hash, "platform_secondary deployment")) + + dataFeedsAddress, dataFeedsTx, dataFeeds, err := aptosdatafeeds.DeployToObject( + deployer, + client, + owner, + primaryForwarderAddr, + owner, + secondaryAddress, + ) + require.NoError(t, err, "failed to deploy Aptos data feeds receiver package") + require.NoError(t, blockchains_aptos.WaitForTransactionSuccess(client, dataFeedsTx.Hash, "data_feeds deployment")) + + workflowOwner := workflowRegistryOwnerBytes(t, tenv) + tx, err := dataFeeds.Registry().SetWorkflowConfig( + &aptosbind.TransactOpts{Signer: deployer}, + [][]byte{workflowOwner}, + [][]byte{}, + ) + require.NoError(t, err, "failed to set data feeds workflow config") + require.NoError(t, blockchains_aptos.WaitForTransactionSuccess(client, tx.Hash, "data_feeds set_workflow_config")) + + // Configure the feed that the write workflow will update. + // Without this, registry::perform_update emits WriteSkippedFeedNotSet and benchmark remains unchanged. + tx, err = dataFeeds.Registry().SetFeeds( + &aptosbind.TransactOpts{Signer: deployer}, + [][]byte{feedID}, + []string{"CRE-BENCHMARK"}, + []byte{0x99}, + ) + require.NoError(t, err, "failed to set data feeds feed config") + require.NoError(t, blockchains_aptos.WaitForTransactionSuccess(client, tx.Hash, "data_feeds set_feeds")) + + return dataFeedsAddress.StringLong() +} + +func aptosDeployerAccount() (*aptoslib.Account, error) { + const defaultAptosDeployerKey = "d477c65f88ed9e6d4ec6e2014755c3cfa3e0c44e521d0111a02868c5f04c41d4" + keyHex := strings.TrimSpace(os.Getenv("CRE_APTOS_DEPLOYER_PRIVATE_KEY")) + if keyHex == "" { + keyHex = defaultAptosDeployerKey + } + if keyHex == "" { + return nil, errors.New("empty Aptos deployer key") + } + keyHex = strings.TrimPrefix(keyHex, "0x") + var privateKey aptoscrypto.Ed25519PrivateKey + if err := privateKey.FromHex(keyHex); err != nil { + return nil, fmt.Errorf("parse Aptos deployer private key: %w", err) + } + return aptoslib.NewAccountFromSigner(&privateKey) +} + +func ensureAptosWriteWorkersFunded(t *testing.T, aptosChain blockchains.Blockchain, writeDon *crelib.Don) { + t.Helper() + + aptosBC, ok := aptosChain.(*blockchains_aptos.Blockchain) + require.True(t, ok, "expected aptos blockchain type") + workers, workerErr := writeDon.Workers() + require.NoError(t, workerErr, "failed to list Aptos write DON workers for funding") + require.NotEmpty(t, workers, "Aptos write DON workers list is empty") + + for _, worker := range workers { + require.NotNil(t, worker.Keys, "worker %q is missing metadata keys", worker.Name) + require.NotNil(t, worker.Keys.Aptos, "worker %q is missing metadata Aptos key", worker.Name) + + var account aptoslib.AccountAddress + parseErr := account.ParseStringRelaxed(worker.Keys.Aptos.Account) + require.NoError(t, parseErr, "failed to parse Aptos worker account for worker %q", worker.Name) + + require.NoError(t, aptosBC.Fund(t.Context(), account.StringLong(), aptosWorkerFundingAmountOctas), "failed to fund Aptos worker account %s for worker %q", account.StringLong(), worker.Name) + } +} + +func workflowRegistryOwnerBytes(t *testing.T, tenv *configuration.TestEnvironment) []byte { + t.Helper() + registryChain, ok := tenv.CreEnvironment.Blockchains[0].(*blockchains_evm.Blockchain) + require.True(t, ok, "registry chain must be EVM") + rootOwner := registryChain.SethClient.MustGetRootKeyAddress() + return common.HexToAddress(rootOwner.Hex()).Bytes() +} + +func buildAptosDataFeedsBenchmarkPayloadFor(feedID []byte, benchmark uint64) []byte { + // ABI-like benchmark payload expected by data_feeds::registry::parse_raw_report + // [offset=32][count=1][feed_id(32)][report(64)] + const ( + offsetToArray = uint64(32) + reportCount = uint64(1) + timestamp = uint64(1700000000) + ) + + report := make([]byte, 64) + writeU256BE(report[0:32], timestamp) + writeU256BE(report[32:64], benchmark) + + out := make([]byte, 0, 160) + out = appendU256BE(out, offsetToArray) + out = appendU256BE(out, reportCount) + out = append(out, feedID...) + out = append(out, report...) + return out +} + +func aptosBenchmarkFeedID() []byte { + feedID := make([]byte, 32) + feedID[31] = 1 + return feedID +} + +func aptosRoundtripFeedID() []byte { + feedID := make([]byte, 32) + feedID[31] = 2 + return feedID +} + +func appendU256BE(dst []byte, v uint64) []byte { + buf := make([]byte, 32) + binary.BigEndian.PutUint64(buf[24:], v) + return append(dst, buf...) +} + +func writeU256BE(dst []byte, v uint64) { + binary.BigEndian.PutUint64(dst[24:], v) +} diff --git a/system-tests/tests/test-helpers/before_suite.go b/system-tests/tests/test-helpers/before_suite.go index 8cc18a6562d..eab0ee7e0d7 100644 --- a/system-tests/tests/test-helpers/before_suite.go +++ b/system-tests/tests/test-helpers/before_suite.go @@ -336,18 +336,13 @@ func setConfigurationIfMissing(configName string) error { func createEnvironmentIfNotExists(ctx context.Context, relativePathToRepoRoot, environmentDir string, flags ...string) error { if !envconfig.LocalCREStateFileExists(relativePathToRepoRoot) { - framework.L.Info().Str("CTF_CONFIGS", os.Getenv("CTF_CONFIGS")).Str("local CRE state file", envconfig.MustLocalCREStateFileAbsPath(relativePathToRepoRoot)).Msg("Local CRE state file does not exist, starting environment...") - - args := []string{"run", ".", "env", "start"} - args = append(args, flags...) - - cmd := exec.CommandContext(ctx, "go", args...) - cmd.Dir = environmentDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmdErr := cmd.Run() - if cmdErr != nil { - return errors.Wrap(cmdErr, "failed to start environment") + framework.L.Info(). + Str("CTF_CONFIGS", os.Getenv("CTF_CONFIGS")). + Str("local CRE state file", envconfig.MustLocalCREStateFileAbsPath(relativePathToRepoRoot)). + Msg("Local CRE state file does not exist, starting environment...") + + if err := startEnvironment(ctx, environmentDir, flags...); err != nil { + return err } } @@ -397,3 +392,17 @@ func setCldfEVMDeployerKey(env *cldf.Environment, chainSelector uint64, deployer env.BlockChains = cldf_chain.NewBlockChainsFromSlice(chainCopies) return nil } + +func startEnvironment(ctx context.Context, environmentDir string, flags ...string) error { + args := []string{"run", ".", "env", "start"} + args = append(args, flags...) + + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = environmentDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return errors.Wrap(err, "failed to start environment") + } + return nil +} diff --git a/system-tests/tests/test-helpers/t_helpers.go b/system-tests/tests/test-helpers/t_helpers.go index d460e252846..830d071fb21 100644 --- a/system-tests/tests/test-helpers/t_helpers.go +++ b/system-tests/tests/test-helpers/t_helpers.go @@ -47,6 +47,8 @@ import ( evmread_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/evmread-negative/config" evmwrite_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/evmwrite-negative/config" logtrigger_negative_config "github.com/smartcontractkit/chainlink/system-tests/tests/regression/cre/evm/logtrigger-negative/config" + aptoswrite_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswrite/config" + aptoswriteroundtrip_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/aptos/aptoswriteroundtrip/config" evmread_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/evmread/config" logtrigger_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/evm/logtrigger/config" solwrite_config "github.com/smartcontractkit/chainlink/system-tests/tests/smoke/cre/solana/solwrite/config" @@ -288,6 +290,9 @@ type WorkflowConfig interface { None | portypes.WorkflowConfig | porV2types.WorkflowConfig | + AptosReadWorkflowConfig | + aptoswrite_config.Config | + aptoswriteroundtrip_config.Config | crontypes.WorkflowConfig | HTTPWorkflowConfig | consensus_negative_config.Config | @@ -312,6 +317,12 @@ type HTTPWorkflowConfig struct { URL string `json:"url"` } +type AptosReadWorkflowConfig struct { + ChainSelector uint64 `yaml:"chainSelector"` + WorkflowName string `yaml:"workflowName"` + ExpectedCoinName string `yaml:"expectedCoinName"` +} + // WorkflowRegistrationConfig holds configuration for workflow registration type WorkflowRegistrationConfig struct { WorkflowName string @@ -395,6 +406,24 @@ func workflowConfigFactory[T WorkflowConfig](t *testing.T, testLogger zerolog.Lo require.NoError(t, configErr, "failed to create PoR v2 workflow config file") testLogger.Info().Msg("PoR v2 workflow config file created.") + case *AptosReadWorkflowConfig: + workflowCfgFilePath, configErr := CreateWorkflowYamlConfigFile(workflowName, cfg, outputDir) + workflowConfigFilePath = workflowCfgFilePath + require.NoError(t, configErr, "failed to create aptos read workflow config file") + testLogger.Info().Msg("Aptos read workflow config file created.") + + case *aptoswrite_config.Config: + workflowCfgFilePath, configErr := CreateWorkflowYamlConfigFile(workflowName, cfg, outputDir) + workflowConfigFilePath = workflowCfgFilePath + require.NoError(t, configErr, "failed to create aptos write workflow config file") + testLogger.Info().Msg("Aptos write workflow config file created.") + + case *aptoswriteroundtrip_config.Config: + workflowCfgFilePath, configErr := CreateWorkflowYamlConfigFile(workflowName, cfg, outputDir) + workflowConfigFilePath = workflowCfgFilePath + require.NoError(t, configErr, "failed to create aptos write roundtrip workflow config file") + testLogger.Info().Msg("Aptos write roundtrip workflow config file created.") + case *HTTPWorkflowConfig: workflowCfgFilePath, configErr := createHTTPWorkflowConfigFile(workflowName, cfg, outputDir) workflowConfigFilePath = workflowCfgFilePath diff --git a/testdata/scripts/health/multi-chain-loopp.txtar b/testdata/scripts/health/multi-chain-loopp.txtar index 04e0dc87c4f..561bcd58a82 100644 --- a/testdata/scripts/health/multi-chain-loopp.txtar +++ b/testdata/scripts/health/multi-chain-loopp.txtar @@ -43,7 +43,7 @@ fj293fbBnlQ!f9vNs HTTPPort = $PORT [[Aptos]] -ChainID = '42' +ChainID = '4' [[Aptos.Nodes]] Name = 'primary' @@ -103,10 +103,10 @@ URL = 'http://tron.org' SolidityURL = 'https://solidity.evm' -- out.txt -- -ok Aptos.42.RelayerService -ok Aptos.42.RelayerService.PluginRelayerClient -ok Aptos.42.RelayerService.PluginRelayerClient.PluginAptos -ok Aptos.42.RelayerService.PluginRelayerClient.PluginAptos.PluginRelayerConfigEmitter +ok Aptos.4.RelayerService +ok Aptos.4.RelayerService.PluginRelayerClient +ok Aptos.4.RelayerService.PluginRelayerClient.PluginAptos +ok Aptos.4.RelayerService.PluginRelayerClient.PluginAptos.PluginRelayerConfigEmitter ok BridgeStatusReporter ok CRE ok CRE.DispatcherWrapper @@ -195,36 +195,36 @@ ok WorkflowStore "data": [ { "type": "checks", - "id": "Aptos.42.RelayerService", + "id": "Aptos.4.RelayerService", "attributes": { - "name": "Aptos.42.RelayerService", + "name": "Aptos.4.RelayerService", "status": "passing", "output": "" } }, { "type": "checks", - "id": "Aptos.42.RelayerService.PluginRelayerClient", + "id": "Aptos.4.RelayerService.PluginRelayerClient", "attributes": { - "name": "Aptos.42.RelayerService.PluginRelayerClient", + "name": "Aptos.4.RelayerService.PluginRelayerClient", "status": "passing", "output": "" } }, { "type": "checks", - "id": "Aptos.42.RelayerService.PluginRelayerClient.PluginAptos", + "id": "Aptos.4.RelayerService.PluginRelayerClient.PluginAptos", "attributes": { - "name": "Aptos.42.RelayerService.PluginRelayerClient.PluginAptos", + "name": "Aptos.4.RelayerService.PluginRelayerClient.PluginAptos", "status": "passing", "output": "" } }, { "type": "checks", - "id": "Aptos.42.RelayerService.PluginRelayerClient.PluginAptos.PluginRelayerConfigEmitter", + "id": "Aptos.4.RelayerService.PluginRelayerClient.PluginAptos.PluginRelayerConfigEmitter", "attributes": { - "name": "Aptos.42.RelayerService.PluginRelayerClient.PluginAptos.PluginRelayerConfigEmitter", + "name": "Aptos.4.RelayerService.PluginRelayerClient.PluginAptos.PluginRelayerConfigEmitter", "status": "passing", "output": "" }