diff --git a/deployment/vault/changeset/ethbalmon_deploy.go b/deployment/vault/changeset/ethbalmon_deploy.go new file mode 100644 index 00000000000..91361d5bf61 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_deploy.go @@ -0,0 +1,510 @@ +package changeset + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + + ds "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/eth_balance_monitor_wrapper" + + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + vaulttypes "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +const defaultEthBalMonMinWaitPeriodSeconds uint64 = 60 + +var DeployEthBalMonChangeSet cldf.ChangeSetV2[vaulttypes.DeployEthBalMonInput] = deployEthBalMon{} + +type deployEthBalMon struct { +} + +func effectiveMinWaitPeriodSeconds(v uint64) uint64 { + if v == 0 { + return defaultEthBalMonMinWaitPeriodSeconds + } + return v +} + +func mustGetContractAddress(store ds.DataStore, chainSelector uint64, contractType cldf.ContractType) (string, error) { + addr, err := GetContractAddress(store, chainSelector, contractType) + if err != nil { + return "", fmt.Errorf("failed to get contract address for type %s on chain %d: %w", contractType, chainSelector, err) + } + if addr == "" { + return "", fmt.Errorf("empty contract address for type %s on chain %d", contractType, chainSelector) + } + return addr, nil +} + +func (d deployEthBalMon) VerifyPreconditions(env cldf.Environment, config vaulttypes.DeployEthBalMonInput) error { + return ValidateDeployEthBalMonConfig(env.GetContext(), env, config) +} + +func (d deployEthBalMon) Apply(e cldf.Environment, config vaulttypes.DeployEthBalMonInput) (cldf.ChangesetOutput, error) { + logger := e.Logger + logger.Infow("Deploying Ethereum Balances Monitor", + "numChains", len(config.Chains)) + + evmChains := e.BlockChains.EVMChains() + + // Pick a deterministic primary chain for deps (only needed for direct execution, not MCMS). + var ( + primaryChainSelector uint64 + primaryChainSet bool + ) + for chainSelector := range config.Chains { + if !primaryChainSet || chainSelector < primaryChainSelector { + primaryChainSelector = chainSelector + primaryChainSet = true + } + } + + primaryChain := evmChains[primaryChainSelector] + + deps := VaultDeps{ + Auth: primaryChain.DeployerKey, + Chain: primaryChain, + Environment: e, + DataStore: e.DataStore, + } + seqInput := DeployEthBalMonSequenceInput{ + Chains: config.Chains, + MCMSConfig: config.MCMSConfig, + } + + seqReport, err := operations.ExecuteSequence(e.OperationsBundle, DeployEthBalMonSequence, deps, seqInput) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to deploy ethereum balance monitor contract sequence: %w", err) + } + + logger.Infow("ethbalmon contract deployed successfully", + "chains", len(config.Chains)) + + seqOut := seqReport.Output + memoryDataStore := ds.NewMemoryDataStore() + contractsByChain := make(map[uint64]string) + + for _, chainOut := range seqOut.Chains { + contractsByChain[chainOut.ChainSelector] = chainOut.ContractAddress + + addressRef := ds.AddressRef{ + ChainSelector: chainOut.ChainSelector, + Address: chainOut.ContractAddress, + Type: ds.ContractType(vaulttypes.EthBalMonContractType), + Version: semver.MustParse("1.0.0"), + Qualifier: fmt.Sprintf("%s:%s", vaulttypes.EthBalMonContractType, chainOut.ContractAddress), + Labels: ds.NewLabelSet( + vaulttypes.EthBalMonContractType, + "EthBalMonV1_0_0", + ), + } + + contractMetadata := ds.ContractMetadata{ + ChainSelector: chainOut.ChainSelector, + Address: chainOut.ContractAddress, + Metadata: map[string]any{ + "deployTxHash": chainOut.DeployTxHash, + "deployBlockNumber": chainOut.DeployBlockNumber, + "keeperRegistryAddress": chainOut.KeeperRegistryAddress, + "minWaitPeriodSeconds": chainOut.MinWaitPeriodSeconds, + "timelockAddress": chainOut.TimelockAddress, + "mcmsAddress": chainOut.MCMSAddress, + "transferOwnershipTxHash": chainOut.TransferOwnershipTxHash, + }, + } + + if err := memoryDataStore.Addresses().Add(addressRef); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to add address ref for chain %d: %w", chainOut.ChainSelector, err) + } + if err := memoryDataStore.ContractMetadata().Add(contractMetadata); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to add contract metadata for chain %d: %w", chainOut.ChainSelector, err) + } + } + + proposal, err := BuildAcceptOwnershipTimelockProposal( + e, + AcceptOwnershipProposalInput{ + ContractsByChain: contractsByChain, + Description: "Accept ownership of EthBalanceMonitor across chains", + MCMSConfig: deployEthBalMonAcceptOwnershipTimelockConfig(config.MCMSConfig), + }, + ) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to build accept ownership proposal: %w", err) + } + + logger.Infow("Ethereum Balance Monitor deployment completed successfully", + "chains", len(seqOut.Chains), + ) + return cldf.ChangesetOutput{ + DataStore: memoryDataStore, + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil +} + +// ================================================ +// ================================================ +// Deploy Ethereum Balance Monitor SEQUENCE +// ================================================ +// ================================================ + +type DeployEthBalMonSequenceInput struct { + Chains map[uint64]vaulttypes.DeployEthBalMonChainConfig `json:"chains"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} +type DeployEthBalMonPerChainOutput struct { + ChainSelector uint64 + ContractAddress string + DeployTxHash string + DeployBlockNumber uint64 + KeeperRegistryAddress string + MinWaitPeriodSeconds uint64 + TimelockAddress string + MCMSAddress string + TransferOwnershipTxHash string +} + +type DeployEthBalMonSequenceOutput struct { + Chains []DeployEthBalMonPerChainOutput +} + +var DeployEthBalMonSequence = operations.NewSequence( + "deploy-ethbalmon-sequence", + semver.MustParse("1.0.0"), + "Deploy ethereum balance monitor contracts and transfer ownership", + func(b operations.Bundle, deps VaultDeps, input DeployEthBalMonSequenceInput) (DeployEthBalMonSequenceOutput, error) { + b.Logger.Infow("Starting deploy ethbalmon contract sequence", + "chains", len(input.Chains), + ) + out := DeployEthBalMonSequenceOutput{ + Chains: []DeployEthBalMonPerChainOutput{}, + } + + evmChains := deps.Environment.BlockChains.EVMChains() + for chainSelector, chainConfig := range input.Chains { + _, ok := evmChains[chainSelector] + if !ok { + return DeployEthBalMonSequenceOutput{}, fmt.Errorf("chain not found in environment: %d", chainSelector) + } + var rawMinWait uint64 + if chainConfig.SetMinWaitPeriodSeconds != nil { + rawMinWait = *chainConfig.SetMinWaitPeriodSeconds + } + minWait := effectiveMinWaitPeriodSeconds(rawMinWait) + timelockAddr, err := mustGetContractAddress(deps.DataStore, chainSelector, commontypes.RBACTimelock) + if err != nil { + return DeployEthBalMonSequenceOutput{}, fmt.Errorf("chain %d: failed to get timelock address: %w", chainSelector, err) + } + mcmsAddr, err := mustGetContractAddress( + deps.DataStore, + chainSelector, + ethBalMonMCMSContractTypeForAction(deployEthBalMonAcceptOwnershipMCMSAction(input.MCMSConfig)), + ) + if err != nil { + return DeployEthBalMonSequenceOutput{}, fmt.Errorf("chain %d: failed to get mcms address: %w", chainSelector, err) + } + deployReport, err := operations.ExecuteOperation( + b, + DeployEthBalMonContractOperation, + deps, + DeployEthBalMonContractInput{ + ChainSelector: chainSelector, + KeeperRegistryAddress: chainConfig.SetKeeperRegistryAddress, + MinWaitPeriodSeconds: minWait, + }, + ) + if err != nil { + return DeployEthBalMonSequenceOutput{}, fmt.Errorf("chain %d: deploy operation failed: %w", chainSelector, err) + } + deployOut := deployReport.Output + transferReport, err := operations.ExecuteOperation( + b, + TransferOwnershipOperation, + deps, + TransferEthBalMonOwnershipInput{ + ChainSelector: chainSelector, + ContractAddress: deployOut.ContractAddress, + TimelockAddress: timelockAddr, + }, + ) + if err != nil { + return DeployEthBalMonSequenceOutput{}, fmt.Errorf("chain %d: transfer ownership operation failed: %w", chainSelector, err) + } + transferOut := transferReport.Output + + out.Chains = append(out.Chains, DeployEthBalMonPerChainOutput{ + ChainSelector: chainSelector, + ContractAddress: deployOut.ContractAddress, + DeployTxHash: deployOut.TxHash, + DeployBlockNumber: deployOut.BlockNumber, + KeeperRegistryAddress: deployOut.KeeperRegistryAddress, + MinWaitPeriodSeconds: deployOut.MinWaitPeriodSeconds, + TimelockAddress: timelockAddr, + MCMSAddress: mcmsAddr, + TransferOwnershipTxHash: transferOut.TxHash, + }) + } + return out, nil + }, +) + +// ================================================ +// ================================================ +// Deploy Ethereum Balance Monitor OPERATION +// ================================================ +// ================================================ + +type DeployEthBalMonContractInput struct { + ChainSelector uint64 `json:"chain_selector"` + KeeperRegistryAddress string `json:"keeper_registry_address"` + MinWaitPeriodSeconds uint64 `json:"min_wait_period_seconds"` +} + +type DeployEthBalMonContractOutput struct { + ChainSelector uint64 `json:"chain_selector"` + ContractAddress string `json:"contract_address"` + TxHash string `json:"tx_hash"` + BlockNumber uint64 `json:"block_number"` + KeeperRegistryAddress string `json:"keeper_registry_address"` + MinWaitPeriodSeconds uint64 `json:"min_wait_period_seconds"` +} + +var DeployEthBalMonContractOperation = operations.NewOperation( + "deploy-ethbalmon-contract", + semver.MustParse("1.0.0"), + "Deploy the Ethereum Balance Monitor contract", + func(b operations.Bundle, deps VaultDeps, input DeployEthBalMonContractInput) (DeployEthBalMonContractOutput, error) { + chain, ok := deps.Environment.BlockChains.EVMChains()[input.ChainSelector] + if !ok { + return DeployEthBalMonContractOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + keeperRegistryAddress := common.HexToAddress(input.KeeperRegistryAddress) + + b.Logger.Infow("Deploying EthBalanceMonitor", + "chainSelector", input.ChainSelector, + "keeperRegistryAddress", keeperRegistryAddress.Hex(), + "minWaitPeriodSeconds", input.MinWaitPeriodSeconds, + ) + + ethBalMonAddr, tx, _, err := eth_balance_monitor_wrapper.DeployEthBalanceMonitor( + chain.DeployerKey, + chain.Client, + keeperRegistryAddress, + new(big.Int).SetUint64(input.MinWaitPeriodSeconds), + ) + if err != nil { + return DeployEthBalMonContractOutput{}, fmt.Errorf("failed to deploy EthBalanceMonitor: %w", err) + } + + blockNumber, err := chain.Confirm(tx) + if err != nil { + return DeployEthBalMonContractOutput{}, fmt.Errorf("failed to confirm deploy tx %s: %w", tx.Hash().Hex(), err) + } + + out := DeployEthBalMonContractOutput{ + ChainSelector: input.ChainSelector, + ContractAddress: ethBalMonAddr.Hex(), + TxHash: tx.Hash().Hex(), + BlockNumber: blockNumber, + KeeperRegistryAddress: keeperRegistryAddress.Hex(), + MinWaitPeriodSeconds: input.MinWaitPeriodSeconds, + } + + b.Logger.Infow("EthBalanceMonitor deployed successfully", + "chainSelector", input.ChainSelector, + "contractAddress", out.ContractAddress, + "txHash", out.TxHash, + "blockNumber", out.BlockNumber, + ) + + return out, nil + + }, +) + +// ================================================ +// ================================================ +// Transfer ownership out of KMS OPERATION +// ================================================ +// ================================================ + +type TransferEthBalMonOwnershipInput struct { + ChainSelector uint64 `json:"chain_selector"` + ContractAddress string `json:"contract_address"` + TimelockAddress string `json:"timelock_address"` +} + +type TransferEthBalMonOwnershipOutput struct { + ChainSelector uint64 `json:"chain_selector"` + ContractAddress string `json:"contract_address"` + TimelockAddress string `json:"timelock_address"` + TxHash string `json:"tx_hash"` +} + +var TransferOwnershipOperation = operations.NewOperation( + "transfer-ownership", + semver.MustParse("1.0.0"), + "Transfer contract ownership out of KMS to Timelock", + func(b operations.Bundle, deps VaultDeps, input TransferEthBalMonOwnershipInput) (TransferEthBalMonOwnershipOutput, error) { + chain, ok := deps.Environment.BlockChains.EVMChains()[input.ChainSelector] + if !ok { + return TransferEthBalMonOwnershipOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + ethBalMon, err := eth_balance_monitor_wrapper.NewEthBalanceMonitor( + common.HexToAddress(input.ContractAddress), + chain.Client, + ) + if err != nil { + return TransferEthBalMonOwnershipOutput{}, fmt.Errorf("failed to instantiate EthBalanceMonitor at %s: %w", input.ContractAddress, err) + } + + b.Logger.Infow("Transferring EthBalanceMonitor ownership", + "chainSelector", input.ChainSelector, + "contractAddress", input.ContractAddress, + "timelockAddress", input.TimelockAddress, + ) + + tx, err := ethBalMon.TransferOwnership( + chain.DeployerKey, + common.HexToAddress(input.TimelockAddress), + ) + if err != nil { + return TransferEthBalMonOwnershipOutput{}, fmt.Errorf("failed to transfer ownership: %w", err) + } + + if _, err := chain.Confirm(tx); err != nil { + return TransferEthBalMonOwnershipOutput{}, fmt.Errorf("failed to confirm transfer ownership tx %s: %w", tx.Hash().Hex(), err) + } + + out := TransferEthBalMonOwnershipOutput{ + ChainSelector: input.ChainSelector, + ContractAddress: input.ContractAddress, + TimelockAddress: input.TimelockAddress, + TxHash: tx.Hash().Hex(), + } + + b.Logger.Infow("EthBalanceMonitor ownership transferred successfully", + "chainSelector", input.ChainSelector, + "contractAddress", input.ContractAddress, + "timelockAddress", input.TimelockAddress, + "txHash", out.TxHash, + ) + + return out, nil + + }, +) + +// ====================================================== +// ====================================================== +// Operation 3: Build accept ownership batch +// ====================================================== +// ====================================================== + +type AcceptOwnershipProposalInput struct { + ContractsByChain map[uint64]string + Description string + MCMSConfig proposalutils.TimelockConfig +} + +func BuildAcceptOwnershipTimelockProposal( + e cldf.Environment, + input AcceptOwnershipProposalInput, +) (*mcms.TimelockProposal, error) { + if len(input.ContractsByChain) == 0 { + return nil, errors.New("no contracts provided to build accept ownership proposal") + } + + var batches []mcmstypes.BatchOperation + timelockAddresses := make(map[uint64]string) + mcmAddressByChain := make(map[uint64]string) + + for chainSelector, contractAddr := range input.ContractsByChain { + chain, ok := e.BlockChains.EVMChains()[chainSelector] + if !ok { + return nil, fmt.Errorf("chain not found in environment: %d", chainSelector) + } + + timelockAddr, err := mustGetContractAddress( + e.DataStore, + chainSelector, + commontypes.RBACTimelock, + ) + if err != nil { + return nil, fmt.Errorf("chain %d: %w", chainSelector, err) + } + + mcmsAddr, err := mustGetContractAddress( + e.DataStore, + chainSelector, + ethBalMonMCMSContractTypeForProposal(&input.MCMSConfig), + ) + if err != nil { + return nil, fmt.Errorf("chain %d: %w", chainSelector, err) + } + + ethBalMon, err := eth_balance_monitor_wrapper.NewEthBalanceMonitor( + common.HexToAddress(contractAddr), + chain.Client, + ) + if err != nil { + return nil, fmt.Errorf("chain %d: failed to instantiate EthBalanceMonitor at %s: %w", chainSelector, contractAddr, err) + } + + acceptOwnershipTx, err := ethBalMon.AcceptOwnership(cldf.SimTransactOpts()) + if err != nil { + return nil, fmt.Errorf("chain %d: failed to generate acceptOwnership calldata: %w", chainSelector, err) + } + + batches = append(batches, mcmstypes.BatchOperation{ + ChainSelector: mcmstypes.ChainSelector(chainSelector), + Transactions: []mcmstypes.Transaction{ + { + OperationMetadata: mcmstypes.OperationMetadata{ + ContractType: vaulttypes.EthBalMonContractType, + Tags: []string{"acceptOwnership"}, + }, + To: contractAddr, + Data: acceptOwnershipTx.Data(), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }, + }, + }) + + timelockAddresses[chainSelector] = timelockAddr + mcmAddressByChain[chainSelector] = mcmsAddr + } + + description := input.Description + if description == "" { + description = "Accept ownership of EthBalanceMonitor across chains" + } + + tlCfg := input.MCMSConfig + proposal, err := proposalutils.BuildProposalFromBatchesV2( + e, + timelockAddresses, + mcmAddressByChain, + nil, + batches, + description, + tlCfg, + ) + if err != nil { + return nil, fmt.Errorf("failed to build timelock proposal: %w", err) + } + + return proposal, nil +} diff --git a/deployment/vault/changeset/ethbalmon_deploy_test.go b/deployment/vault/changeset/ethbalmon_deploy_test.go new file mode 100644 index 00000000000..ef29c07b9cb --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_deploy_test.go @@ -0,0 +1,699 @@ +package changeset + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + chainselectors "github.com/smartcontractkit/chain-selectors" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/eth_balance_monitor_wrapper" + + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +func TestDeployEthBalMonValidation(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 + config types.DeployEthBalMonInput + wantError bool + errorMsg string + setupMCMSIn bool + }{ + { + name: "empty chains", + config: types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{}, + }, + wantError: true, + errorMsg: "chains must not be empty", + }, + { + name: "unknown chain selector", + config: types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + math.MaxUint64: { + SetKeeperRegistryAddress: "0x1234567890123456789012345678901234567890", + }, + }, + }, + wantError: true, + errorMsg: fmt.Sprintf("unknown chain selector %d", uint64(math.MaxUint64)), + }, + { + name: "chain not in environment", + config: types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selectorOther: { + SetKeeperRegistryAddress: "0x1234567890123456789012345678901234567890", + }, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "empty setKeeperRegistryAddress", + config: types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: { + SetKeeperRegistryAddress: "", + }, + }, + }, + wantError: true, + errorMsg: "setKeeperRegistryAddress must not be empty", + }, + { + name: "invalid setKeeperRegistryAddress", + config: types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: { + SetKeeperRegistryAddress: "not-a-valid-address", + }, + }, + }, + wantError: true, + errorMsg: fmt.Sprintf("chain %d: setKeeperRegistryAddress is not a valid hex address: not-a-valid-address", selector), + }, + { + name: "missing MCMS and timelock in datastore", + config: types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: { + SetKeeperRegistryAddress: "0x1234567890123456789012345678901234567890", + }, + }, + }, + wantError: true, + errorMsg: "failed to get addresses from datastore", + }, + { + name: "valid config", + config: types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: { + SetKeeperRegistryAddress: "0x1234567890123456789012345678901234567890", + }, + }, + }, + wantError: false, + setupMCMSIn: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var testEnv cldf.Environment + if test.setupMCMSIn { + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + )) + require.NoError(t, err) + setupMCMSInfrastructure(t, rt, []uint64{selector}) + testEnv = rt.Environment() + } else { + testEnv = *env + } + + err := ValidateDeployEthBalMonConfig(testEnv.GetContext(), testEnv, test.config) + + if test.wantError { + require.Error(t, err) + if test.errorMsg != "" { + require.Contains(t, err.Error(), test.errorMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestBuildAcceptOwnershipTimelockProposal(t *testing.T) { + t.Parallel() + + t.Run("rejects empty contract set", func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + _, err = BuildAcceptOwnershipTimelockProposal(*env, AcceptOwnershipProposalInput{ + ContractsByChain: map[uint64]string{}, + Description: "test", + MCMSConfig: proposalutils.TimelockConfig{MinDelay: 0, MCMSAction: mcmstypes.TimelockActionBypass}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "no contracts provided") + }) + + t.Run("chain not in environment", func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + otherSel := chainselectors.TEST_90000002.Selector + _, err = BuildAcceptOwnershipTimelockProposal(*env, AcceptOwnershipProposalInput{ + ContractsByChain: map[uint64]string{ + otherSel: testAddr1, + }, + Description: "test", + MCMSConfig: proposalutils.TimelockConfig{MinDelay: 0, MCMSAction: mcmstypes.TimelockActionBypass}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "not found in environment") + }) + + t.Run("fails when MCMS or timelock missing from datastore", func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + + _, err = BuildAcceptOwnershipTimelockProposal(*env, AcceptOwnershipProposalInput{ + ContractsByChain: map[uint64]string{ + selector: testAddr1, + }, + Description: "test", + MCMSConfig: proposalutils.TimelockConfig{MinDelay: 0, MCMSAction: mcmstypes.TimelockActionBypass}, + }) + require.Error(t, err) + }) + + t.Run("builds proposal after deploy with custom description", func(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.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: {SetKeeperRegistryAddress: testAddr1}, + }, + } + out, err := DeployEthBalMonChangeSet.Apply(rt.Environment(), cfg) + require.NoError(t, err) + + contractsByChain := make(map[uint64]string, len(cfg.Chains)) + for sel := range cfg.Chains { + addr, err := GetContractAddress(out.DataStore, sel, cldf.ContractType(types.EthBalMonContractType)) + require.NoError(t, err) + contractsByChain[sel] = addr + } + + customDesc := "custom EthBalanceMonitor accept ownership proposal" + prop, err := BuildAcceptOwnershipTimelockProposal(rt.Environment(), AcceptOwnershipProposalInput{ + ContractsByChain: contractsByChain, + Description: customDesc, + MCMSConfig: proposalutils.TimelockConfig{MinDelay: 0, MCMSAction: mcmstypes.TimelockActionBypass}, + }) + require.NoError(t, err) + require.NotNil(t, prop) + require.Equal(t, customDesc, prop.Description) + require.Len(t, prop.Operations, len(cfg.Chains)) + }) + + t.Run("uses default description when empty", func(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.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: {SetKeeperRegistryAddress: testAddr1}, + }, + } + out, err := DeployEthBalMonChangeSet.Apply(rt.Environment(), cfg) + require.NoError(t, err) + + addr, err := GetContractAddress(out.DataStore, selector, cldf.ContractType(types.EthBalMonContractType)) + require.NoError(t, err) + + prop, err := BuildAcceptOwnershipTimelockProposal(rt.Environment(), AcceptOwnershipProposalInput{ + ContractsByChain: map[uint64]string{selector: addr}, + Description: "", + MCMSConfig: proposalutils.TimelockConfig{MinDelay: 0, MCMSAction: mcmstypes.TimelockActionBypass}, + }) + require.NoError(t, err) + require.Equal(t, "Accept ownership of EthBalanceMonitor across chains", prop.Description) + }) +} + +func TestDeployEthBalMonChangeset(t *testing.T) { + t.Parallel() + + t.Run("single chain", func(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}) + + customWait := uint64(120) + cfg := types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: { + SetKeeperRegistryAddress: testAddr1, + SetMinWaitPeriodSeconds: &customWait, + }, + }, + } + + require.NoError(t, DeployEthBalMonChangeSet.VerifyPreconditions(rt.Environment(), cfg)) + + out, err := DeployEthBalMonChangeSet.Apply(rt.Environment(), cfg) + require.NoError(t, err) + assertEthBalMonDeployOutput(t, rt.Environment(), out, cfg) + }) + + t.Run("default min wait when unset uses 60s", func(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.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: { + SetKeeperRegistryAddress: testAddr1, + }, + }, + } + + out, err := DeployEthBalMonChangeSet.Apply(rt.Environment(), cfg) + require.NoError(t, err) + + mds, err := out.DataStore.ContractMetadata().Fetch() + require.NoError(t, err) + require.Len(t, mds, 1) + mdMap := contractMetadataMap(t, mds[0].Metadata) + require.Equal(t, uint64(60), uint64FromAny(t, mdMap["minWaitPeriodSeconds"])) + }) + + t.Run("explicit zero min wait uses default 60s", func(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}) + + zero := uint64(0) + cfg := types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: { + SetKeeperRegistryAddress: testAddr1, + SetMinWaitPeriodSeconds: &zero, + }, + }, + } + + out, err := DeployEthBalMonChangeSet.Apply(rt.Environment(), cfg) + require.NoError(t, err) + + mds, err := out.DataStore.ContractMetadata().Fetch() + require.NoError(t, err) + require.Len(t, mds, 1) + mdMap := contractMetadataMap(t, mds[0].Metadata) + require.Equal(t, uint64(60), uint64FromAny(t, mdMap["minWaitPeriodSeconds"])) + }) + + t.Run("multiple chains", func(t *testing.T) { + t.Parallel() + + selector1 := chainselectors.TEST_90000001.Selector + selector2 := chainselectors.TEST_90000002.Selector + selectors := []uint64{selector1, selector2} + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, selectors), + )) + require.NoError(t, err) + + setupMCMSInfrastructure(t, rt, selectors) + fundDeployerAccounts(t, rt.Environment(), selectors) + + cfg := types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector1: {SetKeeperRegistryAddress: testAddr1}, + selector2: {SetKeeperRegistryAddress: testAddr2}, + }, + } + + out, err := DeployEthBalMonChangeSet.Apply(rt.Environment(), cfg) + require.NoError(t, err) + assertEthBalMonDeployOutput(t, rt.Environment(), out, cfg) + }) + + t.Run("verify preconditions rejects invalid config", func(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) + + err = DeployEthBalMonChangeSet.VerifyPreconditions(rt.Environment(), types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "chains must not be empty") + }) + + t.Run("verify preconditions rejects chain not in environment", func(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_90000001.Selector + otherSel := chainselectors.TEST_90000002.Selector + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + )) + require.NoError(t, err) + + err = DeployEthBalMonChangeSet.VerifyPreconditions(rt.Environment(), types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + otherSel: {SetKeeperRegistryAddress: testAddr1}, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "not found in environment") + }) + + t.Run("apply without MCMS infrastructure fails", func(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) + + fundDeployerAccounts(t, rt.Environment(), []uint64{selector}) + + cfg := types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: {SetKeeperRegistryAddress: testAddr1}, + }, + } + + _, err = DeployEthBalMonChangeSet.Apply(rt.Environment(), cfg) + require.Error(t, err) + require.ErrorContains(t, err, "timelock") + }) +} + +func TestDeployEthBalMon_RuntimeChangesetTask(t *testing.T) { + t.Parallel() + + t.Run("exec succeeds and merges EthBalMon into runtime datastore", func(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.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector: {SetKeeperRegistryAddress: testAddr1}, + }, + } + + task := runtime.ChangesetTask(DeployEthBalMonChangeSet, cfg) + err = rt.Exec(task) + require.NoError(t, err) + + _, hasOutput := rt.State().Outputs[task.ID()] + require.True(t, hasOutput, "CLDF runtime should store ChangesetOutput under the task id") + + out := rt.State().Outputs[task.ID()] + require.NotNil(t, out.DataStore) + require.NotEmpty(t, out.MCMSTimelockProposals) + + records := rt.State().DataStore.Addresses().Filter( + datastore.AddressRefByChainSelector(selector), + datastore.AddressRefByType(datastore.ContractType(types.EthBalMonContractType)), + ) + require.Len(t, records, 1) + labelSet := records[0].Labels.List() + require.Contains(t, labelSet, types.EthBalMonContractType) + require.Contains(t, labelSet, "EthBalMonV1_0_0") + + assertEthBalMonDeployOutput(t, rt.Environment(), out, cfg) + }) + + t.Run("exec fails on invalid precondition", func(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}) + + task := runtime.ChangesetTask(DeployEthBalMonChangeSet, types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{}, + }) + err = rt.Exec(task) + require.Error(t, err) + require.Contains(t, err.Error(), "chains must not be empty") + }) + + t.Run("multiple chains via runtime task", func(t *testing.T) { + t.Parallel() + + selector1 := chainselectors.TEST_90000001.Selector + selector2 := chainselectors.TEST_90000002.Selector + selectors := []uint64{selector1, selector2} + + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, selectors), + )) + require.NoError(t, err) + + setupMCMSInfrastructure(t, rt, selectors) + fundDeployerAccounts(t, rt.Environment(), selectors) + + cfg := types.DeployEthBalMonInput{ + Chains: map[uint64]types.DeployEthBalMonChainConfig{ + selector1: {SetKeeperRegistryAddress: testAddr1}, + selector2: {SetKeeperRegistryAddress: testAddr2}, + }, + } + + task := runtime.ChangesetTask(DeployEthBalMonChangeSet, cfg) + require.NoError(t, rt.Exec(task)) + + out := rt.State().Outputs[task.ID()] + assertEthBalMonDeployOutput(t, rt.Environment(), out, cfg) + }) +} + +// assertEthBalMonDeployOutput checks datastore, on-chain owner (post transferOwnership, pre-accept), +// and the accept-ownership timelock proposal. +func assertEthBalMonDeployOutput( + t *testing.T, + env cldf.Environment, + out cldf.ChangesetOutput, + cfg types.DeployEthBalMonInput, +) { + t.Helper() + + n := len(cfg.Chains) + require.NotNil(t, out.DataStore) + + addrs, err := out.DataStore.Addresses().Fetch() + require.NoError(t, err) + require.Len(t, addrs, n) + + mds, err := out.DataStore.ContractMetadata().Fetch() + require.NoError(t, err) + require.Len(t, mds, n) + + bySel := make(map[uint64]datastore.ContractMetadata) + for _, m := range mds { + bySel[m.ChainSelector] = m + } + + for sel, chainCfg := range cfg.Chains { + timelockAddr, err := GetContractAddress(env.DataStore, sel, commontypes.RBACTimelock) + require.NoError(t, err) + mcmsType := ethBalMonMCMSContractTypeForAction(deployEthBalMonAcceptOwnershipMCMSAction(cfg.MCMSConfig)) + mcmsAddr, err := GetContractAddress(env.DataStore, sel, mcmsType) + require.NoError(t, err) + + meta, ok := bySel[sel] + require.True(t, ok, "missing contract metadata for chain %d", sel) + require.NotEmpty(t, meta.Address) + + md := contractMetadataMap(t, meta.Metadata) + require.Equal(t, chainCfg.SetKeeperRegistryAddress, md["keeperRegistryAddress"]) + + minWait := chainCfgMinWaitForEffective(chainCfg) + require.Equal(t, effectiveMinWaitPeriodSeconds(minWait), uint64FromAny(t, md["minWaitPeriodSeconds"])) + + require.NotEmpty(t, md["deployTxHash"]) + require.NotZero(t, uint64FromAny(t, md["deployBlockNumber"])) + require.Equal(t, timelockAddr, md["timelockAddress"]) + require.Equal(t, mcmsAddr, md["mcmsAddress"]) + require.NotEmpty(t, md["transferOwnershipTxHash"]) + + ebmAddr, err := GetContractAddress(out.DataStore, sel, cldf.ContractType(types.EthBalMonContractType)) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[sel] + c, err := eth_balance_monitor_wrapper.NewEthBalanceMonitor(common.HexToAddress(ebmAddr), chain.Client) + require.NoError(t, err) + owner, err := c.Owner(nil) + require.NoError(t, err) + require.Equal(t, chain.DeployerKey.From, owner, + "ConfirmedOwner keeps owner until timelock calls acceptOwnership") + } + + require.Len(t, out.MCMSTimelockProposals, 1) + prop := out.MCMSTimelockProposals[0] + require.Contains(t, prop.Description, "EthBalanceMonitor") + require.Len(t, prop.Operations, n) + + seen := make(map[uint64]bool) + for _, op := range prop.Operations { + sel := uint64(op.ChainSelector) + seen[sel] = true + require.Len(t, op.Transactions, 1) + tx := op.Transactions[0] + require.Equal(t, types.EthBalMonContractType, tx.ContractType) + require.Contains(t, tx.Tags, "acceptOwnership") + + wantContract, err := GetContractAddress(out.DataStore, sel, cldf.ContractType(types.EthBalMonContractType)) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(wantContract), common.HexToAddress(tx.To)) + } + + for sel := range cfg.Chains { + require.True(t, seen[sel], "proposal missing operation for chain %d", sel) + } +} + +func chainCfgMinWaitForEffective(c types.DeployEthBalMonChainConfig) uint64 { + if c.SetMinWaitPeriodSeconds == nil { + return 0 + } + return *c.SetMinWaitPeriodSeconds +} + +func contractMetadataMap(t *testing.T, raw any) map[string]any { + t.Helper() + m, ok := raw.(map[string]any) + require.True(t, ok, "expected metadata map[string]any, got %T", raw) + return m +} + +func uint64FromAny(t *testing.T, v any) uint64 { + t.Helper() + require.NotNil(t, v) + switch x := v.(type) { + case uint64: + return x + case uint: + return uint64(x) + case uint32: + return uint64(x) + case int: + require.GreaterOrEqual(t, x, 0) + u, err := strconv.ParseUint(strconv.Itoa(x), 10, 64) + require.NoError(t, err) + return u + case int64: + require.GreaterOrEqual(t, x, int64(0)) + u, err := strconv.ParseUint(strconv.FormatInt(x, 10), 10, 64) + require.NoError(t, err) + return u + case float64: + return uint64(x) + case json.Number: + i, err := x.Int64() + require.NoError(t, err) + require.GreaterOrEqual(t, i, int64(0)) + u, err := strconv.ParseUint(strconv.FormatInt(i, 10), 10, 64) + require.NoError(t, err) + return u + case string: + u, err := strconv.ParseUint(x, 10, 64) + require.NoError(t, err) + return u + default: + require.Failf(t, "unexpected type for uint64 metadata field", "%T %#v", v, v) + return 0 + } +} diff --git a/deployment/vault/changeset/ethbalmon_mcms.go b/deployment/vault/changeset/ethbalmon_mcms.go new file mode 100644 index 00000000000..714970526f6 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_mcms.go @@ -0,0 +1,56 @@ +package changeset + +import ( + mcmstypes "github.com/smartcontractkit/mcms/types" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" +) + +// ethBalMonMCMSContractTypeForAction selects the MCMS contract type in the datastore for EthBalMon +// timelock proposals, matching operations.generateMCMSProposals. +func ethBalMonMCMSContractTypeForAction(action mcmstypes.TimelockAction) cldf.ContractType { + if action == mcmstypes.TimelockActionBypass { + return commontypes.BypasserManyChainMultisig + } + return commontypes.ProposerManyChainMultisig +} + +// ethBalMonMCMSContractTypeForProposal resolves MCMS datastore type from optional timelock config. +// When cfg is nil or MCMSAction is empty, the action is treated as schedule (non-bypass), so the proposer MCM is used. +func ethBalMonMCMSContractTypeForProposal(cfg *proposalutils.TimelockConfig) cldf.ContractType { + action := mcmstypes.TimelockActionSchedule + if cfg != nil && cfg.MCMSAction != "" { + action = cfg.MCMSAction + } + return ethBalMonMCMSContractTypeForAction(action) +} + +func ethBalMonProposalTimelockConfig(cfg *proposalutils.TimelockConfig) proposalutils.TimelockConfig { + if cfg == nil { + return proposalutils.TimelockConfig{MinDelay: 0} + } + return *cfg +} + +// deployEthBalMonAcceptOwnershipMCMSAction returns the MCMS timelock action for the post-deploy accept-ownership proposal. +// When cfg is nil or MCMSAction is unset, the deploy flow defaults to bypass. +func deployEthBalMonAcceptOwnershipMCMSAction(cfg *proposalutils.TimelockConfig) mcmstypes.TimelockAction { + if cfg == nil || cfg.MCMSAction == "" { + return mcmstypes.TimelockActionBypass + } + return cfg.MCMSAction +} + +func deployEthBalMonAcceptOwnershipTimelockConfig(cfg *proposalutils.TimelockConfig) proposalutils.TimelockConfig { + out := proposalutils.TimelockConfig{MinDelay: 0} + if cfg != nil { + out = *cfg + } + if out.MCMSAction == "" { + out.MCMSAction = mcmstypes.TimelockActionBypass + } + return out +} diff --git a/deployment/vault/changeset/ethbalmon_set_keeper_registry_address.go b/deployment/vault/changeset/ethbalmon_set_keeper_registry_address.go new file mode 100644 index 00000000000..f47d3fcf009 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_set_keeper_registry_address.go @@ -0,0 +1,257 @@ +package changeset + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/eth_balance_monitor_wrapper" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + vaulttypes "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +type setKeeperRegistryAddress struct{} + +var SetKeeperRegistryAddress cldf.ChangeSetV2[vaulttypes.EthBalMonSetKeeperRegistryAddressInput] = setKeeperRegistryAddress{} + +func (sk setKeeperRegistryAddress) VerifyPreconditions(env cldf.Environment, config vaulttypes.EthBalMonSetKeeperRegistryAddressInput) error { + return ValidateSetKeeperRegistryAddressConfig(env.GetContext(), env, config) +} + +func (sk setKeeperRegistryAddress) Apply( + e cldf.Environment, + config vaulttypes.EthBalMonSetKeeperRegistryAddressInput, +) (cldf.ChangesetOutput, error) { + logger := e.Logger + logger.Infow("Generating SetKeeperRegistryAddress proposal for Ethereum Balance Monitor", + "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 := EthBalMonSetKeeperRegistryAddressSequenceInput{ + Chains: config.Chains, + MCMSConfig: config.MCMSConfig, + } + + seqReport, err := operations.ExecuteSequence( + e.OperationsBundle, + SetKeeperRegistrySequence, + deps, + seqInput, + ) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to set keeper registry address sequence: %w", err) + } + + return cldf.ChangesetOutput{ + MCMSTimelockProposals: seqReport.Output.MCMSTimelockProposals, + }, nil +} + +type EthBalMonSetKeeperRegistryAddressSequenceInput struct { + Chains map[uint64]vaulttypes.SetKeeperRegistryChainConfig `json:"chains"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonSetKeeperRegistryAddressSequenceOutput struct { + MCMSTimelockProposals []mcms.TimelockProposal +} + +var SetKeeperRegistrySequence = operations.NewSequence( + "ethbalmon-set-keeper-registry", + semver.MustParse("1.0.0"), + "Generate MCMS timelock proposal to set Keeper Registry address on EthBalMon across chains", + func( + b operations.Bundle, + deps VaultDeps, + input EthBalMonSetKeeperRegistryAddressSequenceInput, + ) (EthBalMonSetKeeperRegistryAddressSequenceOutput, error) { + b.Logger.Infow("Starting EthBalMon set keeper registry sequence", + "chains", len(input.Chains), + ) + + if len(input.Chains) == 0 { + return EthBalMonSetKeeperRegistryAddressSequenceOutput{}, errors.New("no chains provided") + } + + var batches []mcmstypes.BatchOperation + timelockAddresses := make(map[uint64]string) + mcmAddressByChain := make(map[uint64]string) + + for chainSelector, chainConfig := range input.Chains { + opReport, err := operations.ExecuteOperation( + b, + SetKeeperRegistryOperation, + deps, + SetKeeperRegistryOperationInput{ + ChainSelector: chainSelector, + NewKeeperRegistryAddress: chainConfig.NewKeeperRegistryAddress, + MCMSConfig: input.MCMSConfig, + }, + ) + if err != nil { + return EthBalMonSetKeeperRegistryAddressSequenceOutput{}, + fmt.Errorf("chain %d: failed to generate set keeper registry batch: %w", chainSelector, err) + } + + opOut := opReport.Output + + batches = append(batches, opOut.BatchOperation) + timelockAddresses[chainSelector] = opOut.TimelockAddress + mcmAddressByChain[chainSelector] = opOut.MCMSAddress + } + + proposal, err := proposalutils.BuildProposalFromBatchesV2( + deps.Environment, + timelockAddresses, + mcmAddressByChain, + nil, + batches, + "EthBalMon SetKeeperRegistryAddress", + ethBalMonProposalTimelockConfig(input.MCMSConfig), + ) + if err != nil { + return EthBalMonSetKeeperRegistryAddressSequenceOutput{}, + fmt.Errorf("failed to build timelock proposal: %w", err) + } + + b.Logger.Infow("Generated EthBalMon set keeper registry proposal", + "chains", len(input.Chains), + "operations", len(batches), + ) + + return EthBalMonSetKeeperRegistryAddressSequenceOutput{ + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil + }, +) + +type SetKeeperRegistryOperationInput struct { + ChainSelector uint64 `json:"chain_selector"` + NewKeeperRegistryAddress string `json:"new_keeper_registry_address"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type SetKeeperRegistryOperationOutput struct { + ChainSelector uint64 `json:"chain_selector"` + BatchOperation mcmstypes.BatchOperation `json:"batch_operation"` + TimelockAddress string `json:"timelock_address"` + MCMSAddress string `json:"mcms_address"` +} + +var SetKeeperRegistryOperation = operations.NewOperation( + "ethbalmon-set-keeper-registry-op", + semver.MustParse("1.0.0"), + "Generate batch operation to set Keeper Registry address on the Ethereum Balance Monitor contract", + func( + b operations.Bundle, + deps VaultDeps, + input SetKeeperRegistryOperationInput, + ) (SetKeeperRegistryOperationOutput, error) { + chain, ok := deps.Environment.BlockChains.EVMChains()[input.ChainSelector] + if !ok { + return SetKeeperRegistryOperationOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + ethBalMonAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + cldf.ContractType(vaulttypes.EthBalMonContractType), + ) + if err != nil { + return SetKeeperRegistryOperationOutput{}, + fmt.Errorf("failed to get EthBalMon address: %w", err) + } + + timelockAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + commontypes.RBACTimelock, + ) + if err != nil { + return SetKeeperRegistryOperationOutput{}, + fmt.Errorf("failed to get timelock address: %w", err) + } + + mcmsAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + ethBalMonMCMSContractTypeForProposal(input.MCMSConfig), + ) + if err != nil { + return SetKeeperRegistryOperationOutput{}, + 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 SetKeeperRegistryOperationOutput{}, + fmt.Errorf("failed to instantiate EthBalanceMonitor at %s: %w", ethBalMonAddr, err) + } + + setKeeperRegistryTx, err := ethBalMon.SetKeeperRegistryAddress( + cldf.SimTransactOpts(), + common.HexToAddress(input.NewKeeperRegistryAddress), + ) + if err != nil { + return SetKeeperRegistryOperationOutput{}, + fmt.Errorf("failed to generate setKeeperRegistryAddress calldata: %w", err) + } + + batch := mcmstypes.BatchOperation{ + ChainSelector: mcmstypes.ChainSelector(input.ChainSelector), + Transactions: []mcmstypes.Transaction{ + { + OperationMetadata: mcmstypes.OperationMetadata{ + ContractType: vaulttypes.EthBalMonContractType, + Tags: []string{ + "setKeeperRegistryAddress", + }, + }, + To: ethBalMonAddr, + Data: setKeeperRegistryTx.Data(), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }, + }, + } + + b.Logger.Infow("Generated EthBalMon set keeper registry batch", + "chainSelector", input.ChainSelector, + "ethBalMon", ethBalMonAddr, + "newKeeperRegistry", input.NewKeeperRegistryAddress, + ) + + return SetKeeperRegistryOperationOutput{ + ChainSelector: input.ChainSelector, + BatchOperation: batch, + TimelockAddress: timelockAddr, + MCMSAddress: mcmsAddr, + }, nil + }, +) diff --git a/deployment/vault/changeset/ethbalmon_set_keeper_registry_address_test.go b/deployment/vault/changeset/ethbalmon_set_keeper_registry_address_test.go new file mode 100644 index 00000000000..00e87d357f9 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_set_keeper_registry_address_test.go @@ -0,0 +1,155 @@ +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-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-deployments-framework/operations/optest" + + "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +func TestValidateSetKeeperRegistryAddressConfig(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.EthBalMonSetKeeperRegistryAddressInput + wantError bool + errorMsg string + }{ + { + name: "empty chains", + cfg: types.EthBalMonSetKeeperRegistryAddressInput{Chains: map[uint64]types.SetKeeperRegistryChainConfig{}}, + wantError: true, + errorMsg: "no chains provided", + }, + { + name: "unknown chain selector", + cfg: types.EthBalMonSetKeeperRegistryAddressInput{ + Chains: map[uint64]types.SetKeeperRegistryChainConfig{ + math.MaxUint64: {NewKeeperRegistryAddress: testAddr1}, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "chain not in environment", + cfg: types.EthBalMonSetKeeperRegistryAddressInput{ + Chains: map[uint64]types.SetKeeperRegistryChainConfig{ + selectorOther: {NewKeeperRegistryAddress: testAddr1}, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "invalid keeper registry address", + cfg: types.EthBalMonSetKeeperRegistryAddressInput{ + Chains: map[uint64]types.SetKeeperRegistryChainConfig{ + selector: {NewKeeperRegistryAddress: "not-hex"}, + }, + }, + wantError: true, + errorMsg: "new_keeper_registry_address", + }, + { + name: "zero keeper registry address", + cfg: types.EthBalMonSetKeeperRegistryAddressInput{ + Chains: map[uint64]types.SetKeeperRegistryChainConfig{ + selector: {NewKeeperRegistryAddress: zeroAddr}, + }, + }, + wantError: true, + errorMsg: "cannot be zero address", + }, + { + name: "valid", + cfg: types.EthBalMonSetKeeperRegistryAddressInput{ + Chains: map[uint64]types.SetKeeperRegistryChainConfig{ + selector: {NewKeeperRegistryAddress: testAddr1}, + }, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateSetKeeperRegistryAddressConfig(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 TestSetKeeperRegistrySequence_noChains(t *testing.T) { + t.Parallel() + + b := optest.NewBundle(t) + _, err := operations.ExecuteSequence(b, SetKeeperRegistrySequence, VaultDeps{}, EthBalMonSetKeeperRegistryAddressSequenceInput{}) + require.Error(t, err) + require.Contains(t, err.Error(), "no chains provided") +} + +func TestSetKeeperRegistryAddressChangeset(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.EthBalMonSetKeeperRegistryAddressInput{ + Chains: map[uint64]types.SetKeeperRegistryChainConfig{ + selector: {NewKeeperRegistryAddress: testAddr2}, + }, + } + require.NoError(t, SetKeeperRegistryAddress.VerifyPreconditions(rt.Environment(), cfg)) + + keeperTask := runtime.ChangesetTask(SetKeeperRegistryAddress, cfg) + require.NoError(t, rt.Exec(keeperTask)) + + out := rt.State().Outputs[keeperTask.ID()] + require.NotEmpty(t, out.MCMSTimelockProposals) + prop := out.MCMSTimelockProposals[0] + require.Contains(t, prop.Description, "EthBalMon SetKeeperRegistryAddress") + require.Len(t, prop.Operations, 1) + require.Len(t, prop.Operations[0].Transactions, 1) + require.Contains(t, prop.Operations[0].Transactions[0].Tags, "setKeeperRegistryAddress") +} diff --git a/deployment/vault/changeset/ethbalmon_set_watchlist.go b/deployment/vault/changeset/ethbalmon_set_watchlist.go new file mode 100644 index 00000000000..4528485ea54 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_set_watchlist.go @@ -0,0 +1,220 @@ +package changeset + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/eth_balance_monitor_wrapper" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + vaulttypes "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +type ethBalMonSetWatchList struct{} + +var EthBalMonSetWatchList cldf.ChangeSetV2[vaulttypes.EthBalMonSetWatchListInput] = ethBalMonSetWatchList{} + +func (sw ethBalMonSetWatchList) VerifyPreconditions(env cldf.Environment, config vaulttypes.EthBalMonSetWatchListInput) error { + return ValidateEthBalMonSetWatchListConfig(env.GetContext(), env, config) +} + +func (sw ethBalMonSetWatchList) Apply(e cldf.Environment, config vaulttypes.EthBalMonSetWatchListInput) (cldf.ChangesetOutput, error) { + logger := e.Logger + logger.Infow("Generating EthBalMon setWatchList 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 := EthBalMonSetWatchListSeqInput{ + Chains: config.Chains, + MCMSConfig: config.MCMSConfig, + } + + seqReport, err := operations.ExecuteSequence(e.OperationsBundle, EthBalMonSetWatchListSequence, deps, seqInput) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed on EthBalMonSetWatchListSequence sequence: %w", err) + } + + return cldf.ChangesetOutput{ + MCMSTimelockProposals: seqReport.Output.MCMSTimelockProposals, + }, nil +} + +type EthBalMonSetWatchListSeqInput struct { + Chains map[uint64]vaulttypes.EthBalMonSetWatchListChainConfig `json:"chains"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonSetWatchListSeqOutput struct { + MCMSTimelockProposals []mcms.TimelockProposal `json:"mcms_timelock_proposals"` +} + +var EthBalMonSetWatchListSequence = operations.NewSequence( + "ethbalmon-setWatchList-sequence", + semver.MustParse("1.0.0"), + "Sequence to create operations for EthBalMon setWatchList", + func(b operations.Bundle, deps VaultDeps, input EthBalMonSetWatchListSeqInput) (EthBalMonSetWatchListSeqOutput, error) { + b.Logger.Infow("Starting EthBalMon setWatchList sequence", + "chains", len(input.Chains), + ) + var batches []mcmstypes.BatchOperation + timelockAddresses := make(map[uint64]string) + mcmAddressByChain := make(map[uint64]string) + + for chainSelector, chainConfig := range input.Chains { + opReport, err := operations.ExecuteOperation(b, EthBalMonSetWatchListOperation, deps, EthBalMonSetWatchListOpInput{ + ChainSelector: chainSelector, + Addresses: chainConfig.Addresses, + MinBalancesWei: chainConfig.MinBalancesWei, + TopUpAmountsWei: chainConfig.TopUpAmountsWei, + MCMSConfig: input.MCMSConfig, + }) + if err != nil { + return EthBalMonSetWatchListSeqOutput{}, fmt.Errorf("chain %d: failed to generate setWatchList batch: %w", chainSelector, err) + } + opOutput := opReport.Output + + batches = append(batches, opOutput.BatchOperation) + timelockAddresses[chainSelector] = opOutput.TimelockAddress + mcmAddressByChain[chainSelector] = opOutput.MCMSAddress + } + + proposal, err := proposalutils.BuildProposalFromBatchesV2(deps.Environment, timelockAddresses, mcmAddressByChain, nil, batches, "EthBalMon SetWatchList", ethBalMonProposalTimelockConfig(input.MCMSConfig)) + + if err != nil { + return EthBalMonSetWatchListSeqOutput{}, fmt.Errorf("failed to build timelock proposal: %w", err) + } + b.Logger.Infow("Generated EthBalMon setWatchList proposal", + "chains", len(input.Chains), "operations", len(batches)) + + return EthBalMonSetWatchListSeqOutput{ + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil + }, +) + +type EthBalMonSetWatchListOpInput struct { + ChainSelector uint64 `json:"chain_selector"` + Addresses []common.Address `json:"addresses"` + MinBalancesWei []*big.Int `json:"min_balance_wei"` + TopUpAmountsWei []*big.Int `json:"topup_amounts_wei"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonSetWatchListOpOutput struct { + ChainSelector uint64 `json:"chain_selector"` + BatchOperation mcmstypes.BatchOperation `json:"batch_operation"` + TimelockAddress string `json:"timelock_address"` + MCMSAddress string `json:"mcms_address"` +} + +var EthBalMonSetWatchListOperation = operations.NewOperation( + "ethbalmon-setWatchList-operation", + semver.MustParse("1.0.0"), + "Operation to create transaction batch for EthBalMon setWatchList", + func(b operations.Bundle, deps VaultDeps, input EthBalMonSetWatchListOpInput) (EthBalMonSetWatchListOpOutput, error) { + b.Logger.Infow("Starting EthBalMon setWatchList operation", + "chainsel", input.ChainSelector, + "addresses", len(input.Addresses), + ) + chain, ok := deps.Environment.BlockChains.EVMChains()[input.ChainSelector] + + if !ok { + return EthBalMonSetWatchListOpOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + ethBalMonAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + cldf.ContractType(vaulttypes.EthBalMonContractType), + ) + if err != nil { + return EthBalMonSetWatchListOpOutput{}, + fmt.Errorf("failed to get EthBalMon address: %w", err) + } + + timelockAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + commontypes.RBACTimelock, + ) + if err != nil { + return EthBalMonSetWatchListOpOutput{}, + fmt.Errorf("failed to get timelock address: %w", err) + } + mcmsAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + ethBalMonMCMSContractTypeForProposal(input.MCMSConfig), + ) + if err != nil { + return EthBalMonSetWatchListOpOutput{}, + 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 EthBalMonSetWatchListOpOutput{}, + fmt.Errorf("failed to instantiate EthBalanceMonitor at %s: %w", ethBalMonAddr, err) + } + + setWatchListTx, err := ethBalMon.SetWatchList(cldf.SimTransactOpts(), input.Addresses, input.MinBalancesWei, input.TopUpAmountsWei) + if err != nil { + return EthBalMonSetWatchListOpOutput{}, fmt.Errorf("failed to generate setWatchList 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{ + "setWatchList", + }, + }, + To: ethBalMonAddr, + Data: setWatchListTx.Data(), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }, + }, + } + + b.Logger.Infow("Generated EthBalMon setWatchlist batch", + "chainSelector", input.ChainSelector, + "ethBalMon", ethBalMonAddr, + "newWatchList", input.Addresses, + ) + + return EthBalMonSetWatchListOpOutput{ + ChainSelector: input.ChainSelector, + BatchOperation: batch, + TimelockAddress: timelockAddr, + MCMSAddress: mcmsAddr, + }, nil + }, +) diff --git a/deployment/vault/changeset/ethbalmon_set_watchlist_test.go b/deployment/vault/changeset/ethbalmon_set_watchlist_test.go new file mode 100644 index 00000000000..7e4b932aad3 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_set_watchlist_test.go @@ -0,0 +1,247 @@ +package changeset + +import ( + "math" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "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 TestValidateEthBalMonSetWatchListConfig(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) + + addr := common.HexToAddress(testAddr1) + minOne := big.NewInt(1) + topOne := big.NewInt(1) + neg := big.NewInt(-1) + + tests := []struct { + name string + cfg types.EthBalMonSetWatchListInput + wantError bool + errorMsg string + }{ + { + name: "empty chains", + cfg: types.EthBalMonSetWatchListInput{Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{}}, + wantError: true, + errorMsg: "no chains provided", + }, + { + name: "unknown chain selector", + cfg: types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + math.MaxUint64: { + Addresses: []common.Address{addr}, + MinBalancesWei: []*big.Int{minOne}, + TopUpAmountsWei: []*big.Int{topOne}, + }, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "chain not in environment", + cfg: types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selectorOther: { + Addresses: []common.Address{addr}, + MinBalancesWei: []*big.Int{minOne}, + TopUpAmountsWei: []*big.Int{topOne}, + }, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "empty addresses", + cfg: types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selector: { + Addresses: nil, + MinBalancesWei: nil, + TopUpAmountsWei: nil, + }, + }, + }, + wantError: true, + errorMsg: "addresses must not be empty", + }, + { + name: "slice length mismatch", + cfg: types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selector: { + Addresses: []common.Address{addr, addr}, + MinBalancesWei: []*big.Int{minOne}, + TopUpAmountsWei: []*big.Int{topOne, topOne}, + }, + }, + }, + wantError: true, + errorMsg: "must have the same length", + }, + { + name: "negative min balance", + cfg: types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selector: { + Addresses: []common.Address{addr}, + MinBalancesWei: []*big.Int{neg}, + TopUpAmountsWei: []*big.Int{topOne}, + }, + }, + }, + wantError: true, + errorMsg: "min_balance_wei at index 0 must be >= 0", + }, + { + name: "negative top-up", + cfg: types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selector: { + Addresses: []common.Address{addr}, + MinBalancesWei: []*big.Int{minOne}, + TopUpAmountsWei: []*big.Int{neg}, + }, + }, + }, + wantError: true, + errorMsg: "topup_amounts_wei at index 0 must be >= 0", + }, + { + name: "valid", + cfg: types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selector: { + Addresses: []common.Address{addr}, + MinBalancesWei: []*big.Int{minOne}, + TopUpAmountsWei: []*big.Int{topOne}, + }, + }, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateEthBalMonSetWatchListConfig(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 TestEthBalMonSetWatchListChangeset(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)) + + watchAddr := common.HexToAddress(testAddr2) + cfg := types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selector: { + Addresses: []common.Address{watchAddr}, + MinBalancesWei: []*big.Int{big.NewInt(1)}, + TopUpAmountsWei: []*big.Int{big.NewInt(2)}, + }, + }, + } + require.NoError(t, EthBalMonSetWatchList.VerifyPreconditions(rt.Environment(), cfg)) + + watchTask := runtime.ChangesetTask(EthBalMonSetWatchList, cfg) + require.NoError(t, rt.Exec(watchTask)) + + out := rt.State().Outputs[watchTask.ID()] + require.NotEmpty(t, out.MCMSTimelockProposals) + prop := out.MCMSTimelockProposals[0] + require.Contains(t, prop.Description, "EthBalMon SetWatchList") + require.Len(t, prop.Operations, 1) + require.Len(t, prop.Operations[0].Transactions, 1) + require.Contains(t, prop.Operations[0].Transactions[0].Tags, "setWatchList") +} + +func TestEthBalMonSetWatchList_VerifyPreconditions_rejectsEmptyChains(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) + + err = EthBalMonSetWatchList.VerifyPreconditions(rt.Environment(), types.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "no chains provided") +} + +func TestEthBalMonSetWatchList_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.EthBalMonSetWatchListInput{ + Chains: map[uint64]types.EthBalMonSetWatchListChainConfig{ + selector: { + Addresses: []common.Address{common.HexToAddress(testAddr1)}, + MinBalancesWei: []*big.Int{big.NewInt(1)}, + TopUpAmountsWei: []*big.Int{big.NewInt(1)}, + }, + }, + } + require.NoError(t, EthBalMonSetWatchList.VerifyPreconditions(rt.Environment(), cfg)) + + _, err = EthBalMonSetWatchList.Apply(rt.Environment(), cfg) + require.Error(t, err) +} diff --git a/deployment/vault/changeset/ethbalmon_transfer_ownership.go b/deployment/vault/changeset/ethbalmon_transfer_ownership.go new file mode 100644 index 00000000000..a188c916695 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_transfer_ownership.go @@ -0,0 +1,208 @@ +package changeset + +import ( + "encoding/json" + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/eth_balance_monitor_wrapper" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + vaulttypes "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +type ethBalMonTransferOwnership struct{} + +var EthBalMonTransferOwnership cldf.ChangeSetV2[vaulttypes.EthBalMonTransferOwnershipInput] = ethBalMonTransferOwnership{} + +func (tw ethBalMonTransferOwnership) VerifyPreconditions(env cldf.Environment, config vaulttypes.EthBalMonTransferOwnershipInput) error { + return ValidateEthBalMonTransferOwnershipConfig(env.GetContext(), env, config) +} + +func (tw ethBalMonTransferOwnership) Apply(e cldf.Environment, config vaulttypes.EthBalMonTransferOwnershipInput) (cldf.ChangesetOutput, error) { + logger := e.Logger + logger.Infow("Generating EthBalMon transferOwnership 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 := EthBalMonTransferOwnershipSeqInput{ + Chains: config.Chains, + MCMSConfig: config.MCMSConfig, + } + seqReport, err := operations.ExecuteSequence(e.OperationsBundle, EthBalMonTransferOwnershipSequence, deps, seqInput) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed on EthBalMonTransferOwnershipSequence sequence: %w", err) + } + + return cldf.ChangesetOutput{ + MCMSTimelockProposals: seqReport.Output.MCMSTimelockProposals, + }, nil +} + +type EthBalMonTransferOwnershipSeqInput struct { + Chains map[uint64]vaulttypes.EthBalMonTransferOwnershipChainConfig `json:"chains"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonTransferOwnershipSeqOutput struct { + MCMSTimelockProposals []mcms.TimelockProposal `json:"mcms_timelock_proposals"` +} + +var EthBalMonTransferOwnershipSequence = operations.NewSequence( + "ethbalmon-transferownership-sequence", + semver.MustParse("1.0.0"), + "Sequence to create transferOwnership EthBalMon batch transaction", + func(b operations.Bundle, deps VaultDeps, input EthBalMonTransferOwnershipSeqInput) (EthBalMonTransferOwnershipSeqOutput, error) { + b.Logger.Infow("Starting EthBalMon transferOwnership sequence", + "chains", len(input.Chains), + ) + var batches []mcmstypes.BatchOperation + timelockAddresses := make(map[uint64]string) + mcmAddressByChain := make(map[uint64]string) + for chainSelector, chainConfig := range input.Chains { + opReport, err := operations.ExecuteOperation(b, EthBalMonTransferOwnershipOperation, deps, EthBalMonTransferOwnershipOpInput{ + ChainSelector: chainSelector, + NewOwner: chainConfig.NewOwner, + MCMSConfig: input.MCMSConfig, + }) + if err != nil { + return EthBalMonTransferOwnershipSeqOutput{}, fmt.Errorf("chain %d: failed to generate ownership batch: %w", chainSelector, err) + } + opOutput := opReport.Output + + batches = append(batches, opOutput.BatchOperation) + timelockAddresses[chainSelector] = opOutput.TimelockAddress + mcmAddressByChain[chainSelector] = opOutput.MCMSAddress + } + + proposal, err := proposalutils.BuildProposalFromBatchesV2(deps.Environment, timelockAddresses, mcmAddressByChain, nil, batches, "EthBalMon transferOwnership", ethBalMonProposalTimelockConfig(input.MCMSConfig)) + + if err != nil { + return EthBalMonTransferOwnershipSeqOutput{}, fmt.Errorf("failed to build timelock proposal: %w", err) + } + b.Logger.Infow("Generated EthBalMon transferOwnership proposal", + "chains", len(input.Chains), "operations", len(batches)) + + return EthBalMonTransferOwnershipSeqOutput{ + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil + }, +) + +type EthBalMonTransferOwnershipOpInput struct { + ChainSelector uint64 `json:"chain_selector"` + NewOwner string `json:"new_owner"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonTransferOwnershipOpOutput struct { + ChainSelector uint64 `json:"chain_selector"` + BatchOperation mcmstypes.BatchOperation `json:"batch_operation"` + TimelockAddress string `json:"timelock_address"` + MCMSAddress string `json:"mcms_address"` +} + +var EthBalMonTransferOwnershipOperation = operations.NewOperation( + "ethbalmon-transferownership-operation", + semver.MustParse("1.0.0"), + "Operation to create transferOwnership EthBalMon batch transaction", + func(b operations.Bundle, deps VaultDeps, input EthBalMonTransferOwnershipOpInput) (EthBalMonTransferOwnershipOpOutput, error) { + b.Logger.Infow("Starting EthBalMon transferOwnership operation", + "chainsel", input.ChainSelector, + ) + + chain, ok := deps.Environment.BlockChains.EVMChains()[input.ChainSelector] + + if !ok { + return EthBalMonTransferOwnershipOpOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + ethBalMonAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + cldf.ContractType(vaulttypes.EthBalMonContractType), + ) + if err != nil { + return EthBalMonTransferOwnershipOpOutput{}, + fmt.Errorf("failed to get EthBalMon address: %w", err) + } + + timelockAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + commontypes.RBACTimelock, + ) + if err != nil { + return EthBalMonTransferOwnershipOpOutput{}, + fmt.Errorf("failed to get timelock address: %w", err) + } + mcmsAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + ethBalMonMCMSContractTypeForProposal(input.MCMSConfig), + ) + if err != nil { + return EthBalMonTransferOwnershipOpOutput{}, + 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 EthBalMonTransferOwnershipOpOutput{}, + fmt.Errorf("failed to instantiate EthBalanceMonitor at %s: %w", ethBalMonAddr, err) + } + + transferOwnershipTx, err := ethBalMon.TransferOwnership(cldf.SimTransactOpts(), common.HexToAddress(input.NewOwner)) + if err != nil { + return EthBalMonTransferOwnershipOpOutput{}, fmt.Errorf("failed to generate transferOwnership 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{ + "transferOwnership", + }, + }, + To: ethBalMonAddr, + Data: transferOwnershipTx.Data(), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }, + }, + } + + b.Logger.Infow("Generated EthBalMon transferOwnership batch", + "chainSelector", input.ChainSelector, + "ethBalMon", ethBalMonAddr, + "newOwner", input.NewOwner, + ) + + return EthBalMonTransferOwnershipOpOutput{ + ChainSelector: input.ChainSelector, + BatchOperation: batch, + TimelockAddress: timelockAddr, + MCMSAddress: mcmsAddr, + }, nil + }, +) diff --git a/deployment/vault/changeset/ethbalmon_transfer_ownership_test.go b/deployment/vault/changeset/ethbalmon_transfer_ownership_test.go new file mode 100644 index 00000000000..3f8a1952071 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_transfer_ownership_test.go @@ -0,0 +1,144 @@ +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 TestValidateEthBalMonTransferOwnershipConfig(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.EthBalMonTransferOwnershipInput + wantError bool + errorMsg string + }{ + { + name: "empty chains", + cfg: types.EthBalMonTransferOwnershipInput{Chains: map[uint64]types.EthBalMonTransferOwnershipChainConfig{}}, + wantError: true, + errorMsg: "no chains provided", + }, + { + name: "unknown chain selector", + cfg: types.EthBalMonTransferOwnershipInput{ + Chains: map[uint64]types.EthBalMonTransferOwnershipChainConfig{ + math.MaxUint64: {NewOwner: testAddr1}, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "chain not in environment", + cfg: types.EthBalMonTransferOwnershipInput{ + Chains: map[uint64]types.EthBalMonTransferOwnershipChainConfig{ + selectorOther: {NewOwner: testAddr1}, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "invalid new owner", + cfg: types.EthBalMonTransferOwnershipInput{ + Chains: map[uint64]types.EthBalMonTransferOwnershipChainConfig{ + selector: {NewOwner: "not-hex"}, + }, + }, + wantError: true, + errorMsg: "newOwner", + }, + { + name: "zero new owner", + cfg: types.EthBalMonTransferOwnershipInput{ + Chains: map[uint64]types.EthBalMonTransferOwnershipChainConfig{ + selector: {NewOwner: zeroAddr}, + }, + }, + wantError: true, + errorMsg: "cannot be zero address", + }, + { + name: "valid", + cfg: types.EthBalMonTransferOwnershipInput{ + Chains: map[uint64]types.EthBalMonTransferOwnershipChainConfig{ + selector: {NewOwner: testAddr1}, + }, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateEthBalMonTransferOwnershipConfig(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 TestEthBalMonTransferOwnershipChangeset(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.EthBalMonTransferOwnershipInput{ + Chains: map[uint64]types.EthBalMonTransferOwnershipChainConfig{ + selector: {NewOwner: testAddr2}, + }, + } + require.NoError(t, EthBalMonTransferOwnership.VerifyPreconditions(rt.Environment(), cfg)) + + transferTask := runtime.ChangesetTask(EthBalMonTransferOwnership, cfg) + require.NoError(t, rt.Exec(transferTask)) + + out := rt.State().Outputs[transferTask.ID()] + require.NotEmpty(t, out.MCMSTimelockProposals) + prop := out.MCMSTimelockProposals[0] + require.Contains(t, prop.Description, "EthBalMon transferOwnership") + require.Len(t, prop.Operations, 1) + require.Len(t, prop.Operations[0].Transactions, 1) + require.Contains(t, prop.Operations[0].Transactions[0].Tags, "transferOwnership") +} diff --git a/deployment/vault/changeset/ethbalmon_withdraw.go b/deployment/vault/changeset/ethbalmon_withdraw.go new file mode 100644 index 00000000000..788fa025740 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_withdraw.go @@ -0,0 +1,212 @@ +package changeset + +import ( + "encoding/json" + "fmt" + "math/big" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/eth_balance_monitor_wrapper" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + vaulttypes "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" +) + +type ethBalMonWithdraw struct{} + +var EthBalMonWithdraw cldf.ChangeSetV2[vaulttypes.EthBalMonWithdrawInput] = ethBalMonWithdraw{} + +func (w ethBalMonWithdraw) VerifyPreconditions(env cldf.Environment, config vaulttypes.EthBalMonWithdrawInput) error { + return ValidateEthBalMonWithdrawConfig(env.GetContext(), env, config) +} + +func (w ethBalMonWithdraw) Apply(e cldf.Environment, config vaulttypes.EthBalMonWithdrawInput) (cldf.ChangesetOutput, error) { + logger := e.Logger + logger.Infow("Generating EthBalMon withdraw 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 := EthBalMonWithdrawSeqInput{ + Chains: config.Chains, + MCMSConfig: config.MCMSConfig, + } + seqReport, err := operations.ExecuteSequence(e.OperationsBundle, EthBalMonWithdrawSequence, deps, seqInput) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed on EthBalMonWithdrawSequence sequence: %w", err) + } + + return cldf.ChangesetOutput{ + MCMSTimelockProposals: seqReport.Output.MCMSTimelockProposals, + }, nil +} + +type EthBalMonWithdrawSeqInput struct { + Chains map[uint64]vaulttypes.EthBalMonWithdrawChainConfig `json:"chains"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonWithdrawSeqOutput struct { + MCMSTimelockProposals []mcms.TimelockProposal `json:"mcms_timelock_proposals"` +} + +var EthBalMonWithdrawSequence = operations.NewSequence( + "ethbalmon-withdraw-sequence", + semver.MustParse("1.0.0"), + "Sequence to create operation for EthBalMon withdraw", + func(b operations.Bundle, deps VaultDeps, input EthBalMonWithdrawSeqInput) (EthBalMonWithdrawSeqOutput, error) { + b.Logger.Infow("Starting EthBalMon withdraw sequence", + "numChains", len(input.Chains), + ) + var batches []mcmstypes.BatchOperation + timelockAddresses := make(map[uint64]string) + mcmAddressByChain := make(map[uint64]string) + for chainSelector, chainConfig := range input.Chains { + opReport, err := operations.ExecuteOperation(b, EthBalMonWithdrawOperation, deps, EthBalMonWithdrawOpInput{ + ChainSelector: chainSelector, + Amount: chainConfig.Amount, + Payee: chainConfig.Payee, + MCMSConfig: input.MCMSConfig, + }) + if err != nil { + return EthBalMonWithdrawSeqOutput{}, fmt.Errorf("chain %d: failed to generate withdraw batch: %w", chainSelector, err) + } + opOutput := opReport.Output + + batches = append(batches, opOutput.BatchOperation) + timelockAddresses[chainSelector] = opOutput.TimelockAddress + mcmAddressByChain[chainSelector] = opOutput.MCMSAddress + } + + proposal, err := proposalutils.BuildProposalFromBatchesV2(deps.Environment, timelockAddresses, mcmAddressByChain, nil, batches, "EthBalMon Withdraw", ethBalMonProposalTimelockConfig(input.MCMSConfig)) + + if err != nil { + return EthBalMonWithdrawSeqOutput{}, fmt.Errorf("failed to build timelock proposal: %w", err) + } + b.Logger.Infow("Generated EthBalMon withdraw proposal", + "chains", len(input.Chains), "operations", len(batches)) + + return EthBalMonWithdrawSeqOutput{ + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil + }, +) + +type EthBalMonWithdrawOpInput struct { + ChainSelector uint64 `json:"chain_selector"` + Amount *big.Int `json:"amount"` + Payee string `json:"payee"` + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +type EthBalMonWithdrawOpOutput struct { + ChainSelector uint64 `json:"chain_selector"` + BatchOperation mcmstypes.BatchOperation `json:"batch_operation"` + TimelockAddress string `json:"timelock_address"` + MCMSAddress string `json:"mcms_address"` +} + +var EthBalMonWithdrawOperation = operations.NewOperation( + "ethbalmon-withdraw-operation", + semver.MustParse("1.0.0"), + "Operation to create withdraw EthBalMon batch transaction", + func(b operations.Bundle, deps VaultDeps, input EthBalMonWithdrawOpInput) (EthBalMonWithdrawOpOutput, error) { + b.Logger.Infow("Starting EthBalMon withdraw operation", + "chainsel", input.ChainSelector, + ) + + chain, ok := deps.Environment.BlockChains.EVMChains()[input.ChainSelector] + + if !ok { + return EthBalMonWithdrawOpOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + ethBalMonAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + cldf.ContractType(vaulttypes.EthBalMonContractType), + ) + if err != nil { + return EthBalMonWithdrawOpOutput{}, + fmt.Errorf("failed to get EthBalMon address: %w", err) + } + + timelockAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + commontypes.RBACTimelock, + ) + if err != nil { + return EthBalMonWithdrawOpOutput{}, + fmt.Errorf("failed to get timelock address: %w", err) + } + mcmsAddr, err := mustGetContractAddress( + deps.DataStore, + input.ChainSelector, + ethBalMonMCMSContractTypeForProposal(input.MCMSConfig), + ) + if err != nil { + return EthBalMonWithdrawOpOutput{}, + 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 EthBalMonWithdrawOpOutput{}, + fmt.Errorf("failed to instantiate EthBalanceMonitor at %s: %w", ethBalMonAddr, err) + } + + withdrawTx, err := ethBalMon.Withdraw(cldf.SimTransactOpts(), input.Amount, common.HexToAddress(input.Payee)) + if err != nil { + return EthBalMonWithdrawOpOutput{}, fmt.Errorf("failed to generate withdraw 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{ + "withdraw", + }, + }, + To: ethBalMonAddr, + Data: withdrawTx.Data(), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }, + }, + } + + b.Logger.Infow("Generated EthBalMon withdraw batch", + "chainSelector", input.ChainSelector, + "ethBalMon", ethBalMonAddr, + "amount", input.Amount, + "payee", input.Payee, + ) + + return EthBalMonWithdrawOpOutput{ + ChainSelector: input.ChainSelector, + BatchOperation: batch, + TimelockAddress: timelockAddr, + MCMSAddress: mcmsAddr, + }, nil + }, +) diff --git a/deployment/vault/changeset/ethbalmon_withdraw_test.go b/deployment/vault/changeset/ethbalmon_withdraw_test.go new file mode 100644 index 00000000000..574c1255df3 --- /dev/null +++ b/deployment/vault/changeset/ethbalmon_withdraw_test.go @@ -0,0 +1,201 @@ +package changeset + +import ( + "math" + "math/big" + "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 TestValidateEthBalMonWithdrawConfig(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.EthBalMonWithdrawInput + wantError bool + errorMsg string + }{ + { + name: "empty chains", + cfg: types.EthBalMonWithdrawInput{Chains: map[uint64]types.EthBalMonWithdrawChainConfig{}}, + wantError: true, + errorMsg: "no chains provided", + }, + { + name: "unknown chain selector", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + math.MaxUint64: {Amount: big.NewInt(1), Payee: testAddr1}, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "chain not in environment", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selectorOther: {Amount: big.NewInt(1), Payee: testAddr1}, + }, + }, + wantError: true, + errorMsg: "not found in environment", + }, + { + name: "nil amount", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: {Amount: nil, Payee: testAddr1}, + }, + }, + wantError: true, + errorMsg: "amount to withdraw must be positive", + }, + { + name: "zero amount", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: {Amount: big.NewInt(0), Payee: testAddr1}, + }, + }, + wantError: true, + errorMsg: "amount to withdraw must be positive", + }, + { + name: "negative amount", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: {Amount: big.NewInt(-1), Payee: testAddr1}, + }, + }, + wantError: true, + errorMsg: "amount to withdraw must be positive", + }, + { + name: "invalid payee", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: {Amount: big.NewInt(1), Payee: "not-hex"}, + }, + }, + wantError: true, + errorMsg: "payee", + }, + { + name: "zero payee", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: {Amount: big.NewInt(1), Payee: zeroAddr}, + }, + }, + wantError: true, + errorMsg: "payeer address cannot be zero address", + }, + { + name: "valid", + cfg: types.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: {Amount: big.NewInt(1), Payee: testAddr1}, + }, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateEthBalMonWithdrawConfig(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 TestEthBalMonWithdrawChangeset(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.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: { + Amount: big.NewInt(1), + Payee: testAddr2, + }, + }, + } + require.NoError(t, EthBalMonWithdraw.VerifyPreconditions(rt.Environment(), cfg)) + + withdrawTask := runtime.ChangesetTask(EthBalMonWithdraw, cfg) + require.NoError(t, rt.Exec(withdrawTask)) + + out := rt.State().Outputs[withdrawTask.ID()] + require.NotEmpty(t, out.MCMSTimelockProposals) + prop := out.MCMSTimelockProposals[0] + require.Contains(t, prop.Description, "EthBalMon Withdraw") + require.Len(t, prop.Operations, 1) + require.Len(t, prop.Operations[0].Transactions, 1) + require.Contains(t, prop.Operations[0].Transactions[0].Tags, "withdraw") +} + +func TestEthBalMonWithdraw_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.EthBalMonWithdrawInput{ + Chains: map[uint64]types.EthBalMonWithdrawChainConfig{ + selector: {Amount: big.NewInt(1), Payee: testAddr1}, + }, + } + require.NoError(t, EthBalMonWithdraw.VerifyPreconditions(rt.Environment(), cfg)) + + _, err = EthBalMonWithdraw.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 0055537bfb2..c849587bc8f 100644 --- a/deployment/vault/changeset/types/types.go +++ b/deployment/vault/changeset/types/types.go @@ -3,6 +3,8 @@ package types import ( "math/big" + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" ) @@ -71,3 +73,87 @@ type BatchNativeTransferState struct { // ValidationErrors contains any validation errors found ValidationErrors []TransferValidationError `json:"validation_errors"` } + +// DeployEthBalMonChainConfig is deployment-time configuration for EthBalMon on one chain. +type DeployEthBalMonChainConfig struct { + // SetKeeperRegistryAddress is the Chainlink Automation registry forwarder (the upkeep + // "forwarder address") on standard automation chains, or the KMS executor address when + // using the Plaid/KMS automation path. + SetKeeperRegistryAddress string `json:"setKeeperRegistryAddress"` + // SetMinWaitPeriodSeconds is the minimum seconds between balance checks for this deployment. + // Optional: nil or 0 means the deploy changeset uses a default (currently 60 seconds). + SetMinWaitPeriodSeconds *uint64 `json:"setMinWaitPeriodSeconds,omitempty"` +} + +// DeployEthBalMonInput is the input to the EthBalMon deploy changeset. +// Keys are chain selectors; each value configures keeper/registry wiring and min wait for that chain. +type DeployEthBalMonInput struct { + Chains map[uint64]DeployEthBalMonChainConfig `json:"chains"` + // MCMSConfig optionally configures the post-deploy accept-ownership timelock proposal (MCMS action, delay, etc.). + // When nil or MCMSAction is unset, the deploy flow defaults accept-ownership to bypass (historical behavior). + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +// EthBalMonContractType is the datastore / MCMS contract type label for EthBalMon deployments. +const EthBalMonContractType = "EthBalMon" + +// SetKeeperRegistryChainConfig updates the automation executor/registry EthBalMon forwards work to. +type SetKeeperRegistryChainConfig struct { + // NewKeeperRegistryAddress is the new Chainlink Automation forwarder or KMS executor address (hex). + NewKeeperRegistryAddress string `json:"new_keeper_registry_address"` +} + +// EthBalMonSetKeeperRegistryAddressInput is the input to the setKeeperRegistryAddress changeset. +// Keys are chain selectors with the registry address to set on each chain's EthBalMon. +type EthBalMonSetKeeperRegistryAddressInput struct { + Chains map[uint64]SetKeeperRegistryChainConfig `json:"chains"` + // MCMSConfig optionally configures the timelock proposal; when nil, schedule + proposer MCM is used. + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +// EthBalMonSetWatchListChainConfig replaces the monitored addresses and thresholds on one chain. +// Addresses, MinBalancesWei, and TopUpAmountsWei are parallel slices: index i applies to Addresses[i]. +// MinBalancesWei and TopUpAmountsWei are represented as *big.Int values in wei. +type EthBalMonSetWatchListChainConfig struct { + Addresses []common.Address `json:"addresses"` + MinBalancesWei []*big.Int `json:"min_balance_wei"` + TopUpAmountsWei []*big.Int `json:"topup_amounts_wei"` +} + +// EthBalMonSetWatchListInput is the input to the setWatchList changeset. +// Keys are chain selectors; each value is the full watch list to install on that chain's EthBalMon. +type EthBalMonSetWatchListInput struct { + Chains map[uint64]EthBalMonSetWatchListChainConfig `json:"chains"` + // MCMSConfig optionally configures the timelock proposal; when nil, schedule + proposer MCM is used. + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +// EthBalMonWithdrawChainConfig configures a native-token withdraw from EthBalMon on one chain. +type EthBalMonWithdrawChainConfig struct { + // Amount is the withdrawal amount in wei. Must be positive (validated by the changeset). + Amount *big.Int `json:"amount"` + // Payee is the recipient address (hex). + Payee string `json:"payee"` +} + +// EthBalMonWithdrawInput is the input to the EthBalMon withdraw changeset. +// Keys are chain selectors; each value specifies amount and recipient for that chain. +type EthBalMonWithdrawInput struct { + Chains map[uint64]EthBalMonWithdrawChainConfig `json:"chains"` + // MCMSConfig optionally configures the timelock proposal; when nil, schedule + proposer MCM is used. + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} + +// EthBalMonTransferOwnershipChainConfig sets the new owner of EthBalMon on one chain. +type EthBalMonTransferOwnershipChainConfig struct { + // NewOwner is the address (hex) that will own the EthBalMon contract after the operation. + NewOwner string `json:"new_owner"` +} + +// EthBalMonTransferOwnershipInput is the input to the EthBalMon transferOwnership changeset. +// Keys are chain selectors; each value is the new owner for that chain's EthBalMon instance. +type EthBalMonTransferOwnershipInput struct { + Chains map[uint64]EthBalMonTransferOwnershipChainConfig `json:"chains"` + // MCMSConfig optionally configures the timelock proposal; when nil, schedule + proposer MCM is used. + MCMSConfig *proposalutils.TimelockConfig `json:"mcms_config,omitempty"` +} diff --git a/deployment/vault/changeset/validation.go b/deployment/vault/changeset/validation.go index 65806e40e27..78f96b6c917 100644 --- a/deployment/vault/changeset/validation.go +++ b/deployment/vault/changeset/validation.go @@ -11,9 +11,8 @@ import ( cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" - evmstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/evm" - "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/changeset/state" "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" "github.com/smartcontractkit/chainlink/deployment/vault/changeset/types" @@ -139,7 +138,7 @@ func validateMCMSConfig(e cldf.Environment, mcmsConfig *proposalutils.TimelockCo } const emptyQualifier = "" for chainSelector := range transfersByChain { - addresses, err := evmstate.LoadAddressesFromDataStore(e.DataStore, chainSelector, emptyQualifier) + addresses, err := state.GetAddressTypeVersionByQualifier(e.DataStore.Addresses(), chainSelector, emptyQualifier) if err != nil { return fmt.Errorf("failed to get addresses from datastore for chain %d: %w", chainSelector, err) } @@ -222,3 +221,170 @@ func ValidateSetWhitelistConfig(e cldf.Environment, cfg types.SetWhitelistConfig return nil } + +func validateEthAddress(field, raw string) error { + if raw == "" { + return fmt.Errorf("%s must not be empty", field) + } + if !common.IsHexAddress(raw) { + return fmt.Errorf("%s is not a valid hex address: %s", field, raw) + } + return nil +} + +func ValidateDeployEthBalMonConfig(ctx context.Context, env cldf.Environment, cfg types.DeployEthBalMonInput) error { + if len(cfg.Chains) == 0 { + return errors.New("chains must not be empty") + } + + if cfg.MCMSConfig != nil && cfg.MCMSConfig.MinDelay < 0 { + return fmt.Errorf("MCMS minimum delay cannot be negative: %d", cfg.MCMSConfig.MinDelay) + } + + for chainSelector, chainCfg := range cfg.Chains { + if err := validateChainSelector(chainSelector, env); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + if err := validateEthAddress("setKeeperRegistryAddress", chainCfg.SetKeeperRegistryAddress); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + if err := validateDeployEthBalMonMCMSInDatastore(env, chainSelector, cfg.MCMSConfig); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + } + + return nil +} + +// validateDeployEthBalMonMCMSInDatastore ensures RBACTimelock, the MCM used for the post-deploy +// accept-ownership proposal (bypasser vs proposer per cfg.MCMSConfig), and loadable MCMS state +// exist in the datastore — matching DeployEthBalMonSequence and BuildAcceptOwnershipTimelockProposal. +func validateDeployEthBalMonMCMSInDatastore(e cldf.Environment, chainSelector uint64, mcmsCfg *proposalutils.TimelockConfig) error { + const emptyQualifier = "" + addresses, err := state.GetAddressTypeVersionByQualifier(e.DataStore.Addresses(), chainSelector, emptyQualifier) + if err != nil { + return fmt.Errorf("failed to get addresses from datastore: %w", err) + } + + _, err = GetContractAddress(e.DataStore, chainSelector, commontypes.RBACTimelock) + if err != nil { + return fmt.Errorf("timelock not found in datastore: %w", err) + } + + mcmType := ethBalMonMCMSContractTypeForAction(deployEthBalMonAcceptOwnershipMCMSAction(mcmsCfg)) + _, err = GetContractAddress(e.DataStore, chainSelector, mcmType) + if err != nil { + return fmt.Errorf("MCMS (%s) not found in datastore: %w", mcmType, err) + } + + chain := e.BlockChains.EVMChains()[chainSelector] + _, err = changeset.MaybeLoadMCMSWithTimelockChainState(chain, addresses) + if err != nil { + return fmt.Errorf("failed to load MCMS with timelock state: %w", err) + } + return nil +} + +func ValidateSetKeeperRegistryAddressConfig(ctx context.Context, env cldf.Environment, cfg types.EthBalMonSetKeeperRegistryAddressInput) error { + if len(cfg.Chains) == 0 { + return errors.New("no chains provided") + } + + for chainSelector, chainConfig := range cfg.Chains { + if _, ok := env.BlockChains.EVMChains()[chainSelector]; !ok { + return fmt.Errorf("chain not found in environment: %d", chainSelector) + } + + if err := validateEthAddress("new_keeper_registry_address", chainConfig.NewKeeperRegistryAddress); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + if common.HexToAddress(chainConfig.NewKeeperRegistryAddress) == (common.Address{}) { + return fmt.Errorf("chain %d: keeper registry address cannot be zero address", chainSelector) + } + } + + return nil +} + +func ValidateEthBalMonWithdrawConfig(ctx context.Context, env cldf.Environment, cfg types.EthBalMonWithdrawInput) error { + if len(cfg.Chains) == 0 { + return errors.New("no chains provided") + } + + for chainSelector, chainConfig := range cfg.Chains { + if _, ok := env.BlockChains.EVMChains()[chainSelector]; !ok { + return fmt.Errorf("chain not found in environment: %d", chainSelector) + } + if chainConfig.Amount == nil || chainConfig.Amount.Cmp(big.NewInt(0)) <= 0 { + return fmt.Errorf("chain %d: amount to withdraw must be positive", chainSelector) + } + + if err := validateEthAddress("payee", chainConfig.Payee); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + if common.HexToAddress(chainConfig.Payee) == (common.Address{}) { + return fmt.Errorf("chain %d: payeer address cannot be zero address", chainSelector) + } + } + + return nil +} + +func ValidateEthBalMonTransferOwnershipConfig(ctx context.Context, env cldf.Environment, cfg types.EthBalMonTransferOwnershipInput) error { + if len(cfg.Chains) == 0 { + return errors.New("no chains provided") + } + + for chainSelector, chainConfig := range cfg.Chains { + if _, ok := env.BlockChains.EVMChains()[chainSelector]; !ok { + return fmt.Errorf("chain not found in environment: %d", chainSelector) + } + if err := validateEthAddress("newOwner", chainConfig.NewOwner); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + if common.HexToAddress(chainConfig.NewOwner) == (common.Address{}) { + return fmt.Errorf("chain %d: newOwner address cannot be zero address", 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") + } + + for chainSelector, chainConfig := range cfg.Chains { + if _, ok := env.BlockChains.EVMChains()[chainSelector]; !ok { + return fmt.Errorf("chain not found in environment: %d", chainSelector) + } + n := len(chainConfig.Addresses) + if n == 0 { + return fmt.Errorf("chain %d: addresses must not be empty", chainSelector) + } + if len(chainConfig.MinBalancesWei) != n || len(chainConfig.TopUpAmountsWei) != n { + return fmt.Errorf( + "chain %d: addresses, min_balance_wei, and topup_amounts_wei must have the same length (got %d, %d, %d)", + chainSelector, n, len(chainConfig.MinBalancesWei), len(chainConfig.TopUpAmountsWei), + ) + } + for i, addr := range chainConfig.Addresses { + if err := validateEthAddress(fmt.Sprintf("address %d", i), addr.Hex()); err != nil { + return fmt.Errorf("chain %d: %w", chainSelector, err) + } + if addr == (common.Address{}) { + return fmt.Errorf("chain %d: address at index %d is zero address", chainSelector, i) + } + // Check MinBalancesWei and TopUpAmountsWei are >= 0 + if chainConfig.MinBalancesWei[i].Cmp(big.NewInt(0)) < 0 { + return fmt.Errorf("chain %d: min_balance_wei at index %d must be >= 0", chainSelector, i) + } + if chainConfig.TopUpAmountsWei[i].Cmp(big.NewInt(0)) < 0 { + return fmt.Errorf("chain %d: topup_amounts_wei at index %d must be >= 0", chainSelector, i) + } + } + } + + return nil +}