diff --git a/deployment/ccip/changeset/state.go b/deployment/ccip/changeset/state.go index 45ea9e8f5b8..cd88db1b9ee 100644 --- a/deployment/ccip/changeset/state.go +++ b/deployment/ccip/changeset/state.go @@ -311,13 +311,13 @@ func LoadOnchainState(e deployment.Environment) (CCIPOnChainState, error) { // LoadChainState Loads all state for a chain into state func LoadChainState(chain deployment.Chain, addresses map[string]deployment.TypeAndVersion) (CCIPChainState, error) { var state CCIPChainState - mcmsWithTimelock, err := commoncs.MaybeLoadMCMSWithTimelockState(chain, addresses) + mcmsWithTimelock, err := commoncs.MaybeLoadMCMSWithTimelockChainState(chain, addresses) if err != nil { return state, err } state.MCMSWithTimelockState = *mcmsWithTimelock - linkState, err := commoncs.MaybeLoadLinkTokenState(chain, addresses) + linkState, err := commoncs.MaybeLoadLinkTokenChainState(chain, addresses) if err != nil { return state, err } diff --git a/deployment/common/changeset/deploy_link_token.go b/deployment/common/changeset/deploy_link_token.go index 292c07c93df..c115a7ee083 100644 --- a/deployment/common/changeset/deploy_link_token.go +++ b/deployment/common/changeset/deploy_link_token.go @@ -12,7 +12,7 @@ import ( var _ deployment.ChangeSet[[]uint64] = DeployLinkToken -// DeployLinkToken deploys a link token contract to the chain identified by the chainSelector. +// DeployLinkToken deploys a link token contract to the chain identified by the ChainSelector. func DeployLinkToken(e deployment.Environment, chains []uint64) (deployment.ChangesetOutput, error) { for _, chain := range chains { _, ok := e.Chains[chain] diff --git a/deployment/common/changeset/deploy_link_token_test.go b/deployment/common/changeset/deploy_link_token_test.go index a61743e9bf4..bc472d2a247 100644 --- a/deployment/common/changeset/deploy_link_token_test.go +++ b/deployment/common/changeset/deploy_link_token_test.go @@ -27,7 +27,7 @@ func TestDeployLinkToken(t *testing.T) { require.NoError(t, err) addrs, err := e.ExistingAddresses.AddressesForChain(chain1) require.NoError(t, err) - state, err := changeset.MaybeLoadLinkTokenState(e.Chains[chain1], addrs) + state, err := changeset.MaybeLoadLinkTokenChainState(e.Chains[chain1], addrs) require.NoError(t, err) // View itself already unit tested _, err = state.GenerateLinkView() diff --git a/deployment/common/changeset/example/add_mint_burners_link.go b/deployment/common/changeset/example/add_mint_burners_link.go new file mode 100644 index 00000000000..7322f99dd60 --- /dev/null +++ b/deployment/common/changeset/example/add_mint_burners_link.go @@ -0,0 +1,70 @@ +package example + +import ( + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/common/changeset" +) + +type AddMintersBurnersLinkConfig struct { + ChainSelector uint64 + Minters []common.Address + Burners []common.Address +} + +var _ deployment.ChangeSet[*AddMintersBurnersLinkConfig] = AddMintersBurnersLink + +// AddMintersBurnersLink grants the minter / burner role to the provided addresses. +func AddMintersBurnersLink(e deployment.Environment, cfg *AddMintersBurnersLinkConfig) (deployment.ChangesetOutput, error) { + + chain := e.Chains[cfg.ChainSelector] + addresses, err := e.ExistingAddresses.AddressesForChain(cfg.ChainSelector) + if err != nil { + return deployment.ChangesetOutput{}, err + } + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addresses) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + for _, minter := range cfg.Minters { + // check if minter is already a minter + isMinter, err := linkState.LinkToken.IsMinter(&bind.CallOpts{Context: e.GetContext()}, minter) + if err != nil { + return deployment.ChangesetOutput{}, err + } + if isMinter { + continue + } + tx, err := linkState.LinkToken.GrantMintRole(chain.DeployerKey, minter) + if err != nil { + return deployment.ChangesetOutput{}, err + } + _, err = deployment.ConfirmIfNoError(chain, tx, err) + if err != nil { + return deployment.ChangesetOutput{}, err + } + } + for _, burner := range cfg.Burners { + // check if burner is already a burner + isBurner, err := linkState.LinkToken.IsBurner(&bind.CallOpts{Context: e.GetContext()}, burner) + if err != nil { + return deployment.ChangesetOutput{}, err + } + if isBurner { + continue + } + tx, err := linkState.LinkToken.GrantBurnRole(chain.DeployerKey, burner) + if err != nil { + return deployment.ChangesetOutput{}, err + } + _, err = deployment.ConfirmIfNoError(chain, tx, err) + if err != nil { + return deployment.ChangesetOutput{}, err + } + } + return deployment.ChangesetOutput{}, nil + +} diff --git a/deployment/common/changeset/example/add_mint_burners_link_test.go b/deployment/common/changeset/example/add_mint_burners_link_test.go new file mode 100644 index 00000000000..4dbfddc0b30 --- /dev/null +++ b/deployment/common/changeset/example/add_mint_burners_link_test.go @@ -0,0 +1,50 @@ +package example_test + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/changeset/example" +) + +// TestAddMintersBurnersLink tests the AddMintersBurnersLink changeset +func TestAddMintersBurnersLink(t *testing.T) { + t.Parallel() + ctx := context.Background() + // Deploy Link Token and Timelock contracts and add addresses to environment + env := setupLinkTransferTestEnv(t) + + chainSelector := env.AllChainSelectors()[0] + chain := env.Chains[chainSelector] + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + require.Len(t, addrs, 6) + + mcmsState, err := changeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addrs) + require.NoError(t, err) + + timelockAddress := mcmsState.Timelock.Address() + + // Mint some funds + _, err = example.AddMintersBurnersLink(env, &example.AddMintersBurnersLinkConfig{ + ChainSelector: chainSelector, + Minters: []common.Address{timelockAddress}, + Burners: []common.Address{timelockAddress}, + }) + require.NoError(t, err) + + // check timelock balance + isMinter, err := linkState.LinkToken.IsMinter(&bind.CallOpts{Context: ctx}, timelockAddress) + require.NoError(t, err) + require.True(t, isMinter) + isBurner, err := linkState.LinkToken.IsBurner(&bind.CallOpts{Context: ctx}, timelockAddress) + require.NoError(t, err) + require.True(t, isBurner) +} diff --git a/deployment/common/changeset/example/link_transfer.go b/deployment/common/changeset/example/link_transfer.go new file mode 100644 index 00000000000..2e3be48a4d1 --- /dev/null +++ b/deployment/common/changeset/example/link_transfer.go @@ -0,0 +1,239 @@ +package example + +import ( + "errors" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + ethTypes "github.com/ethereum/go-ethereum/core/types" + owner_helpers "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + chain_selectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/mcms" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/timelock" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + "github.com/smartcontractkit/chainlink/deployment/common/types" +) + +const MaxTimelockDelay = 24 * 7 * time.Hour + +type TransferConfig struct { + To common.Address + Value *big.Int +} + +type MCMSConfig struct { + MinDelay time.Duration // delay for timelock worker to execute the transfers. + OverrideRoot bool +} + +type LinkTransferConfig struct { + Transfers map[uint64][]TransferConfig + From common.Address + McmsConfig *MCMSConfig +} + +var _ deployment.ChangeSet[*LinkTransferConfig] = LinkTransfer + +func getDeployer(e deployment.Environment, chain uint64, mcmConfig *MCMSConfig) *bind.TransactOpts { + if mcmConfig == nil { + return e.Chains[chain].DeployerKey + } + + return deployment.SimTransactOpts() +} + +// Validate checks that the LinkTransferConfig is valid. +func (cfg LinkTransferConfig) Validate(e deployment.Environment) error { + ctx := e.GetContext() + // Check that Transfers map has at least one chainSel + if len(cfg.Transfers) == 0 { + return errors.New("transfers map must have at least one chainSel") + } + + // Check transfers config values. + for chainSel, transfers := range cfg.Transfers { + selector, err := chain_selectors.GetSelectorFamily(chainSel) + if err != nil { + return fmt.Errorf("invalid chain selector: %w", err) + } + if selector != chain_selectors.FamilyEVM { + return fmt.Errorf("chain selector %d is not an EVM chain", chainSel) + } + chain, ok := e.Chains[chainSel] + if !ok { + return fmt.Errorf("chain with selector %d not found", chainSel) + } + addrs, err := e.ExistingAddresses.AddressesForChain(chainSel) + if err != nil { + return fmt.Errorf("error getting addresses for chain %d: %w", chainSel, err) + } + if len(transfers) == 0 { + return fmt.Errorf("transfers for chainSel %d must have at least one LinkTransfer", chainSel) + } + totalAmount := big.NewInt(0) + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addrs) + if err != nil { + return fmt.Errorf("error loading link token state during validation: %w", err) + } + for _, transfer := range transfers { + if transfer.To == (common.Address{}) { + return errors.New("'to' address for transfers must be set") + } + if transfer.Value == nil { + return errors.New("value for transfers must be set") + } + if transfer.Value.Cmp(big.NewInt(0)) == 0 { + return errors.New("value for transfers must be non-zero") + } + if transfer.Value.Cmp(big.NewInt(0)) == -1 { + return errors.New("value for transfers must be positive") + } + totalAmount.Add(totalAmount, transfer.Value) + } + // check that from address has enough funds for the transfers + balance, err := linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, cfg.From) + if balance.Cmp(totalAmount) < 0 { + return fmt.Errorf("sender does not have enough funds for transfers for chain selector %d, required: %s, available: %s", chainSel, totalAmount.String(), balance.String()) + } + } + + if cfg.McmsConfig == nil { + return nil + } + + // Upper bound for min delay (7 days) + if cfg.McmsConfig.MinDelay > MaxTimelockDelay { + return errors.New("minDelay must be less than 7 days") + } + + return nil +} + +// initStatePerChain initializes the state for each chain selector on the provided config +func initStatePerChain(cfg *LinkTransferConfig, e deployment.Environment) ( + linkStatePerChain map[uint64]*changeset.LinkTokenState, + mcmsStatePerChain map[uint64]*changeset.MCMSWithTimelockState, + err error) { + linkStatePerChain = map[uint64]*changeset.LinkTokenState{} + mcmsStatePerChain = map[uint64]*changeset.MCMSWithTimelockState{} + // Load state for each chain + chainSelectors := []uint64{} + for chainSelector := range cfg.Transfers { + chainSelectors = append(chainSelectors, chainSelector) + } + linkStatePerChain, err = changeset.MaybeLoadLinkTokenState(e, chainSelectors) + if err != nil { + return nil, nil, err + } + mcmsStatePerChain, err = changeset.MaybeLoadMCMSWithTimelockState(e, chainSelectors) + if err != nil { + return nil, nil, err + + } + return linkStatePerChain, mcmsStatePerChain, nil +} + +// transferOrBuildTx transfers the LINK tokens or builds the tx for the MCMS proposal +func transferOrBuildTx( + e deployment.Environment, + linkState *changeset.LinkTokenState, + transfer TransferConfig, + opts *bind.TransactOpts, + chain deployment.Chain, + mcmsConfig *MCMSConfig) (*ethTypes.Transaction, error) { + tx, err := linkState.LinkToken.Transfer(opts, transfer.To, transfer.Value) + if err != nil { + return nil, fmt.Errorf("error packing transfer tx data: %w", err) + } + // only wait for tx if we are not using MCMS + if mcmsConfig == nil { + if _, err := deployment.ConfirmIfNoError(chain, tx, err); err != nil { + e.Logger.Errorw("Failed to confirm transfer tx", "chain", chain.String(), "err", err) + return nil, err + } + } + return tx, nil + +} + +// LinkTransfer takes the given link transfers and executes them or creates an MCMS proposal for them. +func LinkTransfer(e deployment.Environment, cfg *LinkTransferConfig) (deployment.ChangesetOutput, error) { + + err := cfg.Validate(e) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("invalid LinkTransferConfig: %w", err) + } + chainSelectors := []uint64{} + for chainSelector := range cfg.Transfers { + chainSelectors = append(chainSelectors, chainSelector) + } + mcmsPerChain := map[uint64]*owner_helpers.ManyChainMultiSig{} + + timelockAddresses := map[uint64]common.Address{} + // Initialize state for each chain + linkStatePerChain, mcmsStatePerChain, err := initStatePerChain(cfg, e) + + allBatches := []timelock.BatchChainOperation{} + for chainSelector := range cfg.Transfers { + chainID := mcms.ChainIdentifier(chainSelector) + chain := e.Chains[chainSelector] + linkAddress := linkStatePerChain[chainSelector].LinkToken.Address() + mcmsState := mcmsStatePerChain[chainSelector] + linkState := linkStatePerChain[chainSelector] + + timelockAddress := mcmsState.Timelock.Address() + + mcmsPerChain[uint64(chainID)] = mcmsState.ProposerMcm + + timelockAddresses[chainSelector] = timelockAddress + batch := timelock.BatchChainOperation{ + ChainIdentifier: chainID, + Batch: []mcms.Operation{}, + } + + opts := getDeployer(e, chainSelector, cfg.McmsConfig) + totalAmount := big.NewInt(0) + for _, transfer := range cfg.Transfers[chainSelector] { + tx, err := transferOrBuildTx(e, linkState, transfer, opts, chain, cfg.McmsConfig) + if err != nil { + return deployment.ChangesetOutput{}, err + } + op := mcms.Operation{ + To: linkAddress, + Data: tx.Data(), + Value: big.NewInt(0), + ContractType: string(types.LinkToken), + } + batch.Batch = append(batch.Batch, op) + totalAmount.Add(totalAmount, transfer.Value) + } + + allBatches = append(allBatches, batch) + } + + if cfg.McmsConfig != nil { + proposal, err := proposalutils.BuildProposalFromBatches( + timelockAddresses, + mcmsPerChain, + allBatches, + "LINK Value transfer proposal", + cfg.McmsConfig.MinDelay, + ) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + return deployment.ChangesetOutput{ + Proposals: []timelock.MCMSWithTimelockProposal{*proposal}, + }, nil + } + + return deployment.ChangesetOutput{}, nil +} diff --git a/deployment/common/changeset/example/link_transfer_test.go b/deployment/common/changeset/example/link_transfer_test.go new file mode 100644 index 00000000000..eecfbd37c95 --- /dev/null +++ b/deployment/common/changeset/example/link_transfer_test.go @@ -0,0 +1,373 @@ +package example_test + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + chain_selectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink/deployment/common/changeset/example" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + "github.com/smartcontractkit/chainlink/v2/core/logger" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/deployment/environment/memory" +) + +// setupLinkTransferContracts deploys all required contracts for the link transfer tests and returns the updated env. +func setupLinkTransferTestEnv(t *testing.T) deployment.Environment { + + lggr := logger.TestLogger(t) + cfg := memory.MemoryEnvironmentConfig{ + Nodes: 1, + Chains: 2, + } + env := memory.NewMemoryEnvironment(t, lggr, zapcore.DebugLevel, cfg) + chainSelector := env.AllChainSelectors()[0] + config := proposalutils.SingleGroupMCMS(t) + + // Deploy MCMS and Timelock + env, err := changeset.ApplyChangesets(t, env, nil, []changeset.ChangesetApplication{ + { + Changeset: changeset.WrapChangeSet(changeset.DeployLinkToken), + Config: []uint64{chainSelector}, + }, + { + Changeset: changeset.WrapChangeSet(changeset.DeployMCMSWithTimelock), + Config: map[uint64]types.MCMSWithTimelockConfig{ + chainSelector: { + Canceller: config, + Bypasser: config, + Proposer: config, + TimelockMinDelay: big.NewInt(0), + }, + }, + }, + }) + require.NoError(t, err) + return env +} + +// TestLinkTransferMCMS tests the LinkTransfer changeset by sending LINK from a timelock contract +// to the deployer key via mcms proposal. +func TestLinkTransferMCMS(t *testing.T) { + t.Parallel() + ctx := context.Background() + + env := setupLinkTransferTestEnv(t) + chainSelector := env.AllChainSelectors()[0] + chain := env.Chains[chainSelector] + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + require.Len(t, addrs, 6) + + mcmsState, err := changeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addrs) + require.NoError(t, err) + timelockAddress := mcmsState.Timelock.Address() + + // Mint some funds + // grant minter permissions + tx, err := linkState.LinkToken.GrantMintRole(chain.DeployerKey, chain.DeployerKey.From) + require.NoError(t, err) + _, err = deployment.ConfirmIfNoError(chain, tx, err) + require.NoError(t, err) + + tx, err = linkState.LinkToken.Mint(chain.DeployerKey, timelockAddress, big.NewInt(750)) + require.NoError(t, err) + _, err = deployment.ConfirmIfNoError(chain, tx, err) + require.NoError(t, err) + + timelocks := map[uint64]*proposalutils.TimelockExecutionContracts{ + chainSelector: { + Timelock: mcmsState.Timelock, + CallProxy: mcmsState.CallProxy, + }, + } + // Apply the changeset + _, err = changeset.ApplyChangesets(t, env, timelocks, []changeset.ChangesetApplication{ + // the changeset produces proposals, ApplyChangesets will sign & execute them. + // in practice, signing and executing are separated processes. + { + Changeset: changeset.WrapChangeSet(example.LinkTransfer), + Config: &example.LinkTransferConfig{ + From: timelockAddress, + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: { + { + To: chain.DeployerKey.From, + Value: big.NewInt(500), + }, + }, + }, + McmsConfig: &example.MCMSConfig{ + MinDelay: 0, + OverrideRoot: true, + }, + }, + }, + }) + require.NoError(t, err) + + // Check new balances + endBalance, err := linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, chain.DeployerKey.From) + require.NoError(t, err) + expectedBalance := big.NewInt(500) + require.Equal(t, expectedBalance, endBalance) + + // check timelock balance + endBalance, err = linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, timelockAddress) + require.NoError(t, err) + expectedBalance = big.NewInt(250) + require.Equal(t, expectedBalance, endBalance) +} + +// TestLinkTransfer tests the LinkTransfer changeset by sending LINK from a timelock contract to the deployer key. +func TestLinkTransfer(t *testing.T) { + t.Parallel() + ctx := context.Background() + + env := setupLinkTransferTestEnv(t) + chainSelector := env.AllChainSelectors()[0] + chain := env.Chains[chainSelector] + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + require.Len(t, addrs, 6) + + mcmsState, err := changeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addrs) + require.NoError(t, err) + timelockAddress := mcmsState.Timelock.Address() + + // Mint some funds + // grant minter permissions + tx, err := linkState.LinkToken.GrantMintRole(chain.DeployerKey, chain.DeployerKey.From) + require.NoError(t, err) + _, err = deployment.ConfirmIfNoError(chain, tx, err) + require.NoError(t, err) + + tx, err = linkState.LinkToken.Mint(chain.DeployerKey, chain.DeployerKey.From, big.NewInt(750)) + require.NoError(t, err) + _, err = deployment.ConfirmIfNoError(chain, tx, err) + require.NoError(t, err) + + timelocks := map[uint64]*proposalutils.TimelockExecutionContracts{ + chainSelector: { + Timelock: mcmsState.Timelock, + CallProxy: mcmsState.CallProxy, + }, + } + + // Apply the changeset + _, err = changeset.ApplyChangesets(t, env, timelocks, []changeset.ChangesetApplication{ + // the changeset produces proposals, ApplyChangesets will sign & execute them. + // in practice, signing and executing are separated processes. + { + Changeset: changeset.WrapChangeSet(example.LinkTransfer), + Config: &example.LinkTransferConfig{ + From: chain.DeployerKey.From, + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: { + { + To: timelockAddress, + Value: big.NewInt(500), + }, + }, + }, + // No MCMSConfig here means we'll execute the txs directly. + }, + }, + }) + require.NoError(t, err) + + // Check new balances + endBalance, err := linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, chain.DeployerKey.From) + require.NoError(t, err) + expectedBalance := big.NewInt(250) + require.Equal(t, expectedBalance, endBalance) + + // check timelock balance + endBalance, err = linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, timelockAddress) + require.NoError(t, err) + expectedBalance = big.NewInt(500) + require.Equal(t, expectedBalance, endBalance) +} + +func TestValidate(t *testing.T) { + env := setupLinkTransferTestEnv(t) + chainSelector := env.AllChainSelectors()[0] + chain := env.Chains[chainSelector] + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + require.Len(t, addrs, 6) + mcmsState, err := changeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addrs) + require.NoError(t, err) + tx, err := linkState.LinkToken.GrantMintRole(chain.DeployerKey, chain.DeployerKey.From) + require.NoError(t, err) + _, err = deployment.ConfirmIfNoError(chain, tx, err) + require.NoError(t, err) + tx, err = linkState.LinkToken.Mint(chain.DeployerKey, chain.DeployerKey.From, big.NewInt(750)) + require.NoError(t, err) + _, err = deployment.ConfirmIfNoError(chain, tx, err) + + require.NoError(t, err) + tests := []struct { + name string + cfg example.LinkTransferConfig + errorMsg string + }{ + { + name: "valid config", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: {{To: mcmsState.Timelock.Address(), Value: big.NewInt(100)}}}, + From: chain.DeployerKey.From, + McmsConfig: &example.MCMSConfig{ + MinDelay: time.Hour, + }, + }, + }, + { + name: "valid non mcms config", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: {{To: mcmsState.Timelock.Address(), Value: big.NewInt(100)}}}, + From: chain.DeployerKey.From, + }, + }, + { + name: "insufficient funds", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: { + {To: chain.DeployerKey.From, Value: big.NewInt(100)}, + {To: chain.DeployerKey.From, Value: big.NewInt(500)}, + {To: chain.DeployerKey.From, Value: big.NewInt(1250)}, + }, + }, + From: mcmsState.Timelock.Address(), + McmsConfig: &example.MCMSConfig{ + MinDelay: time.Hour, + }, + }, + errorMsg: "sender does not have enough funds for transfers for chain selector 909606746561742123, required: 1850, available: 0", + }, + { + name: "invalid config: empty transfers", + cfg: example.LinkTransferConfig{Transfers: map[uint64][]example.TransferConfig{}}, + errorMsg: "transfers map must have at least one chainSel", + }, + { + name: "invalid chain selector", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + 1: {{To: common.Address{}, Value: big.NewInt(100)}}}, + }, + errorMsg: "invalid chain selector: unknown chain selector 1", + }, + { + name: "chain selector not found", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chain_selectors.ETHEREUM_TESTNET_GOERLI_ARBITRUM_1.Selector: {{To: common.Address{}, Value: big.NewInt(100)}}}, + }, + errorMsg: "chain with selector 6101244977088475029 not found", + }, + { + name: "empty transfer list", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: {}, + }, + }, + errorMsg: "transfers for chainSel 909606746561742123 must have at least one LinkTransfer", + }, + { + name: "empty value", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: { + {To: chain.DeployerKey.From, Value: nil}, + }, + }, + }, + errorMsg: "value for transfers must be set", + }, + { + name: "zero value", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: { + {To: chain.DeployerKey.From, Value: big.NewInt(0)}, + }, + }, + }, + errorMsg: "value for transfers must be non-zero", + }, + { + name: "negative value", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: { + {To: chain.DeployerKey.From, Value: big.NewInt(-5)}, + }, + }, + }, + errorMsg: "value for transfers must be positive", + }, + { + name: "non-evm-chain", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chain_selectors.APTOS_MAINNET.Selector: {{To: mcmsState.Timelock.Address(), Value: big.NewInt(100)}}}, + From: chain.DeployerKey.From, + }, + errorMsg: "chain selector 4741433654826277614 is not an EVM chain", + }, + { + name: "delay greater than max allowed", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: {{To: mcmsState.Timelock.Address(), Value: big.NewInt(100)}}}, + From: chain.DeployerKey.From, + McmsConfig: &example.MCMSConfig{ + MinDelay: time.Hour * 24 * 10, + }, + }, + errorMsg: "minDelay must be less than 7 days", + }, + { + name: "invalid config: transfer to address missing", + cfg: example.LinkTransferConfig{ + Transfers: map[uint64][]example.TransferConfig{ + chainSelector: {{To: common.Address{}, Value: big.NewInt(100)}}}, + }, + errorMsg: "'to' address for transfers must be set", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cfg.Validate(env) + if tt.errorMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/deployment/common/changeset/example/mint_link.go b/deployment/common/changeset/example/mint_link.go new file mode 100644 index 00000000000..dc50f8a1a27 --- /dev/null +++ b/deployment/common/changeset/example/mint_link.go @@ -0,0 +1,43 @@ +package example + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/common/changeset" +) + +type MintLinkConfig struct { + Amount *big.Int + ChainSelector uint64 + To common.Address +} + +var _ deployment.ChangeSet[*MintLinkConfig] = MintLink + +// MintLink mints LINK to the provided contract. +func MintLink(e deployment.Environment, cfg *MintLinkConfig) (deployment.ChangesetOutput, error) { + + chain := e.Chains[cfg.ChainSelector] + addresses, err := e.ExistingAddresses.AddressesForChain(cfg.ChainSelector) + if err != nil { + return deployment.ChangesetOutput{}, err + } + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addresses) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + tx, err := linkState.LinkToken.Mint(chain.DeployerKey, cfg.To, cfg.Amount) + if err != nil { + return deployment.ChangesetOutput{}, err + } + _, err = deployment.ConfirmIfNoError(chain, tx, err) + if err != nil { + return deployment.ChangesetOutput{}, err + } + return deployment.ChangesetOutput{}, nil + +} diff --git a/deployment/common/changeset/example/mint_link_test.go b/deployment/common/changeset/example/mint_link_test.go new file mode 100644 index 00000000000..1c60c3221de --- /dev/null +++ b/deployment/common/changeset/example/mint_link_test.go @@ -0,0 +1,58 @@ +package example_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/changeset/example" +) + +// TestMintLink tests the MintLink changeset +func TestMintLink(t *testing.T) { + t.Parallel() + env := setupLinkTransferTestEnv(t) + ctx := env.GetContext() + chainSelector := env.AllChainSelectors()[0] + chain := env.Chains[chainSelector] + + addrs, err := env.ExistingAddresses.AddressesForChain(chainSelector) + require.NoError(t, err) + require.Len(t, addrs, 6) + + mcmsState, err := changeset.MaybeLoadMCMSWithTimelockChainState(chain, addrs) + require.NoError(t, err) + linkState, err := changeset.MaybeLoadLinkTokenChainState(chain, addrs) + require.NoError(t, err) + + _, err = changeset.ApplyChangesets(t, env, nil, []changeset.ChangesetApplication{ + { + Changeset: changeset.WrapChangeSet(example.AddMintersBurnersLink), + Config: &example.AddMintersBurnersLinkConfig{ + ChainSelector: chainSelector, + Minters: []common.Address{chain.DeployerKey.From}, + }, + }, + }) + require.NoError(t, err) + + timelockAddress := mcmsState.Timelock.Address() + + // Mint some funds + _, err = example.MintLink(env, &example.MintLinkConfig{ + ChainSelector: chainSelector, + To: timelockAddress, + Amount: big.NewInt(7568), + }) + require.NoError(t, err) + + // check timelock balance + endBalance, err := linkState.LinkToken.BalanceOf(&bind.CallOpts{Context: ctx}, timelockAddress) + require.NoError(t, err) + expectedBalance := big.NewInt(7568) + require.Equal(t, expectedBalance, endBalance) +} diff --git a/deployment/common/changeset/internal/mcms_test.go b/deployment/common/changeset/internal/mcms_test.go index 8446aab4bfe..ff013717d30 100644 --- a/deployment/common/changeset/internal/mcms_test.go +++ b/deployment/common/changeset/internal/mcms_test.go @@ -40,7 +40,7 @@ func TestDeployMCMSWithTimelockContracts(t *testing.T) { addresses, err := ab.AddressesForChain(chainsel.TEST_90000001.Selector) require.NoError(t, err) require.Len(t, addresses, 5) - mcmsState, err := changeset.MaybeLoadMCMSWithTimelockState(chains[chainsel.TEST_90000001.Selector], addresses) + mcmsState, err := changeset.MaybeLoadMCMSWithTimelockChainState(chains[chainsel.TEST_90000001.Selector], addresses) require.NoError(t, err) v, err := mcmsState.GenerateMCMSWithTimelockView() b, err := json.MarshalIndent(v, "", " ") diff --git a/deployment/common/changeset/state.go b/deployment/common/changeset/state.go index c45fe6ba9b5..0db34abad71 100644 --- a/deployment/common/changeset/state.go +++ b/deployment/common/changeset/state.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/ethereum/go-ethereum/common" + owner_helpers "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" "github.com/smartcontractkit/chainlink/deployment" "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" @@ -22,17 +23,6 @@ type MCMSWithTimelockState struct { *proposalutils.MCMSWithTimelockContracts } -func MaybeLoadMCMSWithTimelockState(chain deployment.Chain, addresses map[string]deployment.TypeAndVersion) (*MCMSWithTimelockState, error) { - contracts, err := proposalutils.MaybeLoadMCMSWithTimelockContracts(chain, addresses) - if err != nil { - return nil, err - } - - return &MCMSWithTimelockState{ - MCMSWithTimelockContracts: contracts, - }, nil -} - func (state MCMSWithTimelockState) GenerateMCMSWithTimelockView() (v1_0.MCMSWithTimelockView, error) { if err := state.Validate(); err != nil { return v1_0.MCMSWithTimelockView{}, err @@ -66,6 +56,91 @@ func (state MCMSWithTimelockState) GenerateMCMSWithTimelockView() (v1_0.MCMSWith }, nil } +// MaybeLoadMCMSWithTimelockState loads the MCMSWithTimelockState state for each chain in the given environment. +func MaybeLoadMCMSWithTimelockState(env deployment.Environment, chainSelectors []uint64) (map[uint64]*MCMSWithTimelockState, error) { + result := map[uint64]*MCMSWithTimelockState{} + for _, chainSelector := range chainSelectors { + chain, ok := env.Chains[chainSelector] + if !ok { + return nil, fmt.Errorf("chain %d not found", chainSelector) + } + addressesChain, err := env.ExistingAddresses.AddressesForChain(chainSelector) + if err != nil { + return nil, err + } + state, err := MaybeLoadMCMSWithTimelockChainState(chain, addressesChain) + if err != nil { + return nil, err + } + result[chainSelector] = state + } + return result, nil +} + +// MaybeLoadMCMSWithTimelockChainState looks for the addresses corresponding to +// contracts deployed with DeployMCMSWithTimelock and loads them into a +// MCMSWithTimelockState struct. If none of the contracts are found, the state struct will be nil. +// An error indicates: +// - Found but was unable to load a contract +// - It only found part of the bundle of contracts +// - If found more than one instance of a contract (we expect one bundle in the given addresses) +func MaybeLoadMCMSWithTimelockChainState(chain deployment.Chain, addresses map[string]deployment.TypeAndVersion) (*MCMSWithTimelockState, error) { + state := MCMSWithTimelockState{ + MCMSWithTimelockContracts: &proposalutils.MCMSWithTimelockContracts{}, + } + // We expect one of each contract on the chain. + timelock := deployment.NewTypeAndVersion(types.RBACTimelock, deployment.Version1_0_0) + callProxy := deployment.NewTypeAndVersion(types.CallProxy, deployment.Version1_0_0) + proposer := deployment.NewTypeAndVersion(types.ProposerManyChainMultisig, deployment.Version1_0_0) + canceller := deployment.NewTypeAndVersion(types.CancellerManyChainMultisig, deployment.Version1_0_0) + bypasser := deployment.NewTypeAndVersion(types.BypasserManyChainMultisig, deployment.Version1_0_0) + + // Ensure we either have the bundle or not. + _, err := deployment.AddressesContainBundle(addresses, + map[deployment.TypeAndVersion]struct{}{ + timelock: {}, proposer: {}, canceller: {}, bypasser: {}, callProxy: {}, + }) + if err != nil { + return nil, fmt.Errorf("unable to check MCMS contracts on chain %s error: %w", chain.Name(), err) + } + + for address, tvStr := range addresses { + switch tvStr { + case timelock: + tl, err := owner_helpers.NewRBACTimelock(common.HexToAddress(address), chain.Client) + if err != nil { + return nil, err + } + state.Timelock = tl + case callProxy: + cp, err := owner_helpers.NewCallProxy(common.HexToAddress(address), chain.Client) + if err != nil { + return nil, err + } + state.CallProxy = cp + case proposer: + mcms, err := owner_helpers.NewManyChainMultiSig(common.HexToAddress(address), chain.Client) + if err != nil { + return nil, err + } + state.ProposerMcm = mcms + case bypasser: + mcms, err := owner_helpers.NewManyChainMultiSig(common.HexToAddress(address), chain.Client) + if err != nil { + return nil, err + } + state.BypasserMcm = mcms + case canceller: + mcms, err := owner_helpers.NewManyChainMultiSig(common.HexToAddress(address), chain.Client) + if err != nil { + return nil, err + } + state.CancellerMcm = mcms + } + } + return &state, nil +} + type LinkTokenState struct { LinkToken *link_token.LinkToken } @@ -77,7 +152,28 @@ func (s LinkTokenState) GenerateLinkView() (v1_0.LinkTokenView, error) { return v1_0.GenerateLinkTokenView(s.LinkToken) } -func MaybeLoadLinkTokenState(chain deployment.Chain, addresses map[string]deployment.TypeAndVersion) (*LinkTokenState, error) { +// MaybeLoadLinkTokenState loads the LinkTokenState state for each chain in the given environment. +func MaybeLoadLinkTokenState(env deployment.Environment, chainSelectors []uint64) (map[uint64]*LinkTokenState, error) { + result := map[uint64]*LinkTokenState{} + for _, chainSelector := range chainSelectors { + chain, ok := env.Chains[chainSelector] + if !ok { + return nil, fmt.Errorf("chain %d not found", chainSelector) + } + addressesChain, err := env.ExistingAddresses.AddressesForChain(chainSelector) + if err != nil { + return nil, err + } + state, err := MaybeLoadLinkTokenChainState(chain, addressesChain) + if err != nil { + return nil, err + } + result[chainSelector] = state + } + return result, nil +} + +func MaybeLoadLinkTokenChainState(chain deployment.Chain, addresses map[string]deployment.TypeAndVersion) (*LinkTokenState, error) { state := LinkTokenState{} linkToken := deployment.NewTypeAndVersion(types.LinkToken, deployment.Version1_0_0) // Perhaps revisit if we have a use case for multiple. diff --git a/deployment/common/changeset/transfer_to_mcms_with_timelock_test.go b/deployment/common/changeset/transfer_to_mcms_with_timelock_test.go index 40cef99a54f..7ba11596a2d 100644 --- a/deployment/common/changeset/transfer_to_mcms_with_timelock_test.go +++ b/deployment/common/changeset/transfer_to_mcms_with_timelock_test.go @@ -34,9 +34,9 @@ func TestTransferToMCMSWithTimelock(t *testing.T) { require.NoError(t, err) addrs, err := e.ExistingAddresses.AddressesForChain(chain1) require.NoError(t, err) - state, err := MaybeLoadMCMSWithTimelockState(e.Chains[chain1], addrs) + state, err := MaybeLoadMCMSWithTimelockChainState(e.Chains[chain1], addrs) require.NoError(t, err) - link, err := MaybeLoadLinkTokenState(e.Chains[chain1], addrs) + link, err := MaybeLoadLinkTokenChainState(e.Chains[chain1], addrs) require.NoError(t, err) e, err = ApplyChangesets(t, e, map[uint64]*proposalutils.TimelockExecutionContracts{ chain1: { @@ -56,7 +56,7 @@ func TestTransferToMCMSWithTimelock(t *testing.T) { }) require.NoError(t, err) // We expect now that the link token is owned by the MCMS timelock. - link, err = MaybeLoadLinkTokenState(e.Chains[chain1], addrs) + link, err = MaybeLoadLinkTokenChainState(e.Chains[chain1], addrs) require.NoError(t, err) o, err := link.LinkToken.Owner(nil) require.NoError(t, err) diff --git a/deployment/common/proposalutils/propose.go b/deployment/common/proposalutils/propose.go index feaee69940e..32a5bcdfda2 100644 --- a/deployment/common/proposalutils/propose.go +++ b/deployment/common/proposalutils/propose.go @@ -15,7 +15,8 @@ const ( DefaultValidUntil = 72 * time.Hour ) -func buildProposalMetadata( + +func BuildProposalMetadata( chainSelectors []uint64, proposerMcmsesPerChain map[uint64]*gethwrappers.ManyChainMultiSig, ) (map[mcms.ChainIdentifier]mcms.ChainMetadata, error) { @@ -56,7 +57,7 @@ func BuildProposalFromBatches( chains.Add(uint64(op.ChainIdentifier)) } - mcmsMd, err := buildProposalMetadata(chains.ToSlice(), proposerMcmsesPerChain) + mcmsMd, err := BuildProposalMetadata(chains.ToSlice(), proposerMcmsesPerChain) if err != nil { return nil, err } diff --git a/deployment/environment.go b/deployment/environment.go index c9de89b8c0c..0823404da2d 100644 --- a/deployment/environment.go +++ b/deployment/environment.go @@ -181,7 +181,7 @@ func MaybeDataErr(err error) error { var d rpc.DataError ok := errors.As(err, &d) if ok { - return d + return fmt.Errorf("%s: %v", d.Error(), d.ErrorData()) } return err } diff --git a/deployment/keystone/changeset/accept_ownership_test.go b/deployment/keystone/changeset/accept_ownership_test.go index 9e9d29e563a..d949e63c7aa 100644 --- a/deployment/keystone/changeset/accept_ownership_test.go +++ b/deployment/keystone/changeset/accept_ownership_test.go @@ -51,7 +51,7 @@ func TestAcceptAllOwnership(t *testing.T) { require.NoError(t, err) addrs, err := env.ExistingAddresses.AddressesForChain(registrySel) require.NoError(t, err) - timelock, err := commonchangeset.MaybeLoadMCMSWithTimelockState(env.Chains[registrySel], addrs) + timelock, err := commonchangeset.MaybeLoadMCMSWithTimelockChainState(env.Chains[registrySel], addrs) require.NoError(t, err) _, err = commonchangeset.ApplyChangesets(t, env, map[uint64]*proposalutils.TimelockExecutionContracts{ diff --git a/deployment/keystone/state.go b/deployment/keystone/state.go index cbf449c7f31..0ac7cdc89ed 100644 --- a/deployment/keystone/state.go +++ b/deployment/keystone/state.go @@ -78,7 +78,7 @@ func GetContractSets(lggr logger.Logger, req *GetContractSetsRequest) (*GetContr func loadContractSet(lggr logger.Logger, chain deployment.Chain, addresses map[string]deployment.TypeAndVersion) (*ContractSet, error) { var out ContractSet - mcmsWithTimelock, err := commonchangeset.MaybeLoadMCMSWithTimelockState(chain, addresses) + mcmsWithTimelock, err := commonchangeset.MaybeLoadMCMSWithTimelockChainState(chain, addresses) if err != nil { return nil, fmt.Errorf("failed to load mcms contract: %w", err) }