diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0980f730..d1c3897c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,7 @@ updates: - dependency-name: "github.com/ethereum/go-ethereum" - dependency-name: "github.com/gagliardetto/solana-go" - dependency-name: "github.com/stellar/go-stellar-sdk" + - dependency-name: "github.com/smartcontractkit/chainlink-stellar/bindings" - package-ecosystem: npm directory: "/" schedule: diff --git a/.github/workflows/go-mod-validation.yml b/.github/workflows/go-mod-validation.yml deleted file mode 100644 index a7d9d7f9..00000000 --- a/.github/workflows/go-mod-validation.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Validate Go Mod Files -permissions: - contents: read - -on: - pull_request: - merge_group: - -jobs: - go-mod-validation: - name: Validate go.mod dependencies - runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} - steps: - - uses: actions/checkout@v4 - - - name: Validate go.mod - uses: smartcontractkit/.github/apps/go-mod-validator@4864172d998c12cefa9c2552b36d6e9842261816 # go-mod-validator@1.3.0 diff --git a/.github/workflows/pull-request-main.yml b/.github/workflows/pull-request-main.yml index 19d102f9..65781034 100644 --- a/.github/workflows/pull-request-main.yml +++ b/.github/workflows/pull-request-main.yml @@ -140,11 +140,16 @@ jobs: CTF_CONFIGS=../config.sui.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestSuiSuite || sui_failure=true echo "::endgroup::" + echo "::group::Stellar" + CTF_CONFIGS=../config.stellar.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestStellarSuite || stellar_failure=true + echo "::endgroup::" + [[ -n "${evm_failure}" ]] && echo "🚨 EVM e2e tests failed." [[ -n "${solana_failure}" ]] && echo "🚨 Solana e2e tests failed." [[ -n "${aptos_failure}" ]] && echo "🚨 Aptos e2e tests failed." [[ -n "${sui_failure}" ]] && echo "🚨 Sui e2e tests failed." - [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" ]] && { + [[ -n "${stellar_failure}" ]] && echo "🚨 Stellar e2e tests failed." + [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" || -n "${stellar_failure}" ]] && { exit 1 } || { echo "Exiting" diff --git a/.github/workflows/push-main.yml b/.github/workflows/push-main.yml index 51f4db24..a3f24a53 100644 --- a/.github/workflows/push-main.yml +++ b/.github/workflows/push-main.yml @@ -138,11 +138,16 @@ jobs: CTF_CONFIGS=../config.sui.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestSuiSuite || sui_failure=true echo "::endgroup::" + echo "::group::Stellar" + CTF_CONFIGS=../config.stellar.toml go test -p=1 -tags=e2e -v ./e2e/tests/... -run=TestStellarSuite || stellar_failure=true + echo "::endgroup::" + [[ -n "${evm_failure}" ]] && echo "🚨 EVM e2e tests failed." [[ -n "${solana_failure}" ]] && echo "🚨 Solana e2e tests failed." [[ -n "${aptos_failure}" ]] && echo "🚨 Aptos e2e tests failed." [[ -n "${sui_failure}" ]] && echo "🚨 Sui e2e tests failed." - [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" ]] && { + [[ -n "${stellar_failure}" ]] && echo "🚨 Stellar e2e tests failed." + [[ -n "${evm_failure}" || -n "${solana_failure}" || -n "${aptos_failure}" || -n "${sui_failure}" || -n "${stellar_failure}" ]] && { exit 1 } || { echo "Exiting" diff --git a/chainwrappers/chainaccessor.go b/chainwrappers/chainaccessor.go index ca1fed01..ce8a9bbf 100644 --- a/chainwrappers/chainaccessor.go +++ b/chainwrappers/chainaccessor.go @@ -8,6 +8,8 @@ import ( "github.com/xssnick/tonutils-go/ton" tonwallet "github.com/xssnick/tonutils-go/ton/wallet" + stellarbindings "github.com/smartcontractkit/chainlink-stellar/bindings" + evmsdk "github.com/smartcontractkit/mcms/sdk/evm" suisuisdk "github.com/smartcontractkit/mcms/sdk/sui" ) @@ -24,4 +26,5 @@ type ChainAccessor interface { SuiSigner(selector uint64) (suisuisdk.SuiSigner, bool) TonClient(selector uint64) (ton.APIClientWrapped, bool) TonSigner(selector uint64) (*tonwallet.Wallet, bool) + StellarInvoker(selector uint64) (stellarbindings.Invoker, bool) } diff --git a/chainwrappers/converters.go b/chainwrappers/converters.go index 0ccaa18d..8f73643c 100644 --- a/chainwrappers/converters.go +++ b/chainwrappers/converters.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -51,6 +52,8 @@ func BuildConverter(selector types.ChainSelector, metadata types.ChainMetadata) converter, _ = sui.NewTimelockConverter() case chainsel.FamilyTon: converter = ton.NewTimelockConverter(ton.DefaultSendAmount) + case chainsel.FamilyStellar: + converter = stellar.NewTimelockConverter() default: return nil, fmt.Errorf("unsupported chain family %s", fam) } diff --git a/chainwrappers/converters_test.go b/chainwrappers/converters_test.go index 512dfbfb..d8770446 100644 --- a/chainwrappers/converters_test.go +++ b/chainwrappers/converters_test.go @@ -9,6 +9,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -31,6 +32,7 @@ func TestBuildConverters(t *testing.T) { chaintest.Chain5Selector: {}, chaintest.Chain6Selector: {}, chaintest.Chain7Selector: {}, + chaintest.Chain9Selector: {}, }, expectTypes: map[types.ChainSelector]any{ chaintest.Chain2Selector: (*evm.TimelockConverter)(nil), @@ -38,6 +40,7 @@ func TestBuildConverters(t *testing.T) { chaintest.Chain5Selector: (*aptos.TimelockConverter)(nil), chaintest.Chain6Selector: (*sui.TimelockConverter)(nil), chaintest.Chain7Selector: (*ton.TimelockConverter)(nil), + chaintest.Chain9Selector: (*stellar.TimelockConverter)(nil), }, }, { diff --git a/chainwrappers/executors.go b/chainwrappers/executors.go index 1b1b1261..c8bd8a56 100644 --- a/chainwrappers/executors.go +++ b/chainwrappers/executors.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -167,6 +168,18 @@ func BuildExecutor( Amount: ton.DefaultSendAmount, }) + case chainsel.FamilyStellar: + stellarEncoder, ok := encoder.(*stellar.Encoder) + if !ok { + return nil, fmt.Errorf("invalid encoder type for selector %d: %T", chainSelector, encoder) + } + invoker, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", chainSelector) + } + + return stellar.NewExecutor(stellarEncoder, invoker), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/executors_test.go b/chainwrappers/executors_test.go index 626930cf..82f978c5 100644 --- a/chainwrappers/executors_test.go +++ b/chainwrappers/executors_test.go @@ -7,31 +7,37 @@ import ( sol "github.com/gagliardetto/solana-go" solrpc "github.com/gagliardetto/solana-go/rpc" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" chainsel "github.com/smartcontractkit/chain-selectors" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" tonwallet "github.com/xssnick/tonutils-go/ton/wallet" "github.com/smartcontractkit/mcms/chainwrappers/mocks" + "github.com/smartcontractkit/mcms/internal/testutils/chaintest" mcmssdk "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/sdk/aptos" aptosmocks "github.com/smartcontractkit/mcms/sdk/aptos/mocks/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" suibindmocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/bindutils" suimocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/sui" "github.com/smartcontractkit/mcms/sdk/ton" tonmocks "github.com/smartcontractkit/mcms/sdk/ton/mocks" mcmstypes "github.com/smartcontractkit/mcms/types" + + stellarbindings "github.com/smartcontractkit/chainlink-stellar/bindings" ) var ( - evmSelector = mcmstypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector) - solSelector = mcmstypes.ChainSelector(chainsel.SOLANA_DEVNET.Selector) - aptosSelector = mcmstypes.ChainSelector(chainsel.APTOS_TESTNET.Selector) - suiSelector = mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector) - tonSelector = mcmstypes.ChainSelector(chainsel.TON_TESTNET.Selector) + evmSelector = mcmstypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector) + solSelector = mcmstypes.ChainSelector(chainsel.SOLANA_DEVNET.Selector) + aptosSelector = mcmstypes.ChainSelector(chainsel.APTOS_TESTNET.Selector) + suiSelector = mcmstypes.ChainSelector(chainsel.SUI_TESTNET.Selector) + tonSelector = mcmstypes.ChainSelector(chainsel.TON_TESTNET.Selector) + stellarSelector = chaintest.Chain9Selector ) func TestBuildExecutors(t *testing.T) { @@ -66,6 +72,10 @@ func TestBuildExecutors(t *testing.T) { tonExecutor, err := ton.NewExecutor(tonExecOpts) require.NoError(t, err) + stellarEncoder := stellar.NewEncoder(stellarSelector, 0, false) + var stellarInvoker stellarbindings.Invoker + stellarExecutor := stellar.NewExecutor(stellarEncoder, stellarInvoker) + tests := []struct { name string encoders map[mcmstypes.ChainSelector]mcmssdk.Encoder @@ -154,6 +164,24 @@ func TestBuildExecutors(t *testing.T) { aptosSelector: aptosCurseExecutor, }, }, + { + name: "success with stellar", + encoders: map[mcmstypes.ChainSelector]mcmssdk.Encoder{ + stellarSelector: stellarEncoder, + }, + chainMetadata: map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata{ + stellarSelector: { + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + StartingOpCount: 0, + }, + }, + setup: func(accessor *mocks.ChainAccessor) { + accessor.EXPECT().StellarInvoker(mock.Anything).Return(stellarInvoker, true) + }, + want: map[mcmstypes.ChainSelector]mcmssdk.Executor{ + stellarSelector: stellarExecutor, + }, + }, } for _, tt := range tests { @@ -166,7 +194,7 @@ func TestBuildExecutors(t *testing.T) { got, err := BuildExecutors(chainAccessor, tt.chainMetadata, tt.encoders, mcmstypes.TimelockActionSchedule) if tt.wantErr == "" { require.NoError(t, err) - require.Empty(t, cmp.Diff(tt.want, got)) + require.Empty(t, cmp.Diff(tt.want, got, cmpopts.IgnoreUnexported(stellar.Executor{}, stellar.Inspector{}))) } else { require.ErrorContains(t, err, tt.wantErr) } diff --git a/chainwrappers/inspectors.go b/chainwrappers/inspectors.go index 2df8b657..79a792a7 100644 --- a/chainwrappers/inspectors.go +++ b/chainwrappers/inspectors.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -103,6 +104,15 @@ func BuildInspector( } return ton.NewInspector(client), nil + + case chainsel.FamilyStellar: + invoker, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", rawSelector) + } + + return stellar.NewInspector(invoker), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/inspectors_test.go b/chainwrappers/inspectors_test.go index ed47ad20..d65dd7b8 100644 --- a/chainwrappers/inspectors_test.go +++ b/chainwrappers/inspectors_test.go @@ -48,6 +48,10 @@ func TestMCMInspectorBuilder_BuildInspectors(t *testing.T) { mcmsTypes.ChainSelector(chainsel.SOLANA_DEVNET.Selector): {MCMAddress: "0xsolana", StartingOpCount: 0}, mcmsTypes.ChainSelector(chainsel.APTOS_TESTNET.Selector): {MCMAddress: "0xaptos", StartingOpCount: 0}, mcmsTypes.ChainSelector(chainsel.TON_TESTNET.Selector): {MCMAddress: "0xton", StartingOpCount: 0}, + mcmsTypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector): { + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + StartingOpCount: 0, + }, mcmsTypes.ChainSelector(chainsel.SUI_TESTNET.Selector): { MCMAddress: "0xsui", StartingOpCount: 0, @@ -70,8 +74,9 @@ func TestMCMInspectorBuilder_BuildInspectors(t *testing.T) { access.EXPECT().SuiClient(mock.Anything).Return(nil, true) access.EXPECT().SuiSigner(mock.Anything).Return(nil, true) access.EXPECT().TonClient(mock.Anything).Return(nil, true) + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) }, - expectedInspectorsCount: 5, + expectedInspectorsCount: 6, }, { name: "aptos curse mcms from metadata", diff --git a/chainwrappers/mocks/chain_accessor.go b/chainwrappers/mocks/chain_accessor.go index a0bbdbb6..ac63477d 100644 --- a/chainwrappers/mocks/chain_accessor.go +++ b/chainwrappers/mocks/chain_accessor.go @@ -5,21 +5,15 @@ package mocks import ( aptos "github.com/aptos-labs/aptos-go-sdk" bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + bindings "github.com/smartcontractkit/chainlink-stellar/bindings" - evm "github.com/smartcontractkit/mcms/sdk/evm" - - mock "github.com/stretchr/testify/mock" - + sui "github.com/block-vision/sui-go-sdk/sui" + solana "github.com/gagliardetto/solana-go" rpc "github.com/gagliardetto/solana-go/rpc" - + evm "github.com/smartcontractkit/mcms/sdk/evm" sdksui "github.com/smartcontractkit/mcms/sdk/sui" - - solana "github.com/gagliardetto/solana-go" - - sui "github.com/block-vision/sui-go-sdk/sui" - + mock "github.com/stretchr/testify/mock" ton "github.com/xssnick/tonutils-go/ton" - wallet "github.com/xssnick/tonutils-go/ton/wallet" ) @@ -431,6 +425,64 @@ func (_c *ChainAccessor_SolanaSigner_Call) RunAndReturn(run func(uint64) (*solan return _c } +// StellarInvoker provides a mock function with given fields: selector +func (_m *ChainAccessor) StellarInvoker(selector uint64) (bindings.Invoker, bool) { + ret := _m.Called(selector) + + if len(ret) == 0 { + panic("no return value specified for StellarInvoker") + } + + var r0 bindings.Invoker + var r1 bool + if rf, ok := ret.Get(0).(func(uint64) (bindings.Invoker, bool)); ok { + return rf(selector) + } + if rf, ok := ret.Get(0).(func(uint64) bindings.Invoker); ok { + r0 = rf(selector) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(bindings.Invoker) + } + } + + if rf, ok := ret.Get(1).(func(uint64) bool); ok { + r1 = rf(selector) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// ChainAccessor_StellarInvoker_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StellarInvoker' +type ChainAccessor_StellarInvoker_Call struct { + *mock.Call +} + +// StellarInvoker is a helper method to define mock.On call +// - selector uint64 +func (_e *ChainAccessor_Expecter) StellarInvoker(selector interface{}) *ChainAccessor_StellarInvoker_Call { + return &ChainAccessor_StellarInvoker_Call{Call: _e.mock.On("StellarInvoker", selector)} +} + +func (_c *ChainAccessor_StellarInvoker_Call) Run(run func(selector uint64)) *ChainAccessor_StellarInvoker_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(uint64)) + }) + return _c +} + +func (_c *ChainAccessor_StellarInvoker_Call) Return(_a0 bindings.Invoker, _a1 bool) *ChainAccessor_StellarInvoker_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ChainAccessor_StellarInvoker_Call) RunAndReturn(run func(uint64) (bindings.Invoker, bool)) *ChainAccessor_StellarInvoker_Call { + _c.Call.Return(run) + return _c +} + // SuiClient provides a mock function with given fields: selector func (_m *ChainAccessor) SuiClient(selector uint64) (sui.ISuiAPI, bool) { ret := _m.Called(selector) diff --git a/chainwrappers/timelock_configurers.go b/chainwrappers/timelock_configurers.go index cc28ffec..d3bf58f0 100644 --- a/chainwrappers/timelock_configurers.go +++ b/chainwrappers/timelock_configurers.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -109,6 +110,22 @@ func BuildTimelockConfigurer( return ton.NewTimelockConfigurer(w, ton.DefaultSendAmount), nil + case chainsel.FamilyStellar: + inv, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", rawSelector) + } + + af, err := stellar.ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return nil, fmt.Errorf("stellar timelock metadata for selector %d: %w", rawSelector, err) + } + if af.TimelockAdmin == "" { + return nil, fmt.Errorf("stellar timelock: timelockAdmin is required in metadata.additionalFields for selector %d", rawSelector) + } + + return stellar.NewTimelockConfigurer(inv, af.TimelockAdmin), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/timelock_configurers_test.go b/chainwrappers/timelock_configurers_test.go index 598b3dfe..223a2232 100644 --- a/chainwrappers/timelock_configurers_test.go +++ b/chainwrappers/timelock_configurers_test.go @@ -2,6 +2,7 @@ package chainwrappers import ( "encoding/json" + "strings" "testing" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -15,6 +16,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" solanasdk "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" mcmsTypes "github.com/smartcontractkit/mcms/types" @@ -23,6 +25,11 @@ import ( func TestBuildTimelockConfigurers(t *testing.T) { t.Parallel() + stellarAdmin := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + stellarSel := mcmsTypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + stellarAdditional, err := json.Marshal(map[string]string{"timelockAdmin": stellarAdmin}) + require.NoError(t, err) + tests := []struct { name string chainMetadata map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata @@ -62,6 +69,10 @@ func TestBuildTimelockConfigurers(t *testing.T) { "deployer_state_obj":"0xdeployer" }`), }, + stellarSel: { + MCMAddress: strings.Repeat("f", 64), + AdditionalFields: stellarAdditional, + }, }, setup: func(t *testing.T, access *mocks.ChainAccessor, metadata map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata) { t.Helper() @@ -77,6 +88,8 @@ func TestBuildTimelockConfigurers(t *testing.T) { access.EXPECT().AptosClient(mock.Anything).Return(nil, true) access.EXPECT().TonSigner(mock.Anything).Return(&wallet.Wallet{}, true) + + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) }, expectTypes: map[mcmsTypes.ChainSelector]any{ mcmsTypes.ChainSelector(chainsel.ETHEREUM_TESTNET_SEPOLIA.Selector): (*evm.TimelockConfigurer)(nil), @@ -84,6 +97,7 @@ func TestBuildTimelockConfigurers(t *testing.T) { mcmsTypes.ChainSelector(chainsel.APTOS_TESTNET.Selector): (*aptos.TimelockConfigurer)(nil), mcmsTypes.ChainSelector(chainsel.SUI_TESTNET.Selector): (*sui.TimelockConfigurer)(nil), mcmsTypes.ChainSelector(chainsel.TON_TESTNET.Selector): (*ton.TimelockConfigurer)(nil), + stellarSel: (*stellar.TimelockConfigurer)(nil), }, }, { @@ -152,6 +166,36 @@ func TestBuildTimelockConfigurers(t *testing.T) { expectErr: true, errContains: "missing TON chain wallet", }, + { + name: "missing stellar invoker", + chainMetadata: map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata{ + stellarSel: { + MCMAddress: strings.Repeat("f", 64), + AdditionalFields: stellarAdditional, + }, + }, + setup: func(t *testing.T, access *mocks.ChainAccessor, _ map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata) { + t.Helper() + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, false) + }, + expectErr: true, + errContains: "missing stellar invoker", + }, + { + name: "missing timelockAdmin", + chainMetadata: map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata{ + stellarSel: { + MCMAddress: strings.Repeat("f", 64), + AdditionalFields: []byte(`{}`), + }, + }, + setup: func(t *testing.T, access *mocks.ChainAccessor, _ map[mcmsTypes.ChainSelector]mcmsTypes.ChainMetadata) { + t.Helper() + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) + }, + expectErr: true, + errContains: "timelockAdmin", + }, } for _, tc := range tests { diff --git a/chainwrappers/timelock_executors.go b/chainwrappers/timelock_executors.go index 86515211..7a1fed44 100644 --- a/chainwrappers/timelock_executors.go +++ b/chainwrappers/timelock_executors.go @@ -10,6 +10,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" "github.com/smartcontractkit/mcms/types" @@ -135,6 +136,22 @@ func BuildTimelockExecutor( Amount: ton.DefaultSendAmount, }) + case chainsel.FamilyStellar: + inv, ok := chains.StellarInvoker(rawSelector) + if !ok { + return nil, fmt.Errorf("missing stellar invoker for selector %d", chainSelector) + } + + af, err := stellar.ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return nil, fmt.Errorf("stellar timelock metadata for selector %d: %w", chainSelector, err) + } + if af.TimelockExecutor == "" { + return nil, fmt.Errorf("stellar timelock: timelockExecutor is required in metadata.additionalFields for selector %d", chainSelector) + } + + return stellar.NewTimelockExecutor(inv, af.TimelockExecutor), nil + default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/chainwrappers/timelock_executors_test.go b/chainwrappers/timelock_executors_test.go index eb9d07a7..64d72d33 100644 --- a/chainwrappers/timelock_executors_test.go +++ b/chainwrappers/timelock_executors_test.go @@ -1,6 +1,8 @@ package chainwrappers import ( + "encoding/json" + "strings" "testing" gethbind "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -18,6 +20,7 @@ import ( aptosmocks "github.com/smartcontractkit/mcms/sdk/aptos/mocks/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" suibindmocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/bindutils" suimocks "github.com/smartcontractkit/mcms/sdk/sui/mocks/sui" @@ -48,6 +51,12 @@ func TestBuildTimelockExecutors(t *testing.T) { ton.TimelockExecutorOpts{Client: tonClient, Wallet: tonSigner, Amount: ton.DefaultSendAmount}) require.NoError(t, err) + stellarExecutorCaller := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + stellarExecutor := stellar.NewTimelockExecutor(nil, stellarExecutorCaller) + stellarSel := mcmstypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + stellarAdditional, err := json.Marshal(map[string]string{"timelockExecutor": stellarExecutorCaller}) + require.NoError(t, err) + tests := []struct { name string chainMetadata map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata @@ -87,6 +96,11 @@ func TestBuildTimelockExecutors(t *testing.T) { MCMAddress: "0xton", StartingOpCount: 0, }, + stellarSel: { + MCMAddress: strings.Repeat("e", 64), + StartingOpCount: 0, + AdditionalFields: stellarAdditional, + }, }, setup: func(accessor *mocks.ChainAccessor) { accessor.EXPECT().EVMClient(mock.Anything).Return(nil, true) @@ -99,6 +113,7 @@ func TestBuildTimelockExecutors(t *testing.T) { accessor.EXPECT().SuiSigner(mock.Anything).Return(nil, true) accessor.EXPECT().TonClient(mock.Anything).Return(tonClient, true) accessor.EXPECT().TonSigner(mock.Anything).Return(tonSigner, true) + accessor.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) }, want: map[mcmstypes.ChainSelector]mcmssdk.TimelockExecutor{ evmSelector: evmExecutor, @@ -106,6 +121,7 @@ func TestBuildTimelockExecutors(t *testing.T) { aptosSelector: aptosExecutor, suiSelector: suiExecutor, tonSelector: tonExecutor, + stellarSel: stellarExecutor, }, }, } @@ -120,10 +136,36 @@ func TestBuildTimelockExecutors(t *testing.T) { got, err := BuildTimelockExecutors(chainAccessor, tt.chainMetadata, mcmstypes.TimelockActionSchedule) if tt.wantErr == "" { require.NoError(t, err) - require.Empty(t, cmp.Diff(tt.want, got)) + require.Empty(t, cmp.Diff(tt.want, got, + cmp.AllowUnexported(stellar.TimelockExecutor{}, stellar.TimelockInspector{}), + )) } else { require.ErrorContains(t, err, tt.wantErr) } }) } } + +func TestBuildTimelockExecutors_StellarMissingInvoker(t *testing.T) { + t.Parallel() + sel := mcmstypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + additional, err := json.Marshal(map[string]string{"timelockExecutor": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"}) + require.NoError(t, err) + access := mocks.NewChainAccessor(t) + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, false) + _, err = BuildTimelockExecutors(access, map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata{ + sel: {MCMAddress: strings.Repeat("a", 64), AdditionalFields: additional}, + }, mcmstypes.TimelockActionSchedule) + require.ErrorContains(t, err, "missing stellar invoker") +} + +func TestBuildTimelockExecutors_StellarMissingExecutorInMetadata(t *testing.T) { + t.Parallel() + sel := mcmstypes.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + access := mocks.NewChainAccessor(t) + access.EXPECT().StellarInvoker(mock.Anything).Return(nil, true) + _, err := BuildTimelockExecutors(access, map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata{ + sel: {MCMAddress: strings.Repeat("a", 64), AdditionalFields: []byte(`{}`)}, + }, mcmstypes.TimelockActionSchedule) + require.ErrorContains(t, err, "timelockExecutor") +} diff --git a/docs/docs/contributing/integrating-new-chain-guide.md b/docs/docs/contributing/integrating-new-chain-guide.md index fe8ac5da..94b10d47 100644 --- a/docs/docs/contributing/integrating-new-chain-guide.md +++ b/docs/docs/contributing/integrating-new-chain-guide.md @@ -45,12 +45,12 @@ All chain family integrations must implement interfaces defined in the `/sdk` fo ### ChainAccess Registry Adapter -The MCMS SDK intentionally avoids importing chain-registry implementations (for example, the Chainlink Deployments Framework). Instead, shared tooling must expose the `ChainAccess` interface defined in [`sdk/chainclient.go`](https://github.com/smartcontractkit/mcms/blob/main/sdk/chainclient.go) so inspectors and proposal tooling can fetch RPC clients without pulling in external dependencies. +The MCMS SDK intentionally avoids importing chain-registry implementations (for example, the Chainlink Deployments Framework). Instead, shared tooling must expose the `ChainAccessor` interface defined in [`chainwrappers/chainaccessor.go`](https://github.com/smartcontractkit/mcms/blob/main/chainwrappers/chainaccessor.go) so inspectors and proposal tooling can fetch RPC clients without pulling in external dependencies. Your adapter should: -- Implement `Selectors() []uint64` and the per-family lookup helpers (`EVMClient`, `SolanaClient`, `AptosClient`, `Sui`) by delegating to your registry. -- Return chain clients that satisfy `bind.ContractBackend`/`bind.DeployBackend` for EVM, `*solrpc.Client` for Solana, `aptoslib.AptosRpcClient` for Aptos, and `(sui.ISuiAPI, SuiSigner)` for Sui, etc. +- Implement `Selectors() []uint64` and the per-family lookup helpers (`EVMClient`, `SolanaClient`, `AptosClient`, `Sui`, `TonClient` / `TonSigner`, `StellarInvoker`, …) by delegating to your registry. +- Return chain clients that satisfy `bind.ContractBackend`/`bind.DeployBackend` for EVM, `*solrpc.Client` for Solana, `aptoslib.AptosRpcClient` for Aptos, `(sui.ISuiAPI, SuiSigner)` for Sui, and for Stellar a [`github.com/smartcontractkit/chainlink-stellar/bindings.Invoker`](https://github.com/smartcontractkit/chainlink-stellar/tree/main/bindings) used by `sdk/stellar` to simulate and submit Soroban calls. - Live in the repository that already depends on your registry (e.g., CLDF or deployment tooling) so `mcms` itself stays agnostic. This boundary keeps MCMS reusable across environments while still allowing downstream systems to map their chain catalogs into MCMS inspectors. @@ -74,6 +74,7 @@ The `Executor` is the primary interface for executing MCMS operations on your ch - [Solana Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/executor.go) - Uses Solana program instructions - [Aptos Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/executor.go) - Uses Move entry functions - [Sui Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/executor.go) - Uses Sui Move transactions +- [Stellar Executor](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/executor.go) - Uses Soroban `execute` / `set_root` via `chainlink-stellar` `bindings.Invoker` **Key Considerations:** @@ -100,6 +101,7 @@ The `Inspector` queries on-chain state of MCMS contracts. - [Solana Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/inspector.go) - [Aptos Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/inspector.go) - [Sui Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/inspector.go) +- [Stellar Inspector](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/inspector.go) **Key Considerations:** @@ -124,6 +126,7 @@ The `Encoder` creates chain-specific hashes for operations and metadata. - [Solana Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/encoder.go) - Uses Borsh serialization + SHA256 - [Aptos Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/encoder.go) - Uses BCS serialization + SHA3-256 - [Sui Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/encoder.go) - Uses BCS serialization + Blake2b +- [Stellar Encoder](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/encoder.go) - Uses keccak256 over Solidity-ABI-shaped `StellarOp` / metadata (see on-chain `abi_encoding.rs` in chainlink-stellar) **Key Considerations:** @@ -148,6 +151,7 @@ The `ConfigTransformer` converts between chain-agnostic `types.Config` and chain - [Solana ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/config_transformer.go) - [Aptos ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/config_transformer.go) - [Sui ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/config_transformer.go) +- [Stellar ConfigTransformer](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/config_transformer.go) **Key Considerations:** @@ -171,6 +175,7 @@ The `Configurer` updates MCMS contract configuration on-chain. - [Solana Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/configurer.go) - [Aptos Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/configurer.go) - [Sui Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/configurer.go) +- [Stellar Configurer](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/configurer.go) **Key Considerations:** @@ -282,6 +287,8 @@ Converts batch operations into chain-specific timelock operations. - [Aptos TimelockConverter](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/timelock_converter.go) - [Sui TimelockConverter](https://github.com/smartcontractkit/mcms/blob/main/sdk/sui/timelock_converter.go) +**Note:** Stellar does not yet ship `TimelockExecutor` / `TimelockInspector` / `TimelockConverter` in mcms; `chainwrappers.BuildConverter` returns an error for Stellar selectors until those implementations exist. + ## Implementation Guidelines ### Package Structure @@ -311,6 +318,8 @@ sdk/ ā”œā”€ā”€ timelock_inspector_test.go ā”œā”€ā”€ timelock_converter.go # TimelockConverter (if supported) ā”œā”€ā”€ timelock_converter_test.go + ā”œā”€ā”€ validation.go # ValidateChainMetadata / ValidateAdditionalFields (optional) + ā”œā”€ā”€ validation_test.go ā”œā”€ā”€ transaction.go # Transaction utilities ā”œā”€ā”€ transaction_test.go ā”œā”€ā”€ utils.go # Chain-specific helpers @@ -350,7 +359,7 @@ sdk/ #### Additional Fields in Operations - Use `types.Transaction.AdditionalFields` (JSON) for chain-specific data -- Examples: Solana account lists, Aptos type arguments, Sui object references +- Examples: Solana account lists, Aptos type arguments, Sui object references, Stellar optional `value` (32-byte hex string for `StellarOp.value` when non-zero) - Document expected structure for your chain ### Error Handling @@ -365,6 +374,10 @@ Use the `/sdk/errors/` package for standardized error handling: - Return specific errors for common failure cases (insufficient signatures, invalid proof, etc.) - Use typed errors for cases that callers may need to handle specifically +### Proposal validation (`validation.go`) + +Root-level [`validation.go`](https://github.com/smartcontractkit/mcms/blob/main/validation.go) dispatches `ValidateChainMetadata` / `ValidateAdditionalFields` to your SDK package for families that need extra checks (for example Solana access-controller JSON, Sui object IDs, Stellar MCMS contract id parsing and optional `value` JSON). Wire new `case` branches when you add a chain family. + ## Testing Requirements ### Unit Tests @@ -376,6 +389,7 @@ Each interface implementation needs a corresponding `_test.go` file with compreh - [EVM Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/evm/executor_test.go) | [Mock Examples](https://github.com/smartcontractkit/mcms/tree/main/sdk/evm/mocks) - [Solana Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/solana/encoder_test.go) | [Mocks](https://github.com/smartcontractkit/mcms/tree/main/sdk/solana/mocks) - [Aptos Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/aptos/inspector_test.go) | [Mocks](https://github.com/smartcontractkit/mcms/tree/main/sdk/aptos/mocks) +- [Stellar Tests](https://github.com/smartcontractkit/mcms/blob/main/sdk/stellar/encoder_test.go) — table-driven tests alongside `chainwrappers` build tests (`executors_test.go`, `inspectors_test.go`) ### E2E Tests @@ -408,4 +422,5 @@ When implementing your integration, refer to these existing implementations: 2. **Solana**: [sdk/solana/](https://github.com/smartcontractkit/mcms/tree/main/sdk/solana) - Excellent example of chain-specific complexity 3. **Aptos**: [sdk/aptos/](https://github.com/smartcontractkit/mcms/tree/main/sdk/aptos) - Move-based chain without simulation 4. **Sui**: [sdk/sui/](https://github.com/smartcontractkit/mcms/tree/main/sdk/sui) - Recent addition with good patterns +5. **Stellar**: [sdk/stellar/](https://github.com/smartcontractkit/mcms/tree/main/sdk/stellar) - Soroban MCMS (encoder, inspector, executor, configurer); timelock parity and e2e still evolving diff --git a/e2e/config.stellar.toml b/e2e/config.stellar.toml new file mode 100644 index 00000000..8c954887 --- /dev/null +++ b/e2e/config.stellar.toml @@ -0,0 +1,10 @@ +# Soroban RPC via chainlink-testing-framework Stellar stack (Docker stellar/quickstart). +# Port is assigned at runtime in e2e/tests/setup.go (freeport) before starting the network. +# +# Run from repo root (go test cwd is e2e/tests for this package): +# CTF_CONFIGS=../config.stellar.toml go test -tags=e2e -run=TestStellarSuite ./e2e/tests +# +# CI uses the same relative path from the e2e job working directory. + +[stellar_config] +type = "stellar" diff --git a/e2e/tests/runner_test.go b/e2e/tests/runner_test.go index a04ff50e..7fa9ddc2 100644 --- a/e2e/tests/runner_test.go +++ b/e2e/tests/runner_test.go @@ -10,6 +10,7 @@ import ( aptose2e "github.com/smartcontractkit/mcms/e2e/tests/aptos" evme2e "github.com/smartcontractkit/mcms/e2e/tests/evm" solanae2e "github.com/smartcontractkit/mcms/e2e/tests/solana" + stellare2e "github.com/smartcontractkit/mcms/e2e/tests/stellar" suie2e "github.com/smartcontractkit/mcms/e2e/tests/sui" tone2e "github.com/smartcontractkit/mcms/e2e/tests/ton" ) @@ -41,6 +42,10 @@ func TestSuiSuite(t *testing.T) { suite.Run(t, new(suie2e.MCMSUserUpgradeTestSuite)) } +func TestStellarSuite(t *testing.T) { + suite.Run(t, new(stellare2e.SmokeSuite)) +} + func TestTONSuite(t *testing.T) { suite.Run(t, new(tone2e.SigningTestSuite)) suite.Run(t, new(tone2e.SetConfigTestSuite)) diff --git a/e2e/tests/setup.go b/e2e/tests/setup.go index 65adf90f..7f3f5a52 100644 --- a/e2e/tests/setup.go +++ b/e2e/tests/setup.go @@ -37,12 +37,13 @@ var ( // Config defines the blockchain configuration type Config struct { - BlockchainA *blockchain.Input `toml:"evm_config_a"` - BlockchainB *blockchain.Input `toml:"evm_config_b"` - SolanaChain *blockchain.Input `toml:"solana_config"` - AptosChain *blockchain.Input `toml:"aptos_config"` - SuiChain *blockchain.Input `toml:"sui_config"` - TonChain *blockchain.Input `toml:"ton_config"` + BlockchainA *blockchain.Input `toml:"evm_config_a"` + BlockchainB *blockchain.Input `toml:"evm_config_b"` + SolanaChain *blockchain.Input `toml:"solana_config"` + AptosChain *blockchain.Input `toml:"aptos_config"` + SuiChain *blockchain.Input `toml:"sui_config"` + TonChain *blockchain.Input `toml:"ton_config"` + StellarChain *blockchain.Input `toml:"stellar_config"` Settings struct { PrivateKeys []string `toml:"private_keys"` @@ -52,18 +53,20 @@ type Config struct { // TestSetup holds common setup for E2E test suites type TestSetup struct { - ClientA *ethclient.Client - ClientB *ethclient.Client - SolanaClient *rpc.Client - SolanaWSClient *ws.Client - AptosRPCClient *aptos.NodeClient - SolanaBlockchain *blockchain.Output - AptosBlockchain *blockchain.Output - SuiClient sui.ISuiAPI - SuiBlockchain *blockchain.Output - SuiNodeURL string - TonClient *ton.APIClient - TonBlockchain *blockchain.Output + ClientA *ethclient.Client + ClientB *ethclient.Client + SolanaClient *rpc.Client + SolanaWSClient *ws.Client + AptosRPCClient *aptos.NodeClient + SolanaBlockchain *blockchain.Output + AptosBlockchain *blockchain.Output + SuiClient sui.ISuiAPI + SuiBlockchain *blockchain.Output + SuiNodeURL string + TonClient *ton.APIClient + TonBlockchain *blockchain.Output + StellarRPCURL string + StellarBlockchain *blockchain.Output Config } @@ -236,20 +239,45 @@ func InitializeSharedTestSetup(t *testing.T) *TestSetup { t.Logf("Initialized TON RPC client @ %s", nodeURL) } + var ( + stellarBlockchainOutput *blockchain.Output + stellarRPCURL string + ) + if in.StellarChain != nil { + ports := freeport.GetN(t, 1) + in.StellarChain.Port = strconv.Itoa(ports[0]) + + stellarBlockchainOutput, err = blockchain.NewBlockchainNetwork(in.StellarChain) + require.NoError(t, err, "Failed to initialize Stellar blockchain") + + stellarRPCURL = stellarBlockchainOutput.Nodes[0].ExternalHTTPUrl + t.Logf("Initialized Stellar Soroban RPC @ %s", stellarRPCURL) + if stellarBlockchainOutput.NetworkSpecificData != nil && + stellarBlockchainOutput.NetworkSpecificData.StellarNetwork != nil { + sn := stellarBlockchainOutput.NetworkSpecificData.StellarNetwork + t.Logf("Stellar network passphrase: %s", sn.NetworkPassphrase) + if sn.FriendbotURL != "" { + t.Logf("Stellar friendbot @ %s", sn.FriendbotURL) + } + } + } + sharedSetup = &TestSetup{ - ClientA: ethClientA, - ClientB: ethClientB, - SolanaClient: solanaClient, - SolanaWSClient: solanaWsClient, - AptosRPCClient: aptosClient, - SolanaBlockchain: solanaBlockChainOutput, - AptosBlockchain: aptosBlockchainOutput, - SuiClient: suiClient, - SuiBlockchain: suiBlockchainOutput, - SuiNodeURL: suiNodeURL, - TonClient: tonClient, - TonBlockchain: tonBlockchainOutput, - Config: *in, + ClientA: ethClientA, + ClientB: ethClientB, + SolanaClient: solanaClient, + SolanaWSClient: solanaWsClient, + AptosRPCClient: aptosClient, + SolanaBlockchain: solanaBlockChainOutput, + AptosBlockchain: aptosBlockchainOutput, + SuiClient: suiClient, + SuiBlockchain: suiBlockchainOutput, + SuiNodeURL: suiNodeURL, + TonClient: tonClient, + TonBlockchain: tonBlockchainOutput, + StellarRPCURL: stellarRPCURL, + StellarBlockchain: stellarBlockchainOutput, + Config: *in, } }) diff --git a/e2e/tests/stellar/smoke.go b/e2e/tests/stellar/smoke.go new file mode 100644 index 00000000..61d18d3a --- /dev/null +++ b/e2e/tests/stellar/smoke.go @@ -0,0 +1,50 @@ +//go:build e2e + +package stellare2e + +import ( + "bytes" + "io" + "net/http" + "strings" + + "github.com/stretchr/testify/suite" + + e2e "github.com/smartcontractkit/mcms/e2e/tests" +) + +// SmokeSuite is iteration-1 Stellar e2e: Soroban RPC reachability only (no MCMS deploy). +// Extend with contract deploy + MCMS flows in a later iteration. +type SmokeSuite struct { + suite.Suite + e2e.TestSetup +} + +func (s *SmokeSuite) SetupSuite() { + s.TestSetup = *e2e.InitializeSharedTestSetup(s.T()) + s.Require().NotEmpty(s.StellarRPCURL, "CTF_CONFIGS must include stellar_config (see e2e/config.stellar.toml)") +} + +func (s *SmokeSuite) TestSorobanRPCGetHealth() { + const body = `{"jsonrpc":"2.0","id":1,"method":"getHealth"}` + resp, err := http.Post(s.StellarRPCURL, "application/json", bytes.NewReader([]byte(body))) + s.Require().NoError(err) + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, resp.StatusCode, "body=%s", string(raw)) + s.Require().Contains(string(raw), `"result"`, "body=%s", string(raw)) +} + +func (s *SmokeSuite) TestSorobanRPCGetLatestLedger() { + body := `{"jsonrpc":"2.0","id":2,"method":"getLatestLedger","params":{}}` + resp, err := http.Post(s.StellarRPCURL, "application/json", strings.NewReader(body)) + s.Require().NoError(err) + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, resp.StatusCode, "body=%s", string(raw)) + s.Require().Contains(string(raw), `"result"`, "body=%s", string(raw)) +} diff --git a/factory.go b/factory.go index 5d7a6a90..954fd9d7 100644 --- a/factory.go +++ b/factory.go @@ -11,6 +11,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" @@ -61,6 +62,12 @@ func newEncoder( txCount, overridePreviousRoot, ) + case chainsel.FamilyStellar: + encoder = stellar.NewEncoder( + csel, + txCount, + overridePreviousRoot, + ) } return encoder, nil @@ -90,6 +97,8 @@ func operationIDFn(_ context.Context, csel types.ChainSelector) (sdk.OperationID return sui.OperationID, nil case chainsel.FamilyTon: return ton.OperationID, nil + case chainsel.FamilyStellar: + return stellar.OperationID, nil default: return nil, fmt.Errorf("unsupported chain family %s", family) } diff --git a/factory_test.go b/factory_test.go index 9893473f..4f21d61c 100644 --- a/factory_test.go +++ b/factory_test.go @@ -13,6 +13,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" ) @@ -95,6 +96,16 @@ func TestNewEncoder(t *testing.T) { OverridePreviousRoot: false, }, }, + { + name: "success: returns a Stellar encoder", + giveSelector: chaintest.Chain9Selector, + giveIsSim: false, + want: &stellar.Encoder{ + TxCount: giveTxCount, + ChainSelector: chaintest.Chain9Selector, + OverridePreviousRoot: false, + }, + }, { name: "failure: chain not found for selector", giveSelector: chaintest.ChainInvalidSelector, diff --git a/go.mod b/go.mod index 62ba62c5..914faa5d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.2 //nolint:gomoddirectives // allow replace directive replace github.com/gogo/protobuf => github.com/regen-network/protobuf v1.3.3-alpha.regen.1 +// Local chainlink-stellar bindings (private module); adjust path if repos are not siblings of mcms. +replace github.com/smartcontractkit/chainlink-stellar/bindings => ../chainlink-stellar/bindings + require ( github.com/aptos-labs/aptos-go-sdk v1.12.1 github.com/block-vision/sui-go-sdk v1.2.1 @@ -20,6 +23,7 @@ require ( github.com/smartcontractkit/chainlink-aptos v0.0.0-20260428085939-5c70de12dbfc github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260121163256-85accaf3d28d github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20250912190424-fd2e35d7deb5 + github.com/smartcontractkit/chainlink-stellar/bindings v0.0.0-20260505155616-666b86bf48b9 github.com/smartcontractkit/chainlink-sui v0.0.0-20260428231901-a394dd724761 github.com/smartcontractkit/chainlink-testing-framework/framework v0.15.19 github.com/smartcontractkit/chainlink-ton v0.0.0-20260219201907-054376f21418 diff --git a/go.sum b/go.sum index 834ec715..4d87b0c4 100644 --- a/go.sum +++ b/go.sum @@ -693,6 +693,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+x github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc= +github.com/xdrpp/goxdr v0.1.1/go.mod h1:dXo1scL/l6s7iME1gxHWo2XCppbHEKZS7m/KyYWkNzA= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xssnick/tonutils-go v1.14.1 h1:zV/iVYl/h3hArS+tPsd9XrSFfGert3r21caMltPSeHg= diff --git a/internal/testutils/chaintest/testchain.go b/internal/testutils/chaintest/testchain.go index 669da837..8b9cdf9a 100644 --- a/internal/testutils/chaintest/testchain.go +++ b/internal/testutils/chaintest/testchain.go @@ -39,6 +39,10 @@ var ( Chain8Selector = types.ChainSelector(Chain8RawSelector) Chain8StarknetID = chainsel.ETHEREUM_MAINNET_STARKNET_1.ChainID + Chain9RawSelector = chainsel.STELLAR_TESTNET.Selector + Chain9Selector = types.ChainSelector(Chain9RawSelector) + Chain9StellarID = chainsel.STELLAR_TESTNET.ChainID + // ChainInvalidSelector is a chain selector that doesn't exist. ChainInvalidSelector = types.ChainSelector(0) ) diff --git a/sdk/aptos/mocks/aptos/rpcclient.go b/sdk/aptos/mocks/aptos/rpcclient.go index 6331b508..e018a1e1 100644 --- a/sdk/aptos/mocks/aptos/rpcclient.go +++ b/sdk/aptos/mocks/aptos/rpcclient.go @@ -3,12 +3,11 @@ package mock_aptossdk import ( + time "time" + aptos "github.com/aptos-labs/aptos-go-sdk" api "github.com/aptos-labs/aptos-go-sdk/api" - mock "github.com/stretchr/testify/mock" - - time "time" ) // AptosRpcClient is an autogenerated mock type for the AptosRpcClient type diff --git a/sdk/aptos/mocks/aptos/transactionsigner.go b/sdk/aptos/mocks/aptos/transactionsigner.go index ee880ac1..03d7532b 100644 --- a/sdk/aptos/mocks/aptos/transactionsigner.go +++ b/sdk/aptos/mocks/aptos/transactionsigner.go @@ -5,7 +5,6 @@ package mock_aptossdk import ( aptos "github.com/aptos-labs/aptos-go-sdk" crypto "github.com/aptos-labs/aptos-go-sdk/crypto" - mock "github.com/stretchr/testify/mock" ) diff --git a/sdk/aptos/mocks/mcms/mcms.go b/sdk/aptos/mocks/mcms/mcms.go index 60ef397e..c081a43b 100644 --- a/sdk/aptos/mocks/mcms/mcms.go +++ b/sdk/aptos/mocks/mcms/mcms.go @@ -5,17 +5,12 @@ package mock_mcms import ( aptos "github.com/aptos-labs/aptos-go-sdk" - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms" - module_mcms_account "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_account" - module_mcms_deployer "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_deployer" - module_mcms_executor "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_executor" - module_mcms_registry "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_registry" + mock "github.com/stretchr/testify/mock" ) // MCMS is an autogenerated mock type for the MCMS type diff --git a/sdk/aptos/mocks/mcms/mcms/mcms.go b/sdk/aptos/mocks/mcms/mcms/mcms.go index 78205145..b37edfab 100644 --- a/sdk/aptos/mocks/mcms/mcms/mcms.go +++ b/sdk/aptos/mocks/mcms/mcms/mcms.go @@ -3,16 +3,13 @@ package mock_module_mcms import ( - aptos "github.com/aptos-labs/aptos-go-sdk" - api "github.com/aptos-labs/aptos-go-sdk/api" - big "math/big" + aptos "github.com/aptos-labs/aptos-go-sdk" + api "github.com/aptos-labs/aptos-go-sdk/api" bind "github.com/smartcontractkit/chainlink-aptos/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // MCMSInterface is an autogenerated mock type for the MCMSInterface type diff --git a/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go b/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go index 87e1c8e1..cff30474 100644 --- a/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go +++ b/sdk/aptos/mocks/mcms/mcms/mcms_encoder.go @@ -6,12 +6,9 @@ import ( big "math/big" aptos "github.com/aptos-labs/aptos-go-sdk" - bind "github.com/smartcontractkit/chainlink-aptos/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // MCMSEncoder is an autogenerated mock type for the MCMSEncoder type diff --git a/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go b/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go index f84548aa..6f6ffdf1 100644 --- a/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go +++ b/sdk/aptos/mocks/mcms/mcms_executor/mcms_executor.go @@ -3,16 +3,13 @@ package mock_module_mcms_executor import ( - aptos "github.com/aptos-labs/aptos-go-sdk" - api "github.com/aptos-labs/aptos-go-sdk/api" - big "math/big" + aptos "github.com/aptos-labs/aptos-go-sdk" + api "github.com/aptos-labs/aptos-go-sdk/api" bind "github.com/smartcontractkit/chainlink-aptos/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms_executor "github.com/smartcontractkit/chainlink-aptos/bindings/mcms/mcms_executor" + mock "github.com/stretchr/testify/mock" ) // MCMSExecutorInterface is an autogenerated mock type for the MCMSExecutorInterface type diff --git a/sdk/evm/bindings/mocks/call_proxy_interface.go b/sdk/evm/bindings/mocks/call_proxy_interface.go index 47699ab0..980a62cc 100644 --- a/sdk/evm/bindings/mocks/call_proxy_interface.go +++ b/sdk/evm/bindings/mocks/call_proxy_interface.go @@ -4,15 +4,11 @@ package mocks import ( bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" - bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" - common "github.com/ethereum/go-ethereum/common" - + types "github.com/ethereum/go-ethereum/core/types" event "github.com/ethereum/go-ethereum/event" - + bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" mock "github.com/stretchr/testify/mock" - - types "github.com/ethereum/go-ethereum/core/types" ) // CallProxyInterface is an autogenerated mock type for the CallProxyInterface type diff --git a/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go b/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go index 1b824824..903ba406 100644 --- a/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go +++ b/sdk/evm/bindings/mocks/many_chain_multi_sig_interface.go @@ -6,15 +6,11 @@ import ( big "math/big" bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" - bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" - common "github.com/ethereum/go-ethereum/common" - + types "github.com/ethereum/go-ethereum/core/types" event "github.com/ethereum/go-ethereum/event" - + bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" mock "github.com/stretchr/testify/mock" - - types "github.com/ethereum/go-ethereum/core/types" ) // ManyChainMultiSigInterface is an autogenerated mock type for the ManyChainMultiSigInterface type diff --git a/sdk/evm/bindings/mocks/rbac_timelock_interface.go b/sdk/evm/bindings/mocks/rbac_timelock_interface.go index 511fa3ae..e8905385 100644 --- a/sdk/evm/bindings/mocks/rbac_timelock_interface.go +++ b/sdk/evm/bindings/mocks/rbac_timelock_interface.go @@ -6,15 +6,11 @@ import ( big "math/big" bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" - bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" - common "github.com/ethereum/go-ethereum/common" - + types "github.com/ethereum/go-ethereum/core/types" event "github.com/ethereum/go-ethereum/event" - + bindings "github.com/smartcontractkit/mcms/sdk/evm/bindings" mock "github.com/stretchr/testify/mock" - - types "github.com/ethereum/go-ethereum/core/types" ) // RBACTimelockInterface is an autogenerated mock type for the RBACTimelockInterface type diff --git a/sdk/evm/mocks/contract_deploy_backend.go b/sdk/evm/mocks/contract_deploy_backend.go index fd248d1b..d189137f 100644 --- a/sdk/evm/mocks/contract_deploy_backend.go +++ b/sdk/evm/mocks/contract_deploy_backend.go @@ -6,13 +6,11 @@ import ( context "context" big "math/big" - common "github.com/ethereum/go-ethereum/common" - ethereum "github.com/ethereum/go-ethereum" - - mock "github.com/stretchr/testify/mock" + common "github.com/ethereum/go-ethereum/common" types "github.com/ethereum/go-ethereum/core/types" + mock "github.com/stretchr/testify/mock" ) // ContractDeployBackend is an autogenerated mock type for the ContractDeployBackend type diff --git a/sdk/mocks/decoder.go b/sdk/mocks/decoder.go index 1df1d42c..0a627a8a 100644 --- a/sdk/mocks/decoder.go +++ b/sdk/mocks/decoder.go @@ -4,9 +4,8 @@ package mocks import ( sdk "github.com/smartcontractkit/mcms/sdk" - mock "github.com/stretchr/testify/mock" - types "github.com/smartcontractkit/mcms/types" + mock "github.com/stretchr/testify/mock" ) // Decoder is an autogenerated mock type for the Decoder type diff --git a/sdk/mocks/executor.go b/sdk/mocks/executor.go index cd4f2699..596cf7ee 100644 --- a/sdk/mocks/executor.go +++ b/sdk/mocks/executor.go @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/inspector.go b/sdk/mocks/inspector.go index 68632be6..a20598f4 100644 --- a/sdk/mocks/inspector.go +++ b/sdk/mocks/inspector.go @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/operation_id.go b/sdk/mocks/operation_id.go new file mode 100644 index 00000000..8a54a3b9 --- /dev/null +++ b/sdk/mocks/operation_id.go @@ -0,0 +1,98 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" + + types "github.com/smartcontractkit/mcms/types" +) + +// OperationID is an autogenerated mock type for the OperationID type +type OperationID struct { + mock.Mock +} + +type OperationID_Expecter struct { + mock *mock.Mock +} + +func (_m *OperationID) EXPECT() *OperationID_Expecter { + return &OperationID_Expecter{mock: &_m.Mock} +} + +// Execute provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *OperationID) Execute(_a0 types.BatchOperation, _a1 types.TimelockAction, _a2 common.Hash, _a3 common.Hash) (common.Hash, error) { + ret := _m.Called(_a0, _a1, _a2, _a3) + + if len(ret) == 0 { + panic("no return value specified for Execute") + } + + var r0 common.Hash + var r1 error + if rf, ok := ret.Get(0).(func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) (common.Hash, error)); ok { + return rf(_a0, _a1, _a2, _a3) + } + if rf, ok := ret.Get(0).(func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) common.Hash); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Hash) + } + } + + if rf, ok := ret.Get(1).(func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) error); ok { + r1 = rf(_a0, _a1, _a2, _a3) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OperationID_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type OperationID_Execute_Call struct { + *mock.Call +} + +// Execute is a helper method to define mock.On call +// - _a0 types.BatchOperation +// - _a1 types.TimelockAction +// - _a2 common.Hash +// - _a3 common.Hash +func (_e *OperationID_Expecter) Execute(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 interface{}) *OperationID_Execute_Call { + return &OperationID_Execute_Call{Call: _e.mock.On("Execute", _a0, _a1, _a2, _a3)} +} + +func (_c *OperationID_Execute_Call) Run(run func(_a0 types.BatchOperation, _a1 types.TimelockAction, _a2 common.Hash, _a3 common.Hash)) *OperationID_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(types.BatchOperation), args[1].(types.TimelockAction), args[2].(common.Hash), args[3].(common.Hash)) + }) + return _c +} + +func (_c *OperationID_Execute_Call) Return(_a0 common.Hash, _a1 error) *OperationID_Execute_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *OperationID_Execute_Call) RunAndReturn(run func(types.BatchOperation, types.TimelockAction, common.Hash, common.Hash) (common.Hash, error)) *OperationID_Execute_Call { + _c.Call.Return(run) + return _c +} + +// NewOperationID creates a new instance of OperationID. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOperationID(t interface { + mock.TestingT + Cleanup(func()) +}) *OperationID { + mock := &OperationID{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sdk/mocks/simulator.go b/sdk/mocks/simulator.go index 9f3dda3e..866898dd 100644 --- a/sdk/mocks/simulator.go +++ b/sdk/mocks/simulator.go @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/timelock_configurer.go b/sdk/mocks/timelock_configurer.go new file mode 100644 index 00000000..feed7dc3 --- /dev/null +++ b/sdk/mocks/timelock_configurer.go @@ -0,0 +1,96 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + types "github.com/smartcontractkit/mcms/types" +) + +// TimelockConfigurer is an autogenerated mock type for the TimelockConfigurer type +type TimelockConfigurer struct { + mock.Mock +} + +type TimelockConfigurer_Expecter struct { + mock *mock.Mock +} + +func (_m *TimelockConfigurer) EXPECT() *TimelockConfigurer_Expecter { + return &TimelockConfigurer_Expecter{mock: &_m.Mock} +} + +// UpdateDelay provides a mock function with given fields: ctx, timelockAddress, newDelay +func (_m *TimelockConfigurer) UpdateDelay(ctx context.Context, timelockAddress string, newDelay uint64) (types.TransactionResult, error) { + ret := _m.Called(ctx, timelockAddress, newDelay) + + if len(ret) == 0 { + panic("no return value specified for UpdateDelay") + } + + var r0 types.TransactionResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, uint64) (types.TransactionResult, error)); ok { + return rf(ctx, timelockAddress, newDelay) + } + if rf, ok := ret.Get(0).(func(context.Context, string, uint64) types.TransactionResult); ok { + r0 = rf(ctx, timelockAddress, newDelay) + } else { + r0 = ret.Get(0).(types.TransactionResult) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, uint64) error); ok { + r1 = rf(ctx, timelockAddress, newDelay) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TimelockConfigurer_UpdateDelay_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateDelay' +type TimelockConfigurer_UpdateDelay_Call struct { + *mock.Call +} + +// UpdateDelay is a helper method to define mock.On call +// - ctx context.Context +// - timelockAddress string +// - newDelay uint64 +func (_e *TimelockConfigurer_Expecter) UpdateDelay(ctx interface{}, timelockAddress interface{}, newDelay interface{}) *TimelockConfigurer_UpdateDelay_Call { + return &TimelockConfigurer_UpdateDelay_Call{Call: _e.mock.On("UpdateDelay", ctx, timelockAddress, newDelay)} +} + +func (_c *TimelockConfigurer_UpdateDelay_Call) Run(run func(ctx context.Context, timelockAddress string, newDelay uint64)) *TimelockConfigurer_UpdateDelay_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(uint64)) + }) + return _c +} + +func (_c *TimelockConfigurer_UpdateDelay_Call) Return(_a0 types.TransactionResult, _a1 error) *TimelockConfigurer_UpdateDelay_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *TimelockConfigurer_UpdateDelay_Call) RunAndReturn(run func(context.Context, string, uint64) (types.TransactionResult, error)) *TimelockConfigurer_UpdateDelay_Call { + _c.Call.Return(run) + return _c +} + +// NewTimelockConfigurer creates a new instance of TimelockConfigurer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewTimelockConfigurer(t interface { + mock.TestingT + Cleanup(func()) +}) *TimelockConfigurer { + mock := &TimelockConfigurer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sdk/mocks/timelock_converter.go b/sdk/mocks/timelock_converter.go index 0689c0bd..341bcedb 100644 --- a/sdk/mocks/timelock_converter.go +++ b/sdk/mocks/timelock_converter.go @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/mocks/timelock_executor.go b/sdk/mocks/timelock_executor.go index bd509ac7..69dc95f9 100644 --- a/sdk/mocks/timelock_executor.go +++ b/sdk/mocks/timelock_executor.go @@ -6,7 +6,6 @@ import ( context "context" common "github.com/ethereum/go-ethereum/common" - mock "github.com/stretchr/testify/mock" types "github.com/smartcontractkit/mcms/types" diff --git a/sdk/solana/mocks/jsonrpcclient.go b/sdk/solana/mocks/jsonrpcclient.go index 2523cca1..d7e02b8a 100644 --- a/sdk/solana/mocks/jsonrpcclient.go +++ b/sdk/solana/mocks/jsonrpcclient.go @@ -7,7 +7,6 @@ import ( http "net/http" jsonrpc "github.com/gagliardetto/solana-go/rpc/jsonrpc" - mock "github.com/stretchr/testify/mock" ) diff --git a/sdk/stellar/config_transformer.go b/sdk/stellar/config_transformer.go new file mode 100644 index 00000000..2e448dfc --- /dev/null +++ b/sdk/stellar/config_transformer.go @@ -0,0 +1,116 @@ +package stellar + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/mcms/sdk" + sdkerrors "github.com/smartcontractkit/mcms/sdk/errors" + "github.com/smartcontractkit/mcms/types" + + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" +) + +var _ sdk.ConfigTransformer[*stellarmcms.Config, any] = (*ConfigTransformer)(nil) + +const maxUint8Value = 255 + +// ConfigTransformer maps Stellar MCMS on-chain config (get_config) to chain-agnostic types.Config. +type ConfigTransformer struct{} + +// NewConfigTransformer returns a new Stellar config transformer. +func NewConfigTransformer() *ConfigTransformer { + return &ConfigTransformer{} +} + +// ToConfig converts a Stellar ManyChainMultiSig-style config to chain-agnostic types.Config. +func (e *ConfigTransformer) ToConfig(onchainConfig *stellarmcms.Config) (*types.Config, error) { + if onchainConfig == nil { + return nil, fmt.Errorf("nil config") + } + + bindConfig := onchainConfig + + groupToSigners := make([][]common.Address, len(bindConfig.GroupQuorums)) + for _, signer := range bindConfig.Signers { + addr := paddedBytes32ToCommonAddress(signer.Addr) + groupToSigners[signer.Group] = append(groupToSigners[signer.Group], addr) + } + + groups := make([]types.Config, len(bindConfig.GroupQuorums)) + for i := range bindConfig.GroupQuorums { + quorum := bindConfig.GroupQuorums[i] + + signers := groupToSigners[i] + if signers == nil { + signers = []common.Address{} + } + + groups[i] = types.Config{ + Signers: signers, + GroupSigners: []types.Config{}, + Quorum: quorum, + } + } + + // Link nested groups; assumes each group's parent index is lower than the child index. + for i := 31; i >= 0; i-- { + parent := bindConfig.GroupParents[i] + if i > 0 && groups[i].Quorum > 0 { + groups[parent].GroupSigners = append([]types.Config{groups[i]}, groups[parent].GroupSigners...) + } + } + + if err := groups[0].Validate(); err != nil { + return nil, err + } + + return &groups[0], nil +} + +// ToChainConfig converts chain-agnostic types.Config into the Stellar contract config shape. +func (e *ConfigTransformer) ToChainConfig(cfg types.Config, _ any) (*stellarmcms.Config, error) { + groupQuorums, groupParents, signerAddrs, signerGroups, err := sdk.ExtractSetConfigInputs(&cfg) + if err != nil { + return nil, err + } + + if len(signerAddrs) > maxUint8Value { + return nil, sdkerrors.NewTooManySignersError(uint64(len(signerAddrs))) + } + + out := &stellarmcms.Config{} + copy(out.GroupQuorums[:], groupQuorums[:]) + copy(out.GroupParents[:], groupParents[:]) + + out.Signers = make([]stellarmcms.Signer, len(signerAddrs)) + + var idx uint32 + + for i, signerAddr := range signerAddrs { + out.Signers[i] = stellarmcms.Signer{ + Addr: commonAddressToPaddedBytes32(signerAddr), + Group: uint32(signerGroups[i]), + Index: idx, + } + + idx++ + } + + return out, nil +} + +func commonAddressToPaddedBytes32(a common.Address) [32]byte { + var out [32]byte + copy(out[evmAddressABIWordLeadingZeroBytes:], a[:]) + + return out +} + +func paddedBytes32ToCommonAddress(b [32]byte) common.Address { + var a common.Address + copy(a[:], b[evmAddressABIWordLeadingZeroBytes:]) + + return a +} diff --git a/sdk/stellar/configurer.go b/sdk/stellar/configurer.go new file mode 100644 index 00000000..9c4ddd61 --- /dev/null +++ b/sdk/stellar/configurer.go @@ -0,0 +1,66 @@ +package stellar + +import ( + "context" + "fmt" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Configurer = (*Configurer)(nil) + +// Configurer applies MCMS signer configuration on Stellar via Soroban set_config. +type Configurer struct { + ConfigTransformer + invoker bindings.Invoker +} + +// NewConfigurer returns a Configurer that submits set_config through invoker. +func NewConfigurer(invoker bindings.Invoker) *Configurer { + return &Configurer{invoker: invoker} +} + +// SetConfig invokes set_config with signer address vec, group vec, and group tree bytes32 words. +func (c *Configurer) SetConfig(ctx context.Context, mcmAddr string, cfg *types.Config, clearRoot bool) (types.TransactionResult, error) { + if cfg == nil { + return types.TransactionResult{}, fmt.Errorf("nil config") + } + + chainCfg, err := c.ToChainConfig(*cfg, nil) + if err != nil { + return types.TransactionResult{}, err + } + + signerAddresses, signerGroups := setConfigVecsFromChainConfig(chainCfg) + + client, err := newMCMSClient(c.invoker, mcmAddr) + if err != nil { + return types.TransactionResult{}, err + } + + if err := client.SetConfig(ctx, signerAddresses, signerGroups, chainCfg.GroupQuorums, chainCfg.GroupParents, clearRoot); err != nil { + return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err + } + + return stellarTransactionResult(c.invoker), nil +} + +func setConfigVecsFromChainConfig(chainCfg *stellarmcms.Config) (stellarmcms.SignerAddresses, stellarmcms.SignerGroups) { + n := len(chainCfg.Signers) + + addrs := stellarmcms.SignerAddresses{Inner: make([][32]byte, n)} + grps := stellarmcms.SignerGroups{Inner: make([]uint32, n)} + + for i := range chainCfg.Signers { + addrs.Inner[i] = chainCfg.Signers[i].Addr + grps.Inner[i] = chainCfg.Signers[i].Group + } + + return addrs, grps +} diff --git a/sdk/stellar/configurer_test.go b/sdk/stellar/configurer_test.go new file mode 100644 index 00000000..3af79900 --- /dev/null +++ b/sdk/stellar/configurer_test.go @@ -0,0 +1,41 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/mcms/types" +) + +func TestConfigurer_SetConfig_nilConfig(t *testing.T) { + t.Parallel() + + c := NewConfigurer(&recordingInvoker{}) + ctx := context.Background() + + _, err := c.SetConfig(ctx, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", nil, false) + require.Error(t, err) +} + +func TestConfigurer_SetConfig_routesToSetConfig(t *testing.T) { + t.Parallel() + + inv := &recordingInvoker{} + c := NewConfigurer(inv) + ctx := context.Background() + + cfg := &types.Config{ + Quorum: 1, + Signers: []common.Address{{1}}, + } + + res, err := c.SetConfig(ctx, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", cfg, true) + require.NoError(t, err) + require.Equal(t, chainsel.FamilyStellar, res.ChainFamily) + require.Equal(t, "set_config", inv.lastFn) +} diff --git a/sdk/stellar/constants.go b/sdk/stellar/constants.go index 7b74ab32..ff2fd2d0 100644 --- a/sdk/stellar/constants.go +++ b/sdk/stellar/constants.go @@ -25,6 +25,9 @@ const ( hexPrefixLen = 2 // "0x" / "0X" uint32ByteLen = 4 + + // Solidity ABI: address is 20 bytes right-aligned in a 32-byte word (same padding as Stellar MCMS signers). + evmAddressABIWordLeadingZeroBytes = 12 ) // Domain separators — must match chainlink-stellar contracts/mcms/src/constants.rs diff --git a/sdk/stellar/executor.go b/sdk/stellar/executor.go new file mode 100644 index 00000000..aa8b73f8 --- /dev/null +++ b/sdk/stellar/executor.go @@ -0,0 +1,184 @@ +package stellar + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/ethereum/go-ethereum/common" + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Executor = (*Executor)(nil) + +// Executor submits MCMS execute/set_root calls via [bindings.Invoker] (e.g. chainlink-stellar Deployer). +type Executor struct { + *Encoder + *Inspector + invoker bindings.Invoker +} + +// NewExecutor builds an Executor sharing invoker with read and write paths. +func NewExecutor(encoder *Encoder, invoker bindings.Invoker) *Executor { + return &Executor{ + Encoder: encoder, + Inspector: NewInspector(invoker), + invoker: invoker, + } +} + +// ExecuteOperation invokes Soroban `execute` with a [stellarmcms.StellarOp] and Merkle proof. +func (e *Executor) ExecuteOperation( + ctx context.Context, + metadata types.ChainMetadata, + nonce uint32, + proof []common.Hash, + op types.Operation, +) (types.TransactionResult, error) { + if e.Encoder == nil { + return types.TransactionResult{}, errors.New("failed to create sdk.Executor - encoder (sdk.Encoder) is nil") + } + + if uint64(nonce) >= uint40MaxExclusive { + return types.TransactionResult{}, fmt.Errorf("%w: nonce %d", ErrUint40Overflow, nonce) + } + + chainID, err := chainNetworkID(e.ChainSelector) + if err != nil { + return types.TransactionResult{}, err + } + + multisig, err := parseContractID(metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("mcmAddress: %w", err) + } + + to, err := parseContractID(op.Transaction.To) + if err != nil { + return types.TransactionResult{}, fmt.Errorf("transaction.to: %w", err) + } + + valueWord, err := parseValueWord(op.Transaction.AdditionalFields) + if err != nil { + return types.TransactionResult{}, err + } + + stellarOp := stellarmcms.StellarOp{ + ChainId: [32]byte(chainID), + Data: op.Transaction.Data, + Multisig: multisig, + Nonce: uint64(nonce), + To: to, + Value: valueWord, + } + + mcmsClient, err := newMCMSClient(e.invoker, metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, err + } + + mp := merkleProofFromHashes(proof) + + if err := mcmsClient.Execute(ctx, stellarOp, mp); err != nil { + return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err + } + + return stellarTransactionResult(e.invoker), nil +} + +// SetRoot invokes Soroban `set_root` with metadata and ECDSA signatures (contract ABI layout). +func (e *Executor) SetRoot( + ctx context.Context, + metadata types.ChainMetadata, + proof []common.Hash, + root [32]byte, + validUntil uint32, + sortedSignatures []types.Signature, +) (types.TransactionResult, error) { + if e.Encoder == nil { + return types.TransactionResult{}, errors.New("failed to create sdk.Executor - encoder (sdk.Encoder) is nil") + } + + if len(sortedSignatures) > math.MaxUint8 { + return types.TransactionResult{}, fmt.Errorf("too many signatures (max %d)", math.MaxUint8) + } + + rootMeta, err := e.stellarRootMetadata(metadata) + if err != nil { + return types.TransactionResult{}, err + } + + mcmsClient, err := newMCMSClient(e.invoker, metadata.MCMAddress) + if err != nil { + return types.TransactionResult{}, err + } + + sigVec := signatureVecFrom(sortedSignatures) + mp := merkleProofFromHashes(proof) + + if err := mcmsClient.SetRoot(ctx, root, validUntil, rootMeta, mp, sigVec); err != nil { + return types.TransactionResult{ChainFamily: chainsel.FamilyStellar}, err + } + + return stellarTransactionResult(e.invoker), nil +} + +func (e *Executor) stellarRootMetadata(metadata types.ChainMetadata) (stellarmcms.StellarRootMetadata, error) { + var zero stellarmcms.StellarRootMetadata + + if metadata.StartingOpCount >= uint40MaxExclusive { + return zero, fmt.Errorf("%w: startingOpCount %d", ErrUint40Overflow, metadata.StartingOpCount) + } + + post := metadata.StartingOpCount + e.TxCount + if post >= uint40MaxExclusive { + return zero, fmt.Errorf("%w: postOpCount (starting+txCount) %d", ErrUint40Overflow, post) + } + + chainID, err := chainNetworkID(e.ChainSelector) + if err != nil { + return zero, err + } + + multisig, err := parseContractID(metadata.MCMAddress) + if err != nil { + return zero, fmt.Errorf("mcmAddress: %w", err) + } + + return stellarmcms.StellarRootMetadata{ + ChainId: [32]byte(chainID), + Multisig: multisig, + OverridePreviousRoot: e.OverridePreviousRoot, + PreOpCount: metadata.StartingOpCount, + PostOpCount: post, + }, nil +} + +func merkleProofFromHashes(proof []common.Hash) stellarmcms.MerkleProof { + inner := make([][32]byte, len(proof)) + for i, p := range proof { + inner[i] = p + } + + return stellarmcms.MerkleProof{Inner: inner} +} + +func signatureVecFrom(sorted []types.Signature) stellarmcms.SignatureVec { + inner := make([]stellarmcms.Signature, len(sorted)) + for i, s := range sorted { + inner[i] = stellarmcms.Signature{ + R: s.R, + S: s.S, + V: uint32(s.V), + } + } + + return stellarmcms.SignatureVec{Inner: inner} +} diff --git a/sdk/stellar/executor_test.go b/sdk/stellar/executor_test.go new file mode 100644 index 00000000..241afec5 --- /dev/null +++ b/sdk/stellar/executor_test.go @@ -0,0 +1,117 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/mcms/types" +) + +type recordingInvoker struct { + lastFn string +} + +func (r *recordingInvoker) InvokeContract(_ context.Context, _ string, fn string, _ []xdr.ScVal) (*xdr.ScVal, error) { + r.lastFn = fn + + v := xdr.ScVal{} + + return &v, nil +} + +func (r *recordingInvoker) SimulateContract(context.Context, string, string, []xdr.ScVal) (*xdr.ScVal, error) { + v := xdr.ScVal{} + + return &v, nil +} + +func (r *recordingInvoker) GetEvents(context.Context, string, uint32, []string) ([]protocolrpc.EventInfo, error) { + return nil, nil +} + +func TestExecutor_ExecuteOperation_routesToExecute(t *testing.T) { + t.Parallel() + + sel := types.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + + enc := NewEncoder(sel, 0, false) + inv := &recordingInvoker{} + ex := NewExecutor(enc, inv) + + ctx := context.Background() + + md := types.ChainMetadata{ + StartingOpCount: 0, + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + } + + op := types.Operation{ + Transaction: types.Transaction{ + To: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + Data: []byte{1, 2, 3}, + }, + } + + res, err := ex.ExecuteOperation(ctx, md, 0, []common.Hash{{}}, op) + require.NoError(t, err) + require.Equal(t, chainsel.FamilyStellar, res.ChainFamily) + require.Equal(t, "execute", inv.lastFn) +} + +func TestExecutor_SetRoot_routesToSetRoot(t *testing.T) { + t.Parallel() + + sel := types.ChainSelector(chainsel.STELLAR_TESTNET.Selector) + + enc := NewEncoder(sel, 1, false) + inv := &recordingInvoker{} + ex := NewExecutor(enc, inv) + + ctx := context.Background() + + md := types.ChainMetadata{ + StartingOpCount: 1, + MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + } + + sig := types.Signature{ + R: common.Hash{1}, + S: common.Hash{2}, + V: 27, + } + + res, err := ex.SetRoot(ctx, md, []common.Hash{{}}, [32]byte{9}, 100, []types.Signature{sig}) + require.NoError(t, err) + require.Equal(t, chainsel.FamilyStellar, res.ChainFamily) + require.Equal(t, "set_root", inv.lastFn) +} + +func TestMerkleProofFromHashes_roundTrip(t *testing.T) { + t.Parallel() + + proof := []common.Hash{{1}, {2}} + mp := merkleProofFromHashes(proof) + require.Len(t, mp.Inner, 2) + require.Equal(t, proof[0], common.Hash(mp.Inner[0])) + require.Equal(t, proof[1], common.Hash(mp.Inner[1])) +} + +func TestSignatureVecFrom_preservesComponents(t *testing.T) { + t.Parallel() + + sigs := []types.Signature{ + {R: common.Hash{1}, S: common.Hash{2}, V: 28}, + } + vec := signatureVecFrom(sigs) + require.Len(t, vec.Inner, 1) + require.Equal(t, stellarmcms.Signature{R: sigs[0].R, S: sigs[0].S, V: 28}, vec.Inner[0]) +} diff --git a/sdk/stellar/inspector.go b/sdk/stellar/inspector.go new file mode 100644 index 00000000..684bcbe9 --- /dev/null +++ b/sdk/stellar/inspector.go @@ -0,0 +1,111 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/stellar/go-stellar-sdk/strkey" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.Inspector = (*Inspector)(nil) + +// Inspector reads MCMS contract state on Stellar via Soroban simulation (bindings.Invoker). +type Inspector struct { + ConfigTransformer + invoker bindings.Invoker +} + +// NewInspector constructs an Inspector that uses invoker for read-only SimulateContract calls. +func NewInspector(invoker bindings.Invoker) *Inspector { + return &Inspector{ + invoker: invoker, + } +} + +func (i *Inspector) contractClient(mcmAddr string) (*stellarmcms.McmsClient, error) { + return newMCMSClient(i.invoker, mcmAddr) +} + +// GetConfig returns the live multisig configuration from the contract. +func (i *Inspector) GetConfig(ctx context.Context, mcmAddr string) (*types.Config, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return nil, err + } + + cfg, err := client.GetConfig(ctx) + if err != nil { + return nil, err + } + + return i.ToConfig(cfg) +} + +// GetOpCount returns the executed operation counter from the contract. +func (i *Inspector) GetOpCount(ctx context.Context, mcmAddr string) (uint64, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return 0, err + } + + return client.GetOpCount(ctx) +} + +// GetRoot returns the current expiring Merkle root and its valid-until ledger/time bound. +func (i *Inspector) GetRoot(ctx context.Context, mcmAddr string) (common.Hash, uint32, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return common.Hash{}, 0, err + } + + root, validUntil, err := client.GetRoot(ctx) + if err != nil { + return common.Hash{}, 0, err + } + + return common.BytesToHash(root[:]), validUntil, nil +} + +// GetRootMetadata returns proposal metadata aligned with MCMS (starting op count + MCM address). +func (i *Inspector) GetRootMetadata(ctx context.Context, mcmAddr string) (types.ChainMetadata, error) { + client, err := i.contractClient(mcmAddr) + if err != nil { + return types.ChainMetadata{}, err + } + + meta, err := client.GetRootMetadata(ctx) + if err != nil { + return types.ChainMetadata{}, err + } + + if meta == nil { + return types.ChainMetadata{}, fmt.Errorf("nil root metadata from contract") + } + + return types.ChainMetadata{ + StartingOpCount: meta.PreOpCount, + MCMAddress: mcmAddr, + }, nil +} + +// normalizeContractIDStrkey accepts contract id hex or strkey and returns canonical contract strkey (C…). +func normalizeContractIDStrkey(s string) (string, error) { + raw, err := parseContractID(s) + if err != nil { + return "", err + } + + encoded, err := strkey.Encode(strkey.VersionByteContract, raw[:]) + if err != nil { + return "", fmt.Errorf("encode contract id: %w", err) + } + + return encoded, nil +} diff --git a/sdk/stellar/inspector_test.go b/sdk/stellar/inspector_test.go new file mode 100644 index 00000000..2e224ff2 --- /dev/null +++ b/sdk/stellar/inspector_test.go @@ -0,0 +1,151 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/smartcontractkit/mcms/types" +) + +func TestConfigTransformer_ToConfig_ToChainConfig_roundTrip(t *testing.T) { + t.Parallel() + + tr := NewConfigTransformer() + + want := types.Config{ + Quorum: 1, + Signers: []common.Address{{1}}, + } + + chainCfg, err := tr.ToChainConfig(want, nil) + require.NoError(t, err) + + got, err := tr.ToConfig(chainCfg) + require.NoError(t, err) + + require.True(t, got.Equals(&want)) +} + +type mockInvoker struct { + cfg *stellarmcms.Config + root [32]byte + valid uint32 + opCount uint64 + rootMeta *stellarmcms.StellarRootMetadata +} + +func (m *mockInvoker) InvokeContract(context.Context, string, string, []xdr.ScVal) (*xdr.ScVal, error) { + return nil, invokerNotImplementedError{} +} + +func (m *mockInvoker) GetEvents(context.Context, string, uint32, []string) ([]protocolrpc.EventInfo, error) { + return nil, invokerNotImplementedError{} +} + +func (m *mockInvoker) SimulateContract(_ context.Context, _ string, fn string, _ []xdr.ScVal) (*xdr.ScVal, error) { + switch fn { + case "get_config": + v, err := m.cfg.ToScVal() + if err != nil { + return nil, err + } + + return &v, nil + + case "get_root": + v := scval.VecToScVal([]xdr.ScVal{ + scval.Bytes32ToScVal(m.root), + scval.Uint32ToScVal(m.valid), + }) + + return &v, nil + + case "get_op_count": + v := scval.Uint64ToScVal(m.opCount) + + return &v, nil + + case "get_root_metadata": + v, err := m.rootMeta.ToScVal() + if err != nil { + return nil, err + } + + return &v, nil + + default: + return nil, invokerNotImplementedError{} + } +} + +type invokerNotImplementedError struct{} + +func (invokerNotImplementedError) Error() string { + return "mock invoker: not implemented" +} + +func TestInspector_readsViaInvoker(t *testing.T) { + t.Parallel() + + var paddedSigner [32]byte + copy(paddedSigner[evmAddressABIWordLeadingZeroBytes:], []byte{0xab, 0xcd}) + + cfg := &stellarmcms.Config{ + GroupQuorums: func() (out [32]byte) { + out[0] = 1 + + return out + }(), + GroupParents: [32]byte{}, + Signers: []stellarmcms.Signer{ + {Addr: paddedSigner, Group: 0, Index: 0}, + }, + } + + meta := &stellarmcms.StellarRootMetadata{ + PreOpCount: 7, + PostOpCount: 8, + } + + inv := &mockInvoker{ + cfg: cfg, + root: [32]byte{9}, + valid: 42, + opCount: 100, + rootMeta: meta, + } + + insp := NewInspector(inv) + + ctx := context.Background() + + const contractHex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + + gotCfg, err := insp.GetConfig(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, uint8(1), gotCfg.Quorum) + require.Len(t, gotCfg.Signers, 1) + require.Equal(t, common.Address{0xab, 0xcd}, gotCfg.Signers[0]) + + opCount, err := insp.GetOpCount(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, uint64(100), opCount) + + root, validUntil, err := insp.GetRoot(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, common.Hash([32]byte{9}), root) + require.Equal(t, uint32(42), validUntil) + + md, err := insp.GetRootMetadata(ctx, contractHex) + require.NoError(t, err) + require.Equal(t, uint64(7), md.StartingOpCount) + require.Equal(t, contractHex, md.MCMAddress) +} diff --git a/sdk/stellar/mcms_client.go b/sdk/stellar/mcms_client.go new file mode 100644 index 00000000..290f93e0 --- /dev/null +++ b/sdk/stellar/mcms_client.go @@ -0,0 +1,16 @@ +package stellar + +import ( + "github.com/smartcontractkit/chainlink-stellar/bindings" + stellarmcms "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/mcms" +) + +// newMCMSClient returns a McmsClient for mcmAddr (hex or contract strkey), using invoker for RPC. +func newMCMSClient(invoker bindings.Invoker, mcmAddr string) (*stellarmcms.McmsClient, error) { + id, err := normalizeContractIDStrkey(mcmAddr) + if err != nil { + return nil, err + } + + return stellarmcms.NewMcmsClient(invoker, id), nil +} diff --git a/sdk/stellar/timelock_configurer.go b/sdk/stellar/timelock_configurer.go new file mode 100644 index 00000000..25f42c28 --- /dev/null +++ b/sdk/stellar/timelock_configurer.go @@ -0,0 +1,52 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.TimelockConfigurer = (*TimelockConfigurer)(nil) + +// TimelockConfigurer updates timelock parameters on Soroban RBACTimelock. +type TimelockConfigurer struct { + TimelockInspector + invoker bindings.Invoker + adminCaller string +} + +// NewTimelockConfigurer returns a configurer that invokes update_delay as adminCaller (must hold +// ADMIN on the timelock contract). +func NewTimelockConfigurer(invoker bindings.Invoker, adminCaller string) *TimelockConfigurer { + return &TimelockConfigurer{ + TimelockInspector: *NewTimelockInspector(invoker), + invoker: invoker, + adminCaller: adminCaller, + } +} + +// UpdateDelay calls update_delay on the timelock contract. +func (c *TimelockConfigurer) UpdateDelay( + ctx context.Context, timelockAddress string, newDelay uint64, +) (types.TransactionResult, error) { + if c.adminCaller == "" { + return types.TransactionResult{}, fmt.Errorf("stellar timelock: admin caller address is empty") + } + + id, err := normalizeContractIDStrkey(timelockAddress) + if err != nil { + return types.TransactionResult{}, err + } + + client := timelockbindings.NewTimelockClient(c.invoker, id) + if err := client.UpdateDelay(ctx, c.adminCaller, newDelay); err != nil { + return types.TransactionResult{}, err + } + + return stellarTransactionResult(c.invoker), nil +} diff --git a/sdk/stellar/timelock_configurer_test.go b/sdk/stellar/timelock_configurer_test.go new file mode 100644 index 00000000..f96cdda3 --- /dev/null +++ b/sdk/stellar/timelock_configurer_test.go @@ -0,0 +1,14 @@ +package stellar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTimelockConfigurer_UpdateDelayRequiresAdmin(t *testing.T) { + t.Parallel() + c := NewTimelockConfigurer(&timelockSimInvoker{}, "") + _, err := c.UpdateDelay(t.Context(), stringsRepeatHexAddr('c'), 10) + require.ErrorContains(t, err, "admin caller") +} diff --git a/sdk/stellar/timelock_converter.go b/sdk/stellar/timelock_converter.go new file mode 100644 index 00000000..e6e3a7c8 --- /dev/null +++ b/sdk/stellar/timelock_converter.go @@ -0,0 +1,217 @@ +package stellar + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.TimelockConverter = (*TimelockConverter)(nil) + +// TimelockConverter converts timelock proposal batches into Soroban MCMS operations whose +// transaction.to is the timelock contract and transaction.data is Soroban invoke payload bytes +// (ScVec of Symbol + args) as consumed by MCMS execute / timelock decode_invoke_payload. +type TimelockConverter struct{} + +// NewTimelockConverter returns a TimelockConverter for Stellar / Soroban RBACTimelock. +func NewTimelockConverter() *TimelockConverter { + return &TimelockConverter{} +} + +// TimelockProposalAdditionalFields are JSON fields on types.ChainMetadata.AdditionalFields for +// Stellar timelock proposals. The address must hold the corresponding on-chain role when the +// timelock entrypoint runs (first argument to schedule_batch / cancel / bypasser_execute_batch). +// +// For [sdk.TimelockExecutor] / [sdk.TimelockConfigurer] wiring, set timelockExecutor (execute_batch +// caller) and timelockAdmin (update_delay caller) respectively. +type TimelockProposalAdditionalFields struct { + TimelockProposer string `json:"timelockProposer,omitempty"` + TimelockCanceller string `json:"timelockCanceller,omitempty"` + TimelockBypasser string `json:"timelockBypasser,omitempty"` + TimelockExecutor string `json:"timelockExecutor,omitempty"` + TimelockAdmin string `json:"timelockAdmin,omitempty"` +} + +// ParseTimelockProposalAdditionalFields unmarshals Stellar timelock-related additional metadata. +func ParseTimelockProposalAdditionalFields(raw json.RawMessage) (TimelockProposalAdditionalFields, error) { + var z TimelockProposalAdditionalFields + if len(raw) == 0 { + return z, fmt.Errorf("stellar timelock: chain metadata additionalFields is required") + } + if err := json.Unmarshal(raw, &z); err != nil { + return z, fmt.Errorf("stellar timelock: additionalFields: %w", err) + } + + return z, nil +} + +func callsFromBatchOperation(bop types.BatchOperation) ([]timelockbindings.Call, error) { + out := make([]timelockbindings.Call, 0, len(bop.Transactions)) + for _, tx := range bop.Transactions { + to, err := parseContractID(tx.To) + if err != nil { + return nil, fmt.Errorf("batch transaction to: %w", err) + } + + out = append(out, timelockbindings.Call{ + To: to, + Data: tx.Data, + }) + } + + return out, nil +} + +func (t TimelockConverter) ConvertBatchToChainOperations( + ctx context.Context, + metadata types.ChainMetadata, + batchOp types.BatchOperation, + timelockAddress string, + mcmAddress string, + delay types.Duration, + action types.TimelockAction, + predecessor common.Hash, + salt common.Hash, +) ([]types.Operation, common.Hash, error) { + _ = ctx + _ = mcmAddress + + if _, err := parseContractID(timelockAddress); err != nil { + return nil, common.Hash{}, fmt.Errorf("timelock address: %w", err) + } + + if _, err := parseContractID(mcmAddress); err != nil { + return nil, common.Hash{}, fmt.Errorf("mcm address: %w", err) + } + + af, err := ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return nil, common.Hash{}, err + } + + pred := predecessor + if action == types.TimelockActionBypass { + pred = common.Hash{} + } + + calls, err := callsFromBatchOperation(batchOp) + if err != nil { + return nil, common.Hash{}, err + } + + operationID := HashOperationBatch(calls, pred, salt) + + tags := make([]string, 0) + for _, tx := range batchOp.Transactions { + tags = append(tags, tx.Tags...) + } + + var data []byte + + switch action { + case types.TimelockActionSchedule: + caller := af.TimelockProposer + if caller == "" { + return nil, common.Hash{}, fmt.Errorf("stellar timelock: timelockProposer is required in metadata.additionalFields") + } + + callsVal, err := timelockbindings.Calls{Inner: calls}.ToScVal() + if err != nil { + return nil, common.Hash{}, fmt.Errorf("calls to ScVal: %w", err) + } + + data, err = sorobanInvokePayloadBytes("schedule_batch", + scval.AddressToScVal(caller), + callsVal, + scval.Bytes32ToScVal(pred), + scval.Bytes32ToScVal(salt), + scval.Uint64ToScVal(uint64(delay.Seconds())), + ) + if err != nil { + return nil, common.Hash{}, err + } + + case types.TimelockActionCancel: + caller := af.TimelockCanceller + if caller == "" { + return nil, common.Hash{}, fmt.Errorf("stellar timelock: timelockCanceller is required in metadata.additionalFields") + } + + data, err = sorobanInvokePayloadBytes("cancel", + scval.AddressToScVal(caller), + scval.Bytes32ToScVal(operationID), + ) + if err != nil { + return nil, common.Hash{}, err + } + + case types.TimelockActionBypass: + caller := af.TimelockBypasser + if caller == "" { + return nil, common.Hash{}, fmt.Errorf("stellar timelock: timelockBypasser is required in metadata.additionalFields") + } + + callsVal, err := timelockbindings.Calls{Inner: calls}.ToScVal() + if err != nil { + return nil, common.Hash{}, fmt.Errorf("calls to ScVal: %w", err) + } + + data, err = sorobanInvokePayloadBytes("bypasser_execute_batch", + scval.AddressToScVal(caller), + callsVal, + ) + if err != nil { + return nil, common.Hash{}, err + } + + default: + return nil, common.Hash{}, fmt.Errorf("invalid timelock action: %s", action) + } + + additional := json.RawMessage([]byte("{}")) + + op := types.Operation{ + ChainSelector: batchOp.ChainSelector, + Transaction: types.Transaction{ + OperationMetadata: types.OperationMetadata{ + ContractType: "RBACTimelock", + Tags: tags, + }, + To: timelockAddress, + Data: data, + AdditionalFields: additional, + }, + } + + return []types.Operation{op}, operationID, nil +} + +// OperationID returns the Soroban timelock operation id for the batch (same as on-chain +// hash_operation_batch). For bypass actions predecessor is treated as zero before hashing, +// matching schedule vs bypass semantics on-chain. +func OperationID( + batchOp types.BatchOperation, + action types.TimelockAction, + predecessor common.Hash, + salt common.Hash, +) (common.Hash, error) { + calls, err := callsFromBatchOperation(batchOp) + if err != nil { + return common.Hash{}, err + } + + pred := predecessor + if action == types.TimelockActionBypass { + pred = common.Hash{} + } + + return HashOperationBatch(calls, pred, salt), nil +} diff --git a/sdk/stellar/timelock_converter_test.go b/sdk/stellar/timelock_converter_test.go new file mode 100644 index 00000000..54201c50 --- /dev/null +++ b/sdk/stellar/timelock_converter_test.go @@ -0,0 +1,92 @@ +package stellar + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/types" +) + +func TestTimelockConverter_ScheduleAndOperationID(t *testing.T) { + t.Parallel() + tl := strings.Repeat("c", 64) + mcm := strings.Repeat("b", 64) + md := types.ChainMetadata{ + MCMAddress: mcm, + StartingOpCount: 0, + AdditionalFields: mustJSON(t, map[string]string{ + "timelockProposer": "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", + }), + } + bop := types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + { + OperationMetadata: types.OperationMetadata{Tags: []string{"t1"}}, + To: strings.Repeat("a", 64), + Data: []byte{0xde, 0xad}, + AdditionalFields: []byte("{}"), + }, + }, + } + + conv := NewTimelockConverter() + opIDWant, err := OperationID(bop, types.TimelockActionSchedule, common.Hash{}, common.Hash{31: 3}) + require.NoError(t, err) + + ops, opIDGot, err := conv.ConvertBatchToChainOperations( + t.Context(), + md, + bop, + tl, + mcm, + types.NewDuration(100*time.Second), + types.TimelockActionSchedule, + common.Hash{}, + common.Hash{31: 3}, + ) + require.NoError(t, err) + require.Equal(t, opIDWant, opIDGot) + require.Len(t, ops, 1) + require.Equal(t, tl, ops[0].Transaction.To) + require.NotEmpty(t, ops[0].Transaction.Data) + require.Equal(t, "RBACTimelock", ops[0].Transaction.ContractType) + require.Equal(t, []string{"t1"}, ops[0].Transaction.Tags) +} + +func TestTimelockConverter_MissingProposer(t *testing.T) { + t.Parallel() + conv := NewTimelockConverter() + md := types.ChainMetadata{ + MCMAddress: strings.Repeat("b", 64), + AdditionalFields: mustJSON(t, map[string]string{}), + } + bop := types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + {To: strings.Repeat("a", 64), Data: []byte{1}, AdditionalFields: []byte("{}")}, + }, + } + _, _, err := conv.ConvertBatchToChainOperations( + t.Context(), md, bop, + strings.Repeat("c", 64), + strings.Repeat("b", 64), + types.NewDuration(time.Second), + types.TimelockActionSchedule, + common.Hash{}, common.Hash{}, + ) + require.ErrorContains(t, err, "timelockProposer") +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + + return b +} diff --git a/sdk/stellar/timelock_executor.go b/sdk/stellar/timelock_executor.go new file mode 100644 index 00000000..34f73670 --- /dev/null +++ b/sdk/stellar/timelock_executor.go @@ -0,0 +1,65 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +var _ sdk.TimelockExecutor = (*TimelockExecutor)(nil) + +// TimelockExecutor submits execute_batch on Soroban RBACTimelock via [bindings.Invoker]. +type TimelockExecutor struct { + *TimelockInspector + invoker bindings.Invoker + executorCaller string +} + +// NewTimelockExecutor builds an executor that invokes execute_batch as executorCaller (must hold +// EXECUTOR on the timelock contract). +func NewTimelockExecutor(invoker bindings.Invoker, executorCaller string) *TimelockExecutor { + return &TimelockExecutor{ + TimelockInspector: NewTimelockInspector(invoker), + invoker: invoker, + executorCaller: executorCaller, + } +} + +// Execute invokes execute_batch with the batch calls, predecessor, and salt (operation id must match). +func (e *TimelockExecutor) Execute( + ctx context.Context, + bop types.BatchOperation, + timelockAddress string, + predecessor common.Hash, + salt common.Hash, +) (types.TransactionResult, error) { + if e.executorCaller == "" { + return types.TransactionResult{}, fmt.Errorf("stellar timelock: executor caller address is empty") + } + + id, err := normalizeContractIDStrkey(timelockAddress) + if err != nil { + return types.TransactionResult{}, err + } + + calls, err := callsFromBatchOperation(bop) + if err != nil { + return types.TransactionResult{}, err + } + + client := timelockbindings.NewTimelockClient(e.invoker, id) + + tbCalls := timelockbindings.Calls{Inner: calls} + if err := client.ExecuteBatch(ctx, e.executorCaller, tbCalls, predecessor, salt); err != nil { + return types.TransactionResult{}, err + } + + return stellarTransactionResult(e.invoker), nil +} diff --git a/sdk/stellar/timelock_hash.go b/sdk/stellar/timelock_hash.go new file mode 100644 index 00000000..614daf52 --- /dev/null +++ b/sdk/stellar/timelock_hash.go @@ -0,0 +1,43 @@ +package stellar + +import ( + "encoding/binary" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" +) + +// HashOperationBatch matches Soroban RBACTimelock +// hash_operation_batch_internal in chainlink-stellar/contracts/timelock/src/lib.rs: +// +// call_hash_i = keccak256(to_i || keccak256(data_i)) +// id = keccak256(n_calls_u256_be || call_hash_0 || … || call_hash_n || predecessor || salt) +func HashOperationBatch(calls []timelockbindings.Call, predecessor, salt common.Hash) common.Hash { + var buf []byte + + n := uint64(len(calls)) + var nWord [32]byte + binary.BigEndian.PutUint64(nWord[24:32], n) + buf = append(buf, nWord[:]...) + + for _, c := range calls { + h := hashSingleCall(c) + buf = append(buf, h[:]...) + } + + buf = append(buf, predecessor[:]...) + buf = append(buf, salt[:]...) + + return crypto.Keccak256Hash(buf) +} + +func hashSingleCall(c timelockbindings.Call) common.Hash { + dataHash := crypto.Keccak256Hash(c.Data) + var concat [64]byte + copy(concat[:32], c.To[:]) + copy(concat[32:], dataHash[:]) + + return crypto.Keccak256Hash(concat[:]) +} diff --git a/sdk/stellar/timelock_hash_test.go b/sdk/stellar/timelock_hash_test.go new file mode 100644 index 00000000..8c44b863 --- /dev/null +++ b/sdk/stellar/timelock_hash_test.go @@ -0,0 +1,84 @@ +package stellar + +import ( + "encoding/binary" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/types" +) + +func TestHashOperationBatchGolden_EmptyCalls(t *testing.T) { + t.Parallel() + var pred common.Hash + var salt common.Hash + salt[31] = 1 + + got := HashOperationBatch(nil, pred, salt) + want := common.HexToHash("0xcbfe4baa920060fc34aa65135b74b83fa81df36f6e21d90c8301c8810d2c89d9") + require.Equal(t, want, got, "must match contracts/timelock hash_operation_batch_internal (n=0, zero pred, salt[31]=1)") +} + +func TestHashOperationBatchGolden_OneCallEmptyData(t *testing.T) { + t.Parallel() + var to [32]byte + for i := range to { + to[i] = 0x11 + } + calls := []timelockbindings.Call{{To: to, Data: nil}} + var pred common.Hash + var salt common.Hash + salt[31] = 2 + + got := HashOperationBatch(calls, pred, salt) + want := common.HexToHash("0x6ead1e78e7912c0a67d23eba158933299324df465db1c2e9d5ee89aa37dea436") + require.Equal(t, want, got) + + var concat [64]byte + copy(concat[:32], to[:]) + copy(concat[32:], crypto.Keccak256Hash([]byte{}).Bytes()) + callH := crypto.Keccak256Hash(concat[:]) + require.Equal(t, common.HexToHash("0x0323fdea0b67062f39f74437ee69f91108a863c0a6c49271c0ff5684e4cc2c34"), callH) + + var buf []byte + var nWord [32]byte + binary.BigEndian.PutUint64(nWord[24:32], 1) + buf = append(buf, nWord[:]...) + buf = append(buf, callH[:]...) + buf = append(buf, pred[:]...) + buf = append(buf, salt[:]...) + require.Equal(t, want, crypto.Keccak256Hash(buf)) +} + +func TestHashOperationBatchBypassZeroesPredecessor(t *testing.T) { + t.Parallel() + var pred common.Hash + pred[0] = 0xab + var salt common.Hash + var to [32]byte + for i := range to { + to[i] = 1 + } + calls := []timelockbindings.Call{ + {To: to, Data: []byte{1}}, + } + + gotSchedule := HashOperationBatch(calls, pred, salt) + gotBypass := HashOperationBatch(calls, common.Hash{}, salt) + require.NotEqual(t, gotSchedule, gotBypass) + + gotBypass2, err := OperationID(types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + {To: strings.Repeat("01", 32), Data: []byte{1}, AdditionalFields: []byte("{}")}, + }, + }, types.TimelockActionBypass, pred, salt) + require.NoError(t, err) + require.Equal(t, gotBypass, gotBypass2) +} diff --git a/sdk/stellar/timelock_inspector.go b/sdk/stellar/timelock_inspector.go new file mode 100644 index 00000000..c7655a0d --- /dev/null +++ b/sdk/stellar/timelock_inspector.go @@ -0,0 +1,134 @@ +package stellar + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + timelockbindings "github.com/smartcontractkit/chainlink-stellar/bindings/contracts/timelock" + + "github.com/smartcontractkit/mcms/sdk" +) + +// Soroban timelock role symbols match contracts/timelock/src/types.rs (symbol_short). +const ( + timelockRoleProposer = "PROPOSER" + timelockRoleExecutor = "EXECUTOR" + timelockRoleBypasser = "BYPASSER" + timelockRoleCanceller = "CANCELLER" +) + +var _ sdk.TimelockInspector = (*TimelockInspector)(nil) + +// TimelockInspector reads Soroban RBACTimelock state via SimulateContract on [bindings.Invoker]. +type TimelockInspector struct { + invoker bindings.Invoker +} + +// NewTimelockInspector constructs a TimelockInspector for the given invoker (RPC / deployer). +func NewTimelockInspector(invoker bindings.Invoker) *TimelockInspector { + return &TimelockInspector{invoker: invoker} +} + +func (t *TimelockInspector) clientFor(_ context.Context, address string) (*timelockbindings.TimelockClient, error) { + id, err := normalizeContractIDStrkey(address) + if err != nil { + return nil, err + } + + return timelockbindings.NewTimelockClient(t.invoker, id), nil +} + +func (t *TimelockInspector) roleMembers(ctx context.Context, address string, role string) ([]string, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return nil, err + } + + n, err := c.GetRoleMemberCount(ctx, role) + if err != nil { + return nil, fmt.Errorf("get_role_member_count %s: %w", role, err) + } + + out := make([]string, 0, n) + for i := range n { + member, err := c.GetRoleMember(ctx, role, i) + if err != nil { + return nil, fmt.Errorf("get_role_member %s index %d: %w", role, i, err) + } + + out = append(out, member) + } + + return out, nil +} + +// GetProposers returns addresses with the PROPOSER role. +func (t *TimelockInspector) GetProposers(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleProposer) +} + +// GetExecutors returns addresses with the EXECUTOR role. +func (t *TimelockInspector) GetExecutors(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleExecutor) +} + +// GetBypassers returns addresses with the BYPASSER role. +func (t *TimelockInspector) GetBypassers(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleBypasser) +} + +// GetCancellers returns addresses with the CANCELLER role. +func (t *TimelockInspector) GetCancellers(ctx context.Context, address string) ([]string, error) { + return t.roleMembers(ctx, address, timelockRoleCanceller) +} + +// IsOperation returns true if the operation id exists (any non-zero timestamp entry). +func (t *TimelockInspector) IsOperation(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperation(ctx, opID) +} + +// IsOperationPending returns true if the operation is scheduled but not yet ready or done. +func (t *TimelockInspector) IsOperationPending(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperationPending(ctx, opID) +} + +// IsOperationReady returns true if the operation is scheduled and the delay has elapsed. +func (t *TimelockInspector) IsOperationReady(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperationReady(ctx, opID) +} + +// IsOperationDone returns true if the operation has been executed. +func (t *TimelockInspector) IsOperationDone(ctx context.Context, address string, opID [32]byte) (bool, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return false, err + } + + return c.IsOperationDone(ctx, opID) +} + +// GetMinDelay returns the timelock minimum delay in seconds. +func (t *TimelockInspector) GetMinDelay(ctx context.Context, address string) (uint64, error) { + c, err := t.clientFor(ctx, address) + if err != nil { + return 0, err + } + + return c.GetMinDelay(ctx) +} diff --git a/sdk/stellar/timelock_inspector_test.go b/sdk/stellar/timelock_inspector_test.go new file mode 100644 index 00000000..e7d4d5d3 --- /dev/null +++ b/sdk/stellar/timelock_inspector_test.go @@ -0,0 +1,184 @@ +package stellar + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + protocolrpc "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/smartcontractkit/mcms/types" +) + +// timelockSimInvoker stubs Soroban simulation for timelock read methods used by TimelockInspector. +type timelockSimInvoker struct { + minDelay uint64 + roleCounts map[string]uint32 + roleMember map[string]map[uint32]string + opExists map[[32]byte]bool + opPending map[[32]byte]bool + opReady map[[32]byte]bool + opDone map[[32]byte]bool +} + +func (m *timelockSimInvoker) InvokeContract(context.Context, string, string, []xdr.ScVal) (*xdr.ScVal, error) { + return nil, invokerNotImplementedError{} +} + +func (m *timelockSimInvoker) GetEvents(context.Context, string, uint32, []string) ([]protocolrpc.EventInfo, error) { + return nil, invokerNotImplementedError{} +} + +func (m *timelockSimInvoker) SimulateContract(_ context.Context, _ string, fn string, args []xdr.ScVal) (*xdr.ScVal, error) { + switch fn { + case "get_min_delay": + v := scval.Uint64ToScVal(m.minDelay) + + return &v, nil + + case "get_role_member_count": + role, err := scval.SymbolFromScVal(args[0]) + if err != nil { + return nil, err + } + + n := m.roleCounts[role] + v := scval.Uint32ToScVal(n) + + return &v, nil + + case "get_role_member": + role, err := scval.SymbolFromScVal(args[0]) + if err != nil { + return nil, err + } + + idx, err := scval.Uint32FromScVal(args[1]) + if err != nil { + return nil, err + } + + addr := m.roleMember[role][idx] + v := scval.AddressToScVal(addr) + + return &v, nil + + case "is_operation": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opExists[id] + v := scval.BoolToScVal(b) + + return &v, nil + + case "is_operation_pending": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opPending[id] + v := scval.BoolToScVal(b) + + return &v, nil + + case "is_operation_ready": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opReady[id] + v := scval.BoolToScVal(b) + + return &v, nil + + case "is_operation_done": + id, err := scval.Bytes32FromScVal(args[0]) + if err != nil { + return nil, err + } + + b := m.opDone[id] + v := scval.BoolToScVal(b) + + return &v, nil + + default: + return nil, invokerNotImplementedError{} + } +} + +func TestTimelockInspector_rolesAndOps(t *testing.T) { + t.Parallel() + + execAddr := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + inv := &timelockSimInvoker{ + minDelay: 42, + roleCounts: map[string]uint32{ + timelockRoleProposer: 1, + }, + roleMember: map[string]map[uint32]string{ + timelockRoleProposer: {0: execAddr}, + }, + } + var opKey [32]byte + opKey[0] = 1 + inv.opExists = map[[32]byte]bool{opKey: true} + inv.opPending = map[[32]byte]bool{opKey: true} + inv.opReady = map[[32]byte]bool{} + inv.opDone = map[[32]byte]bool{} + + tl := stringsRepeatHexAddr('c') + ins := NewTimelockInspector(inv) + + ctx := t.Context() + delay, err := ins.GetMinDelay(ctx, tl) + require.NoError(t, err) + require.Equal(t, uint64(42), delay) + + proposers, err := ins.GetProposers(ctx, tl) + require.NoError(t, err) + require.Equal(t, []string{execAddr}, proposers) + + _, err = ins.GetExecutors(ctx, tl) + require.NoError(t, err) + + opID := [32]byte{1} + ok, err := ins.IsOperation(ctx, tl, opID) + require.NoError(t, err) + require.True(t, ok) + + pending, err := ins.IsOperationPending(ctx, tl, opID) + require.NoError(t, err) + require.True(t, pending) +} + +func TestTimelockExecutor_ExecuteRequiresCaller(t *testing.T) { + t.Parallel() + e := NewTimelockExecutor(&timelockSimInvoker{}, "") + _, err := e.Execute(t.Context(), types.BatchOperation{ + ChainSelector: 1, + Transactions: []types.Transaction{ + {To: stringsRepeatHexAddr('a'), Data: []byte{1}, AdditionalFields: []byte("{}")}, + }, + }, stringsRepeatHexAddr('b'), common.Hash{}, common.Hash{}) + require.ErrorContains(t, err, "executor caller") +} + +func stringsRepeatHexAddr(c byte) string { + const n = 64 + b := make([]byte, n) + for i := range b { + b[i] = c + } + + return string(b) +} diff --git a/sdk/stellar/timelock_invoke.go b/sdk/stellar/timelock_invoke.go new file mode 100644 index 00000000..74deddab --- /dev/null +++ b/sdk/stellar/timelock_invoke.go @@ -0,0 +1,25 @@ +package stellar + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + "github.com/stellar/go-stellar-sdk/xdr" +) + +// sorobanInvokePayloadBytes encodes MCMS/timelock inner call data as XDR for +// ScVec([Symbol(fnName), ...args]), matching decode_invoke_payload in +// chainlink-stellar/contracts/common/helpers/src/soroban_invoke.rs. +func sorobanInvokePayloadBytes(fnName string, args ...xdr.ScVal) ([]byte, error) { + items := make([]xdr.ScVal, 0, 1+len(args)) + items = append(items, scval.SymbolToScVal(fnName)) + items = append(items, args...) + val := scval.VecToScVal(items) + + b, err := val.MarshalBinary() + if err != nil { + return nil, fmt.Errorf("marshal soroban invoke payload: %w", err) + } + + return b, nil +} diff --git a/sdk/stellar/transaction_result.go b/sdk/stellar/transaction_result.go new file mode 100644 index 00000000..79951452 --- /dev/null +++ b/sdk/stellar/transaction_result.go @@ -0,0 +1,24 @@ +package stellar + +import ( + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-stellar/bindings" + + "github.com/smartcontractkit/mcms/types" +) + +// txHashInvoker is optionally implemented by invokers that expose the last submitted Soroban tx hash. +type txHashInvoker interface { + LastSubmittedTransactionHash() string +} + +func stellarTransactionResult(invoker bindings.Invoker) types.TransactionResult { + hash := "" + + if th, ok := invoker.(txHashInvoker); ok { + hash = th.LastSubmittedTransactionHash() + } + + return types.NewTransactionResult(hash, nil, chainsel.FamilyStellar) +} diff --git a/sdk/stellar/validation.go b/sdk/stellar/validation.go new file mode 100644 index 00000000..1c093ee9 --- /dev/null +++ b/sdk/stellar/validation.go @@ -0,0 +1,113 @@ +package stellar + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/smartcontractkit/chainlink-stellar/bindings/scval" + + "github.com/smartcontractkit/mcms/types" +) + +// ValidateAdditionalFields validates JSON in types.Transaction.AdditionalFields +// (optional StellarOp.value as 32-byte hex; see [Encoder] parseValueWord). +func ValidateAdditionalFields(additionalFields json.RawMessage) error { + _, err := parseValueWord(additionalFields) + if err != nil { + return fmt.Errorf("stellar additional fields: %w", err) + } + + return nil +} + +// ValidateChainMetadata ensures MCMAddress parses as a Stellar contract id (strkey or 32-byte hex). +func ValidateChainMetadata(metadata types.ChainMetadata) error { + if strings.TrimSpace(metadata.MCMAddress) == "" { + return fmt.Errorf("mcm address is required") + } + + if _, err := parseContractID(metadata.MCMAddress); err != nil { + return fmt.Errorf("mcmAddress: %w", err) + } + + return nil +} + +// ValidateTimelockChainMetadata validates Stellar chain metadata for a timelock proposal: +// MCM contract id, timelock role JSON in AdditionalFields, and action-specific required callers. +// timelockExecutor is always required (see chainwrappers.BuildTimelockExecutor). Optional role +// fields are checked when non-empty. +func ValidateTimelockChainMetadata(metadata types.ChainMetadata, action types.TimelockAction) error { + if err := ValidateChainMetadata(metadata); err != nil { + return err + } + + af, err := ParseTimelockProposalAdditionalFields(metadata.AdditionalFields) + if err != nil { + return err + } + + if err := validateTimelockRoleAddress("timelockExecutor", af.TimelockExecutor, true); err != nil { + return err + } + + switch action { + case types.TimelockActionSchedule: + if err := validateTimelockRoleAddress("timelockProposer", af.TimelockProposer, true); err != nil { + return err + } + case types.TimelockActionCancel: + if err := validateTimelockRoleAddress("timelockCanceller", af.TimelockCanceller, true); err != nil { + return err + } + case types.TimelockActionBypass: + if err := validateTimelockRoleAddress("timelockBypasser", af.TimelockBypasser, true); err != nil { + return err + } + default: + return fmt.Errorf("stellar timelock: invalid timelock action: %s", action) + } + + if err := validateTimelockRoleAddress("timelockAdmin", af.TimelockAdmin, false); err != nil { + return err + } + switch action { + case types.TimelockActionSchedule: + if err := validateTimelockRoleAddress("timelockCanceller", af.TimelockCanceller, false); err != nil { + return err + } + if err := validateTimelockRoleAddress("timelockBypasser", af.TimelockBypasser, false); err != nil { + return err + } + case types.TimelockActionCancel: + if err := validateTimelockRoleAddress("timelockProposer", af.TimelockProposer, false); err != nil { + return err + } + if err := validateTimelockRoleAddress("timelockBypasser", af.TimelockBypasser, false); err != nil { + return err + } + case types.TimelockActionBypass: + if err := validateTimelockRoleAddress("timelockProposer", af.TimelockProposer, false); err != nil { + return err + } + if err := validateTimelockRoleAddress("timelockCanceller", af.TimelockCanceller, false); err != nil { + return err + } + } + + return nil +} + +func validateTimelockRoleAddress(field, addr string, required bool) error { + if strings.TrimSpace(addr) == "" { + if required { + return fmt.Errorf("stellar timelock: %s is required in chain metadata additionalFields", field) + } + return nil + } + if scval.ParseAddress(addr) == nil { + return fmt.Errorf("stellar timelock: %s: invalid Stellar address (expect G... account or C... contract)", field) + } + return nil +} diff --git a/sdk/stellar/validation_test.go b/sdk/stellar/validation_test.go new file mode 100644 index 00000000..b860fdae --- /dev/null +++ b/sdk/stellar/validation_test.go @@ -0,0 +1,213 @@ +package stellar + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/types" +) + +func TestValidateChainMetadata(t *testing.T) { + t.Parallel() + + validAddr := "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + + tests := []struct { + name string + metadata types.ChainMetadata + wantErr string + }{ + { + name: "valid hex contract id", + metadata: types.ChainMetadata{ + MCMAddress: validAddr, + StartingOpCount: 0, + }, + }, + { + name: "valid strkey", + metadata: types.ChainMetadata{ + MCMAddress: "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA", + StartingOpCount: 0, + }, + }, + { + name: "empty mcm address", + metadata: types.ChainMetadata{ + MCMAddress: "", + }, + wantErr: "mcm address is required", + }, + { + name: "invalid address", + metadata: types.ChainMetadata{ + MCMAddress: "not-an-address", + }, + wantErr: "mcmAddress:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateChainMetadata(tt.metadata) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateTimelockChainMetadata(t *testing.T) { + t.Parallel() + + const ( + validMCM = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + validAccount = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + ) + + meta := func(raw string) types.ChainMetadata { + return types.ChainMetadata{ + StartingOpCount: 1, + MCMAddress: validMCM, + AdditionalFields: json.RawMessage(raw), + } + } + + fullSchedule := fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockProposer": %q + }`, validAccount, validAccount) + + tests := []struct { + name string + meta types.ChainMetadata + action types.TimelockAction + wantErr string + }{ + { + name: "schedule valid", + meta: meta(fullSchedule), + action: types.TimelockActionSchedule, + wantErr: "", + }, + { + name: "missing additionalFields", + meta: types.ChainMetadata{StartingOpCount: 1, MCMAddress: validMCM}, + action: types.TimelockActionSchedule, + wantErr: "additionalFields is required", + }, + { + name: "invalid additionalFields json", + meta: meta(`{`), + action: types.TimelockActionSchedule, + wantErr: "additionalFields:", + }, + { + name: "schedule missing executor", + meta: meta(fmt.Sprintf(`{"timelockProposer": %q}`, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockExecutor is required", + }, + { + name: "schedule missing proposer", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q}`, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockProposer is required", + }, + { + name: "cancel valid", + meta: meta(fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockCanceller": %q + }`, validAccount, validAccount)), + action: types.TimelockActionCancel, + }, + { + name: "cancel missing canceller", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q}`, validAccount)), + action: types.TimelockActionCancel, + wantErr: "timelockCanceller is required", + }, + { + name: "bypass valid", + meta: meta(fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockBypasser": %q + }`, validAccount, validAccount)), + action: types.TimelockActionBypass, + }, + { + name: "bypass missing bypasser", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q}`, validAccount)), + action: types.TimelockActionBypass, + wantErr: "timelockBypasser is required", + }, + { + name: "invalid proposer strkey", + meta: meta(fmt.Sprintf(`{"timelockExecutor": %q, "timelockProposer": "not-an-address"}`, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockProposer:", + }, + { + name: "optional admin invalid", + meta: meta(fmt.Sprintf(`{ + "timelockExecutor": %q, + "timelockProposer": %q, + "timelockAdmin": "bad" + }`, validAccount, validAccount)), + action: types.TimelockActionSchedule, + wantErr: "timelockAdmin:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateTimelockChainMetadata(tt.meta, tt.action) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateAdditionalFields(t *testing.T) { + t.Parallel() + + valid64 := `{"value":"0x` + strings.Repeat("00", 32) + `"}` + + tests := []struct { + name string + raw json.RawMessage + wantOK bool + }{ + {name: "nil", raw: nil, wantOK: true}, + {name: "empty object", raw: json.RawMessage(`{}`), wantOK: true}, + {name: "valid value word", raw: json.RawMessage(valid64), wantOK: true}, + {name: "invalid json", raw: json.RawMessage(`{`), wantOK: false}, + {name: "value wrong length", raw: json.RawMessage(`{"value":"0x01"}`), wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateAdditionalFields(tt.raw) + if tt.wantOK { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} diff --git a/sdk/sui/mocks/bindutils/iboundcontract.go b/sdk/sui/mocks/bindutils/iboundcontract.go index 9e271ab3..24c68622 100644 --- a/sdk/sui/mocks/bindutils/iboundcontract.go +++ b/sdk/sui/mocks/bindutils/iboundcontract.go @@ -5,13 +5,10 @@ package mock_bindutils import ( context "context" - bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - models "github.com/block-vision/sui-go-sdk/models" - transaction "github.com/block-vision/sui-go-sdk/transaction" + bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" + mock "github.com/stretchr/testify/mock" ) // IBoundContract is an autogenerated mock type for the IBoundContract type diff --git a/sdk/sui/mocks/feequoter/feequoterencoder.go b/sdk/sui/mocks/feequoter/feequoterencoder.go index 4b0d1472..5eba3f50 100644 --- a/sdk/sui/mocks/feequoter/feequoterencoder.go +++ b/sdk/sui/mocks/feequoter/feequoterencoder.go @@ -6,9 +6,8 @@ import ( big "math/big" bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - mock "github.com/stretchr/testify/mock" - module_fee_quoter "github.com/smartcontractkit/chainlink-sui/bindings/generated/ccip/ccip/fee_quoter" + mock "github.com/stretchr/testify/mock" ) // FeeQuoterEncoder is an autogenerated mock type for the FeeQuoterEncoder type diff --git a/sdk/sui/mocks/mcms/imcms.go b/sdk/sui/mocks/mcms/imcms.go index 55ff6629..2d1bb5c0 100644 --- a/sdk/sui/mocks/mcms/imcms.go +++ b/sdk/sui/mocks/mcms/imcms.go @@ -6,13 +6,10 @@ import ( context "context" big "math/big" - bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - models "github.com/block-vision/sui-go-sdk/models" - + bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" module_mcms "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // IMcms is an autogenerated mock type for the IMcms type diff --git a/sdk/sui/mocks/mcms/imcmsdevinspect.go b/sdk/sui/mocks/mcms/imcmsdevinspect.go index 59190724..aabd6019 100644 --- a/sdk/sui/mocks/mcms/imcmsdevinspect.go +++ b/sdk/sui/mocks/mcms/imcmsdevinspect.go @@ -7,10 +7,8 @@ import ( big "math/big" bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // IMcmsDevInspect is an autogenerated mock type for the IMcmsDevInspect type diff --git a/sdk/sui/mocks/mcms/mcmsencoder.go b/sdk/sui/mocks/mcms/mcmsencoder.go index dcf2be7e..3d637b2e 100644 --- a/sdk/sui/mocks/mcms/mcmsencoder.go +++ b/sdk/sui/mocks/mcms/mcmsencoder.go @@ -6,9 +6,8 @@ import ( big "math/big" bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - mock "github.com/stretchr/testify/mock" - module_mcms "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms" + mock "github.com/stretchr/testify/mock" ) // McmsEncoder is an autogenerated mock type for the McmsEncoder type diff --git a/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go b/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go index 40e6274a..69a3c76f 100644 --- a/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go +++ b/sdk/sui/mocks/mcmsdeployer/imcmsdeployer.go @@ -5,13 +5,10 @@ package mock_module_mcmsdeployer import ( context "context" - bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" - - mock "github.com/stretchr/testify/mock" - models "github.com/block-vision/sui-go-sdk/models" - + bind "github.com/smartcontractkit/chainlink-sui/bindings/bind" module_mcms_deployer "github.com/smartcontractkit/chainlink-sui/bindings/generated/mcms/mcms_deployer" + mock "github.com/stretchr/testify/mock" ) // IMcmsDeployer is an autogenerated mock type for the IMcmsDeployer type diff --git a/sdk/ton/mocks/api.go b/sdk/ton/mocks/api.go index f37411f1..85b8fef1 100644 --- a/sdk/ton/mocks/api.go +++ b/sdk/ton/mocks/api.go @@ -3,20 +3,15 @@ package mock_ton import ( - address "github.com/xssnick/tonutils-go/address" - cell "github.com/xssnick/tonutils-go/tvm/cell" - context "context" - - liteclient "github.com/xssnick/tonutils-go/liteclient" - - mock "github.com/stretchr/testify/mock" - time "time" + mock "github.com/stretchr/testify/mock" + address "github.com/xssnick/tonutils-go/address" + liteclient "github.com/xssnick/tonutils-go/liteclient" tlb "github.com/xssnick/tonutils-go/tlb" - ton "github.com/xssnick/tonutils-go/ton" + cell "github.com/xssnick/tonutils-go/tvm/cell" ) // APIClientWrapped is an autogenerated mock type for the APIClientWrapped type diff --git a/sdk/ton/mocks/wallet.go b/sdk/ton/mocks/wallet.go index f70937e2..a870e87e 100644 --- a/sdk/ton/mocks/wallet.go +++ b/sdk/ton/mocks/wallet.go @@ -5,12 +5,9 @@ package mock_ton import ( context "context" - address "github.com/xssnick/tonutils-go/address" - mock "github.com/stretchr/testify/mock" - + address "github.com/xssnick/tonutils-go/address" tlb "github.com/xssnick/tonutils-go/tlb" - ton "github.com/xssnick/tonutils-go/ton" ) diff --git a/timelock_executable_test.go b/timelock_executable_test.go index 2e01da37..0f11ab8a 100644 --- a/timelock_executable_test.go +++ b/timelock_executable_test.go @@ -606,7 +606,9 @@ func scheduleAndExecuteGrantRolesProposal(t *testing.T, targetRoles []common.Has requireOperationNotReady(t, tExecutable, &proposal, opIdx) requireOperationNotDone(t, tExecutable, &proposal, opIdx) - // sleep for 5 seconds and then mine a block + // Advance chain time past the timelock delay. geth's simulated beacon requires an + // empty tx pool before AdjustTime (see SimulatedBeacon.AdjustTime). + sim.Backend.Rollback() require.NoError(t, sim.Backend.AdjustTime(5*time.Second)) sim.Backend.Commit() // Note < 1.14 geth needs a commit after adjusting time. diff --git a/timelock_proposal.go b/timelock_proposal.go index 4c130a55..68854ab6 100644 --- a/timelock_proposal.go +++ b/timelock_proposal.go @@ -12,9 +12,12 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/go-playground/validator/v10" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/mcms/chainwrappers" "github.com/smartcontractkit/mcms/internal/utils/safecast" "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/types" ) @@ -95,6 +98,18 @@ func (m *TimelockProposal) Validate() error { return NewInvalidProposalKindError(m.Kind, types.KindTimelockProposal) } + for chainSelector, metadata := range m.ChainMetadata { + fam, err := types.GetChainSelectorFamily(chainSelector) + if err != nil { + return fmt.Errorf("chain metadata %d: %w", chainSelector, err) + } + if fam == chainsel.FamilyStellar { + if err := stellar.ValidateTimelockChainMetadata(metadata, m.Action); err != nil { + return fmt.Errorf("chain metadata %d: %w", chainSelector, err) + } + } + } + // Validate all chains in transactions have an entry in chain metadata for _, op := range m.Operations { if _, ok := m.ChainMetadata[op.ChainSelector]; !ok { diff --git a/timelock_proposal_test.go b/timelock_proposal_test.go index 09cd14c7..b6d3f648 100644 --- a/timelock_proposal_test.go +++ b/timelock_proposal_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "maps" "math/big" "strings" "testing" @@ -717,6 +718,71 @@ func TestTimelockProposal_Validate(t *testing.T) { } } +func TestTimelockProposal_Validate_StellarTimelockMetadata(t *testing.T) { + t.Parallel() + + sel := chaintest.Chain9Selector + stellarMCM := "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + acc := "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H" + + base := TimelockProposal{ + BaseProposal: BaseProposal{ + Version: "v1", + Kind: types.KindTimelockProposal, + ValidUntil: 2004259681, + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + sel: { + StartingOpCount: 1, + MCMAddress: stellarMCM, + AdditionalFields: json.RawMessage([]byte(`{}`)), + }, + }, + }, + Action: types.TimelockActionSchedule, + Delay: types.MustParseDuration("1h"), + TimelockAddresses: map[types.ChainSelector]string{ + sel: stellarMCM, + }, + Operations: []types.BatchOperation{ + { + ChainSelector: sel, + Transactions: []types.Transaction{ + { + To: stellarMCM, + AdditionalFields: json.RawMessage([]byte(`{}`)), + Data: []byte{}, + }, + }, + }, + }, + } + + t.Run("rejects empty timelock role metadata", func(t *testing.T) { + t.Parallel() + p := base + p.ChainMetadata = maps.Clone(base.ChainMetadata) + err := p.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), "chain metadata") + require.Contains(t, err.Error(), "timelockExecutor is required") + }) + + t.Run("accepts valid stellar timelock metadata", func(t *testing.T) { + t.Parallel() + p := base + p.ChainMetadata = maps.Clone(base.ChainMetadata) + p.ChainMetadata[sel] = types.ChainMetadata{ + StartingOpCount: 1, + MCMAddress: stellarMCM, + AdditionalFields: json.RawMessage([]byte(`{ + "timelockExecutor": "` + acc + `", + "timelockProposer": "` + acc + `" + }`)), + } + require.NoError(t, p.Validate()) + }) +} + func TestTimelockProposal_Convert(t *testing.T) { t.Parallel() diff --git a/validation.go b/validation.go index 4901edf7..c4a2677f 100644 --- a/validation.go +++ b/validation.go @@ -11,6 +11,7 @@ import ( "github.com/smartcontractkit/mcms/sdk/aptos" "github.com/smartcontractkit/mcms/sdk/evm" "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/sdk/stellar" "github.com/smartcontractkit/mcms/sdk/sui" "github.com/smartcontractkit/mcms/sdk/ton" ) @@ -36,6 +37,9 @@ func validateAdditionalFields(additionalFields json.RawMessage, csel types.Chain case chainsel.FamilyTon: return ton.ValidateAdditionalFields(additionalFields) + + case chainsel.FamilyStellar: + return stellar.ValidateAdditionalFields(additionalFields) } return nil @@ -61,6 +65,8 @@ func validateChainMetadata(metadata types.ChainMetadata, csel types.ChainSelecto // TODO (ton): do we need special chain metadata for TON? // Yes! We could attach MCMS -> Timelock value here which is now hardcoded default in timelock converter return nil + case chainsel.FamilyStellar: + return stellar.ValidateChainMetadata(metadata) default: return fmt.Errorf("unsupported chain family: %s", chainFamily) } diff --git a/validation_test.go b/validation_test.go index b2e164f9..bc048407 100644 --- a/validation_test.go +++ b/validation_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "math/big" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -101,6 +102,24 @@ func TestValidateChainMetadata(t *testing.T) { additionalFields: types.ChainMetadata{AdditionalFields: invalidSuiMetadataJSON}, expectedErr: errors.New("mcms package ID is required"), }, + { + name: "valid Stellar metadata", + chainSelector: chaintest.Chain9Selector, + additionalFields: types.ChainMetadata{MCMAddress: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", StartingOpCount: 0}, + expectedErr: nil, + }, + { + name: "empty Stellar mcm address", + chainSelector: chaintest.Chain9Selector, + additionalFields: types.ChainMetadata{MCMAddress: "", StartingOpCount: 0}, + expectedErr: errors.New("mcm address is required"), + }, + { + name: "invalid Stellar mcm address", + chainSelector: chaintest.Chain9Selector, + additionalFields: types.ChainMetadata{MCMAddress: "not-a-contract", StartingOpCount: 0}, + expectedErr: errors.New("mcmAddress:"), + }, { name: "unknown chain family", chainSelector: types.ChainSelector(999), @@ -186,6 +205,8 @@ func TestValidateAdditionalFields(t *testing.T) { invalidSuiFieldsJSON, err := json.Marshal(invalidSuiFields) require.NoError(t, err) + validStellarFieldsJSON := json.RawMessage(`{"value":"0x` + strings.Repeat("00", 32) + `"}`) + tests := []struct { name string operation types.Operation @@ -262,6 +283,36 @@ func TestValidateAdditionalFields(t *testing.T) { }, expectedErr: errors.New("module name length must be between 1 and 64 characters"), }, + { + name: "valid Stellar fields (empty)", + operation: types.Operation{ + ChainSelector: chaintest.Chain9Selector, + Transaction: types.Transaction{ + AdditionalFields: nil, + }, + }, + expectedErr: nil, + }, + { + name: "valid Stellar value word", + operation: types.Operation{ + ChainSelector: chaintest.Chain9Selector, + Transaction: types.Transaction{ + AdditionalFields: validStellarFieldsJSON, + }, + }, + expectedErr: nil, + }, + { + name: "invalid Stellar additional fields JSON", + operation: types.Operation{ + ChainSelector: chaintest.Chain9Selector, + Transaction: types.Transaction{ + AdditionalFields: []byte("{"), + }, + }, + expectedErr: errors.New("stellar additional fields"), + }, { name: "unknown chain family", operation: types.Operation{