diff --git a/deployment/vault/changeset/ethbalmon_accept_ownership.go b/deployment/vault/changeset/ethbalmon_accept_ownership.go new file mode 100644 index 00000000000..0d188d05fa1 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_accept_ownership.go @@ -0,0 +1,206 @@ +package changeset + +import ( + "encoding/json" + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + proposeutils "github.com/smartcontractkit/cld-changesets/legacy/mcms/proposeutils" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + proposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/eth_balance_monitor_wrapper" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + vaulttypes "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +type ethBalMonAcceptOwnership struct{} + +var EthBalMonAcceptOwnership cldf.ChangeSetV2[vaulttypes.EthBalMonAcceptOwnershipInput] = ethBalMonAcceptOwnership{} + +func (tw ethBalMonAcceptOwnership) VerifyPreconditions(env cldf.Environment, config vaulttypes.EthBalMonAcceptOwnershipInput) error { + return ValidateEthBalMonAcceptOwnershipConfig(env.GetContext(), env, config) +} + +func (tw ethBalMonAcceptOwnership) Apply(e cldf.Environment, config vaulttypes.EthBalMonAcceptOwnershipInput) (cldf.ChangesetOutput, error) { + logger := e.Logger + logger.Infow("Generating EthBalMon acceptOwnership proposal", "numChains", len(config.Chains)) + + evmChains := e.BlockChains.EVMChains() + + var primaryChain cldf_evm.Chain + for _, chainSelector := range config.Chains { + primaryChain = evmChains[chainSelector] + break + } + + deps := VaultDeps{ + Auth: primaryChain.DeployerKey, + Chain: primaryChain, + Environment: e, + DataStore: e.DataStore, + } + seqInput := EthBalMonAcceptOwnershipSeqInput{ + Chains: config.Chains, + MCMSConfig: config.MCMSConfig, + } + seqReport, err := operations.ExecuteSequence(e.OperationsBundle, EthBalMonAcceptOwnershipSequence, deps, seqInput) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed on EthBalMonAcceptOwnershipSequence sequence: %w", err) + } + + return cldf.ChangesetOutput{ + MCMSTimelockProposals: seqReport.Output.MCMSTimelockProposals, + }, nil +} + +type EthBalMonAcceptOwnershipSeqInput struct { + Chains []uint64 `json:"chains"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonAcceptOwnershipSeqOutput struct { + MCMSTimelockProposals []mcms.TimelockProposal `json:"mcms_timelock_proposals"` +} + +var EthBalMonAcceptOwnershipSequence = operations.NewSequence( + "ethbalmon-acceptownership-sequence", + semver.MustParse("1.0.0"), + "Sequence to create acceptOwnership EthBalMon batch transaction", + func(b operations.Bundle, deps VaultDeps, input EthBalMonAcceptOwnershipSeqInput) (EthBalMonAcceptOwnershipSeqOutput, error) { + b.Logger.Infow("Starting EthBalMon acceptOwnership sequence", + "chains", len(input.Chains), + ) + var batches []mcmstypes.BatchOperation + timelockAddresses := make(map[uint64]string) + mcmAddressByChain := make(map[uint64]string) + for _, chainSelector := range input.Chains { + opReport, err := operations.ExecuteOperation(b, EthBalMonAcceptOwnershipOperation, deps, EthBalMonAcceptOwnershipOpInput{ + ChainSelector: chainSelector, + MCMSConfig: input.MCMSConfig, + }) + if err != nil { + return EthBalMonAcceptOwnershipSeqOutput{}, fmt.Errorf("chain %d: failed to generate acceptOwnership batch: %w", chainSelector, err) + } + opOutput := opReport.Output + + batches = append(batches, opOutput.BatchOperation) + timelockAddresses[chainSelector] = opOutput.TimelockAddress + mcmAddressByChain[chainSelector] = opOutput.MCMSAddress + } + + proposal, err := proposeutils.BuildProposalFromBatchesV2(deps.Environment, timelockAddresses, mcmAddressByChain, nil, batches, "EthBalMon acceptOwnership", ethBalMonProposalTimelockConfig(input.MCMSConfig)) + if err != nil { + return EthBalMonAcceptOwnershipSeqOutput{}, fmt.Errorf("failed to build timelock proposal: %w", err) + } + b.Logger.Infow("Generated EthBalMon acceptOwnership proposal", + "chains", len(input.Chains), "operations", len(batches)) + + return EthBalMonAcceptOwnershipSeqOutput{ + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil + }, +) + +type EthBalMonAcceptOwnershipOpInput struct { + ChainSelector uint64 `json:"chain_selector"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonAcceptOwnershipOpOutput struct { + ChainSelector uint64 `json:"chain_selector"` + BatchOperation mcmstypes.BatchOperation `json:"batch_operation"` + TimelockAddress string `json:"timelock_address"` + MCMSAddress string `json:"mcms_address"` +} + +var EthBalMonAcceptOwnershipOperation = operations.NewOperation( + "ethbalmon-acceptownership-operation", + semver.MustParse("1.0.0"), + "Operation to create acceptOwnership EthBalMon batch transaction", + func(b operations.Bundle, deps VaultDeps, input EthBalMonAcceptOwnershipOpInput) (EthBalMonAcceptOwnershipOpOutput, error) { + b.Logger.Infow("Starting EthBalMon acceptOwnership operation", + "chainsel", input.ChainSelector, + ) + + chain, ok := deps.Environment.BlockChains.EVMChains()[input.ChainSelector] + if !ok { + return EthBalMonAcceptOwnershipOpOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + ethBalMonAddr, err := getRequiredContractAddress( + deps.DataStore, + input.ChainSelector, + cldf.ContractType(vaulttypes.EthBalMonContractType), + ) + if err != nil { + return EthBalMonAcceptOwnershipOpOutput{}, + fmt.Errorf("failed to get EthBalMon address: %w", err) + } + + timelockAddr, err := getRequiredContractAddress( + deps.DataStore, + input.ChainSelector, + commontypes.RBACTimelock, + ) + if err != nil { + return EthBalMonAcceptOwnershipOpOutput{}, + fmt.Errorf("failed to get timelock address: %w", err) + } + + mcmsAddr, err := getRequiredContractAddress( + deps.DataStore, + input.ChainSelector, + ethBalMonMCMSContractTypeForProposal(input.MCMSConfig), + ) + if err != nil { + return EthBalMonAcceptOwnershipOpOutput{}, + fmt.Errorf("failed to get MCMS address: %w", err) + } + + ethBalMon, err := eth_balance_monitor_wrapper.NewEthBalanceMonitor(common.HexToAddress(ethBalMonAddr), chain.Client) + if err != nil { + return EthBalMonAcceptOwnershipOpOutput{}, + fmt.Errorf("failed to instantiate EthBalanceMonitor at %s: %w", ethBalMonAddr, err) + } + + acceptOwnershipTx, err := ethBalMon.AcceptOwnership(cldf.SimTransactOpts()) + if err != nil { + return EthBalMonAcceptOwnershipOpOutput{}, fmt.Errorf("failed to generate acceptOwnership calldata on chain %d: %w", input.ChainSelector, err) + } + + batch := mcmstypes.BatchOperation{ + ChainSelector: mcmstypes.ChainSelector(input.ChainSelector), + Transactions: []mcmstypes.Transaction{ + { + OperationMetadata: mcmstypes.OperationMetadata{ + ContractType: vaulttypes.EthBalMonContractType, + Tags: []string{ + "acceptOwnership", + }, + }, + To: ethBalMonAddr, + Data: acceptOwnershipTx.Data(), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }, + }, + } + + b.Logger.Infow("Generated EthBalMon acceptOwnership batch", + "chainSelector", input.ChainSelector, + "ethBalMon", ethBalMonAddr, + ) + + return EthBalMonAcceptOwnershipOpOutput{ + ChainSelector: input.ChainSelector, + BatchOperation: batch, + TimelockAddress: timelockAddr, + MCMSAddress: mcmsAddr, + }, nil + }, +) diff --git a/deployment/vault/changeset/ethbalmon_accept_ownership_test.go b/deployment/vault/changeset/ethbalmon_accept_ownership_test.go new file mode 100644 index 00000000000..3ed1f36bf43 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_accept_ownership_test.go @@ -0,0 +1,137 @@ +package changeset + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + + "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +func TestValidateEthBalMonAcceptOwnershipConfig(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + selectorOther := chainselectors.TEST_90000002.Selector + + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + tests := []struct { + name string + cfg types.EthBalMonAcceptOwnershipInput + wantError bool + errorMsg string + }{ + { + name: "empty chains", + cfg: types.EthBalMonAcceptOwnershipInput{Chains: []uint64{}}, + wantError: true, + errorMsg: "no chains provided", + }, + { + name: "unknown chain selector", + cfg: types.EthBalMonAcceptOwnershipInput{ + Chains: []uint64{math.MaxUint64}, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "chain not in environment", + cfg: types.EthBalMonAcceptOwnershipInput{ + Chains: []uint64{selectorOther}, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "valid", + cfg: types.EthBalMonAcceptOwnershipInput{ + Chains: []uint64{selector}, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateEthBalMonAcceptOwnershipConfig(t.Context(), *env, tt.cfg) + if tt.wantError { + require.Error(t, err) + if tt.errorMsg != "" { + require.Contains(t, err.Error(), tt.errorMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestEthBalMonAcceptOwnershipChangeset(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + )) + require.NoError(t, err) + + setupMCMSInfrastructure(t, rt, []uint64{selector}) + fundDeployerAccounts(t, rt.Environment(), []uint64{selector}) + + deployCfg := types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: {SetKeeperRegistryAddress: testAddr1}, + }, + } + deployTask := runtime.ChangesetTask(DeployEthBalMonChangeSet, deployCfg) + require.NoError(t, rt.Exec(deployTask)) + + cfg := types.EthBalMonAcceptOwnershipInput{ + Chains: []uint64{selector}, + } + require.NoError(t, EthBalMonAcceptOwnership.VerifyPreconditions(rt.Environment(), cfg)) + + acceptTask := runtime.ChangesetTask(EthBalMonAcceptOwnership, cfg) + require.NoError(t, rt.Exec(acceptTask)) + + out := rt.State().Outputs[acceptTask.ID()] + require.NotEmpty(t, out.MCMSTimelockProposals) + prop := out.MCMSTimelockProposals[0] + require.Contains(t, prop.Description, "EthBalMon acceptOwnership") + require.Len(t, prop.Operations, 1) + require.Len(t, prop.Operations[0].Transactions, 1) + require.Contains(t, prop.Operations[0].Transactions[0].Tags, "acceptOwnership") +} + +func TestEthBalMonAcceptOwnership_Apply_withoutEthBalMonInDatastore(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + )) + require.NoError(t, err) + + setupMCMSInfrastructure(t, rt, []uint64{selector}) + fundDeployerAccounts(t, rt.Environment(), []uint64{selector}) + + cfg := types.EthBalMonAcceptOwnershipInput{ + Chains: []uint64{selector}, + } + require.NoError(t, EthBalMonAcceptOwnership.VerifyPreconditions(rt.Environment(), cfg)) + + _, err = EthBalMonAcceptOwnership.Apply(rt.Environment(), cfg) + require.Error(t, err) +} diff --git a/deployment/vault/changeset/types/types.go b/deployment/vault/changeset/types/types.go index 14fb4e63f90..89fe8f20200 100644 --- a/deployment/vault/changeset/types/types.go +++ b/deployment/vault/changeset/types/types.go @@ -176,3 +176,11 @@ type EthBalMonTransferOwnershipInput struct { // MCMSConfig optionally configures the timelock proposal; when nil, schedule + proposer MCM is used. MCMSConfig *cldfproposalutils.TimelockConfig `json:"mcms_config,omitempty"` } + +// EthBalMonAcceptOwnershipInput is the input to the EthBalMon acceptOwnership changeset. +// Chains is the list of chain selectors on which to call acceptOwnership on the EthBalMon instance. +type EthBalMonAcceptOwnershipInput struct { + Chains []uint64 `json:"chains"` + // MCMSConfig optionally configures the timelock proposal; when nil, schedule + proposer MCM is used. + MCMSConfig *cldfproposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} diff --git a/deployment/vault/changeset/validation.go b/deployment/vault/changeset/validation.go index 18112b81528..5e370526846 100644 --- a/deployment/vault/changeset/validation.go +++ b/deployment/vault/changeset/validation.go @@ -454,6 +454,20 @@ func ValidateEthBalMonTransferOwnershipConfig(ctx context.Context, env cldf.Envi return nil } +func ValidateEthBalMonAcceptOwnershipConfig(ctx context.Context, env cldf.Environment, cfg types.EthBalMonAcceptOwnershipInput) error { + if len(cfg.Chains) == 0 { + return errors.New("no chains provided") + } + + for _, chainSelector := range cfg.Chains { + if _, ok := env.BlockChains.EVMChains()[chainSelector]; !ok { + return fmt.Errorf("chain not found in environment: %d", chainSelector) + } + } + + return nil +} + func ValidateEthBalMonSetWatchListConfig(ctx context.Context, env cldf.Environment, cfg types.EthBalMonSetWatchListInput) error { if len(cfg.Chains) == 0 { return errors.New("no chains provided")