From 853b7b1cde7f9994d70a7125f717632774938f8f Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Wed, 5 Feb 2025 13:31:37 +1100 Subject: [PATCH 1/3] fix(deployment/mcms): new BuildProposalFromBatch Implement BuildProposalFromBatchV2 which uses the new MCMS library. New changesets can now use this function to build proposals JIRA: https://smartcontract-it.atlassian.net/browse/DPA-1365 --- deployment/common/proposalutils/propose.go | 74 ++++++++++ .../common/proposalutils/propose_test.go | 127 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 deployment/common/proposalutils/propose_test.go diff --git a/deployment/common/proposalutils/propose.go b/deployment/common/proposalutils/propose.go index 874bbecbdb8..3507c63f676 100644 --- a/deployment/common/proposalutils/propose.go +++ b/deployment/common/proposalutils/propose.go @@ -10,6 +10,8 @@ import ( "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/mcms" "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/timelock" + mcmslib "github.com/smartcontractkit/mcms" + "github.com/smartcontractkit/mcms/types" ) const ( @@ -47,6 +49,7 @@ func BuildProposalMetadata( // The batches are specified separately because we eventually intend // to support user-specified cross chain ordering of batch execution by the tooling itself. // TODO: Can/should merge timelocks and proposers into a single map for the chain. +// Deprecated: Use BuildProposalFromBatchesV2 instead. func BuildProposalFromBatches( timelocksPerChain map[uint64]common.Address, proposerMcmsesPerChain map[uint64]*gethwrappers.ManyChainMultiSig, @@ -86,3 +89,74 @@ func BuildProposalFromBatches( minDelay.String(), ) } + +// BuildProposalFromBatchesV2 uses the new MCMS library which replaces the implementation in BuildProposalFromBatches. +func BuildProposalFromBatchesV2( + timelocksPerChain map[uint64]common.Address, + proposerMcmsesPerChain map[uint64]*gethwrappers.ManyChainMultiSig, + batches []types.BatchOperation, + description string, + minDelay time.Duration, +) (*mcmslib.TimelockProposal, error) { + if len(batches) == 0 { + return nil, errors.New("no operations in batch") + } + + chains := mapset.NewSet[uint64]() + for _, op := range batches { + chains.Add(uint64(op.ChainSelector)) + } + + mcmsMd, err := buildProposalMetadataV2(chains.ToSlice(), proposerMcmsesPerChain) + if err != nil { + return nil, err + } + + tlsPerChainID := make(map[types.ChainSelector]string) + for chainID, tl := range timelocksPerChain { + tlsPerChainID[types.ChainSelector(chainID)] = tl.Hex() + } + validUntil := time.Now().Unix() + int64(DefaultValidUntil.Seconds()) + + builder := mcmslib.NewTimelockProposalBuilder() + builder. + SetVersion("v1"). + SetAction(types.TimelockActionSchedule). + //nolint:gosec // G115 + SetValidUntil(uint32(validUntil)). + SetDescription(description). + SetDelay(types.NewDuration(minDelay)). + SetOverridePreviousRoot(false). + SetChainMetadata(mcmsMd). + SetTimelockAddresses(tlsPerChainID). + SetOperations(batches) + + build, err := builder.Build() + if err != nil { + return nil, err + } + return build, nil +} + +func buildProposalMetadataV2( + chainSelectors []uint64, + proposerMcmsesPerChain map[uint64]*gethwrappers.ManyChainMultiSig, +) (map[types.ChainSelector]types.ChainMetadata, error) { + metaDataPerChain := make(map[types.ChainSelector]types.ChainMetadata) + for _, selector := range chainSelectors { + proposerMcms, ok := proposerMcmsesPerChain[selector] + if !ok { + return nil, fmt.Errorf("missing proposer mcm for chain %d", selector) + } + chainID := types.ChainSelector(selector) + opCount, err := proposerMcms.GetOpCount(nil) + if err != nil { + return nil, fmt.Errorf("failed to get op count for chain %d: %w", selector, err) + } + metaDataPerChain[chainID] = types.ChainMetadata{ + StartingOpCount: opCount.Uint64(), + MCMAddress: proposerMcms.Address().Hex(), + } + } + return metaDataPerChain, nil +} diff --git a/deployment/common/proposalutils/propose_test.go b/deployment/common/proposalutils/propose_test.go new file mode 100644 index 00000000000..22f5295ed43 --- /dev/null +++ b/deployment/common/proposalutils/propose_test.go @@ -0,0 +1,127 @@ +package proposalutils_test + +import ( + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + types2 "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/deployment/environment/memory" + "github.com/smartcontractkit/chainlink/v2/core/logger" +) + +func TestBuildProposalFromBatchesV2(t *testing.T) { + lggr := logger.TestLogger(t) + cfg := memory.MemoryEnvironmentConfig{ + Nodes: 1, + Chains: 2, + } + env := memory.NewMemoryEnvironment(t, lggr, zapcore.DebugLevel, cfg) + chainSelector := env.AllChainSelectors()[0] + config := proposalutils.SingleGroupMCMS(t) + + env, err := changeset.ApplyChangesets(t, env, nil, []changeset.ChangesetApplication{ + { + Changeset: changeset.WrapChangeSet(changeset.DeployMCMSWithTimelock), + Config: map[uint64]types2.MCMSWithTimelockConfig{ + chainSelector: { + Canceller: config, + Bypasser: config, + Proposer: config, + TimelockMinDelay: big.NewInt(0), + }, + }, + }, + }) + require.NoError(t, err) + + chain := env.Chains[chainSelector] + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + mcmsState, err := changeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + timelockAddress := mcmsState.Timelock.Address() + require.NoError(t, err) + + timelocksPerChain := map[uint64]common.Address{ + chainSelector: timelockAddress, + } + + sig, err := gethwrappers.NewManyChainMultiSig(mcmsState.ProposerMcm.Address(), env.Chains[chainSelector].Client) + require.NoError(t, err) + + proposerMcmsesPerChain := map[uint64]*gethwrappers.ManyChainMultiSig{ + chainSelector: sig, + } + + description := "Test Proposal" + minDelay := 24 * time.Hour + + tests := []struct { + name string + batches []types.BatchOperation + wantErr bool + errMsg string + }{ + { + name: "success", + batches: []types.BatchOperation{ + { + ChainSelector: types.ChainSelector(chainSelector), + Transactions: []types.Transaction{{To: "0xRecipient1", Data: []byte("data1"), AdditionalFields: json.RawMessage(`{"value": 0}`)}}, + }, + }, + wantErr: false, + }, + { + name: "invalid fields: missing required AdditionalFields", + batches: []types.BatchOperation{ + { + ChainSelector: types.ChainSelector(chainSelector), + Transactions: []types.Transaction{{To: "0xRecipient1", Data: []byte("data1")}}, + }, + }, + wantErr: true, + errMsg: "Key: 'TimelockProposal.Operations[0].Transactions[0].AdditionalFields' Error:Field validation for 'AdditionalFields' failed on the 'required' tag", + }, + { + name: "empty batches", + batches: []types.BatchOperation{}, + wantErr: true, + errMsg: "no operations in batch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proposal, err := proposalutils.BuildProposalFromBatchesV2(timelocksPerChain, proposerMcmsesPerChain, tt.batches, description, minDelay) + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, proposal) + assert.Equal(t, tt.errMsg, err.Error()) + } else { + require.NoError(t, err) + require.NotNil(t, proposal) + assert.Equal(t, "v1", proposal.Version) + assert.Equal(t, string(types.TimelockActionSchedule), string(proposal.Action)) + //nolint:gosec // G115 + assert.InEpsilon(t, uint32(time.Now().Unix()+int64(proposalutils.DefaultValidUntil.Seconds())), proposal.ValidUntil, 1) + assert.Equal(t, description, proposal.Description) + assert.InEpsilon(t, minDelay.Seconds(), proposal.Delay.Seconds(), 0) + assert.Equal(t, map[types.ChainSelector]types.ChainMetadata{0xc9f9284461c852b: {StartingOpCount: 0x0, MCMAddress: mcmsState.ProposerMcm.Address().String()}}, proposal.ChainMetadata) + assert.Equal(t, timelockAddress.String(), proposal.TimelockAddresses[types.ChainSelector(chainSelector)]) + assert.Equal(t, tt.batches, proposal.Operations) + } + }) + } +} From 645beec80f3cf21db7b9d58b2eea3998d179faa9 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Wed, 5 Feb 2025 19:29:22 +1100 Subject: [PATCH 2/3] test --- .../common/changeset/example/link_transfer.go | 29 +++--- deployment/common/changeset/test_helpers.go | 32 ++++++ .../common/proposalutils/mcms_test_helpers.go | 98 +++++++++++++++++++ 3 files changed, 142 insertions(+), 17 deletions(-) diff --git a/deployment/common/changeset/example/link_transfer.go b/deployment/common/changeset/example/link_transfer.go index 6253be187c0..b99a20f1778 100644 --- a/deployment/common/changeset/example/link_transfer.go +++ b/deployment/common/changeset/example/link_transfer.go @@ -11,9 +11,9 @@ import ( ethTypes "github.com/ethereum/go-ethereum/core/types" owner_helpers "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" chain_selectors "github.com/smartcontractkit/chain-selectors" - - "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/mcms" - "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/timelock" + mcms2 "github.com/smartcontractkit/mcms" + "github.com/smartcontractkit/mcms/sdk/evm" + types2 "github.com/smartcontractkit/mcms/types" "github.com/smartcontractkit/chainlink/deployment" "github.com/smartcontractkit/chainlink/deployment/common/changeset" @@ -177,9 +177,9 @@ func LinkTransfer(e deployment.Environment, cfg *LinkTransferConfig) (deployment // Initialize state for each chain linkStatePerChain, mcmsStatePerChain, err := initStatePerChain(cfg, e) - allBatches := []timelock.BatchChainOperation{} + allBatches := []types2.BatchOperation{} for chainSelector := range cfg.Transfers { - chainID := mcms.ChainIdentifier(chainSelector) + chainID := types2.ChainSelector(chainSelector) chain := e.Chains[chainSelector] linkAddress := linkStatePerChain[chainSelector].LinkToken.Address() mcmsState := mcmsStatePerChain[chainSelector] @@ -190,9 +190,9 @@ func LinkTransfer(e deployment.Environment, cfg *LinkTransferConfig) (deployment mcmsPerChain[uint64(chainID)] = mcmsState.ProposerMcm timelockAddresses[chainSelector] = timelockAddress - batch := timelock.BatchChainOperation{ - ChainIdentifier: chainID, - Batch: []mcms.Operation{}, + batch := types2.BatchOperation{ + ChainSelector: chainID, + Transactions: []types2.Transaction{}, } opts := getDeployer(e, chainSelector, cfg.McmsConfig) @@ -202,13 +202,8 @@ func LinkTransfer(e deployment.Environment, cfg *LinkTransferConfig) (deployment if err != nil { return deployment.ChangesetOutput{}, err } - op := mcms.Operation{ - To: linkAddress, - Data: tx.Data(), - Value: big.NewInt(0), - ContractType: string(types.LinkToken), - } - batch.Batch = append(batch.Batch, op) + op := evm.NewTransaction(linkAddress, tx.Data(), big.NewInt(0), string(types.LinkToken), []string{}) + batch.Transactions = append(batch.Transactions, op) totalAmount.Add(totalAmount, transfer.Value) } @@ -216,7 +211,7 @@ func LinkTransfer(e deployment.Environment, cfg *LinkTransferConfig) (deployment } if cfg.McmsConfig != nil { - proposal, err := proposalutils.BuildProposalFromBatches( + proposal, err := proposalutils.BuildProposalFromBatchesV2( timelockAddresses, mcmsPerChain, allBatches, @@ -228,7 +223,7 @@ func LinkTransfer(e deployment.Environment, cfg *LinkTransferConfig) (deployment } return deployment.ChangesetOutput{ - Proposals: []timelock.MCMSWithTimelockProposal{*proposal}, + MCMSTimelockProposals: []mcms2.TimelockProposal{*proposal}, }, nil } diff --git a/deployment/common/changeset/test_helpers.go b/deployment/common/changeset/test_helpers.go index 92619cc0aeb..d7af134f0e2 100644 --- a/deployment/common/changeset/test_helpers.go +++ b/deployment/common/changeset/test_helpers.go @@ -69,6 +69,38 @@ func ApplyChangesets(t *testing.T, e deployment.Environment, timelockContractsPe } } } + if out.MCMSTimelockProposals != nil { + for _, prop := range out.MCMSTimelockProposals { + chains := mapset.NewSet[uint64]() + for _, op := range prop.Operations { + chains.Add(uint64(op.ChainSelector)) + } + + p := proposalutils.SignMCMSTimelockProposal(t, e, &prop) + for _, sel := range chains.ToSlice() { + timelockContracts, ok := timelockContractsPerChain[sel] + if !ok || timelockContracts == nil { + return deployment.Environment{}, fmt.Errorf("timelock contracts not found for chain %d", sel) + } + + proposalutils.ExecuteProposalV2(t, e, p, sel) + } + } + } + if out.MCMSProposals != nil { + for _, prop := range out.MCMSProposals { + chains := mapset.NewSet[uint64]() + for _, op := range prop.Operations { + chains.Add(uint64(op.ChainSelector)) + } + + p := proposalutils.SignMCMSProposal(t, e, &prop) + p.UseSimulatedBackend(true) + for _, sel := range chains.ToSlice() { + proposalutils.ExecuteProposalV2(t, e, p, sel) + } + } + } currentEnv = deployment.Environment{ Name: e.Name, Logger: e.Logger, diff --git a/deployment/common/proposalutils/mcms_test_helpers.go b/deployment/common/proposalutils/mcms_test_helpers.go index 610fe84f34c..211e010de25 100644 --- a/deployment/common/proposalutils/mcms_test_helpers.go +++ b/deployment/common/proposalutils/mcms_test_helpers.go @@ -6,11 +6,16 @@ import ( "testing" "github.com/ethereum/go-ethereum/common" + types2 "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/smartcontractkit/ccip-owner-contracts/pkg/config" "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/mcms" "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/timelock" chainsel "github.com/smartcontractkit/chain-selectors" + mcms2 "github.com/smartcontractkit/mcms" + "github.com/smartcontractkit/mcms/sdk" + "github.com/smartcontractkit/mcms/sdk/evm" + "github.com/smartcontractkit/mcms/types" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink/deployment" @@ -82,6 +87,99 @@ func ExecuteProposal(t *testing.T, env deployment.Environment, executor *mcms.Ex require.NoError(t, RunTimelockExecutor(env, cfg)) } +func SignMCMSTimelockProposal(t *testing.T, env deployment.Environment, proposal *mcms2.TimelockProposal) *mcms2.Proposal { + converters := make(map[types.ChainSelector]sdk.TimelockConverter) + inspectorsMap := make(map[types.ChainSelector]sdk.Inspector) + for _, chain := range env.Chains { + chainselc, exists := chainsel.ChainBySelector(chain.Selector) + require.True(t, exists) + chainSel := types.ChainSelector(chainselc.Selector) + converters[chainSel] = &evm.TimelockConverter{} + inspectorsMap[chainSel] = evm.NewInspector(chain.Client) + } + + p, _, err := proposal.Convert(env.GetContext(), converters) + require.NoError(t, err) + p.UseSimulatedBackend(true) + + signable, err := mcms2.NewSignable(&p, inspectorsMap) + require.NoError(t, err) + + err = signable.ValidateConfigs(env.GetContext()) + require.NoError(t, err) + + signer := mcms2.NewPrivateKeySigner(TestXXXMCMSSigner) + _, err = signable.SignAndAppend(signer) + require.NoError(t, err) + + quorumMet, err := signable.ValidateSignatures(env.GetContext()) + require.NoError(t, err) + require.True(t, quorumMet) + + return &p +} + +func SignMCMSProposal(t *testing.T, env deployment.Environment, p *mcms2.Proposal) *mcms2.Proposal { + converters := make(map[types.ChainSelector]sdk.TimelockConverter) + inspectorsMap := make(map[types.ChainSelector]sdk.Inspector) + for _, chain := range env.Chains { + chainselc, exists := chainsel.ChainBySelector(chain.Selector) + require.True(t, exists) + chainSel := types.ChainSelector(chainselc.Selector) + converters[chainSel] = &evm.TimelockConverter{} + inspectorsMap[chainSel] = evm.NewInspector(chain.Client) + } + + signable, err := mcms2.NewSignable(p, inspectorsMap) + require.NoError(t, err) + + err = signable.ValidateConfigs(env.GetContext()) + require.NoError(t, err) + + signer := mcms2.NewPrivateKeySigner(TestXXXMCMSSigner) + _, err = signable.SignAndAppend(signer) + require.NoError(t, err) + + quorumMet, err := signable.ValidateSignatures(env.GetContext()) + require.NoError(t, err) + require.True(t, quorumMet) + + return p +} + +func ExecuteProposalV2(t *testing.T, env deployment.Environment, proposal *mcms2.Proposal, sel uint64) { + t.Log("Executing proposal on chain", sel) + + encoders, err := proposal.GetEncoders() + require.NoError(t, err) + + selector := types.ChainSelector(sel) + encoder := encoders[selector].(*evm.Encoder) + evmExecutor := evm.NewExecutor(encoder, env.Chains[sel].Client, env.Chains[sel].DeployerKey) + executorsMap := map[types.ChainSelector]sdk.Executor{ + selector: evmExecutor, + } + executable, err := mcms2.NewExecutable(proposal, executorsMap) + require.NoError(t, err) + + chain := env.Chains[sel] + root, err := executable.SetRoot(env.GetContext(), selector) + require.NoError(t, deployment.MaybeDataErr(err)) + + evmTransaction := root.RawTransaction.(*types2.Transaction) + _, err = chain.Confirm(evmTransaction) + require.NoError(t, err) + + for i := 0; i < len(proposal.Operations); i++ { + result, err := executable.Execute(env.GetContext(), i) + require.NoError(t, err) + + evmTransaction = result.RawTransaction.(*types2.Transaction) + _, err = chain.Confirm(evmTransaction) + require.NoError(t, err) + } +} + func SingleGroupTimelockConfig(t *testing.T) commontypes.MCMSWithTimelockConfig { return commontypes.MCMSWithTimelockConfig{ Canceller: SingleGroupMCMS(t), From ae678c3ebf20c07f2e9ac68765c138342b267367 Mon Sep 17 00:00:00 2001 From: ajaskolski Date: Wed, 5 Feb 2025 16:55:11 +0100 Subject: [PATCH 3/3] add timelock execute proposal, update names to the new types --- deployment/common/changeset/test_helpers.go | 5 ++-- .../common/proposalutils/mcms_test_helpers.go | 29 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/deployment/common/changeset/test_helpers.go b/deployment/common/changeset/test_helpers.go index d7af134f0e2..71f8edb234c 100644 --- a/deployment/common/changeset/test_helpers.go +++ b/deployment/common/changeset/test_helpers.go @@ -83,7 +83,8 @@ func ApplyChangesets(t *testing.T, e deployment.Environment, timelockContractsPe return deployment.Environment{}, fmt.Errorf("timelock contracts not found for chain %d", sel) } - proposalutils.ExecuteProposalV2(t, e, p, sel) + proposalutils.ExecuteMCMSProposalV2(t, e, p, sel) + proposalutils.ExecuteMCMSTimelockProposalV2(t, e, &prop, sel) } } } @@ -97,7 +98,7 @@ func ApplyChangesets(t *testing.T, e deployment.Environment, timelockContractsPe p := proposalutils.SignMCMSProposal(t, e, &prop) p.UseSimulatedBackend(true) for _, sel := range chains.ToSlice() { - proposalutils.ExecuteProposalV2(t, e, p, sel) + proposalutils.ExecuteMCMSProposalV2(t, e, p, sel) } } } diff --git a/deployment/common/proposalutils/mcms_test_helpers.go b/deployment/common/proposalutils/mcms_test_helpers.go index 211e010de25..6518ab436b8 100644 --- a/deployment/common/proposalutils/mcms_test_helpers.go +++ b/deployment/common/proposalutils/mcms_test_helpers.go @@ -147,7 +147,7 @@ func SignMCMSProposal(t *testing.T, env deployment.Environment, p *mcms2.Proposa return p } -func ExecuteProposalV2(t *testing.T, env deployment.Environment, proposal *mcms2.Proposal, sel uint64) { +func ExecuteMCMSProposalV2(t *testing.T, env deployment.Environment, proposal *mcms2.Proposal, sel uint64) { t.Log("Executing proposal on chain", sel) encoders, err := proposal.GetEncoders() @@ -180,6 +180,33 @@ func ExecuteProposalV2(t *testing.T, env deployment.Environment, proposal *mcms2 } } +// ExecuteMCMSTimelockProposalV2 - Includes an option to set callProxy to execute the calls through a proxy. +// If the callProxy is not set, the calls will be executed directly to the timelock. +func ExecuteMCMSTimelockProposalV2(t *testing.T, env deployment.Environment, timelockProposal *mcms2.TimelockProposal, sel uint64, opts ...mcms2.Option) { + tExecutors := map[types.ChainSelector]sdk.TimelockExecutor{} + chain := env.Chains[sel] + + chainSel := types.ChainSelector(sel) + tExecutors[chainSel] = evm.NewTimelockExecutor( + env.Chains[sel].Client, + env.Chains[sel].DeployerKey) + + timelockExecutable, err := mcms2.NewTimelockExecutable(timelockProposal, tExecutors) + require.NoError(t, err) + + err = timelockExecutable.IsReady(env.GetContext()) + require.NoError(t, err) + + var tx = types.TransactionResult{} + for i := range timelockProposal.Operations { + tx, err = timelockExecutable.Execute(env.GetContext(), i, opts...) + require.NoError(t, err, "Failed to mine execution transaction on Chain A") + evmTransaction := tx.RawTransaction.(*types2.Transaction) + _, err = chain.Confirm(evmTransaction) + require.NoError(t, err) + } +} + func SingleGroupTimelockConfig(t *testing.T) commontypes.MCMSWithTimelockConfig { return commontypes.MCMSWithTimelockConfig{ Canceller: SingleGroupMCMS(t),