diff --git a/deployment/ccip/changeset/cs_accept_admin_role.go b/deployment/ccip/changeset/cs_accept_admin_role.go new file mode 100644 index 00000000000..68ce58518bf --- /dev/null +++ b/deployment/ccip/changeset/cs_accept_admin_role.go @@ -0,0 +1,59 @@ +package changeset + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_admin_registry" +) + +var _ deployment.ChangeSet[TokenAdminRegistryChangesetConfig] = AcceptAdminRoleChangeset + +func validateAcceptAdminRole( + config token_admin_registry.TokenAdminRegistryTokenConfig, + sender common.Address, + externalAdmin common.Address, + symbol TokenSymbol, + chain deployment.Chain, +) error { + // We must be the pending administrator + if config.PendingAdministrator != sender { + return fmt.Errorf("unable to accept admin role for %s token on %s: %s is not the pending administrator (%s)", symbol, chain, sender, config.PendingAdministrator) + } + return nil +} + +// AcceptAdminRoleChangeset accepts admin rights for tokens on the token admin registry. +func AcceptAdminRoleChangeset(env deployment.Environment, c TokenAdminRegistryChangesetConfig) (deployment.ChangesetOutput, error) { + if err := c.Validate(env, false, validateAcceptAdminRole); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("invalid TokenAdminRegistryChangesetConfig: %w", err) + } + state, err := LoadOnchainState(env) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to load onchain state: %w", err) + } + deployerGroup := NewDeployerGroup(env, state, c.MCMS) + + for chainSelector, tokenSymbolToPoolInfo := range c.Pools { + chain := env.Chains[chainSelector] + chainState := state.Chains[chainSelector] + opts, err := deployerGroup.GetDeployer(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get deployer for %s", chain) + } + for symbol, poolInfo := range tokenSymbolToPoolInfo { + _, tokenAddress, err := poolInfo.GetPoolAndTokenAddress(env.GetContext(), symbol, chain, chainState) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get state of %s token on chain %s: %w", symbol, chain, err) + } + _, err = chainState.TokenAdminRegistry.AcceptAdminRole(opts, tokenAddress) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to create acceptAdminRole transaction for %s on %s registry: %w", symbol, chain, err) + } + } + } + + return deployerGroup.Enact("accept admin role for tokens on token admin registries") +} diff --git a/deployment/ccip/changeset/cs_accept_admin_role_test.go b/deployment/ccip/changeset/cs_accept_admin_role_test.go new file mode 100644 index 00000000000..8d7c8cb6e9f --- /dev/null +++ b/deployment/ccip/changeset/cs_accept_admin_role_test.go @@ -0,0 +1,207 @@ +package changeset_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/v2/core/logger" +) + +func TestAcceptAdminRoleChangeset_Validations(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, true) + + mcmsConfig := &changeset.MCMSConfig{ + MinDelay: 0 * time.Second, + } + + tests := []struct { + Config changeset.TokenAdminRegistryChangesetConfig + ErrStr string + Msg string + }{ + { + Msg: "Chain selector is invalid", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 0: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "failed to validate chain selector 0", + }, + { + Msg: "Chain selector doesn't exist in environment", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 5009297550715157269: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "does not exist in environment", + }, + { + Msg: "Invalid pool type", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: "InvalidType", + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "InvalidType is not a known token pool type", + }, + { + Msg: "Invalid pool version", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_0_0, + }, + }, + }, + }, + ErrStr: "1.0.0 is not a known token pool version", + }, + { + Msg: "Not pending admin", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "is not the pending administrator", + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + _, err := commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.AcceptAdminRoleChangeset), + Config: test.Config, + }, + }) + require.Error(t, err) + require.ErrorContains(t, err, test.ErrStr) + }) + } +} + +func TestAcceptAdminRoleChangeset_Execution(t *testing.T) { + for _, mcmsConfig := range []*changeset.MCMSConfig{nil, &changeset.MCMSConfig{MinDelay: 0 * time.Second}} { + msg := "Accept admin role with MCMS" + if mcmsConfig == nil { + msg = "Accept admin role without MCMS" + } + + t.Run(msg, func(t *testing.T) { + e, selectorA, selectorB, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), mcmsConfig != nil) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + selectorB: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorB].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, mcmsConfig != nil) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + registryOnA := state.Chains[selectorA].TokenAdminRegistry + registryOnB := state.Chains[selectorB].TokenAdminRegistry + + e, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.AcceptAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + configOnA, err := registryOnA.GetTokenConfig(nil, tokens[selectorA].Address) + require.NoError(t, err) + if mcmsConfig != nil { + require.Equal(t, state.Chains[selectorA].Timelock.Address(), configOnA.Administrator) + } else { + require.Equal(t, e.Chains[selectorA].DeployerKey.From, configOnA.Administrator) + } + + configOnB, err := registryOnB.GetTokenConfig(nil, tokens[selectorB].Address) + require.NoError(t, err) + if mcmsConfig != nil { + require.Equal(t, state.Chains[selectorB].Timelock.Address(), configOnB.Administrator) + } else { + require.Equal(t, e.Chains[selectorB].DeployerKey.From, configOnB.Administrator) + } + }) + } +} diff --git a/deployment/ccip/changeset/cs_configure_token_pools.go b/deployment/ccip/changeset/cs_configure_token_pools.go new file mode 100644 index 00000000000..bbddfaa43c2 --- /dev/null +++ b/deployment/ccip/changeset/cs_configure_token_pools.go @@ -0,0 +1,326 @@ +package changeset + +import ( + "bytes" + "context" + "errors" + "fmt" + "math/big" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + commoncs "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_admin_registry" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_pool" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +var _ deployment.ChangeSet[ConfigureTokenPoolContractsConfig] = ConfigureTokenPoolContractsChangeset + +// RateLimiterConfig defines the inbound and outbound rate limits for a remote chain. +type RateLimiterConfig struct { + // Inbound is the rate limiter config for inbound transfers from a remote chain. + Inbound token_pool.RateLimiterConfig + // Outbound is the rate limiter config for outbound transfers to a remote chain. + Outbound token_pool.RateLimiterConfig +} + +// validateRateLimterConfig validates rate and capacity in accordance with on-chain code. +// see https://github.com/smartcontractkit/chainlink/blob/develop/contracts/src/v0.8/ccip/libraries/RateLimiter.sol. +func validateRateLimiterConfig(rateLimiterConfig token_pool.RateLimiterConfig) error { + zero := big.NewInt(0) + if rateLimiterConfig.IsEnabled { + if rateLimiterConfig.Rate.Cmp(rateLimiterConfig.Capacity) >= 0 || rateLimiterConfig.Rate.Cmp(zero) == 0 { + return errors.New("rate must be greater than 0 and less than capacity") + } + } else { + if rateLimiterConfig.Rate.Cmp(zero) != 0 || rateLimiterConfig.Capacity.Cmp(zero) != 0 { + return errors.New("rate and capacity must be 0") + } + } + return nil +} + +// RateLimiterPerChain defines rate limits for remote chains. +type RateLimiterPerChain map[uint64]RateLimiterConfig + +func (c RateLimiterPerChain) Validate() error { + for chainSelector, chainConfig := range c { + if err := validateRateLimiterConfig(chainConfig.Inbound); err != nil { + return fmt.Errorf("validation of inbound rate limiter config for remote chain with selector %d failed: %w", chainSelector, err) + } + if err := validateRateLimiterConfig(chainConfig.Outbound); err != nil { + return fmt.Errorf("validation of outbound rate limiter config for remote chain with selector %d failed: %w", chainSelector, err) + } + } + return nil +} + +// TokenPoolConfig defines all the information required of the user to configure a token pool. +type TokenPoolConfig struct { + // ChainUpdates defines the chains and corresponding rate limits that should be defined on the token pool. + ChainUpdates RateLimiterPerChain + // Type is the type of the token pool. + Type deployment.ContractType + // Version is the version of the token pool. + Version semver.Version +} + +func (c TokenPoolConfig) Validate(ctx context.Context, chain deployment.Chain, state CCIPChainState, useMcms bool, tokenSymbol TokenSymbol) error { + // Ensure that the inputted type is known + if _, ok := tokenPoolTypes[c.Type]; !ok { + return fmt.Errorf("%s is not a known token pool type", c.Type) + } + + // Ensure that the inputted version is known + if _, ok := tokenPoolVersions[c.Version]; !ok { + return fmt.Errorf("%s is not a known token pool version", c.Version) + } + + // Ensure that a pool with given symbol, type and version is known to the environment + tokenPoolAddress, ok := getTokenPoolAddressFromSymbolTypeAndVersion(state, chain, tokenSymbol, c.Type, c.Version) + if !ok { + return fmt.Errorf("token pool does not exist on %s with symbol %s, type %s, and version %s", chain.String(), tokenSymbol, c.Type, c.Version) + } + + // Validate that the token pool is owned by the address that will be actioning the transactions (i.e. Timelock or deployer key) + if err := commoncs.ValidateOwnership(ctx, useMcms, chain.DeployerKey.From, state.Timelock.Address(), state.TokenAdminRegistry); err != nil { + return fmt.Errorf("token pool with address %s on %s failed ownership validation: %w", tokenPoolAddress, chain.String(), err) + } + + // Validate chain configurations, namely rate limits + if err := c.ChainUpdates.Validate(); err != nil { + return fmt.Errorf("failed to validate chain updates: %w", err) + } + + return nil +} + +// ConfigureTokenPoolContractsConfig is the configuration for the ConfigureTokenPoolContractsConfig changeset. +type ConfigureTokenPoolContractsConfig struct { + // MCMS defines the delay to use for Timelock (if absent, the changeset will attempt to use the deployer key). + MCMS *MCMSConfig + // PoolUpdates defines the changes that we want to make to the token pool on a chain + PoolUpdates map[uint64]TokenPoolConfig + // Symbol is the symbol of the token of interest. + TokenSymbol TokenSymbol +} + +func (c ConfigureTokenPoolContractsConfig) Validate(env deployment.Environment) error { + if c.TokenSymbol == "" { + return errors.New("token symbol must be defined") + } + state, err := LoadOnchainState(env) + if err != nil { + return fmt.Errorf("failed to load onchain state: %w", err) + } + for chainSelector, poolUpdate := range c.PoolUpdates { + err := deployment.IsValidChainSelector(chainSelector) + if err != nil { + return fmt.Errorf("failed to validate chain selector %d: %w", chainSelector, err) + } + chain, ok := env.Chains[chainSelector] + if !ok { + return fmt.Errorf("chain with selector %d does not exist in environment", chainSelector) + } + chainState, ok := state.Chains[chainSelector] + if !ok { + return fmt.Errorf("%s does not exist in state", chain.String()) + } + for remoteChainSelector := range poolUpdate.ChainUpdates { + remotePoolUpdate, ok := c.PoolUpdates[remoteChainSelector] + if !ok { + return fmt.Errorf("%s is expecting a pool update to be defined for chain with selector %d", chain.String(), remoteChainSelector) + } + missingErr := fmt.Errorf("%s is expecting pool update on chain with selector %d to define a chain config pointing back to it", chain.String(), remoteChainSelector) + if remotePoolUpdate.ChainUpdates == nil { + return missingErr + } + if _, ok := remotePoolUpdate.ChainUpdates[chainSelector]; !ok { + return missingErr + } + } + if tokenAdminRegistry := chainState.TokenAdminRegistry; tokenAdminRegistry == nil { + return fmt.Errorf("missing tokenAdminRegistry on %s", chain.String()) + } + if c.MCMS != nil { + if timelock := chainState.Timelock; timelock == nil { + return fmt.Errorf("missing timelock on %s", chain.String()) + } + if proposerMcm := chainState.ProposerMcm; proposerMcm == nil { + return fmt.Errorf("missing proposerMcm on %s", chain.String()) + } + } + if err := poolUpdate.Validate(env.GetContext(), chain, chainState, c.MCMS != nil, c.TokenSymbol); err != nil { + return fmt.Errorf("invalid pool update on %s: %w", chain.String(), err) + } + } + + return nil +} + +// ConfigureTokenPoolContractsChangeset configures pools for a given token across multiple chains. +// The outputted MCMS proposal will update chain configurations on each pool, encompassing new chain additions and rate limit changes. +// Removing chain support is not in scope for this changeset. +func ConfigureTokenPoolContractsChangeset(env deployment.Environment, c ConfigureTokenPoolContractsConfig) (deployment.ChangesetOutput, error) { + if err := c.Validate(env); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("invalid ConfigureTokenPoolContractsConfig: %w", err) + } + state, err := LoadOnchainState(env) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to load onchain state: %w", err) + } + deployerGroup := NewDeployerGroup(env, state, c.MCMS) + + for chainSelector := range c.PoolUpdates { + chain := env.Chains[chainSelector] + + opts, err := deployerGroup.GetDeployer(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get deployer for %s", chain) + } + err = configureTokenPool(env.GetContext(), opts, env.Chains, state, c, chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to make operations to configure %s token pool on %s: %w", c.TokenSymbol, chain.String(), err) + } + } + + return deployerGroup.Enact(fmt.Sprintf("configure %s token pools", c.TokenSymbol)) +} + +// configureTokenPool creates all transactions required to configure the desired token pool on a chain, +// either applying the transactions with the deployer key or returning an MCMS proposal. +func configureTokenPool( + ctx context.Context, + opts *bind.TransactOpts, + chains map[uint64]deployment.Chain, + state CCIPOnChainState, + config ConfigureTokenPoolContractsConfig, + chainSelector uint64, +) error { + poolUpdate := config.PoolUpdates[chainSelector] + chain := chains[chainSelector] + tokenPool, _, tokenConfig, err := getTokenStateFromPool(ctx, config.TokenSymbol, poolUpdate.Type, poolUpdate.Version, chain, state.Chains[chainSelector]) + if err != nil { + return fmt.Errorf("failed to get token state from pool with address %s on %s: %w", tokenPool.Address(), chain.String(), err) + } + + // For adding chain support + var chainAdditions []token_pool.TokenPoolChainUpdate + // For updating rate limits + var remoteChainSelectorsToUpdate []uint64 + var updatedOutboundConfigs []token_pool.RateLimiterConfig + var updatedInboundConfigs []token_pool.RateLimiterConfig + // For adding remote pools + remotePoolAddressAdditions := make(map[uint64]common.Address) + + for remoteChainSelector, chainUpdate := range poolUpdate.ChainUpdates { + isSupportedChain, err := tokenPool.IsSupportedChain(&bind.CallOpts{Context: ctx}, remoteChainSelector) + if err != nil { + return fmt.Errorf("failed to check if %d is supported on pool with address %s on %s: %w", remoteChainSelector, tokenPool.Address(), chain.String(), err) + } + remoteChain := chains[remoteChainSelector] + remotePoolUpdate := config.PoolUpdates[remoteChainSelector] + remoteTokenPool, remoteTokenAddress, remoteTokenConfig, err := getTokenStateFromPool(ctx, config.TokenSymbol, remotePoolUpdate.Type, remotePoolUpdate.Version, remoteChain, state.Chains[remoteChainSelector]) + if err != nil { + return fmt.Errorf("failed to get token state from pool with address %s on %s: %w", tokenPool.Address(), chain.String(), err) + } + if isSupportedChain { + // Just update the rate limits if the chain is already supported + remoteChainSelectorsToUpdate = append(remoteChainSelectorsToUpdate, remoteChainSelector) + updatedOutboundConfigs = append(updatedOutboundConfigs, chainUpdate.Outbound) + updatedInboundConfigs = append(updatedInboundConfigs, chainUpdate.Inbound) + // Also, add a new remote pool if the token pool on the remote chain is being updated + if remoteTokenConfig.TokenPool != utils.ZeroAddress && remoteTokenConfig.TokenPool != remoteTokenPool.Address() { + remotePoolAddressAdditions[remoteChainSelector] = remoteTokenPool.Address() + } + } else { + // Add chain support if it doesn't yet exist + // First, we need to assemble a list of valid remote pools + // The desired token pool on the remote chain is added by default + var remotePoolAddresses [][]byte + remotePoolAddresses = append(remotePoolAddresses, remoteTokenPool.Address().Bytes()) + // If the desired token pool is updating an old one, we still need to support the remote pool addresses that the old pool supported to ensure 0 downtime + if tokenConfig.TokenPool != utils.ZeroAddress && tokenConfig.TokenPool != tokenPool.Address() { + activeTokenPool, err := token_pool.NewTokenPool(tokenConfig.TokenPool, chain.Client) + if err != nil { + return fmt.Errorf("failed to connect pool with address %s on %s with token pool bindings: %w", tokenConfig.TokenPool, chain.String(), err) + } + remotePoolAddressesOnChain, err := activeTokenPool.GetRemotePools(&bind.CallOpts{Context: ctx}, remoteChainSelector) + if err != nil { + return fmt.Errorf("failed to fetch remote pools from token pool with address %s on chain %s: %w", tokenConfig.TokenPool, chain.String(), err) + } + for _, address := range remotePoolAddressesOnChain { + if !bytes.Equal(address, remoteTokenPool.Address().Bytes()) { + remotePoolAddresses = append(remotePoolAddresses, remotePoolAddressesOnChain...) + } + } + } + chainAdditions = append(chainAdditions, token_pool.TokenPoolChainUpdate{ + RemoteChainSelector: remoteChainSelector, + InboundRateLimiterConfig: chainUpdate.Inbound, + OutboundRateLimiterConfig: chainUpdate.Outbound, + RemoteTokenAddress: remoteTokenAddress.Bytes(), + RemotePoolAddresses: remotePoolAddresses, + }) + } + } + + // Handle new chain support + if len(chainAdditions) > 0 { + _, err := tokenPool.ApplyChainUpdates(opts, []uint64{}, chainAdditions) + if err != nil { + return fmt.Errorf("failed to create applyChainUpdates transaction for token pool with address %s: %w", tokenPool.Address(), err) + } + } + + // Handle updates to existing chain support + if len(remoteChainSelectorsToUpdate) > 0 { + _, err := tokenPool.SetChainRateLimiterConfigs(opts, remoteChainSelectorsToUpdate, updatedOutboundConfigs, updatedInboundConfigs) + if err != nil { + return fmt.Errorf("failed to create setChainRateLimiterConfigs transaction for token pool with address %s: %w", tokenPool.Address(), err) + } + } + + // Handle remote pool additions + for remoteChainSelector, remotePoolAddress := range remotePoolAddressAdditions { + _, err := tokenPool.AddRemotePool(opts, remoteChainSelector, remotePoolAddress.Bytes()) + if err != nil { + return fmt.Errorf("failed to create addRemotePool transaction for token pool with address %s: %w", tokenPool.Address(), err) + } + } + + return nil +} + +// getTokenStateFromPool fetches the token config from the registry given the pool address +func getTokenStateFromPool( + ctx context.Context, + symbol TokenSymbol, + poolType deployment.ContractType, + version semver.Version, + chain deployment.Chain, + state CCIPChainState, +) (*token_pool.TokenPool, common.Address, token_admin_registry.TokenAdminRegistryTokenConfig, error) { + tokenPoolAddress, ok := getTokenPoolAddressFromSymbolTypeAndVersion(state, chain, symbol, poolType, version) + if !ok { + return nil, utils.ZeroAddress, token_admin_registry.TokenAdminRegistryTokenConfig{}, fmt.Errorf("token pool does not exist on %s with symbol %s, type %s, and version %s", chain.String(), symbol, poolType, version) + } + tokenPool, err := token_pool.NewTokenPool(tokenPoolAddress, chain.Client) + if err != nil { + return nil, utils.ZeroAddress, token_admin_registry.TokenAdminRegistryTokenConfig{}, fmt.Errorf("failed to connect token pool with address %s on chain %s to token pool bindings: %w", tokenPoolAddress, chain, err) + } + tokenAddress, err := tokenPool.GetToken(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, utils.ZeroAddress, token_admin_registry.TokenAdminRegistryTokenConfig{}, fmt.Errorf("failed to get token from pool with address %s on %s: %w", tokenPool.Address(), chain.String(), err) + } + tokenAdminRegistry := state.TokenAdminRegistry + tokenConfig, err := tokenAdminRegistry.GetTokenConfig(&bind.CallOpts{Context: ctx}, tokenAddress) + if err != nil { + return nil, utils.ZeroAddress, token_admin_registry.TokenAdminRegistryTokenConfig{}, fmt.Errorf("failed to get config of token with address %s from registry on %s: %w", tokenAddress, chain.String(), err) + } + return tokenPool, tokenAddress, tokenConfig, nil +} diff --git a/deployment/ccip/changeset/cs_configure_token_pools_test.go b/deployment/ccip/changeset/cs_configure_token_pools_test.go new file mode 100644 index 00000000000..749c6ab8f59 --- /dev/null +++ b/deployment/ccip/changeset/cs_configure_token_pools_test.go @@ -0,0 +1,624 @@ +package changeset_test + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/environment/memory" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/shared/generated/burn_mint_erc677" + "github.com/smartcontractkit/chainlink/v2/core/logger" +) + +// createSymmetricRateLimits is a utility to quickly create a rate limiter config with equal inbound and outbound values. +func createSymmetricRateLimits(rate int64, capacity int64) changeset.RateLimiterConfig { + return changeset.RateLimiterConfig{ + Inbound: token_pool.RateLimiterConfig{ + IsEnabled: rate != 0 || capacity != 0, + Rate: big.NewInt(rate), + Capacity: big.NewInt(capacity), + }, + Outbound: token_pool.RateLimiterConfig{ + IsEnabled: rate != 0 || capacity != 0, + Rate: big.NewInt(rate), + Capacity: big.NewInt(capacity), + }, + } +} + +// validateMemberOfTokenPoolPair performs checks required to validate that a token pool is fully configured for cross-chain transfer. +func validateMemberOfTokenPoolPair( + t *testing.T, + state changeset.CCIPOnChainState, + tokenPool *token_pool.TokenPool, + expectedRemotePools []string, + tokens map[uint64]*deployment.ContractDeploy[*burn_mint_erc677.BurnMintERC677], + tokenSymbol changeset.TokenSymbol, + chainSelector uint64, + rate *big.Int, + capacity *big.Int, + expectedOwner common.Address, +) { + // Verify that the owner is expected + owner, err := tokenPool.Owner(nil) + require.NoError(t, err) + require.Equal(t, expectedOwner, owner) + + // Fetch the supported remote chains + supportedChains, err := tokenPool.GetSupportedChains(nil) + require.NoError(t, err) + + // Verify that the rate limits and remote addresses are correct + for _, supportedChain := range supportedChains { + inboundConfig, err := tokenPool.GetCurrentInboundRateLimiterState(nil, supportedChain) + require.NoError(t, err) + require.True(t, inboundConfig.IsEnabled) + require.Equal(t, capacity, inboundConfig.Capacity) + require.Equal(t, rate, inboundConfig.Rate) + + outboundConfig, err := tokenPool.GetCurrentOutboundRateLimiterState(nil, supportedChain) + require.NoError(t, err) + require.True(t, outboundConfig.IsEnabled) + require.Equal(t, capacity, outboundConfig.Capacity) + require.Equal(t, rate, outboundConfig.Rate) + + remoteTokenAddress, err := tokenPool.GetRemoteToken(nil, supportedChain) + require.NoError(t, err) + require.Equal(t, tokens[supportedChain].Address.Bytes(), remoteTokenAddress) + + remotePoolAddresses, err := tokenPool.GetRemotePools(nil, supportedChain) + require.NoError(t, err) + + remotePoolsStr := make([]string, len(remotePoolAddresses)) + for i, remotePool := range remotePoolAddresses { + remotePoolsStr[i] = common.HexToAddress(common.Bytes2Hex(remotePool)).String() + } + require.ElementsMatch(t, expectedRemotePools, remotePoolsStr) + } +} + +func TestValidateRemoteChains(t *testing.T) { + t.Parallel() + + tests := []struct { + IsEnabled bool + Rate *big.Int + Capacity *big.Int + ErrStr string + }{ + { + IsEnabled: false, + Rate: big.NewInt(1), + Capacity: big.NewInt(10), + ErrStr: "rate and capacity must be 0", + }, + { + IsEnabled: true, + Rate: big.NewInt(0), + Capacity: big.NewInt(10), + ErrStr: "rate must be greater than 0 and less than capacity", + }, + { + IsEnabled: true, + Rate: big.NewInt(11), + Capacity: big.NewInt(10), + ErrStr: "rate must be greater than 0 and less than capacity", + }, + } + + for _, test := range tests { + t.Run(test.ErrStr, func(t *testing.T) { + remoteChains := changeset.RateLimiterPerChain{ + 1: { + Inbound: token_pool.RateLimiterConfig{ + IsEnabled: test.IsEnabled, + Rate: test.Rate, + Capacity: test.Capacity, + }, + Outbound: token_pool.RateLimiterConfig{ + IsEnabled: test.IsEnabled, + Rate: test.Rate, + Capacity: test.Capacity, + }, + }, + } + + err := remoteChains.Validate() + require.Error(t, err) + require.Contains(t, err.Error(), test.ErrStr) + }) + } +} + +func TestValidateTokenPoolConfig(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, _ := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, true) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + tests := []struct { + UseMcms bool + TokenPoolConfig changeset.TokenPoolConfig + ErrStr string + Msg string + }{ + { + Msg: "Pool type is invalid", + TokenPoolConfig: changeset.TokenPoolConfig{}, + ErrStr: "is not a known token pool type", + }, + { + Msg: "Pool version is invalid", + TokenPoolConfig: changeset.TokenPoolConfig{ + Type: changeset.BurnMintTokenPool, + }, + ErrStr: "is not a known token pool version", + }, + { + Msg: "Pool is not owned by required address", + TokenPoolConfig: changeset.TokenPoolConfig{ + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + ErrStr: "failed ownership validation", + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + err := test.TokenPoolConfig.Validate(e.GetContext(), e.Chains[selectorA], state.Chains[selectorA], test.UseMcms, testhelpers.TestTokenSymbol) + require.Error(t, err) + require.ErrorContains(t, err, test.ErrStr) + }) + } +} + +func TestValidateConfigureTokenPoolContractsConfig(t *testing.T) { + t.Parallel() + + lggr := logger.TestLogger(t) + e := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{ + Chains: 2, + }) + + tests := []struct { + TokenSymbol changeset.TokenSymbol + Input changeset.ConfigureTokenPoolContractsConfig + ErrStr string + Msg string + }{ + { + Msg: "Token symbol is missing", + Input: changeset.ConfigureTokenPoolContractsConfig{}, + ErrStr: "token symbol must be defined", + }, + { + Msg: "Chain selector is invalid", + Input: changeset.ConfigureTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + PoolUpdates: map[uint64]changeset.TokenPoolConfig{ + 0: changeset.TokenPoolConfig{}, + }, + }, + ErrStr: "failed to validate chain selector 0", + }, + { + Msg: "Chain selector doesn't exist in environment", + Input: changeset.ConfigureTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + PoolUpdates: map[uint64]changeset.TokenPoolConfig{ + 5009297550715157269: changeset.TokenPoolConfig{}, + }, + }, + ErrStr: "does not exist in environment", + }, + { + Msg: "Corresponding pool update missing", + Input: changeset.ConfigureTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + PoolUpdates: map[uint64]changeset.TokenPoolConfig{ + e.AllChainSelectors()[0]: changeset.TokenPoolConfig{ + ChainUpdates: changeset.RateLimiterPerChain{ + e.AllChainSelectors()[1]: changeset.RateLimiterConfig{}, + }, + }, + }, + }, + ErrStr: "is expecting a pool update to be defined for chain with selector", + }, + /* This test condition is flakey, as we will see "missing tokenAdminRegistry" if e.AllChainSelectors()[1] is checked first + { + Msg: "Corresponding pool update missing a chain update", + Input: changeset.ConfigureTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + PoolUpdates: map[uint64]changeset.TokenPoolConfig{ + e.AllChainSelectors()[0]: changeset.TokenPoolConfig{ + ChainUpdates: changeset.RateLimiterPerChain{ + e.AllChainSelectors()[1]: changeset.RateLimiterConfig{}, + }, + }, + e.AllChainSelectors()[1]: changeset.TokenPoolConfig{}, + }, + }, + ErrStr: "to define a chain config pointing back to it", + }, + */ + { + Msg: "Token admin registry is missing", + Input: changeset.ConfigureTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + PoolUpdates: map[uint64]changeset.TokenPoolConfig{ + e.AllChainSelectors()[0]: changeset.TokenPoolConfig{ + ChainUpdates: changeset.RateLimiterPerChain{ + e.AllChainSelectors()[1]: changeset.RateLimiterConfig{}, + }, + }, + e.AllChainSelectors()[1]: changeset.TokenPoolConfig{ + ChainUpdates: changeset.RateLimiterPerChain{ + e.AllChainSelectors()[0]: changeset.RateLimiterConfig{}, + }, + }, + }, + }, + ErrStr: "missing tokenAdminRegistry", + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + err := test.Input.Validate(e) + require.Contains(t, err.Error(), test.ErrStr) + }) + } +} + +func TestValidateConfigureTokenPoolContracts(t *testing.T) { + t.Parallel() + + type regPass struct { + SelectorA2B changeset.RateLimiterConfig + SelectorB2A changeset.RateLimiterConfig + } + + type updatePass struct { + UpdatePoolOnA bool + UpdatePoolOnB bool + SelectorA2B changeset.RateLimiterConfig + SelectorB2A changeset.RateLimiterConfig + } + + type tokenPools struct { + LockRelease *token_pool.TokenPool + BurnMint *token_pool.TokenPool + } + + acceptLiquidity := false + + tests := []struct { + Msg string + RegistrationPass *regPass + UpdatePass *updatePass + }{ + { + Msg: "Configure new pools on registry", + RegistrationPass: ®Pass{ + SelectorA2B: createSymmetricRateLimits(100, 1000), + SelectorB2A: createSymmetricRateLimits(100, 1000), + }, + }, + { + Msg: "Configure new pools on registry, update their rate limits", + RegistrationPass: ®Pass{ + SelectorA2B: createSymmetricRateLimits(100, 1000), + SelectorB2A: createSymmetricRateLimits(100, 1000), + }, + UpdatePass: &updatePass{ + UpdatePoolOnA: false, + UpdatePoolOnB: false, + SelectorA2B: createSymmetricRateLimits(200, 2000), + SelectorB2A: createSymmetricRateLimits(200, 2000), + }, + }, + { + Msg: "Configure new pools on registry, update both pools", + RegistrationPass: ®Pass{ + SelectorA2B: createSymmetricRateLimits(100, 1000), + SelectorB2A: createSymmetricRateLimits(100, 1000), + }, + UpdatePass: &updatePass{ + UpdatePoolOnA: true, + UpdatePoolOnB: true, + SelectorA2B: createSymmetricRateLimits(100, 1000), + SelectorB2A: createSymmetricRateLimits(100, 1000), + }, + }, + { + Msg: "Configure new pools on registry, update only one pool", + RegistrationPass: ®Pass{ + SelectorA2B: createSymmetricRateLimits(100, 1000), + SelectorB2A: createSymmetricRateLimits(100, 1000), + }, + UpdatePass: &updatePass{ + UpdatePoolOnA: false, + UpdatePoolOnB: true, + SelectorA2B: createSymmetricRateLimits(200, 2000), + SelectorB2A: createSymmetricRateLimits(200, 2000), + }, + }, + } + + for _, test := range tests { + for _, mcmsConfig := range []*changeset.MCMSConfig{nil, &changeset.MCMSConfig{MinDelay: 0 * time.Second}} { // Run all tests with and without MCMS + t.Run(test.Msg, func(t *testing.T) { + e, selectorA, selectorB, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), mcmsConfig != nil) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + selectorB: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorB].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, mcmsConfig != nil) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.LockReleaseTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AcceptLiquidity: &acceptLiquidity, + }, + selectorB: { + Type: changeset.LockReleaseTokenPool, + TokenAddress: tokens[selectorB].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AcceptLiquidity: &acceptLiquidity, + }, + }, mcmsConfig != nil) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + lockReleaseA, _ := token_pool.NewTokenPool(state.Chains[selectorA].LockReleaseTokenPools[testhelpers.TestTokenSymbol][deployment.Version1_5_1].Address(), e.Chains[selectorA].Client) + burnMintA, _ := token_pool.NewTokenPool(state.Chains[selectorA].BurnMintTokenPools[testhelpers.TestTokenSymbol][deployment.Version1_5_1].Address(), e.Chains[selectorA].Client) + + lockReleaseB, _ := token_pool.NewTokenPool(state.Chains[selectorB].LockReleaseTokenPools[testhelpers.TestTokenSymbol][deployment.Version1_5_1].Address(), e.Chains[selectorB].Client) + burnMintB, _ := token_pool.NewTokenPool(state.Chains[selectorB].BurnMintTokenPools[testhelpers.TestTokenSymbol][deployment.Version1_5_1].Address(), e.Chains[selectorB].Client) + + pools := map[uint64]tokenPools{ + selectorA: tokenPools{ + LockRelease: lockReleaseA, + BurnMint: burnMintA, + }, + selectorB: tokenPools{ + LockRelease: lockReleaseB, + BurnMint: burnMintB, + }, + } + expectedOwners := make(map[uint64]common.Address, 2) + if mcmsConfig != nil { + expectedOwners[selectorA] = state.Chains[selectorA].Timelock.Address() + expectedOwners[selectorB] = state.Chains[selectorB].Timelock.Address() + } else { + expectedOwners[selectorA] = e.Chains[selectorA].DeployerKey.From + expectedOwners[selectorB] = e.Chains[selectorB].DeployerKey.From + } + + if test.RegistrationPass != nil { + // Configure & set the active pools on the registry + e, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ConfigureTokenPoolContractsChangeset), + Config: changeset.ConfigureTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + MCMS: mcmsConfig, + PoolUpdates: map[uint64]changeset.TokenPoolConfig{ + selectorA: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + ChainUpdates: changeset.RateLimiterPerChain{ + selectorB: test.RegistrationPass.SelectorA2B, + }, + }, + selectorB: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + ChainUpdates: changeset.RateLimiterPerChain{ + selectorA: test.RegistrationPass.SelectorB2A, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.AcceptAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.SetPoolChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.LockReleaseTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + for _, selector := range e.AllChainSelectors() { + var remoteChainSelector uint64 + var rateLimiterConfig changeset.RateLimiterConfig + switch selector { + case selectorA: + remoteChainSelector = selectorB + rateLimiterConfig = test.RegistrationPass.SelectorA2B + case selectorB: + remoteChainSelector = selectorA + rateLimiterConfig = test.RegistrationPass.SelectorB2A + } + validateMemberOfTokenPoolPair( + t, + state, + pools[selector].LockRelease, + []string{pools[remoteChainSelector].LockRelease.Address().String()}, + tokens, + testhelpers.TestTokenSymbol, + selector, + rateLimiterConfig.Inbound.Rate, // inbound & outbound are the same in this test + rateLimiterConfig.Inbound.Capacity, + expectedOwners[selector], + ) + } + } + + if test.UpdatePass != nil { + // Only configure, do not update registry + aType := changeset.LockReleaseTokenPool + if test.UpdatePass.UpdatePoolOnA { + aType = changeset.BurnMintTokenPool + } + bType := changeset.LockReleaseTokenPool + if test.UpdatePass.UpdatePoolOnB { + bType = changeset.BurnMintTokenPool + } + e, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ConfigureTokenPoolContractsChangeset), + Config: changeset.ConfigureTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + MCMS: mcmsConfig, + PoolUpdates: map[uint64]changeset.TokenPoolConfig{ + selectorA: { + Type: aType, + Version: deployment.Version1_5_1, + ChainUpdates: changeset.RateLimiterPerChain{ + selectorB: test.UpdatePass.SelectorA2B, + }, + }, + selectorB: { + Type: bType, + Version: deployment.Version1_5_1, + ChainUpdates: changeset.RateLimiterPerChain{ + selectorA: test.UpdatePass.SelectorB2A, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + for _, selector := range e.AllChainSelectors() { + var updatePool bool + var updateRemotePool bool + var remoteChainSelector uint64 + var rateLimiterConfig changeset.RateLimiterConfig + switch selector { + case selectorA: + remoteChainSelector = selectorB + rateLimiterConfig = test.UpdatePass.SelectorA2B + updatePool = test.UpdatePass.UpdatePoolOnA + updateRemotePool = test.UpdatePass.UpdatePoolOnB + case selectorB: + remoteChainSelector = selectorA + rateLimiterConfig = test.UpdatePass.SelectorB2A + updatePool = test.UpdatePass.UpdatePoolOnB + updateRemotePool = test.UpdatePass.UpdatePoolOnA + } + remotePoolAddresses := []string{pools[remoteChainSelector].LockRelease.Address().String()} // add registered pool by default + if updateRemotePool { // if remote pool address is being updated, we push the new address + remotePoolAddresses = append(remotePoolAddresses, pools[remoteChainSelector].BurnMint.Address().String()) + } + tokenPool := pools[selector].LockRelease + if updatePool { + tokenPool = pools[selector].BurnMint + } + validateMemberOfTokenPoolPair( + t, + state, + tokenPool, + remotePoolAddresses, + tokens, + testhelpers.TestTokenSymbol, + selector, + rateLimiterConfig.Inbound.Rate, // inbound & outbound are the same in this test + rateLimiterConfig.Inbound.Capacity, + expectedOwners[selector], + ) + } + } + }) + } + } +} diff --git a/deployment/ccip/changeset/cs_deploy_token_pools.go b/deployment/ccip/changeset/cs_deploy_token_pools.go new file mode 100644 index 00000000000..5f3cae22e5f --- /dev/null +++ b/deployment/ccip/changeset/cs_deploy_token_pools.go @@ -0,0 +1,216 @@ +package changeset + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/burn_from_mint_token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/burn_mint_token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/burn_with_from_mint_token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/lock_release_token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/shared/generated/erc20" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +var _ deployment.ChangeSet[DeployTokenPoolContractsConfig] = DeployTokenPoolContractsChangeset + +// DeployTokenPoolInput defines all information required of the user to deploy a new token pool contract. +type DeployTokenPoolInput struct { + // Type is the type of token pool that must be deployed. + Type deployment.ContractType + // TokenAddress is the address of the token for which we are deploying a pool. + TokenAddress common.Address + // AllowList is the optional list of addresses permitted to initiate a token transfer. + // If omitted, all addresses will be permitted to transfer the token. + AllowList []common.Address + // LocalTokenDecimals is the number of decimals used by the token at tokenAddress. + LocalTokenDecimals uint8 + // AcceptLiquidity indicates whether or not the new pool can accept liquidity from a rebalancer address (lock-release only). + AcceptLiquidity *bool +} + +func (i DeployTokenPoolInput) Validate(ctx context.Context, chain deployment.Chain, state CCIPChainState, tokenSymbol TokenSymbol) error { + // Ensure that required fields are populated + if i.TokenAddress == utils.ZeroAddress { + return errors.New("token address must be defined") + } + if i.Type == deployment.ContractType("") { + return errors.New("type must be defined") + } + + // Validate that the type is known + if _, ok := tokenPoolTypes[i.Type]; !ok { + return fmt.Errorf("requested token pool type %s is unknown", i.Type) + } + + // Validate the token exists and matches the expected symbol + token, err := erc20.NewERC20(i.TokenAddress, chain.Client) + if err != nil { + return fmt.Errorf("failed to connect address %s with erc20 bindings: %w", i.TokenAddress, err) + } + symbol, err := token.Symbol(&bind.CallOpts{Context: ctx}) + if err != nil { + return fmt.Errorf("failed to fetch symbol from token with address %s: %w", i.TokenAddress, err) + } + if symbol != string(tokenSymbol) { + return fmt.Errorf("symbol of token with address %s (%s) does not match expected symbol (%s)", i.TokenAddress, symbol, tokenSymbol) + } + + // Validate localTokenDecimals against the decimals value on the token contract + decimals, err := token.Decimals(&bind.CallOpts{Context: ctx}) + if err != nil { + return fmt.Errorf("failed to fetch decimals from token with address %s: %w", i.TokenAddress, err) + } + if decimals != i.LocalTokenDecimals { + return fmt.Errorf("decimals of token with address %s (%d) does not match localTokenDecimals (%d)", i.TokenAddress, decimals, i.LocalTokenDecimals) + } + + // Validate acceptLiquidity based on requested pool type + if i.Type == LockReleaseTokenPool && i.AcceptLiquidity == nil { + return errors.New("accept liquidity must be defined for lock release pools") + } + if i.Type != LockReleaseTokenPool && i.AcceptLiquidity != nil { + return errors.New("accept liquidity must be nil for burn mint pools") + } + + // We should check if a token pool with this type, version, and symbol already exists + _, ok := getTokenPoolAddressFromSymbolTypeAndVersion(state, chain, tokenSymbol, i.Type, currentTokenPoolVersion) + if ok { + return fmt.Errorf("token pool with type %s and version %s already exists for %s on %s", i.Type, currentTokenPoolVersion, tokenSymbol, chain) + } + + return nil +} + +// DeployTokenPoolContractsConfig defines the token pool contracts that need to be deployed on each chain. +type DeployTokenPoolContractsConfig struct { + // Symbol is the symbol of the token for which we are deploying a pool. + TokenSymbol TokenSymbol + // NewPools defines the per-chain configuration of each new pool + NewPools map[uint64]DeployTokenPoolInput +} + +func (c DeployTokenPoolContractsConfig) Validate(env deployment.Environment) error { + // Ensure that required fields are populated + if c.TokenSymbol == TokenSymbol("") { + return errors.New("token symbol must be defined") + } + + state, err := LoadOnchainState(env) + if err != nil { + return fmt.Errorf("failed to load onchain state: %w", err) + } + for chainSelector, poolConfig := range c.NewPools { + err := deployment.IsValidChainSelector(chainSelector) + if err != nil { + return fmt.Errorf("failed to validate chain selector %d: %w", chainSelector, err) + } + chain, ok := env.Chains[chainSelector] + if !ok { + return fmt.Errorf("chain with selector %d does not exist in environment", chainSelector) + } + chainState, ok := state.Chains[chainSelector] + if !ok { + return fmt.Errorf("chain with selector %d does not exist in state", chainSelector) + } + if router := chainState.Router; router == nil { + return fmt.Errorf("missing router on %s", chain.String()) + } + if rmnProxy := chainState.RMNProxy; rmnProxy == nil { + return fmt.Errorf("missing rmnProxy on %s", chain.String()) + } + err = poolConfig.Validate(env.GetContext(), chain, chainState, c.TokenSymbol) + if err != nil { + return fmt.Errorf("failed to validate token pool config for chain selector %d: %w", chainSelector, err) + } + } + return nil +} + +// DeployTokenPoolContractsChangeset deploys new pools for a given token across multiple chains. +func DeployTokenPoolContractsChangeset(env deployment.Environment, c DeployTokenPoolContractsConfig) (deployment.ChangesetOutput, error) { + if err := c.Validate(env); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("invalid DeployTokenPoolContractsConfig: %w", err) + } + newAddresses := deployment.NewMemoryAddressBook() + + state, err := LoadOnchainState(env) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to load onchain state: %w", err) + } + + for chainSelector, poolConfig := range c.NewPools { + chain := env.Chains[chainSelector] + chainState := state.Chains[chainSelector] + + _, err := DeployTokenPool(env.Logger, chain, chainState, newAddresses, poolConfig) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to deploy %s token pool on %s: %w", c.TokenSymbol, chain.String(), err) + } + } + + return deployment.ChangesetOutput{ + AddressBook: newAddresses, + }, nil +} + +// deployTokenPool deploys a token pool contract based on a given type & configuration. +func DeployTokenPool( + logger logger.Logger, + chain deployment.Chain, + chainState CCIPChainState, + addressBook deployment.AddressBook, + poolConfig DeployTokenPoolInput, +) (*deployment.ContractDeploy[*token_pool.TokenPool], error) { + router := chainState.Router + rmnProxy := chainState.RMNProxy + + return deployment.DeployContract(logger, chain, addressBook, + func(chain deployment.Chain) deployment.ContractDeploy[*token_pool.TokenPool] { + var tpAddr common.Address + var tx *types.Transaction + var err error + switch poolConfig.Type { + case BurnMintTokenPool: + tpAddr, tx, _, err = burn_mint_token_pool.DeployBurnMintTokenPool( + chain.DeployerKey, chain.Client, poolConfig.TokenAddress, poolConfig.LocalTokenDecimals, + poolConfig.AllowList, rmnProxy.Address(), router.Address(), + ) + case BurnWithFromMintTokenPool: + tpAddr, tx, _, err = burn_with_from_mint_token_pool.DeployBurnWithFromMintTokenPool( + chain.DeployerKey, chain.Client, poolConfig.TokenAddress, poolConfig.LocalTokenDecimals, + poolConfig.AllowList, rmnProxy.Address(), router.Address(), + ) + case BurnFromMintTokenPool: + tpAddr, tx, _, err = burn_from_mint_token_pool.DeployBurnFromMintTokenPool( + chain.DeployerKey, chain.Client, poolConfig.TokenAddress, poolConfig.LocalTokenDecimals, + poolConfig.AllowList, rmnProxy.Address(), router.Address(), + ) + case LockReleaseTokenPool: + tpAddr, tx, _, err = lock_release_token_pool.DeployLockReleaseTokenPool( + chain.DeployerKey, chain.Client, poolConfig.TokenAddress, poolConfig.LocalTokenDecimals, + poolConfig.AllowList, rmnProxy.Address(), *poolConfig.AcceptLiquidity, router.Address(), + ) + } + var tp *token_pool.TokenPool + if err == nil { // prevents overwriting the error (also, if there were an error with deployment, converting to an abstract token pool wouldn't be useful) + tp, err = token_pool.NewTokenPool(tpAddr, chain.Client) + } + return deployment.ContractDeploy[*token_pool.TokenPool]{ + Address: tpAddr, + Contract: tp, + Tv: deployment.NewTypeAndVersion(poolConfig.Type, currentTokenPoolVersion), + Tx: tx, + Err: err, + } + }, + ) +} diff --git a/deployment/ccip/changeset/cs_deploy_token_pools_test.go b/deployment/ccip/changeset/cs_deploy_token_pools_test.go new file mode 100644 index 00000000000..693b5f54f3e --- /dev/null +++ b/deployment/ccip/changeset/cs_deploy_token_pools_test.go @@ -0,0 +1,312 @@ +package changeset_test + +import ( + "context" + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/environment/memory" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +func TestValidateDeployTokenPoolContractsConfig(t *testing.T) { + t.Parallel() + + lggr := logger.TestLogger(t) + e := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{ + Chains: 2, + }) + + tests := []struct { + Msg string + TokenSymbol changeset.TokenSymbol + Input changeset.DeployTokenPoolContractsConfig + ErrStr string + }{ + { + Msg: "Token symbol is missing", + Input: changeset.DeployTokenPoolContractsConfig{}, + ErrStr: "token symbol must be defined", + }, + { + Msg: "Chain selector is not valid", + Input: changeset.DeployTokenPoolContractsConfig{ + TokenSymbol: "TEST", + NewPools: map[uint64]changeset.DeployTokenPoolInput{ + 0: changeset.DeployTokenPoolInput{}, + }, + }, + ErrStr: "failed to validate chain selector 0", + }, + { + Msg: "Chain selector doesn't exist in environment", + Input: changeset.DeployTokenPoolContractsConfig{ + TokenSymbol: "TEST", + NewPools: map[uint64]changeset.DeployTokenPoolInput{ + 5009297550715157269: changeset.DeployTokenPoolInput{}, + }, + }, + ErrStr: "does not exist in environment", + }, + { + Msg: "Router contract is missing from chain", + Input: changeset.DeployTokenPoolContractsConfig{ + TokenSymbol: "TEST", + NewPools: map[uint64]changeset.DeployTokenPoolInput{ + e.AllChainSelectors()[0]: changeset.DeployTokenPoolInput{}, + }, + }, + ErrStr: "missing router", + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + err := test.Input.Validate(e) + require.Contains(t, err.Error(), test.ErrStr) + }) + } +} + +func TestValidateDeployTokenPoolInput(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, _ := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + acceptLiquidity := false + invalidAddress := utils.RandomAddress() + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, true) + + tests := []struct { + Msg string + Symbol changeset.TokenSymbol + Input changeset.DeployTokenPoolInput + ErrStr string + }{ + { + Msg: "Token address is missing", + Input: changeset.DeployTokenPoolInput{}, + ErrStr: "token address must be defined", + }, + { + Msg: "Token pool type is missing", + Input: changeset.DeployTokenPoolInput{ + TokenAddress: invalidAddress, + }, + ErrStr: "type must be defined", + }, + { + Msg: "Token pool type is invalid", + Input: changeset.DeployTokenPoolInput{ + TokenAddress: invalidAddress, + Type: deployment.ContractType("InvalidTokenPool"), + }, + ErrStr: "requested token pool type InvalidTokenPool is unknown", + }, + { + Msg: "Token address is invalid", + Input: changeset.DeployTokenPoolInput{ + Type: changeset.BurnMintTokenPool, + TokenAddress: invalidAddress, + }, + ErrStr: fmt.Sprintf("failed to fetch symbol from token with address %s", invalidAddress), + }, + { + Msg: "Token symbol mismatch", + Symbol: "WRONG", + Input: changeset.DeployTokenPoolInput{ + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + }, + ErrStr: fmt.Sprintf("symbol of token with address %s (%s) does not match expected symbol (WRONG)", tokens[selectorA].Address, testhelpers.TestTokenSymbol), + }, + { + Msg: "Token decimal mismatch", + Symbol: testhelpers.TestTokenSymbol, + Input: changeset.DeployTokenPoolInput{ + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: 17, + }, + ErrStr: fmt.Sprintf("decimals of token with address %s (%d) does not match localTokenDecimals (17)", tokens[selectorA].Address, testhelpers.LocalTokenDecimals), + }, + { + Msg: "Accept liquidity should be defined", + Symbol: testhelpers.TestTokenSymbol, + Input: changeset.DeployTokenPoolInput{ + Type: changeset.LockReleaseTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + ErrStr: "accept liquidity must be defined for lock release pools", + }, + { + Msg: "Accept liquidity should be omitted", + Symbol: testhelpers.TestTokenSymbol, + Input: changeset.DeployTokenPoolInput{ + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AcceptLiquidity: &acceptLiquidity, + }, + ErrStr: "accept liquidity must be nil for burn mint pools", + }, + { + Msg: "Token pool already exists", + Symbol: testhelpers.TestTokenSymbol, + Input: changeset.DeployTokenPoolInput{ + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + ErrStr: fmt.Sprintf("token pool with type BurnMintTokenPool and version %s already exists", deployment.Version1_5_1), + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + err = test.Input.Validate(context.Background(), e.Chains[selectorA], state.Chains[selectorA], test.Symbol) + require.Contains(t, err.Error(), test.ErrStr) + }) + } +} + +func TestDeployTokenPool(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, _ := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + acceptLiquidity := false + + tests := []struct { + Msg string + Input changeset.DeployTokenPoolInput + }{ + { + Msg: "BurnMint", + Input: changeset.DeployTokenPoolInput{ + TokenAddress: tokens[selectorA].Address, + Type: changeset.BurnMintTokenPool, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AllowList: []common.Address{}, + }, + }, + { + Msg: "BurnWithFromMint", + Input: changeset.DeployTokenPoolInput{ + TokenAddress: tokens[selectorA].Address, + Type: changeset.BurnWithFromMintTokenPool, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AllowList: []common.Address{}, + }, + }, + { + Msg: "BurnFromMint", + Input: changeset.DeployTokenPoolInput{ + TokenAddress: tokens[selectorA].Address, + Type: changeset.BurnFromMintTokenPool, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AllowList: []common.Address{}, + }, + }, + { + Msg: "LockRelease", + Input: changeset.DeployTokenPoolInput{ + TokenAddress: tokens[selectorA].Address, + Type: changeset.LockReleaseTokenPool, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AllowList: []common.Address{}, + AcceptLiquidity: &acceptLiquidity, + }, + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + addressBook := deployment.NewMemoryAddressBook() + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + _, err = changeset.DeployTokenPool( + e.Logger, + e.Chains[selectorA], + state.Chains[selectorA], + addressBook, + test.Input, + ) + require.NoError(t, err) + + err = e.ExistingAddresses.Merge(addressBook) + require.NoError(t, err) + + state, err = changeset.LoadOnchainState(e) + require.NoError(t, err) + + switch test.Input.Type { + case changeset.BurnMintTokenPool: + _, ok := state.Chains[selectorA].BurnMintTokenPools[testhelpers.TestTokenSymbol] + require.True(t, ok) + case changeset.LockReleaseTokenPool: + _, ok := state.Chains[selectorA].LockReleaseTokenPools[testhelpers.TestTokenSymbol] + require.True(t, ok) + case changeset.BurnWithFromMintTokenPool: + _, ok := state.Chains[selectorA].BurnWithFromMintTokenPools[testhelpers.TestTokenSymbol] + require.True(t, ok) + case changeset.BurnFromMintTokenPool: + _, ok := state.Chains[selectorA].BurnFromMintTokenPools[testhelpers.TestTokenSymbol] + require.True(t, ok) + } + }) + } +} + +func TestDeployTokenPoolContracts(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + + e, err := commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + commonchangeset.ChangesetApplication{ + Changeset: commonchangeset.WrapChangeSet(changeset.DeployTokenPoolContractsChangeset), + Config: changeset.DeployTokenPoolContractsConfig{ + TokenSymbol: testhelpers.TestTokenSymbol, + NewPools: map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + TokenAddress: tokens[selectorA].Address, + Type: changeset.BurnMintTokenPool, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + AllowList: []common.Address{}, + }, + }, + }, + }, + }) + require.NoError(t, err) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + burnMintTokenPools, ok := state.Chains[selectorA].BurnMintTokenPools[testhelpers.TestTokenSymbol] + require.True(t, ok) + require.Len(t, burnMintTokenPools, 1) + owner, err := burnMintTokenPools[deployment.Version1_5_1].Owner(nil) + require.NoError(t, err) + require.Equal(t, e.Chains[selectorA].DeployerKey.From, owner) +} diff --git a/deployment/ccip/changeset/cs_propose_admin_role.go b/deployment/ccip/changeset/cs_propose_admin_role.go new file mode 100644 index 00000000000..b6db1b0611d --- /dev/null +++ b/deployment/ccip/changeset/cs_propose_admin_role.go @@ -0,0 +1,70 @@ +package changeset + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_admin_registry" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +var _ deployment.ChangeSet[TokenAdminRegistryChangesetConfig] = ProposeAdminRoleChangeset + +func validateProposeAdminRole( + config token_admin_registry.TokenAdminRegistryTokenConfig, + sender common.Address, + externalAdmin common.Address, + symbol TokenSymbol, + chain deployment.Chain, +) error { + // To propose ourselves as admin of the token, two things must be true. + // 1. We own the token admin registry + // 2. An admin does not exist exist yet + // We've already validated that we own the registry during ValidateOwnership, so we only need to check the 2nd condition + if config.Administrator != utils.ZeroAddress { + return fmt.Errorf("unable to propose %s as admin of %s token on %s: token already has an administrator (%s)", sender, symbol, chain, config.Administrator) + } + return nil +} + +// ProposeAdminRoleChangeset proposes admin rights for tokens on the token admin registry. +func ProposeAdminRoleChangeset(env deployment.Environment, c TokenAdminRegistryChangesetConfig) (deployment.ChangesetOutput, error) { + if err := c.Validate(env, true, validateProposeAdminRole); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("invalid TokenAdminRegistryChangesetConfig: %w", err) + } + state, err := LoadOnchainState(env) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to load onchain state: %w", err) + } + deployerGroup := NewDeployerGroup(env, state, c.MCMS) + + for chainSelector, tokenSymbolToPoolInfo := range c.Pools { + chain := env.Chains[chainSelector] + chainState := state.Chains[chainSelector] + opts, err := deployerGroup.GetDeployer(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get deployer for %s", chain) + } + desiredAdmin := chainState.Timelock.Address() + if c.MCMS == nil { + desiredAdmin = chain.DeployerKey.From + } + for symbol, poolInfo := range tokenSymbolToPoolInfo { + if poolInfo.ExternalAdmin != utils.ZeroAddress { + desiredAdmin = poolInfo.ExternalAdmin + } + _, tokenAddress, err := poolInfo.GetPoolAndTokenAddress(env.GetContext(), symbol, chain, chainState) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get state of %s token on chain %s: %w", symbol, chain, err) + } + _, err = chainState.TokenAdminRegistry.ProposeAdministrator(opts, tokenAddress, desiredAdmin) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to create proposeAdministrator transaction for %s on %s registry: %w", symbol, chain, err) + } + } + } + + return deployerGroup.Enact("propose admin role for tokens on token admin registries") +} diff --git a/deployment/ccip/changeset/cs_propose_admin_role_test.go b/deployment/ccip/changeset/cs_propose_admin_role_test.go new file mode 100644 index 00000000000..c60948d2abd --- /dev/null +++ b/deployment/ccip/changeset/cs_propose_admin_role_test.go @@ -0,0 +1,298 @@ +package changeset_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +func TestProposeAdminRoleChangeset_Validations(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, true) + + mcmsConfig := &changeset.MCMSConfig{ + MinDelay: 0 * time.Second, + } + + // We want an administrator to exist to force failure in the last test + e, err := commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.AcceptAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + Config changeset.TokenAdminRegistryChangesetConfig + ErrStr string + Msg string + }{ + { + Msg: "Chain selector is invalid", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 0: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "failed to validate chain selector 0", + }, + { + Msg: "Chain selector doesn't exist in environment", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 5009297550715157269: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "does not exist in environment", + }, + { + Msg: "Ownership validation failure", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "token admin registry failed ownership validation", + }, + { + Msg: "Invalid pool type", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: "InvalidType", + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "InvalidType is not a known token pool type", + }, + { + Msg: "Invalid pool version", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_0_0, + }, + }, + }, + }, + ErrStr: "1.0.0 is not a known token pool version", + }, + { + Msg: "Admin already exists", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "token already has an administrator", + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + _, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: test.Config, + }, + }) + require.Error(t, err) + require.ErrorContains(t, err, test.ErrStr) + }) + } +} + +func TestProposeAdminRoleChangeset_ExecutionWithoutExternalAdmin(t *testing.T) { + for _, mcmsConfig := range []*changeset.MCMSConfig{nil, &changeset.MCMSConfig{MinDelay: 0 * time.Second}} { + msg := "Propose admin role without external admin with MCMS" + if mcmsConfig == nil { + msg = "Propose admin role without external admin without MCMS" + } + + t.Run(msg, func(t *testing.T) { + e, selectorA, selectorB, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), mcmsConfig != nil) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + selectorB: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorB].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, mcmsConfig != nil) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + registryOnA := state.Chains[selectorA].TokenAdminRegistry + registryOnB := state.Chains[selectorB].TokenAdminRegistry + + e, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + configOnA, err := registryOnA.GetTokenConfig(nil, tokens[selectorA].Address) + require.NoError(t, err) + if mcmsConfig != nil { + require.Equal(t, state.Chains[selectorA].Timelock.Address(), configOnA.PendingAdministrator) + } else { + require.Equal(t, e.Chains[selectorA].DeployerKey.From, configOnA.PendingAdministrator) + } + + configOnB, err := registryOnB.GetTokenConfig(nil, tokens[selectorB].Address) + require.NoError(t, err) + if mcmsConfig != nil { + require.Equal(t, state.Chains[selectorB].Timelock.Address(), configOnB.PendingAdministrator) + } else { + require.Equal(t, e.Chains[selectorB].DeployerKey.From, configOnB.PendingAdministrator) + } + }) + } +} + +func TestProposeAdminRoleChangeset_ExecutionWithExternalAdmin(t *testing.T) { + for _, mcmsConfig := range []*changeset.MCMSConfig{nil, &changeset.MCMSConfig{MinDelay: 0 * time.Second}} { + msg := "Propose admin role with external admin with MCMS" + if mcmsConfig == nil { + msg = "Propose admin role with external admin without MCMS" + } + + t.Run(msg, func(t *testing.T) { + e, selectorA, selectorB, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), mcmsConfig != nil) + externalAdminA := utils.RandomAddress() + externalAdminB := utils.RandomAddress() + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + selectorB: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorB].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, mcmsConfig != nil) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + registryOnA := state.Chains[selectorA].TokenAdminRegistry + registryOnB := state.Chains[selectorB].TokenAdminRegistry + + _, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + ExternalAdmin: externalAdminA, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + ExternalAdmin: externalAdminB, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + configOnA, err := registryOnA.GetTokenConfig(nil, tokens[selectorA].Address) + require.NoError(t, err) + require.Equal(t, externalAdminA, configOnA.PendingAdministrator) + + configOnB, err := registryOnB.GetTokenConfig(nil, tokens[selectorB].Address) + require.NoError(t, err) + require.Equal(t, externalAdminB, configOnB.PendingAdministrator) + }) + } +} diff --git a/deployment/ccip/changeset/cs_set_pool.go b/deployment/ccip/changeset/cs_set_pool.go new file mode 100644 index 00000000000..96661d1fe65 --- /dev/null +++ b/deployment/ccip/changeset/cs_set_pool.go @@ -0,0 +1,59 @@ +package changeset + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_admin_registry" +) + +var _ deployment.ChangeSet[TokenAdminRegistryChangesetConfig] = SetPoolChangeset + +func validateSetPool( + config token_admin_registry.TokenAdminRegistryTokenConfig, + sender common.Address, + externalAdmin common.Address, + symbol TokenSymbol, + chain deployment.Chain, +) error { + // We must be the administrator + if config.Administrator != sender { + return fmt.Errorf("unable to set pool for %s token on %s: %s is not the administrator (%s)", symbol, chain, sender, config.Administrator) + } + return nil +} + +// SetPoolChangeset sets pools for tokens on the token admin registry. +func SetPoolChangeset(env deployment.Environment, c TokenAdminRegistryChangesetConfig) (deployment.ChangesetOutput, error) { + if err := c.Validate(env, false, validateSetPool); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("invalid TokenAdminRegistryChangesetConfig: %w", err) + } + state, err := LoadOnchainState(env) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to load onchain state: %w", err) + } + deployerGroup := NewDeployerGroup(env, state, c.MCMS) + + for chainSelector, tokenSymbolToPoolInfo := range c.Pools { + chain := env.Chains[chainSelector] + chainState := state.Chains[chainSelector] + opts, err := deployerGroup.GetDeployer(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get deployer for %s", chain) + } + for symbol, poolInfo := range tokenSymbolToPoolInfo { + tokenPool, tokenAddress, err := poolInfo.GetPoolAndTokenAddress(env.GetContext(), symbol, chain, chainState) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get state of %s token on chain %s: %w", symbol, chain, err) + } + _, err = chainState.TokenAdminRegistry.SetPool(opts, tokenAddress, tokenPool.Address()) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to create setPool transaction for %s on %s registry: %w", symbol, chain, err) + } + } + } + + return deployerGroup.Enact("set pool for tokens on token admin registries") +} diff --git a/deployment/ccip/changeset/cs_set_pool_test.go b/deployment/ccip/changeset/cs_set_pool_test.go new file mode 100644 index 00000000000..f20ef7c214a --- /dev/null +++ b/deployment/ccip/changeset/cs_set_pool_test.go @@ -0,0 +1,219 @@ +package changeset_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/v2/core/logger" +) + +func TestSetPoolChangeset_Validations(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, true) + + mcmsConfig := &changeset.MCMSConfig{ + MinDelay: 0 * time.Second, + } + + tests := []struct { + Config changeset.TokenAdminRegistryChangesetConfig + ErrStr string + Msg string + }{ + { + Msg: "Chain selector is invalid", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 0: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "failed to validate chain selector 0", + }, + { + Msg: "Chain selector doesn't exist in environment", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 5009297550715157269: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "does not exist in environment", + }, + { + Msg: "Invalid pool type", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: "InvalidType", + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "InvalidType is not a known token pool type", + }, + { + Msg: "Invalid pool version", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_0_0, + }, + }, + }, + }, + ErrStr: "1.0.0 is not a known token pool version", + }, + { + Msg: "Not admin", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "is not the administrator", + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + _, err := commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.SetPoolChangeset), + Config: test.Config, + }, + }) + require.Error(t, err) + require.ErrorContains(t, err, test.ErrStr) + }) + } +} + +func TestSetPoolChangeset_Execution(t *testing.T) { + for _, mcmsConfig := range []*changeset.MCMSConfig{nil, &changeset.MCMSConfig{MinDelay: 0 * time.Second}} { + msg := "Set pool with MCMS" + if mcmsConfig == nil { + msg = "Set pool without MCMS" + } + + t.Run(msg, func(t *testing.T) { + e, selectorA, selectorB, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), mcmsConfig != nil) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + selectorB: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorB].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, mcmsConfig != nil) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + registryOnA := state.Chains[selectorA].TokenAdminRegistry + registryOnB := state.Chains[selectorB].TokenAdminRegistry + + _, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.AcceptAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.SetPoolChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + configOnA, err := registryOnA.GetTokenConfig(nil, tokens[selectorA].Address) + require.NoError(t, err) + require.Equal(t, state.Chains[selectorA].BurnMintTokenPools[testhelpers.TestTokenSymbol][deployment.Version1_5_1].Address(), configOnA.TokenPool) + + configOnB, err := registryOnB.GetTokenConfig(nil, tokens[selectorB].Address) + require.NoError(t, err) + require.Equal(t, state.Chains[selectorB].BurnMintTokenPools[testhelpers.TestTokenSymbol][deployment.Version1_5_1].Address(), configOnB.TokenPool) + }) + } +} diff --git a/deployment/ccip/changeset/cs_transfer_admin_role.go b/deployment/ccip/changeset/cs_transfer_admin_role.go new file mode 100644 index 00000000000..a5d9269dc0c --- /dev/null +++ b/deployment/ccip/changeset/cs_transfer_admin_role.go @@ -0,0 +1,64 @@ +package changeset + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_admin_registry" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +var _ deployment.ChangeSet[TokenAdminRegistryChangesetConfig] = TransferAdminRoleChangeset + +func validateTransferAdminRole( + config token_admin_registry.TokenAdminRegistryTokenConfig, + sender common.Address, + externalAdmin common.Address, + symbol TokenSymbol, + chain deployment.Chain, +) error { + if externalAdmin == utils.ZeroAddress { + return errors.New("external admin must be defined") + } + // We must be the administrator + if config.Administrator != sender { + return fmt.Errorf("unable to transfer admin role for %s token on %s: %s is not the administrator (%s)", symbol, chain, sender, config.Administrator) + } + return nil +} + +// TransferAdminRoleChangeset transfers the admin role for tokens on the token admin registry to 3rd parties. +func TransferAdminRoleChangeset(env deployment.Environment, c TokenAdminRegistryChangesetConfig) (deployment.ChangesetOutput, error) { + if err := c.Validate(env, false, validateTransferAdminRole); err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("invalid TokenAdminRegistryChangesetConfig: %w", err) + } + state, err := LoadOnchainState(env) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to load onchain state: %w", err) + } + deployerGroup := NewDeployerGroup(env, state, c.MCMS) + + for chainSelector, tokenSymbolToPoolInfo := range c.Pools { + chain := env.Chains[chainSelector] + chainState := state.Chains[chainSelector] + opts, err := deployerGroup.GetDeployer(chainSelector) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get deployer for %s", chain) + } + for symbol, poolInfo := range tokenSymbolToPoolInfo { + _, tokenAddress, err := poolInfo.GetPoolAndTokenAddress(env.GetContext(), symbol, chain, chainState) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to get state of %s token on chain %s: %w", symbol, chain, err) + } + _, err = chainState.TokenAdminRegistry.TransferAdminRole(opts, tokenAddress, poolInfo.ExternalAdmin) + if err != nil { + return deployment.ChangesetOutput{}, fmt.Errorf("failed to create transferAdminRole transaction for %s on %s registry: %w", symbol, chain, err) + } + } + } + + return deployerGroup.Enact("transfer admin role for tokens on token admin registries") +} diff --git a/deployment/ccip/changeset/cs_transfer_admin_role_test.go b/deployment/ccip/changeset/cs_transfer_admin_role_test.go new file mode 100644 index 00000000000..6bab89d5016 --- /dev/null +++ b/deployment/ccip/changeset/cs_transfer_admin_role_test.go @@ -0,0 +1,240 @@ +package changeset_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +func TestTransferAdminRoleChangeset_Validations(t *testing.T) { + t.Parallel() + + e, selectorA, _, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), true) + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, true) + + mcmsConfig := &changeset.MCMSConfig{ + MinDelay: 0 * time.Second, + } + + tests := []struct { + Config changeset.TokenAdminRegistryChangesetConfig + ErrStr string + Msg string + }{ + { + Msg: "Chain selector is invalid", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 0: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "failed to validate chain selector 0", + }, + { + Msg: "Chain selector doesn't exist in environment", + Config: changeset.TokenAdminRegistryChangesetConfig{ + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + 5009297550715157269: map[changeset.TokenSymbol]changeset.TokenPoolInfo{}, + }, + }, + ErrStr: "does not exist in environment", + }, + { + Msg: "Invalid pool type", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: "InvalidType", + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "InvalidType is not a known token pool type", + }, + { + Msg: "Invalid pool version", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_0_0, + }, + }, + }, + }, + ErrStr: "1.0.0 is not a known token pool version", + }, + { + Msg: "External admin undefined", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + ErrStr: "external admin must be defined", + }, + { + Msg: "Not admin", + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + ExternalAdmin: utils.RandomAddress(), + }, + }, + }, + }, + ErrStr: "is not the administrator", + }, + } + + for _, test := range tests { + t.Run(test.Msg, func(t *testing.T) { + _, err := commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.TransferAdminRoleChangeset), + Config: test.Config, + }, + }) + require.Error(t, err) + require.ErrorContains(t, err, test.ErrStr) + }) + } +} + +func TestTransferAdminRoleChangeset_Execution(t *testing.T) { + for _, mcmsConfig := range []*changeset.MCMSConfig{nil, &changeset.MCMSConfig{MinDelay: 0 * time.Second}} { + msg := "Transfer admin role with MCMS" + if mcmsConfig == nil { + msg = "Transfer admin role without MCMS" + } + + t.Run(msg, func(t *testing.T) { + e, selectorA, selectorB, tokens, timelockContracts := testhelpers.SetupTwoChainEnvironmentWithTokens(t, logger.TestLogger(t), mcmsConfig != nil) + externalAdminA := utils.RandomAddress() + externalAdminB := utils.RandomAddress() + + e = testhelpers.DeployTestTokenPools(t, e, map[uint64]changeset.DeployTokenPoolInput{ + selectorA: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorA].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + selectorB: { + Type: changeset.BurnMintTokenPool, + TokenAddress: tokens[selectorB].Address, + LocalTokenDecimals: testhelpers.LocalTokenDecimals, + }, + }, mcmsConfig != nil) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + registryOnA := state.Chains[selectorA].TokenAdminRegistry + registryOnB := state.Chains[selectorB].TokenAdminRegistry + + _, err = commonchangeset.ApplyChangesets(t, e, timelockContracts, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.ProposeAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.AcceptAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + }, + }, + }, + }, + }, + { + Changeset: commonchangeset.WrapChangeSet(changeset.TransferAdminRoleChangeset), + Config: changeset.TokenAdminRegistryChangesetConfig{ + MCMS: mcmsConfig, + Pools: map[uint64]map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + selectorA: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + ExternalAdmin: externalAdminA, + }, + }, + selectorB: map[changeset.TokenSymbol]changeset.TokenPoolInfo{ + testhelpers.TestTokenSymbol: { + Type: changeset.BurnMintTokenPool, + Version: deployment.Version1_5_1, + ExternalAdmin: externalAdminB, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + + configOnA, err := registryOnA.GetTokenConfig(nil, tokens[selectorA].Address) + require.NoError(t, err) + require.Equal(t, externalAdminA, configOnA.PendingAdministrator) + + configOnB, err := registryOnB.GetTokenConfig(nil, tokens[selectorB].Address) + require.NoError(t, err) + require.Equal(t, externalAdminB, configOnB.PendingAdministrator) + }) + } +} diff --git a/deployment/ccip/changeset/cs_update_rmn_config.go b/deployment/ccip/changeset/cs_update_rmn_config.go index f5349cd71f0..07c6333ab76 100644 --- a/deployment/ccip/changeset/cs_update_rmn_config.go +++ b/deployment/ccip/changeset/cs_update_rmn_config.go @@ -6,7 +6,6 @@ import ( "fmt" "math/big" "reflect" - "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -169,10 +168,6 @@ func getDeployer(e deployment.Environment, chain uint64, mcmConfig *MCMSConfig) return deployment.SimTransactOpts() } -type MCMSConfig struct { - MinDelay time.Duration -} - type SetRMNHomeCandidateConfig struct { HomeChainSelector uint64 RMNStaticConfig rmn_home.RMNHomeStaticConfig diff --git a/deployment/ccip/changeset/deployer_group.go b/deployment/ccip/changeset/deployer_group.go index 5f7c7e52da2..a552d6d9f9b 100644 --- a/deployment/ccip/changeset/deployer_group.go +++ b/deployment/ccip/changeset/deployer_group.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/big" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -16,6 +17,11 @@ import ( "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" ) +// MCMSConfig defines timelock duration. +type MCMSConfig struct { + MinDelay time.Duration +} + type DeployerGroup struct { e deployment.Environment state CCIPOnChainState diff --git a/deployment/ccip/changeset/state.go b/deployment/ccip/changeset/state.go index e0c704aebf9..c353b43ce9b 100644 --- a/deployment/ccip/changeset/state.go +++ b/deployment/ccip/changeset/state.go @@ -1,15 +1,20 @@ package changeset import ( + "context" "fmt" "strconv" + "github.com/Masterminds/semver/v3" "github.com/smartcontractkit/ccip-owner-contracts/pkg/gethwrappers" - burn_mint_token_pool "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/burn_mint_token_pool_1_4_0" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/burn_from_mint_token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/burn_mint_token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/burn_with_from_mint_token_pool" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/commit_store" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_offramp" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_onramp" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/lock_release_token_pool" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/log_message_data_receiver" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/price_registry_1_2_0" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/rmn_contract" @@ -33,6 +38,7 @@ import ( commoncs "github.com/smartcontractkit/chainlink/deployment/common/changeset" commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" common_v1_0 "github.com/smartcontractkit/chainlink/deployment/common/view/v1_0" + "github.com/smartcontractkit/chainlink/deployment/helpers" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/mock_rmn_contract" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/registry_module_owner_custom" @@ -87,13 +93,16 @@ var ( USDCMockTransmitter deployment.ContractType = "USDCMockTransmitter" // Pools - BurnMintToken deployment.ContractType = "BurnMintToken" - ERC20Token deployment.ContractType = "ERC20Token" - ERC677Token deployment.ContractType = "ERC677Token" - BurnMintTokenPool deployment.ContractType = "BurnMintTokenPool" - USDCToken deployment.ContractType = "USDCToken" - USDCTokenMessenger deployment.ContractType = "USDCTokenMessenger" - USDCTokenPool deployment.ContractType = "USDCTokenPool" + BurnMintToken deployment.ContractType = "BurnMintToken" + ERC20Token deployment.ContractType = "ERC20Token" + ERC677Token deployment.ContractType = "ERC677Token" + BurnMintTokenPool deployment.ContractType = "BurnMintTokenPool" + BurnWithFromMintTokenPool deployment.ContractType = "BurnWithFromMintTokenPool" + BurnFromMintTokenPool deployment.ContractType = "BurnFromMintTokenPool" + LockReleaseTokenPool deployment.ContractType = "LockReleaseTokenPool" + USDCToken deployment.ContractType = "USDCToken" + USDCTokenMessenger deployment.ContractType = "USDCTokenMessenger" + USDCTokenPool deployment.ContractType = "USDCTokenPool" ) // CCIPChainState holds a Go binding for all the currently deployed CCIP contracts @@ -113,11 +122,15 @@ type CCIPChainState struct { Weth9 *weth9.WETH9 RMNRemote *rmn_remote.RMNRemote // Map between token Descriptor (e.g. LinkSymbol, WethSymbol) - // and the respective token contract - ERC20Tokens map[TokenSymbol]*erc20.ERC20 - ERC677Tokens map[TokenSymbol]*erc677.ERC677 - BurnMintTokens677 map[TokenSymbol]*burn_mint_erc677.BurnMintERC677 - BurnMintTokenPools map[TokenSymbol]*burn_mint_token_pool.BurnMintTokenPool + // and the respective token / token pool contract(s) (only one of which would be active on the registry). + // This is more of an illustration of how we'll have tokens, and it might need some work later to work properly. + ERC20Tokens map[TokenSymbol]*erc20.ERC20 + ERC677Tokens map[TokenSymbol]*erc677.ERC677 + BurnMintTokens677 map[TokenSymbol]*burn_mint_erc677.BurnMintERC677 + BurnMintTokenPools map[TokenSymbol]map[semver.Version]*burn_mint_token_pool.BurnMintTokenPool + BurnWithFromMintTokenPools map[TokenSymbol]map[semver.Version]*burn_with_from_mint_token_pool.BurnWithFromMintTokenPool + BurnFromMintTokenPools map[TokenSymbol]map[semver.Version]*burn_from_mint_token_pool.BurnFromMintTokenPool + LockReleaseTokenPools map[TokenSymbol]map[semver.Version]*lock_release_token_pool.LockReleaseTokenPool // Map between token Symbol (e.g. LinkSymbol, WethSymbol) // and the respective aggregator USD feed contract USDFeeds map[TokenSymbol]*aggregator_v3_interface.AggregatorV3Interface @@ -422,7 +435,7 @@ func LoadOnchainState(e deployment.Environment) (CCIPOnChainState, error) { return state, err } } - chainState, err := LoadChainState(chain, addresses) + chainState, err := LoadChainState(e.GetContext(), chain, addresses) if err != nil { return state, err } @@ -432,7 +445,7 @@ 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) { +func LoadChainState(ctx context.Context, chain deployment.Chain, addresses map[string]deployment.TypeAndVersion) (CCIPChainState, error) { var state CCIPChainState mcmsWithTimelock, err := commoncs.MaybeLoadMCMSWithTimelockChainState(chain, addresses) if err != nil { @@ -607,26 +620,33 @@ func LoadChainState(chain deployment.Chain, addresses map[string]deployment.Type } state.USDFeeds[key] = feed case deployment.NewTypeAndVersion(BurnMintTokenPool, deployment.Version1_5_1).String(): - pool, err := burn_mint_token_pool.NewBurnMintTokenPool(common.HexToAddress(address), chain.Client) + ethAddress := common.HexToAddress(address) + pool, metadata, err := newTokenPoolWithMetadata(ctx, burn_mint_token_pool.NewBurnMintTokenPool, ethAddress, chain.Client) if err != nil { - return state, err - } - if state.BurnMintTokenPools == nil { - state.BurnMintTokenPools = make(map[TokenSymbol]*burn_mint_token_pool.BurnMintTokenPool) + return state, fmt.Errorf("failed to connect address %s with token pool bindings and get token symbol: %w", ethAddress, err) } - tokAddress, err := pool.GetToken(nil) + state.BurnMintTokenPools = helpers.AddValueToNestedMap(state.BurnMintTokenPools, metadata.Symbol, metadata.Version, pool) + case deployment.NewTypeAndVersion(BurnWithFromMintTokenPool, deployment.Version1_5_1).String(): + ethAddress := common.HexToAddress(address) + pool, metadata, err := newTokenPoolWithMetadata(ctx, burn_with_from_mint_token_pool.NewBurnWithFromMintTokenPool, ethAddress, chain.Client) if err != nil { - return state, err + return state, fmt.Errorf("failed to connect address %s with token pool bindings and get token symbol: %w", ethAddress, err) } - tok, err := erc20.NewERC20(tokAddress, chain.Client) + state.BurnWithFromMintTokenPools = helpers.AddValueToNestedMap(state.BurnWithFromMintTokenPools, metadata.Symbol, metadata.Version, pool) + case deployment.NewTypeAndVersion(BurnFromMintTokenPool, deployment.Version1_5_1).String(): + ethAddress := common.HexToAddress(address) + pool, metadata, err := newTokenPoolWithMetadata(ctx, burn_from_mint_token_pool.NewBurnFromMintTokenPool, ethAddress, chain.Client) if err != nil { - return state, err + return state, fmt.Errorf("failed to connect address %s with token pool bindings and get token symbol: %w", ethAddress, err) } - symbol, err := tok.Symbol(nil) + state.BurnFromMintTokenPools = helpers.AddValueToNestedMap(state.BurnFromMintTokenPools, metadata.Symbol, metadata.Version, pool) + case deployment.NewTypeAndVersion(LockReleaseTokenPool, deployment.Version1_5_1).String(): + ethAddress := common.HexToAddress(address) + pool, metadata, err := newTokenPoolWithMetadata(ctx, lock_release_token_pool.NewLockReleaseTokenPool, ethAddress, chain.Client) if err != nil { - return state, err + return state, fmt.Errorf("failed to connect address %s with token pool bindings and get token symbol: %w", ethAddress, err) } - state.BurnMintTokenPools[TokenSymbol(symbol)] = pool + state.LockReleaseTokenPools = helpers.AddValueToNestedMap(state.LockReleaseTokenPools, metadata.Symbol, metadata.Version, pool) case deployment.NewTypeAndVersion(BurnMintToken, deployment.Version1_0_0).String(): tok, err := burn_mint_erc677.NewBurnMintERC677(common.HexToAddress(address), chain.Client) if err != nil { diff --git a/deployment/ccip/changeset/testhelpers/test_token_helpers.go b/deployment/ccip/changeset/testhelpers/test_token_helpers.go new file mode 100644 index 00000000000..cc7fde2c01c --- /dev/null +++ b/deployment/ccip/changeset/testhelpers/test_token_helpers.go @@ -0,0 +1,204 @@ +package testhelpers + +import ( + "math/big" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + commoncs "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/deployment/environment/memory" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/shared/generated/burn_mint_erc677" +) + +const ( + LocalTokenDecimals = 18 + TestTokenSymbol changeset.TokenSymbol = "TEST" +) + +// SetupTwoChainEnvironmentWithTokens preps the environment for token pool deployment testing. +func SetupTwoChainEnvironmentWithTokens( + t *testing.T, + lggr logger.Logger, + transferToTimelock bool, +) (deployment.Environment, uint64, uint64, map[uint64]*deployment.ContractDeploy[*burn_mint_erc677.BurnMintERC677], map[uint64]*proposalutils.TimelockExecutionContracts) { + e := memory.NewMemoryEnvironment(t, lggr, zapcore.InfoLevel, memory.MemoryEnvironmentConfig{ + Chains: 2, + }) + selectors := e.AllChainSelectors() + + addressBook := deployment.NewMemoryAddressBook() + prereqCfg := make([]changeset.DeployPrerequisiteConfigPerChain, len(selectors)) + for i, selector := range selectors { + prereqCfg[i] = changeset.DeployPrerequisiteConfigPerChain{ + ChainSelector: selector, + } + } + + mcmsCfg := make(map[uint64]commontypes.MCMSWithTimelockConfig) + for _, selector := range selectors { + mcmsCfg[selector] = proposalutils.SingleGroupTimelockConfig(t) + } + + // Deploy one burn-mint token per chain to use in the tests + tokens := make(map[uint64]*deployment.ContractDeploy[*burn_mint_erc677.BurnMintERC677]) + for _, selector := range selectors { + token, err := deployment.DeployContract(e.Logger, e.Chains[selector], addressBook, + func(chain deployment.Chain) deployment.ContractDeploy[*burn_mint_erc677.BurnMintERC677] { + tokenAddress, tx, token, err := burn_mint_erc677.DeployBurnMintERC677( + e.Chains[selector].DeployerKey, + e.Chains[selector].Client, + string(TestTokenSymbol), + string(TestTokenSymbol), + LocalTokenDecimals, + big.NewInt(0).Mul(big.NewInt(1e9), big.NewInt(1e18)), + ) + return deployment.ContractDeploy[*burn_mint_erc677.BurnMintERC677]{ + Address: tokenAddress, + Contract: token, + Tv: deployment.NewTypeAndVersion(changeset.BurnMintToken, deployment.Version1_0_0), + Tx: tx, + Err: err, + } + }, + ) + require.NoError(t, err) + tokens[selector] = token + } + + // Deploy MCMS setup & prerequisite contracts + e, err := commoncs.ApplyChangesets(t, e, nil, []commoncs.ChangesetApplication{ + { + Changeset: commoncs.WrapChangeSet(changeset.DeployPrerequisitesChangeset), + Config: changeset.DeployPrerequisiteConfig{ + Configs: prereqCfg, + }, + }, + { + Changeset: commoncs.WrapChangeSet(commoncs.DeployMCMSWithTimelock), + Config: mcmsCfg, + }, + }) + require.NoError(t, err) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + // We only need the token admin registry to be owned by the timelock in these tests + timelockOwnedContractsByChain := make(map[uint64][]common.Address) + for _, selector := range selectors { + timelockOwnedContractsByChain[selector] = []common.Address{state.Chains[selector].TokenAdminRegistry.Address()} + } + + // Assemble map of addresses required for Timelock scheduling & execution + timelockContracts := make(map[uint64]*proposalutils.TimelockExecutionContracts) + for _, selector := range selectors { + timelockContracts[selector] = &proposalutils.TimelockExecutionContracts{ + Timelock: state.Chains[selector].Timelock, + CallProxy: state.Chains[selector].CallProxy, + } + } + + if transferToTimelock { + // Transfer ownership of token admin registry to the Timelock + e, err = commoncs.ApplyChangesets(t, e, timelockContracts, []commoncs.ChangesetApplication{ + { + Changeset: commoncs.WrapChangeSet(commoncs.TransferToMCMSWithTimelock), + Config: commoncs.TransferToMCMSWithTimelockConfig{ + ContractsByChain: timelockOwnedContractsByChain, + MinDelay: 0, + }, + }, + }) + require.NoError(t, err) + } + + return e, selectors[0], selectors[1], tokens, timelockContracts +} + +// getPoolsOwnedByDeployer returns any pools that need to be transferred to timelock. +func getPoolsOwnedByDeployer[T commonchangeset.Ownable](t *testing.T, contracts map[semver.Version]T, chain deployment.Chain) []common.Address { + var addresses []common.Address + for _, contract := range contracts { + owner, err := contract.Owner(nil) + require.NoError(t, err) + if owner == chain.DeployerKey.From { + addresses = append(addresses, contract.Address()) + } + } + return addresses +} + +// DeployTestTokenPools deploys token pools tied for the TEST token across multiple chains. +func DeployTestTokenPools( + t *testing.T, + e deployment.Environment, + newPools map[uint64]changeset.DeployTokenPoolInput, + transferToTimelock bool, +) deployment.Environment { + selectors := e.AllChainSelectors() + + e, err := commonchangeset.ApplyChangesets(t, e, nil, []commonchangeset.ChangesetApplication{ + { + Changeset: commonchangeset.WrapChangeSet(changeset.DeployTokenPoolContractsChangeset), + Config: changeset.DeployTokenPoolContractsConfig{ + TokenSymbol: TestTokenSymbol, + NewPools: newPools, + }, + }, + }) + require.NoError(t, err) + + state, err := changeset.LoadOnchainState(e) + require.NoError(t, err) + + if transferToTimelock { + // Assemble map of addresses required for Timelock scheduling & execution + timelockContracts := make(map[uint64]*proposalutils.TimelockExecutionContracts) + for _, selector := range selectors { + timelockContracts[selector] = &proposalutils.TimelockExecutionContracts{ + Timelock: state.Chains[selector].Timelock, + CallProxy: state.Chains[selector].CallProxy, + } + } + + timelockOwnedContractsByChain := make(map[uint64][]common.Address) + for _, selector := range selectors { + if newPool, ok := newPools[selector]; ok { + switch newPool.Type { + case changeset.BurnFromMintTokenPool: + timelockOwnedContractsByChain[selector] = getPoolsOwnedByDeployer(t, state.Chains[selector].BurnFromMintTokenPools[TestTokenSymbol], e.Chains[selector]) + case changeset.BurnWithFromMintTokenPool: + timelockOwnedContractsByChain[selector] = getPoolsOwnedByDeployer(t, state.Chains[selector].BurnWithFromMintTokenPools[TestTokenSymbol], e.Chains[selector]) + case changeset.BurnMintTokenPool: + timelockOwnedContractsByChain[selector] = getPoolsOwnedByDeployer(t, state.Chains[selector].BurnMintTokenPools[TestTokenSymbol], e.Chains[selector]) + case changeset.LockReleaseTokenPool: + timelockOwnedContractsByChain[selector] = getPoolsOwnedByDeployer(t, state.Chains[selector].LockReleaseTokenPools[TestTokenSymbol], e.Chains[selector]) + } + } + } + + // Transfer ownership of token admin registry to the Timelock + e, err = commoncs.ApplyChangesets(t, e, timelockContracts, []commoncs.ChangesetApplication{ + { + Changeset: commoncs.WrapChangeSet(commoncs.TransferToMCMSWithTimelock), + Config: commoncs.TransferToMCMSWithTimelockConfig{ + ContractsByChain: timelockOwnedContractsByChain, + MinDelay: 0, + }, + }, + }) + require.NoError(t, err) + } + + return e +} diff --git a/deployment/ccip/changeset/token_pools.go b/deployment/ccip/changeset/token_pools.go new file mode 100644 index 00000000000..92982664b60 --- /dev/null +++ b/deployment/ccip/changeset/token_pools.go @@ -0,0 +1,267 @@ +package changeset + +import ( + "context" + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink/deployment" + commoncs "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_admin_registry" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/token_pool" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/shared/generated/erc20" + ccipconfig "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/config" + "github.com/smartcontractkit/chainlink/v2/evm/utils" +) + +var currentTokenPoolVersion semver.Version = deployment.Version1_5_1 + +var tokenPoolTypes map[deployment.ContractType]struct{} = map[deployment.ContractType]struct{}{ + BurnMintTokenPool: struct{}{}, + BurnWithFromMintTokenPool: struct{}{}, + BurnFromMintTokenPool: struct{}{}, + LockReleaseTokenPool: struct{}{}, +} + +var tokenPoolVersions map[semver.Version]struct{} = map[semver.Version]struct{}{ + deployment.Version1_5_1: struct{}{}, +} + +// TokenPoolInfo defines the type & version of a token pool, along with an optional external administrator. +type TokenPoolInfo struct { + // Type is the type of the token pool. + Type deployment.ContractType + // Version is the version of the token pool. + Version semver.Version + // ExternalAdmin is the external administrator of the token pool on the registry. + ExternalAdmin common.Address +} + +func (t TokenPoolInfo) Validate() error { + // Ensure that the inputted type is known + if _, ok := tokenPoolTypes[t.Type]; !ok { + return fmt.Errorf("%s is not a known token pool type", t.Type) + } + + // Ensure that the inputted version is known + if _, ok := tokenPoolVersions[t.Version]; !ok { + return fmt.Errorf("%s is not a known token pool version", t.Version) + } + + return nil +} + +// GetConfigOnRegistry fetches the token's config on the token admin registry. +func (t TokenPoolInfo) GetConfigOnRegistry( + ctx context.Context, + symbol TokenSymbol, + chain deployment.Chain, + state CCIPChainState, +) (token_admin_registry.TokenAdminRegistryTokenConfig, error) { + _, tokenAddress, err := t.GetPoolAndTokenAddress(ctx, symbol, chain, state) + if err != nil { + return token_admin_registry.TokenAdminRegistryTokenConfig{}, fmt.Errorf("failed to get token pool and token address for %s token on %s: %w", symbol, chain, err) + } + tokenAdminRegistry := state.TokenAdminRegistry + tokenConfig, err := tokenAdminRegistry.GetTokenConfig(&bind.CallOpts{Context: ctx}, tokenAddress) + if err != nil { + return token_admin_registry.TokenAdminRegistryTokenConfig{}, fmt.Errorf("failed to get config of %s token with address %s from registry on %s: %w", symbol, tokenAddress, chain, err) + } + return tokenConfig, nil +} + +// GetPoolAndTokenAddress returns pool bindings and the token address. +func (t TokenPoolInfo) GetPoolAndTokenAddress( + ctx context.Context, + symbol TokenSymbol, + chain deployment.Chain, + state CCIPChainState, +) (*token_pool.TokenPool, common.Address, error) { + tokenPoolAddress, ok := getTokenPoolAddressFromSymbolTypeAndVersion(state, chain, symbol, t.Type, t.Version) + if !ok { + return nil, utils.ZeroAddress, fmt.Errorf("token pool does not exist on %s with symbol %s, type %s, and version %s", chain, symbol, t.Type, t.Version) + } + tokenPool, err := token_pool.NewTokenPool(tokenPoolAddress, chain.Client) + if err != nil { + return nil, utils.ZeroAddress, fmt.Errorf("failed to connect token pool with address %s on chain %s to token pool bindings: %w", tokenPoolAddress, chain, err) + } + tokenAddress, err := tokenPool.GetToken(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, utils.ZeroAddress, fmt.Errorf("failed to get token from pool with address %s on %s: %w", tokenPool.Address(), chain, err) + } + return tokenPool, tokenAddress, nil +} + +// tokenPool defines behavior common to all token pools. +type tokenPool interface { + GetToken(opts *bind.CallOpts) (common.Address, error) + TypeAndVersion(*bind.CallOpts) (string, error) +} + +// tokenPoolMetadata defines the token pool version version and symbol of the corresponding token. +type tokenPoolMetadata struct { + Version semver.Version + Symbol TokenSymbol +} + +// newTokenPoolWithMetadata returns a token pool along with its metadata. +func newTokenPoolWithMetadata[P tokenPool]( + ctx context.Context, + newTokenPool func(address common.Address, backend bind.ContractBackend) (P, error), + poolAddress common.Address, + chainClient deployment.OnchainClient, +) (P, tokenPoolMetadata, error) { + pool, err := newTokenPool(poolAddress, chainClient) + if err != nil { + return pool, tokenPoolMetadata{}, fmt.Errorf("failed to connect address %s with token pool bindings: %w", poolAddress, err) + } + tokenAddress, err := pool.GetToken(&bind.CallOpts{Context: ctx}) + if err != nil { + return pool, tokenPoolMetadata{}, fmt.Errorf("failed to get token address from pool with address %s: %w", poolAddress, err) + } + typeAndVersionStr, err := pool.TypeAndVersion(&bind.CallOpts{Context: ctx}) + if err != nil { + return pool, tokenPoolMetadata{}, fmt.Errorf("failed to get type and version from pool with address %s: %w", poolAddress, err) + } + _, versionStr, err := ccipconfig.ParseTypeAndVersion(typeAndVersionStr) + if err != nil { + return pool, tokenPoolMetadata{}, fmt.Errorf("failed to parse type and version of pool with address %s: %w", poolAddress, err) + } + version, err := semver.NewVersion(versionStr) + if err != nil { + return pool, tokenPoolMetadata{}, fmt.Errorf("failed parsing version %s of pool with address %s: %w", versionStr, poolAddress, err) + } + token, err := erc20.NewERC20(tokenAddress, chainClient) + if err != nil { + return pool, tokenPoolMetadata{}, fmt.Errorf("failed to connect address %s with ERC20 bindings: %w", tokenAddress, err) + } + symbol, err := token.Symbol(&bind.CallOpts{Context: ctx}) + if err != nil { + return pool, tokenPoolMetadata{}, fmt.Errorf("failed to fetch symbol from token with address %s: %w", tokenAddress, err) + } + return pool, tokenPoolMetadata{ + Symbol: TokenSymbol(symbol), + Version: *version, + }, nil +} + +// getTokenPoolAddressFromSymbolTypeAndVersion returns the token pool address in the environment linked to a particular symbol, type, and version +func getTokenPoolAddressFromSymbolTypeAndVersion( + chainState CCIPChainState, + chain deployment.Chain, + symbol TokenSymbol, + poolType deployment.ContractType, + version semver.Version, +) (common.Address, bool) { + switch poolType { + case BurnMintTokenPool: + if tokenPools, ok := chainState.BurnMintTokenPools[symbol]; ok { + if tokenPool, ok := tokenPools[version]; ok { + return tokenPool.Address(), true + } + } + case BurnFromMintTokenPool: + if tokenPools, ok := chainState.BurnFromMintTokenPools[symbol]; ok { + if tokenPool, ok := tokenPools[version]; ok { + return tokenPool.Address(), true + } + } + case BurnWithFromMintTokenPool: + if tokenPools, ok := chainState.BurnWithFromMintTokenPools[symbol]; ok { + if tokenPool, ok := tokenPools[version]; ok { + return tokenPool.Address(), true + } + } + case LockReleaseTokenPool: + if tokenPools, ok := chainState.LockReleaseTokenPools[symbol]; ok { + if tokenPool, ok := tokenPools[version]; ok { + return tokenPool.Address(), true + } + } + } + + return utils.ZeroAddress, false +} + +// TokenAdminRegistryChangesetConfig defines a config for all token admin registry actions. +type TokenAdminRegistryChangesetConfig struct { + // MCMS defines the delay to use for Timelock (if absent, the changeset will attempt to use the deployer key). + MCMS *MCMSConfig + // Pools defines the pools corresponding to the tokens we want to accept admin role for. + Pools map[uint64]map[TokenSymbol]TokenPoolInfo +} + +// validateTokenAdminRegistryChangeset validates all token admin registry changesets. +func (c TokenAdminRegistryChangesetConfig) Validate( + env deployment.Environment, + mustBeOwner bool, + registryConfigCheck func( + config token_admin_registry.TokenAdminRegistryTokenConfig, + sender common.Address, + externalAdmin common.Address, + symbol TokenSymbol, + chain deployment.Chain, + ) error, +) error { + state, err := LoadOnchainState(env) + if err != nil { + return fmt.Errorf("failed to load onchain state: %w", err) + } + for chainSelector, symbolToPoolInfo := range c.Pools { + err := deployment.IsValidChainSelector(chainSelector) + if err != nil { + return fmt.Errorf("failed to validate chain selector %d: %w", chainSelector, err) + } + chain, ok := env.Chains[chainSelector] + if !ok { + return fmt.Errorf("chain with selector %d does not exist in environment", chainSelector) + } + chainState, ok := state.Chains[chainSelector] + if !ok { + return fmt.Errorf("%s does not exist in state", chain) + } + if tokenAdminRegistry := chainState.TokenAdminRegistry; tokenAdminRegistry == nil { + return fmt.Errorf("missing tokenAdminRegistry on %s", chain) + } + if c.MCMS != nil { + if timelock := chainState.Timelock; timelock == nil { + return fmt.Errorf("missing timelock on %s", chain) + } + if proposerMcm := chainState.ProposerMcm; proposerMcm == nil { + return fmt.Errorf("missing proposerMcm on %s", chain) + } + } + // Validate that the token admin registry is owned by the address that will be actioning the transactions (i.e. Timelock or deployer key) + // However, most token admin registry actions aren't owner-protected. They just require you to be the admin. + if mustBeOwner { + if err := commoncs.ValidateOwnership(env.GetContext(), c.MCMS != nil, chain.DeployerKey.From, chainState.Timelock.Address(), chainState.TokenAdminRegistry); err != nil { + return fmt.Errorf("token admin registry failed ownership validation on %s: %w", chain, err) + } + } + for symbol, poolInfo := range symbolToPoolInfo { + if err := poolInfo.Validate(); err != nil { + return fmt.Errorf("failed to validate token pool info for %s token on chain %s: %w", symbol, chain, err) + } + + tokenConfigOnRegistry, err := poolInfo.GetConfigOnRegistry(env.GetContext(), symbol, chain, chainState) + if err != nil { + return fmt.Errorf("failed to get state of %s token on chain %s: %w", symbol, chain, err) + } + + fromAddress := chain.DeployerKey.From // "We" are either the deployer key or the timelock + if c.MCMS != nil { + fromAddress = chainState.Timelock.Address() + } + + err = registryConfigCheck(tokenConfigOnRegistry, fromAddress, poolInfo.ExternalAdmin, symbol, chain) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/deployment/helpers/maps.go b/deployment/helpers/maps.go new file mode 100644 index 00000000000..9329cdfc5c3 --- /dev/null +++ b/deployment/helpers/maps.go @@ -0,0 +1,15 @@ +package helpers + +// AddValueToNestedMap adds a value to a map nested within another map. +func AddValueToNestedMap[K1 comparable, K2 comparable, V any](mapping map[K1]map[K2]V, key1 K1, key2 K2, value V) map[K1]map[K2]V { + if mapping == nil { + mapping = make(map[K1]map[K2]V) + } + if mapping[key1] == nil { + mapping[key1] = make(map[K2]V) + mapping[key1][key2] = value + return mapping + } + mapping[key1][key2] = value + return mapping +} diff --git a/deployment/helpers/maps_test.go b/deployment/helpers/maps_test.go new file mode 100644 index 00000000000..c78e77c56ce --- /dev/null +++ b/deployment/helpers/maps_test.go @@ -0,0 +1,75 @@ +package helpers_test + +import ( + "reflect" + "testing" + + "github.com/smartcontractkit/chainlink/deployment/helpers" +) + +func TestAddValueToNestedMap(t *testing.T) { + tests := []struct { + name string + mapping map[string]map[string]int + key1 string + key2 string + value int + expected map[string]map[string]int + }{ + { + name: "Add to empty map", + mapping: nil, + key1: "group1", + key2: "item1", + value: 42, + expected: map[string]map[string]int{"group1": {"item1": 42}}, + }, + { + name: "Add to existing nested map", + mapping: map[string]map[string]int{ + "group1": {"item1": 10}, + }, + key1: "group1", + key2: "item2", + value: 20, + expected: map[string]map[string]int{"group1": {"item1": 10, "item2": 20}}, + }, + { + name: "Add to a new key in top-level map", + mapping: map[string]map[string]int{ + "group1": {"item1": 10}, + }, + key1: "group2", + key2: "item1", + value: 30, + expected: map[string]map[string]int{"group1": {"item1": 10}, "group2": {"item1": 30}}, + }, + { + name: "Overwrite existing value in nested map", + mapping: map[string]map[string]int{ + "group1": {"item1": 10}, + }, + key1: "group1", + key2: "item1", + value: 50, + expected: map[string]map[string]int{"group1": {"item1": 50}}, + }, + { + name: "Add to nil nested map", + mapping: map[string]map[string]int{"group1": nil}, + key1: "group1", + key2: "item1", + value: 60, + expected: map[string]map[string]int{"group1": {"item1": 60}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := helpers.AddValueToNestedMap(tt.mapping, tt.key1, tt.key2, tt.value) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +}