From dc20de3d7e9803b57c2a4d93896a33cfafe98cd6 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 25 Jun 2026 14:18:33 +0200 Subject: [PATCH 01/11] Add shared test runner - Timelock schedule/cancel --- e2e/tests/ton/executable.go | 145 ++++++++++ test_runner_timelock_schedule_cancel.go | 334 ++++++++++++++++++++++++ timelock_executable_test.go | 260 ++++-------------- 3 files changed, 524 insertions(+), 215 deletions(-) create mode 100644 test_runner_timelock_schedule_cancel.go diff --git a/e2e/tests/ton/executable.go b/e2e/tests/ton/executable.go index 891de8ee..4568b605 100644 --- a/e2e/tests/ton/executable.go +++ b/e2e/tests/ton/executable.go @@ -8,11 +8,14 @@ import ( "fmt" "math/big" "strconv" + "testing" "github.com/stretchr/testify/suite" + "github.com/stretchr/testify/require" chainsel "github.com/smartcontractkit/chain-selectors" + chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" "github.com/xssnick/tonutils-go/address" "github.com/xssnick/tonutils-go/tlb" "github.com/xssnick/tonutils-go/ton/wallet" @@ -229,6 +232,130 @@ func (s *ExecutionTestSuite) TestExecuteProposal() { s.Require().Contains(proposers, s.mcmsAddr) } +func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { + tests := []struct { + name string + targetRoles []*big.Int + }{ + { + name: "valid schedule and cancel proposal with one tx and one op", + targetRoles: []*big.Int{timelock.RoleProposer}, + }, + { + name: "valid schedule and cancel proposal with one tx and three ops", + targetRoles: []*big.Int{timelock.RoleProposer, timelock.RoleBypasser, timelock.RoleCanceller}, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := s.T().Context() + + inspector := mcmston.NewInspector(s.TonClient) + opCount, err := inspector.GetOpCount(ctx, s.mcmsAddr) + s.Require().NoError(err) + + chainID, err := strconv.ParseInt(s.TonBlockchain.ChainID, 10, 32) + s.Require().NoError(err) + + grantee := must(tvm.NewRandomV5R1TestWallet(s.TonClient, int32(chainID))).Address() + accessor := chainwrappermocks.NewChainAccessor(s.T()) + accessor.EXPECT().TonClient(uint64(s.ChainA)).Return(s.TonClient, true).Maybe() + accessor.EXPECT().TonSigner(uint64(s.ChainA)).Return(s.wallet, true).Maybe() + + mcmslib.RunScheduleAndCancelTest(s.T(), mcmslib.ScheduleAndCancelTestHooks{ + Setup: func(ctx context.Context, t *testing.T) (mcmslib.ScheduleAndCancelTestEnv, error) { + transactions := make([]types.Transaction, 0, len(tt.targetRoles)) + for i, role := range tt.targetRoles { + grantRoleData, grantErr := tlb.ToCell(rbac.GrantRole{ + QueryID: uint64(i + 1), + Role: tlbe.NewUint256(role), + Account: grantee, + }) + if grantErr != nil { + return mcmslib.ScheduleAndCancelTestEnv{}, grantErr + } + + tx, txErr := mcmston.NewTransaction( + address.MustParseAddr(s.timelockAddr), + grantRoleData.ToBuilder().ToSlice(), + tlb.MustFromTON("0.1").Nano(), + bindings.ShortTimelock, + nil, + bindings.TypeTimelock, + []string{bindings.ShortTimelock, "GrantRole"}, + ) + if txErr != nil { + return mcmslib.ScheduleAndCancelTestEnv{}, txErr + } + + transactions = append(transactions, tx) + } + + return mcmslib.ScheduleAndCancelTestEnv{ + Proposal: mcmslib.TimelockProposal{ + BaseProposal: mcmslib.BaseProposal{ + Version: "v1", + Kind: types.KindTimelockProposal, + Description: "Cancels scheduled TON timelock role grants", + ValidUntil: 2004259681, + OverridePreviousRoot: false, + Signatures: []types.Signature{}, + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + s.ChainA: { + StartingOpCount: opCount, + MCMAddress: s.mcmsAddr, + AdditionalFields: testOpAdditionalFields, + }, + }, + }, + Action: types.TimelockActionSchedule, + Delay: types.MustParseDuration("5m"), + TimelockAddresses: map[types.ChainSelector]string{ + s.ChainA: s.timelockAddr, + }, + Operations: []types.BatchOperation{ + { + ChainSelector: s.ChainA, + Transactions: transactions, + }, + }, + }, + Chains: accessor, + }, nil + }, + Sign: func(t *testing.T, signable *mcmslib.Signable) { + _, err := signable.SignAndAppend(mcmslib.NewPrivateKeySigner(s.signers[1].Key)) + require.NoError(t, err) + }, + WaitForTransaction: func(ctx context.Context, t *testing.T, tx types.TransactionResult) { + rawTx, ok := tx.RawData.(*tlb.Transaction) + require.True(t, ok) + require.NotNil(t, rawTx) + require.NoError(t, tracetracking.WaitForTrace(ctx, s.TonClient, rawTx)) + }, + AssertAfterCancel: func( + ctx context.Context, + t *testing.T, + env *mcmslib.ScheduleAndCancelTestEnv, + schedule *mcmslib.TimelockProposal, + cancel *mcmslib.TimelockProposal, + cancelMCMS *mcmslib.Proposal, + tExecutable *mcmslib.TimelockExecutable, + inspectors map[types.ChainSelector]sdk.Inspector, + ) { + inspectorT := mcmston.NewTimelockInspector(s.TonClient) + for _, role := range tt.targetRoles { + members, membersErr := tonRoleMembersFor(ctx, inspectorT, s.timelockAddr, role) + require.NoError(t, membersErr) + require.NotContains(t, members, grantee.String()) + } + }, + }) + }) + } +} + // TestExecuteProposalMultiple executes 2 proposals to check nonce calculation mechanisms are working func (s *ExecutionTestSuite) TestExecuteProposalMultiple() { ctx := context.Background() @@ -1005,3 +1132,21 @@ func (i mockEVMInspector) GetRoot(ctx context.Context, mcmAddr string) (common.H func (i mockEVMInspector) GetRootMetadata(ctx context.Context, mcmAddr string) (types.ChainMetadata, error) { return i.rootMetadata, nil } + +func tonRoleMembersFor( + ctx context.Context, + inspector sdk.TimelockInspector, + timelockAddr string, + role *big.Int, +) ([]string, error) { + switch { + case role.Cmp(timelock.RoleProposer) == 0: + return inspector.GetProposers(ctx, timelockAddr) + case role.Cmp(timelock.RoleBypasser) == 0: + return inspector.GetBypassers(ctx, timelockAddr) + case role.Cmp(timelock.RoleCanceller) == 0: + return inspector.GetCancellers(ctx, timelockAddr) + default: + return nil, fmt.Errorf("unsupported timelock role: %s", role.String()) + } +} diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel.go new file mode 100644 index 00000000..bc4596e8 --- /dev/null +++ b/test_runner_timelock_schedule_cancel.go @@ -0,0 +1,334 @@ +package mcms + +import ( + "context" + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms/chainwrappers" + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +// ScheduleAndCancelTestEnv describes the chain-specific state needed to run the shared +// schedule/cancel timelock flow. +// +// Testing-only: this is intended to let tests in other packages reuse the same generic lifecycle. +type ScheduleAndCancelTestEnv struct { + Proposal TimelockProposal + Chains chainwrappers.ChainAccessor +} + +// ScheduleAndCancelTestHooks defines the chain-specific hooks for the shared schedule/cancel test. +// +// Testing-only: this is intended to let tests in other packages reuse the same generic lifecycle. +type ScheduleAndCancelTestHooks struct { + Setup func(ctx context.Context, t *testing.T) (ScheduleAndCancelTestEnv, error) + Sign func(t *testing.T, signable *Signable) + + // PrepareConvertedProposal allows tests to tweak the converted MCMS proposal before encoders and + // execution are built. EVM simulated backend tests use this to enable simulated-backend mode. + PrepareConvertedProposal func(t *testing.T, proposal *Proposal) + + // WaitForTransaction is called after every SetRoot / Execute result so each chain can confirm + // finality using its own mechanism. + WaitForTransaction func(ctx context.Context, t *testing.T, tx types.TransactionResult) + + AssertAfterSchedule func( + ctx context.Context, + t *testing.T, + env *ScheduleAndCancelTestEnv, + schedule *TimelockProposal, + scheduleMCMS *Proposal, + timelockExecutable *TimelockExecutable, + inspectors map[types.ChainSelector]sdk.Inspector, + ) + AssertAfterCancel func( + ctx context.Context, + t *testing.T, + env *ScheduleAndCancelTestEnv, + schedule *TimelockProposal, + cancel *TimelockProposal, + cancelMCMS *Proposal, + timelockExecutable *TimelockExecutable, + inspectors map[types.ChainSelector]sdk.Inspector, + ) +} + +// RunScheduleAndCancelTest executes the shared timelock schedule/cancel lifecycle using +// chain-specific hooks for setup, signing, and transaction finality. +// +// Testing-only: this is intended to let tests in other packages reuse the same generic lifecycle. +func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { + t.Helper() + + ctx := t.Context() + + env, err := hooks.Setup(ctx, t) + require.NoError(t, err) + + scheduleInspectors, scheduleMCMS := runTimelockLifecycleProposal( + ctx, + t, + &env.Proposal, + env.Chains, + hooks, + ) + + tExecutors, err := chainwrappers.BuildTimelockExecutors(env.Chains, env.Proposal.ChainMetadata, env.Proposal.Action) + require.NoError(t, err) + + tExecutable, err := NewTimelockExecutable(ctx, &env.Proposal, tExecutors) + require.NoError(t, err) + + for opIdx := range env.Proposal.Operations { + assertTimelockOperationState(t, ctx, &env.Proposal, tExecutable, tExecutors, opIdx, true, true, false, false) + assertOperationPendingState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotReadyState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) + } + + if hooks.AssertAfterSchedule != nil { + hooks.AssertAfterSchedule(ctx, t, &env, &env.Proposal, scheduleMCMS, tExecutable, scheduleInspectors) + } + + cancelProposal, err := deriveCancellationProposal(ctx, &env.Proposal, scheduleInspectors) + require.NoError(t, err) + + cancelInspectors, cancelMCMS := runTimelockLifecycleProposal( + ctx, + t, + &cancelProposal, + env.Chains, + hooks, + ) + + for opIdx := range env.Proposal.Operations { + assertTimelockOperationState(t, ctx, &env.Proposal, tExecutable, tExecutors, opIdx, false, false, false, false) + assertOperationNotPendingState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotReadyState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) + } + + if hooks.AssertAfterCancel != nil { + hooks.AssertAfterCancel(ctx, t, &env, &env.Proposal, &cancelProposal, cancelMCMS, tExecutable, cancelInspectors) + } +} + +func runTimelockLifecycleProposal( + ctx context.Context, + t *testing.T, + proposal *TimelockProposal, + chains chainwrappers.ChainAccessor, + hooks ScheduleAndCancelTestHooks, +) (map[types.ChainSelector]sdk.Inspector, *Proposal) { + t.Helper() + + converters, err := chainwrappers.BuildConverters(proposal.ChainMetadata) + require.NoError(t, err) + + mcmsProposal, _, err := proposal.Convert(ctx, converters) + require.NoError(t, err) + + if hooks.PrepareConvertedProposal != nil { + hooks.PrepareConvertedProposal(t, &mcmsProposal) + } + + inspectors, err := chainwrappers.BuildInspectors(chains, mcmsProposal.ChainMetadata, proposal.Action) + require.NoError(t, err) + + signable, err := NewSignable(&mcmsProposal, inspectors) + require.NoError(t, err) + + require.NoError(t, signable.ValidateConfigs(ctx)) + + hooks.Sign(t, signable) + + quorumMet, err := signable.ValidateSignatures(ctx) + require.NoError(t, err) + require.True(t, quorumMet) + + encoders, err := mcmsProposal.GetEncoders() + require.NoError(t, err) + + executors, err := chainwrappers.BuildExecutors(chains, mcmsProposal.ChainMetadata, encoders, proposal.Action) + require.NoError(t, err) + + executable, err := NewExecutable(&mcmsProposal, executors) + require.NoError(t, err) + + tree, err := mcmsProposal.MerkleTree() + require.NoError(t, err) + + for _, selector := range uniqueOperationSelectors(proposal.Operations) { + tx, setRootErr := executable.SetRoot(ctx, selector) + require.NoError(t, setRootErr) + require.NotEmpty(t, tx.Hash) + waitForTransaction(ctx, t, hooks, tx) + + root, validUntil, rootErr := inspectors[selector].GetRoot(ctx, proposal.ChainMetadata[selector].MCMAddress) + require.NoError(t, rootErr) + require.Equal(t, common.Hash([32]byte(tree.Root.Bytes())), root) + require.Equal(t, proposal.ValidUntil, validUntil) + } + + for opIdx := range proposal.Operations { + tx, execErr := executable.Execute(ctx, opIdx) + require.NoError(t, execErr) + require.NotEmpty(t, tx.Hash) + waitForTransaction(ctx, t, hooks, tx) + } + + assertOpCounts(t, ctx, proposal, inspectors) + + return inspectors, &mcmsProposal +} + +func deriveCancellationProposal( + ctx context.Context, + schedule *TimelockProposal, + inspectors map[types.ChainSelector]sdk.Inspector, +) (TimelockProposal, error) { + cancellerMetadata := make(map[types.ChainSelector]types.ChainMetadata, len(schedule.ChainMetadata)) + for selector, metadata := range schedule.ChainMetadata { + opCount, err := inspectors[selector].GetOpCount(ctx, metadata.MCMAddress) + if err != nil { + return TimelockProposal{}, err + } + + next := metadata + next.StartingOpCount = opCount + cancellerMetadata[selector] = next + } + + return schedule.DeriveCancellationProposal(cancellerMetadata) +} + +func assertOpCounts( + t *testing.T, + ctx context.Context, + proposal *TimelockProposal, + inspectors map[types.ChainSelector]sdk.Inspector, +) { + t.Helper() + + counts, err := proposal.OperationCounts(ctx) + require.NoError(t, err) + + for selector, metadata := range proposal.ChainMetadata { + opCount, getErr := inspectors[selector].GetOpCount(ctx, metadata.MCMAddress) + require.NoError(t, getErr) + require.Equal(t, metadata.StartingOpCount+counts[selector], opCount) + } +} + +func waitForTransaction( + ctx context.Context, + t *testing.T, + hooks ScheduleAndCancelTestHooks, + tx types.TransactionResult, +) { + t.Helper() + + if hooks.WaitForTransaction != nil { + hooks.WaitForTransaction(ctx, t, tx) + } +} + +func uniqueOperationSelectors(ops []types.BatchOperation) []types.ChainSelector { + selectors := make([]types.ChainSelector, 0, len(ops)) + seen := make(map[types.ChainSelector]struct{}, len(ops)) + for _, op := range ops { + if _, ok := seen[op.ChainSelector]; ok { + continue + } + seen[op.ChainSelector] = struct{}{} + selectors = append(selectors, op.ChainSelector) + } + + return selectors +} + +func assertOperationPendingState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + require.NoError(t, tExecutable.IsOperationPending(ctx, opIdx)) + for selector := range proposal.ChainMetadata { + require.NoError(t, tExecutable.IsChainPending(ctx, selector)) + } +} + +func assertTimelockOperationState( + t *testing.T, + ctx context.Context, + proposal *TimelockProposal, + tExecutable *TimelockExecutable, + inspectors map[types.ChainSelector]sdk.TimelockExecutor, + opIdx int, + wantIsOperation bool, + wantPending bool, + wantReady bool, + wantDone bool, +) { + t.Helper() + + op := proposal.Operations[opIdx] + selector := op.ChainSelector + timelockAddress := proposal.TimelockAddresses[selector] + + opID, err := tExecutable.GetOpID(ctx, opIdx, op, selector) + require.NoError(t, err) + + gotIsOperation, err := inspectors[selector].IsOperation(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantIsOperation, gotIsOperation) + + gotPending, err := inspectors[selector].IsOperationPending(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantPending, gotPending) + + gotReady, err := inspectors[selector].IsOperationReady(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantReady, gotReady) + + gotDone, err := inspectors[selector].IsOperationDone(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantDone, gotDone) +} + +func assertOperationNotPendingState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + want := fmt.Sprintf("operation %d is not pending", opIdx) + require.ErrorContains(t, tExecutable.IsOperationPending(ctx, opIdx), want) + for selector := range proposal.ChainMetadata { + require.ErrorContains(t, tExecutable.IsChainPending(ctx, selector), want) + } +} + +func assertOperationNotReadyState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + want := fmt.Sprintf("operation %d is not ready", opIdx) + require.ErrorContains(t, tExecutable.IsOperationReady(ctx, opIdx), want) + for selector := range proposal.ChainMetadata { + require.ErrorContains(t, tExecutable.IsChainReady(ctx, selector), want) + } +} + +func assertOperationNotDoneState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + want := fmt.Sprintf("operation %d is not done", opIdx) + require.ErrorContains(t, tExecutable.IsOperationDone(ctx, opIdx), want) + for selector := range proposal.ChainMetadata { + require.ErrorContains(t, tExecutable.IsChainDone(ctx, selector), want) + } +} diff --git a/timelock_executable_test.go b/timelock_executable_test.go index 2e01da37..4ef1fe59 100644 --- a/timelock_executable_test.go +++ b/timelock_executable_test.go @@ -17,6 +17,7 @@ import ( chainsel "github.com/smartcontractkit/chain-selectors" + chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" testutils "github.com/smartcontractkit/mcms/e2e/utils" "github.com/smartcontractkit/mcms/internal/testutils/chaintest" "github.com/smartcontractkit/mcms/internal/testutils/evmsim" @@ -636,224 +637,53 @@ func scheduleAndExecuteGrantRolesProposal(t *testing.T, targetRoles []common.Has func scheduleAndCancelGrantRolesProposal(t *testing.T, targetRoles []common.Hash) { t.Helper() - ctx := t.Context() - - sim, mcmC, timelockC, proposal, _ := scheduleGrantRolesProposal(t, targetRoles, types.MustParseDuration("5m")) - - converters := map[types.ChainSelector]sdk.TimelockConverter{ - chaintest.Chain1Selector: &evm.TimelockConverter{}, - } - - // convert proposal to mcms - mcmsProposal, predecessors, err := proposal.Convert(ctx, converters) - require.NoError(t, err) - mcmsProposal.UseSimulatedBackend(true) - tree, err := mcmsProposal.MerkleTree() - require.NoError(t, err) - - // Gen caller map for easy access - inspectors := map[types.ChainSelector]sdk.Inspector{ - chaintest.Chain1Selector: evm.NewInspector(sim.Backend.Client()), - } - - // Construct executor - signable, err := NewSignable(&mcmsProposal, inspectors) - require.NoError(t, err) - require.NotNil(t, signable) - - _, err = signable.SignAndAppend(NewPrivateKeySigner(sim.Signers[0].PrivateKey)) - require.NoError(t, err) - - // Validate the signatures - quorumMet, err := signable.ValidateSignatures(ctx) - require.NoError(t, err) - require.True(t, quorumMet) - - // Construct encoders - encoders, err := mcmsProposal.GetEncoders() - require.NoError(t, err) - - // Construct executors - executors := map[types.ChainSelector]sdk.Executor{ - chaintest.Chain1Selector: evm.NewExecutor( - encoders[chaintest.Chain1Selector].(*evm.Encoder), - sim.Backend.Client(), - sim.Signers[0].NewTransactOpts(t), - ), - } - - // Construct executable - executable, err := NewExecutable(&mcmsProposal, executors) - require.NoError(t, err) - - // SetRoot on the contract - txHash, err := executable.SetRoot(ctx, chaintest.Chain1Selector) - require.NoError(t, err) - require.NotEmpty(t, txHash) - sim.Backend.Commit() - - // Validate Contract State and verify root was set - root, err := mcmC.GetRoot(&bind.CallOpts{}) - require.NoError(t, err) - require.Equal(t, root.Root, [32]byte(tree.Root.Bytes())) - require.Equal(t, root.ValidUntil, proposal.ValidUntil) - - // Execute the proposal - var receipt *geth_types.Receipt - var tx types.TransactionResult - for i := range proposal.Operations { - tx, err = executable.Execute(ctx, i) - require.NoError(t, err) - require.NotEmpty(t, txHash) - sim.Backend.Commit() - - // Wait for the transaction to be mined - receipt, err = testutils.WaitMinedWithTxHash(ctx, sim.Backend.Client(), common.HexToHash(tx.Hash)) - require.NoError(t, err) - require.NotNil(t, receipt) - require.Equal(t, geth_types.ReceiptStatusSuccessful, receipt.Status) - } - - // Check the state of the MCMS contract - newOpCount, err := mcmC.GetOpCount(&bind.CallOpts{}) - require.NoError(t, err) - require.NotNil(t, newOpCount) - require.Equal(t, uint64(1), newOpCount.Uint64()) - - // Construct executors - tExecutors := map[types.ChainSelector]sdk.TimelockExecutor{ - chaintest.Chain1Selector: evm.NewTimelockExecutor( - sim.Backend.Client(), - sim.Signers[0].NewTransactOpts(t), - ), - } - - // Create new executable - tExecutable, err := NewTimelockExecutable(ctx, &proposal, tExecutors) - require.NoError(t, err) - - for i := range predecessors { - if i == 0 || predecessors[i] == ZeroHash { - continue - } - - var isOperation, isOperationPending, isOperationReady bool - isOperation, err = timelockC.IsOperation(&bind.CallOpts{}, predecessors[i]) - require.NoError(t, err) - require.True(t, isOperation) - isOperationPending, err = timelockC.IsOperationPending(&bind.CallOpts{}, predecessors[i]) - require.NoError(t, err) - require.True(t, isOperationPending) - isOperationReady, err = timelockC.IsOperationReady(&bind.CallOpts{}, predecessors[i]) - require.NoError(t, err) - require.False(t, isOperationReady) - } - - // Check IsReady function fails - err = tExecutable.IsReady(ctx) - require.Error(t, err) - - // Construct cancel proposal - cancelProposal := proposal - cancelProposal.ChainMetadata[chaintest.Chain1Selector] = types.ChainMetadata{ - StartingOpCount: 1, - MCMAddress: mcmC.Address().Hex(), - } - cancelProposal.Action = types.TimelockActionCancel - - // TODO: in practice ValidUntil would need to be updated here, but we set a validUntil - // far enough in the future that it doesn't matter for this test. - - // convert cancelProposal to mcms - cancelMcmsProposal, _, err := cancelProposal.Convert(ctx, converters) - require.NoError(t, err) - cancelMcmsProposal.UseSimulatedBackend(true) - // cancelTree, err := cancelMcmsProposal.MerkleTree() - require.NoError(t, err) - - // Construct executor - cancelSignable, err := NewSignable(&cancelMcmsProposal, inspectors) - require.NoError(t, err) - require.NotNil(t, cancelSignable) - - _, err = cancelSignable.SignAndAppend(NewPrivateKeySigner(sim.Signers[0].PrivateKey)) - require.NoError(t, err) - - // Validate the signatures - cancelQuorumMet, err := cancelSignable.ValidateSignatures(ctx) - require.NoError(t, err) - require.True(t, cancelQuorumMet) - - // Construct encoders - cancelEncoders, err := cancelMcmsProposal.GetEncoders() - require.NoError(t, err) - - // Construct executors - cancelExecutors := map[types.ChainSelector]sdk.Executor{ - chaintest.Chain1Selector: evm.NewExecutor( - cancelEncoders[chaintest.Chain1Selector].(*evm.Encoder), - sim.Backend.Client(), - sim.Signers[0].NewTransactOpts(t), - ), - } - // Construct executable - cancelExecutable, err := NewExecutable(&cancelMcmsProposal, cancelExecutors) - require.NoError(t, err) - - // SetRoot on the contract - tx, err = cancelExecutable.SetRoot(ctx, chaintest.Chain1Selector) - require.NoError(t, err) - require.NotEmpty(t, txHash) - sim.Backend.Commit() - - cancelReceipt, err := testutils.WaitMinedWithTxHash(ctx, sim.Backend.Client(), common.HexToHash(tx.Hash)) - require.NoError(t, err) - require.NotNil(t, cancelReceipt) - require.Equal(t, geth_types.ReceiptStatusSuccessful, cancelReceipt.Status) - - // Validate Contract State and verify root was set - cancelRoot, err := mcmC.GetRoot(&bind.CallOpts{}) - require.NoError(t, err) - // require.Equal(t, cancelRoot.Root, [32]byte(cancelTree.Root.Bytes())) - require.Equal(t, cancelRoot.ValidUntil, cancelProposal.ValidUntil) - - // Execute the cancelProposal - for i := range cancelProposal.Operations { - tx, err = cancelExecutable.Execute(ctx, i) - require.NoError(t, err) - require.NotEmpty(t, txHash) - sim.Backend.Commit() - - // Wait for the transaction to be mined - cancelReceipt, err = testutils.WaitMinedWithTxHash(ctx, sim.Backend.Client(), common.HexToHash(tx.Hash)) - require.NoError(t, err) - require.NotNil(t, cancelReceipt) - require.Equal(t, geth_types.ReceiptStatusSuccessful, cancelReceipt.Status) - } - - // Check the state of the MCMS contract - newOpCount, err = mcmC.GetOpCount(&bind.CallOpts{}) - require.NoError(t, err) - require.NotNil(t, newOpCount) - require.Equal(t, uint64(2), newOpCount.Uint64()) + sim, _, timelockC, proposal, _ := scheduleGrantRolesProposal(t, targetRoles, types.MustParseDuration("5m")) - for i := range predecessors { - if i == 0 { - continue - } + accessor := chainwrappermocks.NewChainAccessor(t) + accessor.EXPECT().EVMClient(uint64(chaintest.Chain1Selector)). + Return(sim.Backend.Client(), true).Maybe() + accessor.EXPECT().EVMSigner(uint64(chaintest.Chain1Selector)). + Return(sim.Signers[0].NewTransactOpts(t), true).Maybe() - var isOperation, isOperationPending, isOperationReady bool - isOperation, err = timelockC.IsOperation(&bind.CallOpts{}, predecessors[i]) - require.NoError(t, err) - require.False(t, isOperation) - isOperationPending, err = timelockC.IsOperationPending(&bind.CallOpts{}, predecessors[i]) - require.NoError(t, err) - require.False(t, isOperationPending) - isOperationReady, err = timelockC.IsOperationReady(&bind.CallOpts{}, predecessors[i]) - require.NoError(t, err) - require.False(t, isOperationReady) - } + RunScheduleAndCancelTest(t, ScheduleAndCancelTestHooks{ + Setup: func(ctx context.Context, t *testing.T) (ScheduleAndCancelTestEnv, error) { + return ScheduleAndCancelTestEnv{ + Proposal: proposal, + Chains: accessor, + }, nil + }, + Sign: func(t *testing.T, signable *Signable) { + _, err := signable.SignAndAppend(NewPrivateKeySigner(sim.Signers[0].PrivateKey)) + require.NoError(t, err) + }, + PrepareConvertedProposal: func(t *testing.T, proposal *Proposal) { + proposal.UseSimulatedBackend(true) + }, + WaitForTransaction: func(ctx context.Context, t *testing.T, tx types.TransactionResult) { + sim.Backend.Commit() + receipt, err := testutils.WaitMinedWithTxHash(ctx, sim.Backend.Client(), common.HexToHash(tx.Hash)) + require.NoError(t, err) + require.NotNil(t, receipt) + require.Equal(t, geth_types.ReceiptStatusSuccessful, receipt.Status) + }, + AssertAfterCancel: func( + ctx context.Context, + t *testing.T, + env *ScheduleAndCancelTestEnv, + schedule *TimelockProposal, + cancel *TimelockProposal, + cancelMCMS *Proposal, + tExecutable *TimelockExecutable, + inspectors map[types.ChainSelector]sdk.Inspector, + ) { + for _, role := range targetRoles { + hasRole, err := timelockC.HasRole(&bind.CallOpts{}, role, sim.Signers[0].Address(t)) + require.NoError(t, err) + require.False(t, hasRole) + } + }, + }) } func TestTimelockExecutable_GetChainSpecificIndex(t *testing.T) { From c126fd59f196a52613bb6ea649b034fe76c842b9 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Thu, 25 Jun 2026 14:34:22 +0200 Subject: [PATCH 02/11] Cleanup tests --- e2e/tests/ton/executable.go | 11 +------ test_runner_timelock_schedule_cancel.go | 44 +++++++++---------------- timelock_executable_test.go | 11 +------ 3 files changed, 17 insertions(+), 49 deletions(-) diff --git a/e2e/tests/ton/executable.go b/e2e/tests/ton/executable.go index 4568b605..491a2e5c 100644 --- a/e2e/tests/ton/executable.go +++ b/e2e/tests/ton/executable.go @@ -334,16 +334,7 @@ func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { require.NotNil(t, rawTx) require.NoError(t, tracetracking.WaitForTrace(ctx, s.TonClient, rawTx)) }, - AssertAfterCancel: func( - ctx context.Context, - t *testing.T, - env *mcmslib.ScheduleAndCancelTestEnv, - schedule *mcmslib.TimelockProposal, - cancel *mcmslib.TimelockProposal, - cancelMCMS *mcmslib.Proposal, - tExecutable *mcmslib.TimelockExecutable, - inspectors map[types.ChainSelector]sdk.Inspector, - ) { + AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *mcmslib.ScheduleAndCancelTestEnv) { inspectorT := mcmston.NewTimelockInspector(s.TonClient) for _, role := range tt.targetRoles { members, membersErr := tonRoleMembersFor(ctx, inspectorT, s.timelockAddr, role) diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel.go index bc4596e8..19b83333 100644 --- a/test_runner_timelock_schedule_cancel.go +++ b/test_runner_timelock_schedule_cancel.go @@ -37,25 +37,13 @@ type ScheduleAndCancelTestHooks struct { // finality using its own mechanism. WaitForTransaction func(ctx context.Context, t *testing.T, tx types.TransactionResult) - AssertAfterSchedule func( - ctx context.Context, - t *testing.T, - env *ScheduleAndCancelTestEnv, - schedule *TimelockProposal, - scheduleMCMS *Proposal, - timelockExecutable *TimelockExecutable, - inspectors map[types.ChainSelector]sdk.Inspector, - ) - AssertAfterCancel func( - ctx context.Context, - t *testing.T, - env *ScheduleAndCancelTestEnv, - schedule *TimelockProposal, - cancel *TimelockProposal, - cancelMCMS *Proposal, - timelockExecutable *TimelockExecutable, - inspectors map[types.ChainSelector]sdk.Inspector, - ) + // AssertExtraAfterCancel is reserved for chain-specific semantic assertions that go beyond the + // generic schedule/cancel lifecycle state checks performed by this runner. + // + // Examples: + // - EVM: verify a role grant target still does not have the role after cancellation. + // - TON: verify a grantee address is absent from the role-members list after cancellation. + AssertExtraAfterCancel func(ctx context.Context, t *testing.T, env *ScheduleAndCancelTestEnv) } // RunScheduleAndCancelTest executes the shared timelock schedule/cancel lifecycle using @@ -70,7 +58,7 @@ func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { env, err := hooks.Setup(ctx, t) require.NoError(t, err) - scheduleInspectors, scheduleMCMS := runTimelockLifecycleProposal( + scheduleInspectors, _ := runTimelockLifecycleProposal( ctx, t, &env.Proposal, @@ -91,14 +79,10 @@ func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) } - if hooks.AssertAfterSchedule != nil { - hooks.AssertAfterSchedule(ctx, t, &env, &env.Proposal, scheduleMCMS, tExecutable, scheduleInspectors) - } - cancelProposal, err := deriveCancellationProposal(ctx, &env.Proposal, scheduleInspectors) require.NoError(t, err) - cancelInspectors, cancelMCMS := runTimelockLifecycleProposal( + runTimelockLifecycleProposal( ctx, t, &cancelProposal, @@ -113,8 +97,8 @@ func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) } - if hooks.AssertAfterCancel != nil { - hooks.AssertAfterCancel(ctx, t, &env, &env.Proposal, &cancelProposal, cancelMCMS, tExecutable, cancelInspectors) + if hooks.AssertExtraAfterCancel != nil { + hooks.AssertExtraAfterCancel(ctx, t, &env) } } @@ -163,7 +147,9 @@ func runTimelockLifecycleProposal( tree, err := mcmsProposal.MerkleTree() require.NoError(t, err) - for _, selector := range uniqueOperationSelectors(proposal.Operations) { + // SetRoot is per participating chain, not per operation. A proposal may contain multiple + // operations on the same chain, but its root only needs to be written once for that chain. + for _, selector := range proposalChainSelectors(proposal.Operations) { tx, setRootErr := executable.SetRoot(ctx, selector) require.NoError(t, setRootErr) require.NotEmpty(t, tx.Hash) @@ -238,7 +224,7 @@ func waitForTransaction( } } -func uniqueOperationSelectors(ops []types.BatchOperation) []types.ChainSelector { +func proposalChainSelectors(ops []types.BatchOperation) []types.ChainSelector { selectors := make([]types.ChainSelector, 0, len(ops)) seen := make(map[types.ChainSelector]struct{}, len(ops)) for _, op := range ops { diff --git a/timelock_executable_test.go b/timelock_executable_test.go index 4ef1fe59..257ab7c4 100644 --- a/timelock_executable_test.go +++ b/timelock_executable_test.go @@ -667,16 +667,7 @@ func scheduleAndCancelGrantRolesProposal(t *testing.T, targetRoles []common.Hash require.NotNil(t, receipt) require.Equal(t, geth_types.ReceiptStatusSuccessful, receipt.Status) }, - AssertAfterCancel: func( - ctx context.Context, - t *testing.T, - env *ScheduleAndCancelTestEnv, - schedule *TimelockProposal, - cancel *TimelockProposal, - cancelMCMS *Proposal, - tExecutable *TimelockExecutable, - inspectors map[types.ChainSelector]sdk.Inspector, - ) { + AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *ScheduleAndCancelTestEnv) { for _, role := range targetRoles { hasRole, err := timelockC.HasRole(&bind.CallOpts{}, role, sim.Signers[0].Address(t)) require.NoError(t, err) From 4d72dabdf1a0b70eb0e4ea6d8a229eed921e87ec Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 3 Jul 2026 10:10:13 +0200 Subject: [PATCH 03/11] Make Aptos use mcmslib.RunScheduleAndCancelTest --- e2e/tests/aptos/timelock_cancel.go | 361 ++++++++---------------- test_runner_timelock_schedule_cancel.go | 36 ++- timelock_executable_test.go | 3 + 3 files changed, 155 insertions(+), 245 deletions(-) diff --git a/e2e/tests/aptos/timelock_cancel.go b/e2e/tests/aptos/timelock_cancel.go index 0f22be33..7551b689 100644 --- a/e2e/tests/aptos/timelock_cancel.go +++ b/e2e/tests/aptos/timelock_cancel.go @@ -3,117 +3,143 @@ package aptos import ( - "crypto/ecdsa" + "context" "encoding/json" - "slices" + "testing" "time" - "github.com/smartcontractkit/mcms" - "github.com/smartcontractkit/mcms/sdk" - "github.com/smartcontractkit/mcms/types" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-aptos/bindings/bind" aptossdk "github.com/smartcontractkit/mcms/sdk/aptos" + + mcmslib "github.com/smartcontractkit/mcms" + chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" + "github.com/smartcontractkit/mcms/internal/testutils" + "github.com/smartcontractkit/mcms/types" ) func (a *TestSuite) TestTimelock_Cancel() { - /* - This tests that a timelock proposal scheduled by the Proposer MCM can be cancelled by the - Canceller MCM. - - 1. Configure the Canceller signers - 2. Configure the Proposer signers - 3. Initiate the ownership transfer from the deployer EOA (transfer_ownership) - 4. Create and schedule a proposal using the Proposer MCM to accept ownership - 5. Check that the operation has actually been scheduled with the timelock (is_operation) - 6. Derive a cancellation-proposal form the timelock proposal and execute it - 7. Check that the operation has actually been cancelled -> is_operation should return false - */ a.deployMCMSContract() mcmsAddress := a.MCMSContract.Address() - opts := &bind.TransactOpts{Signer: a.deployerAccount} - - // Configure Cancellers - cancellers := [2]common.Address{} - cancellerKeys := [2]*ecdsa.PrivateKey{} - for i := range cancellers { - cancellerKeys[i], _ = crypto.GenerateKey() - cancellers[i] = crypto.PubkeyToAddress(cancellerKeys[i].PublicKey) - } - slices.SortFunc(cancellers[:], func(a, b common.Address) int { - return a.Cmp(b) + a.initTransferOwnership() + + // Generate signers for both Proposer and Canceller roles + proposerSigners := testutils.MakeNewECDSASigners(2) + cancellerSigners := testutils.MakeNewECDSASigners(2) + + a.configureRole(aptossdk.TimelockRoleProposer, keysToAddresses(proposerSigners), 2) + a.configureRole(aptossdk.TimelockRoleCanceller, keysToAddresses(cancellerSigners), 2) + + accessor := chainwrappermocks.NewChainAccessor(a.T()) + accessor.EXPECT().AptosClient(uint64(a.ChainSelector)).Return(a.AptosRPCClient, true).Maybe() + accessor.EXPECT().AptosSigner(uint64(a.ChainSelector)).Return(a.deployerAccount, true).Maybe() + + phase := 0 + mcmslib.RunScheduleAndCancelTest(a.T(), mcmslib.ScheduleAndCancelTestHooks{ + Setup: func(ctx context.Context, t *testing.T) (mcmslib.ScheduleAndCancelTestEnv, error) { + transaction, txErr := a.buildAcceptOwnershipTransaction() + if txErr != nil { + return mcmslib.ScheduleAndCancelTestEnv{}, txErr + } + + return mcmslib.ScheduleAndCancelTestEnv{ + Proposal: mcmslib.TimelockProposal{ + BaseProposal: mcmslib.BaseProposal{ + Version: "v1", + Kind: types.KindTimelockProposal, + Description: "Accept ownership via timelock", + ValidUntil: uint32(time.Now().Add(time.Hour * 24).Unix()), + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + a.ChainSelector: { + StartingOpCount: 0, + MCMAddress: mcmsAddress.StringLong(), + AdditionalFields: Must(json.Marshal(aptossdk.AdditionalFieldsMetadata{Role: aptossdk.TimelockRoleProposer})), + }, + }, + }, + Action: types.TimelockActionSchedule, + Delay: types.MustParseDuration("1s"), + TimelockAddresses: map[types.ChainSelector]string{ + a.ChainSelector: mcmsAddress.StringLong(), + }, + Operations: []types.BatchOperation{ + { + ChainSelector: a.ChainSelector, + Transactions: []types.Transaction{transaction}, + }, + }, + }, + Chains: accessor, + }, nil + }, + Sign: func(t *testing.T, signable *mcmslib.Signable) { + // Phase 0 = schedule (proposer), Phase 1 = cancel (canceller) + signers := proposerSigners + if phase > 0 { + signers = cancellerSigners + } + phase++ + for _, signer := range signers { + _, err := signable.SignAndAppend(mcmslib.NewPrivateKeySigner(signer.Key)) + require.NoError(t, err) + } + }, + DeriveCancellationMetadata: func(t *testing.T, selector types.ChainSelector, scheduleMetadata types.ChainMetadata) (types.ChainMetadata, error) { + // For the cancellation proposal, the MCMS role in AdditionalFields must be switched + // from the schedule role (Proposer) to the Canceller role. The Aptos SetRoot call + // reads the role from this metadata field and passes it to the on-chain contract. + next := scheduleMetadata + next.AdditionalFields = Must(json.Marshal(aptossdk.AdditionalFieldsMetadata{Role: aptossdk.TimelockRoleCanceller})) + + cancellerInspector := aptossdk.NewInspector(a.AptosRPCClient, aptossdk.TimelockRoleCanceller) + cancellerOpCount, err := cancellerInspector.GetOpCount(t.Context(), next.MCMAddress) + if err != nil { + return types.ChainMetadata{}, err + } + next.StartingOpCount = cancellerOpCount + + return next, nil + }, + WaitForTransaction: func(ctx context.Context, t *testing.T, tx types.TransactionResult) { + data, err := a.AptosRPCClient.WaitForTransaction(tx.Hash) + require.NoError(t, err) + require.True(t, data.Success, data.VmStatus) + }, }) - { - cancellerConfig := &types.Config{ - Quorum: 2, - Signers: cancellers[:], - } - cancelerConfigurer := aptossdk.NewConfigurer(a.AptosRPCClient, a.deployerAccount, aptossdk.TimelockRoleCanceller) - result, err := cancelerConfigurer.SetConfig(a.T().Context(), mcmsAddress.StringLong(), cancellerConfig, false) - a.Require().NoError(err) - data, err := a.AptosRPCClient.WaitForTransaction(result.Hash) - a.Require().NoError(err) - a.Require().True(data.Success, data.VmStatus) - } +} - // Configure Proposers - proposers := [3]common.Address{} - proposerKeys := [3]*ecdsa.PrivateKey{} - for i := range proposers { - proposerKeys[i], _ = crypto.GenerateKey() - proposers[i] = crypto.PubkeyToAddress(proposerKeys[i].PublicKey) - } - slices.SortFunc(proposers[:], func(a, b common.Address) int { - return a.Cmp(b) - }) - { - proposerConfig := &types.Config{ - Quorum: 3, - Signers: proposers[:], - } - proposeConfigurer := aptossdk.NewConfigurer(a.AptosRPCClient, a.deployerAccount, aptossdk.TimelockRoleProposer) - result, err := proposeConfigurer.SetConfig(a.T().Context(), mcmsAddress.StringLong(), proposerConfig, false) - a.Require().NoError(err) - data, err := a.AptosRPCClient.WaitForTransaction(result.Hash) - a.Require().NoError(err) - a.Require().True(data.Success, data.VmStatus) - } +func (a *TestSuite) initTransferOwnership() { + opts := &bind.TransactOpts{Signer: a.deployerAccount} + tx, err := a.MCMSContract.MCMSAccount().TransferOwnershipToSelf(opts) + a.Require().NoError(err) + data, err := a.AptosRPCClient.WaitForTransaction(tx.Hash) + a.Require().NoError(err) + a.Require().True(data.Success, data.VmStatus) +} - // Initiate ownership transfer - { - tx, err := a.MCMSContract.MCMSAccount().TransferOwnershipToSelf(opts) - a.Require().NoError(err) - data, err := a.AptosRPCClient.WaitForTransaction(tx.Hash) - a.Require().NoError(err) - a.Require().True(data.Success, data.VmStatus) - a.T().Logf("🚀 TransferOwnershipToSelf in tx: %s", tx.Hash) +func (a *TestSuite) configureRole(role aptossdk.TimelockRole, signers []common.Address, quorum uint8) { + config := &types.Config{ + Quorum: quorum, + Signers: signers, } + configurer := aptossdk.NewConfigurer(a.AptosRPCClient, a.deployerAccount, role) + addr := a.MCMSContract.Address() + result, err := configurer.SetConfig(a.T().Context(), addr.StringLong(), config, false) + a.Require().NoError(err) + data, err := a.AptosRPCClient.WaitForTransaction(result.Hash) + a.Require().NoError(err) + a.Require().True(data.Success, data.VmStatus) +} - // ======================================================= - // | Proposal - schedule accept ownership with proposers | - // ======================================================= - - validUntil := uint32(time.Now().Add(time.Hour * 24).Unix()) - acceptOwnershipProposalBuilder := mcms.NewTimelockProposalBuilder(). - SetVersion("v1"). - SetValidUntil(validUntil). - SetDescription("Accept ownership via timelock"). - AddTimelockAddress(a.ChainSelector, mcmsAddress.StringLong()). - AddChainMetadata(a.ChainSelector, types.ChainMetadata{ - StartingOpCount: 0, - MCMAddress: mcmsAddress.StringLong(), - AdditionalFields: Must(json.Marshal(aptossdk.AdditionalFieldsMetadata{Role: aptossdk.TimelockRoleProposer})), - }). - SetAction(types.TimelockActionSchedule). - SetDelay(types.NewDuration(time.Second)) - +func (a *TestSuite) buildAcceptOwnershipTransaction() (types.Transaction, error) { module, function, _, args, err := a.MCMSContract.MCMSAccount().Encoder().AcceptOwnership() - a.Require().NoError(err) - transaction, err := aptossdk.NewTransaction( + if err != nil { + return types.Transaction{}, err + } + return aptossdk.NewTransaction( module.PackageName, module.ModuleName, function, @@ -122,151 +148,12 @@ func (a *TestSuite) TestTimelock_Cancel() { "MCMS", nil, ) - a.Require().NoError(err) - acceptOwnershipProposalBuilder.AddOperation(types.BatchOperation{ - ChainSelector: a.ChainSelector, - Transactions: []types.Transaction{transaction}, - }) - acceptOwnershipTimelockProposal, err := acceptOwnershipProposalBuilder.Build() - a.Require().NoError(err) - - convertersMap := map[types.ChainSelector]sdk.TimelockConverter{ - a.ChainSelector: aptossdk.NewTimelockConverter(), - } - acceptOwnershipProposal, _, err := acceptOwnershipTimelockProposal.Convert(a.T().Context(), convertersMap) - a.Require().NoError(err) - - proposerInspector := aptossdk.NewInspector(a.AptosRPCClient, aptossdk.TimelockRoleProposer) - proposerInspectorsMap := map[types.ChainSelector]sdk.Inspector{ - a.ChainSelector: proposerInspector, - } - proposerSignable, err := mcms.NewSignable(&acceptOwnershipProposal, proposerInspectorsMap) - a.Require().NoError(err) - - _, err = proposerSignable.SignAndAppend(mcms.NewPrivateKeySigner(proposerKeys[0])) - a.Require().NoError(err) - _, err = proposerSignable.SignAndAppend(mcms.NewPrivateKeySigner(proposerKeys[1])) - a.Require().NoError(err) - _, err = proposerSignable.SignAndAppend(mcms.NewPrivateKeySigner(proposerKeys[2])) - a.Require().NoError(err) - - quorumMet, err := proposerSignable.ValidateSignatures(a.T().Context()) - a.Require().NoError(err, "Error validating signatures") - a.Require().True(quorumMet, "Quorum not met") - - // Set Root - encoders, err := acceptOwnershipProposal.GetEncoders() - a.Require().NoError(err) - aptosEncoder := encoders[a.ChainSelector].(*aptossdk.Encoder) - proposerExecutors := map[types.ChainSelector]sdk.Executor{ - a.ChainSelector: aptossdk.NewExecutor(a.AptosRPCClient, a.deployerAccount, aptosEncoder, aptossdk.TimelockRoleProposer), - } - proposerExecutable, err := mcms.NewExecutable(&acceptOwnershipProposal, proposerExecutors) - a.Require().NoError(err, "Error creating executable") - - result, err := proposerExecutable.SetRoot(a.T().Context(), a.ChainSelector) - a.Require().NoError(err) - - data, err := a.AptosRPCClient.WaitForTransaction(result.Hash) - a.Require().NoError(err) - a.Require().True(data.Success, data.VmStatus) - a.T().Logf("✅ SetRoot in tx: %s", result.Hash) - - // Assert - tree, _ := acceptOwnershipProposal.MerkleTree() - gotHash, gotValidUntil, err := proposerInspector.GetRoot(a.T().Context(), mcmsAddress.StringLong()) - a.Require().NoError(err) - a.Require().Equal(validUntil, gotValidUntil) - a.Require().Equal(tree.Root, gotHash) - - // Execute - a.T().Logf("Executing operation: %v", 0) - txOutput, err := proposerExecutable.Execute(a.T().Context(), 0) - a.Require().NoError(err) - data, err = a.AptosRPCClient.WaitForTransaction(txOutput.Hash) - a.Require().NoError(err) - a.Require().True(data.Success, data.VmStatus) - a.T().Logf("✅ Executed Operation in tx: %s", txOutput.Hash) - - // Assert - - // Check that op count has increased on the mcms contract - var opCount uint64 - opCount, err = proposerInspector.GetOpCount(a.T().Context(), mcmsAddress.StringLong()) - a.Require().NoError(err) - a.Require().EqualValues(1, opCount) - - timelockExecutor := aptossdk.NewTimelockExecutor(a.AptosRPCClient, a.deployerAccount) - timelockExecutors := map[types.ChainSelector]sdk.TimelockExecutor{ - a.ChainSelector: timelockExecutor, - } - timelockExecutable, err := mcms.NewTimelockExecutable(a.T().Context(), acceptOwnershipTimelockProposal, timelockExecutors) - a.Require().NoError(err) - - operationID, err := timelockExecutable.GetOpID(a.T().Context(), 0, acceptOwnershipTimelockProposal.Operations[0], a.ChainSelector) - a.Require().NoError(err) - timelockInspector := aptossdk.NewTimelockInspector(a.AptosRPCClient) - ok, err := timelockInspector.IsOperation(a.T().Context(), mcmsAddress.StringLong(), operationID) - a.Require().NoError(err) - a.Require().True(ok, "Operation not found in timelock") - - // ====================================================== - // | Proposal - cancel accept ownership with cancellers | - // ====================================================== - - cancelTimelockProposal, err := acceptOwnershipTimelockProposal.DeriveCancellationProposal(map[types.ChainSelector]types.ChainMetadata{ - a.ChainSelector: { - StartingOpCount: 0, - MCMAddress: mcmsAddress.StringLong(), - AdditionalFields: Must(json.Marshal(aptossdk.AdditionalFieldsMetadata{Role: aptossdk.TimelockRoleCanceller})), - }, - }) - a.Require().NoError(err) - - cancelProposal, _, err := cancelTimelockProposal.Convert(a.T().Context(), convertersMap) - a.Require().NoError(err) - - cancellerInspector := aptossdk.NewInspector(a.AptosRPCClient, aptossdk.TimelockRoleCanceller) - cancellerInspectorMap := map[types.ChainSelector]sdk.Inspector{ - a.ChainSelector: cancellerInspector, - } - cancellerSignable, err := mcms.NewSignable(&cancelProposal, cancellerInspectorMap) - a.Require().NoError(err) - - _, err = cancellerSignable.SignAndAppend(mcms.NewPrivateKeySigner(cancellerKeys[0])) - a.Require().NoError(err) - _, err = cancellerSignable.SignAndAppend(mcms.NewPrivateKeySigner(cancellerKeys[1])) - a.Require().NoError(err) - - quorumMet, err = cancellerSignable.ValidateSignatures(a.T().Context()) - a.Require().NoError(err) - a.Require().True(quorumMet, "Quorum not met") +} - // Set Root - cancellerExecutors := map[types.ChainSelector]sdk.Executor{ - a.ChainSelector: aptossdk.NewExecutor(a.AptosRPCClient, a.deployerAccount, aptosEncoder, aptossdk.TimelockRoleCanceller), +func keysToAddresses(signers []testutils.ECDSASigner) []common.Address { + addrs := make([]common.Address, len(signers)) + for i, s := range signers { + addrs[i] = s.Address() } - cancelExecutable, err := mcms.NewExecutable(&cancelProposal, cancellerExecutors) - a.Require().NoError(err) - - result, err = cancelExecutable.SetRoot(a.T().Context(), a.ChainSelector) - a.Require().NoError(err) - data, err = a.AptosRPCClient.WaitForTransaction(result.Hash) - a.Require().NoError(err) - a.Require().True(data.Success, data.VmStatus) - a.T().Logf("SetRoot of cancel proposal: %s", result.Hash) - - txOutput, err = cancelExecutable.Execute(a.T().Context(), 0) - a.Require().NoError(err) - data, err = a.AptosRPCClient.WaitForTransaction(txOutput.Hash) - a.Require().NoError(err) - a.Require().True(data.Success, data.VmStatus) - a.T().Logf("Canceled operation") - - // Assert - ok, err = timelockInspector.IsOperation(a.T().Context(), mcmsAddress.StringLong(), operationID) - a.Require().NoError(err) - a.Require().False(ok, "Operation was cancelled but is still found in timelock") - - a.T().Logf("🔓 Timelock operation %v canceled successfully", operationID) + return addrs } diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel.go index 19b83333..6ac0a4e6 100644 --- a/test_runner_timelock_schedule_cancel.go +++ b/test_runner_timelock_schedule_cancel.go @@ -33,6 +33,13 @@ type ScheduleAndCancelTestHooks struct { // execution are built. EVM simulated backend tests use this to enable simulated-backend mode. PrepareConvertedProposal func(t *testing.T, proposal *Proposal) + // DeriveCancellationMetadata allows tests to override the cancellation metadata for a chain. + // The default runner copies the schedule metadata (only updating StartingOpCount), but some + // chains need to change fields like AdditionalFields. For example, Aptos stores the MCMS role + // in AdditionalFields and switches it from Proposer → Canceller for the cancel proposal. + // If nil, the runner falls back to copying the schedule metadata as-is. + DeriveCancellationMetadata func(t *testing.T, selector types.ChainSelector, scheduleMetadata types.ChainMetadata) (types.ChainMetadata, error) + // WaitForTransaction is called after every SetRoot / Execute result so each chain can confirm // finality using its own mechanism. WaitForTransaction func(ctx context.Context, t *testing.T, tx types.TransactionResult) @@ -58,7 +65,7 @@ func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { env, err := hooks.Setup(ctx, t) require.NoError(t, err) - scheduleInspectors, _ := runTimelockLifecycleProposal( + scheduleInspectors := runTimelockLifecycleProposal( ctx, t, &env.Proposal, @@ -79,7 +86,7 @@ func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) } - cancelProposal, err := deriveCancellationProposal(ctx, &env.Proposal, scheduleInspectors) + cancelProposal, err := deriveCancellationProposal(ctx, t, &env.Proposal, scheduleInspectors, hooks) require.NoError(t, err) runTimelockLifecycleProposal( @@ -108,7 +115,7 @@ func runTimelockLifecycleProposal( proposal *TimelockProposal, chains chainwrappers.ChainAccessor, hooks ScheduleAndCancelTestHooks, -) (map[types.ChainSelector]sdk.Inspector, *Proposal) { +) map[types.ChainSelector]sdk.Inspector { t.Helper() converters, err := chainwrappers.BuildConverters(proposal.ChainMetadata) @@ -124,7 +131,7 @@ func runTimelockLifecycleProposal( inspectors, err := chainwrappers.BuildInspectors(chains, mcmsProposal.ChainMetadata, proposal.Action) require.NoError(t, err) - signable, err := NewSignable(&mcmsProposal, inspectors) + signable, err := NewSignable(&mcmsProposal, inspectors) //nolint:contextcheck //OPT-400 require.NoError(t, err) require.NoError(t, signable.ValidateConfigs(ctx)) @@ -135,16 +142,16 @@ func runTimelockLifecycleProposal( require.NoError(t, err) require.True(t, quorumMet) - encoders, err := mcmsProposal.GetEncoders() + encoders, err := mcmsProposal.GetEncoders() //nolint:contextcheck //OPT-400 require.NoError(t, err) executors, err := chainwrappers.BuildExecutors(chains, mcmsProposal.ChainMetadata, encoders, proposal.Action) require.NoError(t, err) - executable, err := NewExecutable(&mcmsProposal, executors) + executable, err := NewExecutable(&mcmsProposal, executors) //nolint:contextcheck //OPT-400 require.NoError(t, err) - tree, err := mcmsProposal.MerkleTree() + tree, err := mcmsProposal.MerkleTree() //nolint:contextcheck //OPT-400 require.NoError(t, err) // SetRoot is per participating chain, not per operation. A proposal may contain multiple @@ -170,13 +177,15 @@ func runTimelockLifecycleProposal( assertOpCounts(t, ctx, proposal, inspectors) - return inspectors, &mcmsProposal + return inspectors } func deriveCancellationProposal( ctx context.Context, + t *testing.T, schedule *TimelockProposal, inspectors map[types.ChainSelector]sdk.Inspector, + hooks ScheduleAndCancelTestHooks, ) (TimelockProposal, error) { cancellerMetadata := make(map[types.ChainSelector]types.ChainMetadata, len(schedule.ChainMetadata)) for selector, metadata := range schedule.ChainMetadata { @@ -187,6 +196,17 @@ func deriveCancellationProposal( next := metadata next.StartingOpCount = opCount + + // Allow chain-specific overrides (e.g., Aptos needs to swap the role in AdditionalFields + // and use the Canceller's per-role op count instead of the Proposer's). + if hooks.DeriveCancellationMetadata != nil { + var err error + next, err = hooks.DeriveCancellationMetadata(t, selector, next) + if err != nil { + return TimelockProposal{}, fmt.Errorf("deriving cancellation metadata for selector %d: %w", selector, err) + } + } + cancellerMetadata[selector] = next } diff --git a/timelock_executable_test.go b/timelock_executable_test.go index 257ab7c4..74ac539d 100644 --- a/timelock_executable_test.go +++ b/timelock_executable_test.go @@ -654,13 +654,16 @@ func scheduleAndCancelGrantRolesProposal(t *testing.T, targetRoles []common.Hash }, nil }, Sign: func(t *testing.T, signable *Signable) { + t.Helper() _, err := signable.SignAndAppend(NewPrivateKeySigner(sim.Signers[0].PrivateKey)) require.NoError(t, err) }, PrepareConvertedProposal: func(t *testing.T, proposal *Proposal) { + t.Helper() proposal.UseSimulatedBackend(true) }, WaitForTransaction: func(ctx context.Context, t *testing.T, tx types.TransactionResult) { + t.Helper() sim.Backend.Commit() receipt, err := testutils.WaitMinedWithTxHash(ctx, sim.Backend.Client(), common.HexToHash(tx.Hash)) require.NoError(t, err) From f29a3fd9f47dc48305af97e2f07085de191ad6ba Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 3 Jul 2026 10:31:31 +0200 Subject: [PATCH 04/11] Add assert + fix lint --- e2e/tests/aptos/timelock_cancel.go | 8 ++++++++ test_runner_timelock_schedule_cancel.go | 1 + timelock_executable_test.go | 2 ++ 3 files changed, 11 insertions(+) diff --git a/e2e/tests/aptos/timelock_cancel.go b/e2e/tests/aptos/timelock_cancel.go index 7551b689..045993bc 100644 --- a/e2e/tests/aptos/timelock_cancel.go +++ b/e2e/tests/aptos/timelock_cancel.go @@ -108,6 +108,14 @@ func (a *TestSuite) TestTimelock_Cancel() { require.NoError(t, err) require.True(t, data.Success, data.VmStatus) }, + AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *mcmslib.ScheduleAndCancelTestEnv) { + // After the cancel proposal is executed, verify that the ownership transfer + // was not accepted — the owner should still be the deployer (from TransferOwnershipToSelf). + owner, err := a.MCMSContract.MCMSAccount().Owner(nil) + require.NoError(t, err) + require.Equal(t, a.deployerAccount.AccountAddress(), owner, + "Owner should remain the deployer after cancellation (accept_ownership was not executed)") + }, }) } diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel.go index 6ac0a4e6..294e7c9f 100644 --- a/test_runner_timelock_schedule_cancel.go +++ b/test_runner_timelock_schedule_cancel.go @@ -187,6 +187,7 @@ func deriveCancellationProposal( inspectors map[types.ChainSelector]sdk.Inspector, hooks ScheduleAndCancelTestHooks, ) (TimelockProposal, error) { + t.Helper() cancellerMetadata := make(map[types.ChainSelector]types.ChainMetadata, len(schedule.ChainMetadata)) for selector, metadata := range schedule.ChainMetadata { opCount, err := inspectors[selector].GetOpCount(ctx, metadata.MCMAddress) diff --git a/timelock_executable_test.go b/timelock_executable_test.go index 74ac539d..0f3b7557 100644 --- a/timelock_executable_test.go +++ b/timelock_executable_test.go @@ -648,6 +648,7 @@ func scheduleAndCancelGrantRolesProposal(t *testing.T, targetRoles []common.Hash RunScheduleAndCancelTest(t, ScheduleAndCancelTestHooks{ Setup: func(ctx context.Context, t *testing.T) (ScheduleAndCancelTestEnv, error) { + t.Helper() return ScheduleAndCancelTestEnv{ Proposal: proposal, Chains: accessor, @@ -671,6 +672,7 @@ func scheduleAndCancelGrantRolesProposal(t *testing.T, targetRoles []common.Hash require.Equal(t, geth_types.ReceiptStatusSuccessful, receipt.Status) }, AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *ScheduleAndCancelTestEnv) { + t.Helper() for _, role := range targetRoles { hasRole, err := timelockC.HasRole(&bind.CallOpts{}, role, sim.Signers[0].Address(t)) require.NoError(t, err) From 08e521b789706a505fb7155befaa8d5f3f53d1b0 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 3 Jul 2026 11:44:01 +0200 Subject: [PATCH 05/11] Fix lint --- e2e/tests/aptos/timelock_cancel.go | 5 +++++ e2e/tests/ton/executable.go | 28 +++++++++++----------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/e2e/tests/aptos/timelock_cancel.go b/e2e/tests/aptos/timelock_cancel.go index 045993bc..b272601e 100644 --- a/e2e/tests/aptos/timelock_cancel.go +++ b/e2e/tests/aptos/timelock_cancel.go @@ -40,6 +40,7 @@ func (a *TestSuite) TestTimelock_Cancel() { phase := 0 mcmslib.RunScheduleAndCancelTest(a.T(), mcmslib.ScheduleAndCancelTestHooks{ Setup: func(ctx context.Context, t *testing.T) (mcmslib.ScheduleAndCancelTestEnv, error) { + t.Helper() transaction, txErr := a.buildAcceptOwnershipTransaction() if txErr != nil { return mcmslib.ScheduleAndCancelTestEnv{}, txErr @@ -76,6 +77,7 @@ func (a *TestSuite) TestTimelock_Cancel() { }, nil }, Sign: func(t *testing.T, signable *mcmslib.Signable) { + t.Helper() // Phase 0 = schedule (proposer), Phase 1 = cancel (canceller) signers := proposerSigners if phase > 0 { @@ -88,6 +90,7 @@ func (a *TestSuite) TestTimelock_Cancel() { } }, DeriveCancellationMetadata: func(t *testing.T, selector types.ChainSelector, scheduleMetadata types.ChainMetadata) (types.ChainMetadata, error) { + t.Helper() // For the cancellation proposal, the MCMS role in AdditionalFields must be switched // from the schedule role (Proposer) to the Canceller role. The Aptos SetRoot call // reads the role from this metadata field and passes it to the on-chain contract. @@ -147,6 +150,7 @@ func (a *TestSuite) buildAcceptOwnershipTransaction() (types.Transaction, error) if err != nil { return types.Transaction{}, err } + return aptossdk.NewTransaction( module.PackageName, module.ModuleName, @@ -163,5 +167,6 @@ func keysToAddresses(signers []testutils.ECDSASigner) []common.Address { for i, s := range signers { addrs[i] = s.Address() } + return addrs } diff --git a/e2e/tests/ton/executable.go b/e2e/tests/ton/executable.go index 491a2e5c..a70d603c 100644 --- a/e2e/tests/ton/executable.go +++ b/e2e/tests/ton/executable.go @@ -10,17 +10,8 @@ import ( "strconv" "testing" - "github.com/stretchr/testify/suite" - "github.com/stretchr/testify/require" - + "github.com/ethereum/go-ethereum/common" chainsel "github.com/smartcontractkit/chain-selectors" - - chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" - "github.com/xssnick/tonutils-go/address" - "github.com/xssnick/tonutils-go/tlb" - "github.com/xssnick/tonutils-go/ton/wallet" - "github.com/xssnick/tonutils-go/tvm/cell" - "github.com/smartcontractkit/chainlink-ton/pkg/bindings" "github.com/smartcontractkit/chainlink-ton/pkg/bindings/lib/access/rbac" "github.com/smartcontractkit/chainlink-ton/pkg/bindings/mcms/mcms" @@ -30,18 +21,21 @@ import ( "github.com/smartcontractkit/chainlink-ton/pkg/ton/tlbe" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tracetracking" "github.com/smartcontractkit/chainlink-ton/pkg/ton/tvm" - - "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/xssnick/tonutils-go/address" + "github.com/xssnick/tonutils-go/tlb" + "github.com/xssnick/tonutils-go/ton/wallet" + "github.com/xssnick/tonutils-go/tvm/cell" mcmslib "github.com/smartcontractkit/mcms" - "github.com/smartcontractkit/mcms/sdk" - "github.com/smartcontractkit/mcms/sdk/evm" - "github.com/smartcontractkit/mcms/types" - + chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" e2e "github.com/smartcontractkit/mcms/e2e/tests" "github.com/smartcontractkit/mcms/internal/testutils" - + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/sdk/evm" mcmston "github.com/smartcontractkit/mcms/sdk/ton" + "github.com/smartcontractkit/mcms/types" ) type ExecutionTestSuite struct { From fd2e866c4881e562cf6a7068835dc79ece28d1f2 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 3 Jul 2026 12:12:34 +0200 Subject: [PATCH 06/11] Feedback + lint --- e2e/tests/aptos/timelock_cancel.go | 2 ++ e2e/tests/ton/executable.go | 4 ++++ test_runner_timelock_schedule_cancel.go | 27 ++++++++++++++++--------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/e2e/tests/aptos/timelock_cancel.go b/e2e/tests/aptos/timelock_cancel.go index b272601e..48edff54 100644 --- a/e2e/tests/aptos/timelock_cancel.go +++ b/e2e/tests/aptos/timelock_cancel.go @@ -107,11 +107,13 @@ func (a *TestSuite) TestTimelock_Cancel() { return next, nil }, WaitForTransaction: func(ctx context.Context, t *testing.T, tx types.TransactionResult) { + t.Helper() data, err := a.AptosRPCClient.WaitForTransaction(tx.Hash) require.NoError(t, err) require.True(t, data.Success, data.VmStatus) }, AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *mcmslib.ScheduleAndCancelTestEnv) { + t.Helper() // After the cancel proposal is executed, verify that the ownership transfer // was not accepted — the owner should still be the deployer (from TransferOwnershipToSelf). owner, err := a.MCMSContract.MCMSAccount().Owner(nil) diff --git a/e2e/tests/ton/executable.go b/e2e/tests/ton/executable.go index a70d603c..33877e0a 100644 --- a/e2e/tests/ton/executable.go +++ b/e2e/tests/ton/executable.go @@ -259,6 +259,7 @@ func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { mcmslib.RunScheduleAndCancelTest(s.T(), mcmslib.ScheduleAndCancelTestHooks{ Setup: func(ctx context.Context, t *testing.T) (mcmslib.ScheduleAndCancelTestEnv, error) { + t.Helper() transactions := make([]types.Transaction, 0, len(tt.targetRoles)) for i, role := range tt.targetRoles { grantRoleData, grantErr := tlb.ToCell(rbac.GrantRole{ @@ -319,16 +320,19 @@ func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { }, nil }, Sign: func(t *testing.T, signable *mcmslib.Signable) { + t.Helper() _, err := signable.SignAndAppend(mcmslib.NewPrivateKeySigner(s.signers[1].Key)) require.NoError(t, err) }, WaitForTransaction: func(ctx context.Context, t *testing.T, tx types.TransactionResult) { + t.Helper() rawTx, ok := tx.RawData.(*tlb.Transaction) require.True(t, ok) require.NotNil(t, rawTx) require.NoError(t, tracetracking.WaitForTrace(ctx, s.TonClient, rawTx)) }, AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *mcmslib.ScheduleAndCancelTestEnv) { + t.Helper() inspectorT := mcmston.NewTimelockInspector(s.TonClient) for _, role := range tt.targetRoles { members, membersErr := tonRoleMembersFor(ctx, inspectorT, s.timelockAddr, role) diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel.go index 294e7c9f..5395fe83 100644 --- a/test_runner_timelock_schedule_cancel.go +++ b/test_runner_timelock_schedule_cancel.go @@ -60,6 +60,9 @@ type ScheduleAndCancelTestHooks struct { func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { t.Helper() + require.NotNil(t, hooks.Setup, "ScheduleAndCancelTestHooks.Setup must not be nil") + require.NotNil(t, hooks.Sign, "ScheduleAndCancelTestHooks.Sign must not be nil") + ctx := t.Context() env, err := hooks.Setup(ctx, t) @@ -311,10 +314,12 @@ func assertOperationNotPendingState(t *testing.T, tExecutable *TimelockExecutabl t.Helper() ctx := t.Context() - want := fmt.Sprintf("operation %d is not pending", opIdx) - require.ErrorContains(t, tExecutable.IsOperationPending(ctx, opIdx), want) + err := tExecutable.IsOperationPending(ctx, opIdx) + var opErr *OperationNotPendingError + require.ErrorAs(t, err, &opErr) + require.Equal(t, opIdx, opErr.OpIndex) for selector := range proposal.ChainMetadata { - require.ErrorContains(t, tExecutable.IsChainPending(ctx, selector), want) + require.ErrorAs(t, tExecutable.IsChainPending(ctx, selector), &opErr) } } @@ -322,10 +327,12 @@ func assertOperationNotReadyState(t *testing.T, tExecutable *TimelockExecutable, t.Helper() ctx := t.Context() - want := fmt.Sprintf("operation %d is not ready", opIdx) - require.ErrorContains(t, tExecutable.IsOperationReady(ctx, opIdx), want) + err := tExecutable.IsOperationReady(ctx, opIdx) + var opErr *OperationNotReadyError + require.ErrorAs(t, err, &opErr) + require.Equal(t, opIdx, opErr.OpIndex) for selector := range proposal.ChainMetadata { - require.ErrorContains(t, tExecutable.IsChainReady(ctx, selector), want) + require.ErrorAs(t, tExecutable.IsChainReady(ctx, selector), &opErr) } } @@ -333,9 +340,11 @@ func assertOperationNotDoneState(t *testing.T, tExecutable *TimelockExecutable, t.Helper() ctx := t.Context() - want := fmt.Sprintf("operation %d is not done", opIdx) - require.ErrorContains(t, tExecutable.IsOperationDone(ctx, opIdx), want) + err := tExecutable.IsOperationDone(ctx, opIdx) + var opErr *OperationNotDoneError + require.ErrorAs(t, err, &opErr) + require.Equal(t, opIdx, opErr.OpIndex) for selector := range proposal.ChainMetadata { - require.ErrorContains(t, tExecutable.IsChainDone(ctx, selector), want) + require.ErrorAs(t, tExecutable.IsChainDone(ctx, selector), &opErr) } } From 8fd4960c017b593207674ca304369792b603d075 Mon Sep 17 00:00:00 2001 From: Kristijan Date: Fri, 3 Jul 2026 15:01:07 +0200 Subject: [PATCH 07/11] More PR feedback --- test_runner_timelock_schedule_cancel.go | 30 +++++++++++-------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel.go index 5395fe83..00aa9388 100644 --- a/test_runner_timelock_schedule_cancel.go +++ b/test_runner_timelock_schedule_cancel.go @@ -267,9 +267,8 @@ func assertOperationPendingState(t *testing.T, tExecutable *TimelockExecutable, ctx := t.Context() require.NoError(t, tExecutable.IsOperationPending(ctx, opIdx)) - for selector := range proposal.ChainMetadata { - require.NoError(t, tExecutable.IsChainPending(ctx, selector)) - } + selector := proposal.Operations[opIdx].ChainSelector + require.NoError(t, tExecutable.IsChainPending(ctx, selector)) } func assertTimelockOperationState( @@ -277,7 +276,7 @@ func assertTimelockOperationState( ctx context.Context, proposal *TimelockProposal, tExecutable *TimelockExecutable, - inspectors map[types.ChainSelector]sdk.TimelockExecutor, + executors map[types.ChainSelector]sdk.TimelockExecutor, opIdx int, wantIsOperation bool, wantPending bool, @@ -293,19 +292,19 @@ func assertTimelockOperationState( opID, err := tExecutable.GetOpID(ctx, opIdx, op, selector) require.NoError(t, err) - gotIsOperation, err := inspectors[selector].IsOperation(ctx, timelockAddress, opID) + gotIsOperation, err := executors[selector].IsOperation(ctx, timelockAddress, opID) require.NoError(t, err) require.Equal(t, wantIsOperation, gotIsOperation) - gotPending, err := inspectors[selector].IsOperationPending(ctx, timelockAddress, opID) + gotPending, err := executors[selector].IsOperationPending(ctx, timelockAddress, opID) require.NoError(t, err) require.Equal(t, wantPending, gotPending) - gotReady, err := inspectors[selector].IsOperationReady(ctx, timelockAddress, opID) + gotReady, err := executors[selector].IsOperationReady(ctx, timelockAddress, opID) require.NoError(t, err) require.Equal(t, wantReady, gotReady) - gotDone, err := inspectors[selector].IsOperationDone(ctx, timelockAddress, opID) + gotDone, err := executors[selector].IsOperationDone(ctx, timelockAddress, opID) require.NoError(t, err) require.Equal(t, wantDone, gotDone) } @@ -318,9 +317,8 @@ func assertOperationNotPendingState(t *testing.T, tExecutable *TimelockExecutabl var opErr *OperationNotPendingError require.ErrorAs(t, err, &opErr) require.Equal(t, opIdx, opErr.OpIndex) - for selector := range proposal.ChainMetadata { - require.ErrorAs(t, tExecutable.IsChainPending(ctx, selector), &opErr) - } + selector := proposal.Operations[opIdx].ChainSelector + require.ErrorAs(t, tExecutable.IsChainPending(ctx, selector), &opErr) } func assertOperationNotReadyState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { @@ -331,9 +329,8 @@ func assertOperationNotReadyState(t *testing.T, tExecutable *TimelockExecutable, var opErr *OperationNotReadyError require.ErrorAs(t, err, &opErr) require.Equal(t, opIdx, opErr.OpIndex) - for selector := range proposal.ChainMetadata { - require.ErrorAs(t, tExecutable.IsChainReady(ctx, selector), &opErr) - } + selector := proposal.Operations[opIdx].ChainSelector + require.ErrorAs(t, tExecutable.IsChainReady(ctx, selector), &opErr) } func assertOperationNotDoneState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { @@ -344,7 +341,6 @@ func assertOperationNotDoneState(t *testing.T, tExecutable *TimelockExecutable, var opErr *OperationNotDoneError require.ErrorAs(t, err, &opErr) require.Equal(t, opIdx, opErr.OpIndex) - for selector := range proposal.ChainMetadata { - require.ErrorAs(t, tExecutable.IsChainDone(ctx, selector), &opErr) - } + selector := proposal.Operations[opIdx].ChainSelector + require.ErrorAs(t, tExecutable.IsChainDone(ctx, selector), &opErr) } From 3031e4d2b4e1cf6fdc532c0150ac8bb4ac3cca72 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 3 Jul 2026 11:33:22 -0600 Subject: [PATCH 08/11] chore: restructure e2e tests for proposal cancellation so they can be reused by other chain Signed-off-by: Pablo --- e2e/tests/aptos/timelock_cancel.go | 11 +- e2e/tests/common/timelock_schedule_cancel.go | 352 ++++++++++++++++++ e2e/tests/evm/timelock_schedule_cancel.go | 111 ++++++ e2e/tests/solana/timelock_schedule_cancel.go | 124 ++++++ e2e/tests/ton/executable.go | 13 +- ...st_runner_timelock_schedule_cancel_test.go | 0 6 files changed, 600 insertions(+), 11 deletions(-) create mode 100644 e2e/tests/common/timelock_schedule_cancel.go create mode 100644 e2e/tests/evm/timelock_schedule_cancel.go create mode 100644 e2e/tests/solana/timelock_schedule_cancel.go rename test_runner_timelock_schedule_cancel.go => test_runner_timelock_schedule_cancel_test.go (100%) diff --git a/e2e/tests/aptos/timelock_cancel.go b/e2e/tests/aptos/timelock_cancel.go index 48edff54..56786dec 100644 --- a/e2e/tests/aptos/timelock_cancel.go +++ b/e2e/tests/aptos/timelock_cancel.go @@ -17,6 +17,7 @@ import ( mcmslib "github.com/smartcontractkit/mcms" chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" + e2ecommon "github.com/smartcontractkit/mcms/e2e/tests/common" "github.com/smartcontractkit/mcms/internal/testutils" "github.com/smartcontractkit/mcms/types" ) @@ -38,15 +39,15 @@ func (a *TestSuite) TestTimelock_Cancel() { accessor.EXPECT().AptosSigner(uint64(a.ChainSelector)).Return(a.deployerAccount, true).Maybe() phase := 0 - mcmslib.RunScheduleAndCancelTest(a.T(), mcmslib.ScheduleAndCancelTestHooks{ - Setup: func(ctx context.Context, t *testing.T) (mcmslib.ScheduleAndCancelTestEnv, error) { + e2ecommon.RunScheduleAndCancelTest(a.T(), e2ecommon.ScheduleAndCancelTestHooks{ + Setup: func(ctx context.Context, t *testing.T) (e2ecommon.ScheduleAndCancelTestEnv, error) { t.Helper() transaction, txErr := a.buildAcceptOwnershipTransaction() if txErr != nil { - return mcmslib.ScheduleAndCancelTestEnv{}, txErr + return e2ecommon.ScheduleAndCancelTestEnv{}, txErr } - return mcmslib.ScheduleAndCancelTestEnv{ + return e2ecommon.ScheduleAndCancelTestEnv{ Proposal: mcmslib.TimelockProposal{ BaseProposal: mcmslib.BaseProposal{ Version: "v1", @@ -112,7 +113,7 @@ func (a *TestSuite) TestTimelock_Cancel() { require.NoError(t, err) require.True(t, data.Success, data.VmStatus) }, - AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *mcmslib.ScheduleAndCancelTestEnv) { + AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *e2ecommon.ScheduleAndCancelTestEnv) { t.Helper() // After the cancel proposal is executed, verify that the ownership transfer // was not accepted — the owner should still be the deployer (from TransferOwnershipToSelf). diff --git a/e2e/tests/common/timelock_schedule_cancel.go b/e2e/tests/common/timelock_schedule_cancel.go new file mode 100644 index 00000000..f9cefb18 --- /dev/null +++ b/e2e/tests/common/timelock_schedule_cancel.go @@ -0,0 +1,352 @@ +//go:build e2e + +package common + +import ( + "context" + "crypto/ecdsa" + "fmt" + "slices" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms" + "github.com/smartcontractkit/mcms/chainwrappers" + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/types" +) + +type RoleConfig struct { + Keys []*ecdsa.PrivateKey + Config *types.Config +} + +func NewRoleConfig(t *testing.T, count int, quorum uint8) RoleConfig { + t.Helper() + + keys := make([]*ecdsa.PrivateKey, count) + signers := make([]common.Address, count) + for i := range count { + key, err := crypto.GenerateKey() + require.NoError(t, err) + keys[i] = key + signers[i] = crypto.PubkeyToAddress(key.PublicKey) + } + slices.SortFunc(signers, func(a, b common.Address) int { return a.Cmp(b) }) + + return RoleConfig{ + Keys: keys, + Config: &types.Config{ + Quorum: quorum, + Signers: signers, + }, + } +} + +func (r RoleConfig) Sign(t *testing.T, signable *mcms.Signable) { + t.Helper() + + require.LessOrEqual(t, int(r.Config.Quorum), len(r.Keys)) + for i := 0; i < int(r.Config.Quorum); i++ { + _, err := signable.SignAndAppend(mcms.NewPrivateKeySigner(r.Keys[i])) + require.NoError(t, err) + } +} + +type ScheduleAndCancelTestEnv struct { + Proposal mcms.TimelockProposal + Chains chainwrappers.ChainAccessor +} + +type ScheduleAndCancelTestHooks struct { + Setup func(ctx context.Context, t *testing.T) (ScheduleAndCancelTestEnv, error) + Sign func(t *testing.T, signable *mcms.Signable) + + PrepareConvertedProposal func(t *testing.T, proposal *mcms.Proposal) + + DeriveCancellationMetadata func(t *testing.T, selector types.ChainSelector, scheduleMetadata types.ChainMetadata) (types.ChainMetadata, error) + + WaitForTransaction func(ctx context.Context, t *testing.T, tx types.TransactionResult) + + AssertExtraAfterCancel func(ctx context.Context, t *testing.T, env *ScheduleAndCancelTestEnv) +} + +func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { + t.Helper() + + require.NotNil(t, hooks.Setup, "ScheduleAndCancelTestHooks.Setup must not be nil") + require.NotNil(t, hooks.Sign, "ScheduleAndCancelTestHooks.Sign must not be nil") + + ctx := t.Context() + + env, err := hooks.Setup(ctx, t) + require.NoError(t, err) + + scheduleInspectors := runTimelockLifecycleProposal(ctx, t, &env.Proposal, env.Chains, hooks) + + tExecutors, err := chainwrappers.BuildTimelockExecutors(env.Chains, env.Proposal.ChainMetadata, env.Proposal.Action) + require.NoError(t, err) + + tExecutable, err := mcms.NewTimelockExecutable(ctx, &env.Proposal, tExecutors) + require.NoError(t, err) + + for opIdx := range env.Proposal.Operations { + assertTimelockOperationState(t, ctx, &env.Proposal, tExecutable, tExecutors, opIdx, true, true, false, false) + assertOperationPendingState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotReadyState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) + } + + cancelProposal, err := deriveCancellationProposal(ctx, t, &env.Proposal, scheduleInspectors, hooks) + require.NoError(t, err) + + runTimelockLifecycleProposal(ctx, t, &cancelProposal, env.Chains, hooks) + + for opIdx := range env.Proposal.Operations { + assertTimelockOperationState(t, ctx, &env.Proposal, tExecutable, tExecutors, opIdx, false, false, false, false) + assertOperationNotPendingState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotReadyState(t, tExecutable, &env.Proposal, opIdx) + assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) + } + + if hooks.AssertExtraAfterCancel != nil { + hooks.AssertExtraAfterCancel(ctx, t, &env) + } +} + +func runTimelockLifecycleProposal( + ctx context.Context, + t *testing.T, + proposal *mcms.TimelockProposal, + chains chainwrappers.ChainAccessor, + hooks ScheduleAndCancelTestHooks, +) map[types.ChainSelector]sdk.Inspector { + t.Helper() + + converters, err := chainwrappers.BuildConverters(proposal.ChainMetadata) + require.NoError(t, err) + + mcmsProposal, _, err := proposal.Convert(ctx, converters) + require.NoError(t, err) + + if hooks.PrepareConvertedProposal != nil { + hooks.PrepareConvertedProposal(t, &mcmsProposal) + } + + inspectors, err := chainwrappers.BuildInspectors(chains, mcmsProposal.ChainMetadata, proposal.Action) + require.NoError(t, err) + + signable, err := mcms.NewSignable(&mcmsProposal, inspectors) //nolint:contextcheck //OPT-400 + require.NoError(t, err) + + require.NoError(t, signable.ValidateConfigs(ctx)) + + hooks.Sign(t, signable) + + quorumMet, err := signable.ValidateSignatures(ctx) + require.NoError(t, err) + require.True(t, quorumMet) + + encoders, err := mcmsProposal.GetEncoders() //nolint:contextcheck //OPT-400 + require.NoError(t, err) + + executors, err := chainwrappers.BuildExecutors(chains, mcmsProposal.ChainMetadata, encoders, proposal.Action) + require.NoError(t, err) + + executable, err := mcms.NewExecutable(&mcmsProposal, executors) //nolint:contextcheck //OPT-400 + require.NoError(t, err) + + tree, err := mcmsProposal.MerkleTree() //nolint:contextcheck //OPT-400 + require.NoError(t, err) + + for _, selector := range proposalChainSelectors(proposal.Operations) { + tx, setRootErr := executable.SetRoot(ctx, selector) + require.NoError(t, setRootErr) + require.NotEmpty(t, tx.Hash) + waitForTransaction(ctx, t, hooks, tx) + + root, validUntil, rootErr := inspectors[selector].GetRoot(ctx, proposal.ChainMetadata[selector].MCMAddress) + require.NoError(t, rootErr) + require.Equal(t, common.Hash([32]byte(tree.Root.Bytes())), root) + require.Equal(t, proposal.ValidUntil, validUntil) + } + + for opIdx := range proposal.Operations { + tx, execErr := executable.Execute(ctx, opIdx) + require.NoError(t, execErr) + require.NotEmpty(t, tx.Hash) + waitForTransaction(ctx, t, hooks, tx) + } + + assertOpCounts(t, ctx, proposal, inspectors) + + return inspectors +} + +func deriveCancellationProposal( + ctx context.Context, + t *testing.T, + schedule *mcms.TimelockProposal, + inspectors map[types.ChainSelector]sdk.Inspector, + hooks ScheduleAndCancelTestHooks, +) (mcms.TimelockProposal, error) { + t.Helper() + + cancellerMetadata := make(map[types.ChainSelector]types.ChainMetadata, len(schedule.ChainMetadata)) + for selector, metadata := range schedule.ChainMetadata { + opCount, err := inspectors[selector].GetOpCount(ctx, metadata.MCMAddress) + if err != nil { + return mcms.TimelockProposal{}, err + } + + next := metadata + next.StartingOpCount = opCount + + if hooks.DeriveCancellationMetadata != nil { + var err error + next, err = hooks.DeriveCancellationMetadata(t, selector, next) + if err != nil { + return mcms.TimelockProposal{}, fmt.Errorf("deriving cancellation metadata for selector %d: %w", selector, err) + } + } + + cancellerMetadata[selector] = next + } + + return schedule.DeriveCancellationProposal(cancellerMetadata) +} + +func assertOpCounts( + t *testing.T, + ctx context.Context, + proposal *mcms.TimelockProposal, + inspectors map[types.ChainSelector]sdk.Inspector, +) { + t.Helper() + + counts, err := proposal.OperationCounts(ctx) + require.NoError(t, err) + + for selector, metadata := range proposal.ChainMetadata { + opCount, getErr := inspectors[selector].GetOpCount(ctx, metadata.MCMAddress) + require.NoError(t, getErr) + require.Equal(t, metadata.StartingOpCount+counts[selector], opCount) + } +} + +func waitForTransaction( + ctx context.Context, + t *testing.T, + hooks ScheduleAndCancelTestHooks, + tx types.TransactionResult, +) { + t.Helper() + + if hooks.WaitForTransaction != nil { + hooks.WaitForTransaction(ctx, t, tx) + } +} + +func proposalChainSelectors(ops []types.BatchOperation) []types.ChainSelector { + selectors := make([]types.ChainSelector, 0, len(ops)) + seen := make(map[types.ChainSelector]struct{}, len(ops)) + for _, op := range ops { + if _, ok := seen[op.ChainSelector]; ok { + continue + } + seen[op.ChainSelector] = struct{}{} + selectors = append(selectors, op.ChainSelector) + } + + return selectors +} + +func assertOperationPendingState(t *testing.T, tExecutable *mcms.TimelockExecutable, proposal *mcms.TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + require.NoError(t, tExecutable.IsOperationPending(ctx, opIdx)) + selector := proposal.Operations[opIdx].ChainSelector + require.NoError(t, tExecutable.IsChainPending(ctx, selector)) +} + +func assertTimelockOperationState( + t *testing.T, + ctx context.Context, + proposal *mcms.TimelockProposal, + tExecutable *mcms.TimelockExecutable, + executors map[types.ChainSelector]sdk.TimelockExecutor, + opIdx int, + wantIsOperation bool, + wantPending bool, + wantReady bool, + wantDone bool, +) { + t.Helper() + + op := proposal.Operations[opIdx] + selector := op.ChainSelector + timelockAddress := proposal.TimelockAddresses[selector] + + opID, err := tExecutable.GetOpID(ctx, opIdx, op, selector) + require.NoError(t, err) + + gotIsOperation, err := executors[selector].IsOperation(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantIsOperation, gotIsOperation) + + if !wantIsOperation && !gotIsOperation { + return + } + + gotPending, err := executors[selector].IsOperationPending(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantPending, gotPending) + + gotReady, err := executors[selector].IsOperationReady(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantReady, gotReady) + + gotDone, err := executors[selector].IsOperationDone(ctx, timelockAddress, opID) + require.NoError(t, err) + require.Equal(t, wantDone, gotDone) +} + +func assertOperationNotPendingState(t *testing.T, tExecutable *mcms.TimelockExecutable, proposal *mcms.TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + err := tExecutable.IsOperationPending(ctx, opIdx) + var opErr *mcms.OperationNotPendingError + require.ErrorAs(t, err, &opErr) + require.Equal(t, opIdx, opErr.OpIndex) + selector := proposal.Operations[opIdx].ChainSelector + require.ErrorAs(t, tExecutable.IsChainPending(ctx, selector), &opErr) +} + +func assertOperationNotReadyState(t *testing.T, tExecutable *mcms.TimelockExecutable, proposal *mcms.TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + err := tExecutable.IsOperationReady(ctx, opIdx) + var opErr *mcms.OperationNotReadyError + require.ErrorAs(t, err, &opErr) + require.Equal(t, opIdx, opErr.OpIndex) + selector := proposal.Operations[opIdx].ChainSelector + require.ErrorAs(t, tExecutable.IsChainReady(ctx, selector), &opErr) +} + +func assertOperationNotDoneState(t *testing.T, tExecutable *mcms.TimelockExecutable, proposal *mcms.TimelockProposal, opIdx int) { + t.Helper() + + ctx := t.Context() + err := tExecutable.IsOperationDone(ctx, opIdx) + var opErr *mcms.OperationNotDoneError + require.ErrorAs(t, err, &opErr) + require.Equal(t, opIdx, opErr.OpIndex) + selector := proposal.Operations[opIdx].ChainSelector + require.ErrorAs(t, tExecutable.IsChainDone(ctx, selector), &opErr) +} diff --git a/e2e/tests/evm/timelock_schedule_cancel.go b/e2e/tests/evm/timelock_schedule_cancel.go new file mode 100644 index 00000000..08474fd0 --- /dev/null +++ b/e2e/tests/evm/timelock_schedule_cancel.go @@ -0,0 +1,111 @@ +//go:build e2e + +package evme2e + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/mcms" + chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" + e2ecommon "github.com/smartcontractkit/mcms/e2e/tests/common" + testutils "github.com/smartcontractkit/mcms/e2e/utils" + "github.com/smartcontractkit/mcms/sdk/evm" + "github.com/smartcontractkit/mcms/sdk/evm/bindings" + mcmtypes "github.com/smartcontractkit/mcms/types" +) + +func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { + ctx := s.T().Context() + + target := s.signerAddresses[0] + role, err := s.ChainA.timelockContract.EXECUTORROLE(&bind.CallOpts{Context: ctx}) + s.Require().NoError(err) + + hasRole, err := s.ChainA.timelockContract.HasRole(&bind.CallOpts{Context: ctx}, role, target) + s.Require().NoError(err) + s.Require().False(hasRole, "target should not already have executor role") + + inspector := evm.NewInspector(s.ClientA) + opCount, err := inspector.GetOpCount(ctx, s.ChainA.mcmsContract.Address().Hex()) + s.Require().NoError(err) + + timelockABI, err := bindings.RBACTimelockMetaData.GetAbi() + s.Require().NoError(err) + grantRoleData, err := timelockABI.Pack("grantRole", role, target) + s.Require().NoError(err) + + accessor := chainwrappermocks.NewChainAccessor(s.T()) + accessor.EXPECT().EVMClient(uint64(s.ChainA.chainSelector)).Return(s.ClientA, true).Maybe() + accessor.EXPECT().EVMSigner(uint64(s.ChainA.chainSelector)).Return(s.ChainA.auth, true).Maybe() + + signer := testutils.ParsePrivateKey(s.Settings.PrivateKeys[1]) + e2ecommon.RunScheduleAndCancelTest(s.T(), e2ecommon.ScheduleAndCancelTestHooks{ + Setup: func(ctx context.Context, t *testing.T) (e2ecommon.ScheduleAndCancelTestEnv, error) { + t.Helper() + + return e2ecommon.ScheduleAndCancelTestEnv{ + Proposal: mcms.TimelockProposal{ + BaseProposal: mcms.BaseProposal{ + Version: "v1", + Kind: mcmtypes.KindTimelockProposal, + Description: "Cancels scheduled EVM timelock role grant", + ValidUntil: uint32(time.Now().Add(24 * time.Hour).Unix()), + OverridePreviousRoot: false, + Signatures: []mcmtypes.Signature{}, + ChainMetadata: map[mcmtypes.ChainSelector]mcmtypes.ChainMetadata{ + s.ChainA.chainSelector: { + StartingOpCount: opCount, + MCMAddress: s.ChainA.mcmsContract.Address().Hex(), + }, + }, + }, + Action: mcmtypes.TimelockActionSchedule, + Delay: mcmtypes.MustParseDuration("5m"), + TimelockAddresses: map[mcmtypes.ChainSelector]string{ + s.ChainA.chainSelector: s.ChainA.timelockContract.Address().Hex(), + }, + Operations: []mcmtypes.BatchOperation{ + { + ChainSelector: s.ChainA.chainSelector, + Transactions: []mcmtypes.Transaction{ + evm.NewTransaction( + common.HexToAddress(s.ChainA.timelockContract.Address().Hex()), + grantRoleData, + big.NewInt(0), + "RBACTimelock", + []string{"RBACTimelock", "GrantRole"}, + ), + }, + }, + }, + }, + Chains: accessor, + }, nil + }, + Sign: func(t *testing.T, signable *mcms.Signable) { + t.Helper() + _, err := signable.SignAndAppend(mcms.NewPrivateKeySigner(signer)) + require.NoError(t, err) + }, + WaitForTransaction: func(ctx context.Context, t *testing.T, tx mcmtypes.TransactionResult) { + t.Helper() + receipt, err := testutils.WaitMinedWithTxHash(ctx, s.ClientA, common.HexToHash(tx.Hash)) + require.NoError(t, err) + require.Equal(t, gethtypes.ReceiptStatusSuccessful, receipt.Status) + }, + AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *e2ecommon.ScheduleAndCancelTestEnv) { + t.Helper() + hasRole, err := s.ChainA.timelockContract.HasRole(&bind.CallOpts{Context: ctx}, role, target) + require.NoError(t, err) + require.False(t, hasRole, "target should not receive role after cancellation") + }, + }) +} diff --git a/e2e/tests/solana/timelock_schedule_cancel.go b/e2e/tests/solana/timelock_schedule_cancel.go new file mode 100644 index 00000000..0631e6d2 --- /dev/null +++ b/e2e/tests/solana/timelock_schedule_cancel.go @@ -0,0 +1,124 @@ +//go:build e2e + +package solanae2e + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + + cpistub "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/external_program_cpi_stub" + "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock" + + "github.com/smartcontractkit/mcms" + chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" + e2ecommon "github.com/smartcontractkit/mcms/e2e/tests/common" + e2esolanautils "github.com/smartcontractkit/mcms/e2e/utils/solana" + solanasdk "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/types" +) + +var testPDASeedTimelockScheduleCancel = [32]byte{'t', 'e', 's', 't', '-', 's', 'c', 'h', 'e', 'd', 'c', 'a', 'n', 'c', 'e', 'l'} + +func (s *TestSuite) TestScheduleAndCancelProposal() { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + s.T().Cleanup(cancel) + + wallet, err := solana.PrivateKeyFromBase58(privateKey) + s.Require().NoError(err) + + s.SetupMCM(testPDASeedTimelockScheduleCancel) + s.SetupTimelock(testPDASeedTimelockScheduleCancel, 5*time.Minute) + s.SetupCPIStub(testPDASeedTimelockScheduleCancel) + + mcmAddress := solanasdk.ContractAddress(s.MCMProgramID, testPDASeedTimelockScheduleCancel) + timelockAddress := solanasdk.ContractAddress(s.TimelockProgramID, testPDASeedTimelockScheduleCancel) + + mcmSignerPDA, err := solanasdk.FindSignerPDA(s.MCMProgramID, testPDASeedTimelockScheduleCancel) + s.Require().NoError(err) + timelockSignerPDA, err := solanasdk.FindTimelockSignerPDA(s.TimelockProgramID, testPDASeedTimelockScheduleCancel) + s.Require().NoError(err) + e2esolanautils.FundAccounts(s.T(), []solana.PublicKey{mcmSignerPDA, timelockSignerPDA}, 1, s.SolanaClient) + + s.AssignRoleToAccounts(ctx, testPDASeedTimelockScheduleCancel, wallet, []solana.PublicKey{mcmSignerPDA}, timelock.Proposer_Role) + s.AssignRoleToAccounts(ctx, testPDASeedTimelockScheduleCancel, wallet, []solana.PublicKey{mcmSignerPDA}, timelock.Canceller_Role) + + signer := NewEVMTestAccount(s.T()) + configurer := solanasdk.NewConfigurer(s.SolanaClient, wallet, s.ChainSelector) + _, err = configurer.SetConfig(ctx, mcmAddress, &types.Config{ + Quorum: 1, + Signers: []common.Address{signer.Address}, + }, true) + s.Require().NoError(err) + + inspector := solanasdk.NewInspector(s.SolanaClient) + opCount, err := inspector.GetOpCount(ctx, mcmAddress) + s.Require().NoError(err) + + metadata, err := solanasdk.NewChainMetadata( + opCount, + s.MCMProgramID, + testPDASeedTimelockScheduleCancel, + s.Roles[timelock.Proposer_Role].AccessController.PublicKey(), + s.Roles[timelock.Canceller_Role].AccessController.PublicKey(), + s.Roles[timelock.Bypasser_Role].AccessController.PublicKey(), + ) + s.Require().NoError(err) + + emptyInstruction, err := cpistub.NewEmptyInstruction().ValidateAndBuild() + s.Require().NoError(err) + transaction, err := solanasdk.NewTransactionFromInstruction(emptyInstruction, "CPIStub", []string{"cpi-stub-empty"}) + s.Require().NoError(err) + + accessor := chainwrappermocks.NewChainAccessor(s.T()) + accessor.EXPECT().SolanaClient(uint64(s.ChainSelector)).Return(s.SolanaClient, true).Maybe() + accessor.EXPECT().SolanaSigner(uint64(s.ChainSelector)).Return(&wallet, true).Maybe() + + e2ecommon.RunScheduleAndCancelTest(s.T(), e2ecommon.ScheduleAndCancelTestHooks{ + Setup: func(ctx context.Context, t *testing.T) (e2ecommon.ScheduleAndCancelTestEnv, error) { + t.Helper() + + return e2ecommon.ScheduleAndCancelTestEnv{ + Proposal: mcms.TimelockProposal{ + BaseProposal: mcms.BaseProposal{ + Version: "v1", + Kind: types.KindTimelockProposal, + Description: "Cancels scheduled Solana timelock CPI stub call", + ValidUntil: uint32(time.Now().Add(24 * time.Hour).Unix()), + OverridePreviousRoot: false, + Signatures: []types.Signature{}, + ChainMetadata: map[types.ChainSelector]types.ChainMetadata{ + s.ChainSelector: metadata, + }, + }, + Action: types.TimelockActionSchedule, + Delay: types.MustParseDuration("5m"), + TimelockAddresses: map[types.ChainSelector]string{ + s.ChainSelector: timelockAddress, + }, + Operations: []types.BatchOperation{ + { + ChainSelector: s.ChainSelector, + Transactions: []types.Transaction{transaction}, + }, + }, + }, + Chains: accessor, + }, nil + }, + Sign: func(t *testing.T, signable *mcms.Signable) { + t.Helper() + _, err := signable.SignAndAppend(mcms.NewPrivateKeySigner(signer.PrivateKey)) + require.NoError(t, err) + }, + WaitForTransaction: func(ctx context.Context, t *testing.T, tx types.TransactionResult) { + t.Helper() + _, err := solana.SignatureFromBase58(tx.Hash) + require.NoError(t, err) + }, + }) +} diff --git a/e2e/tests/ton/executable.go b/e2e/tests/ton/executable.go index 33877e0a..e6d94788 100644 --- a/e2e/tests/ton/executable.go +++ b/e2e/tests/ton/executable.go @@ -31,6 +31,7 @@ import ( mcmslib "github.com/smartcontractkit/mcms" chainwrappermocks "github.com/smartcontractkit/mcms/chainwrappers/mocks" e2e "github.com/smartcontractkit/mcms/e2e/tests" + e2ecommon "github.com/smartcontractkit/mcms/e2e/tests/common" "github.com/smartcontractkit/mcms/internal/testutils" "github.com/smartcontractkit/mcms/sdk" "github.com/smartcontractkit/mcms/sdk/evm" @@ -257,8 +258,8 @@ func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { accessor.EXPECT().TonClient(uint64(s.ChainA)).Return(s.TonClient, true).Maybe() accessor.EXPECT().TonSigner(uint64(s.ChainA)).Return(s.wallet, true).Maybe() - mcmslib.RunScheduleAndCancelTest(s.T(), mcmslib.ScheduleAndCancelTestHooks{ - Setup: func(ctx context.Context, t *testing.T) (mcmslib.ScheduleAndCancelTestEnv, error) { + e2ecommon.RunScheduleAndCancelTest(s.T(), e2ecommon.ScheduleAndCancelTestHooks{ + Setup: func(ctx context.Context, t *testing.T) (e2ecommon.ScheduleAndCancelTestEnv, error) { t.Helper() transactions := make([]types.Transaction, 0, len(tt.targetRoles)) for i, role := range tt.targetRoles { @@ -268,7 +269,7 @@ func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { Account: grantee, }) if grantErr != nil { - return mcmslib.ScheduleAndCancelTestEnv{}, grantErr + return e2ecommon.ScheduleAndCancelTestEnv{}, grantErr } tx, txErr := mcmston.NewTransaction( @@ -281,13 +282,13 @@ func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { []string{bindings.ShortTimelock, "GrantRole"}, ) if txErr != nil { - return mcmslib.ScheduleAndCancelTestEnv{}, txErr + return e2ecommon.ScheduleAndCancelTestEnv{}, txErr } transactions = append(transactions, tx) } - return mcmslib.ScheduleAndCancelTestEnv{ + return e2ecommon.ScheduleAndCancelTestEnv{ Proposal: mcmslib.TimelockProposal{ BaseProposal: mcmslib.BaseProposal{ Version: "v1", @@ -331,7 +332,7 @@ func (s *ExecutionTestSuite) TestScheduleAndCancelProposal() { require.NotNil(t, rawTx) require.NoError(t, tracetracking.WaitForTrace(ctx, s.TonClient, rawTx)) }, - AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *mcmslib.ScheduleAndCancelTestEnv) { + AssertExtraAfterCancel: func(ctx context.Context, t *testing.T, env *e2ecommon.ScheduleAndCancelTestEnv) { t.Helper() inspectorT := mcmston.NewTimelockInspector(s.TonClient) for _, role := range tt.targetRoles { diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel_test.go similarity index 100% rename from test_runner_timelock_schedule_cancel.go rename to test_runner_timelock_schedule_cancel_test.go From 7c5ce248495d2875bcab68c15f7a1ff25d8204a5 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 3 Jul 2026 18:30:11 -0600 Subject: [PATCH 09/11] fix: linting errors Signed-off-by: Pablo --- test_runner_timelock_schedule_cancel.go | 346 ------------------------ 1 file changed, 346 deletions(-) delete mode 100644 test_runner_timelock_schedule_cancel.go diff --git a/test_runner_timelock_schedule_cancel.go b/test_runner_timelock_schedule_cancel.go deleted file mode 100644 index 00aa9388..00000000 --- a/test_runner_timelock_schedule_cancel.go +++ /dev/null @@ -1,346 +0,0 @@ -package mcms - -import ( - "context" - "fmt" - "testing" - - "github.com/ethereum/go-ethereum/common" - "github.com/stretchr/testify/require" - - "github.com/smartcontractkit/mcms/chainwrappers" - "github.com/smartcontractkit/mcms/sdk" - "github.com/smartcontractkit/mcms/types" -) - -// ScheduleAndCancelTestEnv describes the chain-specific state needed to run the shared -// schedule/cancel timelock flow. -// -// Testing-only: this is intended to let tests in other packages reuse the same generic lifecycle. -type ScheduleAndCancelTestEnv struct { - Proposal TimelockProposal - Chains chainwrappers.ChainAccessor -} - -// ScheduleAndCancelTestHooks defines the chain-specific hooks for the shared schedule/cancel test. -// -// Testing-only: this is intended to let tests in other packages reuse the same generic lifecycle. -type ScheduleAndCancelTestHooks struct { - Setup func(ctx context.Context, t *testing.T) (ScheduleAndCancelTestEnv, error) - Sign func(t *testing.T, signable *Signable) - - // PrepareConvertedProposal allows tests to tweak the converted MCMS proposal before encoders and - // execution are built. EVM simulated backend tests use this to enable simulated-backend mode. - PrepareConvertedProposal func(t *testing.T, proposal *Proposal) - - // DeriveCancellationMetadata allows tests to override the cancellation metadata for a chain. - // The default runner copies the schedule metadata (only updating StartingOpCount), but some - // chains need to change fields like AdditionalFields. For example, Aptos stores the MCMS role - // in AdditionalFields and switches it from Proposer → Canceller for the cancel proposal. - // If nil, the runner falls back to copying the schedule metadata as-is. - DeriveCancellationMetadata func(t *testing.T, selector types.ChainSelector, scheduleMetadata types.ChainMetadata) (types.ChainMetadata, error) - - // WaitForTransaction is called after every SetRoot / Execute result so each chain can confirm - // finality using its own mechanism. - WaitForTransaction func(ctx context.Context, t *testing.T, tx types.TransactionResult) - - // AssertExtraAfterCancel is reserved for chain-specific semantic assertions that go beyond the - // generic schedule/cancel lifecycle state checks performed by this runner. - // - // Examples: - // - EVM: verify a role grant target still does not have the role after cancellation. - // - TON: verify a grantee address is absent from the role-members list after cancellation. - AssertExtraAfterCancel func(ctx context.Context, t *testing.T, env *ScheduleAndCancelTestEnv) -} - -// RunScheduleAndCancelTest executes the shared timelock schedule/cancel lifecycle using -// chain-specific hooks for setup, signing, and transaction finality. -// -// Testing-only: this is intended to let tests in other packages reuse the same generic lifecycle. -func RunScheduleAndCancelTest(t *testing.T, hooks ScheduleAndCancelTestHooks) { - t.Helper() - - require.NotNil(t, hooks.Setup, "ScheduleAndCancelTestHooks.Setup must not be nil") - require.NotNil(t, hooks.Sign, "ScheduleAndCancelTestHooks.Sign must not be nil") - - ctx := t.Context() - - env, err := hooks.Setup(ctx, t) - require.NoError(t, err) - - scheduleInspectors := runTimelockLifecycleProposal( - ctx, - t, - &env.Proposal, - env.Chains, - hooks, - ) - - tExecutors, err := chainwrappers.BuildTimelockExecutors(env.Chains, env.Proposal.ChainMetadata, env.Proposal.Action) - require.NoError(t, err) - - tExecutable, err := NewTimelockExecutable(ctx, &env.Proposal, tExecutors) - require.NoError(t, err) - - for opIdx := range env.Proposal.Operations { - assertTimelockOperationState(t, ctx, &env.Proposal, tExecutable, tExecutors, opIdx, true, true, false, false) - assertOperationPendingState(t, tExecutable, &env.Proposal, opIdx) - assertOperationNotReadyState(t, tExecutable, &env.Proposal, opIdx) - assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) - } - - cancelProposal, err := deriveCancellationProposal(ctx, t, &env.Proposal, scheduleInspectors, hooks) - require.NoError(t, err) - - runTimelockLifecycleProposal( - ctx, - t, - &cancelProposal, - env.Chains, - hooks, - ) - - for opIdx := range env.Proposal.Operations { - assertTimelockOperationState(t, ctx, &env.Proposal, tExecutable, tExecutors, opIdx, false, false, false, false) - assertOperationNotPendingState(t, tExecutable, &env.Proposal, opIdx) - assertOperationNotReadyState(t, tExecutable, &env.Proposal, opIdx) - assertOperationNotDoneState(t, tExecutable, &env.Proposal, opIdx) - } - - if hooks.AssertExtraAfterCancel != nil { - hooks.AssertExtraAfterCancel(ctx, t, &env) - } -} - -func runTimelockLifecycleProposal( - ctx context.Context, - t *testing.T, - proposal *TimelockProposal, - chains chainwrappers.ChainAccessor, - hooks ScheduleAndCancelTestHooks, -) map[types.ChainSelector]sdk.Inspector { - t.Helper() - - converters, err := chainwrappers.BuildConverters(proposal.ChainMetadata) - require.NoError(t, err) - - mcmsProposal, _, err := proposal.Convert(ctx, converters) - require.NoError(t, err) - - if hooks.PrepareConvertedProposal != nil { - hooks.PrepareConvertedProposal(t, &mcmsProposal) - } - - inspectors, err := chainwrappers.BuildInspectors(chains, mcmsProposal.ChainMetadata, proposal.Action) - require.NoError(t, err) - - signable, err := NewSignable(&mcmsProposal, inspectors) //nolint:contextcheck //OPT-400 - require.NoError(t, err) - - require.NoError(t, signable.ValidateConfigs(ctx)) - - hooks.Sign(t, signable) - - quorumMet, err := signable.ValidateSignatures(ctx) - require.NoError(t, err) - require.True(t, quorumMet) - - encoders, err := mcmsProposal.GetEncoders() //nolint:contextcheck //OPT-400 - require.NoError(t, err) - - executors, err := chainwrappers.BuildExecutors(chains, mcmsProposal.ChainMetadata, encoders, proposal.Action) - require.NoError(t, err) - - executable, err := NewExecutable(&mcmsProposal, executors) //nolint:contextcheck //OPT-400 - require.NoError(t, err) - - tree, err := mcmsProposal.MerkleTree() //nolint:contextcheck //OPT-400 - require.NoError(t, err) - - // SetRoot is per participating chain, not per operation. A proposal may contain multiple - // operations on the same chain, but its root only needs to be written once for that chain. - for _, selector := range proposalChainSelectors(proposal.Operations) { - tx, setRootErr := executable.SetRoot(ctx, selector) - require.NoError(t, setRootErr) - require.NotEmpty(t, tx.Hash) - waitForTransaction(ctx, t, hooks, tx) - - root, validUntil, rootErr := inspectors[selector].GetRoot(ctx, proposal.ChainMetadata[selector].MCMAddress) - require.NoError(t, rootErr) - require.Equal(t, common.Hash([32]byte(tree.Root.Bytes())), root) - require.Equal(t, proposal.ValidUntil, validUntil) - } - - for opIdx := range proposal.Operations { - tx, execErr := executable.Execute(ctx, opIdx) - require.NoError(t, execErr) - require.NotEmpty(t, tx.Hash) - waitForTransaction(ctx, t, hooks, tx) - } - - assertOpCounts(t, ctx, proposal, inspectors) - - return inspectors -} - -func deriveCancellationProposal( - ctx context.Context, - t *testing.T, - schedule *TimelockProposal, - inspectors map[types.ChainSelector]sdk.Inspector, - hooks ScheduleAndCancelTestHooks, -) (TimelockProposal, error) { - t.Helper() - cancellerMetadata := make(map[types.ChainSelector]types.ChainMetadata, len(schedule.ChainMetadata)) - for selector, metadata := range schedule.ChainMetadata { - opCount, err := inspectors[selector].GetOpCount(ctx, metadata.MCMAddress) - if err != nil { - return TimelockProposal{}, err - } - - next := metadata - next.StartingOpCount = opCount - - // Allow chain-specific overrides (e.g., Aptos needs to swap the role in AdditionalFields - // and use the Canceller's per-role op count instead of the Proposer's). - if hooks.DeriveCancellationMetadata != nil { - var err error - next, err = hooks.DeriveCancellationMetadata(t, selector, next) - if err != nil { - return TimelockProposal{}, fmt.Errorf("deriving cancellation metadata for selector %d: %w", selector, err) - } - } - - cancellerMetadata[selector] = next - } - - return schedule.DeriveCancellationProposal(cancellerMetadata) -} - -func assertOpCounts( - t *testing.T, - ctx context.Context, - proposal *TimelockProposal, - inspectors map[types.ChainSelector]sdk.Inspector, -) { - t.Helper() - - counts, err := proposal.OperationCounts(ctx) - require.NoError(t, err) - - for selector, metadata := range proposal.ChainMetadata { - opCount, getErr := inspectors[selector].GetOpCount(ctx, metadata.MCMAddress) - require.NoError(t, getErr) - require.Equal(t, metadata.StartingOpCount+counts[selector], opCount) - } -} - -func waitForTransaction( - ctx context.Context, - t *testing.T, - hooks ScheduleAndCancelTestHooks, - tx types.TransactionResult, -) { - t.Helper() - - if hooks.WaitForTransaction != nil { - hooks.WaitForTransaction(ctx, t, tx) - } -} - -func proposalChainSelectors(ops []types.BatchOperation) []types.ChainSelector { - selectors := make([]types.ChainSelector, 0, len(ops)) - seen := make(map[types.ChainSelector]struct{}, len(ops)) - for _, op := range ops { - if _, ok := seen[op.ChainSelector]; ok { - continue - } - seen[op.ChainSelector] = struct{}{} - selectors = append(selectors, op.ChainSelector) - } - - return selectors -} - -func assertOperationPendingState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { - t.Helper() - - ctx := t.Context() - require.NoError(t, tExecutable.IsOperationPending(ctx, opIdx)) - selector := proposal.Operations[opIdx].ChainSelector - require.NoError(t, tExecutable.IsChainPending(ctx, selector)) -} - -func assertTimelockOperationState( - t *testing.T, - ctx context.Context, - proposal *TimelockProposal, - tExecutable *TimelockExecutable, - executors map[types.ChainSelector]sdk.TimelockExecutor, - opIdx int, - wantIsOperation bool, - wantPending bool, - wantReady bool, - wantDone bool, -) { - t.Helper() - - op := proposal.Operations[opIdx] - selector := op.ChainSelector - timelockAddress := proposal.TimelockAddresses[selector] - - opID, err := tExecutable.GetOpID(ctx, opIdx, op, selector) - require.NoError(t, err) - - gotIsOperation, err := executors[selector].IsOperation(ctx, timelockAddress, opID) - require.NoError(t, err) - require.Equal(t, wantIsOperation, gotIsOperation) - - gotPending, err := executors[selector].IsOperationPending(ctx, timelockAddress, opID) - require.NoError(t, err) - require.Equal(t, wantPending, gotPending) - - gotReady, err := executors[selector].IsOperationReady(ctx, timelockAddress, opID) - require.NoError(t, err) - require.Equal(t, wantReady, gotReady) - - gotDone, err := executors[selector].IsOperationDone(ctx, timelockAddress, opID) - require.NoError(t, err) - require.Equal(t, wantDone, gotDone) -} - -func assertOperationNotPendingState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { - t.Helper() - - ctx := t.Context() - err := tExecutable.IsOperationPending(ctx, opIdx) - var opErr *OperationNotPendingError - require.ErrorAs(t, err, &opErr) - require.Equal(t, opIdx, opErr.OpIndex) - selector := proposal.Operations[opIdx].ChainSelector - require.ErrorAs(t, tExecutable.IsChainPending(ctx, selector), &opErr) -} - -func assertOperationNotReadyState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { - t.Helper() - - ctx := t.Context() - err := tExecutable.IsOperationReady(ctx, opIdx) - var opErr *OperationNotReadyError - require.ErrorAs(t, err, &opErr) - require.Equal(t, opIdx, opErr.OpIndex) - selector := proposal.Operations[opIdx].ChainSelector - require.ErrorAs(t, tExecutable.IsChainReady(ctx, selector), &opErr) -} - -func assertOperationNotDoneState(t *testing.T, tExecutable *TimelockExecutable, proposal *TimelockProposal, opIdx int) { - t.Helper() - - ctx := t.Context() - err := tExecutable.IsOperationDone(ctx, opIdx) - var opErr *OperationNotDoneError - require.ErrorAs(t, err, &opErr) - require.Equal(t, opIdx, opErr.OpIndex) - selector := proposal.Operations[opIdx].ChainSelector - require.ErrorAs(t, tExecutable.IsChainDone(ctx, selector), &opErr) -} From 4087d511bc37e4d65d1ba6da961f6e98660a415c Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 3 Jul 2026 19:19:33 -0600 Subject: [PATCH 10/11] fix: linting errors Signed-off-by: Pablo --- e2e/tests/common/timelock_schedule_cancel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/common/timelock_schedule_cancel.go b/e2e/tests/common/timelock_schedule_cancel.go index f9cefb18..c8d623bc 100644 --- a/e2e/tests/common/timelock_schedule_cancel.go +++ b/e2e/tests/common/timelock_schedule_cancel.go @@ -50,7 +50,7 @@ func (r RoleConfig) Sign(t *testing.T, signable *mcms.Signable) { t.Helper() require.LessOrEqual(t, int(r.Config.Quorum), len(r.Keys)) - for i := 0; i < int(r.Config.Quorum); i++ { + for i := range int(r.Config.Quorum) { _, err := signable.SignAndAppend(mcms.NewPrivateKeySigner(r.Keys[i])) require.NoError(t, err) } From 338832beb5981e60fa0e459e13f167e9228802f4 Mon Sep 17 00:00:00 2001 From: Pablo Date: Fri, 3 Jul 2026 20:14:25 -0600 Subject: [PATCH 11/11] fix: failing tests Signed-off-by: Pablo --- e2e/tests/common/timelock_schedule_cancel.go | 2 +- e2e/tests/solana/timelock_schedule_cancel.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/tests/common/timelock_schedule_cancel.go b/e2e/tests/common/timelock_schedule_cancel.go index c8d623bc..1b75fcff 100644 --- a/e2e/tests/common/timelock_schedule_cancel.go +++ b/e2e/tests/common/timelock_schedule_cancel.go @@ -174,7 +174,7 @@ func runTimelockLifecycleProposal( require.Equal(t, proposal.ValidUntil, validUntil) } - for opIdx := range proposal.Operations { + for opIdx := range mcmsProposal.Operations { tx, execErr := executable.Execute(ctx, opIdx) require.NoError(t, execErr) require.NotEmpty(t, tx.Hash) diff --git a/e2e/tests/solana/timelock_schedule_cancel.go b/e2e/tests/solana/timelock_schedule_cancel.go index 0631e6d2..c0cab891 100644 --- a/e2e/tests/solana/timelock_schedule_cancel.go +++ b/e2e/tests/solana/timelock_schedule_cancel.go @@ -33,7 +33,6 @@ func (s *TestSuite) TestScheduleAndCancelProposal() { s.SetupMCM(testPDASeedTimelockScheduleCancel) s.SetupTimelock(testPDASeedTimelockScheduleCancel, 5*time.Minute) - s.SetupCPIStub(testPDASeedTimelockScheduleCancel) mcmAddress := solanasdk.ContractAddress(s.MCMProgramID, testPDASeedTimelockScheduleCancel) timelockAddress := solanasdk.ContractAddress(s.TimelockProgramID, testPDASeedTimelockScheduleCancel)