From 68c8f217b141860f8b958ddabf0b5fe491e9f568 Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Sat, 30 Nov 2024 17:11:25 +1100 Subject: [PATCH] feat(deployment): add link token transfer changeset Added a new changeset in shared package which transfer the link token. JIRA: https://smartcontract-it.atlassian.net/browse/DPA-1314 --- .../common/changeset/transfer_link_token.go | 187 +++++++++++++ .../changeset/transfer_link_token_test.go | 264 ++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 deployment/common/changeset/transfer_link_token.go create mode 100644 deployment/common/changeset/transfer_link_token_test.go diff --git a/deployment/common/changeset/transfer_link_token.go b/deployment/common/changeset/transfer_link_token.go new file mode 100644 index 00000000000..849648256be --- /dev/null +++ b/deployment/common/changeset/transfer_link_token.go @@ -0,0 +1,187 @@ +package changeset + +import ( + "errors" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/mcms" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/proposal/timelock" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/shared/generated/link_token" +) + +var _ deployment.ChangeSet[TransferLinkTokenConfig] = TransferLinkToken +var _ deployment.ChangeSet[TransferLinkTokenProposalConfig] = TransferLinkTokenProposal + +type Transfer struct { + To common.Address + Amount *big.Int +} + +type TransferLinkTokenConfig struct { + Transfers map[uint64]Transfer +} + +func (c TransferLinkTokenConfig) Validate() error { + for k, v := range c.Transfers { + if err := deployment.IsValidChainSelector(k); err != nil { + return err + } + + if v.To == (common.Address{}) { + return errors.New("to address must be set") + } + if v.Amount == nil || v.Amount.Sign() == -1 { + return errors.New("amount must be set and positive") + } + } + return nil +} + +// TransferLinkToken transfers link token to the to address on the chain identified by the chainSelector. +func TransferLinkToken(e deployment.Environment, config TransferLinkTokenConfig) (deployment.ChangesetOutput, error) { + if err := config.Validate(); err != nil { + return deployment.ChangesetOutput{}, err + } + + for chainSelector, transferConfig := range config.Transfers { + addresses, err := e.ExistingAddresses.AddressesForChain(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + for address, typeversion := range addresses { + if typeversion.Type == types.LinkToken && typeversion.Version == deployment.Version1_0_0 { + chain, ok := e.Chains[chainSelector] + if !ok { + return deployment.ChangesetOutput{}, fmt.Errorf("chain not found in environment") + } + contract, err := link_token.NewLinkToken(common.HexToAddress(address), chain.Client) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + tx, err := contract.Transfer(chain.DeployerKey, transferConfig.To, transferConfig.Amount) + if _, err = deployment.ConfirmIfNoError(chain, tx, err); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to confirm transfer link token to %s: %v", transferConfig.To, err) + } + e.Logger.Infow("Transferred LINK", + "to", transferConfig.To, + "amount", transferConfig.Amount, + "txHash", tx.Hash().Hex(), + "chainSelector", chainSelector) + + break + } + } + } + return deployment.ChangesetOutput{}, nil +} + +type TransferLinkTokenProposalConfig struct { + Transfers map[uint64]Transfer + + // OwnersPerChain is a mapping from chain selector to the owner contract address on that chain. + OwnersPerChain map[uint64]common.Address + // ProposerMCMSes is a mapping from chain selector to the proposer MCMS contract on that chain. + ProposerMCMSes map[uint64]*gethwrappers.ManyChainMultiSig + // MinDelay is the minimum amount of time that must pass before the proposal + // can be executed onchain. + // This is typically set to 3 hours but can be set to 0 for immediate execution (useful for tests). + MinDelay time.Duration +} + +func (c TransferLinkTokenProposalConfig) Validate() error { + for k, v := range c.Transfers { + if err := deployment.IsValidChainSelector(k); err != nil { + return err + } + + if v.To == (common.Address{}) { + return errors.New("to address must be set") + } + if v.Amount == nil || v.Amount.Sign() == -1 { + return errors.New("amount must be set and positive") + } + if _, ok := c.OwnersPerChain[k]; !ok { + return fmt.Errorf("missing owner for chain %d", k) + } + if _, ok := c.ProposerMCMSes[k]; !ok { + return fmt.Errorf("missing proposer MCMS for chain %d", k) + } + } + return nil +} + +// TransferLinkTokenProposal transfers link token to the to address on the chain identified by the chainSelector with proposal. +func TransferLinkTokenProposal(e deployment.Environment, config TransferLinkTokenProposalConfig) (deployment.ChangesetOutput, error) { + if err := config.Validate(); err != nil { + return deployment.ChangesetOutput{}, err + } + + var batches []timelock.BatchChainOperation + for chainSelector, transferConfig := range config.Transfers { + addresses, err := e.ExistingAddresses.AddressesForChain(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + for address, typeversion := range addresses { + if typeversion.Type == types.LinkToken && typeversion.Version == deployment.Version1_0_0 { + chain, ok := e.Chains[chainSelector] + if !ok { + return deployment.ChangesetOutput{}, fmt.Errorf("chain not found in environment") + } + contract, err := link_token.NewLinkToken(common.HexToAddress(address), chain.Client) + if err != nil { + return deployment.ChangesetOutput{}, err + } + + tx, err := contract.Transfer(chain.DeployerKey, transferConfig.To, transferConfig.Amount) + if _, err = deployment.ConfirmIfNoError(chain, tx, err); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to confirm transfer link token to %s: %v", transferConfig.To, err) + } + e.Logger.Infow("Transferring LINK", + "to", transferConfig.To, + "amount", transferConfig.Amount, + "txHash", tx.Hash().Hex(), + "chainSelector", chainSelector) + + batches = append(batches, timelock.BatchChainOperation{ + ChainIdentifier: mcms.ChainIdentifier(chainSelector), + Batch: []mcms.Operation{{ + To: contract.Address(), + Data: tx.Data(), + Value: big.NewInt(0), + }}, + }) + break + } + } + } + + if len(batches) == 0 { + return deployment.ChangesetOutput{}, errors.New("no link token contract found") + } + + proposal, err := proposalutils.BuildProposalFromBatches( + config.OwnersPerChain, + config.ProposerMCMSes, + batches, + "Transfer LINK", + config.MinDelay, + ) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to build proposal from batch: %w, batches: %+v", err, batches) + } + return deployment.ChangesetOutput{ + Proposals: []timelock.MCMSWithTimelockProposal{*proposal}, + }, nil +} diff --git a/deployment/common/changeset/transfer_link_token_test.go b/deployment/common/changeset/transfer_link_token_test.go new file mode 100644 index 00000000000..90b024820aa --- /dev/null +++ b/deployment/common/changeset/transfer_link_token_test.go @@ -0,0 +1,264 @@ +package changeset_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" + chainselectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/stretchr/testify/assert" + "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" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/shared/generated/link_token" +) + +func TestTransferLinkToken_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config changeset.TransferLinkTokenConfig + wantErr bool + wantErrMsg string + }{ + { + name: "valid config", + config: changeset.TransferLinkTokenConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + To: common.HexToAddress("0x1"), + Amount: big.NewInt(1), + }, + }, + }, + wantErr: false, + }, + { + name: "missing to address", + config: changeset.TransferLinkTokenConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + Amount: big.NewInt(1), + }, + }, + }, + wantErr: true, + wantErrMsg: "to address must be set", + }, + { + name: "missing amount", + config: changeset.TransferLinkTokenConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + To: common.HexToAddress("0x1"), + }, + }, + }, + wantErr: true, + wantErrMsg: "amount must be set and positive", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := test.config.Validate() + if test.wantErr { + if test.wantErrMsg != "" { + assert.Contains(t, err.Error(), test.wantErrMsg) + } + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestTransferLinkTokenProposalConfig_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config changeset.TransferLinkTokenProposalConfig + wantErr bool + wantErrMsg string + }{ + { + name: "valid config", + config: changeset.TransferLinkTokenProposalConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + To: common.HexToAddress("0x1"), + Amount: big.NewInt(1), + }, + }, + OwnersPerChain: map[uint64]common.Address{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: common.HexToAddress("0x1"), + }, + ProposerMCMSes: map[uint64]*gethwrappers.ManyChainMultiSig{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: {}, + }, + MinDelay: 0, + }, + wantErr: false, + }, + { + name: "missing to address", + config: changeset.TransferLinkTokenProposalConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + Amount: big.NewInt(1), + }, + }, + OwnersPerChain: map[uint64]common.Address{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: common.HexToAddress("0x1"), + }, + ProposerMCMSes: map[uint64]*gethwrappers.ManyChainMultiSig{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: {}, + }, + MinDelay: 0, + }, + wantErr: true, + wantErrMsg: "to address must be set", + }, + { + name: "missing amount", + config: changeset.TransferLinkTokenProposalConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + To: common.HexToAddress("0x1"), + }, + }, + OwnersPerChain: map[uint64]common.Address{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: common.HexToAddress("0x1"), + }, + ProposerMCMSes: map[uint64]*gethwrappers.ManyChainMultiSig{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: {}, + }, + MinDelay: 0, + }, + wantErr: true, + wantErrMsg: "amount must be set and positive", + }, + { + name: "missing chain in OwnersPerChain", + config: changeset.TransferLinkTokenProposalConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + To: common.HexToAddress("0x1"), + Amount: big.NewInt(1), + }, + }, + OwnersPerChain: map[uint64]common.Address{ + 1: common.HexToAddress("0x1"), + }, + ProposerMCMSes: map[uint64]*gethwrappers.ManyChainMultiSig{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: {}, + }, + MinDelay: 0, + }, + wantErr: true, + wantErrMsg: "missing owner for chain 16015286601757825753", + }, + { + name: "missing chain in ProposerMCMSes", + config: changeset.TransferLinkTokenProposalConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: { + To: common.HexToAddress("0x1"), + Amount: big.NewInt(1), + }, + }, + OwnersPerChain: map[uint64]common.Address{ + chainselectors.ETHEREUM_TESTNET_SEPOLIA.Selector: common.HexToAddress("0x1"), + }, + ProposerMCMSes: map[uint64]*gethwrappers.ManyChainMultiSig{ + 1: {}, + }, + MinDelay: 0, + }, + wantErr: true, + wantErrMsg: "missing proposer MCMS for chain 16015286601757825753", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := test.config.Validate() + if test.wantErr { + if test.wantErrMsg != "" { + assert.Contains(t, err.Error(), test.wantErrMsg) + } + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestTransferLinkToken(t *testing.T) { + t.Parallel() + + lggr := logger.Test(t) + cfg := memory.MemoryEnvironmentConfig{ + Nodes: 1, + Chains: 1, + } + env := memory.NewMemoryEnvironment(t, lggr, zapcore.DebugLevel, cfg) + chainSelectorId := env.AllChainSelectors()[0] + + chain := env.Chains[chainSelectorId] + deployer := chain.DeployerKey + tokenContract, err := deployment.DeployContract(lggr, chain, env.ExistingAddresses, + func(chain deployment.Chain) deployment.ContractDeploy[*link_token.LinkToken] { + tokenAddress, tx, token, err2 := link_token.DeployLinkToken( + deployer, + chain.Client, + ) + return deployment.ContractDeploy[*link_token.LinkToken]{ + tokenAddress, token, tx, deployment.NewTypeAndVersion(types.LinkToken, deployment.Version1_0_0), err2, + } + }) + require.NoError(t, err) + + tx, err := tokenContract.Contract.GrantMintRole(deployer, deployer.From) + require.NoError(t, err) + _, err = chain.Confirm(tx) + + tx, err = tokenContract.Contract.Mint(deployer, deployer.From, big.NewInt(100)) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + receiver := common.HexToAddress("0x1") + _, err = changeset.TransferLinkToken(env, + changeset.TransferLinkTokenConfig{ + Transfers: map[uint64]changeset.Transfer{ + chainSelectorId: { + To: receiver, + Amount: big.NewInt(30), + }, + }, + }) + require.NoError(t, err) + + balance, err := tokenContract.Contract.BalanceOf(nil, deployer.From) + require.NoError(t, err) + require.Equal(t, big.NewInt(70), balance) + + balance, err = tokenContract.Contract.BalanceOf(nil, receiver) + require.NoError(t, err) + require.Equal(t, big.NewInt(30), balance) +}