diff --git a/chains/evm/deployment/tokens/tokenimpl/helpers.go b/chains/evm/deployment/tokens/tokenimpl/helpers.go index be6cff75b7..660ff3b02a 100644 --- a/chains/evm/deployment/tokens/tokenimpl/helpers.go +++ b/chains/evm/deployment/tokens/tokenimpl/helpers.go @@ -1,6 +1,7 @@ package tokenimpl import ( + "context" "fmt" "math/big" @@ -38,6 +39,22 @@ func revokeDefaultAdminRoleBurnMintERC20(b cldf_ops.Bundle, chain evm.Chain, tok return []contract.WriteOutput{report.Output}, nil } +func hasDefaultAdminRoleBurnMintERC20(ctx context.Context, chain evm.Chain, token, user common.Address) (bool, error) { + tokenContract, err := bnm_erc20_bindings.NewBurnMintERC20(token, chain.Client) + if err != nil { + return false, fmt.Errorf("failed to instantiate BurnMintERC20 contract: %w", err) + } + role, err := tokenContract.DEFAULTADMINROLE(&bind.CallOpts{Context: ctx}) + if err != nil { + return false, fmt.Errorf("failed to get default admin role constant: %w", err) + } + hasRole, err := tokenContract.HasRole(&bind.CallOpts{Context: ctx}, role, user) + if err != nil { + return false, fmt.Errorf("failed to check default admin role for %s: %w", user.Hex(), err) + } + return hasRole, nil +} + func grantDefaultAdminRoleBurnMintERC20(b cldf_ops.Bundle, chain evm.Chain, token, user common.Address) ([]contract.WriteOutput, error) { tokenContract, err := bnm_erc20_bindings.NewBurnMintERC20(token, chain.Client) if err != nil { diff --git a/chains/evm/deployment/tokens/tokenimpl/impl.go b/chains/evm/deployment/tokens/tokenimpl/impl.go index 74ef7e8d5d..c083283c74 100644 --- a/chains/evm/deployment/tokens/tokenimpl/impl.go +++ b/chains/evm/deployment/tokens/tokenimpl/impl.go @@ -1,6 +1,7 @@ package tokenimpl import ( + "context" "math/big" "github.com/ethereum/go-ethereum/common" @@ -46,6 +47,11 @@ type Token interface { // role from user. Callers should consult SupportsAdminRole first. RevokeAdminRole(b cldf_ops.Bundle, chain evm.Chain, token, user common.Address) ([]contract.WriteOutput, error) + // HasAdminRole checks whether user has the default-admin or contract-specific + // admin role. Returns an error for token types whose Capabilities.SupportsAdminRole + // is false; callers should consult that flag first. + HasAdminRole(ctx context.Context, chain evm.Chain, token, user common.Address) (bool, error) + // GrantAdminRole grants the default-admin or contract-specific // admin role to user. Returns an error for token types whose // Capabilities.SupportsAdminRole is false; callers should consult diff --git a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20.go b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20.go index 34d2e1a0cd..0a11fb52b6 100644 --- a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20.go +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20.go @@ -1,6 +1,7 @@ package tokenimpl import ( + "context" "fmt" "math/big" @@ -35,6 +36,10 @@ func (tokenBurnMintERC20) RevokeAdminRole(b operations.Bundle, chain evm.Chain, return revokeDefaultAdminRoleBurnMintERC20(b, chain, token, user) } +func (tokenBurnMintERC20) HasAdminRole(ctx context.Context, chain evm.Chain, token, user common.Address) (bool, error) { + return hasDefaultAdminRoleBurnMintERC20(ctx, chain, token, user) +} + func (tokenBurnMintERC20) GrantAdminRole(b operations.Bundle, chain evm.Chain, token, externalAdmin common.Address) ([]contract.WriteOutput, error) { return grantDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) } diff --git a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_0_0.go b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_0_0.go index 8efcf108a6..ed1f055b58 100644 --- a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_0_0.go +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_0_0.go @@ -1,6 +1,7 @@ package tokenimpl import ( + "context" "fmt" "math/big" @@ -38,6 +39,10 @@ func (tokenBurnMintERC20WithDripV1_0_0) RevokeAdminRole(b operations.Bundle, cha return revokeDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) } +func (tokenBurnMintERC20WithDripV1_0_0) HasAdminRole(ctx context.Context, chain evm.Chain, token, user common.Address) (bool, error) { + return hasDefaultAdminRoleBurnMintERC20(ctx, chain, token, user) +} + func (tokenBurnMintERC20WithDripV1_0_0) GrantAdminRole(b operations.Bundle, chain evm.Chain, token, externalAdmin common.Address) ([]contract.WriteOutput, error) { return grantDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) } diff --git a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_5_0.go b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_5_0.go index 89f8ea51df..35a5f875e6 100644 --- a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_5_0.go +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_5_0.go @@ -1,6 +1,7 @@ package tokenimpl import ( + "context" "fmt" "math/big" @@ -34,6 +35,10 @@ func (tokenBurnMintERC20WithDripV1_5_0) RevokeAdminRole(b operations.Bundle, cha return revokeDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) } +func (tokenBurnMintERC20WithDripV1_5_0) HasAdminRole(ctx context.Context, chain evm.Chain, token, user common.Address) (bool, error) { + return hasDefaultAdminRoleBurnMintERC20(ctx, chain, token, user) +} + func (tokenBurnMintERC20WithDripV1_5_0) GrantAdminRole(b operations.Bundle, chain evm.Chain, token, externalAdmin common.Address) ([]contract.WriteOutput, error) { return grantDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) } diff --git a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc677.go b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc677.go index 812787f063..fb7df8b652 100644 --- a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc677.go +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc677.go @@ -1,6 +1,7 @@ package tokenimpl import ( + "context" "fmt" "math/big" @@ -37,6 +38,10 @@ func (tokenBurnMintERC677) RevokeAdminRole(_ operations.Bundle, _ evm.Chain, _, return nil, fmt.Errorf("admin role revoke not supported for BurnMintERC677 token type") } +func (tokenBurnMintERC677) HasAdminRole(_ context.Context, _ evm.Chain, _, _ common.Address) (bool, error) { + return false, fmt.Errorf("admin role checks not supported for BurnMintERC677 token type") +} + func (tokenBurnMintERC677) GrantAdminRole(_ operations.Bundle, _ evm.Chain, _, _ common.Address) ([]contract.WriteOutput, error) { return nil, fmt.Errorf("admin role grant not supported for BurnMintERC677 token type") } diff --git a/chains/evm/deployment/tokens/tokenimpl/token_erc20.go b/chains/evm/deployment/tokens/tokenimpl/token_erc20.go index a27555dc0d..906c3ef4f6 100644 --- a/chains/evm/deployment/tokens/tokenimpl/token_erc20.go +++ b/chains/evm/deployment/tokens/tokenimpl/token_erc20.go @@ -1,6 +1,7 @@ package tokenimpl import ( + "context" "fmt" "math/big" @@ -35,6 +36,10 @@ func (tokenERC20) RevokeAdminRole(_ operations.Bundle, _ evm.Chain, _, _ common. return nil, fmt.Errorf("admin role not supported for plain ERC20 token") } +func (tokenERC20) HasAdminRole(_ context.Context, _ evm.Chain, _, _ common.Address) (bool, error) { + return false, fmt.Errorf("admin role checks not supported for plain ERC20 token") +} + func (tokenERC20) GrantAdminRole(_ operations.Bundle, _ evm.Chain, _, _ common.Address) ([]contract.WriteOutput, error) { return nil, fmt.Errorf("admin role granting not supported for plain ERC20 token") } diff --git a/chains/evm/deployment/tokens/tokenimpl/token_tip20.go b/chains/evm/deployment/tokens/tokenimpl/token_tip20.go index 41ef4323ba..9ac87b84b3 100644 --- a/chains/evm/deployment/tokens/tokenimpl/token_tip20.go +++ b/chains/evm/deployment/tokens/tokenimpl/token_tip20.go @@ -1,9 +1,11 @@ package tokenimpl import ( + "context" "fmt" "math/big" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/tip20" @@ -42,6 +44,18 @@ func (tokenTIP20) RevokeAdminRole(b operations.Bundle, chain evm.Chain, token, u return []contract.WriteOutput{report.Output}, nil } +func (tokenTIP20) HasAdminRole(ctx context.Context, chain evm.Chain, token, user common.Address) (bool, error) { + tokenContract, err := tip20.NewTIP20Token(token, chain.Client) + if err != nil { + return false, fmt.Errorf("failed to instantiate TIP-20 token contract: %w", err) + } + hasRole, err := tokenContract.HasRole(&bind.CallOpts{Context: ctx}, user, tip20.DefaultAdminRole) + if err != nil { + return false, fmt.Errorf("failed to check TIP-20 admin role for %s: %w", user.Hex(), err) + } + return hasRole, nil +} + func (tokenTIP20) GrantAdminRole(b operations.Bundle, chain evm.Chain, token, user common.Address) ([]contract.WriteOutput, error) { report, err := operations.ExecuteOperation(b, tip20.GrantAdminRole, chain, contract.FunctionInput[common.Address]{ ChainSelector: chain.Selector, diff --git a/chains/evm/deployment/utils/datastore/datastore.go b/chains/evm/deployment/utils/datastore/datastore.go index e38e7936d4..dac281b7a7 100644 --- a/chains/evm/deployment/utils/datastore/datastore.go +++ b/chains/evm/deployment/utils/datastore/datastore.go @@ -9,6 +9,18 @@ import ( datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" ) +// ToNonZeroEVMAddress formats a datastore.AddressRef into an ethereum common.Address, ensuring the address is not the zero address. +func ToNonZeroEVMAddress(ref datastore.AddressRef) (commonAddress common.Address, err error) { + addr, err := ToEVMAddress(ref) + if err != nil { + return common.Address{}, fmt.Errorf("failed to convert address ref to EVM address: %w", err) + } + if addr == (common.Address{}) { + return common.Address{}, fmt.Errorf("address is the zero address in ref: %s", datastore_utils.SprintRef(ref)) + } + return addr, nil +} + // ToEVMAddress formats a datastore.AddressRef into an ethereum common.Address. func ToEVMAddress(ref datastore.AddressRef) (commonAddress common.Address, err error) { if ref.Address == "" { diff --git a/chains/evm/deployment/v1_0_0/adapters/pool_adapter.go b/chains/evm/deployment/v1_0_0/adapters/pool_adapter.go index c5fd318888..dbbe0e31e1 100644 --- a/chains/evm/deployment/v1_0_0/adapters/pool_adapter.go +++ b/chains/evm/deployment/v1_0_0/adapters/pool_adapter.go @@ -11,7 +11,6 @@ import ( "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/tokens/tokenimpl" datastore_utils_evm "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/erc20" - tarops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/token_admin_registry" tarseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/sequences" tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" cciputils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" @@ -35,16 +34,16 @@ var ( // that wires into its own bindings/operations. type PoolOps interface { GetToken(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address) (common.Address, error) - GetTokenDecimals(ctx context.Context, chain evm.Chain, poolAddr common.Address) (uint8, error) + GetTokenDecimals(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address) (uint8, error) GetPoolAdmins(ctx context.Context, chain *evm.Chain, poolAddr common.Address) (owner, rlAdmin common.Address, err error) SetRateLimiterConfig(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, input tokensapi.TPRLRemotes) ([]evm_contract.WriteOutput, error) - SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) (evm_contract.WriteOutput, error) + SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) ([]evm_contract.WriteOutput, error) // GetCurrentInboundRateLimit reads the on-chain inbound rate limiter state for the given remote // chain selector from the token pool at poolAddr. Used by outbound-only TPRL writes to read and // pass through the current inbound, and by RateLimitReaderAdapter for cross-chain validation. // Returns a zero-value RateLimiterConfig (IsEnabled=false, Capacity=0, Rate=0) when the pool has // no inbound configured for the lane. - GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64) (tokensapi.RateLimiterConfig, error) + GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64, fastFinality bool) (tokensapi.RateLimiterConfig, error) Version() *semver.Version } @@ -69,36 +68,18 @@ func (a *EVMPoolAdapter) DeriveTokenAddress(e deployment.Environment, chainSelec return "", fmt.Errorf("chain with selector %d not defined", chainSelector) } - // If the ref has the pool address already, then skip the datastore lookup altogether - if poolRef.Address != "" { - tokenPoolAddr := poolRef.Address - if !common.IsHexAddress(tokenPoolAddr) { - return "", fmt.Errorf("token pool address %q in ref is not a valid hex address", tokenPoolAddr) - } - tokenAddr, err := a.Ops.GetToken(e.OperationsBundle, chain, common.HexToAddress(tokenPoolAddr)) - if err != nil { - return "", fmt.Errorf("failed to get token address from token pool (%s): %w", datastore_utils.SprintRef(poolRef), err) - } - return tokenAddr.Hex(), nil - } - - // If the pool address isn't in the ref, then look it up in the datastore - tokenPoolAddrRef, err := datastore_utils.FindAndFormatRef(e.DataStore, poolRef, chainSelector, datastore_utils.FullRef) + // If the ref already has the pool address, then skip the datastore lookup altogether + // and use it to get the token. If the pool address is NOT in the ref, then fall back + // to resolving it from the datastore first. + tokenPoolAddr, err := a.EVMTokenBase.ParseNonZeroAddressRef(e.DataStore, poolRef, chainSelector) if err != nil { - return "", fmt.Errorf("failed to find token pool in datastore using ref (%+v): %w", poolRef, err) - } - tokenPoolAddrBytes, err := a.AddressRefToBytes(tokenPoolAddrRef) - if err != nil { - return "", fmt.Errorf("failed to convert address ref to bytes: %w", err) - } - tokenPoolAddr := common.BytesToAddress(tokenPoolAddrBytes) - if tokenPoolAddr == (common.Address{}) { - return "", errors.New("token pool address is zero address") + return "", fmt.Errorf("failed to parse token pool address from ref (%s): %w", datastore_utils.SprintRef(poolRef), err) } tokenAddr, err := a.Ops.GetToken(e.OperationsBundle, chain, tokenPoolAddr) if err != nil { - return "", fmt.Errorf("failed to get token address from token pool ref (%+v): %w", tokenPoolAddrRef, err) + return "", fmt.Errorf("failed to get token address from token pool ref (%s): %w", datastore_utils.SprintRef(poolRef), err) } + return tokenAddr.Hex(), nil } @@ -129,22 +110,14 @@ func (a *EVMPoolAdapter) DeriveTokenDecimals(e deployment.Environment, chainSele // If we can't source the decimals directly from the token then check if the pool // address is directly available in the ref. If so, we can skip the datastore and - // go straight to the pool contract for decimals. - if poolRef.Address != "" { - if !common.IsHexAddress(poolRef.Address) { - return 0, fmt.Errorf("token pool address %q in ref is not a valid hex address", poolRef.Address) - } else { - return a.Ops.GetTokenDecimals(e.GetContext(), chain, common.HexToAddress(poolRef.Address)) - } - } - - // If the ref doesn't have the pool address, then we need to hit the datastore for - // the full pool ref, then get the decimals from the pool contract. - poolAddr, err := datastore_utils.FindAndFormatRef(e.DataStore, poolRef, chainSelector, datastore_utils_evm.ToEVMAddress) + // go straight to the pool contract for the decimals. If the ref doesn't have the + // pool address, then we need to hit the datastore for the full pool ref then get + // the token decimals from the pool contract. + poolAddr, err := a.EVMTokenBase.ParseNonZeroAddressRef(e.DataStore, poolRef, chainSelector) if err != nil { return 0, fmt.Errorf("failed to find token pool address for ref (%s): %w", datastore_utils.SprintRef(poolRef), err) } else { - return a.Ops.GetTokenDecimals(e.GetContext(), chain, poolAddr) + return a.Ops.GetTokenDecimals(e.OperationsBundle, chain, poolAddr) } } @@ -153,34 +126,16 @@ func (a *EVMPoolAdapter) DeriveTokenDecimals(e deployment.Environment, chainSele // not supported for v1.x EVM pools and returns an error. tokenRef is unused on EVM (pools are // keyed by pool address alone) and exists for parity with chain families that need the token // mint to resolve the read. -func (a *EVMPoolAdapter) GetOnchainInboundRateLimit( - e deployment.Environment, - chainSelector uint64, - poolRef datastore.AddressRef, - _ datastore.AddressRef, - remoteSelector uint64, - fastFinality bool, -) (tokensapi.RateLimiterConfig, error) { - if fastFinality { - return tokensapi.RateLimiterConfig{}, fmt.Errorf("v1.x EVM pools do not support fastFinality rate limit buckets") - } +func (a *EVMPoolAdapter) GetOnchainInboundRateLimit(e deployment.Environment, chainSelector uint64, poolRef datastore.AddressRef, _ datastore.AddressRef, remoteSelector uint64, fastFinality bool) (tokensapi.RateLimiterConfig, error) { chain, ok := e.BlockChains.EVMChains()[chainSelector] if !ok { return tokensapi.RateLimiterConfig{}, fmt.Errorf("chain with selector %d not defined", chainSelector) } - addrRef, err := datastore_utils.FindAndFormatRef(e.DataStore, poolRef, chainSelector, datastore_utils.FullRef) - if err != nil { - return tokensapi.RateLimiterConfig{}, fmt.Errorf("failed to find token pool in datastore using ref (%+v): %w", poolRef, err) - } - addrRaw, err := a.AddressRefToBytes(addrRef) + poolAddr, err := a.EVMTokenBase.ParseNonZeroAddressRef(e.DataStore, poolRef, chainSelector) if err != nil { - return tokensapi.RateLimiterConfig{}, fmt.Errorf("failed to convert address ref to bytes: %w", err) + return tokensapi.RateLimiterConfig{}, fmt.Errorf("failed to find token pool address for ref (%s): %w", datastore_utils.SprintRef(poolRef), err) } - poolAddr := common.BytesToAddress(addrRaw) - if poolAddr == (common.Address{}) { - return tokensapi.RateLimiterConfig{}, fmt.Errorf("token pool address for ref (%+v) is zero", addrRef) - } - return a.Ops.GetCurrentInboundRateLimit(e.OperationsBundle, chain, poolAddr, remoteSelector) + return a.Ops.GetCurrentInboundRateLimit(e.OperationsBundle, chain, poolAddr, remoteSelector, fastFinality) } func (a *EVMPoolAdapter) SetTokenPoolRateLimits() *cldf_ops.Sequence[tokensapi.TPRLRemotes, sequences.OnChainOutput, cldf_chain.BlockChains] { @@ -194,19 +149,14 @@ func (a *EVMPoolAdapter) SetTokenPoolRateLimits() *cldf_ops.Sequence[tokensapi.T if !ok { return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not defined", input.ChainSelector) } - - tokenPoolAddrBytes, err := a.AddressRefToBytes(input.TokenPoolRef) + tokenPoolAddr, err := a.EVMTokenBase.ParseNonZeroAddressRef(input.ExistingDataStore, input.TokenPoolRef, input.ChainSelector) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token pool address ref to bytes: %w", err) - } - tokenPoolAddr := common.BytesToAddress(tokenPoolAddrBytes) - if tokenPoolAddr == (common.Address{}) { - return sequences.OnChainOutput{}, fmt.Errorf("token pool address for ref (%+v) is zero address", input.TokenPoolRef) + return sequences.OnChainOutput{}, fmt.Errorf("failed to find token pool address for ref (%s): %w", datastore_utils.SprintRef(input.TokenPoolRef), err) } if input.SkipIfMissingPermissions { timelockFltr := datastore.AddressRef{Type: datastore.ContractType(cciputils.RBACTimelock), ChainSelector: chain.Selector, Qualifier: cciputils.CLLQualifier} - timelockAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, timelockFltr, chain.Selector, datastore_utils_evm.ToEVMAddress) + timelockAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, timelockFltr, chain.Selector, datastore_utils_evm.ToNonZeroEVMAddress) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to find timelock address for chain %d: %w", chain.Selector, err) } @@ -253,7 +203,7 @@ func (a *EVMPoolAdapter) ManualRegistration() *cldf_ops.Sequence[tokensapi.Manua return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not defined", input.ChainSelector) } - tarAddress, err := GetTokenAdminRegistryAddress(input.ExistingDataStore, chain.Selector, &a.EVMTokenBase) + tarAddress, err := a.EVMTokenBase.GetTokenAdminRegistryAddress(input.ExistingDataStore, chain.Selector) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to get token admin registry address for chain %d: %w", chain.Selector, err) } @@ -267,26 +217,16 @@ func (a *EVMPoolAdapter) ManualRegistration() *cldf_ops.Sequence[tokensapi.Manua tokenRef := input.TokenRef if tokenRef.Address == "" { if tokRef, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, tokenRef, chain.Selector, datastore_utils.FullRef); err != nil { - b.Logger.Warnf("token address could not be resolved using TokenRef (%+v): %v", tokenRef, err) - b.Logger.Warnf("attempting to resolve token address using TokenPoolRef instead: (%+v)", input.TokenPoolRef) - - tokenPoolRef, poolErr := datastore_utils.FindAndFormatRef(input.ExistingDataStore, input.TokenPoolRef, chain.Selector, datastore_utils.FullRef) - if poolErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("token pool could not be resolved using TokenPoolRef (%+v): %w", input.TokenPoolRef, poolErr) - } - tokenPoolAddrBytes, addrErr := a.AddressRefToBytes(tokenPoolRef) - if addrErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token pool address ref to bytes: %w", addrErr) - } - tokenPoolAddr := common.BytesToAddress(tokenPoolAddrBytes) - if tokenPoolAddr == (common.Address{}) { - return sequences.OnChainOutput{}, fmt.Errorf("token pool address for ref (%+v) is zero address", tokenPoolRef) + b.Logger.Warnf("token address could not be resolved using TokenRef (%s): %v", datastore_utils.SprintRef(tokenRef), err) + b.Logger.Warnf("attempting to resolve token address using TokenPoolRef instead: (%s)", datastore_utils.SprintRef(input.TokenPoolRef)) + tokenPoolAddr, err := a.EVMTokenBase.ParseNonZeroAddressRef(input.ExistingDataStore, input.TokenPoolRef, chain.Selector) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to find token pool address for ref (%s): %w", datastore_utils.SprintRef(input.TokenPoolRef), err) } - tokenAddr, getErr := a.Ops.GetToken(b, chain, tokenPoolAddr) - if getErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get token address from token pool ref (%+v): %w", tokenPoolRef, getErr) + tokenAddr, err := a.Ops.GetToken(b, chain, tokenPoolAddr) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to get token address from token pool at ref (%s): %w", datastore_utils.SprintRef(input.TokenPoolRef), err) } - tokenRef = datastore.AddressRef{ ChainSelector: chain.Selector, Address: tokenAddr.Hex(), @@ -338,45 +278,39 @@ func (a *EVMPoolAdapter) DeployTokenPoolForToken() *cldf_ops.Sequence[tokensapi. a.Ops.Version(), "Deploy a token pool for a token on an EVM chain", func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input tokensapi.DeployTokenPoolInput) (sequences.OnChainOutput, error) { - var writes []evm_contract.WriteOutput - + var result sequences.OnChainOutput if a.DeployTokenPoolSeq == nil { return sequences.OnChainOutput{}, errors.New("DeployTokenPoolSeq is not set on EVMPoolAdapter") } + chain, ok := chains.EVMChains()[input.ChainSelector] if !ok { return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not defined", input.ChainSelector) } + out, err := cldf_ops.ExecuteSequence(b, a.DeployTokenPoolSeq, chains, input) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy token pool on chain %d: %w", input.ChainSelector, err) } - var result sequences.OnChainOutput result.Addresses = append(result.Addresses, out.Output.Addresses...) result.BatchOps = append(result.BatchOps, out.Output.BatchOps...) - - toknFilterDS := datastore.AddressRef{ChainSelector: input.ChainSelector} - if input.TokenRef.Address != "" { - toknFilterDS.Address = input.TokenRef.Address - } - if input.TokenRef.Qualifier != "" { - toknFilterDS.Qualifier = input.TokenRef.Qualifier - } - if input.TokenRef.Type != "" { - toknFilterDS.Type = input.TokenRef.Type + if input.TokenRef == nil { + return sequences.OnChainOutput{}, errors.New("token ref must be provided in input to DeployTokenPoolForToken sequence for EVM pools") } - toknRef, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, toknFilterDS, input.ChainSelector, datastore_utils.FullRef) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to find token address for symbol %q on chain %d: %w", input.TokenRef.Qualifier, input.ChainSelector, err) - } - toknAddr, err := datastore_utils_evm.ToEVMAddress(toknRef) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token ref to EVM address for chain %d: %w", input.ChainSelector, err) - } - if toknAddr == (common.Address{}) { - return sequences.OnChainOutput{}, fmt.Errorf("token address for symbol %q is zero address", input.TokenRef.Qualifier) + // NOTE: the token ref may be fully populated, but might not exist in the datastore + // (this can happen when using token address ref resolvers). In these situations we + // should avoid the datastore lookup altogether since it is doomed to fail and just + // use the input address ref directly. Failure to do this would cause this sequence + // to fail unnecessarily even when it has all the data it needs to succeed. + tokenRef := input.TokenRef.Clone() + if !datastore_utils.IsAddressRefFullyPopulated(tokenRef) { + if ref, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, tokenRef, input.ChainSelector, datastore_utils.FullRef); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve token address for ref (%s) from datastore after token pool deployment: %w", datastore_utils.SprintRef(tokenRef), err) + } else { + tokenRef = ref + } } var poolRef datastore.AddressRef @@ -384,8 +318,9 @@ func (a *EVMPoolAdapter) DeployTokenPoolForToken() *cldf_ops.Sequence[tokensapi. poolRef = out.Output.Addresses[0] } + var writes []evm_contract.WriteOutput if !datastore_utils.IsAddressRefEmpty(poolRef) { - if tokenPoolRolesWrites, err := tidyTokenPoolRoles(b, chain, input, poolRef, toknRef); err != nil { + if tokenPoolRolesWrites, err := a.TidyTokenPoolRoles(b, chain, input, poolRef, tokenRef); err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to tidy token pool roles: %w", err) } else { writes = append(writes, tokenPoolRolesWrites...) @@ -395,26 +330,21 @@ func (a *EVMPoolAdapter) DeployTokenPoolForToken() *cldf_ops.Sequence[tokensapi. if !common.IsHexAddress(rlAdminHex) { return sequences.OnChainOutput{}, fmt.Errorf("rate limit admin address %q is not a valid hex address", input.RateLimitAdmin) } - rlAdminAddr := common.HexToAddress(rlAdminHex) - if rlAdminAddr == (common.Address{}) { - return sequences.OnChainOutput{}, errors.New("rate limit admin address cannot be the zero address") - } - poolAddr, err := datastore_utils_evm.ToEVMAddress(poolRef) + poolAddr, err := datastore_utils_evm.ToNonZeroEVMAddress(poolRef) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token pool ref to EVM address for chain %d: %w", input.ChainSelector, err) } - if poolAddr == (common.Address{}) { - return sequences.OnChainOutput{}, errors.New("deployed token pool address cannot be the zero address") - } - output, err := a.Ops.SetRateLimitAdmin(b, chain, poolAddr, rlAdminAddr) + output, err := a.Ops.SetRateLimitAdmin(b, chain, poolAddr, common.HexToAddress(rlAdminHex)) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to set rate limit admin: %w", err) } - writes = append(writes, output) + if len(output) > 0 { + writes = append(writes, output...) + } } } - if tokenRolesWrites, err := tidyTokenRoles(b, chain, input, toknRef); err != nil { + if tokenRolesWrites, err := a.TidyTokenRoles(b, chain, input, tokenRef); err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to tidy token roles: %w", err) } else { writes = append(writes, tokenRolesWrites...) @@ -436,23 +366,23 @@ func (a *EVMPoolAdapter) DeployTokenPoolForToken() *cldf_ops.Sequence[tokensapi. // tidyTokenPoolRoles grants a token pool the token-side roles required for its // pool type. Burn/mint pools delegate role selection to the registered token // strategy because token contracts expose different role APIs. -func tidyTokenPoolRoles( +func (a *EVMPoolAdapter) TidyTokenPoolRoles( b cldf_ops.Bundle, chain evm.Chain, input tokensapi.DeployTokenPoolInput, - poolRef datastore.AddressRef, + tokenPoolRef datastore.AddressRef, tokenRef datastore.AddressRef, ) ([]evm_contract.WriteOutput, error) { - tokenAddr, err := datastore_utils_evm.ToEVMAddress(tokenRef) + tokenPoolAddr, err := datastore_utils_evm.ToNonZeroEVMAddress(tokenPoolRef) if err != nil { - return nil, fmt.Errorf("failed to convert token ref to EVM address for chain %d: %w", input.ChainSelector, err) + return nil, fmt.Errorf("failed to convert token pool ref to EVM address for chain %d: %w", input.ChainSelector, err) } - poolAddress, err := datastore_utils_evm.ToEVMAddress(poolRef) + tokenAddr, err := datastore_utils_evm.ToNonZeroEVMAddress(tokenRef) if err != nil { - return nil, fmt.Errorf("failed to convert token pool ref to EVM address for chain %d: %w", input.ChainSelector, err) + return nil, fmt.Errorf("failed to convert token ref to EVM address for chain %d: %w", input.ChainSelector, err) } - if input.PoolType == cciputils.BurnMintTokenPool.String() { + if a.IsBurnMintPoolType(input.PoolType) { tokenImpl, ok := tokenimpl.Get(deployment.ContractType(tokenRef.Type)) if !ok { b.Logger.Warnf( @@ -466,13 +396,13 @@ func tidyTokenPoolRoles( if !tokenCaps.ParticipatesInPoolRoleGrant { b.Logger.Warnf( "token type %q has no pool role grant strategy registered, skipping grant for token pool %q on token %q on chain %d", - tokenRef.Type.String(), poolAddress.Hex(), input.TokenRef.Qualifier, input.ChainSelector, + tokenRef.Type.String(), tokenPoolAddr.Hex(), input.TokenRef.Qualifier, input.ChainSelector, ) return nil, nil } - if grantWrites, grantErr := tokenImpl.GrantPoolRoles(b, chain, tokenAddr, poolAddress, common.HexToAddress(input.TimelockAddress)); grantErr != nil { - return nil, fmt.Errorf("failed to grant pool roles for token type %q (token %q, pool %q) on chain %d: %w", tokenRef.Type, input.TokenRef.Qualifier, poolAddress.Hex(), input.ChainSelector, grantErr) + if grantWrites, grantErr := tokenImpl.GrantPoolRoles(b, chain, tokenAddr, tokenPoolAddr, common.HexToAddress(input.TimelockAddress)); grantErr != nil { + return nil, fmt.Errorf("failed to grant pool roles for token type %q (token %q, pool %q) on chain %d: %w", tokenRef.Type, input.TokenRef.Qualifier, tokenPoolAddr.Hex(), input.ChainSelector, grantErr) } else { return grantWrites, nil } @@ -486,13 +416,13 @@ func tidyTokenPoolRoles( // (i.e. not deployed/not applicable which can be the case in test cases), // then it leaves the deployer account as an admin so the token isn't left // without an operator. -func tidyTokenRoles( +func (a *EVMPoolAdapter) TidyTokenRoles( b cldf_ops.Bundle, chain evm.Chain, input tokensapi.DeployTokenPoolInput, tokenRef datastore.AddressRef, ) ([]evm_contract.WriteOutput, error) { - tokenAddr, err := datastore_utils_evm.ToEVMAddress(tokenRef) + tokenAddr, err := datastore_utils_evm.ToNonZeroEVMAddress(tokenRef) if err != nil { return nil, fmt.Errorf("failed to convert token ref to EVM address for chain %d: %w", input.ChainSelector, err) } @@ -515,21 +445,12 @@ func tidyTokenRoles( return nil, nil } - timelockRef := datastore_utils.GetAddressRef( - input.ExistingDataStore.Addresses().Filter(), - input.ChainSelector, - cciputils.RBACTimelock, - cciputils.Version_1_0_0, - cciputils.CLLQualifier, - ) - if datastore_utils.IsAddressRefEmpty(timelockRef) { - b.Logger.Infof("CLL timelock not found for chain %d; keeping deployer as token admin", input.ChainSelector) - return nil, nil - } - timelockAddr, err := datastore_utils_evm.ToEVMAddress(timelockRef) + timelockAddr, err := a.GetTimelockAddressCLL(input.ExistingDataStore, input.ChainSelector) if err != nil { - return nil, fmt.Errorf("failed to convert timelock ref to EVM address for chain %d: %w", input.ChainSelector, err) + b.Logger.Infof("CLL timelock not found for chain %d; keeping deployer as token admin: %s", input.ChainSelector, err.Error()) + return nil, nil } + grantWrites, err := tokenImpl.GrantAdminRole(b, chain, tokenAddr, timelockAddr) if err != nil { return nil, fmt.Errorf("failed to grant timelock admin role for token %q on chain %d: %w", tokenAddr.Hex(), input.ChainSelector, err) @@ -541,21 +462,3 @@ func tidyTokenRoles( return append(grantWrites, revokeWrites...), nil } - -// GetTokenAdminRegistryAddress looks up the TAR (v1.5.0) address from the datastore. -func GetTokenAdminRegistryAddress(ds datastore.DataStore, selector uint64, base *EVMTokenBase) (common.Address, error) { - filters := datastore.AddressRef{ - Type: datastore.ContractType(tarops.ContractType), - ChainSelector: selector, - Version: tarops.Version, - } - ref, err := datastore_utils.FindAndFormatRef(ds, filters, selector, datastore_utils.FullRef) - if err != nil { - return common.Address{}, fmt.Errorf("failed to find token admin registry address on chain %d: %w", selector, err) - } - addr, err := base.AddressRefToBytes(ref) - if err != nil { - return common.Address{}, fmt.Errorf("failed to convert address ref to bytes: %w", err) - } - return common.BytesToAddress(addr), nil -} diff --git a/chains/evm/deployment/v1_0_0/adapters/token_adapter.go b/chains/evm/deployment/v1_0_0/adapters/token_adapter.go index 68841f59e8..1d2d160abe 100644 --- a/chains/evm/deployment/v1_0_0/adapters/token_adapter.go +++ b/chains/evm/deployment/v1_0_0/adapters/token_adapter.go @@ -6,9 +6,16 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils" + datastore_utils_evm "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" + bnmERC20Ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + bnmDripOpsV1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20_with_drip" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/erc20" + rmnproxyops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/rmn_proxy" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/type_and_version" v1_0_0_seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/sequences" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" + bnmDripOpsV1_5_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/burn_mint_erc20_with_drip" + tarops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/token_admin_registry" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/token_pool" deployops "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" @@ -17,15 +24,17 @@ import ( "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" - evm_contract "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" ) var ( - _ tokensapi.TokenRefResolver = &EVMTokenBase{} - _ tokensapi.TokenAdapter = &EVMTokenBase{} + _ tokensapi.TokenAdminRoleAdapter = &EVMTokenBase{} + _ tokensapi.TokenRefResolver = &EVMTokenBase{} + _ tokensapi.TokenAdapter = &EVMTokenBase{} ) // EVMTokenBase provides version-agnostic EVM token adapter methods that are @@ -46,14 +55,22 @@ func (a *EVMTokenBase) DeployToken() *cldf_ops.Sequence[tokensapi.DeployTokenInp return v1_0_0_seq.DeployToken } +func (a *EVMTokenBase) RevokeTokenAdminRole() *cldf_ops.Sequence[tokensapi.RevokeTokenAdminRoleSequenceInput, sequences.OnChainOutput, cldf_chain.BlockChains] { + return v1_0_0_seq.RevokeTokenAdminRole +} + func (a *EVMTokenBase) DeployTokenVerify(e deployment.Environment, input tokensapi.DeployTokenInput) error { - tokenAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Type: datastore.ContractType(input.Type), - Qualifier: input.Symbol, - }, input.ChainSelector, datastore_utils.FullRef) + tokenAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, + datastore.AddressRef{ + ChainSelector: input.ChainSelector, + Type: datastore.ContractType(input.Type), + Qualifier: input.Symbol, + }, + input.ChainSelector, + datastore_utils.FullRef, + ) if err == nil { - e.OperationsBundle.Logger.Info("Token already deployed at address:", tokenAddr.Address) + e.Logger.Info("Token already deployed at address:", tokenAddr.Address) return nil } @@ -75,10 +92,6 @@ func (a *EVMTokenBase) DeployTokenVerify(e deployment.Environment, input tokensa return nil } -func (a *EVMTokenBase) DeriveTokenPoolCounterpart(_ deployment.Environment, _ uint64, tokenPool []byte, _ []byte) ([]byte, error) { - return tokenPool, nil -} - // UpdateAuthorities transfers token pool ownership to the timelock via MCMS. // It creates a self-contained EVMTransferOwnershipAdapter within the sequence // closure so it works correctly regardless of how the embedding struct is initialized. @@ -93,41 +106,28 @@ func (a *EVMTokenBase) UpdateAuthorities() *cldf_ops.Sequence[tokensapi.UpdateAu return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not defined", input.ChainSelector) } - timelockRef, err := datastore_utils.FindAndFormatRef( - e.DataStore, - datastore.AddressRef{ - Type: datastore.ContractType(cciputils.RBACTimelock), - ChainSelector: chain.Selector, - Qualifier: cciputils.CLLQualifier, - }, - chain.Selector, - datastore_utils.FullRef, - ) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to find timelock address for chain %d: %w", input.ChainSelector, err) - } - adapter := &EVMTransferOwnershipAdapter{} if err := adapter.InitializeTimelockAddress(*e, mcms.Input{Qualifier: cciputils.CLLQualifier}); err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to initialize timelock address for chain %d: %w", input.ChainSelector, err) } + timelockAddr, err := a.GetTimelockAddressCLL(e.DataStore, chain.Selector) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to get timelock address for chain %d: %w", input.ChainSelector, err) + } ownershipInput := deployops.TransferOwnershipPerChainInput{ ChainSelector: chain.Selector, CurrentOwner: chain.DeployerKey.From.Hex(), - ProposedOwner: timelockRef.Address, + ProposedOwner: timelockAddr.Hex(), ContractRef: []datastore.AddressRef{input.TokenPoolRef}, } var result sequences.OnChainOutput - result, err = sequences.RunAndMergeSequence(b, e.BlockChains, - adapter.SequenceTransferOwnershipViaMCMS(), ownershipInput, result) + result, err = sequences.RunAndMergeSequence(b, e.BlockChains, adapter.SequenceTransferOwnershipViaMCMS(), ownershipInput, result) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to transfer ownership on chain %d: %w", input.ChainSelector, err) } - - result, err = sequences.RunAndMergeSequence(b, e.BlockChains, - adapter.SequenceAcceptOwnership(), ownershipInput, result) + result, err = sequences.RunAndMergeSequence(b, e.BlockChains, adapter.SequenceAcceptOwnership(), ownershipInput, result) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to accept ownership on chain %d: %w", input.ChainSelector, err) } @@ -136,38 +136,6 @@ func (a *EVMTokenBase) UpdateAuthorities() *cldf_ops.Sequence[tokensapi.UpdateAu }) } -func (a *EVMTokenBase) MigrateLockReleasePoolLiquiditySequence() *cldf_ops.Sequence[tokensapi.MigrateLockReleasePoolLiquidityInput, sequences.OnChainOutput, cldf_chain.BlockChains] { - return nil -} - -// Pool-specific stubs -- these are overridden by per-version adapters (v1.5.1, v1.6.1, v2.0.0). -// EVMTokenBase is registered at v1.0.0 so callers that only need token deployment (DeployToken, -// DeployTokenVerify) can obtain a valid adapter without importing a pool-version package. - -func (a *EVMTokenBase) ConfigureTokenForTransfersSequence() *cldf_ops.Sequence[tokensapi.ConfigureTokenForTransfersInput, sequences.OnChainOutput, cldf_chain.BlockChains] { - return nil -} - -func (a *EVMTokenBase) DeriveTokenAddress(_ deployment.Environment, _ uint64, _ datastore.AddressRef) (string, error) { - return "", fmt.Errorf("DeriveTokenAddress is not implemented on EVMTokenBase; use a pool-version adapter") -} - -func (a *EVMTokenBase) DeriveTokenDecimals(_ deployment.Environment, _ uint64, _ datastore.AddressRef, _ []byte) (uint8, error) { - return 0, fmt.Errorf("DeriveTokenDecimals is not implemented on EVMTokenBase; use a pool-version adapter") -} - -func (a *EVMTokenBase) SetTokenPoolRateLimits() *cldf_ops.Sequence[tokensapi.TPRLRemotes, sequences.OnChainOutput, cldf_chain.BlockChains] { - return nil -} - -func (a *EVMTokenBase) ManualRegistration() *cldf_ops.Sequence[tokensapi.ManualRegistrationSequenceInput, sequences.OnChainOutput, cldf_chain.BlockChains] { - return nil -} - -func (a *EVMTokenBase) DeployTokenPoolForToken() *cldf_ops.Sequence[tokensapi.DeployTokenPoolInput, sequences.OnChainOutput, cldf_chain.BlockChains] { - return nil -} - func (a *EVMTokenBase) ResolveTokenPoolRef(b cldf_ops.Bundle, chains cldf_chain.BlockChains, _ datastore.DataStore, chainSelector uint64, address string) (datastore.AddressRef, error) { var poolAddress common.Address if !common.IsHexAddress(address) { @@ -183,7 +151,7 @@ func (a *EVMTokenBase) ResolveTokenPoolRef(b cldf_ops.Bundle, chains cldf_chain. tv, err := cldf_ops.ExecuteOperation(b, type_and_version.GetTypeAndVersion, chain, - evm_contract.FunctionInput[struct{}]{ + contract.FunctionInput[struct{}]{ ChainSelector: chainSelector, Address: poolAddress, }, @@ -203,7 +171,7 @@ func (a *EVMTokenBase) ResolveTokenPoolRef(b cldf_ops.Bundle, chains cldf_chain. qualifier := fmt.Sprintf("%s-%s", poolAddress, tv.Output.Type) if token, err := cldf_ops.ExecuteOperation(b, token_pool.GetToken, chain, - evm_contract.FunctionInput[any]{ + contract.FunctionInput[any]{ ChainSelector: chainSelector, Address: poolAddress, }, @@ -237,7 +205,7 @@ func (a *EVMTokenBase) ResolveTokenRef(b cldf_ops.Bundle, chains cldf_chain.Bloc symbolReport, err := cldf_ops.ExecuteOperation(b, erc20.GetSymbol, chain, - evm_contract.FunctionInput[struct{}]{ + contract.FunctionInput[struct{}]{ ChainSelector: chainSelector, Address: tokenAddress, }, @@ -257,3 +225,199 @@ func (a *EVMTokenBase) ResolveTokenRef(b cldf_ops.Bundle, chains cldf_chain.Bloc Address: tokenAddress.Hex(), }, nil } + +// Pool-specific stubs -- these are overridden by per-version adapters (v1.5.1, v1.6.1, v2.0.0). +// EVMTokenBase is registered at v1.0.0 so callers that only need token deployment (DeployToken, +// DeployTokenVerify) can obtain a valid adapter without importing a pool-version package. + +func (a *EVMTokenBase) MigrateLockReleasePoolLiquiditySequence() *cldf_ops.Sequence[tokensapi.MigrateLockReleasePoolLiquidityInput, sequences.OnChainOutput, cldf_chain.BlockChains] { + return nil +} + +func (a *EVMTokenBase) ConfigureTokenForTransfersSequence() *cldf_ops.Sequence[tokensapi.ConfigureTokenForTransfersInput, sequences.OnChainOutput, cldf_chain.BlockChains] { + return nil +} + +func (a *EVMTokenBase) DeriveTokenPoolCounterpart(_ deployment.Environment, _ uint64, tokenPool []byte, _ []byte) ([]byte, error) { + return tokenPool, nil +} + +func (a *EVMTokenBase) DeriveTokenAddress(_ deployment.Environment, _ uint64, _ datastore.AddressRef) (string, error) { + return "", fmt.Errorf("DeriveTokenAddress is not implemented on EVMTokenBase; use a pool-version adapter") +} + +func (a *EVMTokenBase) DeriveTokenDecimals(_ deployment.Environment, _ uint64, _ datastore.AddressRef, _ []byte) (uint8, error) { + return 0, fmt.Errorf("DeriveTokenDecimals is not implemented on EVMTokenBase; use a pool-version adapter") +} + +func (a *EVMTokenBase) SetTokenPoolRateLimits() *cldf_ops.Sequence[tokensapi.TPRLRemotes, sequences.OnChainOutput, cldf_chain.BlockChains] { + return nil +} + +func (a *EVMTokenBase) ManualRegistration() *cldf_ops.Sequence[tokensapi.ManualRegistrationSequenceInput, sequences.OnChainOutput, cldf_chain.BlockChains] { + return nil +} + +func (a *EVMTokenBase) DeployTokenPoolForToken() *cldf_ops.Sequence[tokensapi.DeployTokenPoolInput, sequences.OnChainOutput, cldf_chain.BlockChains] { + return nil +} + +// ================================================================ +// === Version-agnostic helpers for all EVM token/pool versions === +// ================================================================ + +// IsBurnMintPoolType returns true if the pool type is one of the burn-mint variants (standard or with from-mint). +func (a *EVMTokenBase) IsBurnMintPoolType(poolType string) bool { + return poolType == cciputils.BurnMintTokenPool.String() || + poolType == cciputils.BurnFromMintTokenPool.String() || + poolType == cciputils.BurnWithFromMintTokenPool.String() +} + +// IsLockReleasePoolType returns true if the pool type is one of the lock-release variants (standard or siloed). +func (a *EVMTokenBase) IsLockReleasePoolType(poolType string) bool { + return poolType == cciputils.LockReleaseTokenPool.String() || + poolType == cciputils.SiloedLockReleaseTokenPool.String() +} + +// IsBurnMintTokenType returns true if the token type is one of the burn-mint variants (ERC20 or ERC677). +func (a *EVMTokenBase) IsBurnMintTokenType(tokenType string) bool { + return tokenType == bnmERC20Ops.ContractType.String() || + tokenType == bnmDripOpsV1_0_0.ContractType.String() || + tokenType == bnmDripOpsV1_5_0.ContractType.String() || + a.IsBurnMintERC677TokenType(tokenType) +} + +// IsBurnMintERC677TokenType returns true if the token type is one of the burn-mint ERC677 variants. +func (a *EVMTokenBase) IsBurnMintERC677TokenType(tokenType string) bool { + return tokenType == cciputils.BurnMintToken.String() || + tokenType == cciputils.ERC677TokenHelper.String() +} + +// resolveRouterAddress returns the router address to wire into the pool. +// If routerRef is nil, the chain's production Router is looked up in the datastore. +// If routerRef.Address is non-empty, it is used directly (no datastore lookup). +// Otherwise the ref is resolved against the datastore; ChainSelector is forced to +// the target chain and Type defaults to the production Router when unset, so callers +// targeting the TestRouter only need to set Type=router.TestRouterContractType. +func (a *EVMTokenBase) ResolveRouterAddress(ds datastore.DataStore, chainSelector uint64, routerRef *datastore.AddressRef) (common.Address, error) { + filter := datastore.AddressRef{ + ChainSelector: chainSelector, + Type: datastore.ContractType(router.ContractType), + } + if routerRef != nil { + filter = routerRef.Clone() + filter.ChainSelector = chainSelector + if filter.Type == "" { + filter.Type = datastore.ContractType(router.ContractType) + } + } + + addr, err := a.ParseNonZeroAddressRef(ds, filter, chainSelector) + if err != nil { + return common.Address{}, fmt.Errorf("failed to resolve router address for chain %d: %w", chainSelector, err) + } + + return addr, nil +} + +// GetRMNProxyAddress looks up the RMNProxy address from the datastore using the provided selector and the RMNProxy contract type. +func (a *EVMTokenBase) GetRMNProxyAddress(ds datastore.DataStore, selector uint64) (common.Address, error) { + filter := datastore.AddressRef{ + ChainSelector: selector, + Type: datastore.ContractType(rmnproxyops.ContractType), + } + + addr, err := a.ParseNonZeroAddressRef(ds, filter, selector) + if err != nil { + return common.Address{}, fmt.Errorf("failed to find RMNProxy address on chain %d: %w", selector, err) + } + + return addr, nil +} + +// GetTokenAdminRegistryAddress looks up the TAR (v1.5.0) address from the datastore. +func (a *EVMTokenBase) GetTokenAdminRegistryAddress(ds datastore.DataStore, selector uint64) (common.Address, error) { + filter := datastore.AddressRef{ + ChainSelector: selector, + Type: datastore.ContractType(tarops.ContractType), + Version: tarops.Version, + } + + addr, err := a.ParseNonZeroAddressRef(ds, filter, selector) + if err != nil { + return common.Address{}, fmt.Errorf("failed to find token admin registry address on chain %d: %w", selector, err) + } + + return addr, nil +} + +// GetTimelockAddressCLL looks up the timelock (RBACTimelock) address from the datastore using the CLL qualifier. +func (a *EVMTokenBase) GetTimelockAddressCLL(ds datastore.DataStore, selector uint64) (common.Address, error) { + filter := datastore.AddressRef{ + ChainSelector: selector, + Type: datastore.ContractType(cciputils.RBACTimelock), + Version: cciputils.Version_1_0_0, + Qualifier: cciputils.CLLQualifier, + } + + addr, err := a.ParseNonZeroAddressRef(ds, filter, selector) + if err != nil { + return common.Address{}, fmt.Errorf("failed to find timelock address on chain %d: %w", selector, err) + } + + return addr, nil +} + +// ParseNonZeroAddressRef attempts to parse an address from the given ref. If ref.Address is non-empty, then the datastore +// lookup is skipped and the provided address is parsed as an EVM address and returned directly. Otherwise, the ref +// is resolved against the datastore and parsed as a hex address. +func (a *EVMTokenBase) ParseNonZeroAddressRef(ds datastore.DataStore, ref datastore.AddressRef, sel uint64) (common.Address, error) { + if ref.Address != "" { + refAddr := ref.Address + if !common.IsHexAddress(refAddr) { + return common.Address{}, fmt.Errorf("invalid address %q: not a hex address", refAddr) + } + evmAddr := common.HexToAddress(refAddr) + if evmAddr == (common.Address{}) { + return common.Address{}, fmt.Errorf("invalid address %q: zero address is not allowed", refAddr) + } + return evmAddr, nil + } + + evmAddr, err := datastore_utils.FindAndFormatRef(ds, ref, sel, datastore_utils_evm.ToNonZeroEVMAddress) + if err != nil { + return common.Address{}, fmt.Errorf("failed to resolve address from datastore using ref filter (%s): %w", datastore_utils.SprintRef(ref), err) + } + + return evmAddr, nil +} + +// ParseAddressStrings parses a list of hex address strings into a slice of common.Address, validating each address in the process. +func (a *EVMTokenBase) ParseAddressStrings(allowed []string) ([]common.Address, error) { + addresses := make([]common.Address, 0, len(allowed)) + for _, addrStr := range allowed { + if !common.IsHexAddress(addrStr) { + return nil, fmt.Errorf("invalid address %q in allow list: not a hex address", addrStr) + } else { + addresses = append(addresses, common.HexToAddress(addrStr)) + } + } + return addresses, nil +} + +// ERC20Decimals returns the decimals of an ERC20 token by calling the getDecimals operation. +func (a *EVMTokenBase) ERC20Decimals(b cldf_ops.Bundle, ds datastore.DataStore, chain evm.Chain, tokenAddress common.Address) (uint8, error) { + decimals, err := cldf_ops.ExecuteOperation( + b, erc20.GetDecimals, chain, + contract.FunctionInput[struct{}]{ + ChainSelector: chain.Selector, + Address: tokenAddress, + Args: struct{}{}, + }, + ) + if err != nil { + return 0, fmt.Errorf("failed to read ERC20 decimals for token at address %s: %w", tokenAddress, err) + } + + return decimals.Output, nil +} diff --git a/chains/evm/deployment/v2_0_0/adapters/tokens_internal_test.go b/chains/evm/deployment/v1_0_0/adapters/token_adapter_internal_test.go similarity index 82% rename from chains/evm/deployment/v2_0_0/adapters/tokens_internal_test.go rename to chains/evm/deployment/v1_0_0/adapters/token_adapter_internal_test.go index 5c42d0ff05..3852d6382f 100644 --- a/chains/evm/deployment/v2_0_0/adapters/tokens_internal_test.go +++ b/chains/evm/deployment/v1_0_0/adapters/token_adapter_internal_test.go @@ -16,6 +16,8 @@ func TestResolveRouterAddress(t *testing.T) { testRouter := common.HexToAddress("0x2222222222222222222222222222222222222222") override := common.HexToAddress("0x3333333333333333333333333333333333333333") + evmTokenBase := EVMTokenBase{} + newStore := func(t *testing.T) datastore.DataStore { t.Helper() ds := datastore.NewMemoryDataStore() @@ -35,13 +37,13 @@ func TestResolveRouterAddress(t *testing.T) { } t.Run("nil ref defaults to production router", func(t *testing.T) { - got, err := resolveRouterAddress(newStore(t), chainSelector, nil) + got, err := evmTokenBase.ResolveRouterAddress(newStore(t), chainSelector, nil) require.NoError(t, err) require.Equal(t, prodRouter, got) }) t.Run("ref with TestRouter type resolves to test router", func(t *testing.T) { - got, err := resolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ + got, err := evmTokenBase.ResolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ Type: datastore.ContractType(router.TestRouterContractType), }) require.NoError(t, err) @@ -51,7 +53,7 @@ func TestResolveRouterAddress(t *testing.T) { t.Run("ref with explicit Address bypasses datastore", func(t *testing.T) { // Empty datastore would normally cause a lookup failure; an explicit // Address must skip the lookup entirely. - got, err := resolveRouterAddress(datastore.NewMemoryDataStore().Seal(), chainSelector, &datastore.AddressRef{ + got, err := evmTokenBase.ResolveRouterAddress(datastore.NewMemoryDataStore().Seal(), chainSelector, &datastore.AddressRef{ Address: override.Hex(), }) require.NoError(t, err) @@ -61,7 +63,7 @@ func TestResolveRouterAddress(t *testing.T) { t.Run("ref with non-hex Address errors", func(t *testing.T) { // "0x123" is not 20 bytes; common.HexToAddress would silently pad it, // so resolveRouterAddress must reject it up-front. - _, err := resolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ + _, err := evmTokenBase.ResolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ Address: "0x123", }) require.Error(t, err) @@ -69,7 +71,7 @@ func TestResolveRouterAddress(t *testing.T) { }) t.Run("ref with zero Address errors", func(t *testing.T) { - _, err := resolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ + _, err := evmTokenBase.ResolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ Address: "0x0000000000000000000000000000000000000000", }) require.Error(t, err) @@ -79,7 +81,7 @@ func TestResolveRouterAddress(t *testing.T) { t.Run("ref forces chain selector to target chain", func(t *testing.T) { // User passes a ref with the wrong chain selector — the helper must // rewrite it to the target chain so the lookup succeeds. - got, err := resolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ + got, err := evmTokenBase.ResolveRouterAddress(newStore(t), chainSelector, &datastore.AddressRef{ ChainSelector: chainSelector + 1, Type: datastore.ContractType(router.TestRouterContractType), }) @@ -89,7 +91,7 @@ func TestResolveRouterAddress(t *testing.T) { t.Run("missing router type in datastore returns error", func(t *testing.T) { emptyDS := datastore.NewMemoryDataStore().Seal() - _, err := resolveRouterAddress(emptyDS, chainSelector, nil) + _, err := evmTokenBase.ResolveRouterAddress(emptyDS, chainSelector, nil) require.Error(t, err) }) @@ -110,7 +112,7 @@ func TestResolveRouterAddress(t *testing.T) { Version: router.Version, Address: prodRouter.Hex(), })) - got, err := resolveRouterAddress(ds.Seal(), chainSelector, &datastore.AddressRef{ + got, err := evmTokenBase.ResolveRouterAddress(ds.Seal(), chainSelector, &datastore.AddressRef{ Qualifier: "canary", }) require.NoError(t, err) diff --git a/chains/evm/deployment/v1_0_0/sequences/revoke_token_admin_role.go b/chains/evm/deployment/v1_0_0/sequences/revoke_token_admin_role.go new file mode 100644 index 0000000000..33d0d25676 --- /dev/null +++ b/chains/evm/deployment/v1_0_0/sequences/revoke_token_admin_role.go @@ -0,0 +1,165 @@ +package sequences + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/tokens/tokenimpl" + datastore_utils_evm "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + cciputils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + evm_contract "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" + cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" +) + +var RevokeTokenAdminRole = cldf_ops.NewSequence( + "evm-revoke-token-admin-role", + cciputils.Version_1_0_0, + "Revoke an admin role from an EVM token", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input tokensapi.RevokeTokenAdminRoleSequenceInput) (sequences.OnChainOutput, error) { + // Validate the chain + chain, ok := chains.EVMChains()[input.ChainSelector] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not found among provided EVM chains", input.ChainSelector) + } + if !datastore_utils.IsAddressRefFullyPopulated(input.TokenRef) { + return sequences.OnChainOutput{}, fmt.Errorf("token ref is incomplete: %v", input.TokenRef) + } + + // Validate the token address + tokenAddress, err := datastore_utils_evm.ToEVMAddress(input.TokenRef) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token ref to EVM address: %w", err) + } + if tokenAddress == (common.Address{}) { + return sequences.OnChainOutput{}, errors.New("token address cannot be the zero address") + } + + // Validate timelock address (if it exists) + timelockAddress := common.Address{} + if input.TimelockAddress != "" { + if !common.IsHexAddress(input.TimelockAddress) { + return sequences.OnChainOutput{}, fmt.Errorf("timelock address %q is not a valid hex address", input.TimelockAddress) + } else { + timelockAddress = common.HexToAddress(input.TimelockAddress) + } + } + + // Validate the fallback address (if provided) + fallbackAddress := common.Address{} + if input.FallbackAddress != "" { + if !common.IsHexAddress(input.FallbackAddress) { + return sequences.OnChainOutput{}, fmt.Errorf("fallback address %q is not a valid hex address", input.FallbackAddress) + } else { + fallbackAddress = common.HexToAddress(input.FallbackAddress) + } + } + + // Validate that the token type supports admin role management + tokenImpl, ok := tokenimpl.Get(cldf_deployment.ContractType(input.TokenRef.Type)) + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("unsupported token type %q for token address %q on chain %d", input.TokenRef.Type, input.TokenRef.Address, input.TokenRef.ChainSelector) + } + if !tokenImpl.Capabilities().SupportsAdminRole { + return sequences.OnChainOutput{}, fmt.Errorf("token %s on chain %d with type %s does not support admin role management", tokenAddress.Hex(), input.ChainSelector, input.TokenRef.Type) + } + + // This operation will be run by either timelock or the deployer key, so we need to ensure that + // the account running the operation has sufficient access to perform the operation. If this is + // not the case, then we return no operations and log a warning instead of returning an error. + timelockHasAdminRole := false + if timelockAddress != (common.Address{}) { + if hasRole, err := tokenImpl.HasAdminRole(b.GetContext(), chain, tokenAddress, timelockAddress); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to check admin role for timelock %s on token %s: %w", timelockAddress.Hex(), tokenAddress.Hex(), err) + } else { + timelockHasAdminRole = hasRole + } + } + deployerHasAdminRole := false + if chain.DeployerKey.From != (common.Address{}) { + if hasRole, err := tokenImpl.HasAdminRole(b.GetContext(), chain, tokenAddress, chain.DeployerKey.From); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to check admin role for deployer %s on token %s: %w", chain.DeployerKey.From.Hex(), tokenAddress.Hex(), err) + } else { + deployerHasAdminRole = hasRole + } + } + if !timelockHasAdminRole && !deployerHasAdminRole { + b.Logger.Warnf("neither timelock %s nor deployer %s has an admin role on token %s on chain %d; skipping revoke since there is no account with sufficient permissions to perform the operation", timelockAddress.Hex(), chain.DeployerKey.From.Hex(), tokenAddress.Hex(), input.ChainSelector) + return sequences.OnChainOutput{}, nil + } + + // If the user does not provide an AdminAddress, then the top-level changeset will attempt + // to set it to timelock. If timelock isn't found in the datastore then we'll fall back to + // the deployer key in this sequence. + revokeAddress := chain.DeployerKey.From + if input.AdminAddress != "" { + if !common.IsHexAddress(input.AdminAddress) { + return sequences.OnChainOutput{}, fmt.Errorf("admin address %q is not a valid hex address", input.AdminAddress) + } else { + revokeAddress = common.HexToAddress(input.AdminAddress) + } + } + if revokeAddress == (common.Address{}) { + return sequences.OnChainOutput{}, errors.New("admin address cannot be the zero address") + } + + // If the fallback address is unspecified OR the fallback and revoke addresses are the + // same, then we skip the grant operation and assume the user wants to bypass all role + // protections. If the fallback and revoke addresses differ, then the token admin role + // is granted to the fallback address (if applicable) and we proceed with revoking the + // admin role from the revoke address. The fallback address is an extra safety measure + // that ensures there's at least one account with the admin role on the token contract + var writes []evm_contract.WriteOutput + if fallbackAddress != (common.Address{}) && fallbackAddress != revokeAddress { + fallbackAccountHasAdminRole, err := tokenImpl.HasAdminRole(b.GetContext(), chain, tokenAddress, fallbackAddress) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to check admin role for fallback address %s on token %s: %w", fallbackAddress.Hex(), tokenAddress.Hex(), err) + } + if !fallbackAccountHasAdminRole { + if output, err := tokenImpl.GrantAdminRole(b, chain, tokenAddress, fallbackAddress); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to grant admin role to fallback address %s on token %s: %w", fallbackAddress.Hex(), tokenAddress.Hex(), err) + } else { + writes = append(writes, output...) + } + } else { + b.Logger.Infof("fallback address %s already has an admin role on token %s on chain %d; skipping grant", fallbackAddress.Hex(), tokenAddress.Hex(), input.ChainSelector) + } + } else { + b.Logger.Warnf("no fallback address provided or fallback address is the same as revoke address for token %s on chain %d; skipping grant operation and proceeding directly to revoke (this can be dangerous if the revoke address is the only account with admin role on the token)", tokenAddress.Hex(), input.ChainSelector) + } + + // If the account that we want to revoke admin access from does NOT have the admin role + // then we skip the revocation to save gas and avoid unnecessary transactions. + revokeAccountHasAdminRole, err := tokenImpl.HasAdminRole(b.GetContext(), chain, tokenAddress, revokeAddress) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to check admin role for %s on token %s: %w", revokeAddress.Hex(), tokenAddress.Hex(), err) + } + if revokeAccountHasAdminRole { + if output, err := tokenImpl.RevokeAdminRole(b, chain, tokenAddress, revokeAddress); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to revoke admin role from %s on token %s: %w", revokeAddress.Hex(), tokenAddress.Hex(), err) + } else { + writes = append(writes, output...) + } + } else { + b.Logger.Infof("admin %s does not have an admin role on token %s on chain %d; skipping revoke", revokeAddress.Hex(), tokenAddress.Hex(), input.ChainSelector) + } + + if len(writes) == 0 { + b.Logger.Infof("no writes generated for revoking admin role from %s on token %s on chain %d; skipping operation", revokeAddress.Hex(), tokenAddress.Hex(), input.ChainSelector) + return sequences.OnChainOutput{}, nil + } + + batchOp, err := evm_contract.NewBatchOperationFromWrites(writes) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation for token admin role revocation: %w", err) + } + + return sequences.OnChainOutput{BatchOps: []mcms_types.BatchOperation{batchOp}}, nil + }, +) diff --git a/chains/evm/deployment/v1_5_0/adapters/fees.go b/chains/evm/deployment/v1_5_0/adapters/fees.go index db4ff38e93..a8f925ff5c 100644 --- a/chains/evm/deployment/v1_5_0/adapters/fees.go +++ b/chains/evm/deployment/v1_5_0/adapters/fees.go @@ -123,7 +123,7 @@ func (a *FeesAdapter) SetTokenTransferFee(e cldf.Environment, feeRef datastore.A token = common.HexToAddress(rawTokenAddress) } - if feeCfg == nil { + if feeCfg == nil || !feeCfg.IsEnabled { val.TokensToUseDefaultFeeConfigs = append(val.TokensToUseDefaultFeeConfigs, token) } else { val.TokenTransferFeeConfigArgs = append( diff --git a/chains/evm/deployment/v1_5_1/adapters/tokens.go b/chains/evm/deployment/v1_5_1/adapters/tokens.go index b272474344..d6f309a2d3 100644 --- a/chains/evm/deployment/v1_5_1/adapters/tokens.go +++ b/chains/evm/deployment/v1_5_1/adapters/tokens.go @@ -12,7 +12,7 @@ import ( evm1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" tarseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/sequences" tpOps "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/operations/token_pool" - v1_5_1_seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/sequences" + seqV1_5_1 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/sequences" tpSeq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/sequences/token_pool" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_1/token_pool" tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" @@ -40,7 +40,7 @@ func NewTokenAdapter() *TokenAdapter { return &TokenAdapter{ EVMPoolAdapter: evm1_0_0.EVMPoolAdapter{ Ops: &poolOpsV151{}, - DeployTokenPoolSeq: v1_5_1_seq.DeployTokenPool, + DeployTokenPoolSeq: seqV1_5_1.DeployTokenPool, }, } } @@ -73,7 +73,7 @@ func (t *TokenAdapter) ConfigureTokenForTransfersSequence() *cldf_ops.Sequence[t externalAdmin = common.HexToAddress(input.ExternalAdmin) } - tarAddress, err := evm1_0_0.GetTokenAdminRegistryAddress(input.ExistingDataStore, input.ChainSelector, &t.EVMTokenBase) + tarAddress, err := t.EVMTokenBase.GetTokenAdminRegistryAddress(input.ExistingDataStore, input.ChainSelector) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to get token admin registry address for chain %d: %w", input.ChainSelector, err) } @@ -134,12 +134,18 @@ func (p *poolOpsV151) GetToken(b cldf_ops.Bundle, chain evm.Chain, poolAddr comm return res.Output, nil } -func (p *poolOpsV151) GetTokenDecimals(ctx context.Context, chain evm.Chain, poolAddr common.Address) (uint8, error) { - pool, err := token_pool.NewTokenPool(poolAddr, chain.Client) +func (p *poolOpsV151) GetTokenDecimals(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address) (uint8, error) { + res, err := cldf_ops.ExecuteOperation(b, + tpOps.GetTokenDecimals, chain, + evm_contract.FunctionInput[struct{}]{ + ChainSelector: chain.Selector, + Address: poolAddr, + }, + ) if err != nil { - return 0, fmt.Errorf("failed to instantiate token pool v1.5.1 contract: %w", err) + return 0, fmt.Errorf("GetTokenDecimals v1.5.1: %w", err) } - return pool.GetTokenDecimals(&bind.CallOpts{Context: ctx}) + return res.Output, nil } func (p *poolOpsV151) GetPoolAdmins(ctx context.Context, chain *evm.Chain, poolAddr common.Address) (owner, rlAdmin common.Address, err error) { @@ -200,7 +206,7 @@ func (p *poolOpsV151) SetRateLimiterConfig(b cldf_ops.Bundle, chain evm.Chain, p return []evm_contract.WriteOutput{report.Output}, nil } -func (p *poolOpsV151) SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) (evm_contract.WriteOutput, error) { +func (p *poolOpsV151) SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) ([]evm_contract.WriteOutput, error) { report, err := cldf_ops.ExecuteOperation(b, tpOps.SetRateLimitAdmin, chain, evm_contract.FunctionInput[tpOps.SetRateLimitAdminArgs]{ @@ -211,12 +217,16 @@ func (p *poolOpsV151) SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, pool }, }) if err != nil { - return evm_contract.WriteOutput{}, fmt.Errorf("SetRateLimitAdmin v1.5.1: %w", err) + return nil, fmt.Errorf("SetRateLimitAdmin v1.5.1: %w", err) } - return report.Output, nil + return []evm_contract.WriteOutput{report.Output}, nil } -func (p *poolOpsV151) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64) (tokensapi.RateLimiterConfig, error) { +func (p *poolOpsV151) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64, ff bool) (tokensapi.RateLimiterConfig, error) { + if ff { + return tokensapi.RateLimiterConfig{}, fmt.Errorf("fast finality buckets are not supported on v1.5.x token pools") + } + tp, err := token_pool.NewTokenPool(poolAddr, chain.Client) if err != nil { return tokensapi.RateLimiterConfig{}, fmt.Errorf("failed to instantiate token pool v1.5.1 contract: %w", err) @@ -225,6 +235,7 @@ func (p *poolOpsV151) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Ch if err != nil { return tokensapi.RateLimiterConfig{}, fmt.Errorf("failed to get inbound rate limiter state for remote chain %d: %w", remoteSelector, err) } + return tokensapi.RateLimiterConfig{ IsEnabled: bucket.IsEnabled, Capacity: bucket.Capacity, diff --git a/chains/evm/deployment/v1_5_1/operations/token_pool/token_pool.go b/chains/evm/deployment/v1_5_1/operations/token_pool/token_pool.go index c9a8c1b6e0..6a89058270 100644 --- a/chains/evm/deployment/v1_5_1/operations/token_pool/token_pool.go +++ b/chains/evm/deployment/v1_5_1/operations/token_pool/token_pool.go @@ -10,7 +10,7 @@ import ( "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_1/token_pool" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" ) @@ -39,6 +39,17 @@ type SetRateLimitAdminArgs struct { NewAdmin common.Address } +var GetTokenDecimals = contract.NewRead(contract.ReadParams[struct{}, uint8, *token_pool.TokenPool]{ + Name: "token-pool:get-token-decimals", + Version: Version, + Description: "Gets the decimals of the token managed by the TokenPool 1.5.1 contract", + ContractType: ContractType, + NewContract: token_pool.NewTokenPool, + CallContract: func(tp *token_pool.TokenPool, opts *bind.CallOpts, args struct{}) (uint8, error) { + return tp.GetTokenDecimals(opts) + }, +}) + var GetToken = contract.NewRead(contract.ReadParams[struct{}, common.Address, *token_pool.TokenPool]{ Name: "token-pool:get-token", Version: Version, diff --git a/chains/evm/deployment/v1_5_1/sequences/deploy_token_pool.go b/chains/evm/deployment/v1_5_1/sequences/deploy_token_pool.go index 2402e6a8a5..aaaac3486d 100644 --- a/chains/evm/deployment/v1_5_1/sequences/deploy_token_pool.go +++ b/chains/evm/deployment/v1_5_1/sequences/deploy_token_pool.go @@ -1,118 +1,122 @@ package sequences import ( + "errors" "fmt" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" mcms_types "github.com/smartcontractkit/mcms/types" cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" - "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc20" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" - rmnproxyops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/rmn_proxy" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" + adaptersV1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" v1_5_1_burn_from_mint_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/operations/burn_from_mint_token_pool" v1_5_1_burn_mint_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/operations/burn_mint_token_pool" v1_5_1_burn_to_address_mint_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/operations/burn_to_address_mint_token_pool" v1_5_1_burn_with_from_mint_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/operations/burn_with_from_mint_token_pool" v1_5_1_lock_release_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/operations/lock_release_token_pool" tokenapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" - common_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" ) var DeployTokenPool = cldf_ops.NewSequence( - "deploy-token-pool-v1.5.1", - common_utils.Version_1_5_1, + "deploy-token-pool", + utils.Version_1_5_1, "Deploy v1.5.1 token pool contracts", func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input tokenapi.DeployTokenPoolInput) (sequences.OnChainOutput, error) { - if input.TokenPoolVersion == nil { - return sequences.OnChainOutput{}, fmt.Errorf("TokenPoolVersion is required") + chain, ok := chains.EVMChains()[input.ChainSelector] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not found in environment", input.ChainSelector) } - chain := chains.EVMChains()[input.ChainSelector] - qualifier := input.TokenPoolQualifier + // Validate required deployment inputs + poolutil := adaptersV1_0_0.EVMTokenBase{} + if input.TokenPoolVersion == nil { + return sequences.OnChainOutput{}, errors.New("TokenPoolVersion is required") + } + if input.TokenRef == nil { + return sequences.OnChainOutput{}, errors.New("TokenRef is required") + } - tokenAddr, err := resolveTokenAddress(input) + // Parse the token ref as an EVM address + tokenAddress, err := poolutil.ParseNonZeroAddressRef(input.ExistingDataStore, input.TokenRef.Clone(), chain.Selector) if err != nil { - return sequences.OnChainOutput{}, err + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve token address from ref: %w", err) } - if qualifier == "" { - qualifier = tokenAddr + + // If no pool qualifier is provided, then fall back to using the token address + poolQualifier := input.TokenPoolQualifier + if poolQualifier == "" { + poolQualifier = tokenAddress.Hex() } + // NOTE: the datastore uses the type, selector, qualifier, and version of an address + // ref to uniquely identify records, so the query below should only match one record + // at most. If multiple records are returned, then this would indicate an issue with + // the datastore's data integrity. If no matches are returned, then the ref does not + // exist and we proceed with the deployment. matches := input.ExistingDataStore.Addresses().Filter( datastore.AddressRefByType(datastore.ContractType(input.PoolType)), - datastore.AddressRefByChainSelector(input.ChainSelector), - datastore.AddressRefByQualifier(qualifier), + datastore.AddressRefByChainSelector(chain.Selector), + datastore.AddressRefByQualifier(poolQualifier), datastore.AddressRefByVersion(input.TokenPoolVersion), ) if len(matches) > 1 { return sequences.OnChainOutput{}, fmt.Errorf( "multiple token pools found in datastore with type '%s', version '%s', qualifier '%s' on chain with selector %d", - input.PoolType, input.TokenPoolVersion.String(), qualifier, input.ChainSelector, + input.PoolType, input.TokenPoolVersion.String(), poolQualifier, chain.Selector, ) } if len(matches) == 1 { - b.Logger.Info("Token pool already deployed at address:", matches[0].Address) - return sequences.OnChainOutput{}, nil + b.Logger.Infof("Token pool already deployed: %s", datastore_utils.SprintRef(matches[0])) + return sequences.OnChainOutput{Addresses: matches}, nil } - token, err := burn_mint_erc20.NewBurnMintERC20(common.HexToAddress(tokenAddr), chain.Client) + // Infer pool deployment inputs + tokenDecimals, err := poolutil.ERC20Decimals(b, input.ExistingDataStore, chain, tokenAddress) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to instantiate token contract at address '%s': %w", tokenAddr, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to get token decimals for token at address '%s': %w", tokenAddress, err) } - tokenDecimal, err := token.Decimals(&bind.CallOpts{Context: b.GetContext()}) + rmnProxyAddr, err := poolutil.GetRMNProxyAddress(input.ExistingDataStore, chain.Selector) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get token decimals for token at address '%s': %w", tokenAddr, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve rmn proxy address for chain selector %d: %w", chain.Selector, err) } - - routerAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Type: datastore.ContractType(router.ContractType), - }, input.ChainSelector, datastore_utils.FullRef) + routerAddr, err := poolutil.ResolveRouterAddress(input.ExistingDataStore, chain.Selector, input.RouterRef) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to find router address in datastore for chain with selector %d: %w", input.ChainSelector, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve router address for chain selector %d: %w", chain.Selector, err) } - - rmpProxyAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Type: datastore.ContractType(rmnproxyops.ContractType), - }, input.ChainSelector, datastore_utils.FullRef) + allowlist, err := poolutil.ParseAddressStrings(input.Allowlist) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to find rmnproxy address in datastore for chain with selector %d: %w", input.ChainSelector, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to parse allowlist: %w", err) } - var allowlist []common.Address - if len(input.Allowlist) > 0 { - allowlist = make([]common.Address, 0, len(input.Allowlist)) - for _, addr := range input.Allowlist { - allowlist = append(allowlist, common.HexToAddress(addr)) - } - } + // Build type and version struct + typeAndVersion := deployment.NewTypeAndVersion( + deployment.ContractType(input.PoolType), + *input.TokenPoolVersion, + ) + // Deploy the desired pool contract var poolRef datastore.AddressRef - typeAndVersion := deployment.NewTypeAndVersion(deployment.ContractType(input.PoolType), *input.TokenPoolVersion).String() - - switch typeAndVersion { + switch typeAndVersion.String() { case v1_5_1_burn_mint_token_pool.TypeAndVersion.String(): poolRef, err = contract.MaybeDeployContract(b, v1_5_1_burn_mint_token_pool.Deploy, chain, contract.DeployInput[v1_5_1_burn_mint_token_pool.ConstructorArgs]{ TypeAndVersion: v1_5_1_burn_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_5_1_burn_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + Token: tokenAddress, + LocalTokenDecimals: tokenDecimals, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnMintTokenPool v1.5.1: %w", err) @@ -123,13 +127,13 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_5_1_burn_from_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_5_1_burn_from_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + Token: tokenAddress, + LocalTokenDecimals: tokenDecimals, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnFromMintTokenPool v1.5.1: %w", err) @@ -140,14 +144,14 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_5_1_burn_to_address_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_5_1_burn_to_address_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + Token: tokenAddress, + LocalTokenDecimals: tokenDecimals, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, BurnAddress: common.HexToAddress(input.BurnAddress), }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnToAddressMintTokenPool v1.5.1: %w", err) @@ -158,13 +162,13 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_5_1_burn_with_from_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_5_1_burn_with_from_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + Token: tokenAddress, + LocalTokenDecimals: tokenDecimals, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnWithFromMintTokenPool v1.5.1: %w", err) @@ -175,14 +179,14 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_5_1_lock_release_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_5_1_lock_release_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + Token: tokenAddress, + LocalTokenDecimals: tokenDecimals, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), + RmnProxy: rmnProxyAddr, AcceptLiquidity: *input.AcceptLiquidity, - Router: common.HexToAddress(routerAddr.Address), + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy LockReleaseTokenPool v1.5.1: %w", err) @@ -192,37 +196,9 @@ var DeployTokenPool = cldf_ops.NewSequence( return sequences.OnChainOutput{}, fmt.Errorf("unsupported v1.5.1 token pool type and version: %s", typeAndVersion) } - batchOp, err := contract.NewBatchOperationFromWrites(nil) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation from writes: %w", err) - } - return sequences.OnChainOutput{ Addresses: []datastore.AddressRef{poolRef}, - BatchOps: []mcms_types.BatchOperation{batchOp}, + BatchOps: []mcms_types.BatchOperation{}, }, nil }, ) - -func resolveTokenAddress(input tokenapi.DeployTokenPoolInput) (string, error) { - var tokenAddr string - if input.TokenRef != nil && input.TokenRef.Address != "" { - tokenAddr = input.TokenRef.Address - } - if input.TokenRef != nil && input.TokenRef.Qualifier != "" { - storedAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, *input.TokenRef, input.ChainSelector, datastore_utils.FullRef) - if err != nil { - return "", fmt.Errorf("token with symbol '%s' is not found in datastore, %v", input.TokenRef.Qualifier, err) - } - if tokenAddr != "" && storedAddr.Address != tokenAddr { - return "", fmt.Errorf("provided token address '%s' does not match address '%s' found in datastore for symbol '%s'", tokenAddr, storedAddr.Address, input.TokenRef.Qualifier) - } - if tokenAddr == "" { - tokenAddr = storedAddr.Address - } - } - if tokenAddr == "" { - return "", fmt.Errorf("token address must be provided either directly or via a datastore reference") - } - return tokenAddr, nil -} diff --git a/chains/evm/deployment/v1_6_0/adapters/fees.go b/chains/evm/deployment/v1_6_0/adapters/fees.go index 95861f73fa..fdd1cd8c0a 100644 --- a/chains/evm/deployment/v1_6_0/adapters/fees.go +++ b/chains/evm/deployment/v1_6_0/adapters/fees.go @@ -171,7 +171,7 @@ func (a *FeesAdapter) SetTokenTransferFee(e cldf.Environment, feeRef datastore.A token = common.HexToAddress(rawTokenAddress) } - if feeCfg == nil { + if feeCfg == nil || !feeCfg.IsEnabled { val.TokensToUseDefaultFeeConfigs = append( val.TokensToUseDefaultFeeConfigs, fqops.TokenTransferFeeConfigRemoveArgs{ diff --git a/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts.go b/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts.go index fa80292c95..50be65bca5 100644 --- a/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts.go +++ b/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts.go @@ -1,21 +1,20 @@ package sequences import ( + "errors" "fmt" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" mcms_types "github.com/smartcontractkit/mcms/types" cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" - "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc20" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" - rmnproxyops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/rmn_proxy" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" + datastore_utils_evm "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" + adaptersV1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" v1_6_0_burn_mint_with_external_minter_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/burn_mint_with_external_minter_token_pool" v1_6_0_hybrid_with_external_minter_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/hybrid_with_external_minter_token_pool" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/token_governor" @@ -27,51 +26,40 @@ import ( v1_6_1_lock_release_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/lock_release_token_pool" v1_6_1_siloed_lock_release_token_pool "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/siloed_lock_release_token_pool" tokenapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" - common_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" ) var DeployTokenPool = cldf_ops.NewSequence( "deploy-token-pool", - common_utils.Version_1_6_1, + utils.Version_1_6_1, "Deploy given type of token pool contracts", func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input tokenapi.DeployTokenPoolInput) (sequences.OnChainOutput, error) { - if input.TokenPoolVersion == nil { - return sequences.OnChainOutput{}, fmt.Errorf("TokenPoolVersion is required") + chain, ok := chains.EVMChains()[input.ChainSelector] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not found in environment", input.ChainSelector) } - addresses := make([]datastore.AddressRef, 0) - writes := make([]contract.WriteOutput, 0) - chain := chains.EVMChains()[input.ChainSelector] - qualifier := input.TokenPoolQualifier - - var tokenAddr string - if input.TokenRef != nil && input.TokenRef.Address != "" { - tokenAddr = input.TokenRef.Address + // Validate required deployment inputs + poolutil := adaptersV1_0_0.EVMTokenBase{} + if input.TokenPoolVersion == nil { + return sequences.OnChainOutput{}, errors.New("TokenPoolVersion is required") } - - // this should resolve to the same address as the above lookup if the provided address is correct, - // but will error if the provided address is incorrect or not provided at all - if input.TokenRef != nil && input.TokenRef.Qualifier != "" { - // find token address from the data store - storedAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, *input.TokenRef, input.ChainSelector, datastore_utils.FullRef) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("token with symbol '%s' is not found in datastore, %v", input.TokenRef.Qualifier, err) - } - if tokenAddr != "" && storedAddr.Address != tokenAddr { - return sequences.OnChainOutput{}, fmt.Errorf("provided token address '%s' does not match address '%s' found in datastore for symbol '%s'", tokenAddr, storedAddr.Address, input.TokenRef.Qualifier) - } - if tokenAddr == "" { - tokenAddr = storedAddr.Address - } + if input.TokenRef == nil { + return sequences.OnChainOutput{}, errors.New("TokenRef is required") } - if tokenAddr == "" { - return sequences.OnChainOutput{}, fmt.Errorf("token address must be provided either directly or via a datastore reference") + // Parse the token ref as an EVM address + tokenAddress, err := poolutil.ParseNonZeroAddressRef(input.ExistingDataStore, input.TokenRef.Clone(), chain.Selector) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve token address from ref: %w", err) } - if qualifier == "" { - qualifier = tokenAddr + + // If no pool qualifier is provided, then fall back to using the token address + poolQualifier := input.TokenPoolQualifier + if poolQualifier == "" { + poolQualifier = tokenAddress.Hex() } // NOTE: the datastore uses the type, selector, qualifier, and version of an address @@ -81,76 +69,60 @@ var DeployTokenPool = cldf_ops.NewSequence( // exist and we proceed with the deployment. matches := input.ExistingDataStore.Addresses().Filter( datastore.AddressRefByType(datastore.ContractType(input.PoolType)), - datastore.AddressRefByChainSelector(input.ChainSelector), - datastore.AddressRefByQualifier(qualifier), + datastore.AddressRefByChainSelector(chain.Selector), + datastore.AddressRefByQualifier(poolQualifier), datastore.AddressRefByVersion(input.TokenPoolVersion), ) if len(matches) > 1 { return sequences.OnChainOutput{}, fmt.Errorf( "multiple token pools found in datastore with type '%s', version '%s', qualifier '%s' on chain with selector %d", - input.PoolType, input.TokenPoolVersion.String(), qualifier, input.ChainSelector, + input.PoolType, input.TokenPoolVersion.String(), poolQualifier, chain.Selector, ) } if len(matches) == 1 { - b.Logger.Info("Token pool already deployed at address:", matches[0].Address) - return sequences.OnChainOutput{}, nil + b.Logger.Infof("Token pool already deployed: %s", datastore_utils.SprintRef(matches[0])) + return sequences.OnChainOutput{Addresses: matches}, nil } - // get token decimals - token, err := burn_mint_erc20.NewBurnMintERC20(common.HexToAddress(tokenAddr), chain.Client) + // Infer pool deployment inputs + tokenDecimals, err := poolutil.ERC20Decimals(b, input.ExistingDataStore, chain, tokenAddress) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to instantiate token contract at address '%s': %w", tokenAddr, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to get token decimals for token at address '%s': %w", tokenAddress, err) } - tokenDecimal, err := token.Decimals(&bind.CallOpts{Context: b.GetContext()}) + rmnProxyAddr, err := poolutil.GetRMNProxyAddress(input.ExistingDataStore, chain.Selector) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get token decimals for token at address '%s': %w", tokenAddr, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve rmn proxy address for chain selector %d: %w", chain.Selector, err) } - - // find the router address from the data store - routerAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Type: datastore.ContractType(router.ContractType), - }, input.ChainSelector, datastore_utils.FullRef) + routerAddr, err := poolutil.ResolveRouterAddress(input.ExistingDataStore, chain.Selector, input.RouterRef) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to find router address in datastore for chain with selector %d: %w", input.ChainSelector, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve router address for chain selector %d: %w", chain.Selector, err) } - - // find the rmnproxy address from the data store - rmpProxyAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Type: datastore.ContractType(rmnproxyops.ContractType), - }, input.ChainSelector, datastore_utils.FullRef) + allowlist, err := poolutil.ParseAddressStrings(input.Allowlist) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to find rmnproxy address in datastore for chain with selector %d: %w", input.ChainSelector, err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to parse allowlist: %w", err) } - // prepare allowlist - var allowlist []common.Address - if len(input.Allowlist) > 0 { - allowlist = make([]common.Address, 0, len(input.Allowlist)) - for _, addr := range input.Allowlist { - allowlist = append(allowlist, common.HexToAddress(addr)) - } - } + // Build type and version struct + typeAndVersion := deployment.NewTypeAndVersion( + deployment.ContractType(input.PoolType), + *input.TokenPoolVersion, + ) + // Deploy the desired pool contract var poolRef datastore.AddressRef - - typeAndVersion := deployment.NewTypeAndVersion(deployment.ContractType(input.PoolType), *input.TokenPoolVersion).String() - - switch typeAndVersion { - // v1.6.1 pools + switch typeAndVersion.String() { case v1_6_1_burn_mint_token_pool.TypeAndVersion.String(): poolRef, err = contract.MaybeDeployContract(b, v1_6_1_burn_mint_token_pool.Deploy, chain, contract.DeployInput[v1_6_1_burn_mint_token_pool.ConstructorArgs]{ TypeAndVersion: v1_6_1_burn_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_1_burn_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnMintTokenPool v1.6.1: %w", err) @@ -161,13 +133,13 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_1_burn_from_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_1_burn_from_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnFromMintTokenPool v1.6.1: %w", err) @@ -178,13 +150,13 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_1_burn_mint_with_lock_release_flag_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_1_burn_mint_with_lock_release_flag_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnMintWithLockReleaseFlagTokenPool v1.6.1: %w", err) @@ -195,14 +167,14 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_1_burn_to_address_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_1_burn_to_address_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, BurnAddress: common.HexToAddress(input.BurnAddress), }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnToAddressMintTokenPool v1.6.1: %w", err) @@ -213,13 +185,13 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_1_burn_with_from_mint_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_1_burn_with_from_mint_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnWithFromMintTokenPool v1.6.1: %w", err) @@ -230,13 +202,13 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_1_lock_release_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_1_lock_release_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy LockReleaseTokenPool v1.6.1: %w", err) @@ -247,19 +219,18 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_1_siloed_lock_release_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_1_siloed_lock_release_token_pool.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy SiloedLockReleaseTokenPool v1.6.1: %w", err) } - // 1.6.0 pools case v1_6_0_burn_mint_with_external_minter_token_pool.TypeAndVersion.String(): tokenGovernor, err := fetchTokenGovernor(input) if err != nil { @@ -269,14 +240,14 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_0_burn_mint_with_external_minter_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_0_burn_mint_with_external_minter_token_pool.ConstructorArgs{ - Minter: common.HexToAddress(tokenGovernor), - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + Minter: tokenGovernor, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnMintWithExternalMinterTokenPool v1.6.0: %w", err) @@ -291,14 +262,14 @@ var DeployTokenPool = cldf_ops.NewSequence( TypeAndVersion: v1_6_0_hybrid_with_external_minter_token_pool.TypeAndVersion, ChainSelector: chain.Selector, Args: v1_6_0_hybrid_with_external_minter_token_pool.ConstructorArgs{ - Minter: common.HexToAddress(tokenGovernor), - Token: common.HexToAddress(tokenAddr), - LocalTokenDecimals: tokenDecimal, + Minter: tokenGovernor, + LocalTokenDecimals: tokenDecimals, + Token: tokenAddress, Allowlist: allowlist, - RmnProxy: common.HexToAddress(rmpProxyAddr.Address), - Router: common.HexToAddress(routerAddr.Address), + RmnProxy: rmnProxyAddr, + Router: routerAddr, }, - Qualifier: &qualifier, + Qualifier: &poolQualifier, }, nil) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy HybridWithExternalMinterTokenPool v1.6.0: %w", err) @@ -308,33 +279,46 @@ var DeployTokenPool = cldf_ops.NewSequence( return sequences.OnChainOutput{}, fmt.Errorf("unsupported token pool type and version: %s", typeAndVersion) } - addresses = append(addresses, poolRef) - - batchOp, err := contract.NewBatchOperationFromWrites(writes) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation from writes: %w", err) - } - return sequences.OnChainOutput{ - Addresses: addresses, - BatchOps: []mcms_types.BatchOperation{batchOp}, + Addresses: []datastore.AddressRef{poolRef}, + BatchOps: []mcms_types.BatchOperation{}, }, nil }, ) -func fetchTokenGovernor(input tokenapi.DeployTokenPoolInput) (string, error) { - tokenGovernor := input.TokenGovernor - if tokenGovernor == "" { - // fetch token governor from the data store - tokenGovernorAddr, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Type: datastore.ContractType(token_governor.ContractType), - Qualifier: input.TokenRef.Qualifier, - }, input.ChainSelector, datastore_utils.FullRef) - if err != nil { - return "", fmt.Errorf("token governor for token with symbol '%s' is not found in datastore, %v", input.TokenRef.Qualifier, err) +func fetchTokenGovernor(input tokenapi.DeployTokenPoolInput) (common.Address, error) { + // If the token governor address is provided directly, then + // skip the datastore lookup and use the provided address. + if input.TokenGovernor != "" { + govStrn := input.TokenGovernor + if !common.IsHexAddress(input.TokenGovernor) { + return common.Address{}, fmt.Errorf("provided token governor address '%s' is not a valid hex address", input.TokenGovernor) + } + govAddr := common.HexToAddress(govStrn) + if govAddr == (common.Address{}) { + return common.Address{}, fmt.Errorf("provided token governor address '%s' is the zero address", input.TokenGovernor) } - tokenGovernor = tokenGovernorAddr.Address + return govAddr, nil + } + + // Define token governor datastore filter + filter := datastore.AddressRef{ + ChainSelector: input.ChainSelector, + Qualifier: input.TokenRef.Qualifier, + Type: datastore.ContractType(token_governor.ContractType), } - return tokenGovernor, nil + + // If the token governor address isn't provided, then try + // to find it in the datastore. + tokenGovernorAddr, err := datastore_utils.FindAndFormatRef( + input.ExistingDataStore, + filter, + input.ChainSelector, + datastore_utils_evm.ToNonZeroEVMAddress, + ) + if err != nil { + return common.Address{}, fmt.Errorf("failed to find token governor address in datastore using filter (%s): %w", datastore_utils.SprintRef(filter), err) + } + + return tokenGovernorAddr, nil } diff --git a/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts_test.go b/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts_test.go index 8ea93f2317..e182ef9476 100644 --- a/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts_test.go +++ b/chains/evm/deployment/v1_6_0/sequences/deploy_token_pool_contracts_test.go @@ -315,8 +315,8 @@ func TestDeployTokenPool_AlreadyDeployed(t *testing.T) { require.NoError(t, err, "Should not error when pool already exists") require.NotNil(t, report, "Report should not be nil") - // Verify no new addresses were deployed - require.Equal(t, 0, len(report.Output.Addresses), "Should not deploy any new addresses when pool already exists") + // Verify existing pool address was returned + require.Equal(t, 1, len(report.Output.Addresses), "Expected exactly one address in output (existing pool address)") } // TestDeployTokenPool_MissingTokenPoolVersion verifies that the sequence fails @@ -460,11 +460,12 @@ func TestDeployTokenPool_MissingRouter(t *testing.T) { TokenPoolVersion: utils.Version_1_6_1, ChainSelector: chainSelector, ExistingDataStore: e.DataStore, + TokenRef: &datastore.AddressRef{Address: tokenAddr.Hex()}, } _, err = cldf_ops.ExecuteSequence(e.OperationsBundle, DeployTokenPool, e.BlockChains, input) require.Error(t, err, "Should error when router is not found in datastore") - require.Contains(t, err.Error(), "token address must be provided either directly or via a datastore reference", "Error message should mention not found in datastore") + require.Contains(t, err.Error(), "failed to resolve router address") } // TestDeployTokenPool_MissingRMNProxy verifies that the sequence fails @@ -516,11 +517,12 @@ func TestDeployTokenPool_MissingRMNProxy(t *testing.T) { TokenPoolVersion: utils.Version_1_6_1, ChainSelector: chainSelector, ExistingDataStore: e.DataStore, + TokenRef: &datastore.AddressRef{Address: tokenAddr.Hex()}, } _, err = cldf_ops.ExecuteSequence(e.OperationsBundle, DeployTokenPool, e.BlockChains, input) require.Error(t, err, "Should error when RMN proxy is not found in datastore") - require.Contains(t, err.Error(), "token address must be provided either directly or via a datastore reference", "Error message should mention not found in datastore") + require.Contains(t, err.Error(), "failed to resolve rmn proxy address") } // TestDeployTokenPool_MissingToken verifies that the sequence fails @@ -566,11 +568,12 @@ func TestDeployTokenPool_MissingToken(t *testing.T) { TokenPoolVersion: utils.Version_1_6_1, ChainSelector: chainSelector, ExistingDataStore: e.DataStore, + TokenRef: nil, } _, err = cldf_ops.ExecuteSequence(e.OperationsBundle, DeployTokenPool, e.BlockChains, input) require.Error(t, err, "Should error when token is not found in datastore") - require.Contains(t, err.Error(), "token address must be provided either directly or via a datastore reference", "Error message should mention token not found") + require.Contains(t, err.Error(), "TokenRef is required") } // deployTestToken deploys a BurnMintERC20 token for testing purposes and returns its address. diff --git a/chains/evm/deployment/v1_6_0/sequences/token_and_pools.go b/chains/evm/deployment/v1_6_0/sequences/token_and_pools.go index e27543a23b..e023707cc3 100644 --- a/chains/evm/deployment/v1_6_0/sequences/token_and_pools.go +++ b/chains/evm/deployment/v1_6_0/sequences/token_and_pools.go @@ -6,8 +6,8 @@ import ( "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" - evm1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" evm_ds_utils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" + evm1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" ) @@ -25,7 +25,7 @@ func (a *EVMAdapter) AddressRefToBytes(ref datastore.AddressRef) ([]byte, error) // GetTokenAdminRegistryAddress looks up the TAR (v1.5.0) address from the datastore. // Delegates to the shared v1.0.0 adapter helper. func (a *EVMAdapter) GetTokenAdminRegistryAddress(ds datastore.DataStore, selector uint64) (common.Address, error) { - return evm1_0_0.GetTokenAdminRegistryAddress(ds, selector, &evm1_0_0.EVMTokenBase{}) + return (&evm1_0_0.EVMTokenBase{}).GetTokenAdminRegistryAddress(ds, selector) } func (a *EVMAdapter) FindOneTokenAddress(ds datastore.DataStore, chainSelector uint64, partialRef *datastore.AddressRef) (common.Address, error) { @@ -58,4 +58,3 @@ func (a *EVMAdapter) FindLatestAddressRef(ds datastore.DataStore, ref datastore. } return evm_ds_utils.ToEVMAddress(latestRef) } - diff --git a/chains/evm/deployment/v1_6_1/adapters/tokens.go b/chains/evm/deployment/v1_6_1/adapters/tokens.go index cdcaf8093a..deb16b3425 100644 --- a/chains/evm/deployment/v1_6_1/adapters/tokens.go +++ b/chains/evm/deployment/v1_6_1/adapters/tokens.go @@ -9,10 +9,10 @@ import ( "github.com/ethereum/go-ethereum/common" evm1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" - v1_6_0_seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/sequences" - tpOpsV1_6_1 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/token_pool" - evm_seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/sequences" - tpV1_6_1 "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_1/token_pool" + seqV1_6_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/sequences" + tpOps "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/token_pool" + seqV1_6_1 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/sequences" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_1/token_pool" tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" @@ -38,7 +38,7 @@ func NewTokenAdapter() *TokenAdapter { return &TokenAdapter{ EVMPoolAdapter: evm1_0_0.EVMPoolAdapter{ Ops: &poolOpsV161{}, - DeployTokenPoolSeq: v1_6_0_seq.DeployTokenPool, + DeployTokenPoolSeq: seqV1_6_0.DeployTokenPool, }, } } @@ -49,18 +49,18 @@ func NewTokenAdapter() *TokenAdapter { func (t *TokenAdapter) ConfigureTokenForTransfersSequence() *cldf_ops.Sequence[tokensapi.ConfigureTokenForTransfersInput, sequences.OnChainOutput, cldf_chain.BlockChains] { return cldf_ops.NewSequence( "evm-v1.6.1-adapter:configure-token-for-transfers", - tpOpsV1_6_1.Version, + tpOps.Version, "Configure a v1.6.1 token pool for cross-chain transfers on an EVM chain", func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input tokensapi.ConfigureTokenForTransfersInput) (sequences.OnChainOutput, error) { if input.RegistryAddress == "" { - tarAddr, err := evm1_0_0.GetTokenAdminRegistryAddress(input.ExistingDataStore, input.ChainSelector, &t.EVMTokenBase) + tarAddr, err := t.EVMTokenBase.GetTokenAdminRegistryAddress(input.ExistingDataStore, input.ChainSelector) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve TAR address for chain %d: %w", input.ChainSelector, err) } input.RegistryAddress = tarAddr.Hex() } - report, err := cldf_ops.ExecuteSequence(b, evm_seq.ConfigureTokenForTransfers, chains, input) + report, err := cldf_ops.ExecuteSequence(b, seqV1_6_1.ConfigureTokenForTransfers, chains, input) if err != nil { return sequences.OnChainOutput{}, err } @@ -73,7 +73,7 @@ type poolOpsV161 struct{} func (p *poolOpsV161) GetToken(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address) (common.Address, error) { res, err := cldf_ops.ExecuteOperation(b, - tpOpsV1_6_1.GetToken, chain, + tpOps.GetToken, chain, evm_contract.FunctionInput[struct{}]{ ChainSelector: chain.Selector, Address: poolAddr, @@ -85,16 +85,22 @@ func (p *poolOpsV161) GetToken(b cldf_ops.Bundle, chain evm.Chain, poolAddr comm return res.Output, nil } -func (p *poolOpsV161) GetTokenDecimals(ctx context.Context, chain evm.Chain, poolAddr common.Address) (uint8, error) { - pool, err := tpV1_6_1.NewTokenPool(poolAddr, chain.Client) +func (p *poolOpsV161) GetTokenDecimals(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address) (uint8, error) { + res, err := cldf_ops.ExecuteOperation(b, + tpOps.GetTokenDecimals, chain, + evm_contract.FunctionInput[struct{}]{ + ChainSelector: chain.Selector, + Address: poolAddr, + }, + ) if err != nil { - return 0, fmt.Errorf("failed to instantiate token pool v1.6.1 contract: %w", err) + return 0, fmt.Errorf("GetTokenDecimals v1.6.1: %w", err) } - return pool.GetTokenDecimals(&bind.CallOpts{Context: ctx}) + return res.Output, nil } func (p *poolOpsV161) GetPoolAdmins(ctx context.Context, chain *evm.Chain, poolAddr common.Address) (owner, rlAdmin common.Address, err error) { - pool, err := tpV1_6_1.NewTokenPool(poolAddr, chain.Client) + pool, err := token_pool.NewTokenPool(poolAddr, chain.Client) if err != nil { return common.Address{}, common.Address{}, fmt.Errorf("failed to instantiate token pool v1.6.1 contract: %w", err) } @@ -117,17 +123,17 @@ func (p *poolOpsV161) SetRateLimiterConfig(b cldf_ops.Bundle, chain evm.Chain, p } report, err := cldf_ops.ExecuteOperation(b, - tpOpsV1_6_1.SetChainRateLimiterConfig, chain, - evm_contract.FunctionInput[tpOpsV1_6_1.SetChainRateLimiterConfigArgs]{ + tpOps.SetChainRateLimiterConfig, chain, + evm_contract.FunctionInput[tpOps.SetChainRateLimiterConfigArgs]{ ChainSelector: chain.Selector, Address: poolAddr, - Args: tpOpsV1_6_1.SetChainRateLimiterConfigArgs{ - OutboundConfig: tpOpsV1_6_1.Config{ + Args: tpOps.SetChainRateLimiterConfigArgs{ + OutboundConfig: tpOps.Config{ IsEnabled: bucket.OutboundRateLimiterConfig.IsEnabled, Capacity: bucket.OutboundRateLimiterConfig.Capacity, Rate: bucket.OutboundRateLimiterConfig.Rate, }, - InboundConfig: tpOpsV1_6_1.Config{ + InboundConfig: tpOps.Config{ IsEnabled: bucket.InboundRateLimiterConfig.IsEnabled, Capacity: bucket.InboundRateLimiterConfig.Capacity, Rate: bucket.InboundRateLimiterConfig.Rate, @@ -141,25 +147,29 @@ func (p *poolOpsV161) SetRateLimiterConfig(b cldf_ops.Bundle, chain evm.Chain, p return []evm_contract.WriteOutput{report.Output}, nil } -func (p *poolOpsV161) SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) (evm_contract.WriteOutput, error) { +func (p *poolOpsV161) SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) ([]evm_contract.WriteOutput, error) { report, err := cldf_ops.ExecuteOperation(b, - tpOpsV1_6_1.SetRateLimitAdmin, chain, + tpOps.SetRateLimitAdmin, chain, evm_contract.FunctionInput[common.Address]{ ChainSelector: chain.Selector, Address: poolAddr, Args: newAdmin, }) if err != nil { - return evm_contract.WriteOutput{}, fmt.Errorf("SetRateLimitAdmin v1.6.1: %w", err) + return nil, fmt.Errorf("SetRateLimitAdmin v1.6.1: %w", err) } - return report.Output, nil + return []evm_contract.WriteOutput{report.Output}, nil } -func (p *poolOpsV161) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64) (tokensapi.RateLimiterConfig, error) { +func (p *poolOpsV161) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64, ff bool) (tokensapi.RateLimiterConfig, error) { + if ff { + return tokensapi.RateLimiterConfig{}, fmt.Errorf("fast finality buckets are not supported on v1.6.x token pools") + } + // Call the contract binding directly rather than cldf_ops Read: the framework caches read // reports by input hash, and earlier sequences in the same Apply run may have read this // same lane while it was still uninitialized — caching that stale result. - tp, err := tpV1_6_1.NewTokenPool(poolAddr, chain.Client) + tp, err := token_pool.NewTokenPool(poolAddr, chain.Client) if err != nil { return tokensapi.RateLimiterConfig{}, fmt.Errorf("failed to instantiate v1.6.1 token pool contract at %s: %w", poolAddr.Hex(), err) } @@ -167,6 +177,7 @@ func (p *poolOpsV161) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Ch if err != nil { return tokensapi.RateLimiterConfig{}, fmt.Errorf("failed to get inbound rate limiter state for remote chain %d: %w", remoteSelector, err) } + return tokensapi.RateLimiterConfig{ IsEnabled: bucket.IsEnabled, Capacity: bucket.Capacity, @@ -175,5 +186,5 @@ func (p *poolOpsV161) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Ch } func (p *poolOpsV161) Version() *semver.Version { - return tpOpsV1_6_1.Version + return tpOps.Version } diff --git a/chains/evm/deployment/v1_6_1/sequences/configure_non_canonical_usdc_for_lanes.go b/chains/evm/deployment/v1_6_1/sequences/configure_non_canonical_usdc_for_lanes.go index b9565b48d2..3ecb7cf0fc 100644 --- a/chains/evm/deployment/v1_6_1/sequences/configure_non_canonical_usdc_for_lanes.go +++ b/chains/evm/deployment/v1_6_1/sequences/configure_non_canonical_usdc_for_lanes.go @@ -58,8 +58,9 @@ var ConfigureNonCanonicalUSDCForLanes = cldf_ops.NewSequence( remoteToken = common.LeftPadBytes(remoteToken, 32) remotePool = common.LeftPadBytes(remotePool, 32) + feeConfig := tokens.PartialTokenTransferFeeConfig{}.Populate(remoteChainConfig.TokenTransferFeeConfig) remoteChains[remoteChainSelector] = tokens.RemoteChainConfig[[]byte, string]{ - TokenTransferFeeConfig: remoteChainConfig.TokenTransferFeeConfig, + TokenTransferFeeConfig: &feeConfig, RemoteToken: remoteToken, RemotePool: remotePool, InboundRateLimiterConfig: &remoteChainConfig.InboundRateLimiterConfig, diff --git a/chains/evm/deployment/v1_6_1/sequences/configure_token_pool_for_remote_chain.go b/chains/evm/deployment/v1_6_1/sequences/configure_token_pool_for_remote_chain.go index 2fca3fba6a..a74f047fec 100644 --- a/chains/evm/deployment/v1_6_1/sequences/configure_token_pool_for_remote_chain.go +++ b/chains/evm/deployment/v1_6_1/sequences/configure_token_pool_for_remote_chain.go @@ -82,6 +82,7 @@ var ConfigureTokenPoolForRemoteChain = cldf_ops.NewSequence( // Get outbound and inbound rate limits from the input outboundRL, outboundOk := input.RemoteChainConfig.GetOutboundRateLimitBuckets().DefaultBucket() inboundRL, inboundOk := input.RemoteChainConfig.GetInboundRateLimitBuckets().DefaultBucket() + paddedPool := common.LeftPadBytes(input.RemoteChainConfig.RemotePool, 32) // Resolve the outbound and inbound rate limits var outboundConfig, inboundConfig tokens.RateLimiterConfig @@ -186,7 +187,7 @@ var ConfigureTokenPoolForRemoteChain = cldf_ops.NewSequence( return sequences.OnChainOutput{}, fmt.Errorf("failed to get remote pools: %w", err) } if !slices.ContainsFunc(getRemotePoolsReport.Output, func(addr []byte) bool { - return bytes.Equal(addr, input.RemoteChainConfig.RemotePool) + return bytes.Equal(addr, paddedPool) }) { // Add the requested remote pool addRemotePoolsReport, err := cldf_ops.ExecuteOperation(b, token_pool.AddRemotePool, chain, evm_contract.FunctionInput[token_pool.AddRemotePoolArgs]{ @@ -194,7 +195,7 @@ var ConfigureTokenPoolForRemoteChain = cldf_ops.NewSequence( Address: input.TokenPoolAddress, Args: token_pool.AddRemotePoolArgs{ RemoteChainSelector: input.RemoteChainSelector, - RemotePoolAddress: common.LeftPadBytes(input.RemoteChainConfig.RemotePool, 32), + RemotePoolAddress: paddedPool, }, }) if err != nil { @@ -222,10 +223,8 @@ var ConfigureTokenPoolForRemoteChain = cldf_ops.NewSequence( ChainsToAdd: []token_pool.ChainUpdate{ { RemoteChainSelector: input.RemoteChainSelector, - RemotePoolAddresses: [][]byte{ - common.LeftPadBytes(input.RemoteChainConfig.RemotePool, 32), - }, - RemoteTokenAddress: common.LeftPadBytes(input.RemoteChainConfig.RemoteToken, 32), + RemotePoolAddresses: [][]byte{paddedPool}, + RemoteTokenAddress: common.LeftPadBytes(input.RemoteChainConfig.RemoteToken, 32), OutboundRateLimiterConfig: token_pool.Config{ IsEnabled: outboundConfig.IsEnabled, Capacity: outboundConfig.Capacity, diff --git a/chains/evm/deployment/v1_6_1/sequences/token_pool/configure_token_pool_for_remote_chains.go b/chains/evm/deployment/v1_6_1/sequences/token_pool/configure_token_pool_for_remote_chains.go deleted file mode 100644 index 45b52eaa40..0000000000 --- a/chains/evm/deployment/v1_6_1/sequences/token_pool/configure_token_pool_for_remote_chains.go +++ /dev/null @@ -1,343 +0,0 @@ -package token_pool - -import ( - "bytes" - "fmt" - "math/big" - "slices" - - "github.com/Masterminds/semver/v3" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/type_and_version" - tpops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/operations/token_pool" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_1/token_pool" - tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" - "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" - "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" - "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" - cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" - mcms_types "github.com/smartcontractkit/mcms/types" -) - -type ConfigureTokenPoolForRemoteChainsInput struct { - TokenPoolAddress common.Address - TokenPoolVersion *semver.Version - RemoteChains map[uint64]tokensapi.RemoteChainConfig[[]byte, string] -} - -type ConfigureTokenPoolForRemoteChainInput struct { - TokenPoolAddress common.Address - RemoteChainSelector uint64 - RemoteChainConfig tokensapi.RemoteChainConfig[[]byte, string] -} - -// ConfigureTokenPoolForRemoteChains configures a token pool on an EVM chain for cross- -// chain token transfers with other remote chains. It's capable of configuring multiple -// remote chains with a single invocation. -var ConfigureTokenPoolForRemoteChains = cldf_ops.NewSequence( - "token-pool:configure-token-pool-for-remote-chains", - tpops.Version, - "Configure a token on an EVM chain for cross-chain transfers", - func(b cldf_ops.Bundle, chain evm.Chain, input ConfigureTokenPoolForRemoteChainsInput) (sequences.OnChainOutput, error) { - // NOTE: this sequence will be called repeatedly as part of a larger changeset (e.g. - // ConfigureTokensForTransfers) so we intentionally use the direct contract bindings - // over ExecuteOperation to avoid the possibility of reading stale onchain data from - // the operation reports cache. - tokenPool, err := token_pool.NewTokenPool(input.TokenPoolAddress, chain.Client) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to instantiate token pool contract: %w", err) - } - - tokenAddress, err := tokenPool.GetToken(&bind.CallOpts{Context: b.GetContext()}) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get token from token pool: %w", err) - } - - isSupported, err := tokenPool.IsSupportedToken(&bind.CallOpts{Context: b.GetContext()}, tokenAddress) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to check if token is supported: %w", err) - } - if !isSupported { - return sequences.OnChainOutput{}, fmt.Errorf("token %s is not supported by token pool %s", tokenAddress.Hex(), input.TokenPoolAddress) - } - - batchOps := make([]mcms_types.BatchOperation, 0) - for remoteChainSelector, remoteChainConfig := range input.RemoteChains { - report, err := cldf_ops.ExecuteSequence(b, - ConfigureTokenPoolForRemoteChain, - chain, - ConfigureTokenPoolForRemoteChainInput{ - TokenPoolAddress: tokenPool.Address(), - RemoteChainSelector: remoteChainSelector, - RemoteChainConfig: remoteChainConfig, - }, - ) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to configure token pool for remote chain %d: %w", remoteChainSelector, err) - } - - batchOps = append(batchOps, report.Output.BatchOps...) - } - - return sequences.OnChainOutput{BatchOps: batchOps}, nil - }) - -// ConfigureTokenPoolForRemoteChain is a helper sequence that performs the logic for -// configuring a token pool for a SINGLE remote chain. The sequence allows the upper -// level ConfigureTokenPoolForRemoteChains sequence to handle multiple remote chains -var ConfigureTokenPoolForRemoteChain = cldf_ops.NewSequence( - "token-pool:configure-token-pool-for-remote-chain", - tpops.Version, - "Configures a token pool on an EVM chain for transfers with other chains", - func(b cldf_ops.Bundle, chain evm.Chain, input ConfigureTokenPoolForRemoteChainInput) (sequences.OnChainOutput, error) { - if err := input.RemoteChainConfig.Validate(); err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("invalid remote chain config for remote chain selector %d: %w", input.RemoteChainSelector, err) - } - - // Below, we read onchain state directly from the contract binding. We intentionally - // avoid the use of ExecuteOperation because it could return stale onchain data from - // the operations reports cache if this sequence is called as part of a broader, and - // more complex changeset that repeatedly reads and writes to the same config during - // execution (e.g. ConfigureTokensForTransfers) - tp, err := token_pool.NewTokenPool(input.TokenPoolAddress, chain.Client) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to instantiate token pool contract: %w", err) - } - sc, err := tp.GetSupportedChains(&bind.CallOpts{Context: b.GetContext()}) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get supported chains: %w", err) - } - localDecimals, err := tp.GetTokenDecimals(&bind.CallOpts{Context: b.GetContext()}) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get token decimals: %w", err) - } - - // A pool's type and version is immutable so we can safely use ExecuteOperation here - // without worrying about stale data from the cache. - tvReport, err := cldf_ops.ExecuteOperation(b, type_and_version.GetTypeAndVersion, chain, contract.FunctionInput[struct{}]{ - ChainSelector: chain.Selector, - Address: input.TokenPoolAddress, - Args: struct{}{}, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get type and version of token pool: %w", err) - } - - // Get outbound and inbound rate limits from the input - outboundRL, outboundOk := input.RemoteChainConfig.GetOutboundRateLimitBuckets().DefaultBucket() - inboundRL, inboundOk := input.RemoteChainConfig.GetInboundRateLimitBuckets().DefaultBucket() - - // Resolve the outbound and inbound rate limits - var inputORL, inputIRL tokensapi.RateLimiterConfig - switch { - case outboundOk && inboundOk: - // If the user explicitly provided both the outbound and inbound rate limits, then - // we use them. - inputORL, inputIRL = tokensapi.GenerateTPRLConfigs( - outboundRL.RateLimit, - inboundRL.RateLimit, - localDecimals, - input.RemoteChainConfig.RemoteDecimals, - chain.Family(), - tvReport.Output.Version, - tvReport.Output.Type.String(), - ) - - case !outboundOk && !inboundOk: - if slices.Contains(sc, input.RemoteChainSelector) { - // Idempotent behavior: if we're re-calling this sequence and no rate limits are - // specified, then we re-use whatever is currently onchain to avoid accidentally - // overwriting existing onchain config - onchainOutboundBucket, err := tp.GetCurrentOutboundRateLimiterState(&bind.CallOpts{Context: b.GetContext()}, input.RemoteChainSelector) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get outbound rate limiter state for remote chain %d: %w", input.RemoteChainSelector, err) - } - onchainInboundBucket, err := tp.GetCurrentInboundRateLimiterState(&bind.CallOpts{Context: b.GetContext()}, input.RemoteChainSelector) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get inbound rate limiter state for remote chain %d: %w", input.RemoteChainSelector, err) - } - inputORL = tokensapi.RateLimiterConfig{ - IsEnabled: onchainOutboundBucket.IsEnabled, - Capacity: onchainOutboundBucket.Capacity, - Rate: onchainOutboundBucket.Rate, - } - inputIRL = tokensapi.RateLimiterConfig{ - IsEnabled: onchainInboundBucket.IsEnabled, - Capacity: onchainInboundBucket.Capacity, - Rate: onchainInboundBucket.Rate, - } - } else { - // If this is a fresh configuration for a remote chain (i.e. the remote chain selector - // is not currently supported onchain), and no rate limits are specified in the input, - // then we default to disabled rate limiters. - inputORL = tokensapi.RateLimiterConfig{IsEnabled: false, Capacity: big.NewInt(0), Rate: big.NewInt(0)} - inputIRL = tokensapi.RateLimiterConfig{IsEnabled: false, Capacity: big.NewInt(0), Rate: big.NewInt(0)} - } - - default: - return sequences.OnChainOutput{}, fmt.Errorf( - "default outbound and inbound rate limits must both be specified together or both omitted for remote chain %d", - input.RemoteChainSelector, - ) - } - - // Token pool remote chain configuration can vary depending on whether the remote - // pool is or isn't supported. The different cases to consider are recorded below - // in the code. - reportWrites := []contract.WriteOutput{} - remotesToDel := []uint64{} - if slices.Contains(sc, input.RemoteChainSelector) { - remoteToken, err := tp.GetRemoteToken(&bind.CallOpts{Context: b.GetContext()}, input.RemoteChainSelector) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get remote token: %w", err) - } - - // Token pool remote chain configuration can also vary depending on whether the - // remote token matches or not - see comment further below for more details. - if !bytes.Equal(remoteToken, input.RemoteChainConfig.RemoteToken) { - // If the remote token onchain is different from the one provided as input, then we - // need to ensure that ApplyChainUpdates removes any existing config for the remote - // chain before a new one is used. - remotesToDel = []uint64{input.RemoteChainSelector} - } else { - // If the remote token onchain matches the one provided as input, then we won't call - // ApplyChainUpdates and instead handle the onchain updates via SetRateLimiterConfig - // and AddRemotePool. - // Remote pool addresses in CCIP messages are ABI-encoded (32-byte left-padded). - // Using left-padded addresses here ensures the stored value matches what - // the protocol sends, preventing "invalid source pool" errors on delivery. - remoteTP := common.LeftPadBytes(input.RemoteChainConfig.RemotePool, 32) - remoteCS := input.RemoteChainSelector - - // Query rate limits and remote pools - onchainORL, err := tp.GetCurrentOutboundRateLimiterState(&bind.CallOpts{Context: b.GetContext()}, remoteCS) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get outbound rate limiter state: %w", err) - } - onchainIRL, err := tp.GetCurrentInboundRateLimiterState(&bind.CallOpts{Context: b.GetContext()}, remoteCS) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get inbound rate limiter state: %w", err) - } - remoteTPs, err := tp.GetRemotePools(&bind.CallOpts{Context: b.GetContext()}, remoteCS) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get remote token pools: %w", err) - } - - // Check if the provided outbound RL matches the onchain outbound RL - isOutboundEqual := inputORL.IsEnabled == onchainORL.IsEnabled && - inputORL.Capacity.Cmp(onchainORL.Capacity) == 0 && - inputORL.Rate.Cmp(onchainORL.Rate) == 0 - - // Check if the provided inbound RL matches the onchain inbound RL - isInboundEqual := inputIRL.IsEnabled == onchainIRL.IsEnabled && - inputIRL.Capacity.Cmp(onchainIRL.Capacity) == 0 && - inputIRL.Rate.Cmp(onchainIRL.Rate) == 0 - - // Check whether the exact 32-byte padded address is already registered. - // We intentionally use an exact (not normalized) comparison: if only a - // 20-byte entry exists from a prior run, this returns false and we will - // call AddRemotePool to register the correct 32-byte value alongside it. - hasRemoteTP := slices.ContainsFunc(remoteTPs, func(rtp []byte) bool { - return bytes.Equal(rtp, remoteTP) - }) - - // If either rate limiter config is different, then update it - if !isOutboundEqual || !isInboundEqual { - report, err := cldf_ops.ExecuteOperation(b, tpops.SetChainRateLimiterConfig, chain, contract.FunctionInput[tpops.SetChainRateLimiterConfigArgs]{ - ChainSelector: chain.Selector, - Address: input.TokenPoolAddress, - Args: tpops.SetChainRateLimiterConfigArgs{ - OutboundConfig: tpops.Config{IsEnabled: inputORL.IsEnabled, Capacity: inputORL.Capacity, Rate: inputORL.Rate}, - InboundConfig: tpops.Config{IsEnabled: inputIRL.IsEnabled, Capacity: inputIRL.Capacity, Rate: inputIRL.Rate}, - RemoteChainSelector: remoteCS, - }, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to set rate limiter config: %w", err) - } - reportWrites = append(reportWrites, report.Output) - } - - // If the exact 32-byte remote pool address is not registered, add it - if !hasRemoteTP { - report, err := cldf_ops.ExecuteOperation(b, tpops.AddRemotePool, chain, contract.FunctionInput[tpops.AddRemotePoolArgs]{ - ChainSelector: chain.Selector, - Address: input.TokenPoolAddress, - Args: tpops.AddRemotePoolArgs{ - RemoteChainSelector: remoteCS, - RemotePoolAddress: remoteTP, - }, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to add remote token pool: %w", err) - } - reportWrites = append(reportWrites, report.Output) - } - - // The chain is already supported with a matching remote token. If - // reportWrites is still empty here, rate limiters and pool addresses are - // all already correct — nothing left to do. - if len(reportWrites) == 0 { - return sequences.OnChainOutput{BatchOps: []mcms_types.BatchOperation{}}, nil - } - } - } - - // Three cases to consider here: - // -- - // 1. The chain is not supported yet in which case the only thing that's needed is to add - // it via ApplyChainUpdates. No removals are necessary, and rate limiters will be set. - // -- - // 2. The chain is already supported AND the input remote token DIFFERS from the onchain - // remote token. In this case, we need to ensure that any existing remote configs are - // removed before adding a new one via ApplyChainUpdates. - // -- - // 3. The chain is already supported AND the input remote token EQUALS the onchain remote - // token. In this case, we will never call ApplyChainUpdates. Instead, we handle - // onchain updates via SetRateLimiterConfig and AddRemotePool above, returning early - // if the chain is already fully configured. - // - if len(reportWrites) == 0 { - paddedRemoteTokenPoolAddress := common.LeftPadBytes(input.RemoteChainConfig.RemotePool, 32) - applyChainUpdatesInput := contract.FunctionInput[tpops.ApplyChainUpdatesArgs]{ - ChainSelector: chain.Selector, - Address: input.TokenPoolAddress, - Args: tpops.ApplyChainUpdatesArgs{ - RemoteChainSelectorsToRemove: remotesToDel, - ChainsToAdd: []tpops.ChainUpdate{ - { - RemotePoolAddresses: [][]byte{paddedRemoteTokenPoolAddress}, - RemoteChainSelector: input.RemoteChainSelector, - RemoteTokenAddress: input.RemoteChainConfig.RemoteToken, - OutboundRateLimiterConfig: tpops.Config{ - IsEnabled: inputORL.IsEnabled, - Capacity: inputORL.Capacity, - Rate: inputORL.Rate, - }, - InboundRateLimiterConfig: tpops.Config{ - IsEnabled: inputIRL.IsEnabled, - Capacity: inputIRL.Capacity, - Rate: inputIRL.Rate, - }, - }, - }, - }, - } - - report, err := cldf_ops.ExecuteOperation(b, tpops.ApplyChainUpdates, chain, applyChainUpdatesInput) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to apply chain updates: %w", err) - } - - reportWrites = append(reportWrites, report.Output) - } - - batchOp, err := contract.NewBatchOperationFromWrites(reportWrites) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation: %w", err) - } - - return sequences.OnChainOutput{BatchOps: []mcms_types.BatchOperation{batchOp}}, nil - }) diff --git a/chains/evm/deployment/v2_0_0/adapters/fees.go b/chains/evm/deployment/v2_0_0/adapters/fees.go index 122e76f280..ac4f8f03e0 100644 --- a/chains/evm/deployment/v2_0_0/adapters/fees.go +++ b/chains/evm/deployment/v2_0_0/adapters/fees.go @@ -167,7 +167,7 @@ func (a *FeesAdapter) SetTokenTransferFee(e cldf.Environment, feeRef datastore.A token = common.HexToAddress(rawTokenAddress) } - if feeCfg == nil { + if feeCfg == nil || !feeCfg.IsEnabled { val.TokensToUseDefaultFeeConfigs = append( val.TokensToUseDefaultFeeConfigs, fqops.TokenTransferFeeConfigRemoveArgs{ diff --git a/chains/evm/deployment/v2_0_0/adapters/tokens.go b/chains/evm/deployment/v2_0_0/adapters/tokens.go index 7508704e8b..a24d9f6ffb 100644 --- a/chains/evm/deployment/v2_0_0/adapters/tokens.go +++ b/chains/evm/deployment/v2_0_0/adapters/tokens.go @@ -4,38 +4,25 @@ import ( "context" "errors" "fmt" - "math/big" "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - mcms_types "github.com/smartcontractkit/mcms/types" evm1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/siloed_lock_release_token_pool" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/token_pool" evm_tokens "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences/tokens" "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" tpBindingsV2_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v2_0_0/token_pool" - bnmOps "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" - bnmDripOps "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20_with_drip" - bnmERC677Ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677" - rmnproxyops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/rmn_proxy" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" - bnmDripOps150 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/burn_mint_erc20_with_drip" - "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" cciputils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" - datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" - "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" - "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/erc20" ) var ( @@ -46,470 +33,28 @@ var ( // TokenAdapter handles EVM token pools at version 2.0.0. // It embeds EVMPoolAdapter for shared methods (DeriveTokenAddress, // ManualRegistration) and overrides the methods that have genuinely -// different v2.0.0 logic (DeriveTokenDecimals with ERC20 fallback, -// SetTokenPoolRateLimits with batched default + fast-finality TPRL buckets, -// DeployTokenPoolForToken with its own deploy sequences). +// different v2.0.0 logic (SetTokenPoolRateLimits with batched default +// + fast-finality TPRL buckets, ConfigureTokenForTransfersSequence with +// its own sequences). type TokenAdapter struct { evm1_0_0.EVMPoolAdapter } -// NewTokenAdapter constructs a v2.0.0 TokenAdapter with pre-wired PoolOps. -// DeployTokenPoolSeq is nil because DeployTokenPoolForToken is fully overridden. +// NewTokenAdapter constructs a v2.0.0 TokenAdapter with pre-wired PoolOps and +// the deploy-token-pool sequence. func NewTokenAdapter() *TokenAdapter { return &TokenAdapter{ EVMPoolAdapter: evm1_0_0.EVMPoolAdapter{ - Ops: &poolOpsV200{}, + Ops: &poolOpsV200{}, + DeployTokenPoolSeq: evm_tokens.DeployTokenPool, }, } } -// ConfigureTokenForTransfersSequence returns the sequence for configuring an EVM token with a 2.0.0 token pool. func (t *TokenAdapter) ConfigureTokenForTransfersSequence() *cldf_ops.Sequence[tokens.ConfigureTokenForTransfersInput, sequences.OnChainOutput, chain.BlockChains] { return evm_tokens.ConfigureTokenForTransfers } -func (t *TokenAdapter) DeployTokenPoolForToken() *cldf_ops.Sequence[tokens.DeployTokenPoolInput, sequences.OnChainOutput, chain.BlockChains] { - return cldf_ops.NewSequence( - "evm-2.0-adapter:deploy-token-pool-for-token", - cciputils.Version_2_0_0, - "Deploy a 2.0.0 token pool for a token on an EVM chain", - func(b cldf_ops.Bundle, chains chain.BlockChains, input tokens.DeployTokenPoolInput) (sequences.OnChainOutput, error) { - if input.TokenPoolVersion == nil { - return sequences.OnChainOutput{}, errors.New("TokenPoolVersion is required") - } - - evmChain, ok := chains.EVMChains()[input.ChainSelector] - if !ok { - return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not found", input.ChainSelector) - } - - threshold := big.NewInt(0) - thresholdProvided := input.ThresholdAmountForAdditionalCCVs != "" - if thresholdProvided { - var ok bool - threshold, ok = new(big.Int).SetString(input.ThresholdAmountForAdditionalCCVs, 10) - if !ok { - return sequences.OnChainOutput{}, fmt.Errorf("invalid ThresholdAmountForAdditionalCCVs %q: must be a decimal integer string", input.ThresholdAmountForAdditionalCCVs) - } - } - - var rateLimitAdmin common.Address - if input.RateLimitAdmin != "" { - if !common.IsHexAddress(input.RateLimitAdmin) { - return sequences.OnChainOutput{}, fmt.Errorf("invalid RateLimitAdmin address %q", input.RateLimitAdmin) - } - rateLimitAdmin = common.HexToAddress(input.RateLimitAdmin) - } - - var feeAggregator common.Address - if input.FeeAggregator != "" { - if !common.IsHexAddress(input.FeeAggregator) { - return sequences.OnChainOutput{}, fmt.Errorf("invalid FeeAggregator address %q", input.FeeAggregator) - } - feeAggregator = common.HexToAddress(input.FeeAggregator) - } - - var tokenAddr string - if input.TokenRef != nil && input.TokenRef.Address != "" { - tokenAddr = input.TokenRef.Address - } - if input.TokenRef != nil && input.TokenRef.Qualifier != "" { - storedRef, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, *input.TokenRef, input.ChainSelector, datastore_utils.FullRef) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("token with ref %+v not found in datastore: %w", *input.TokenRef, err) - } - if tokenAddr != "" && storedRef.Address != tokenAddr { - return sequences.OnChainOutput{}, fmt.Errorf("provided token address %q does not match datastore address %q", tokenAddr, storedRef.Address) - } - if tokenAddr == "" { - tokenAddr = storedRef.Address - } - } - if tokenAddr == "" { - return sequences.OnChainOutput{}, errors.New("token address must be provided either directly or via a datastore reference") - } - - qualifier := input.TokenPoolQualifier - if qualifier == "" { - qualifier = tokenAddr - } - poolType := deployment.ContractType(input.PoolType) - - grantMintBurnRoles := func(poolRef datastore.AddressRef) (*mcms_types.BatchOperation, error) { - if !isBurnMintPoolType(poolType) { - return nil, nil - } - - tokenRef, lookupErr := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Address: tokenAddr, - }, input.ChainSelector, datastore_utils.FullRef) - if lookupErr != nil || !isBurnMintTokenType(tokenRef.Type) { - return nil, nil - } - - poolAddr := common.HexToAddress(poolRef.Address) - if poolAddr == (common.Address{}) { - return nil, errors.New("token pool address is zero") - } - - grantInput := contract.FunctionInput[common.Address]{ - ChainSelector: input.ChainSelector, - Address: common.HexToAddress(tokenAddr), - Args: poolAddr, - } - var writes []contract.WriteOutput - if isBurnMintERC677TokenType(tokenRef.Type) { - var grantErr error - writes, grantErr = bnmERC677Ops.PrepareGrantMintAndBurnRoles( - b, - evmChain, - grantInput, - common.HexToAddress(input.TimelockAddress), - ) - if grantErr != nil { - return nil, fmt.Errorf("failed to grant mint/burn roles to pool %s for token %s: %w", poolAddr, tokenAddr, grantErr) - } - } else { - grantReport, grantErr := cldf_ops.ExecuteOperation(b, - bnmOps.GrantMintAndBurnRoles, evmChain, grantInput) - if grantErr != nil { - return nil, fmt.Errorf("failed to grant mint/burn roles to pool %s for token %s: %w", poolAddr, tokenAddr, grantErr) - } - writes = append(writes, grantReport.Output) - } - - batchOp, bErr := contract.NewBatchOperationFromWrites(writes) - if bErr != nil { - return nil, fmt.Errorf("failed to create batch operation for role grants: %w", bErr) - } - return &batchOp, nil - } - - matches := input.ExistingDataStore.Addresses().Filter( - datastore.AddressRefByType(datastore.ContractType(input.PoolType)), - datastore.AddressRefByChainSelector(input.ChainSelector), - datastore.AddressRefByQualifier(qualifier), - datastore.AddressRefByVersion(input.TokenPoolVersion), - ) - if len(matches) > 1 { - return sequences.OnChainOutput{}, fmt.Errorf("multiple token pools found in datastore with type %q, version %q, qualifier %q on chain %d", - input.PoolType, input.TokenPoolVersion, qualifier, input.ChainSelector) - } - if len(matches) == 1 { - b.Logger.Info("Token pool already deployed at address:", matches[0].Address) - // A previous partial run can leave the pool in datastore before - // the token grants it burn/mint rights. Keep DeployTokenPoolForToken - // declarative: after it runs, the token/pool authority relationship - // should be correct whether the pool was just deployed or reused. - var result sequences.OnChainOutput - batchOp, err := grantMintBurnRoles(matches[0]) - if err != nil { - return sequences.OnChainOutput{}, err - } - if batchOp != nil { - result.BatchOps = append(result.BatchOps, *batchOp) - } - - // Reconcile any dynamic-config fields the caller explicitly supplied - // (router, rate-limit admin, fee aggregator, additional-CCVs - // threshold). ConfigureTokenPool reads current values and only - // emits a write when they differ, so re-runs with the same inputs - // are no-ops. Fields the caller leaves unset (zero/empty) retain - // their current on-chain values. - if input.RouterRef != nil || rateLimitAdmin != (common.Address{}) || feeAggregator != (common.Address{}) || thresholdProvided { - poolAddr := common.HexToAddress(matches[0].Address) - configureInput := evm_tokens.ConfigureTokenPoolInput{ - ChainSelector: input.ChainSelector, - TokenPoolAddress: poolAddr, - RateLimitAdmin: rateLimitAdmin, - FeeAggregator: feeAggregator, - } - if input.RouterRef != nil { - resolved, err := resolveRouterAddress(input.ExistingDataStore, input.ChainSelector, input.RouterRef) - if err != nil { - return sequences.OnChainOutput{}, err - } - configureInput.RouterAddress = resolved - } - if thresholdProvided { - hooksReport, err := cldf_ops.ExecuteOperation(b, token_pool.GetAdvancedPoolHooks, evmChain, contract.FunctionInput[struct{}]{ - ChainSelector: input.ChainSelector, - Address: poolAddr, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to read advanced pool hooks address from existing token pool %s on chain %d: %w", poolAddr, input.ChainSelector, err) - } - configureInput.AdvancedPoolHooks = hooksReport.Output - configureInput.ThresholdAmountForAdditionalCCVs = threshold - } - configureReport, err := cldf_ops.ExecuteSequence(b, evm_tokens.ConfigureTokenPool, evmChain, configureInput) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to reconcile dynamic config for existing token pool %s on chain %d: %w", poolAddr, input.ChainSelector, err) - } - result.BatchOps = append(result.BatchOps, configureReport.Output.BatchOps...) - } - - return result, nil - } - - tokenContract, err := erc20.NewERC20(common.HexToAddress(tokenAddr), evmChain.Client) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to bind ERC20 at %s: %w", tokenAddr, err) - } - tokenDecimals, err := tokenContract.Decimals(&bind.CallOpts{Context: b.GetContext()}) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get decimals for token at %s: %w", tokenAddr, err) - } - - resolvedRouter, err := resolveRouterAddress(input.ExistingDataStore, input.ChainSelector, input.RouterRef) - if err != nil { - return sequences.OnChainOutput{}, err - } - rmnProxyRef, err := datastore_utils.FindAndFormatRef(input.ExistingDataStore, datastore.AddressRef{ - ChainSelector: input.ChainSelector, - Type: datastore.ContractType(rmnproxyops.ContractType), - }, input.ChainSelector, datastore_utils.FullRef) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to find RMN proxy in datastore for chain %d: %w", input.ChainSelector, err) - } - - var allowlist []common.Address - for _, addr := range input.Allowlist { - if !common.IsHexAddress(addr) { - return sequences.OnChainOutput{}, fmt.Errorf("invalid allowlist address: %s", addr) - } - allowlist = append(allowlist, common.HexToAddress(addr)) - } - - internalInput := evm_tokens.DeployTokenPoolInput{ - ChainSel: input.ChainSelector, - TokenPoolType: datastore.ContractType(input.PoolType), - TokenPoolVersion: input.TokenPoolVersion, - TokenSymbol: qualifier, - RateLimitAdmin: rateLimitAdmin, - FeeAggregator: feeAggregator, - ThresholdAmountForAdditionalCCVs: threshold, - ConstructorArgs: evm_tokens.ConstructorArgs{ - Token: common.HexToAddress(tokenAddr), - Decimals: tokenDecimals, - RMNProxy: common.HexToAddress(rmnProxyRef.Address), - Router: resolvedRouter, - }, - AdvancedPoolHooksConfig: evm_tokens.AdvancedPoolHooksConfig{ - Allowlist: allowlist, - }, - } - - var deployOutput sequences.OnChainOutput - - switch { - case isBurnMintPoolType(poolType): - report, execErr := cldf_ops.ExecuteSequence(b, evm_tokens.DeployBurnMintTokenPool, evmChain, internalInput) - if execErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy burn mint token pool on chain %d: %w", input.ChainSelector, execErr) - } - deployOutput = report.Output - case isLockReleasePoolType(poolType): - report, execErr := cldf_ops.ExecuteSequence(b, evm_tokens.DeployLockReleaseTokenPool, evmChain, internalInput) - if execErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy lock release token pool on chain %d: %w", input.ChainSelector, execErr) - } - deployOutput = report.Output - default: - return sequences.OnChainOutput{}, fmt.Errorf("unsupported 2.0.0 token pool type: %s", input.PoolType) - } - - var result sequences.OnChainOutput - result.Addresses = append(result.Addresses, deployOutput.Addresses...) - result.BatchOps = append(result.BatchOps, deployOutput.BatchOps...) - - if isBurnMintPoolType(poolType) && len(deployOutput.Addresses) >= 1 { - batchOp, err := grantMintBurnRoles(deployOutput.Addresses[0]) - if err != nil { - return sequences.OnChainOutput{}, err - } - if batchOp != nil { - result.BatchOps = append(result.BatchOps, *batchOp) - } - } - - return result, nil - }, - ) -} - -// DeriveTokenDecimals has v2.0.0-specific logic: it falls back to ERC20.Decimals() -// when the pool's GetTokenDecimals fails (e.g., proxy pools like USDCTokenPoolProxy). -func (t *TokenAdapter) DeriveTokenDecimals(e deployment.Environment, chainSelector uint64, poolRef datastore.AddressRef, tokenBytes []byte) (uint8, error) { - evmChain, ok := e.BlockChains.EVMChains()[chainSelector] - if !ok { - return 0, fmt.Errorf("chain with selector %d not found", chainSelector) - } - getTokenDecimalsReport, err := cldf_ops.ExecuteOperation(e.OperationsBundle, token_pool.GetTokenDecimals, evmChain, contract.FunctionInput[struct{}]{ - ChainSelector: chainSelector, - Address: common.HexToAddress(poolRef.Address), - }) - if err == nil { - return getTokenDecimalsReport.Output, nil - } - poolErr := err - - tokenAddr := common.BytesToAddress(tokenBytes) - if tokenAddr.Cmp(common.Address{}) == 0 { - getTokenReport, getTokErr := cldf_ops.ExecuteOperation(e.OperationsBundle, token_pool.GetToken, evmChain, contract.FunctionInput[struct{}]{ - ChainSelector: chainSelector, - Address: common.HexToAddress(poolRef.Address), - }) - if getTokErr != nil { - return 0, fmt.Errorf("failed to get token decimals from token pool with address %s on %s: %w", poolRef.Address, evmChain, poolErr) - } - tokenAddr = getTokenReport.Output - } - - tokenContract, newErr := erc20.NewERC20(tokenAddr, evmChain.Client) - if newErr != nil { - return 0, fmt.Errorf("failed to get token decimals from token pool with address %s on %s: %w; failed to bind erc20 at token %s: %w", poolRef.Address, evmChain, poolErr, tokenAddr.Hex(), newErr) - } - decimals, erc20Err := tokenContract.Decimals(&bind.CallOpts{Context: e.GetContext()}) - if erc20Err != nil { - return 0, fmt.Errorf("failed to get token decimals from token pool with address %s on %s: %w; erc20.decimals on token %s also failed: %w", poolRef.Address, evmChain, poolErr, tokenAddr.Hex(), erc20Err) - } - return decimals, nil -} - -// SetTokenPoolRateLimits applies one or more rate limit buckets (default and/or fast-finality) in a single -// setRateLimitConfig call. Optional AllowedFinalityConfig is applied first when non-zero. -func (t *TokenAdapter) SetTokenPoolRateLimits() *cldf_ops.Sequence[tokens.TPRLRemotes, sequences.OnChainOutput, chain.BlockChains] { - return cldf_ops.NewSequence( - "evm-2.0-adapter:set-token-pool-rate-limits", - cciputils.Version_2_0_0, - "Set rate limits for a 2.0.0 token pool on an EVM chain", - func(b cldf_ops.Bundle, chains chain.BlockChains, input tokens.TPRLRemotes) (sequences.OnChainOutput, error) { - evmChain, ok := chains.EVMChains()[input.ChainSelector] - if !ok { - return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not found", input.ChainSelector) - } - - tokenPoolAddrBytes, err := t.AddressRefToBytes(input.TokenPoolRef) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token pool address ref: %w", err) - } - tokenPoolAddr := common.BytesToAddress(tokenPoolAddrBytes) - if tokenPoolAddr == (common.Address{}) { - return sequences.OnChainOutput{}, fmt.Errorf("token pool address for ref %+v is zero", input.TokenPoolRef) - } - - var writes []contract.WriteOutput - if !input.AllowedFinalityConfig.IsZero() { - currentFinalityConfig, err := cldf_ops.ExecuteOperation(b, token_pool.GetAllowedFinalityConfig, evmChain, contract.FunctionInput[struct{}]{ - ChainSelector: input.ChainSelector, - Address: tokenPoolAddr, - Args: struct{}{}, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get allowed finality config for token pool at %s on chain %d: %w", tokenPoolAddr.Hex(), input.ChainSelector, err) - } - if input.AllowedFinalityConfig.Raw() != currentFinalityConfig.Output { - setFinalityReport, err := cldf_ops.ExecuteOperation(b, token_pool.SetAllowedFinalityConfig, evmChain, contract.FunctionInput[[4]byte]{ - ChainSelector: input.ChainSelector, - Address: tokenPoolAddr, - Args: input.AllowedFinalityConfig.Raw(), - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to set allowed finality config on token pool at %s on chain %d: %w", tokenPoolAddr.Hex(), input.ChainSelector, err) - } - writes = append(writes, setFinalityReport.Output) - } - } - - args := make([]token_pool.RateLimitConfigArgs, 0, len(input.RateLimitBuckets)) - for _, bucket := range input.RateLimitBuckets { - args = append(args, token_pool.RateLimitConfigArgs{ - RemoteChainSelector: input.RemoteChainSelector, - FastFinality: bucket.FastFinality, - OutboundRateLimiterConfig: token_pool.Config{ - IsEnabled: bucket.OutboundRateLimiterConfig.IsEnabled, - Capacity: bucket.OutboundRateLimiterConfig.Capacity, - Rate: bucket.OutboundRateLimiterConfig.Rate, - }, - InboundRateLimiterConfig: token_pool.Config{ - IsEnabled: bucket.InboundRateLimiterConfig.IsEnabled, - Capacity: bucket.InboundRateLimiterConfig.Capacity, - Rate: bucket.InboundRateLimiterConfig.Rate, - }, - }) - } - - if len(args) > 0 { - rateLimitsReport, err := cldf_ops.ExecuteOperation(b, token_pool.SetRateLimitConfig, evmChain, contract.FunctionInput[[]token_pool.RateLimitConfigArgs]{ - ChainSelector: input.ChainSelector, - Address: tokenPoolAddr, - Args: args, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to set rate limit config on pool %s: %w", tokenPoolAddr, err) - } - writes = append(writes, rateLimitsReport.Output) - } - - if len(writes) == 0 { - return sequences.OnChainOutput{}, nil - } - - batchOp, err := contract.NewBatchOperationFromWrites(writes) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation: %w", err) - } - return sequences.OnChainOutput{BatchOps: []mcms_types.BatchOperation{batchOp}}, nil - }) -} - -// GetOnchainInboundRateLimit overrides the v1.x EVMPoolAdapter implementation so v2.0.0 pools -// can be read via getCurrentRateLimiterState(remoteChainSelector, fastFinality), which supports -// both default and fast-finality buckets. tokenRef is unused on EVM (pool address suffices). -func (t *TokenAdapter) GetOnchainInboundRateLimit( - e deployment.Environment, - chainSelector uint64, - poolRef datastore.AddressRef, - _ datastore.AddressRef, - remoteSelector uint64, - fastFinality bool, -) (tokens.RateLimiterConfig, error) { - evmChain, ok := e.BlockChains.EVMChains()[chainSelector] - if !ok { - return tokens.RateLimiterConfig{}, fmt.Errorf("chain with selector %d not defined", chainSelector) - } - addrRef, err := datastore_utils.FindAndFormatRef(e.DataStore, poolRef, chainSelector, datastore_utils.FullRef) - if err != nil { - return tokens.RateLimiterConfig{}, fmt.Errorf("failed to find token pool in datastore using ref (%+v): %w", poolRef, err) - } - addrBytes, err := t.AddressRefToBytes(addrRef) - if err != nil { - return tokens.RateLimiterConfig{}, fmt.Errorf("failed to convert pool address ref to bytes: %w", err) - } - poolAddr := common.BytesToAddress(addrBytes) - if poolAddr == (common.Address{}) { - return tokens.RateLimiterConfig{}, fmt.Errorf("token pool address for ref (%+v) is zero", addrRef) - } - // Call the contract binding directly rather than cldf_ops Read: the framework caches read - // reports by input hash, and earlier sequences in the same Apply run may have read this - // same lane while it was still uninitialized — caching that stale result. - tp, err := tpBindingsV2_0_0.NewTokenPool(poolAddr, evmChain.Client) - if err != nil { - return tokens.RateLimiterConfig{}, fmt.Errorf("failed to instantiate v2.0.0 token pool contract at %s: %w", poolAddr.Hex(), err) - } - state, err := tp.GetCurrentRateLimiterState(&bind.CallOpts{Context: e.OperationsBundle.GetContext()}, remoteSelector, fastFinality) - if err != nil { - return tokens.RateLimiterConfig{}, fmt.Errorf("failed to get inbound rate limiter state for remote chain %d (fastFinality=%v): %w", remoteSelector, fastFinality, err) - } - return tokens.RateLimiterConfig{ - IsEnabled: state.InboundRateLimiterState.IsEnabled, - Capacity: state.InboundRateLimiterState.Capacity, - Rate: state.InboundRateLimiterState.Rate, - }, nil -} - func (t *TokenAdapter) MigrateLockReleasePoolLiquiditySequence() *cldf_ops.Sequence[tokens.MigrateLockReleasePoolLiquidityInput, sequences.OnChainOutput, chain.BlockChains] { return evm_tokens.MigrateLockReleasePoolLiquidity } @@ -567,17 +112,14 @@ func (t *TokenAdapter) GetOnchainTokenTransferFeeConfig(e deployment.Environment }, nil } -// poolOpsV200 implements PoolOps using v2.0.0 token pool bindings. -// Only GetToken and Version are called at runtime (by the inherited -// DeriveTokenAddress and ManualRegistration); the other methods are -// stubs because the v2.0.0 adapter overrides the methods that use them. +// poolOpsV200 implements PoolOps using v2.0.0 bindings. type poolOpsV200 struct{} -func (p *poolOpsV200) GetToken(b cldf_ops.Bundle, ch evm.Chain, poolAddr common.Address) (common.Address, error) { +func (p *poolOpsV200) GetToken(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address) (common.Address, error) { res, err := cldf_ops.ExecuteOperation(b, - token_pool.GetToken, ch, + token_pool.GetToken, chain, contract.FunctionInput[struct{}]{ - ChainSelector: ch.Selector, + ChainSelector: chain.Selector, Address: poolAddr, }, ) @@ -587,88 +129,149 @@ func (p *poolOpsV200) GetToken(b cldf_ops.Bundle, ch evm.Chain, poolAddr common. return res.Output, nil } -func (p *poolOpsV200) GetTokenDecimals(_ context.Context, _ evm.Chain, _ common.Address) (uint8, error) { - return 0, errors.New("poolOpsV200.GetTokenDecimals: not used; v2.0.0 adapter overrides DeriveTokenDecimals") +func (p *poolOpsV200) GetTokenDecimals(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address) (uint8, error) { + res, err := cldf_ops.ExecuteOperation(b, + token_pool.GetTokenDecimals, chain, + contract.FunctionInput[struct{}]{ + ChainSelector: chain.Selector, + Address: poolAddr, + }, + ) + if err != nil { + return 0, fmt.Errorf("GetTokenDecimals v2.0.0: %w", err) + } + return res.Output, nil } -func (p *poolOpsV200) GetPoolAdmins(_ context.Context, _ *evm.Chain, _ common.Address) (common.Address, common.Address, error) { - return common.Address{}, common.Address{}, errors.New("poolOpsV200.GetPoolAdmins: not used; v2.0.0 adapter overrides SetTokenPoolRateLimits") +func (p *poolOpsV200) GetPoolAdmins(ctx context.Context, chain *evm.Chain, poolAddr common.Address) (common.Address, common.Address, error) { + pool, err := token_pool.NewTokenPoolContract(poolAddr, chain.Client) + if err != nil { + return common.Address{}, common.Address{}, fmt.Errorf("failed to instantiate token pool v2.0.0 contract at %s on chain %d: %w", poolAddr.Hex(), chain.Selector, err) + } + owner, err := pool.Owner(&bind.CallOpts{Context: ctx}) + if err != nil { + return common.Address{}, common.Address{}, fmt.Errorf("failed to get owner of token pool at %s on chain %d: %w", poolAddr.Hex(), chain.Selector, err) + } + cfg, err := pool.GetDynamicConfig(&bind.CallOpts{Context: ctx}) + if err != nil { + return common.Address{}, common.Address{}, fmt.Errorf("failed to get dynamic config of token pool at %s on chain %d: %w", poolAddr.Hex(), chain.Selector, err) + } + return owner, cfg.RateLimitAdmin, nil } func (p *poolOpsV200) SetRateLimiterConfig(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, input tokens.TPRLRemotes) ([]contract.WriteOutput, error) { - return nil, errors.New("poolOpsV200.SetRateLimiterConfig: not used; v2.0.0 adapter overrides SetTokenPoolRateLimits") -} + var writes []contract.WriteOutput + if !input.AllowedFinalityConfig.IsZero() { + currentFinalityConfig, err := cldf_ops.ExecuteOperation(b, token_pool.GetAllowedFinalityConfig, chain, contract.FunctionInput[struct{}]{ + ChainSelector: chain.Selector, + Address: poolAddr, + Args: struct{}{}, + }) + if err != nil { + return nil, fmt.Errorf("failed to get allowed finality config for token pool at %s on chain %d: %w", poolAddr.Hex(), chain.Selector, err) + } + if input.AllowedFinalityConfig.Raw() != currentFinalityConfig.Output { + setFinalityReport, err := cldf_ops.ExecuteOperation(b, token_pool.SetAllowedFinalityConfig, chain, contract.FunctionInput[[4]byte]{ + ChainSelector: chain.Selector, + Address: poolAddr, + Args: input.AllowedFinalityConfig.Raw(), + }) + if err != nil { + return nil, fmt.Errorf("failed to set allowed finality config on token pool at %s on chain %d: %w", poolAddr.Hex(), chain.Selector, err) + } + writes = append(writes, setFinalityReport.Output) + } + } -func (p *poolOpsV200) SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) (contract.WriteOutput, error) { - return contract.WriteOutput{}, errors.New("poolOpsV200.SetRateLimitAdmin: not used; v2.0.0 adapter overrides SetRateLimitAdmin") -} + args := make([]token_pool.RateLimitConfigArgs, 0, len(input.RateLimitBuckets)) + for _, bucket := range input.RateLimitBuckets { + args = append(args, token_pool.RateLimitConfigArgs{ + RemoteChainSelector: input.RemoteChainSelector, + FastFinality: bucket.FastFinality, + OutboundRateLimiterConfig: token_pool.Config{ + IsEnabled: bucket.OutboundRateLimiterConfig.IsEnabled, + Capacity: bucket.OutboundRateLimiterConfig.Capacity, + Rate: bucket.OutboundRateLimiterConfig.Rate, + }, + InboundRateLimiterConfig: token_pool.Config{ + IsEnabled: bucket.InboundRateLimiterConfig.IsEnabled, + Capacity: bucket.InboundRateLimiterConfig.Capacity, + Rate: bucket.InboundRateLimiterConfig.Rate, + }, + }) + } -func (p *poolOpsV200) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64) (tokens.RateLimiterConfig, error) { - return tokens.RateLimiterConfig{}, errors.New("poolOpsV200.GetCurrentInboundRateLimit: not used; v2.0.0 adapter overrides SetTokenPoolRateLimits and implements RateLimitReaderAdapter directly") -} + if len(args) > 0 { + rateLimitsReport, err := cldf_ops.ExecuteOperation(b, token_pool.SetRateLimitConfig, chain, contract.FunctionInput[[]token_pool.RateLimitConfigArgs]{ + ChainSelector: chain.Selector, + Address: poolAddr, + Args: args, + }) + if err != nil { + return nil, fmt.Errorf("failed to set rate limit config on pool %s: %w", poolAddr, err) + } + writes = append(writes, rateLimitsReport.Output) + } -func (p *poolOpsV200) Version() *semver.Version { - return cciputils.Version_2_0_0 -} + if len(writes) == 0 { + return nil, nil + } -func isBurnMintPoolType(poolType deployment.ContractType) bool { - return poolType == cciputils.BurnMintTokenPool || - poolType == cciputils.BurnFromMintTokenPool || - poolType == cciputils.BurnWithFromMintTokenPool + return writes, nil } -func isLockReleasePoolType(poolType deployment.ContractType) bool { - return poolType == cciputils.LockReleaseTokenPool || - poolType == siloed_lock_release_token_pool.ContractType -} +func (p *poolOpsV200) SetRateLimitAdmin(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, newAdmin common.Address) ([]contract.WriteOutput, error) { + pool, err := token_pool.NewTokenPoolContract(poolAddr, chain.Client) + if err != nil { + return nil, fmt.Errorf("failed to instantiate token pool v2.0.0 contract at %s on chain %d: %w", poolAddr.Hex(), chain.Selector, err) + } + cfg, err := pool.GetDynamicConfig(&bind.CallOpts{Context: b.GetContext()}) + if err != nil { + return nil, fmt.Errorf("failed to get dynamic config of token pool at %s on chain %d: %w", poolAddr.Hex(), chain.Selector, err) + } + if newAdmin == cfg.RateLimitAdmin { + b.Logger.Info("Rate limit admin is already set to the desired address; no update needed") + return nil, nil + } -func isBurnMintTokenType(typ datastore.ContractType) bool { - return typ.String() == bnmOps.ContractType.String() || - typ.String() == bnmDripOps.ContractType.String() || - typ.String() == bnmDripOps150.ContractType.String() || - isBurnMintERC677TokenType(typ) -} + report, err := cldf_ops.ExecuteOperation(b, + token_pool.SetDynamicConfig, chain, + contract.FunctionInput[token_pool.SetDynamicConfigArgs]{ + ChainSelector: chain.Selector, + Address: poolAddr, + Args: token_pool.SetDynamicConfigArgs{ + RateLimitAdmin: newAdmin, + FeeAdmin: cfg.FeeAdmin, + Router: cfg.Router, + }, + }, + ) + if err != nil { + return nil, fmt.Errorf("SetDynamicConfig v2.0.0: %w", err) + } -func isBurnMintERC677TokenType(typ datastore.ContractType) bool { - return typ.String() == cciputils.BurnMintToken.String() || - typ.String() == cciputils.ERC677TokenHelper.String() + return []contract.WriteOutput{report.Output}, nil } -// resolveRouterAddress returns the router address to wire into the pool. -// If routerRef is nil, the chain's production Router is looked up in the datastore. -// If routerRef.Address is non-empty, it is used directly (no datastore lookup). -// Otherwise the ref is resolved against the datastore; ChainSelector is forced to -// the target chain and Type defaults to the production Router when unset, so callers -// targeting the TestRouter only need to set Type=router.TestRouterContractType. -func resolveRouterAddress( - ds datastore.DataStore, - chainSelector uint64, - routerRef *datastore.AddressRef, -) (common.Address, error) { - ref := datastore.AddressRef{ - ChainSelector: chainSelector, - Type: datastore.ContractType(router.ContractType), - } - if routerRef != nil { - if routerRef.Address != "" { - if !common.IsHexAddress(routerRef.Address) { - return common.Address{}, fmt.Errorf("invalid RouterRef.Address %q: not a hex address", routerRef.Address) - } - addr := common.HexToAddress(routerRef.Address) - if addr == (common.Address{}) { - return common.Address{}, errors.New("RouterRef.Address resolves to the zero address") - } - return addr, nil - } - ref = *routerRef - ref.ChainSelector = chainSelector - if ref.Type == "" { - ref.Type = datastore.ContractType(router.ContractType) - } +func (p *poolOpsV200) GetCurrentInboundRateLimit(b cldf_ops.Bundle, chain evm.Chain, poolAddr common.Address, remoteSelector uint64, ff bool) (tokens.RateLimiterConfig, error) { + // Call the contract binding directly rather than cldf_ops Read: the framework caches read + // reports by input hash, and earlier sequences in the same Apply run may have read this + // same lane while it was still uninitialized — caching that stale result. + tp, err := tpBindingsV2_0_0.NewTokenPool(poolAddr, chain.Client) + if err != nil { + return tokens.RateLimiterConfig{}, fmt.Errorf("failed to instantiate v2.0.0 token pool contract at %s: %w", poolAddr.Hex(), err) } - resolved, err := datastore_utils.FindAndFormatRef(ds, ref, chainSelector, datastore_utils.FullRef) + state, err := tp.GetCurrentRateLimiterState(&bind.CallOpts{Context: b.GetContext()}, remoteSelector, ff) if err != nil { - return common.Address{}, fmt.Errorf("failed to find router (type=%q qualifier=%q) in datastore for chain %d: %w", ref.Type, ref.Qualifier, chainSelector, err) + return tokens.RateLimiterConfig{}, fmt.Errorf("failed to get inbound rate limiter state for remote chain %d (fastFinality=%v): %w", remoteSelector, ff, err) } - return common.HexToAddress(resolved.Address), nil + return tokens.RateLimiterConfig{ + IsEnabled: state.InboundRateLimiterState.IsEnabled, + Capacity: state.InboundRateLimiterState.Capacity, + Rate: state.InboundRateLimiterState.Rate, + }, nil +} + +func (p *poolOpsV200) Version() *semver.Version { + return cciputils.Version_2_0_0 } diff --git a/chains/evm/deployment/v2_0_0/sequences/cctp/configure_cctp_chain_for_lanes.go b/chains/evm/deployment/v2_0_0/sequences/cctp/configure_cctp_chain_for_lanes.go index 4d27f64c5a..bc6b570385 100644 --- a/chains/evm/deployment/v2_0_0/sequences/cctp/configure_cctp_chain_for_lanes.go +++ b/chains/evm/deployment/v2_0_0/sequences/cctp/configure_cctp_chain_for_lanes.go @@ -489,10 +489,12 @@ func buildRemoteChainConfigs(dep adapters.ConfigureCCTPChainForLanesDeps, input if err != nil { return nil, fmt.Errorf("failed to get remote token address: %w", err) } + + feeCfg := (tokens_core.PartialTokenTransferFeeConfig{}).Populate(remoteChain.TokenTransferFeeConfig) configs[remoteChainSelector] = tokens_core.RemoteChainConfig[[]byte, string]{ RemotePool: common.LeftPadBytes(remotePoolAddress, 32), RemoteToken: common.LeftPadBytes(remoteTokenAddress, 32), - TokenTransferFeeConfig: remoteChain.TokenTransferFeeConfig, + TokenTransferFeeConfig: &feeCfg, OutboundRateLimiterConfig: &remoteChain.OutboundRateLimiterConfig, InboundRateLimiterConfig: &remoteChain.InboundRateLimiterConfig, } diff --git a/chains/evm/deployment/v2_0_0/sequences/lombard/configure_lombard_chain_for_lanes.go b/chains/evm/deployment/v2_0_0/sequences/lombard/configure_lombard_chain_for_lanes.go index 004d35d023..7f11086864 100644 --- a/chains/evm/deployment/v2_0_0/sequences/lombard/configure_lombard_chain_for_lanes.go +++ b/chains/evm/deployment/v2_0_0/sequences/lombard/configure_lombard_chain_for_lanes.go @@ -121,10 +121,11 @@ var ConfigureLombardChainForLanes = cldf_ops.NewSequence( return sequences.OnChainOutput{}, fmt.Errorf("failed to get remote token address: %w", err) } + feeCfg := (tokens_core.PartialTokenTransferFeeConfig{}).Populate(remoteChain.TokenTransferFeeConfig) remoteChainConfigs[remoteChainSelector] = tokens_core.RemoteChainConfig[[]byte, string]{ RemotePool: common.LeftPadBytes(remotePoolAddress, 32), RemoteToken: common.LeftPadBytes(remoteTokenAddress, 32), - TokenTransferFeeConfig: remoteChain.TokenTransferFeeConfig, + TokenTransferFeeConfig: &feeCfg, // Lombard does not use rate limiters InboundRateLimiterConfig: &tokens_core.RateLimiterConfigFloatInput{ Capacity: 0, diff --git a/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_for_transfers_test.go b/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_for_transfers_test.go index f0ce86807f..913ba757ca 100644 --- a/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_for_transfers_test.go +++ b/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_for_transfers_test.go @@ -80,6 +80,7 @@ func TestConfigureTokenForTransfers(t *testing.T) { tokenAdminRegistryAddress := tokenAdminRegistryAddr.Hex() // Prepare input for configuring token for transfers + feeCfg := (tokens_core.PartialTokenTransferFeeConfig{}).Populate(testsetup.CreateBasicTokenTransferFeeConfig()) input := tokens_core.ConfigureTokenForTransfersInput{ ChainSelector: chainSel, TokenAddress: tokenAddress, @@ -92,7 +93,7 @@ func TestConfigureTokenForTransfers(t *testing.T) { OutboundRateLimiterConfig: testsetup.CreateRateLimiterConfigFloatInput(150, 1500), OutboundCCVs: []string{"0x789"}, InboundCCVs: []string{"0xabc"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, remoteChainSel2: { RemoteToken: common.LeftPadBytes(common.FromHex("0x321"), 32), @@ -101,7 +102,7 @@ func TestConfigureTokenForTransfers(t *testing.T) { OutboundRateLimiterConfig: testsetup.CreateRateLimiterConfigFloatInput(250, 2500), OutboundCCVs: []string{"0xdef"}, InboundCCVs: []string{"0x012"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, }, ExternalAdmin: "", // Use internal admin @@ -213,6 +214,7 @@ func TestConfigureTokenForTransfers(t *testing.T) { require.NoError(t, err, "Token admin registry should exist in datastore") tokenAdminRegistryAddress := tokenAdminRegistryAddr.Hex() + feeCfg := (tokens_core.PartialTokenTransferFeeConfig{}).Populate(testsetup.CreateBasicTokenTransferFeeConfig()) input := tokens_core.ConfigureTokenForTransfersInput{ ChainSelector: chainSel, TokenAddress: result.TokenAddress.Hex(), @@ -225,7 +227,7 @@ func TestConfigureTokenForTransfers(t *testing.T) { OutboundRateLimiterConfig: testsetup.CreateRateLimiterConfigFloatInput(600, 6000), OutboundCCVs: []string{"0x999"}, InboundCCVs: []string{"0xaa0"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, }, ExternalAdmin: "", diff --git a/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain.go b/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain.go index 50ac2ce7ae..b6fca599af 100644 --- a/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain.go +++ b/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain.go @@ -419,17 +419,15 @@ var ConfigureTokenPoolForRemoteChain = cldf_ops.NewSequence( } // Update token transfer fee configuration (after applyChainUpdates so chain exists on pool). - tokenTransferFeeConfigUpdates, err := makeTokenTransferFeeConfigUpdates(b, chain, input, input.RemoteChainSelector) + ttfcArgs, err := makeTokenTransferFeeConfigUpdates(b, chain, input, input.RemoteChainSelector) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to make token transfer fee config updates: %w", err) } - if len(tokenTransferFeeConfigUpdates) > 0 { + if len(ttfcArgs.DisableTokenTransferFeeConfigs) > 0 || len(ttfcArgs.TokenTransferFeeConfigArgs) > 0 { applyTokenTransferFeeConfigUpdatesReport, err := cldf_ops.ExecuteOperation(b, token_pool.ApplyTokenTransferFeeConfigUpdates, chain, evm_contract.FunctionInput[token_pool.ApplyTokenTransferFeeConfigUpdatesArgs]{ ChainSelector: input.ChainSelector, Address: input.TokenPoolAddress, - Args: token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{ - TokenTransferFeeConfigArgs: tokenTransferFeeConfigUpdates, - }, + Args: ttfcArgs, }) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to apply token transfer fee config updates: %w", err) @@ -752,20 +750,17 @@ func tokensRateLimiterToConfig(c tokens.RateLimiterConfig) token_pool.Config { } func applyTokenTransferFeeConfigIfNeeded(b cldf_ops.Bundle, chain evm.Chain, input ConfigureTokenPoolForRemoteChainInput, remoteChainSelector uint64) ([]evm_contract.WriteOutput, error) { - tokenTransferFeeConfigUpdates, err := makeTokenTransferFeeConfigUpdates(b, chain, input, remoteChainSelector) + ttfcArgs, err := makeTokenTransferFeeConfigUpdates(b, chain, input, remoteChainSelector) if err != nil { return nil, fmt.Errorf("failed to make token transfer fee config updates: %w", err) } - if len(tokenTransferFeeConfigUpdates) == 0 { + if len(ttfcArgs.DisableTokenTransferFeeConfigs) == 0 && len(ttfcArgs.TokenTransferFeeConfigArgs) == 0 { return nil, nil } report, err := cldf_ops.ExecuteOperation(b, token_pool.ApplyTokenTransferFeeConfigUpdates, chain, evm_contract.FunctionInput[token_pool.ApplyTokenTransferFeeConfigUpdatesArgs]{ ChainSelector: input.ChainSelector, Address: input.TokenPoolAddress, - Args: token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{ - TokenTransferFeeConfigArgs: tokenTransferFeeConfigUpdates, - DisableTokenTransferFeeConfigs: nil, - }, + Args: ttfcArgs, }) if err != nil { return nil, err @@ -982,76 +977,88 @@ func mergeTokenTransferFeeConfig(desired, imported *tokens.TokenTransferFeeConfi return &merged } -func makeTokenTransferFeeConfigUpdates(b cldf_ops.Bundle, chain evm.Chain, input ConfigureTokenPoolForRemoteChainInput, remoteChainSelector uint64) ([]token_pool.TokenTransferFeeConfigArgs, error) { +func makeTokenTransferFeeConfigUpdates(b cldf_ops.Bundle, chain evm.Chain, input ConfigureTokenPoolForRemoteChainInput, remoteChainSelector uint64) (token_pool.ApplyTokenTransferFeeConfigUpdatesArgs, error) { desiredTokenTransferFeeConfig := input.RemoteChainConfig.TokenTransferFeeConfig + if desiredTokenTransferFeeConfig == nil { + return token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{}, nil + } + importedConfig, err := importTokenTransferFeeConfigFromActivePool(b, chain, input) if err != nil { - return nil, fmt.Errorf("failed to import token transfer fee config from active pool: %w", err) - } - // merge imported config with desired config, giving precedence to desired config values when they are non-zero (i.e. non-default) - desiredTokenTransferFeeConfig = *mergeTokenTransferFeeConfig(&desiredTokenTransferFeeConfig, importedConfig) - if !desiredTokenTransferFeeConfig.IsEnabled { - return nil, nil + return token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{}, fmt.Errorf("failed to import token transfer fee config from active pool: %w", err) } + report, err := cldf_ops.ExecuteOperation(b, token_pool.GetTokenTransferFeeConfig, chain, evm_contract.FunctionInput[token_pool.GetTokenTransferFeeConfigArgs]{ ChainSelector: input.ChainSelector, Address: input.TokenPoolAddress, Args: token_pool.GetTokenTransferFeeConfigArgs{ - Arg0: common.Address{}, - DestChainSelector: remoteChainSelector, - Arg2: finality.RawWaitForFinality, - Arg3: []byte{}, + Arg0: common.Address{}, // unused + DestChainSelector: remoteChainSelector, // this IS used + Arg2: finality.RawWaitForFinality, // unused + Arg3: []byte{}, // unused }, }) if err != nil { - return nil, fmt.Errorf("failed to get token transfer fee config: %w", err) + return token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{}, fmt.Errorf("failed to get token transfer fee config: %w", err) } - currentTokenTransferFeeConfig := report.Output + defaultConfig := tokens.GetDefaultChainAgnosticTokenTransferFeeConfig( + input.ChainSelector, + input.RemoteChainSelector, + ) - // Fall back to on-chain values if inputted values are empty - if desiredTokenTransferFeeConfig.DestGasOverhead == 0 { - desiredTokenTransferFeeConfig.DestGasOverhead = currentTokenTransferFeeConfig.DestGasOverhead - } - if desiredTokenTransferFeeConfig.DestBytesOverhead == 0 { - desiredTokenTransferFeeConfig.DestBytesOverhead = currentTokenTransferFeeConfig.DestBytesOverhead - } - if desiredTokenTransferFeeConfig.DefaultFinalityFeeUSDCents == 0 { - desiredTokenTransferFeeConfig.DefaultFinalityFeeUSDCents = currentTokenTransferFeeConfig.FinalityFeeUSDCents - } - if desiredTokenTransferFeeConfig.CustomFinalityFeeUSDCents == 0 { - desiredTokenTransferFeeConfig.CustomFinalityFeeUSDCents = currentTokenTransferFeeConfig.FastFinalityFeeUSDCents + currentConfig := tokens.TokenTransferFeeConfig{ + DefaultFinalityTransferFeeBps: report.Output.FinalityTransferFeeBps, + CustomFinalityTransferFeeBps: report.Output.FastFinalityTransferFeeBps, + DefaultFinalityFeeUSDCents: report.Output.FinalityFeeUSDCents, + CustomFinalityFeeUSDCents: report.Output.FastFinalityFeeUSDCents, + DestBytesOverhead: report.Output.DestBytesOverhead, + DestGasOverhead: report.Output.DestGasOverhead, + IsEnabled: report.Output.IsEnabled, } - if desiredTokenTransferFeeConfig.DefaultFinalityTransferFeeBps == 0 { - desiredTokenTransferFeeConfig.DefaultFinalityTransferFeeBps = currentTokenTransferFeeConfig.FinalityTransferFeeBps - } - if desiredTokenTransferFeeConfig.CustomFinalityTransferFeeBps == 0 { - desiredTokenTransferFeeConfig.CustomFinalityTransferFeeBps = currentTokenTransferFeeConfig.FastFinalityTransferFeeBps + + // Resolution strategy: + // (1) If imported config is enabled, merge it with the user's provided config (giving precedence to user's config) + // (2) If on-chain config is enabled, merge it with the user's provided config (giving precedence to user's config) + // (3) Fall back to sensible defaults merged with user's provided config (giving precedence to user's config) + resolvedConfig := tokens.TokenTransferFeeConfig{} + switch { + case importedConfig != nil && importedConfig.IsEnabled: + resolvedConfig = desiredTokenTransferFeeConfig.MergeWith(*importedConfig) + case currentConfig.IsEnabled: + resolvedConfig = desiredTokenTransferFeeConfig.MergeWith(currentConfig) + default: + resolvedConfig = desiredTokenTransferFeeConfig.MergeWith(defaultConfig) } - updates := make([]token_pool.TokenTransferFeeConfigArgs, 0) + if resolvedConfig == currentConfig { + return token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{}, nil + } - if desiredTokenTransferFeeConfig.DestGasOverhead != currentTokenTransferFeeConfig.DestGasOverhead || - desiredTokenTransferFeeConfig.DestBytesOverhead != currentTokenTransferFeeConfig.DestBytesOverhead || - desiredTokenTransferFeeConfig.DefaultFinalityFeeUSDCents != currentTokenTransferFeeConfig.FinalityFeeUSDCents || - desiredTokenTransferFeeConfig.CustomFinalityFeeUSDCents != currentTokenTransferFeeConfig.FastFinalityFeeUSDCents || - desiredTokenTransferFeeConfig.DefaultFinalityTransferFeeBps != currentTokenTransferFeeConfig.FinalityTransferFeeBps || - desiredTokenTransferFeeConfig.CustomFinalityTransferFeeBps != currentTokenTransferFeeConfig.FastFinalityTransferFeeBps { - updates = append(updates, token_pool.TokenTransferFeeConfigArgs{ - DestChainSelector: remoteChainSelector, - TokenTransferFeeConfig: token_pool.TokenTransferFeeConfig{ - DestGasOverhead: desiredTokenTransferFeeConfig.DestGasOverhead, - DestBytesOverhead: desiredTokenTransferFeeConfig.DestBytesOverhead, - FinalityFeeUSDCents: desiredTokenTransferFeeConfig.DefaultFinalityFeeUSDCents, - FastFinalityFeeUSDCents: desiredTokenTransferFeeConfig.CustomFinalityFeeUSDCents, - FinalityTransferFeeBps: desiredTokenTransferFeeConfig.DefaultFinalityTransferFeeBps, - FastFinalityTransferFeeBps: desiredTokenTransferFeeConfig.CustomFinalityTransferFeeBps, - IsEnabled: true, + if !resolvedConfig.IsEnabled { + return token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{ + DisableTokenTransferFeeConfigs: []uint64{ + remoteChainSelector, }, - }) + }, nil + } else { + return token_pool.ApplyTokenTransferFeeConfigUpdatesArgs{ + TokenTransferFeeConfigArgs: []token_pool.TokenTransferFeeConfigArgs{ + { + DestChainSelector: remoteChainSelector, + TokenTransferFeeConfig: token_pool.TokenTransferFeeConfig{ + FastFinalityTransferFeeBps: resolvedConfig.CustomFinalityTransferFeeBps, + FastFinalityFeeUSDCents: resolvedConfig.CustomFinalityFeeUSDCents, + FinalityTransferFeeBps: resolvedConfig.DefaultFinalityTransferFeeBps, + FinalityFeeUSDCents: resolvedConfig.DefaultFinalityFeeUSDCents, + DestBytesOverhead: resolvedConfig.DestBytesOverhead, + DestGasOverhead: resolvedConfig.DestGasOverhead, + IsEnabled: resolvedConfig.IsEnabled, + }, + }, + }, + }, nil } - - return updates, nil } // makeCCVUpdates returns the CCV config update to apply for the remote chain, or (nil, false, nil) if on-chain diff --git a/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain_test.go b/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain_test.go index 98d4e0c411..bda617a43c 100644 --- a/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain_test.go +++ b/chains/evm/deployment/v2_0_0/sequences/tokens/configure_token_pool_for_remote_chain_test.go @@ -38,6 +38,7 @@ import ( ) func makeFirstPassInput(chainSel uint64, remoteChainSel uint64, tokenPoolAddress common.Address, advancedPoolHooksAddress common.Address) tokens.ConfigureTokenPoolForRemoteChainInput { + feeCfg := (tokens_core.PartialTokenTransferFeeConfig{}).Populate(testsetup.CreateBasicTokenTransferFeeConfig()) return tokens.ConfigureTokenPoolForRemoteChainInput{ ChainSelector: chainSel, TokenPoolAddress: tokenPoolAddress, @@ -53,7 +54,7 @@ func makeFirstPassInput(chainSel uint64, remoteChainSel uint64, tokenPoolAddress InboundCCVs: []string{"0xabc"}, OutboundCCVsToAddAboveThreshold: []string{"0xdef"}, InboundCCVsToAddAboveThreshold: []string{"0xace"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, } } @@ -384,6 +385,9 @@ func TestConfigureTokenPoolForRemoteChainUpgradeImport(t *testing.T) { t.Skipf("Token B active pool not set in registry (got %s, expected %s); RegisterToken batch may not execute in this env. Skipping upgrade import assertions.", getCfgReport.Output.TokenPool, poolBAddress) } + // Build token transfer fee config + feeCfg := (tokens_core.PartialTokenTransferFeeConfig{}).Populate(testsetup.CreateBasicTokenTransferFeeConfig()) + // Configure pool B (1.6.1) for remote chain with specific rate limits to be imported importedOutboundRate, importedOutboundCapacity := 111.0, 1111.0 importedInboundRate, importedInboundCapacity := 222.0, 2222.0 @@ -400,7 +404,7 @@ func TestConfigureTokenPoolForRemoteChainUpgradeImport(t *testing.T) { InboundCCVs: []string{"0xabc"}, OutboundCCVsToAddAboveThreshold: []string{"0xdef"}, InboundCCVsToAddAboveThreshold: []string{"0xace"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, } _, err = operations.ExecuteSequence( @@ -429,7 +433,7 @@ func TestConfigureTokenPoolForRemoteChainUpgradeImport(t *testing.T) { InboundCCVs: []string{"0xabc"}, OutboundCCVsToAddAboveThreshold: []string{"0xdef"}, InboundCCVsToAddAboveThreshold: []string{"0xace"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, } _, err = operations.ExecuteSequence( @@ -549,12 +553,14 @@ func TestConfigureTokenPoolForRemoteChainUpgradeImportLegacyInboundDecimals(t *t t.Skipf("active pool not set in registry (got %s, expected %s); skipping legacy import assertions", getCfgReport.Output.TokenPool, legacyPoolAddress) } + feeCfg := (tokens_core.PartialTokenTransferFeeConfig{}).Populate(testsetup.CreateBasicTokenTransferFeeConfig()) const ( importedInboundRate = 13.88 importedInboundCapacity = 50_000.0 importedOutboundRate = 7.77 importedOutboundCapacity = 10_000.0 ) + _, err = operations.ExecuteSequence( testsetup.BundleWithFreshReporter(e.OperationsBundle), v1_5_1_token_pool_sequences.ConfigureTokenPoolForRemoteChain, @@ -572,7 +578,7 @@ func TestConfigureTokenPoolForRemoteChainUpgradeImportLegacyInboundDecimals(t *t InboundCCVs: []string{"0xabc"}, OutboundCCVsToAddAboveThreshold: []string{"0xdef"}, InboundCCVsToAddAboveThreshold: []string{"0xace"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, }, ) @@ -603,7 +609,7 @@ func TestConfigureTokenPoolForRemoteChainUpgradeImportLegacyInboundDecimals(t *t InboundCCVs: []string{"0xabc"}, OutboundCCVsToAddAboveThreshold: []string{"0xdef"}, InboundCCVsToAddAboveThreshold: []string{"0xace"}, - TokenTransferFeeConfig: testsetup.CreateBasicTokenTransferFeeConfig(), + TokenTransferFeeConfig: &feeCfg, }, } _, err = operations.ExecuteSequence( diff --git a/chains/evm/deployment/v2_0_0/sequences/tokens/deploy_token_pool.go b/chains/evm/deployment/v2_0_0/sequences/tokens/deploy_token_pool.go new file mode 100644 index 0000000000..20f816aa20 --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/tokens/deploy_token_pool.go @@ -0,0 +1,238 @@ +package tokens + +import ( + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + adaptersV1_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/token_pool" + "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" +) + +var DeployTokenPool = cldf_ops.NewSequence( + "deploy-token-pool", + utils.Version_2_0_0, + "Deploy a 2.0.0 token pool for a token on an EVM chain", + func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input tokens.DeployTokenPoolInput) (sequences.OnChainOutput, error) { + chain, ok := chains.EVMChains()[input.ChainSelector] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not found in environment", input.ChainSelector) + } + + // Validate required deployment inputs + poolutil := adaptersV1_0_0.EVMTokenBase{} + if input.TokenPoolVersion == nil { + return sequences.OnChainOutput{}, errors.New("TokenPoolVersion is required") + } + if input.TokenRef == nil { + return sequences.OnChainOutput{}, errors.New("TokenRef is required") + } + + // Parse the token ref as an EVM address + tokenAddress, err := poolutil.ParseNonZeroAddressRef(input.ExistingDataStore, input.TokenRef.Clone(), chain.Selector) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve token address from ref: %w", err) + } + + // If no pool qualifier is provided, then fall back to using the token address + poolQualifier := input.TokenPoolQualifier + if poolQualifier == "" { + poolQualifier = tokenAddress.Hex() + } + + // NOTE: the datastore uses the type, selector, qualifier, and version of an address + // ref to uniquely identify records, so the query below should only match one record + // at most. If multiple records are returned, then this would indicate an issue with + // the datastore's data integrity. If no matches are returned, then the ref does not + // exist and we proceed with the deployment. + var tokenPoolAddress common.Address + matches := input.ExistingDataStore.Addresses().Filter( + datastore.AddressRefByType(datastore.ContractType(input.PoolType)), + datastore.AddressRefByChainSelector(chain.Selector), + datastore.AddressRefByQualifier(poolQualifier), + datastore.AddressRefByVersion(input.TokenPoolVersion), + ) + if len(matches) > 1 { + return sequences.OnChainOutput{}, fmt.Errorf( + "multiple token pools found in datastore with type '%s', version '%s', qualifier '%s' on chain with selector %d", + input.PoolType, input.TokenPoolVersion.String(), poolQualifier, chain.Selector, + ) + } + if len(matches) == 1 { + tokenPoolAddress = common.HexToAddress(matches[0].Address) + } + + // If the token pool is already deployed, then apply any dynamic configuration updates the + // caller gave (e.g. router, rate-limit admin, fee aggregator, additional-CCVs threshold). + // This allows the seq to be re-run idempotently with an updated config without needing to + // tear down and re-deploy the pool. + if tokenPoolAddress != (common.Address{}) { + b.Logger.Infof("Token pool already deployed on chain %d at address %q - updating dynamic pool config if needed", chain.Selector, tokenPoolAddress.Hex()) + configureInput := ConfigureTokenPoolInput{} + + // Populate configureInput with any dynamic config fields that the caller entered. + // Empty/zero values are ignored and result in no change to those fields on-chain. + if input.ThresholdAmountForAdditionalCCVs != "" { + threshold, ok := new(big.Int).SetString(input.ThresholdAmountForAdditionalCCVs, 10) + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("invalid ThresholdAmountForAdditionalCCVs '%s': must be a decimal integer string", input.ThresholdAmountForAdditionalCCVs) + } + report, err := cldf_ops.ExecuteOperation(b, + token_pool.GetAdvancedPoolHooks, chain, + contract.FunctionInput[struct{}]{ + ChainSelector: chain.Selector, + Address: tokenPoolAddress, + }, + ) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to read advanced pool hooks address from existing token pool %s on chain %d: %w", tokenPoolAddress, chain.Selector, err) + } + configureInput.ThresholdAmountForAdditionalCCVs = threshold + configureInput.AdvancedPoolHooks = report.Output + } + if input.RateLimitAdmin != "" { + if !common.IsHexAddress(input.RateLimitAdmin) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid RateLimitAdmin address '%s'", input.RateLimitAdmin) + } else { + configureInput.RateLimitAdmin = common.HexToAddress(input.RateLimitAdmin) + } + } + if input.FeeAggregator != "" { + if !common.IsHexAddress(input.FeeAggregator) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid FeeAggregator address '%s'", input.FeeAggregator) + } else { + configureInput.FeeAggregator = common.HexToAddress(input.FeeAggregator) + } + } + if input.RouterRef != nil { + if routerAddr, err := poolutil.ResolveRouterAddress(input.ExistingDataStore, chain.Selector, input.RouterRef); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve router address for ref (%s): %w", datastore_utils.SprintRef(*input.RouterRef), err) + } else { + configureInput.RouterAddress = routerAddr + } + } + + // If the caller did not provide any dynamic config fields to update, then + // skip the configure step and return early. + if configureInput == (ConfigureTokenPoolInput{}) { + return sequences.OnChainOutput{Addresses: matches}, nil + } else { + configureInput.TokenPoolAddress = tokenPoolAddress + configureInput.ChainSelector = chain.Selector + } + + // ConfigureTokenPool reads current values and only emits a write when they + // differ so reruns with the same inputs are no-ops. Fields that the caller + // leaves unset (zero/empty) retain their current on-chain values. + if report, err := cldf_ops.ExecuteSequence(b, ConfigureTokenPool, chain, configureInput); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to reconcile dynamic config for existing token pool %s on chain %d: %w", tokenPoolAddress, chain.Selector, err) + } else { + return sequences.OnChainOutput{ + Addresses: append(matches, report.Output.Addresses...), + BatchOps: report.Output.BatchOps, + }, nil + } + } + + // Infer pool deployment inputs + tokenDecimals, err := poolutil.ERC20Decimals(b, input.ExistingDataStore, chain, tokenAddress) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to get token decimals for token at address '%s': %w", tokenAddress, err) + } + rmnProxyAddr, err := poolutil.GetRMNProxyAddress(input.ExistingDataStore, chain.Selector) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve rmn proxy address for chain selector %d: %w", chain.Selector, err) + } + routerAddr, err := poolutil.ResolveRouterAddress(input.ExistingDataStore, chain.Selector, input.RouterRef) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to resolve router address for chain selector %d: %w", chain.Selector, err) + } + allowlist, err := poolutil.ParseAddressStrings(input.Allowlist) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to parse allowlist: %w", err) + } + + // Resolve pool configuration inputs + var rateLimitAdmin common.Address + if input.RateLimitAdmin != "" { + if !common.IsHexAddress(input.RateLimitAdmin) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid RateLimitAdmin address '%s'", input.RateLimitAdmin) + } else { + rateLimitAdmin = common.HexToAddress(input.RateLimitAdmin) + } + } + var feeAggregator common.Address + if input.FeeAggregator != "" { + if !common.IsHexAddress(input.FeeAggregator) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid FeeAggregator address '%s'", input.FeeAggregator) + } else { + feeAggregator = common.HexToAddress(input.FeeAggregator) + } + } + thresholdCCV := big.NewInt(0) + if input.ThresholdAmountForAdditionalCCVs != "" { + if threshold, ok := new(big.Int).SetString(input.ThresholdAmountForAdditionalCCVs, 10); !ok { + return sequences.OnChainOutput{}, fmt.Errorf("invalid ThresholdAmountForAdditionalCCVs '%s': must be a decimal integer string", input.ThresholdAmountForAdditionalCCVs) + } else { + thresholdCCV = threshold + } + } + + // Build the pool deployment input + tokenPoolType := datastore.ContractType(input.PoolType) + internalInput := DeployTokenPoolInput{ + TokenPoolVersion: input.TokenPoolVersion, + TokenPoolType: tokenPoolType, + ChainSel: chain.Selector, + TokenSymbol: poolQualifier, + RateLimitAdmin: rateLimitAdmin, + FeeAggregator: feeAggregator, + ThresholdAmountForAdditionalCCVs: thresholdCCV, + ConstructorArgs: ConstructorArgs{ + Token: tokenAddress, + Decimals: tokenDecimals, + RMNProxy: rmnProxyAddr, + Router: routerAddr, + }, + AdvancedPoolHooksConfig: AdvancedPoolHooksConfig{ + Allowlist: allowlist, + }, + } + + // Deploy the desired pool contract + switch { + case poolutil.IsLockReleasePoolType(tokenPoolType.String()): + if report, err := cldf_ops.ExecuteSequence(b, DeployLockReleaseTokenPool, chain, internalInput); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy lock release token pool on chain %d: %w", chain.Selector, err) + } else { + return sequences.OnChainOutput{ + Addresses: append(matches, report.Output.Addresses...), + BatchOps: report.Output.BatchOps, + }, nil + } + + case poolutil.IsBurnMintPoolType(tokenPoolType.String()): + if report, err := cldf_ops.ExecuteSequence(b, DeployBurnMintTokenPool, chain, internalInput); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy burn mint token pool on chain %d: %w", chain.Selector, err) + } else { + return sequences.OnChainOutput{ + Addresses: append(matches, report.Output.Addresses...), + BatchOps: report.Output.BatchOps, + }, nil + } + + default: + return sequences.OnChainOutput{}, fmt.Errorf("unsupported token pool type '%s' for chain with selector %d", input.PoolType, chain.Selector) + } + }, +) diff --git a/chains/evm/deployment/v2_0_0/sequences/tokens/types.go b/chains/evm/deployment/v2_0_0/sequences/tokens/types.go index ed6c9a8cd6..68793e1a7d 100644 --- a/chains/evm/deployment/v2_0_0/sequences/tokens/types.go +++ b/chains/evm/deployment/v2_0_0/sequences/tokens/types.go @@ -42,9 +42,14 @@ type DeployTokenPoolInput struct { TokenPoolType datastore.ContractType // TokenPoolVersion is the version of the token pool to deploy. TokenPoolVersion *semver.Version + // TokenSymbol is the symbol of the token to be configured. // This symbol will be stored in the returned AddressRef. + // + // TODO: this field is not named correctly - it should be renamed to `TokenPoolQualifier`. + // TokenSymbol string + // RateLimitAdmin is an additional address allowed to set rate limiters. // If left empty, setRateLimitAdmin will not be attempted. RateLimitAdmin common.Address diff --git a/chains/evm/go.mod b/chains/evm/go.mod index a770803f73..83fe12b355 100644 --- a/chains/evm/go.mod +++ b/chains/evm/go.mod @@ -14,7 +14,7 @@ require ( github.com/smartcontractkit/ccip-contract-examples/chains/evm v0.0.0-20250826190403-aed7f5f33cde github.com/smartcontractkit/ccip-owner-contracts v0.1.0 github.com/smartcontractkit/chain-selectors v1.0.98 - github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260520205139-e02dace3eefa + github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260526052449-0ceed63f1a5a github.com/smartcontractkit/chainlink-ccv v0.0.1 github.com/smartcontractkit/chainlink-ccv/deployment v0.0.2-0.20260518113836-b4e2fcbb6799 github.com/smartcontractkit/chainlink-common v0.11.2-0.20260417081611-8bdbd9f45629 diff --git a/chains/solana/contracts/programs/rmn-remote/src/config.rs b/chains/solana/contracts/programs/rmn-remote/src/config.rs index a26387ccc0..a4e70fd597 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/config.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/config.rs @@ -3,69 +3,84 @@ use std::cell::Ref; use anchor_lang::prelude::*; use anchor_lang::Discriminator; -use crate::context::ANCHOR_DISCRIMINATOR; use crate::state::{CodeVersion, Config}; use crate::RmnRemoteError; -#[derive(AnchorDeserialize, InitSpace, Debug)] -pub(super) struct ConfigV1 { +#[derive(AnchorSerialize, AnchorDeserialize, InitSpace, Debug)] +pub struct ConfigV2 { + // --- v1 fields --- pub version: u8, pub owner: Pubkey, pub proposed_owner: Pubkey, pub default_code_version: CodeVersion, -} - -impl TryFrom for Config { - type Error = anchor_lang::error::Error; - fn try_from(v1: ConfigV1) -> std::result::Result { - require_eq!( - v1.version, - 1, // this deserialization is only valid for v1 - RmnRemoteError::InvalidInputsConfigAccount - ); + // --- v2 fields --- + // Using max_len for INIT_SPACE calculation. At the time of this migration, there is a single entry here + #[max_len(1)] + pub event_authorities: Vec, +} - Ok(Config { - version: 1, - owner: v1.owner, - proposed_owner: v1.proposed_owner, - default_code_version: v1.default_code_version, - event_authorities: vec![], // this is not part of the v1 data, it defaults to empty - }) +impl From for Config { + fn from(v2: ConfigV2) -> Config { + Config { + version: 2, + owner: v2.owner, + proposed_owner: v2.proposed_owner, + default_code_version: v2.default_code_version, + event_authorities: v2.event_authorities, + curser: Pubkey::default(), // added in v3, defaults to empty (unset) + bump: 0, // added in v3, defaults to 0 + } } } pub(super) fn load_config(config: &AccountInfo<'_>) -> Result { let borrowed_data = config.try_borrow_data()?; - let (discriminator, data) = borrowed_data.split_at(ANCHOR_DISCRIMINATOR); - require!( - Config::DISCRIMINATOR == discriminator, - RmnRemoteError::InvalidInputsConfigAccount - ); + // version is the first byte of the data after the discriminator + let version_byte = borrowed_data[Config::DISCRIMINATOR.len()]; - let version_byte = data[0]; // version is the first byte of the data (after the discriminator) + match version_byte { + 2 => { + // this assumes a single entry in the event_authorities vec, which is true at the time of this migration, + // which will only be executed once. + const V2_SPACE: usize = Config::DISCRIMINATOR.len() + ConfigV2::INIT_SPACE; - const V1_SPACE: usize = ANCHOR_DISCRIMINATOR + ConfigV1::INIT_SPACE; - if config.data_len() == V1_SPACE && version_byte == 1 { - let config_v1 = load_config_v1_unchecked(borrowed_data)?; - return Config::try_from(config_v1); - } + require_eq!( + config.data_len(), + V2_SPACE, + RmnRemoteError::InvalidInputsConfigAccount + ); + let old_config = load_old_config::(borrowed_data)?; + Ok(Config::from(old_config)) + } + Config::LATEST_VERSION => { + const MIN_SPACE: usize = Config::DISCRIMINATOR.len() + Config::INIT_SPACE; - // Use >= for the size because the event_authorities vec makes the size variable - const V2_MIN_SPACE: usize = ANCHOR_DISCRIMINATOR + Config::INIT_SPACE; - if config.data_len() >= V2_MIN_SPACE && version_byte == 2 { - let mut data: &[u8] = &borrowed_data; - return Config::try_deserialize(&mut data); + require_gte!( + config.data_len(), + MIN_SPACE, + RmnRemoteError::InvalidInputsConfigAccount + ); + let mut data: &[u8] = &borrowed_data; + Config::try_deserialize(&mut data) + } + _ => Err(RmnRemoteError::InvalidInputsConfigAccount.into()), } - - Err(RmnRemoteError::InvalidInputsConfigAccount.into()) } -pub(super) fn load_config_v1_unchecked(borrowed_data: Ref<&mut [u8]>) -> Result { - let (_discriminator, data) = borrowed_data.split_at(ANCHOR_DISCRIMINATOR); - let config_v1 = ConfigV1::deserialize(&mut &data[..]) - .map_err(|_| RmnRemoteError::InvalidInputsConfigAccount)?; - Ok(config_v1) +pub(super) fn load_old_config(borrowed_data: Ref<&mut [u8]>) -> Result +where + T: AnchorDeserialize, + Config: From, +{ + let (discriminator, data) = borrowed_data.split_at(Config::DISCRIMINATOR.len()); + require!( + Config::DISCRIMINATOR == discriminator, + RmnRemoteError::InvalidInputsConfigAccount + ); + let old = + T::deserialize(&mut &data[..]).map_err(|_| RmnRemoteError::InvalidInputsConfigAccount)?; + Ok(old) } diff --git a/chains/solana/contracts/programs/rmn-remote/src/context.rs b/chains/solana/contracts/programs/rmn-remote/src/context.rs index 15a71cf601..533a4881b8 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/context.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/context.rs @@ -1,7 +1,9 @@ use anchor_lang::prelude::*; use ccip_common::seed; -use crate::{program::RmnRemote, Config, CurseSubject, Curses, RmnRemoteError}; +use crate::{ + config::load_config, program::RmnRemote, Config, CurseSubject, Curses, RmnRemoteError, +}; /// Static space allocated to any account: must always be added to space calculations. pub const ANCHOR_DISCRIMINATOR: usize = 8; @@ -22,7 +24,7 @@ pub fn uninitialized(v: u8) -> bool { /// Maximum acceptable config version accepted by this module: any accounts with higher /// version numbers than this will be rejected. -pub const MAX_CONFIG_V: u8 = 2; +pub const MAX_CONFIG_V: u8 = 3; pub const MAX_CURSES_V: u8 = 1; #[derive(Accounts)] @@ -69,8 +71,8 @@ pub struct UpdateConfig<'info> { #[account( mut, seeds = [seed::CONFIG], - bump, - constraint = valid_version(config.version, MAX_CONFIG_V) @ RmnRemoteError::InvalidVersion, + bump = config.bump, + constraint = config.version == MAX_CONFIG_V @ RmnRemoteError::InvalidVersion, )] pub config: Account<'info, Config>, @@ -92,8 +94,8 @@ pub struct UpdateEventAuthorities<'info> { #[account( mut, seeds = [seed::CONFIG], - bump, - constraint = version_in_range(config.version, 2, MAX_CONFIG_V) @ RmnRemoteError::InvalidVersion, + bump = config.bump, + constraint = config.version == MAX_CONFIG_V @ RmnRemoteError::InvalidVersion, realloc = ANCHOR_DISCRIMINATOR + Config::dynamic_len(new_event_authorities.len()), realloc::payer = authority, realloc::zero = false, @@ -107,7 +109,7 @@ pub struct UpdateEventAuthorities<'info> { } #[derive(Accounts)] -pub struct MigrateConfigV1ToV2<'info> { +pub struct MigrateConfigV2ToV3<'info> { #[account( mut, seeds = [seed::CONFIG], @@ -129,8 +131,8 @@ pub struct AcceptOwnership<'info> { #[account( mut, seeds = [seed::CONFIG], - bump, - constraint = valid_version(config.version, MAX_CONFIG_V) @ RmnRemoteError::InvalidVersion, + bump = config.bump, + constraint = config.version == MAX_CONFIG_V @ RmnRemoteError::InvalidVersion, )] pub config: Account<'info, Config>, @@ -141,15 +143,22 @@ pub struct AcceptOwnership<'info> { #[derive(Accounts)] pub struct Curse<'info> { + /// CHECK: Unchecked by Anchor as we will be migrating the Config account in-place. Thus, we will be loading the + /// account manually to handle the different structs before and after migration., allowing for no-downtime migration. #[account( seeds = [seed::CONFIG], bump, - constraint = valid_version(config.version, MAX_CONFIG_V) @ RmnRemoteError::InvalidVersion, + owner = crate::ID, // check it is initialized )] - pub config: Account<'info, Config>, + pub config: UncheckedAccount<'info>, - // validate signer is registered admin - #[account(mut, address = config.owner @ RmnRemoteError::Unauthorized)] + #[account( + mut, + constraint = { + let config_data = load_config(&config).unwrap(); + authority.key() == config_data.curser || authority.key() == config_data.owner + } @ RmnRemoteError::Unauthorized) + ] pub authority: Signer<'info>, #[account( @@ -168,15 +177,17 @@ pub struct Curse<'info> { #[derive(Accounts)] pub struct Uncurse<'info> { + /// CHECK: Unchecked by Anchor as we will be migrating the Config account in-place. Thus, we will be loading the + /// account manually to handle the different structs before and after migration., allowing for no-downtime migration. #[account( seeds = [seed::CONFIG], bump, - constraint = valid_version(config.version, MAX_CONFIG_V) @ RmnRemoteError::InvalidVersion, + owner = crate::ID, // check it is initialized )] - pub config: Account<'info, Config>, + pub config: UncheckedAccount<'info>, // validate signer is registered admin - #[account(mut, address = config.owner @ RmnRemoteError::Unauthorized)] + #[account(mut, address = load_config(&config).unwrap().owner @ RmnRemoteError::Unauthorized)] pub authority: Signer<'info>, #[account( @@ -207,8 +218,7 @@ pub struct InspectCurses<'info> { bump, owner = crate::ID, // check it is initialized, can be removed when using Account<'info, Config> )] - /// CHECK: using UncheckedAccount to allow no-downtime during config upgrade, so load using load_config method. - /// After the upgrade is made, this can be changed to Account<'info, Config>. + /// CHECK: using UncheckedAccount to allow no-downtime during config upgrade. pub config: UncheckedAccount<'info>, } @@ -217,12 +227,14 @@ pub struct CpiEvent<'info> { #[account( seeds = [seed::CONFIG], bump, - constraint = version_in_range(config.version, 2, MAX_CONFIG_V) @ RmnRemoteError::InvalidVersion, + owner = crate::ID, // check it is initialized )] - pub config: Account<'info, Config>, + /// CHECK: using UncheckedAccount to allow no-downtime during config upgrade, so load using load_config method. + /// After the upgrade is made, this can be changed to Account<'info, Config>. + pub config: UncheckedAccount<'info>, #[account( - constraint = config.event_authorities.contains(authority.key) @ RmnRemoteError::Unauthorized, + constraint = load_config(&config).unwrap().event_authorities.contains(authority.key) @ RmnRemoteError::Unauthorized, )] pub authority: Signer<'info>, } diff --git a/chains/solana/contracts/programs/rmn-remote/src/event.rs b/chains/solana/contracts/programs/rmn-remote/src/event.rs index d3ef7b8410..fdd4564e67 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/event.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/event.rs @@ -33,3 +33,8 @@ pub struct SubjectUncursed { pub struct EventAuthoritiesSet { pub event_authorities: Vec, } + +#[event] +pub struct CurserSet { + pub curser: Pubkey, +} diff --git a/chains/solana/contracts/programs/rmn-remote/src/instructions/interfaces.rs b/chains/solana/contracts/programs/rmn-remote/src/instructions/interfaces.rs index 92d93bc1e7..c32846783f 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/instructions/interfaces.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/instructions/interfaces.rs @@ -1,13 +1,13 @@ use anchor_lang::prelude::*; -use crate::context::{MigrateConfigV1ToV2, UpdateEventAuthorities}; +use crate::context::{MigrateConfigV2ToV3, UpdateEventAuthorities}; use crate::state::CodeVersion; use crate::{AcceptOwnership, Curse, CurseSubject, InspectCurses, Uncurse, UpdateConfig}; pub trait Public { fn verify_not_cursed(&self, ctx: Context, subject: CurseSubject) -> Result<()>; - fn migrate_config_v1_to_v2(&self, ctx: Context) -> Result<()>; + fn migrate_config_v2_to_v3(&self, ctx: Context) -> Result<()>; } pub trait Admin { @@ -29,4 +29,6 @@ pub trait Admin { ctx: Context, new_event_authorities: Vec, ) -> Result<()>; + + fn set_curser(&self, ctx: Context, curser: Pubkey) -> Result<()>; } diff --git a/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/admin.rs b/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/admin.rs index c81895719e..ba419d6921 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/admin.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/admin.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use crate::context::UpdateEventAuthorities; -use crate::event::EventAuthoritiesSet; +use crate::event::{CurserSet, EventAuthoritiesSet}; use crate::instructions::interfaces::Admin; use crate::{ AcceptOwnership, CodeVersion, ConfigSet, Curse, CurseSubject, OwnershipTransferRequested, @@ -98,4 +98,13 @@ impl Admin for Impl { Ok(()) } + + fn set_curser(&self, ctx: Context, curser: Pubkey) -> Result<()> { + ctx.accounts.config.curser = curser; + emit!(CurserSet { + curser: ctx.accounts.config.curser, + }); + + Ok(()) + } } diff --git a/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/public.rs b/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/public.rs index 4515db99ac..a9c763a060 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/public.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/instructions/v1/public.rs @@ -1,9 +1,10 @@ +use std::cell::Ref; + use anchor_lang::prelude::*; use anchor_lang::system_program; -use crate::config::load_config_v1_unchecked; -use crate::config::ConfigV1; -use crate::context::{MigrateConfigV1ToV2, ANCHOR_DISCRIMINATOR}; +use crate::config::{load_old_config, ConfigV2}; +use crate::context::{MigrateConfigV2ToV3, ANCHOR_DISCRIMINATOR}; use crate::instructions::interfaces::Public; use crate::state::Config; use crate::{CurseSubject, Curses, InspectCurses, RmnRemoteError}; @@ -28,18 +29,21 @@ impl Public for Impl { Ok(()) } - fn migrate_config_v1_to_v2(&self, ctx: Context) -> Result<()> { - let required_v2_space = ANCHOR_DISCRIMINATOR + Config::INIT_SPACE; - let minimum_balance = Rent::get()?.minimum_balance(required_v2_space); - + fn migrate_config_v2_to_v3(&self, ctx: Context) -> Result<()> { let account_info = &ctx.accounts.config.to_account_info(); - require_eq!( account_info.data_len(), - ANCHOR_DISCRIMINATOR + ConfigV1::INIT_SPACE, // it's v1 space + ANCHOR_DISCRIMINATOR + ConfigV2::INIT_SPACE, // it's v2 space RmnRemoteError::InvalidInputsConfigAccount ); + let new_config = old_to_new(account_info.try_borrow_data()?, ctx.bumps.config)?; + msg!("Computed new config: {:?}", new_config); + + let required_space = + ANCHOR_DISCRIMINATOR + Config::dynamic_len(new_config.event_authorities.len()); + let minimum_balance = Rent::get()?.minimum_balance(required_space); + // Extend the account msg!("Extending RMNRemote Config account..."); let current_lamports = account_info.lamports(); @@ -55,31 +59,41 @@ impl Public for Impl { minimum_balance.checked_sub(current_lamports).unwrap(), )?; } - account_info.realloc(required_v2_space, false)?; - - // Set the new values - msg!("Loading config V1..."); - let config_v1 = load_config_v1_unchecked(account_info.try_borrow_data()?)?; - msg!("Read config V1: {:?}", config_v1); - require_eq!( - config_v1.version, - 1, // confirm it was v1 - RmnRemoteError::InvalidInputsConfigAccount - ); - - let mut config: Config = config_v1.try_into()?; - - config.version = 2; // migrate to version 2 - config.event_authorities = vec![]; // backwards-compatible default, so the migration can be permissionless + account_info.realloc(required_space, false)?; // Write back to permanent state - msg!("Writing migrated RMNRemote Config v2 to account..."); - config.try_serialize(&mut &mut ctx.accounts.config.try_borrow_mut_data()?[..])?; + msg!("Writing migrated RMNRemote Config to account..."); + new_config.try_serialize(&mut &mut ctx.accounts.config.try_borrow_mut_data()?[..])?; Ok(()) } } +fn old_to_new(bytes: Ref<&mut [u8]>, bump: u8) -> Result { + msg!("Loading config V2..."); + let config_v2 = load_old_config::(bytes)?; + msg!("Read config V2: {:?}", config_v2); + + require_eq!( + config_v2.version, + 2, // confirm it was v2 + RmnRemoteError::InvalidInputsConfigAccount + ); + + let new_config = Config { + version: Config::LATEST_VERSION, + owner: config_v2.owner, + proposed_owner: config_v2.proposed_owner, + default_code_version: config_v2.default_code_version, + event_authorities: config_v2.event_authorities, + + // new v3 fields + curser: config_v2.owner, // initialize to same value as owner, so there is no downtime on cursing + bump, + }; + Ok(new_config) +} + fn is_subject_cursed(curses: &Curses, subject: CurseSubject) -> bool { curses.cursed_subjects.contains(&subject) } @@ -87,3 +101,50 @@ fn is_subject_cursed(curses: &Curses, subject: CurseSubject) -> bool { fn is_chain_globally_cursed(curses: &Curses) -> bool { curses.cursed_subjects.contains(&CurseSubject::GLOBAL) } + +#[cfg(test)] +mod tests { + use crate::state::CodeVersion; + use anchor_lang::Discriminator; + use std::cell::RefCell; + + use super::*; + + #[test] + fn test_v2_to_v3() { + let owner = Pubkey::new_unique(); + let proposed_owner = Pubkey::new_unique(); + let event_authority = Pubkey::new_unique(); + + let old = ConfigV2 { + version: 2, + owner, + proposed_owner, + default_code_version: CodeVersion::V1, + event_authorities: vec![event_authority], + }; + + // Prepend the discriminator: load_old_config_unchecked expects [discriminator | data] + let mut old_bytes = Config::DISCRIMINATOR.to_vec(); + old_bytes.extend_from_slice(&old.try_to_vec().unwrap()); + + // Wrap in RefCell to obtain a Ref<&mut [u8]> (same type as AccountInfo::try_borrow_data) + let rc: RefCell<&mut [u8]> = RefCell::new(&mut old_bytes); + let data = rc.borrow(); + let bump = 255; + let migrated = old_to_new(data, bump).unwrap(); + + assert_eq!( + migrated, + Config { + version: 3, // hardcode expected version, so that if LATEST_VERSION is updated without updating this test, the test will fail and alert us to update the migration logic as well + owner, + proposed_owner, + default_code_version: CodeVersion::V1, + event_authorities: vec![event_authority], + curser: owner, // curser initialized to same value as owner + bump, + } + ); + } +} diff --git a/chains/solana/contracts/programs/rmn-remote/src/lib.rs b/chains/solana/contracts/programs/rmn-remote/src/lib.rs index 0c7f1f6aec..2821525f1b 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/lib.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/lib.rs @@ -31,14 +31,19 @@ pub mod rmn_remote { /// * `ctx` - The context containing the accounts required for initialization. pub fn initialize(ctx: Context) -> Result<()> { ctx.accounts.config.set_inner(Config { + version: Config::LATEST_VERSION, owner: ctx.accounts.authority.key(), - version: 2, proposed_owner: Pubkey::default(), default_code_version: CodeVersion::V1, event_authorities: vec![], + curser: ctx.accounts.authority.key(), // initialize the curser as the admin, so that they can perform curses right away if needed + bump: ctx.bumps.config, // initialize the bump }); - ctx.accounts.curses.version = 1; + ctx.accounts.curses.set_inner(Curses { + version: 1, + cursed_subjects: vec![], + }); emit!(ConfigSet { default_code_version: ctx.accounts.config.default_code_version, @@ -108,7 +113,8 @@ pub mod rmn_remote { /// * `ctx` - The context containing the accounts required for adding a new curse. /// * `subject` - The subject to curse. pub fn curse(ctx: Context, subject: CurseSubject) -> Result<()> { - router::admin(ctx.accounts.config.default_code_version).curse(ctx, subject) + let config = load_config(&ctx.accounts.config)?; + router::admin(config.default_code_version).curse(ctx, subject) } /// Uncurses an abstract subject. If the subject is CurseSubject::GLOBAL, @@ -122,7 +128,8 @@ pub mod rmn_remote { /// * `ctx` - The context containing the accounts required for removing a curse. /// * `subject` - The subject to uncurse. pub fn uncurse(ctx: Context, subject: CurseSubject) -> Result<()> { - router::admin(ctx.accounts.config.default_code_version).uncurse(ctx, subject) + let config = load_config(&ctx.accounts.config)?; + router::admin(config.default_code_version).uncurse(ctx, subject) } /// Overwrites the list of addresses authorized to invoke the `cpi_event` instruction. @@ -140,6 +147,18 @@ pub mod rmn_remote { router::admin(code_version).set_event_authorities(ctx, new_event_authorities) } + /// Sets the address for the curser role who, alongside the contract owner, is authorized to curse. + /// + /// Only the CCIP Admin may perform this operation. + /// + /// # Arguments + /// * `ctx` - The context containing the accounts required for updating event authorities. + /// * `curser` - The new curser public key. + pub fn set_curser(ctx: Context, curser: Pubkey) -> Result<()> { + let code_version = ctx.accounts.config.default_code_version; + router::admin(code_version).set_curser(ctx, curser) + } + /// Verifies that the subject is not cursed AND that this chain is not globally cursed. /// In case either of those assumptions fail, the instruction reverts. /// @@ -178,9 +197,9 @@ pub mod rmn_remote { /// # Arguments /// /// * `ctx` - The context containing the accounts required for the migration. - pub fn migrate_config_v1_to_v2(ctx: Context) -> Result<()> { + pub fn migrate_config_v2_to_v3(ctx: Context) -> Result<()> { let code_version = load_config(&ctx.accounts.config)?.default_code_version; - router::public(code_version).migrate_config_v1_to_v2(ctx) + router::public(code_version).migrate_config_v2_to_v3(ctx) } } diff --git a/chains/solana/contracts/programs/rmn-remote/src/state.rs b/chains/solana/contracts/programs/rmn-remote/src/state.rs index 91df75d520..d009d48651 100644 --- a/chains/solana/contracts/programs/rmn-remote/src/state.rs +++ b/chains/solana/contracts/programs/rmn-remote/src/state.rs @@ -55,7 +55,7 @@ impl Display for CodeVersion { } #[account] -#[derive(InitSpace, Debug)] +#[derive(InitSpace, PartialEq, Debug)] pub struct Config { pub version: u8, pub owner: Pubkey, @@ -63,11 +63,18 @@ pub struct Config { pub proposed_owner: Pubkey, pub default_code_version: CodeVersion, + // --- v3 fields below --- + pub curser: Pubkey, // the only account authorized to curse subjects, besides the owner + pub bump: u8, + + // --- v2 fields below (keeping variable-length fields at the end) --- #[max_len(0)] // just for INIT_SPACE calculation - pub event_authorities: Vec, + pub event_authorities: Vec, // added in v2 struct } impl Config { + pub const LATEST_VERSION: u8 = 3; + pub fn dynamic_len(event_authorities_len: usize) -> usize { Config::INIT_SPACE + event_authorities_len * PUBKEY_BYTES } diff --git a/chains/solana/contracts/target/idl/rmn_remote.json b/chains/solana/contracts/target/idl/rmn_remote.json index 40ed62e6c7..06ffc07839 100644 --- a/chains/solana/contracts/target/idl/rmn_remote.json +++ b/chains/solana/contracts/target/idl/rmn_remote.json @@ -184,7 +184,10 @@ { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "account manually to handle the different structs before and after migration., allowing for no-downtime migration." + ] }, { "name": "authority", @@ -229,7 +232,10 @@ { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "account manually to handle the different structs before and after migration., allowing for no-downtime migration." + ] }, { "name": "authority", @@ -293,6 +299,41 @@ } ] }, + { + "name": "setCurser", + "docs": [ + "Sets the address for the curser role who, alongside the contract owner, is authorized to curse.", + "", + "Only the CCIP Admin may perform this operation.", + "", + "# Arguments", + "* `ctx` - The context containing the accounts required for updating event authorities.", + "* `curser` - The new curser public key." + ], + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "curses", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "curser", + "type": "publicKey" + } + ] + }, { "name": "verifyNotCursed", "docs": [ @@ -314,10 +355,7 @@ { "name": "config", "isMut": false, - "isSigner": false, - "docs": [ - "After the upgrade is made, this can be changed to Account<'info, Config>." - ] + "isSigner": false } ], "args": [ @@ -348,7 +386,10 @@ { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "After the upgrade is made, this can be changed to Account<'info, Config>." + ] }, { "name": "authority", @@ -364,7 +405,7 @@ ] }, { - "name": "migrateConfigV1ToV2", + "name": "migrateConfigV2ToV3", "docs": [ "Extends the Config PDA to allocate space for v2 fields, and migrates the onchain state", "from v1 to v2. This is a permissionless operation, as the default values set for the new", @@ -420,6 +461,14 @@ "defined": "CodeVersion" } }, + { + "name": "curser", + "type": "publicKey" + }, + { + "name": "bump", + "type": "u8" + }, { "name": "eventAuthorities", "type": { @@ -451,6 +500,38 @@ } ], "types": [ + { + "name": "ConfigV2", + "type": { + "kind": "struct", + "fields": [ + { + "name": "version", + "type": "u8" + }, + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "proposedOwner", + "type": "publicKey" + }, + { + "name": "defaultCodeVersion", + "type": { + "defined": "CodeVersion" + } + }, + { + "name": "eventAuthorities", + "type": { + "vec": "publicKey" + } + } + ] + } + }, { "name": "CurseSubject", "docs": [ @@ -570,6 +651,16 @@ "index": false } ] + }, + { + "name": "CurserSet", + "fields": [ + { + "name": "curser", + "type": "publicKey", + "index": false + } + ] } ], "errors": [ diff --git a/chains/solana/contracts/target/types/rmn_remote.ts b/chains/solana/contracts/target/types/rmn_remote.ts index 21dd03119b..575c445eac 100644 --- a/chains/solana/contracts/target/types/rmn_remote.ts +++ b/chains/solana/contracts/target/types/rmn_remote.ts @@ -184,7 +184,10 @@ export type RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "account manually to handle the different structs before and after migration., allowing for no-downtime migration." + ] }, { "name": "authority", @@ -229,7 +232,10 @@ export type RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "account manually to handle the different structs before and after migration., allowing for no-downtime migration." + ] }, { "name": "authority", @@ -293,6 +299,41 @@ export type RmnRemote = { } ] }, + { + "name": "setCurser", + "docs": [ + "Sets the address for the curser role who, alongside the contract owner, is authorized to curse.", + "", + "Only the CCIP Admin may perform this operation.", + "", + "# Arguments", + "* `ctx` - The context containing the accounts required for updating event authorities.", + "* `curser` - The new curser public key." + ], + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "curses", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "curser", + "type": "publicKey" + } + ] + }, { "name": "verifyNotCursed", "docs": [ @@ -314,10 +355,7 @@ export type RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false, - "docs": [ - "After the upgrade is made, this can be changed to Account<'info, Config>." - ] + "isSigner": false } ], "args": [ @@ -348,7 +386,10 @@ export type RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "After the upgrade is made, this can be changed to Account<'info, Config>." + ] }, { "name": "authority", @@ -364,7 +405,7 @@ export type RmnRemote = { ] }, { - "name": "migrateConfigV1ToV2", + "name": "migrateConfigV2ToV3", "docs": [ "Extends the Config PDA to allocate space for v2 fields, and migrates the onchain state", "from v1 to v2. This is a permissionless operation, as the default values set for the new", @@ -420,6 +461,14 @@ export type RmnRemote = { "defined": "CodeVersion" } }, + { + "name": "curser", + "type": "publicKey" + }, + { + "name": "bump", + "type": "u8" + }, { "name": "eventAuthorities", "type": { @@ -451,6 +500,38 @@ export type RmnRemote = { } ], "types": [ + { + "name": "ConfigV2", + "type": { + "kind": "struct", + "fields": [ + { + "name": "version", + "type": "u8" + }, + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "proposedOwner", + "type": "publicKey" + }, + { + "name": "defaultCodeVersion", + "type": { + "defined": "CodeVersion" + } + }, + { + "name": "eventAuthorities", + "type": { + "vec": "publicKey" + } + } + ] + } + }, { "name": "CurseSubject", "docs": [ @@ -570,6 +651,16 @@ export type RmnRemote = { "index": false } ] + }, + { + "name": "CurserSet", + "fields": [ + { + "name": "curser", + "type": "publicKey", + "index": false + } + ] } ], "errors": [ @@ -812,7 +903,10 @@ export const IDL: RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "account manually to handle the different structs before and after migration., allowing for no-downtime migration." + ] }, { "name": "authority", @@ -857,7 +951,10 @@ export const IDL: RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "account manually to handle the different structs before and after migration., allowing for no-downtime migration." + ] }, { "name": "authority", @@ -921,6 +1018,41 @@ export const IDL: RmnRemote = { } ] }, + { + "name": "setCurser", + "docs": [ + "Sets the address for the curser role who, alongside the contract owner, is authorized to curse.", + "", + "Only the CCIP Admin may perform this operation.", + "", + "# Arguments", + "* `ctx` - The context containing the accounts required for updating event authorities.", + "* `curser` - The new curser public key." + ], + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "curses", + "isMut": false, + "isSigner": false + }, + { + "name": "authority", + "isMut": false, + "isSigner": true + } + ], + "args": [ + { + "name": "curser", + "type": "publicKey" + } + ] + }, { "name": "verifyNotCursed", "docs": [ @@ -942,10 +1074,7 @@ export const IDL: RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false, - "docs": [ - "After the upgrade is made, this can be changed to Account<'info, Config>." - ] + "isSigner": false } ], "args": [ @@ -976,7 +1105,10 @@ export const IDL: RmnRemote = { { "name": "config", "isMut": false, - "isSigner": false + "isSigner": false, + "docs": [ + "After the upgrade is made, this can be changed to Account<'info, Config>." + ] }, { "name": "authority", @@ -992,7 +1124,7 @@ export const IDL: RmnRemote = { ] }, { - "name": "migrateConfigV1ToV2", + "name": "migrateConfigV2ToV3", "docs": [ "Extends the Config PDA to allocate space for v2 fields, and migrates the onchain state", "from v1 to v2. This is a permissionless operation, as the default values set for the new", @@ -1048,6 +1180,14 @@ export const IDL: RmnRemote = { "defined": "CodeVersion" } }, + { + "name": "curser", + "type": "publicKey" + }, + { + "name": "bump", + "type": "u8" + }, { "name": "eventAuthorities", "type": { @@ -1079,6 +1219,38 @@ export const IDL: RmnRemote = { } ], "types": [ + { + "name": "ConfigV2", + "type": { + "kind": "struct", + "fields": [ + { + "name": "version", + "type": "u8" + }, + { + "name": "owner", + "type": "publicKey" + }, + { + "name": "proposedOwner", + "type": "publicKey" + }, + { + "name": "defaultCodeVersion", + "type": { + "defined": "CodeVersion" + } + }, + { + "name": "eventAuthorities", + "type": { + "vec": "publicKey" + } + } + ] + } + }, { "name": "CurseSubject", "docs": [ @@ -1198,6 +1370,16 @@ export const IDL: RmnRemote = { "index": false } ] + }, + { + "name": "CurserSet", + "fields": [ + { + "name": "curser", + "type": "publicKey", + "index": false + } + ] } ], "errors": [ diff --git a/chains/solana/contracts/tests/ccip/ccip_router_test.go b/chains/solana/contracts/tests/ccip/ccip_router_test.go index 9b1ac6e199..b001f9b94b 100644 --- a/chains/solana/contracts/tests/ccip/ccip_router_test.go +++ b/chains/solana/contracts/tests/ccip/ccip_router_test.go @@ -2145,7 +2145,8 @@ func TestCCIPRouter(t *testing.T) { Timestamp: validTimestamp, }, PremiumMultiplierWeiPerEth: 9000000, - }}, + }, + }, { Accounts: link22, Config: fee_quoter.BillingTokenConfig{ @@ -2156,7 +2157,8 @@ func TestCCIPRouter(t *testing.T) { Timestamp: validTimestamp, }, PremiumMultiplierWeiPerEth: 11000000, - }}, + }, + }, } for _, token := range testTokens { @@ -3605,6 +3607,24 @@ func TestCCIPRouter(t *testing.T) { // Manual Cursing Tests // ///////////////////////////// t.Run("Manual Cursing", func(t *testing.T) { + t.Run("setup curser", func(t *testing.T) { + ix, err := rmn_remote.NewSetCurserInstruction( + legacyAdmin.PublicKey(), + config.RMNRemoteConfigPDA, + config.RMNRemoteCursesPDA, + ccipAdmin.PublicKey(), + ).ValidateAndBuild() + require.NoError(t, err) + + testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ix}, ccipAdmin, config.DefaultCommitment) + + var conf rmn_remote.Config + err = common.GetAccountDataBorshInto(ctx, solanaGoClient, config.RMNRemoteConfigPDA, config.DefaultCommitment, &conf) + require.NoError(t, err, "failed to get account info") + + require.Equal(t, legacyAdmin.PublicKey(), conf.Curser) + }) + t.Run("no curses by default", func(t *testing.T) { svmCurse := rmn_remote.CurseSubject{} binary.LittleEndian.PutUint64(svmCurse.Value[:], config.SvmChainSelector) @@ -3630,7 +3650,19 @@ func TestCCIPRouter(t *testing.T) { require.NotNil(t, result) }) - t.Run("applying a global curse", func(t *testing.T) { + t.Run("when a random user tries to apply a curse, it fails", func(t *testing.T) { + ix, err := rmn_remote.NewCurseInstruction( + rmn_remote.CurseSubject{Value: [16]uint8{1, 2, 3}}, + config.RMNRemoteConfigPDA, + user.PublicKey(), + config.RMNRemoteCursesPDA, + solana.SystemProgramID, + ).ValidateAndBuild() + require.NoError(t, err) + testutils.SendAndFailWith(ctx, t, solanaGoClient, []solana.Instruction{ix}, user, config.DefaultCommitment, []string{ccip.Unauthorized_RmnRemoteError.String()}) + }) + + t.Run("applying a global curse as the owner", func(t *testing.T) { globalCurse := rmn_remote.CurseSubject{ Value: [16]uint8{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, } @@ -3638,7 +3670,7 @@ func TestCCIPRouter(t *testing.T) { ix, err := rmn_remote.NewCurseInstruction( globalCurse, config.RMNRemoteConfigPDA, - ccipAdmin.PublicKey(), + ccipAdmin.PublicKey(), // owner here config.RMNRemoteCursesPDA, solana.SystemProgramID, ).ValidateAndBuild() @@ -3788,7 +3820,7 @@ func TestCCIPRouter(t *testing.T) { require.Equal(t, len(curses.CursedSubjects), 0) }) - t.Run("adding chain selector curses", func(t *testing.T) { + t.Run("adding chain selector curses as the curser", func(t *testing.T) { svmCurse := rmn_remote.CurseSubject{} binary.LittleEndian.PutUint64(svmCurse.Value[:], config.SvmChainSelector) evmCurse := rmn_remote.CurseSubject{} @@ -3797,12 +3829,12 @@ func TestCCIPRouter(t *testing.T) { ix, err := rmn_remote.NewCurseInstruction( svmCurse, config.RMNRemoteConfigPDA, - ccipAdmin.PublicKey(), + legacyAdmin.PublicKey(), // this is registered as the "curser" in RMN config.RMNRemoteCursesPDA, solana.SystemProgramID, ).ValidateAndBuild() require.NoError(t, err) - result := testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ix}, ccipAdmin, config.DefaultCommitment) + result := testutils.SendAndConfirm(ctx, t, solanaGoClient, []solana.Instruction{ix}, legacyAdmin, config.DefaultCommitment) require.NotNil(t, result) var curses rmn_remote.Curses diff --git a/chains/solana/contracts/tests/devnet/cctp_tp_devnet_test.go b/chains/solana/contracts/tests/devnet/cctp_tp_devnet_test.go index 0ad0c54400..42a5bf28fe 100644 --- a/chains/solana/contracts/tests/devnet/cctp_tp_devnet_test.go +++ b/chains/solana/contracts/tests/devnet/cctp_tp_devnet_test.go @@ -130,7 +130,7 @@ func TestCctpTpDevnet(t *testing.T) { rmnConfig, _, err := state.FindRMNRemoteConfigPDA(referenceAddresses.RmnRemote) require.NoError(t, err) - ix, err := rmn_remote.NewMigrateConfigV1ToV2Instruction( + ix, err := rmn_remote.NewMigrateConfigV2ToV3Instruction( rmnConfig, admin.PublicKey(), solana.SystemProgramID, diff --git a/chains/solana/deployment/v1_6_0/adapters/fees.go b/chains/solana/deployment/v1_6_0/adapters/fees.go index 5944d857c6..6c89f0e649 100644 --- a/chains/solana/deployment/v1_6_0/adapters/fees.go +++ b/chains/solana/deployment/v1_6_0/adapters/fees.go @@ -169,7 +169,7 @@ func (a *FeesAdapter) SetTokenTransferFee(e cldf.Environment, feeRef datastore.A if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("invalid base58 token address for src %d and dst %d: %s", src, dst, rawTokenAddress) } - if feeCfg == nil { + if feeCfg == nil || !feeCfg.IsEnabled { // NOTE: the Solana FeeQuoter will always perform validation checks on the input // config even if we are trying to disable it. As a result, we need to provide a // proper set of values even though none of them will actually be used. @@ -204,7 +204,6 @@ func (a *FeesAdapter) SetTokenTransferFee(e cldf.Environment, feeRef datastore.A solseq.SetTokenTransferFeeConfig, solseq.FeeQuoterSetTokenTransferFeeConfigSequenceInput{ RemoteChainConfigs: remoteChainConfigs, - DataStore: e.DataStore, FeeQuoter: fqAddr, Selector: input.Selector, }, diff --git a/chains/solana/deployment/v1_6_0/sequences/fee_quoter.go b/chains/solana/deployment/v1_6_0/sequences/fee_quoter.go index 5cc20d4a6a..0aad53d316 100644 --- a/chains/solana/deployment/v1_6_0/sequences/fee_quoter.go +++ b/chains/solana/deployment/v1_6_0/sequences/fee_quoter.go @@ -9,14 +9,12 @@ import ( "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/fee_quoter" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" - "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/operations" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" ) type FeeQuoterSetTokenTransferFeeConfigSequenceInput struct { RemoteChainConfigs map[uint64]map[solana.PublicKey]fee_quoter.TokenTransferFeeConfig - DataStore datastore.DataStore FeeQuoter solana.PublicKey Selector uint64 } @@ -35,11 +33,6 @@ var SetTokenTransferFeeConfig = cldf_ops.NewSequence( if !ok { return sequences.OnChainOutput{}, fmt.Errorf("solana chain with selector %d not found", input.Selector) } - fqAddrBytes, err := (&SolanaAdapter{}).GetFQAddress(input.DataStore, input.Selector) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get FeeQuoter address: %w", err) - } - fqPubkey := solana.PublicKeyFromBytes(fqAddrBytes) for dst, dstCfg := range input.RemoteChainConfigs { for tok, feeCfg := range dstCfg { out, err := operations.ExecuteOperation(b, @@ -48,7 +41,7 @@ var SetTokenTransferFeeConfig = cldf_ops.NewSequence( fqops.SetTokenTransferFeeConfigInput{ SrcSelector: input.Selector, DstSelector: dst, - FeeQuoter: fqPubkey, + FeeQuoter: input.FeeQuoter, Config: feeCfg, Token: tok, }, diff --git a/chains/solana/gobindings/latest/rmn_remote/CpiEvent.go b/chains/solana/gobindings/latest/rmn_remote/CpiEvent.go index 31cc31d9ef..73a3cfe397 100644 --- a/chains/solana/gobindings/latest/rmn_remote/CpiEvent.go +++ b/chains/solana/gobindings/latest/rmn_remote/CpiEvent.go @@ -25,6 +25,7 @@ type CpiEvent struct { EventData *[]byte // [0] = [] config + // ··········· After the upgrade is made, this can be changed to Account<'info, Config>. // // [1] = [SIGNER] authority ag_solanago.AccountMetaSlice `bin:"-" borsh_skip:"true"` @@ -45,12 +46,14 @@ func (inst *CpiEvent) SetEventData(eventData []byte) *CpiEvent { } // SetConfigAccount sets the "config" account. +// After the upgrade is made, this can be changed to Account<'info, Config>. func (inst *CpiEvent) SetConfigAccount(config ag_solanago.PublicKey) *CpiEvent { inst.AccountMetaSlice[0] = ag_solanago.Meta(config) return inst } // GetConfigAccount gets the "config" account. +// After the upgrade is made, this can be changed to Account<'info, Config>. func (inst *CpiEvent) GetConfigAccount() *ag_solanago.AccountMeta { return inst.AccountMetaSlice[0] } diff --git a/chains/solana/gobindings/latest/rmn_remote/Curse.go b/chains/solana/gobindings/latest/rmn_remote/Curse.go index de32888a1b..9fa6a52d2b 100644 --- a/chains/solana/gobindings/latest/rmn_remote/Curse.go +++ b/chains/solana/gobindings/latest/rmn_remote/Curse.go @@ -23,6 +23,7 @@ type Curse struct { Subject *CurseSubject // [0] = [] config + // ··········· account manually to handle the different structs before and after migration., allowing for no-downtime migration. // // [1] = [WRITE, SIGNER] authority // @@ -47,12 +48,14 @@ func (inst *Curse) SetSubject(subject CurseSubject) *Curse { } // SetConfigAccount sets the "config" account. +// account manually to handle the different structs before and after migration., allowing for no-downtime migration. func (inst *Curse) SetConfigAccount(config ag_solanago.PublicKey) *Curse { inst.AccountMetaSlice[0] = ag_solanago.Meta(config) return inst } // GetConfigAccount gets the "config" account. +// account manually to handle the different structs before and after migration., allowing for no-downtime migration. func (inst *Curse) GetConfigAccount() *ag_solanago.AccountMeta { return inst.AccountMetaSlice[0] } diff --git a/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV1ToV2.go b/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV2ToV3.go similarity index 70% rename from chains/solana/gobindings/latest/rmn_remote/MigrateConfigV1ToV2.go rename to chains/solana/gobindings/latest/rmn_remote/MigrateConfigV2ToV3.go index f1e2a4b71d..aa2048491c 100644 --- a/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV1ToV2.go +++ b/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV2ToV3.go @@ -16,7 +16,7 @@ import ( // # Arguments // // * `ctx` - The context containing the accounts required for the migration. -type MigrateConfigV1ToV2 struct { +type MigrateConfigV2ToV3 struct { // [0] = [WRITE] config // ··········· has the new property in it, so it can't be deserialized as Config before the migration has happened. @@ -27,9 +27,9 @@ type MigrateConfigV1ToV2 struct { ag_solanago.AccountMetaSlice `bin:"-" borsh_skip:"true"` } -// NewMigrateConfigV1ToV2InstructionBuilder creates a new `MigrateConfigV1ToV2` instruction builder. -func NewMigrateConfigV1ToV2InstructionBuilder() *MigrateConfigV1ToV2 { - nd := &MigrateConfigV1ToV2{ +// NewMigrateConfigV2ToV3InstructionBuilder creates a new `MigrateConfigV2ToV3` instruction builder. +func NewMigrateConfigV2ToV3InstructionBuilder() *MigrateConfigV2ToV3 { + nd := &MigrateConfigV2ToV3{ AccountMetaSlice: make(ag_solanago.AccountMetaSlice, 3), } return nd @@ -37,57 +37,57 @@ func NewMigrateConfigV1ToV2InstructionBuilder() *MigrateConfigV1ToV2 { // SetConfigAccount sets the "config" account. // has the new property in it, so it can't be deserialized as Config before the migration has happened. -func (inst *MigrateConfigV1ToV2) SetConfigAccount(config ag_solanago.PublicKey) *MigrateConfigV1ToV2 { +func (inst *MigrateConfigV2ToV3) SetConfigAccount(config ag_solanago.PublicKey) *MigrateConfigV2ToV3 { inst.AccountMetaSlice[0] = ag_solanago.Meta(config).WRITE() return inst } // GetConfigAccount gets the "config" account. // has the new property in it, so it can't be deserialized as Config before the migration has happened. -func (inst *MigrateConfigV1ToV2) GetConfigAccount() *ag_solanago.AccountMeta { +func (inst *MigrateConfigV2ToV3) GetConfigAccount() *ag_solanago.AccountMeta { return inst.AccountMetaSlice[0] } // SetAuthorityAccount sets the "authority" account. -func (inst *MigrateConfigV1ToV2) SetAuthorityAccount(authority ag_solanago.PublicKey) *MigrateConfigV1ToV2 { +func (inst *MigrateConfigV2ToV3) SetAuthorityAccount(authority ag_solanago.PublicKey) *MigrateConfigV2ToV3 { inst.AccountMetaSlice[1] = ag_solanago.Meta(authority).WRITE().SIGNER() return inst } // GetAuthorityAccount gets the "authority" account. -func (inst *MigrateConfigV1ToV2) GetAuthorityAccount() *ag_solanago.AccountMeta { +func (inst *MigrateConfigV2ToV3) GetAuthorityAccount() *ag_solanago.AccountMeta { return inst.AccountMetaSlice[1] } // SetSystemProgramAccount sets the "systemProgram" account. -func (inst *MigrateConfigV1ToV2) SetSystemProgramAccount(systemProgram ag_solanago.PublicKey) *MigrateConfigV1ToV2 { +func (inst *MigrateConfigV2ToV3) SetSystemProgramAccount(systemProgram ag_solanago.PublicKey) *MigrateConfigV2ToV3 { inst.AccountMetaSlice[2] = ag_solanago.Meta(systemProgram) return inst } // GetSystemProgramAccount gets the "systemProgram" account. -func (inst *MigrateConfigV1ToV2) GetSystemProgramAccount() *ag_solanago.AccountMeta { +func (inst *MigrateConfigV2ToV3) GetSystemProgramAccount() *ag_solanago.AccountMeta { return inst.AccountMetaSlice[2] } -func (inst MigrateConfigV1ToV2) Build() *Instruction { +func (inst MigrateConfigV2ToV3) Build() *Instruction { return &Instruction{BaseVariant: ag_binary.BaseVariant{ Impl: inst, - TypeID: Instruction_MigrateConfigV1ToV2, + TypeID: Instruction_MigrateConfigV2ToV3, }} } // ValidateAndBuild validates the instruction parameters and accounts; // if there is a validation error, it returns the error. // Otherwise, it builds and returns the instruction. -func (inst MigrateConfigV1ToV2) ValidateAndBuild() (*Instruction, error) { +func (inst MigrateConfigV2ToV3) ValidateAndBuild() (*Instruction, error) { if err := inst.Validate(); err != nil { return nil, err } return inst.Build(), nil } -func (inst *MigrateConfigV1ToV2) Validate() error { +func (inst *MigrateConfigV2ToV3) Validate() error { // Check whether all (required) accounts are set: { if inst.AccountMetaSlice[0] == nil { @@ -103,11 +103,11 @@ func (inst *MigrateConfigV1ToV2) Validate() error { return nil } -func (inst *MigrateConfigV1ToV2) EncodeToTree(parent ag_treeout.Branches) { +func (inst *MigrateConfigV2ToV3) EncodeToTree(parent ag_treeout.Branches) { parent.Child(ag_format.Program(ProgramName, ProgramID)). // ParentFunc(func(programBranch ag_treeout.Branches) { - programBranch.Child(ag_format.Instruction("MigrateConfigV1ToV2")). + programBranch.Child(ag_format.Instruction("MigrateConfigV2ToV3")). // ParentFunc(func(instructionBranch ag_treeout.Branches) { @@ -124,20 +124,20 @@ func (inst *MigrateConfigV1ToV2) EncodeToTree(parent ag_treeout.Branches) { }) } -func (obj MigrateConfigV1ToV2) MarshalWithEncoder(encoder *ag_binary.Encoder) (err error) { +func (obj MigrateConfigV2ToV3) MarshalWithEncoder(encoder *ag_binary.Encoder) (err error) { return nil } -func (obj *MigrateConfigV1ToV2) UnmarshalWithDecoder(decoder *ag_binary.Decoder) (err error) { +func (obj *MigrateConfigV2ToV3) UnmarshalWithDecoder(decoder *ag_binary.Decoder) (err error) { return nil } -// NewMigrateConfigV1ToV2Instruction declares a new MigrateConfigV1ToV2 instruction with the provided parameters and accounts. -func NewMigrateConfigV1ToV2Instruction( +// NewMigrateConfigV2ToV3Instruction declares a new MigrateConfigV2ToV3 instruction with the provided parameters and accounts. +func NewMigrateConfigV2ToV3Instruction( // Accounts: config ag_solanago.PublicKey, authority ag_solanago.PublicKey, - systemProgram ag_solanago.PublicKey) *MigrateConfigV1ToV2 { - return NewMigrateConfigV1ToV2InstructionBuilder(). + systemProgram ag_solanago.PublicKey) *MigrateConfigV2ToV3 { + return NewMigrateConfigV2ToV3InstructionBuilder(). SetConfigAccount(config). SetAuthorityAccount(authority). SetSystemProgramAccount(systemProgram) diff --git a/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV1ToV2_test.go b/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV2ToV3_test.go similarity index 75% rename from chains/solana/gobindings/latest/rmn_remote/MigrateConfigV1ToV2_test.go rename to chains/solana/gobindings/latest/rmn_remote/MigrateConfigV2ToV3_test.go index 5a76190f85..438cf62b73 100644 --- a/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV1ToV2_test.go +++ b/chains/solana/gobindings/latest/rmn_remote/MigrateConfigV2ToV3_test.go @@ -10,18 +10,18 @@ import ( "testing" ) -func TestEncodeDecode_MigrateConfigV1ToV2(t *testing.T) { +func TestEncodeDecode_MigrateConfigV2ToV3(t *testing.T) { fu := ag_gofuzz.New().NilChance(0) for i := 0; i < 1; i++ { - t.Run("MigrateConfigV1ToV2"+strconv.Itoa(i), func(t *testing.T) { + t.Run("MigrateConfigV2ToV3"+strconv.Itoa(i), func(t *testing.T) { { - params := new(MigrateConfigV1ToV2) + params := new(MigrateConfigV2ToV3) fu.Fuzz(params) params.AccountMetaSlice = nil buf := new(bytes.Buffer) err := encodeT(*params, buf) ag_require.NoError(t, err) - got := new(MigrateConfigV1ToV2) + got := new(MigrateConfigV2ToV3) err = decodeT(got, buf.Bytes()) got.AccountMetaSlice = nil ag_require.NoError(t, err) diff --git a/chains/solana/gobindings/latest/rmn_remote/SetCurser.go b/chains/solana/gobindings/latest/rmn_remote/SetCurser.go new file mode 100644 index 0000000000..8c4dacbd9e --- /dev/null +++ b/chains/solana/gobindings/latest/rmn_remote/SetCurser.go @@ -0,0 +1,171 @@ +// Code generated by https://github.com/gagliardetto/anchor-go. DO NOT EDIT. + +package rmn_remote + +import ( + "errors" + ag_binary "github.com/gagliardetto/binary" + ag_solanago "github.com/gagliardetto/solana-go" + ag_format "github.com/gagliardetto/solana-go/text/format" + ag_treeout "github.com/gagliardetto/treeout" +) + +// Sets the address for the curser role who, alongside the contract owner, is authorized to curse. +// +// Only the CCIP Admin may perform this operation. +// +// # Arguments +// * `ctx` - The context containing the accounts required for updating event authorities. +// * `curser` - The new curser public key. +type SetCurser struct { + Curser *ag_solanago.PublicKey + + // [0] = [WRITE] config + // + // [1] = [] curses + // + // [2] = [SIGNER] authority + ag_solanago.AccountMetaSlice `bin:"-" borsh_skip:"true"` +} + +// NewSetCurserInstructionBuilder creates a new `SetCurser` instruction builder. +func NewSetCurserInstructionBuilder() *SetCurser { + nd := &SetCurser{ + AccountMetaSlice: make(ag_solanago.AccountMetaSlice, 3), + } + return nd +} + +// SetCurser sets the "curser" parameter. +func (inst *SetCurser) SetCurser(curser ag_solanago.PublicKey) *SetCurser { + inst.Curser = &curser + return inst +} + +// SetConfigAccount sets the "config" account. +func (inst *SetCurser) SetConfigAccount(config ag_solanago.PublicKey) *SetCurser { + inst.AccountMetaSlice[0] = ag_solanago.Meta(config).WRITE() + return inst +} + +// GetConfigAccount gets the "config" account. +func (inst *SetCurser) GetConfigAccount() *ag_solanago.AccountMeta { + return inst.AccountMetaSlice[0] +} + +// SetCursesAccount sets the "curses" account. +func (inst *SetCurser) SetCursesAccount(curses ag_solanago.PublicKey) *SetCurser { + inst.AccountMetaSlice[1] = ag_solanago.Meta(curses) + return inst +} + +// GetCursesAccount gets the "curses" account. +func (inst *SetCurser) GetCursesAccount() *ag_solanago.AccountMeta { + return inst.AccountMetaSlice[1] +} + +// SetAuthorityAccount sets the "authority" account. +func (inst *SetCurser) SetAuthorityAccount(authority ag_solanago.PublicKey) *SetCurser { + inst.AccountMetaSlice[2] = ag_solanago.Meta(authority).SIGNER() + return inst +} + +// GetAuthorityAccount gets the "authority" account. +func (inst *SetCurser) GetAuthorityAccount() *ag_solanago.AccountMeta { + return inst.AccountMetaSlice[2] +} + +func (inst SetCurser) Build() *Instruction { + return &Instruction{BaseVariant: ag_binary.BaseVariant{ + Impl: inst, + TypeID: Instruction_SetCurser, + }} +} + +// ValidateAndBuild validates the instruction parameters and accounts; +// if there is a validation error, it returns the error. +// Otherwise, it builds and returns the instruction. +func (inst SetCurser) ValidateAndBuild() (*Instruction, error) { + if err := inst.Validate(); err != nil { + return nil, err + } + return inst.Build(), nil +} + +func (inst *SetCurser) Validate() error { + // Check whether all (required) parameters are set: + { + if inst.Curser == nil { + return errors.New("Curser parameter is not set") + } + } + + // Check whether all (required) accounts are set: + { + if inst.AccountMetaSlice[0] == nil { + return errors.New("accounts.Config is not set") + } + if inst.AccountMetaSlice[1] == nil { + return errors.New("accounts.Curses is not set") + } + if inst.AccountMetaSlice[2] == nil { + return errors.New("accounts.Authority is not set") + } + } + return nil +} + +func (inst *SetCurser) EncodeToTree(parent ag_treeout.Branches) { + parent.Child(ag_format.Program(ProgramName, ProgramID)). + // + ParentFunc(func(programBranch ag_treeout.Branches) { + programBranch.Child(ag_format.Instruction("SetCurser")). + // + ParentFunc(func(instructionBranch ag_treeout.Branches) { + + // Parameters of the instruction: + instructionBranch.Child("Params[len=1]").ParentFunc(func(paramsBranch ag_treeout.Branches) { + paramsBranch.Child(ag_format.Param("Curser", *inst.Curser)) + }) + + // Accounts of the instruction: + instructionBranch.Child("Accounts[len=3]").ParentFunc(func(accountsBranch ag_treeout.Branches) { + accountsBranch.Child(ag_format.Meta(" config", inst.AccountMetaSlice[0])) + accountsBranch.Child(ag_format.Meta(" curses", inst.AccountMetaSlice[1])) + accountsBranch.Child(ag_format.Meta("authority", inst.AccountMetaSlice[2])) + }) + }) + }) +} + +func (obj SetCurser) MarshalWithEncoder(encoder *ag_binary.Encoder) (err error) { + // Serialize `Curser` param: + err = encoder.Encode(obj.Curser) + if err != nil { + return err + } + return nil +} +func (obj *SetCurser) UnmarshalWithDecoder(decoder *ag_binary.Decoder) (err error) { + // Deserialize `Curser`: + err = decoder.Decode(&obj.Curser) + if err != nil { + return err + } + return nil +} + +// NewSetCurserInstruction declares a new SetCurser instruction with the provided parameters and accounts. +func NewSetCurserInstruction( + // Parameters: + curser ag_solanago.PublicKey, + // Accounts: + config ag_solanago.PublicKey, + curses ag_solanago.PublicKey, + authority ag_solanago.PublicKey) *SetCurser { + return NewSetCurserInstructionBuilder(). + SetCurser(curser). + SetConfigAccount(config). + SetCursesAccount(curses). + SetAuthorityAccount(authority) +} diff --git a/chains/solana/gobindings/latest/rmn_remote/SetCurser_test.go b/chains/solana/gobindings/latest/rmn_remote/SetCurser_test.go new file mode 100644 index 0000000000..8cf38c372a --- /dev/null +++ b/chains/solana/gobindings/latest/rmn_remote/SetCurser_test.go @@ -0,0 +1,32 @@ +// Code generated by https://github.com/gagliardetto/anchor-go. DO NOT EDIT. + +package rmn_remote + +import ( + "bytes" + ag_gofuzz "github.com/gagliardetto/gofuzz" + ag_require "github.com/stretchr/testify/require" + "strconv" + "testing" +) + +func TestEncodeDecode_SetCurser(t *testing.T) { + fu := ag_gofuzz.New().NilChance(0) + for i := 0; i < 1; i++ { + t.Run("SetCurser"+strconv.Itoa(i), func(t *testing.T) { + { + params := new(SetCurser) + fu.Fuzz(params) + params.AccountMetaSlice = nil + buf := new(bytes.Buffer) + err := encodeT(*params, buf) + ag_require.NoError(t, err) + got := new(SetCurser) + err = decodeT(got, buf.Bytes()) + got.AccountMetaSlice = nil + ag_require.NoError(t, err) + ag_require.Equal(t, params, got) + } + }) + } +} diff --git a/chains/solana/gobindings/latest/rmn_remote/Uncurse.go b/chains/solana/gobindings/latest/rmn_remote/Uncurse.go index f0e28303cb..d195a3c490 100644 --- a/chains/solana/gobindings/latest/rmn_remote/Uncurse.go +++ b/chains/solana/gobindings/latest/rmn_remote/Uncurse.go @@ -24,6 +24,7 @@ type Uncurse struct { Subject *CurseSubject // [0] = [] config + // ··········· account manually to handle the different structs before and after migration., allowing for no-downtime migration. // // [1] = [WRITE, SIGNER] authority // @@ -48,12 +49,14 @@ func (inst *Uncurse) SetSubject(subject CurseSubject) *Uncurse { } // SetConfigAccount sets the "config" account. +// account manually to handle the different structs before and after migration., allowing for no-downtime migration. func (inst *Uncurse) SetConfigAccount(config ag_solanago.PublicKey) *Uncurse { inst.AccountMetaSlice[0] = ag_solanago.Meta(config) return inst } // GetConfigAccount gets the "config" account. +// account manually to handle the different structs before and after migration., allowing for no-downtime migration. func (inst *Uncurse) GetConfigAccount() *ag_solanago.AccountMeta { return inst.AccountMetaSlice[0] } diff --git a/chains/solana/gobindings/latest/rmn_remote/VerifyNotCursed.go b/chains/solana/gobindings/latest/rmn_remote/VerifyNotCursed.go index 2da9bf7471..2dd29fa2d6 100644 --- a/chains/solana/gobindings/latest/rmn_remote/VerifyNotCursed.go +++ b/chains/solana/gobindings/latest/rmn_remote/VerifyNotCursed.go @@ -24,7 +24,6 @@ type VerifyNotCursed struct { // [0] = [] curses // // [1] = [] config - // ··········· After the upgrade is made, this can be changed to Account<'info, Config>. ag_solanago.AccountMetaSlice `bin:"-" borsh_skip:"true"` } @@ -54,14 +53,12 @@ func (inst *VerifyNotCursed) GetCursesAccount() *ag_solanago.AccountMeta { } // SetConfigAccount sets the "config" account. -// After the upgrade is made, this can be changed to Account<'info, Config>. func (inst *VerifyNotCursed) SetConfigAccount(config ag_solanago.PublicKey) *VerifyNotCursed { inst.AccountMetaSlice[1] = ag_solanago.Meta(config) return inst } // GetConfigAccount gets the "config" account. -// After the upgrade is made, this can be changed to Account<'info, Config>. func (inst *VerifyNotCursed) GetConfigAccount() *ag_solanago.AccountMeta { return inst.AccountMetaSlice[1] } diff --git a/chains/solana/gobindings/latest/rmn_remote/accounts.go b/chains/solana/gobindings/latest/rmn_remote/accounts.go index 4ecb9e1661..79ce0c3b1a 100644 --- a/chains/solana/gobindings/latest/rmn_remote/accounts.go +++ b/chains/solana/gobindings/latest/rmn_remote/accounts.go @@ -13,6 +13,8 @@ type Config struct { Owner ag_solanago.PublicKey ProposedOwner ag_solanago.PublicKey DefaultCodeVersion CodeVersion + Curser ag_solanago.PublicKey + Bump uint8 EventAuthorities []ag_solanago.PublicKey } @@ -44,6 +46,16 @@ func (obj Config) MarshalWithEncoder(encoder *ag_binary.Encoder) (err error) { if err != nil { return err } + // Serialize `Curser` param: + err = encoder.Encode(obj.Curser) + if err != nil { + return err + } + // Serialize `Bump` param: + err = encoder.Encode(obj.Bump) + if err != nil { + return err + } // Serialize `EventAuthorities` param: err = encoder.Encode(obj.EventAuthorities) if err != nil { @@ -86,6 +98,16 @@ func (obj *Config) UnmarshalWithDecoder(decoder *ag_binary.Decoder) (err error) if err != nil { return err } + // Deserialize `Curser`: + err = decoder.Decode(&obj.Curser) + if err != nil { + return err + } + // Deserialize `Bump`: + err = decoder.Decode(&obj.Bump) + if err != nil { + return err + } // Deserialize `EventAuthorities`: err = decoder.Decode(&obj.EventAuthorities) if err != nil { diff --git a/chains/solana/gobindings/latest/rmn_remote/instructions.go b/chains/solana/gobindings/latest/rmn_remote/instructions.go index f33b0dbf82..bad1cc8e0a 100644 --- a/chains/solana/gobindings/latest/rmn_remote/instructions.go +++ b/chains/solana/gobindings/latest/rmn_remote/instructions.go @@ -107,6 +107,15 @@ var ( // * `new_event_authorities` - The new list of event authority public keys. Instruction_SetEventAuthorities = ag_binary.TypeID([8]byte{76, 83, 229, 160, 40, 1, 149, 103}) + // Sets the address for the curser role who, alongside the contract owner, is authorized to curse. + // + // Only the CCIP Admin may perform this operation. + // + // # Arguments + // * `ctx` - The context containing the accounts required for updating event authorities. + // * `curser` - The new curser public key. + Instruction_SetCurser = ag_binary.TypeID([8]byte{107, 220, 52, 86, 132, 117, 92, 232}) + // Verifies that the subject is not cursed AND that this chain is not globally cursed. // In case either of those assumptions fail, the instruction reverts. // @@ -136,7 +145,7 @@ var ( // # Arguments // // * `ctx` - The context containing the accounts required for the migration. - Instruction_MigrateConfigV1ToV2 = ag_binary.TypeID([8]byte{201, 212, 171, 0, 73, 91, 124, 99}) + Instruction_MigrateConfigV2ToV3 = ag_binary.TypeID([8]byte{164, 212, 48, 121, 32, 23, 211, 44}) ) // InstructionIDToName returns the name of the instruction given its ID. @@ -158,12 +167,14 @@ func InstructionIDToName(id ag_binary.TypeID) string { return "Uncurse" case Instruction_SetEventAuthorities: return "SetEventAuthorities" + case Instruction_SetCurser: + return "SetCurser" case Instruction_VerifyNotCursed: return "VerifyNotCursed" case Instruction_CpiEvent: return "CpiEvent" - case Instruction_MigrateConfigV1ToV2: - return "MigrateConfigV1ToV2" + case Instruction_MigrateConfigV2ToV3: + return "MigrateConfigV2ToV3" default: return "" } @@ -208,6 +219,9 @@ var InstructionImplDef = ag_binary.NewVariantDefinition( { "set_event_authorities", (*SetEventAuthorities)(nil), }, + { + "set_curser", (*SetCurser)(nil), + }, { "verify_not_cursed", (*VerifyNotCursed)(nil), }, @@ -215,7 +229,7 @@ var InstructionImplDef = ag_binary.NewVariantDefinition( "cpi_event", (*CpiEvent)(nil), }, { - "migrate_config_v1_to_v2", (*MigrateConfigV1ToV2)(nil), + "migrate_config_v2_to_v3", (*MigrateConfigV2ToV3)(nil), }, }, ) diff --git a/chains/solana/gobindings/latest/rmn_remote/types.go b/chains/solana/gobindings/latest/rmn_remote/types.go index 69327435b0..489382fe97 100644 --- a/chains/solana/gobindings/latest/rmn_remote/types.go +++ b/chains/solana/gobindings/latest/rmn_remote/types.go @@ -2,7 +2,76 @@ package rmn_remote -import ag_binary "github.com/gagliardetto/binary" +import ( + ag_binary "github.com/gagliardetto/binary" + ag_solanago "github.com/gagliardetto/solana-go" +) + +type ConfigV2 struct { + Version uint8 + Owner ag_solanago.PublicKey + ProposedOwner ag_solanago.PublicKey + DefaultCodeVersion CodeVersion + EventAuthorities []ag_solanago.PublicKey +} + +func (obj ConfigV2) MarshalWithEncoder(encoder *ag_binary.Encoder) (err error) { + // Serialize `Version` param: + err = encoder.Encode(obj.Version) + if err != nil { + return err + } + // Serialize `Owner` param: + err = encoder.Encode(obj.Owner) + if err != nil { + return err + } + // Serialize `ProposedOwner` param: + err = encoder.Encode(obj.ProposedOwner) + if err != nil { + return err + } + // Serialize `DefaultCodeVersion` param: + err = encoder.Encode(obj.DefaultCodeVersion) + if err != nil { + return err + } + // Serialize `EventAuthorities` param: + err = encoder.Encode(obj.EventAuthorities) + if err != nil { + return err + } + return nil +} + +func (obj *ConfigV2) UnmarshalWithDecoder(decoder *ag_binary.Decoder) (err error) { + // Deserialize `Version`: + err = decoder.Decode(&obj.Version) + if err != nil { + return err + } + // Deserialize `Owner`: + err = decoder.Decode(&obj.Owner) + if err != nil { + return err + } + // Deserialize `ProposedOwner`: + err = decoder.Decode(&obj.ProposedOwner) + if err != nil { + return err + } + // Deserialize `DefaultCodeVersion`: + err = decoder.Decode(&obj.DefaultCodeVersion) + if err != nil { + return err + } + // Deserialize `EventAuthorities`: + err = decoder.Decode(&obj.EventAuthorities) + if err != nil { + return err + } + return nil +} type CurseSubject struct { Value [16]uint8 diff --git a/deployment/tokens/configure_tokens_for_transfers.go b/deployment/tokens/configure_tokens_for_transfers.go index cdb9ce8dd1..656978aee8 100644 --- a/deployment/tokens/configure_tokens_for_transfers.go +++ b/deployment/tokens/configure_tokens_for_transfers.go @@ -4,18 +4,22 @@ import ( "fmt" "math/big" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" + chain_selectors "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" - "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" mcms_types "github.com/smartcontractkit/mcms/types" + "github.com/smartcontractkit/chainlink-ccip/deployment/fees" "github.com/smartcontractkit/chainlink-ccip/deployment/finality" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" ) // TokenTransferConfig specifies configuration for a token on one chain to enable transfers with other chains. @@ -87,7 +91,7 @@ func makeApply(_ *TokenAdapterRegistry, mcmsRegistry *changesets.MCMSReaderRegis } } -func processTokenConfigForChain(e deployment.Environment, mcmsRegistry *changesets.MCMSReaderRegistry, mcmsInput mcms.Input, cfg map[uint64]TokenTransferConfig) ([]mcms_types.BatchOperation, []cldf_ops.Report[any, any], *datastore.MemoryDataStore, error) { +func processTokenConfigForChain(e cldf.Environment, mcmsRegistry *changesets.MCMSReaderRegistry, mcmsInput mcms.Input, cfg map[uint64]TokenTransferConfig) ([]mcms_types.BatchOperation, []cldf_ops.Report[any, any], *datastore.MemoryDataStore, error) { tokenRegistry := GetTokenAdapterRegistry() batchOps := make([]mcms_types.BatchOperation, 0) reports := make([]cldf_ops.Report[any, any], 0) @@ -171,7 +175,6 @@ func processTokenConfigForChain(e deployment.Environment, mcmsRegistry *changese if err != nil { return batchOps, reports, nil, fmt.Errorf("failed to configure token pool on chain with selector %d: %w", selector, err) } - batchOps = append(batchOps, configureTokenReport.Output.BatchOps...) reports = append(reports, configureTokenReport.ExecutionReports...) for _, r := range configureTokenReport.Output.Addresses { @@ -179,10 +182,146 @@ func processTokenConfigForChain(e deployment.Environment, mcmsRegistry *changese return nil, nil, nil, fmt.Errorf("failed to add address %s to datastore: %w", r.Address, err) } } + + for remoteSelector, inCfg := range remoteChains { + dstSelector := remoteSelector + srcPoolVers := tokenPool.Version + srcTokenRef := fullTokenRef + srcSelector := selector + output, err := maybeApplyTokenTransferFeeConfig(e, + srcPoolVers, + srcSelector, + dstSelector, + srcTokenRef, + inCfg, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to apply token transfer fee config for remote chain selector %d: %w", remoteSelector, err) + } + batchOps = append(batchOps, output.Output.BatchOps...) + reports = append(reports, output.ExecutionReports...) + } } return batchOps, reports, ds, nil } +func maybeApplyTokenTransferFeeConfig( + e cldf.Environment, + poolVersion *semver.Version, + src, dst uint64, + srcTokRef datastore.AddressRef, + srcConfig RemoteChainConfig[[]byte, string], +) (cldf_ops.SequenceReport[fees.SetTokenTransferFeeSequenceInput, sequences.OnChainOutput], error) { + // Helper vars + emptyReport := cldf_ops.SequenceReport[fees.SetTokenTransferFeeSequenceInput, sequences.OnChainOutput]{} + feeRegistry := fees.GetRegistry() + + // NOTE: for pre-v2 pools, token transfer fees can only be set on the fee quoter. For + // v2 pools, token transfer fees can be set on both the fee quoter or the token pool. + // In this changeset, the behavior is: for pre-v2 pools the token transfer fee config + // will be set on the fee quoter, and for v2 pools the token transfer fee config will + // be set on the token pool in the ConfigureTokensForTransfers sequence. + if poolVersion == nil || poolVersion.GreaterThanEqual(utils.Version_2_0_0) || srcConfig.TokenTransferFeeConfig == nil { + return emptyReport, nil + } + if srcTokRef.Address == "" { + return emptyReport, fmt.Errorf("source token address is required to apply token transfer fee config for remote chain selector %d", dst) + } + + // Get source chain family + fam, err := chain_selectors.GetSelectorFamily(src) + if err != nil { + return emptyReport, fmt.Errorf("failed to get chain selector family for selector %d: %w", src, err) + } + + // Fee Quoter resolution part 1: get the current on ramp from the router + resolver, ok := feeRegistry.GetFeeResolver(fam) + if !ok { + e.Logger.Warnf("No fee resolver found for chain selector %d, skipping token transfer fee config for remote chain selector %d", src, dst) + return emptyReport, nil + } + onRampRef, err := resolver.GetOnRampRef(e, src, dst) + if err != nil { + return emptyReport, fmt.Errorf("failed to resolve fee ref for chain selector %d and remote chain selector %d: %w", src, dst, err) + } + + // Fee Quoter resolution part 2: get the current fee quoter from the on ramp + adapter, ok := feeRegistry.GetFeeAdapter(fam, onRampRef.Version) + if !ok { + e.Logger.Warnf("No fee adapter found for chain selector %d, version %s, skipping token transfer fee config for remote chain selector %d", src, onRampRef.Version, dst) + return emptyReport, nil + } + feeRef, err := adapter.GetFeeContractRef(e, onRampRef, src, dst) + if err != nil { + return emptyReport, fmt.Errorf("failed to get fee contract ref for chain selector %d and remote chain selector %d: %w", src, dst, err) + } + + // NOTE: the TokenTransferFeeConfig for token pools is V2-focused and + // does NOT have MaxFeeUSDCents fields. As a result we will reuse the + // existing value from the chain or fallback to a sensible default if + // it isn't set on chain. It can't be configured directly by the user + // at the moment, but realistically speaking this should not an issue + // since we've never had the need to modify it after we initially set + // it to MaxUint32. + onChainConfig, err := adapter.GetOnchainTokenTransferFeeConfig(e, feeRef, src, dst, srcTokRef.Address) + if err != nil { + return emptyReport, fmt.Errorf("failed to get current on-chain token transfer fee config for chain selector %d and remote chain selector %d: %w", src, dst, err) + } + defaultConfig := fees.GetDefaultChainAgnosticTokenTransferFeeConfig( + src, + dst, + ) + + // Resolution strategy: + // (1) If on-chain config is enabled, merge it with the user's provided config (giving precedence to user's config) + // (2) Fall back to sensible defaults merged with user's provided config (giving precedence to user's config) + var requestedConfig fees.TokenTransferFeeArgs + if onChainConfig.IsEnabled { + requestedConfig = fees.TokenTransferFeeArgs{ + MinFeeUSDCents: srcConfig.TokenTransferFeeConfig.DefaultFinalityFeeUSDCents.GetOrDefault(onChainConfig.MinFeeUSDCents), + DeciBps: srcConfig.TokenTransferFeeConfig.DefaultFinalityTransferFeeBps.GetOrDefault(onChainConfig.DeciBps), + DestBytesOverhead: srcConfig.TokenTransferFeeConfig.DestBytesOverhead.GetOrDefault(onChainConfig.DestBytesOverhead), + DestGasOverhead: srcConfig.TokenTransferFeeConfig.DestGasOverhead.GetOrDefault(onChainConfig.DestGasOverhead), + IsEnabled: srcConfig.TokenTransferFeeConfig.IsEnabled.GetOrDefault(onChainConfig.IsEnabled), + MaxFeeUSDCents: onChainConfig.MaxFeeUSDCents, + } + } else { + requestedConfig = fees.TokenTransferFeeArgs{ + MinFeeUSDCents: srcConfig.TokenTransferFeeConfig.DefaultFinalityFeeUSDCents.GetOrDefault(defaultConfig.MinFeeUSDCents), + DeciBps: srcConfig.TokenTransferFeeConfig.DefaultFinalityTransferFeeBps.GetOrDefault(defaultConfig.DeciBps), + DestBytesOverhead: srcConfig.TokenTransferFeeConfig.DestBytesOverhead.GetOrDefault(defaultConfig.DestBytesOverhead), + DestGasOverhead: srcConfig.TokenTransferFeeConfig.DestGasOverhead.GetOrDefault(defaultConfig.DestGasOverhead), + IsEnabled: srcConfig.TokenTransferFeeConfig.IsEnabled.GetOrDefault(defaultConfig.IsEnabled), + MaxFeeUSDCents: defaultConfig.MaxFeeUSDCents, + } + } + + // Skip applying fees if the desired config is the same as the current on-chain config to avoid unnecessary work + if requestedConfig == onChainConfig { + e.Logger.Infof("Skipping token transfer fee config for chain selector %d and remote chain selector %d since the desired config is the same as the current on-chain config", src, dst) + return emptyReport, nil + } + + // Apply the token transfer fee config + result, err := cldf_ops.ExecuteSequence(e.OperationsBundle, + adapter.SetTokenTransferFee(e, feeRef), + e.BlockChains, + fees.SetTokenTransferFeeSequenceInput{ + Selector: src, + Settings: map[uint64]map[string]*fees.TokenTransferFeeArgs{ + dst: { + srcTokRef.Address: &requestedConfig, + }, + }, + }, + ) + if err != nil { + return emptyReport, fmt.Errorf("failed to execute set token transfer fee sequence for chain selector %d and remote chain selector %d: %w", src, dst, err) + } + + return result, nil +} + func convertRemoteChainConfig( e cldf.Environment, chainSelector uint64, diff --git a/deployment/tokens/product.go b/deployment/tokens/product.go index fcd4d20bf0..acf9a768e1 100644 --- a/deployment/tokens/product.go +++ b/deployment/tokens/product.go @@ -14,6 +14,7 @@ import ( cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" "github.com/smartcontractkit/chainlink-ccip/deployment/finality" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" ) @@ -34,6 +35,11 @@ type TokenFeeAdapter interface { GetDefaultTokenTransferFeeConfig(src uint64, dst uint64) TokenTransferFeeConfig } +// TokenAdminRoleAdapter is an optional interface for chain families that support token admin role management. +type TokenAdminRoleAdapter interface { + RevokeTokenAdminRole() *cldf_ops.Sequence[RevokeTokenAdminRoleSequenceInput, sequences.OnChainOutput, cldf_chain.BlockChains] +} + // TokenRefResolver is an optional interface that can be implemented by TokenAdapters. It acts as a form of middleware that allows token // and pool references to be resolved in a particular way before they are passed into the adapter logic. For example, a ref resolver can // reconstruct refs from onchain data, normalize addresses, apply transformations on the raw input ref, etc. @@ -133,22 +139,56 @@ type RateLimiterConfig struct { Rate *big.Int } +// PartialTokenTransferFeeConfig is a version of TokenTransferFeeConfig where all fields are optional. This +// is used for user input, where the user may only want to specify a subset of the fields and have the rest +// be filled in with defaults or existing on-chain values. +type PartialTokenTransferFeeConfig struct { + DefaultFinalityTransferFeeBps utils.Optional[uint16] `yaml:"defaultFinalityTransferFeeBps" json:"defaultFinalityTransferFeeBps"` + CustomFinalityTransferFeeBps utils.Optional[uint16] `yaml:"customFinalityTransferFeeBps" json:"customFinalityTransferFeeBps"` + DefaultFinalityFeeUSDCents utils.Optional[uint32] `yaml:"defaultFinalityFeeUSDCents" json:"defaultFinalityFeeUSDCents"` + CustomFinalityFeeUSDCents utils.Optional[uint32] `yaml:"customFinalityFeeUSDCents" json:"customFinalityFeeUSDCents"` + DestBytesOverhead utils.Optional[uint32] `yaml:"destBytesOverhead" json:"destBytesOverhead"` + DestGasOverhead utils.Optional[uint32] `yaml:"destGasOverhead" json:"destGasOverhead"` + IsEnabled utils.Optional[bool] `yaml:"isEnabled" json:"isEnabled"` +} + +// Populate fills in the fields of the PartialTokenTransferFeeConfig with values from the provided TokenTransferFeeConfig +// and returns a new PartialTokenTransferFeeConfig. +func (cfg PartialTokenTransferFeeConfig) Populate(input TokenTransferFeeConfig) PartialTokenTransferFeeConfig { + return PartialTokenTransferFeeConfig{ + DefaultFinalityTransferFeeBps: utils.NewOptional(input.DefaultFinalityTransferFeeBps), + CustomFinalityTransferFeeBps: utils.NewOptional(input.CustomFinalityTransferFeeBps), + DefaultFinalityFeeUSDCents: utils.NewOptional(input.DefaultFinalityFeeUSDCents), + CustomFinalityFeeUSDCents: utils.NewOptional(input.CustomFinalityFeeUSDCents), + DestBytesOverhead: utils.NewOptional(input.DestBytesOverhead), + DestGasOverhead: utils.NewOptional(input.DestGasOverhead), + IsEnabled: utils.NewOptional(input.IsEnabled), + } +} + +// MergeWith fills in the missing fields in the PartialTokenTransferFeeConfig with values from +// the provided fallbacks TokenTransferFeeConfig and returns a complete TokenTransferFeeConfig. +func (cfg PartialTokenTransferFeeConfig) MergeWith(fallbacks TokenTransferFeeConfig) TokenTransferFeeConfig { + return TokenTransferFeeConfig{ + DefaultFinalityTransferFeeBps: cfg.DefaultFinalityTransferFeeBps.GetOrDefault(fallbacks.DefaultFinalityTransferFeeBps), + CustomFinalityTransferFeeBps: cfg.CustomFinalityTransferFeeBps.GetOrDefault(fallbacks.CustomFinalityTransferFeeBps), + DefaultFinalityFeeUSDCents: cfg.DefaultFinalityFeeUSDCents.GetOrDefault(fallbacks.DefaultFinalityFeeUSDCents), + CustomFinalityFeeUSDCents: cfg.CustomFinalityFeeUSDCents.GetOrDefault(fallbacks.CustomFinalityFeeUSDCents), + DestBytesOverhead: cfg.DestBytesOverhead.GetOrDefault(fallbacks.DestBytesOverhead), + DestGasOverhead: cfg.DestGasOverhead.GetOrDefault(fallbacks.DestGasOverhead), + IsEnabled: cfg.IsEnabled.GetOrDefault(fallbacks.IsEnabled), + } +} + // TokenTransferFeeConfig specifies configuration for a token transfer fee on a token pool. type TokenTransferFeeConfig struct { - // DestGasOverhead is the gas overhead for the token transfer. - DestGasOverhead uint32 - // DestBytesOverhead is the bytes overhead for the token transfer. - DestBytesOverhead uint32 - // DefaultFinalityFeeUSDCents is the flat fee for a default finality transfer. - DefaultFinalityFeeUSDCents uint32 - // CustomFinalityFeeUSDCents is the flat fee for a custom finality transfer. - CustomFinalityFeeUSDCents uint32 - // DefaultFinalityTransferFeeBps is the bps fee for a default finality transfer. - DefaultFinalityTransferFeeBps uint16 - // CustomFinalityTransferFeeBps is the bps fee for a custom finality transfer. - CustomFinalityTransferFeeBps uint16 - // IsEnabled is whether the token transfer fee config is enabled. - IsEnabled bool + DefaultFinalityTransferFeeBps uint16 `yaml:"defaultFinalityTransferFeeBps" json:"defaultFinalityTransferFeeBps"` + CustomFinalityTransferFeeBps uint16 `yaml:"customFinalityTransferFeeBps" json:"customFinalityTransferFeeBps"` + DefaultFinalityFeeUSDCents uint32 `yaml:"defaultFinalityFeeUSDCents" json:"defaultFinalityFeeUSDCents"` + CustomFinalityFeeUSDCents uint32 `yaml:"customFinalityFeeUSDCents" json:"customFinalityFeeUSDCents"` + DestBytesOverhead uint32 `yaml:"destBytesOverhead" json:"destBytesOverhead"` + DestGasOverhead uint32 `yaml:"destGasOverhead" json:"destGasOverhead"` + IsEnabled bool `yaml:"isEnabled" json:"isEnabled"` } // RateLimiterConfigFloatInput is the user-friendly version of RateLimiterConfig that accepts @@ -219,7 +259,7 @@ type RemoteChainConfig[R any, CCV any] struct { // InboundCCVsToAddAboveThreshold specifies the verifiers to apply to inbound traffic above the threshold. InboundCCVsToAddAboveThreshold []CCV `yaml:"inboundCCVsToAddAboveThreshold" json:"inboundCCVsToAddAboveThreshold"` // TokenTransferFeeConfig specifies the desired token transfer fee configuration for this remote chain. - TokenTransferFeeConfig TokenTransferFeeConfig `yaml:"tokenTransferFeeConfig" json:"tokenTransferFeeConfig"` + TokenTransferFeeConfig *PartialTokenTransferFeeConfig `yaml:"tokenTransferFeeConfig" json:"tokenTransferFeeConfig"` } // GetOutboundRateLimitBuckets returns the outbound RL configuration as a RemoteOutbounds struct. The diff --git a/deployment/tokens/revoke_admin_role.go b/deployment/tokens/revoke_admin_role.go new file mode 100644 index 0000000000..7e44002003 --- /dev/null +++ b/deployment/tokens/revoke_admin_role.go @@ -0,0 +1,184 @@ +package tokens + +import ( + "errors" + "fmt" + + "github.com/Masterminds/semver/v3" + chain_selectors "github.com/smartcontractkit/chain-selectors" + mcms_types "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +type RevokeTokenAdminRoleInput struct { + ChainAdapterVersion *semver.Version `yaml:"chainAdapterVersion" json:"chainAdapterVersion"` + Revocations []RevokeTokenAdminRoleConfig `yaml:"revocations" json:"revocations"` + MCMS mcms.Input `yaml:"mcms,omitempty" json:"mcms"` +} + +type RevokeTokenAdminRoleConfig struct { + // ChainSelector identifies the chain that the token exists on + ChainSelector uint64 `yaml:"chainSelector" json:"chainSelector"` + + // TokenRef is a reference to the token in the datastore. It is + // expected that the token supports role-based access control. + TokenRef datastore.AddressRef `yaml:"tokenRef" json:"tokenRef"` + + // AdminAddress is the address that currently has the admin role + // on the token and from which the role will be revoked. If this + // is empty, then the changeset will fallback to revoking access + // from timelock. If timelock is not deployed on the chain, then + // the changeset will fallback to the deployer key. If the final + // account does not have admin role, then this changeset becomes + // a no-op, though other configured actions (such as granting an + // admin role to FallbackAddress) may still be performed. + AdminAddress string `yaml:"adminAddress,omitempty" json:"adminAddress,omitempty"` + + // FallbackAddress is a defensive input that prevents the token + // from being put into a state where it has no remaining admins + // after the revocation. If the FallbackAddress doesn't have an + // admin role on the token, then the changeset will grant it to + // the account BEFORE it revokes access from AdminAddress. This + // field is optional - if it is unspecified, then the changeset + // will only perform the revocation. If the value of this field + // is the same as AdminAddress then only the revocation will be + // performed. It's strongly recommended to use this field as it + // can help avoid scenarios where the token contract is left in + // a state with no admins. + FallbackAddress string `yaml:"fallbackAddress,omitempty" json:"fallbackAddress,omitempty"` +} + +type RevokeTokenAdminRoleSequenceInput struct { + ChainSelector uint64 + TokenRef datastore.AddressRef + AdminAddress string + FallbackAddress string + TimelockAddress string +} + +func RevokeTokenAdminRole() cldf.ChangeSetV2[RevokeTokenAdminRoleInput] { + return cldf.CreateChangeSet( + revokeTokenAdminRoleApply(GetTokenAdapterRegistry(), changesets.GetRegistry()), + revokeTokenAdminRoleVerify(GetTokenAdapterRegistry()), + ) +} + +func revokeTokenAdminRoleVerify(tokenRegistry *TokenAdapterRegistry) func(cldf.Environment, RevokeTokenAdminRoleInput) error { + return func(e cldf.Environment, cfg RevokeTokenAdminRoleInput) error { + if len(cfg.Revocations) == 0 { + return errors.New("at least one token admin role revocation is required") + } + + version := cfg.ChainAdapterVersion + if version == nil { + return errors.New("chain adapter version is required") + } + + for i, revocation := range cfg.Revocations { + selector := revocation.ChainSelector + if revocation.TokenRef.ChainSelector != 0 && revocation.TokenRef.ChainSelector != revocation.ChainSelector { + return fmt.Errorf("revocation[%d]: chain selector mismatch in TokenRef: expected %d, got %d", i, revocation.ChainSelector, revocation.TokenRef.ChainSelector) + } + if datastore_utils.IsAddressRefEmpty(revocation.TokenRef) { + return fmt.Errorf("revocation[%d]: token ref is required", i) + } + if !e.BlockChains.Exists(selector) { + return fmt.Errorf("revocation[%d]: chain selector %d not found in environment", i, selector) + } + + family, err := chain_selectors.GetSelectorFamily(selector) + if err != nil { + return fmt.Errorf("revocation[%d]: invalid chain selector %d: %w", i, selector, err) + } + adapter, exists := tokenRegistry.GetTokenAdapter(family, version) + if !exists { + return fmt.Errorf("revocation[%d]: no token adapter registered for chain family '%s' and version '%v'", i, family, version) + } + if _, ok := adapter.(TokenAdminRoleAdapter); !ok { + return fmt.Errorf("revocation[%d]: token adapter for chain family '%s' and version '%v' does not support token admin role revocation", i, family, version) + } + } + + return nil + } +} + +func revokeTokenAdminRoleApply(tokenRegistry *TokenAdapterRegistry, mcmsRegistry *changesets.MCMSReaderRegistry) func(cldf.Environment, RevokeTokenAdminRoleInput) (cldf.ChangesetOutput, error) { + return func(e cldf.Environment, cfg RevokeTokenAdminRoleInput) (cldf.ChangesetOutput, error) { + batchOps := make([]mcms_types.BatchOperation, 0) + reports := make([]cldf_ops.Report[any, any], 0) + + version := cfg.ChainAdapterVersion + if version == nil { + return cldf.ChangesetOutput{}, errors.New("chain adapter version is required") + } + + for i, revocation := range cfg.Revocations { + selector := revocation.ChainSelector + + family, err := chain_selectors.GetSelectorFamily(selector) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("revocation[%d]: invalid chain selector %d: %w", i, selector, err) + } + tokenAdapter, ok := tokenRegistry.GetTokenAdapter(family, version) + if !ok { + return cldf.ChangesetOutput{}, fmt.Errorf("revocation[%d]: no token adapter registered for chain family '%s' and version '%v'", i, family, version) + } + roleAdapter, ok := tokenAdapter.(TokenAdminRoleAdapter) + if !ok { + return cldf.ChangesetOutput{}, fmt.Errorf("revocation[%d]: token adapter for chain family '%s' and version '%v' does not support token admin role revocation", i, family, version) + } + tokenRef, err := datastore_utils.FindAndFormatRef(e.DataStore, revocation.TokenRef, revocation.ChainSelector, datastore_utils.FullRef) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("revocation[%d]: failed to resolve token ref: %w", i, err) + } + + // NOTE: if after resolution, the timelock address is still empty, the adapter should fall back to the deployer key + var timelockAddress string + if mcmsReader, ok := mcmsRegistry.GetMCMSReader(family); ok { + timelockRef, err := mcmsReader.GetTimelockRef(e, selector, cfg.MCMS) + switch { + case err != nil: + e.Logger.Warnf("failed to resolve timelock address for revocation[%d] on chain selector %d: %v", i, selector, err) + case datastore_utils.IsAddressRefEmpty(timelockRef): + e.Logger.Warnf("timelock ref is empty for revocation[%d] on chain selector %d", i, selector) + default: + timelockAddress = timelockRef.Address + } + } + + adminAddress := revocation.AdminAddress + if adminAddress == "" { + adminAddress = timelockAddress + } + if adminAddress == "" { + e.Logger.Warnf("admin address not provided for revocation[%d] on chain selector %d, and timelock address could not be resolved. This changeset will attempt to fall back to the deployer key.", i, selector) + } + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, roleAdapter.RevokeTokenAdminRole(), e.BlockChains, RevokeTokenAdminRoleSequenceInput{ + ChainSelector: revocation.ChainSelector, + FallbackAddress: revocation.FallbackAddress, + TimelockAddress: timelockAddress, + AdminAddress: adminAddress, + TokenRef: tokenRef, + }) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("revocation[%d]: failed to revoke token admin role: %w", i, err) + } + + batchOps = append(batchOps, report.Output.BatchOps...) + reports = append(reports, report.ExecutionReports...) + } + + return changesets.NewOutputBuilder(e, mcmsRegistry). + WithReports(reports). + WithBatchOps(batchOps). + Build(cfg.MCMS) + } +} diff --git a/deployment/utils/common.go b/deployment/utils/common.go index 73097032da..03cf616591 100644 --- a/deployment/utils/common.go +++ b/deployment/utils/common.go @@ -42,6 +42,7 @@ const ( ERC677TokenHelper cldf.ContractType = "ERC677TokenHelper" USDCTokenPool cldf.ContractType = "USDCTokenPool" CCTPMessageTransmitterProxy cldf.ContractType = "CCTPMessageTransmitterProxy" + SiloedLockReleaseTokenPool cldf.ContractType = "SiloedLockReleaseTokenPool" HybridLockReleaseUSDCTokenPool cldf.ContractType = "HybridLockReleaseUSDCTokenPool" BurnMintWithExternalMinterTokenPool cldf.ContractType = "BurnMintWithExternalMinterTokenPool" HybridWithExternalMinterTokenPool cldf.ContractType = "HybridWithExternalMinterTokenPool" diff --git a/deployment/utils/datastore/datastore.go b/deployment/utils/datastore/datastore.go index d6aaab2e81..5b33bda12b 100644 --- a/deployment/utils/datastore/datastore.go +++ b/deployment/utils/datastore/datastore.go @@ -92,6 +92,15 @@ func findRef(ds datastore.DataStore, ref datastore.AddressRef) (datastore.Addres return refs[0], 1, nil } +// IsAddressRefFullyPopulated checks if an AddressRef has all fields populated (except Labels). +func IsAddressRefFullyPopulated(ref datastore.AddressRef) bool { + return ref.Address != "" && + ref.Type != "" && + ref.Version != nil && + ref.Qualifier != "" && + ref.ChainSelector != 0 +} + // IsAddressRefEmpty checks if an AddressRef is empty. func IsAddressRefEmpty(ref datastore.AddressRef) bool { return ref.Address == "" && diff --git a/deployment/utils/optional.go b/deployment/utils/optional.go index 8a93868859..c24760b14c 100644 --- a/deployment/utils/optional.go +++ b/deployment/utils/optional.go @@ -1,8 +1,16 @@ package utils +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + // Optional represents a value that may or may not be explicitly set. This // is useful in scenarios where a changeset only needs to partially update -// a subset of fields in a large configuration struct. +// a subset of fields in a large configuration struct. In YAML files, it's +// not strictly necessary to define both `value` and `valid` keys; instead +// see optional_test.go for the supported YAML shapes and their semantics. type Optional[T any] struct { // Valid indicates whether the provided value should be used. If this is // false (the default), then the provided value will be ignored. @@ -34,3 +42,62 @@ func (o Optional[T]) GetOrDefault(fallback T) T { func (o Optional[T]) Get() (T, bool) { return o.Value, o.Valid } + +// UnmarshalYAML supports three YAML representations: +// - Very-Verbose: `field: {value: 32, valid: false}` → backwards-compatible explicit control +// - Semi-verbose: `field: {value: 32}` → {Value: 32, Valid: true} (inferred) +// - Shorthand: `field: 32` → {Value: 32, Valid: true} +func (o *Optional[T]) UnmarshalYAML(node *yaml.Node) error { + // Explicit null (`field: ~` or `field:`) — omitted equivalent, Valid stays false + if node.Kind == yaml.ScalarNode && node.Tag == "!!null" { + return nil + } + + // Shorthand: `field: ` — infer value and valid=true + if node.Kind == yaml.ScalarNode { + if err := node.Decode(&o.Value); err != nil { + return err + } + o.Valid = true + return nil + } + + // If we're NOT using the shorthand, then we should expect a mapping node + if node.Kind != yaml.MappingNode { + return fmt.Errorf("optional: expected scalar or mapping, got node kind %v", node.Kind) + } + + // Scan keys present in the mapping + hasValid, hasValue := false, false + for i := 0; i < len(node.Content)-1; i += 2 { + switch node.Content[i].Value { + case "valid": + hasValid = true + case "value": + hasValue = true + } + } + + // Decode via a local anonymous struct to avoid infinite recursion + // (a named type alias doesn't work cleanly with generics in Go) + var raw struct { + Valid bool `yaml:"valid"` + Value T `yaml:"value"` + } + if err := node.Decode(&raw); err != nil { + return err + } + + // Infer whether `Valid` should be true or false based on which keys were present in the mapping + o.Value = raw.Value + switch { + case hasValid: + o.Valid = raw.Valid // explicit — backwards-compatible + case hasValue: + o.Valid = true // value present, no valid key → infer true + default: + o.Valid = false // empty mapping → zero value, Valid stays false + } + + return nil +} diff --git a/deployment/utils/optional_test.go b/deployment/utils/optional_test.go new file mode 100644 index 0000000000..1da47aa3fa --- /dev/null +++ b/deployment/utils/optional_test.go @@ -0,0 +1,125 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestOptionalUnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yaml string + wantValue int + wantValid bool + wantErr bool + }{ + {name: "verbose: value and valid false", yaml: "{value: 50, valid: false}", wantValue: 50, wantValid: false}, + {name: "verbose: value and valid true", yaml: "{value: 100, valid: true}", wantValue: 100, wantValid: true}, + {name: "semi-verbose: valid only", yaml: "valid: false", wantValue: 0, wantValid: false}, + {name: "semi-verbose: value only", yaml: "value: 32", wantValue: 32, wantValid: true}, + {name: "empty value (null)", yaml: "", wantValue: 0, wantValid: false}, + {name: "shorthand scalar", yaml: "4", wantValue: 4, wantValid: true}, + {name: "empty mapping", yaml: "{}", wantValue: 0, wantValid: false}, + {name: "explicit null", yaml: "~", wantValue: 0, wantValid: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var opt Optional[int] + err := yaml.Unmarshal([]byte(tt.yaml), &opt) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantValue, opt.Value) + require.Equal(t, tt.wantValid, opt.Valid) + }) + } +} + +func TestOptionalUnmarshalYAMLWithStruct(t *testing.T) { + type config struct { + Arg1 Optional[string] `yaml:"arg1"` + Arg2 Optional[int] `yaml:"arg2"` + } + + var cfg1 config + err := yaml.Unmarshal([]byte("arg1: hello\narg2: 42"), &cfg1) + require.NoError(t, err) + require.Equal(t, "hello", cfg1.Arg1.Value) + require.True(t, cfg1.Arg1.Valid) + require.Equal(t, 42, cfg1.Arg2.Value) + require.True(t, cfg1.Arg2.Valid) + + var cfg2 config + err = yaml.Unmarshal([]byte("arg1: world"), &cfg2) + require.NoError(t, err) + require.Equal(t, "world", cfg2.Arg1.Value) + require.True(t, cfg2.Arg1.Valid) + require.Equal(t, 0, cfg2.Arg2.Value) + require.False(t, cfg2.Arg2.Valid) + + var cfg3 config + err = yaml.Unmarshal([]byte("arg2: 100"), &cfg3) + require.NoError(t, err) + require.Equal(t, "", cfg3.Arg1.Value) + require.False(t, cfg3.Arg1.Valid) + require.Equal(t, 100, cfg3.Arg2.Value) + require.True(t, cfg3.Arg2.Valid) + + existing := config{Arg1: NewOptional("hi"), Arg2: NewOptional(10)} + err = yaml.Unmarshal([]byte("arg1: bye"), &existing) + require.NoError(t, err) + require.Equal(t, "bye", existing.Arg1.Value) + require.True(t, existing.Arg1.Valid) + require.Equal(t, 10, existing.Arg2.Value) + require.True(t, existing.Arg2.Valid) +} + +func TestOptionalUnmarshalYAMLWithString(t *testing.T) { + tests := []struct { + name string + yaml string + wantValue string + wantValid bool + }{ + {name: "string semi-verbose", yaml: "value: world", wantValue: "world", wantValid: true}, + {name: "string shorthand", yaml: `"hello"`, wantValue: "hello", wantValid: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var opt Optional[string] + err := yaml.Unmarshal([]byte(tt.yaml), &opt) + require.NoError(t, err) + require.Equal(t, tt.wantValue, opt.Value) + require.Equal(t, tt.wantValid, opt.Valid) + }) + } +} + +func TestOptionalUnmarshalYAMLWithBool(t *testing.T) { + tests := []struct { + name string + yaml string + wantValue bool + wantValid bool + }{ + {name: "bool semi-verbose true", yaml: "value: true", wantValue: true, wantValid: true}, + {name: "bool shorthand false", yaml: "false", wantValue: false, wantValid: true}, + {name: "bool shorthand true", yaml: "true", wantValue: true, wantValid: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var opt Optional[bool] + err := yaml.Unmarshal([]byte(tt.yaml), &opt) + require.NoError(t, err) + require.Equal(t, tt.wantValue, opt.Value) + require.Equal(t, tt.wantValid, opt.Valid) + }) + } +} diff --git a/deployment/v2_0_0/offchain/topology.go b/deployment/v2_0_0/offchain/topology.go index c4827113be..7a430da252 100644 --- a/deployment/v2_0_0/offchain/topology.go +++ b/deployment/v2_0_0/offchain/topology.go @@ -14,7 +14,7 @@ import ( "github.com/smartcontractkit/chainlink-ccip/deployment/v2_0_0/offchain/shared" ) -const minProductionChainNOPs = 15 // this is temporarily 15 while we replace AlphaChain +const minProductionChainNOPs = 15 // EnvironmentTopology holds all environment-specific configuration that cannot be inferred // from the datastore. This serves as the single source of truth for the desired state of both off-chain diff --git a/devenv/go.mod b/devenv/go.mod index be6a4f2dc1..7a65bd1bbf 100644 --- a/devenv/go.mod +++ b/devenv/go.mod @@ -39,7 +39,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260121163256-85accaf3d28d github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260312233953-f588f8dc6d7c - github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260520205139-e02dace3eefa + github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260526052449-0ceed63f1a5a github.com/smartcontractkit/chainlink-common v0.11.2-0.20260417081611-8bdbd9f45629 github.com/smartcontractkit/chainlink-deployments-framework v0.101.1 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260410162948-2dca02f24e98 // indirect diff --git a/execute/tokendata/observer/observer_background_test.go b/execute/tokendata/observer/observer_background_test.go index 05d794293e..655595f280 100644 --- a/execute/tokendata/observer/observer_background_test.go +++ b/execute/tokendata/observer/observer_background_test.go @@ -118,32 +118,44 @@ func assertTokenDataEmptyInitially(t *testing.T, func testCacheExpirationAndShutdown( t *testing.T, rawObserver *backgroundObserver, numMsgsPerChain map[cciptypes.ChainSelector]int) { - // keep only len(chains) messages in the cache - msgsToKeep := len(numMsgsPerChain) - i := 0 rawObserver.cachedTokenData.mu.Lock() + cacheSize := len(rawObserver.cachedTokenData.inMemTokenData) + require.Greater(t, cacheSize, 1, "need multiple cached messages to test expiration") + + // Keep at most len(chains) entries, but never more than are actually cached. + // Error messages are never cached, so cacheSize can be smaller than len(chains). + msgsToKeep := min(len(numMsgsPerChain), cacheSize-1) + expiredAt := time.Now().Add(-time.Second).UTC() + i := 0 for msgID := range rawObserver.cachedTokenData.inMemTokenData { if i < msgsToKeep { i++ continue } - rawObserver.cachedTokenData.expiresAt[msgID] = time.Now() + rawObserver.cachedTokenData.expiresAt[msgID] = expiredAt } rawObserver.cachedTokenData.mu.Unlock() - // run another expiration loop to remove expired messages - rawObserver.cachedTokenData.runExpirationLoop(time.Millisecond) require.Eventually(t, func() bool { - rawObserver.cachedTokenData.mu.RLock() - totalMsgs := len(rawObserver.cachedTokenData.inMemTokenData) - rawObserver.cachedTokenData.mu.RUnlock() - return msgsToKeep == totalMsgs + purgeExpiredFromCache(rawObserver.cachedTokenData) + return msgsToKeep == rawObserver.cachedTokenData.size() }, tests.WaitTimeout(t), 50*time.Millisecond) // graceful shutdown assert.NoError(t, rawObserver.Close()) } +func purgeExpiredFromCache(c *inMemTokenDataCache) { + c.mu.Lock() + defer c.mu.Unlock() + for msgID := range c.expiresAt { + if c.hasExpired(msgID) { + delete(c.inMemTokenData, msgID) + delete(c.expiresAt, msgID) + } + } +} + func generateMsgObservations( numMsgsPerChain map[cciptypes.ChainSelector]int, ) (exectypes.MessageObservations, map[cciptypes.ChainSelector][]cciptypes.SeqNum) { diff --git a/integration-tests/deployment/helpers.go b/integration-tests/deployment/helpers.go index 83bb2e6cd8..e2e2fef13a 100644 --- a/integration-tests/deployment/helpers.go +++ b/integration-tests/deployment/helpers.go @@ -1,6 +1,9 @@ package deployment import ( + "crypto/rand" + "encoding/hex" + "fmt" "math" "math/big" "testing" @@ -16,6 +19,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/operations" "github.com/stretchr/testify/require" + bnmERC20Operations "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" evmrouterops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_2_0/operations/router" evmofframpops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/offramp" evmonrampops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/onramp" @@ -28,11 +32,12 @@ import ( offrampops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/offramp" rmnremoteops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/rmn_remote" routerops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/router" - "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" mcmsapi "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" "github.com/smartcontractkit/chainlink-ccip/deployment/testhelpers" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" common_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + bnmERC20Bindings "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc20" mcms_types "github.com/smartcontractkit/mcms/types" @@ -406,8 +411,8 @@ func MergeAddresses(t *testing.T, env *cldf_deployment.Environment, ds datastore } } -func NewDefaultDeploymentConfigForSolana(version *semver.Version) deploy.ContractDeploymentConfigPerChain { - return deploy.ContractDeploymentConfigPerChain{ +func NewDefaultDeploymentConfigForSolana(version *semver.Version) mcmsapi.ContractDeploymentConfigPerChain { + return mcmsapi.ContractDeploymentConfigPerChain{ Version: version, MaxFeeJuelsPerMsg: big.NewInt(0).Mul(big.NewInt(200), big.NewInt(1e18)), TokenPriceStalenessThreshold: uint32(24 * 60 * 60), @@ -418,8 +423,8 @@ func NewDefaultDeploymentConfigForSolana(version *semver.Version) deploy.Contrac } } -func NewDefaultDeploymentConfigForEVM(version *semver.Version) deploy.ContractDeploymentConfigPerChain { - return deploy.ContractDeploymentConfigPerChain{ +func NewDefaultDeploymentConfigForEVM(version *semver.Version) mcmsapi.ContractDeploymentConfigPerChain { + return mcmsapi.ContractDeploymentConfigPerChain{ Version: version, MaxFeeJuelsPerMsg: big.NewInt(0).Mul(big.NewInt(200), big.NewInt(1e18)), TokenPriceStalenessThreshold: uint32(24 * 60 * 60), @@ -492,3 +497,81 @@ func RequireBigIntsEqual(t *testing.T, want, got *big.Int, msg string) { } require.Zero(t, w.Cmp(g), "%s: want %s got %s", msg, w.String(), g.String()) } + +func NewRandHex(t *testing.T, nBytes int) string { + t.Helper() + data := make([]byte, nBytes) + _, err := rand.Read(data) + require.NoError(t, err) + return hex.EncodeToString(data) +} + +func DeployBurnMintPoolEVM(t *testing.T, env *cldf_deployment.Environment, selector uint64, version *semver.Version, token common.Address) datastore.AddressRef { + t.Helper() + + poolQualifier := "Pool-" + NewRandHex(t, 32) + poolType := common_utils.BurnMintTokenPool.String() + + output, err := tokensapi.TokenExpansion().Apply(*env, tokensapi.TokenExpansionInput{ + ChainAdapterVersion: version, + MCMS: NewDefaultInputForMCMS(fmt.Sprintf("Deploy %s", poolQualifier)), + TokenExpansionInputPerChain: map[uint64]tokensapi.TokenExpansionInputPerChain{ + selector: { + TokenPoolVersion: version, + DeployTokenPoolInput: &tokensapi.DeployTokenPoolInput{ + TokenRef: &datastore.AddressRef{Address: token.Hex()}, + TokenPoolQualifier: poolQualifier, + PoolType: poolType, + }, + }, + }, + }) + require.NoError(t, err) + MergeAddresses(t, env, output.DataStore) + testhelpers.ProcessTimelockProposals(t, *env, output.MCMSTimelockProposals, false) + + dsFilter := datastore.AddressRef{ChainSelector: selector, Qualifier: poolQualifier, Type: datastore.ContractType(poolType)} + ref, err := datastore_utils.FindAndFormatRef(env.DataStore, dsFilter, selector, datastore_utils.FullRef) + require.NoError(t, err) + + return ref +} + +func DeployBurnMintTokenEVM(t *testing.T, env *cldf_deployment.Environment, selector uint64, admin string) *bnmERC20Bindings.BurnMintERC20 { + t.Helper() + + chain, ok := env.BlockChains.EVMChains()[selector] + require.True(t, ok) + + symbol := "Token-" + NewRandHex(t, 32) + output, err := tokensapi.TokenExpansion().Apply(*env, tokensapi.TokenExpansionInput{ + ChainAdapterVersion: common_utils.Version_1_0_0, + MCMS: NewDefaultInputForMCMS(fmt.Sprintf("Deploy %s", symbol)), + TokenExpansionInputPerChain: map[uint64]tokensapi.TokenExpansionInputPerChain{ + selector: { + DeployTokenInput: &tokensapi.DeployTokenInput{ + ExternalAdmin: admin, + CCIPAdmin: admin, + Decimals: uint8(18), + Supply: nil, // unlimited supply + Symbol: symbol, + Name: symbol + " Token", + Type: bnmERC20Operations.ContractType, + }, + }, + }, + }) + require.NoError(t, err) + MergeAddresses(t, env, output.DataStore) + testhelpers.ProcessTimelockProposals(t, *env, output.MCMSTimelockProposals, false) + + dsFilter := datastore.AddressRef{ChainSelector: selector, Qualifier: symbol, Type: datastore.ContractType(bnmERC20Operations.ContractType)} + ref, err := datastore_utils.FindAndFormatRef(env.DataStore, dsFilter, selector, datastore_utils.FullRef) + require.NoError(t, err) + + require.True(t, common.IsHexAddress(ref.Address)) + token, err := bnmERC20Bindings.NewBurnMintERC20(common.HexToAddress(ref.Address), chain.Client) + require.NoError(t, err) + + return token +} diff --git a/integration-tests/deployment/revoke_token_admin_role_test.go b/integration-tests/deployment/revoke_token_admin_role_test.go new file mode 100644 index 0000000000..04ec507794 --- /dev/null +++ b/integration-tests/deployment/revoke_token_admin_role_test.go @@ -0,0 +1,404 @@ +package deployment + +import ( + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + chainsel "github.com/smartcontractkit/chain-selectors" + bnmERC20ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + deployapi "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" + "github.com/smartcontractkit/chainlink-ccip/deployment/testhelpers" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + cciputils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/onchain" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/stretchr/testify/require" + + _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" + _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/adapters" +) + +func TestRevokeTokenAdminRoleVerifyPreconditions(t *testing.T) { + chainSelector := chainsel.TEST_90000001.Selector + env, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{chainSelector})) + require.NoError(t, err) + + tokenRef := datastore.AddressRef{ + ChainSelector: chainSelector, + Address: common.HexToAddress("0x00000000000000000000000000000000000000aa").Hex(), + Type: datastore.ContractType(bnmERC20ops.ContractType), + Version: cciputils.Version_1_0_0, + Qualifier: "REVOKE_VERIFY", + } + ds := datastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(tokenRef)) + MergeAddresses(t, env, ds) + + cs := tokensapi.RevokeTokenAdminRole() + tests := []struct { + input tokensapi.RevokeTokenAdminRoleInput + title string + error string + }{ + { + title: "requires chain adapter version", + error: "chain adapter version is required", + input: tokensapi.RevokeTokenAdminRoleInput{ + Revocations: []tokensapi.RevokeTokenAdminRoleConfig{{ + ChainSelector: chainSelector, + TokenRef: tokenRef, + }}, + }, + }, + { + title: "requires revocations", + error: "at least one token admin role revocation is required", + input: tokensapi.RevokeTokenAdminRoleInput{ChainAdapterVersion: cciputils.Version_1_0_0}, + }, + { + title: "requires chain in environment", + error: "not found in environment", + input: tokensapi.RevokeTokenAdminRoleInput{ + ChainAdapterVersion: cciputils.Version_1_0_0, + Revocations: []tokensapi.RevokeTokenAdminRoleConfig{{ + ChainSelector: chainSelector + 1, + TokenRef: datastore.AddressRef{ChainSelector: chainSelector + 1}, + }}, + }, + }, + { + title: "requires token ref", + error: "token ref is required", + input: tokensapi.RevokeTokenAdminRoleInput{ + ChainAdapterVersion: cciputils.Version_1_0_0, + Revocations: []tokensapi.RevokeTokenAdminRoleConfig{{ + ChainSelector: chainSelector, + }}, + }, + }, + { + title: "rejects token ref chain mismatch", + error: "chain selector mismatch", + input: tokensapi.RevokeTokenAdminRoleInput{ + ChainAdapterVersion: cciputils.Version_1_0_0, + Revocations: []tokensapi.RevokeTokenAdminRoleConfig{{ + ChainSelector: chainSelector, + TokenRef: datastore.AddressRef{ + ChainSelector: chainSelector + 1, + Address: tokenRef.Address, + }, + }}, + }, + }, + { + title: "accepts datastore token ref by address", + input: tokensapi.RevokeTokenAdminRoleInput{ + ChainAdapterVersion: cciputils.Version_1_0_0, + Revocations: []tokensapi.RevokeTokenAdminRoleConfig{{ + ChainSelector: chainSelector, + TokenRef: datastore.AddressRef{ + Address: tokenRef.Address, + }, + }}, + }, + }, + { + title: "accepts address and type without datastore version", + input: tokensapi.RevokeTokenAdminRoleInput{ + ChainAdapterVersion: cciputils.Version_1_0_0, + Revocations: []tokensapi.RevokeTokenAdminRoleConfig{{ + ChainSelector: chainSelector, + TokenRef: datastore.AddressRef{ + Address: "0x00000000000000000000000000000000000000ff", + Type: tokenRef.Type, + }, + }}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + err := cs.VerifyPreconditions(*env, tt.input) + if tt.error != "" { + require.ErrorContains(t, err, tt.error) + return + } + require.NoError(t, err) + }) + } +} + +func TestRevokeTokenAdminRoleTimelock(t *testing.T) { + // Setup environment + selector := chainsel.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulatedWithConfig(t, + []uint64{selector}, + onchain.EVMSimLoaderConfig{NumAdditionalAccounts: 1}, + ), + ) + require.NoError(t, err) + + // Get customer address from environment + chain, ok := env.BlockChains.EVMChains()[selector] + require.True(t, ok) + require.NotEmpty(t, chain.Users) + customer := chain.Users[0].From + + // Deploy contracts + output, err := deployapi.DeployContracts(deployapi.GetRegistry()).Apply(*env, + deployapi.ContractDeploymentConfig{ + MCMS: mcms.Input{}, + Chains: map[uint64]deployapi.ContractDeploymentConfigPerChain{ + selector: NewDefaultDeploymentConfigForEVM(cciputils.Version_1_6_0), + }, + }, + ) + require.NoError(t, err) + MergeAddresses(t, env, output.DataStore) + + // Setup MCMS + DeployMCMS(t, env, selector, []string{cciputils.CLLQualifier}) + mcmsReader, ok := changesets.GetRegistry().GetMCMSReader(chainsel.FamilyEVM) + require.True(t, ok) + timelockRef, err := mcmsReader.GetTimelockRef(*env, selector, mcms.Input{Qualifier: cciputils.CLLQualifier}) + require.NoError(t, err) + require.True(t, common.IsHexAddress(timelockRef.Address)) + timelockAddress := common.HexToAddress(timelockRef.Address) + + t.Run("revoke timelock admin while customer admin remains", func(t *testing.T) { + // Make both the customer and timelock an admin on the token + token := DeployBurnMintTokenEVM(t, env, selector, customer.Hex()) + _ = DeployBurnMintPoolEVM(t, env, selector, cciputils.Version_1_6_1, token.Address()) + defaultAdminRole, err := token.DEFAULTADMINROLE(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + + // Verify token roles (timelock and customer are both admins) + hasRole, err := token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.True(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.True(t, hasRole) + + // The changeset should default to revoking timelock if no particular account is specified + revokeOutput, err := revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), "", customer.Hex()) + require.NoError(t, err) + require.Len(t, revokeOutput.MCMSTimelockProposals, 1) + testhelpers.ProcessTimelockProposals(t, *env, revokeOutput.MCMSTimelockProposals, false) + + // Verify token roles (customer is still an admin, timelock is not) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.False(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.True(t, hasRole) + + // Revoking again should not generate a proposal (we already revoked timelock) + revokeOutput, err = revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), "", customer.Hex()) + require.NoError(t, err) + require.Empty(t, revokeOutput.MCMSTimelockProposals) + + // Even with no fallback, revoking again should not generate a proposal + revokeOutput, err = revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), "", "") + require.NoError(t, err) + require.Empty(t, revokeOutput.MCMSTimelockProposals) + }) + + t.Run("bypass revoke protections", func(t *testing.T) { + // Only make timelock an admin on the token + token := DeployBurnMintTokenEVM(t, env, selector, timelockAddress.Hex()) + _ = DeployBurnMintPoolEVM(t, env, selector, cciputils.Version_1_6_1, token.Address()) + defaultAdminRole, err := token.DEFAULTADMINROLE(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + + // Verify token roles (timelock is an admin, customer is not) + hasRole, err := token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.True(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.False(t, hasRole) + + // We should be able to revoke admin if no fallback address is provided + revokeOutput, err := revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), "", "") + require.NoError(t, err) + testhelpers.ProcessTimelockProposals(t, *env, revokeOutput.MCMSTimelockProposals, false) + + // Verify token roles (neither timelock nor customer should be admins) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.False(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.False(t, hasRole) + }) + + t.Run("apply revoke protections", func(t *testing.T) { + // Only make timelock an admin on the token + token := DeployBurnMintTokenEVM(t, env, selector, timelockAddress.Hex()) + _ = DeployBurnMintPoolEVM(t, env, selector, cciputils.Version_1_6_1, token.Address()) + defaultAdminRole, err := token.DEFAULTADMINROLE(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + + // Verify token roles (timelock is an admin, customer is not) + hasRole, err := token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.True(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.False(t, hasRole) + + // The changeset should default to revoking timelock if no particular account is specified + revokeOutput, err := revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), "", customer.Hex()) + require.NoError(t, err) + testhelpers.ProcessTimelockProposals(t, *env, revokeOutput.MCMSTimelockProposals, false) + + // Verify token roles (customer is an admin, timelock is not) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.False(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.True(t, hasRole) + }) + + t.Run("mixed timelock and deployer", func(t *testing.T) { + // Only make timelock an admin on the token + token := DeployBurnMintTokenEVM(t, env, selector, "") + _ = DeployBurnMintPoolEVM(t, env, selector, cciputils.Version_1_6_1, token.Address()) + defaultAdminRole, err := token.DEFAULTADMINROLE(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + + // Verify token roles (timelock is an admin, deployer is not) + hasRole, err := token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.True(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, chain.DeployerKey.From) + require.NoError(t, err) + require.False(t, hasRole) + + // The changeset should default to revoking timelock if no particular account is specified + revokeOutput, err := revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), "", chain.DeployerKey.From.Hex()) + require.NoError(t, err) + require.Len(t, revokeOutput.MCMSTimelockProposals, 1) + testhelpers.ProcessTimelockProposals(t, *env, revokeOutput.MCMSTimelockProposals, false) + + // Verify token roles (deployer is an admin, timelock is not) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.False(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, chain.DeployerKey.From) + require.NoError(t, err) + require.True(t, hasRole) + + // We should be able to revoke the deployer key even if timelock was already revoked + revokeOutput, err = revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), chain.DeployerKey.From.Hex(), customer.Hex()) + require.NoError(t, err) + testhelpers.ProcessTimelockProposals(t, *env, revokeOutput.MCMSTimelockProposals, false) + + // Verify token roles (only customer should be an admin) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, chain.DeployerKey.From) + require.NoError(t, err) + require.False(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, timelockAddress) + require.NoError(t, err) + require.False(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.True(t, hasRole) + }) +} + +func TestRevokeTokenAdminRoleDeployer(t *testing.T) { + // Setup environment + selector := chainsel.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulatedWithConfig(t, + []uint64{selector}, + onchain.EVMSimLoaderConfig{NumAdditionalAccounts: 1}, + ), + ) + require.NoError(t, err) + + // Get customer address from environment + chain, ok := env.BlockChains.EVMChains()[selector] + require.True(t, ok) + require.NotEmpty(t, chain.Users) + customer := chain.Users[0].From + + // Deploy contracts + output, err := deployapi.DeployContracts(deployapi.GetRegistry()).Apply(*env, + deployapi.ContractDeploymentConfig{ + MCMS: mcms.Input{}, + Chains: map[uint64]deployapi.ContractDeploymentConfigPerChain{ + selector: NewDefaultDeploymentConfigForEVM(cciputils.Version_1_6_0), + }, + }, + ) + require.NoError(t, err) + MergeAddresses(t, env, output.DataStore) + + t.Run("revoke deployer key while customer admin remains", func(t *testing.T) { + // Make the deployer and customer admins on the token + token := DeployBurnMintTokenEVM(t, env, selector, customer.Hex()) + _ = DeployBurnMintPoolEVM(t, env, selector, cciputils.Version_1_6_1, token.Address()) + defaultAdminRole, err := token.DEFAULTADMINROLE(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + + // Verify token roles (deployer and customer are both admins) + hasRole, err := token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, chain.DeployerKey.From) + require.NoError(t, err) + require.True(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.True(t, hasRole) + + // The changeset should default to revoking the deployer key since timelock is not in the datastore + revokeOutput, err := revokeTokenAdminRoleForTest(t, env, selector, token.Address().Hex(), "", customer.Hex()) + require.NoError(t, err) + require.Empty(t, revokeOutput.MCMSTimelockProposals) + + // Verify token roles (customer is still an admin, deployer is not) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, chain.DeployerKey.From) + require.NoError(t, err) + require.False(t, hasRole) + hasRole, err = token.HasRole(&bind.CallOpts{Context: t.Context()}, defaultAdminRole, customer) + require.NoError(t, err) + require.True(t, hasRole) + }) +} + +func revokeTokenAdminRoleForTest(t *testing.T, + env *cldf_deployment.Environment, + chainSelector uint64, + tokenAddress string, + adminAddress string, + fallbackAddress string, +) (cldf_deployment.ChangesetOutput, error) { + t.Helper() + + env.OperationsBundle = cldf_ops.NewBundle(env.GetContext, env.Logger, cldf_ops.NewMemoryReporter()) + return tokensapi.RevokeTokenAdminRole().Apply(*env, tokensapi.RevokeTokenAdminRoleInput{ + MCMS: NewDefaultInputForMCMS("Revoke admin role on token"), + ChainAdapterVersion: cciputils.Version_1_0_0, + Revocations: []tokensapi.RevokeTokenAdminRoleConfig{ + { + ChainSelector: chainSelector, + FallbackAddress: fallbackAddress, + AdminAddress: adminAddress, + TokenRef: datastore.AddressRef{ + Address: tokenAddress, + }, + }, + }, + }) +} diff --git a/integration-tests/deployment/tokens_and_token_pools_test.go b/integration-tests/deployment/tokens_and_token_pools_test.go index 371dba1b24..83fc0506bd 100644 --- a/integration-tests/deployment/tokens_and_token_pools_test.go +++ b/integration-tests/deployment/tokens_and_token_pools_test.go @@ -3,6 +3,7 @@ package deployment import ( "bytes" "fmt" + "math" "math/big" "testing" @@ -10,11 +11,12 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" chainsel "github.com/smartcontractkit/chain-selectors" - evmadapters "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/ccip_common" "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v1_6_0/burnmint_token_pool" "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/state" "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/fees" + "github.com/smartcontractkit/chainlink-ccip/deployment/lanes" "github.com/smartcontractkit/chainlink-ccip/deployment/testhelpers" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -34,16 +36,19 @@ import ( solchain "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" bnmERC20gen "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc20" evmutils "github.com/smartcontractkit/chainlink-evm/pkg/utils" "github.com/stretchr/testify/require" + _ "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_0_0/adapters" + _ "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/adapters" + + _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_1/adapters" _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_1/adapters" - - // Registers SolanaAddressNormalizer on deployapi (v1_6_0 sequences do not import v1_0_0 adapters). - _ "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_0_0/adapters" + _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/adapters" ) func TestTokensAndTokenPools(t *testing.T) { @@ -83,14 +88,11 @@ func TestTokensAndTokenPools(t *testing.T) { solAdapter := solseqV1_6_0.SolanaAdapter{} evmAdapter := evmseqV1_6_0.EVMAdapter{} - // Configure deployment registry + // Get registries deployRegistry := deployapi.GetRegistry() - deployRegistry.RegisterDeployer(chainsel.FamilyEVM, deployapi.MCMSVersion, &evmadapters.EVMDeployer{}) - deployRegistry.RegisterDeployer(chainsel.FamilySolana, deployapi.MCMSVersion, &solAdapter) - - // Configure MCMS registry + lanesRegistry := lanes.GetLaneAdapterRegistry() mcmsRegistry := changesets.GetRegistry() - mcmsRegistry.RegisterMCMSReader(chainsel.FamilyEVM, &evmadapters.EVMMCMSReader{}) + feesRegistry := fees.GetRegistry() // Registration happens automatically, so the `Register...` // calls below aren't needed, but are left here for clarity @@ -110,8 +112,8 @@ func TestTokensAndTokenPools(t *testing.T) { Deployer *solana.PrivateKey Chain solchain.Chain Deploy deployapi.ContractDeploymentConfigPerChain - // RateLimitAdmin is optional; when set on BurnMint, DeployTokenPoolForToken should set it on-chain. - RateLimitAdmin string + FeeConfig tokensapi.PartialTokenTransferFeeConfig + RateLimitAdmin string }{ { TokenPoolQualifier: "", @@ -120,6 +122,12 @@ func TestTokensAndTokenPools(t *testing.T) { Deployer: solChain.DeployerKey, Chain: solChain, Deploy: NewDefaultDeploymentConfigForSolana(cciputils.Version_1_6_0), + FeeConfig: tokensapi.PartialTokenTransferFeeConfig{ + DefaultFinalityFeeUSDCents: cciputils.NewOptional(uint32(10)), // this will be mapped to minFeeUSDCents + CustomFinalityFeeUSDCents: cciputils.NewOptional(uint32(50)), // custom finality not applicable on v1.6.x, but specifying it should not cause an error + DestBytesOverhead: cciputils.NewOptional(uint32(200_000)), // override default value + DestGasOverhead: cciputils.Optional[uint32]{}, // let adapter choose a sensible default + }, Token: &tokensapi.DeployTokenInput{ Decimals: uint8(9), Symbol: "SOL_TEST", @@ -140,6 +148,12 @@ func TestTokensAndTokenPools(t *testing.T) { Deployer: solChain.DeployerKey, Chain: solChain, Deploy: NewDefaultDeploymentConfigForSolana(cciputils.Version_1_6_0), + FeeConfig: tokensapi.PartialTokenTransferFeeConfig{ + DefaultFinalityFeeUSDCents: cciputils.NewOptional(uint32(50)), // this will be mapped to minFeeUSDCents + CustomFinalityFeeUSDCents: cciputils.NewOptional(uint32(10)), // custom finality not applicable on v1.6.x, but specifying it should not cause an error + DestBytesOverhead: cciputils.Optional[uint32]{}, // let adapter choose a sensible default + DestGasOverhead: cciputils.NewOptional(uint32(50_000)), // override default value + }, Token: &tokensapi.DeployTokenInput{ Decimals: uint8(9), Symbol: "SOL_TEST2", @@ -161,6 +175,8 @@ func TestTokensAndTokenPools(t *testing.T) { // between token pools for different tokens, a qualifier is needed. // // Define testing data for EVM + evmInitDeciBpsA := cciputils.NewOptional(uint16(50)) + evmInitDeciBpsB := cciputils.NewOptional(uint16(75)) evmTestData := []struct { TokenPoolQualifier string Token *tokensapi.DeployTokenInput @@ -168,6 +184,7 @@ func TestTokensAndTokenPools(t *testing.T) { TAR *tarbindings.TokenAdminRegistry Chain evmchain.Chain Deploy deployapi.ContractDeploymentConfigPerChain + FeeConfig tokensapi.PartialTokenTransferFeeConfig RateLimitAdmin string }{ { @@ -177,6 +194,12 @@ func TestTokensAndTokenPools(t *testing.T) { Chain: evmChainA, TAR: nil, // populated later Deploy: NewDefaultDeploymentConfigForEVM(cciputils.Version_1_6_0), + FeeConfig: tokensapi.PartialTokenTransferFeeConfig{ + DefaultFinalityFeeUSDCents: cciputils.NewOptional(uint32(10)), // this will be mapped to minFeeUSDCents + CustomFinalityFeeUSDCents: cciputils.NewOptional(uint32(50)), // custom finality not applicable on v1.6.x, but specifying it should not cause an error + DestBytesOverhead: cciputils.NewOptional(uint32(200_000)), // override default value + DestGasOverhead: cciputils.Optional[uint32]{}, // let adapter choose a sensible default + }, Token: &tokensapi.DeployTokenInput{ Decimals: uint8(18), Symbol: "EVM_TEST_A", @@ -198,6 +221,12 @@ func TestTokensAndTokenPools(t *testing.T) { Chain: evmChainB, TAR: nil, // populated later Deploy: NewDefaultDeploymentConfigForEVM(cciputils.Version_1_6_0), + FeeConfig: tokensapi.PartialTokenTransferFeeConfig{ + DefaultFinalityFeeUSDCents: cciputils.NewOptional(uint32(50)), // this will be mapped to minFeeUSDCents + CustomFinalityFeeUSDCents: cciputils.NewOptional(uint32(10)), // custom finality not applicable on v1.6.x, but specifying it should not cause an error + DestBytesOverhead: cciputils.Optional[uint32]{}, // let adapter choose a sensible default + DestGasOverhead: cciputils.NewOptional(uint32(50_000)), // override default value + }, Token: &tokensapi.DeployTokenInput{ Decimals: uint8(18), Symbol: "EVM_TEST_B", @@ -231,6 +260,29 @@ func TestTokensAndTokenPools(t *testing.T) { DeployMCMS(t, env, data.Chain.Selector, []string{cciputils.CLLQualifier}) } + // Connect all chains + connectOut, err := lanes.ConnectChains(lanesRegistry, mcmsRegistry).Apply(*env, lanes.ConnectChainsConfig{ + Lanes: []lanes.LaneConfig{ + { + Version: cciputils.Version_1_6_0, + ChainA: lanes.ChainDefinition{Selector: evmChainSelA}, + ChainB: lanes.ChainDefinition{Selector: evmChainSelB}, + }, + { + Version: cciputils.Version_1_6_0, + ChainA: lanes.ChainDefinition{Selector: solChainSel}, + ChainB: lanes.ChainDefinition{Selector: evmChainSelA}, + }, + { + Version: cciputils.Version_1_6_0, + ChainA: lanes.ChainDefinition{Selector: solChainSel}, + ChainB: lanes.ChainDefinition{Selector: evmChainSelB}, + }, + }, + }) + require.NoError(t, err) + MergeAddresses(t, env, connectOut.DataStore) + // NOTE: calling TransferOwnership immediately after DeployMCMS for each chain // leads to an issue where TransferOwnership cannot find any MCMS contracts in // the datastore (possibly because of a non-zero timelock delay?). This causes @@ -300,6 +352,7 @@ func TestTokensAndTokenPools(t *testing.T) { }) require.NoError(t, err) MergeAddresses(t, env, output.DataStore) + testhelpers.ProcessTimelockProposals(t, *env, output.MCMSTimelockProposals, false) // Run token expansion for lnr input = make(map[uint64]tokensapi.TokenExpansionInputPerChain) @@ -320,6 +373,7 @@ func TestTokensAndTokenPools(t *testing.T) { }) require.NoError(t, err) MergeAddresses(t, env, output.DataStore) + testhelpers.ProcessTimelockProposals(t, *env, output.MCMSTimelockProposals, false) }) t.Run("EVM Token Adapter", func(t *testing.T) { @@ -500,6 +554,10 @@ func TestTokensAndTokenPools(t *testing.T) { require.NoError(t, err) tokA, err := poolA.GetToken(&bind.CallOpts{Context: t.Context()}) require.NoError(t, err) + poolB, err := evmAdapter.FindLatestAddressRef(env.DataStore, datastore.AddressRef{ChainSelector: evmB.Chain.Selector, Qualifier: evmB.TokenPoolQualifier, Type: datastore.ContractType(evmTokenPoolType)}) + require.NoError(t, err) + tokB, err := evmAdapter.FindOneTokenAddress(env.DataStore, evmB.Chain.Selector, &datastore.AddressRef{Qualifier: evmB.Token.Symbol}) + require.NoError(t, err) outboundRateLimitAB, err := poolA.GetCurrentOutboundRateLimiterState(&bind.CallOpts{Context: t.Context()}, evmB.Chain.Selector) require.NoError(t, err) inboundRateLimitAB, err := poolA.GetCurrentInboundRateLimiterState(&bind.CallOpts{Context: t.Context()}, evmB.Chain.Selector) @@ -518,6 +576,82 @@ func TestTokensAndTokenPools(t *testing.T) { require.Empty(t, remotePoolsAB) require.Empty(t, remoteTokenAB) + // To test partial updates, seed an initial deci bps + out, err := fees.SetTokenTransferFee().Apply(*env, fees.SetTokenTransferFeeInput{ + Version: nil, // inferred + MCMS: NewDefaultInputForMCMS("Set Token Transfer Fee"), + Args: []fees.TokenTransferFeeForSrc{ + { + Selector: evmChainSelA, + Settings: []fees.TokenTransferFeeForDst{ + { + Selector: evmChainSelB, + Settings: []fees.TokenTransferFee{ + {Address: tokA.Hex(), FeeArgs: fees.UnresolvedTokenTransferFeeArgs{DeciBps: evmInitDeciBpsA}}, + }, + }, + }, + }, + { + Selector: evmChainSelB, + Settings: []fees.TokenTransferFeeForDst{ + { + Selector: evmChainSelA, + Settings: []fees.TokenTransferFee{ + {Address: tokB.Hex(), FeeArgs: fees.UnresolvedTokenTransferFeeArgs{DeciBps: evmInitDeciBpsB}}, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + testhelpers.ProcessTimelockProposals(t, *env, out.MCMSTimelockProposals, false) + + // Get fee resolver + feeResolver, ok := feesRegistry.GetFeeResolver(chainsel.FamilyEVM) + require.True(t, ok, "EVM fee resolver should be registered") + + // Get the fee quoter contract on EVM chain A + onRampA, err := feeResolver.GetOnRampRef(*env, evmA.Chain.Selector, evmB.Chain.Selector) + require.NoError(t, err) + feeAdapterA, ok := feesRegistry.GetFeeAdapter(chainsel.FamilyEVM, onRampA.Version) + require.True(t, ok, fmt.Sprintf("fee adapter for version %q should be registered", onRampA.Version)) + fqA, err := feeAdapterA.GetFeeContractRef(*env, onRampA, evmA.Chain.Selector, evmB.Chain.Selector) + require.NoError(t, err) + + // Get the fee quoter contract on EVM chain B + onRampB, err := feeResolver.GetOnRampRef(*env, evmB.Chain.Selector, evmA.Chain.Selector) + require.NoError(t, err) + feeAdapterB, ok := feesRegistry.GetFeeAdapter(chainsel.FamilyEVM, onRampB.Version) + require.True(t, ok, fmt.Sprintf("fee adapter for version %q should be registered", onRampB.Version)) + fqB, err := feeAdapterB.GetFeeContractRef(*env, onRampB, evmB.Chain.Selector, evmA.Chain.Selector) + require.NoError(t, err) + + // Make sure EVM chain A was set + feeA, err := feeAdapterA.GetOnchainTokenTransferFeeConfig(*env, fqA, evmA.Chain.Selector, evmB.Chain.Selector, tokA.Hex()) + require.NoError(t, err) + expectedFeeA := fees.GetDefaultChainAgnosticTokenTransferFeeConfig(evmA.Chain.Selector, evmB.Chain.Selector) + expectedFeeA.DeciBps = evmInitDeciBpsA.Value + require.Equal(t, expectedFeeA.DestBytesOverhead, feeA.DestBytesOverhead) + require.Equal(t, expectedFeeA.DestGasOverhead, feeA.DestGasOverhead) + require.Equal(t, expectedFeeA.MinFeeUSDCents, feeA.MinFeeUSDCents) + require.Equal(t, expectedFeeA.MaxFeeUSDCents, feeA.MaxFeeUSDCents) + require.Equal(t, expectedFeeA.IsEnabled, feeA.IsEnabled) + require.Equal(t, expectedFeeA.DeciBps, feeA.DeciBps) + + // Make sure EVM chain B fee config was set + feeB, err := feeAdapterB.GetOnchainTokenTransferFeeConfig(*env, fqB, evmB.Chain.Selector, evmA.Chain.Selector, tokB.Hex()) + require.NoError(t, err) + expectedFeeB := fees.GetDefaultChainAgnosticTokenTransferFeeConfig(evmB.Chain.Selector, evmA.Chain.Selector) + expectedFeeB.DeciBps = evmInitDeciBpsB.Value + require.Equal(t, expectedFeeB.DestBytesOverhead, feeB.DestBytesOverhead) + require.Equal(t, expectedFeeB.DestGasOverhead, feeB.DestGasOverhead) + require.Equal(t, expectedFeeB.MinFeeUSDCents, feeB.MinFeeUSDCents) + require.Equal(t, expectedFeeB.MaxFeeUSDCents, feeB.MaxFeeUSDCents) + require.Equal(t, expectedFeeB.IsEnabled, feeB.IsEnabled) + require.Equal(t, expectedFeeB.DeciBps, feeB.DeciBps) + // For the first iteration, there are no remote chains configured on token pool A so // ApplyChainUpdates should be called directly. On the second iteration the "update" // path will be taken instead of the "add" path, since chain B will already be fully @@ -539,6 +673,7 @@ func TestTokensAndTokenPools(t *testing.T) { RemoteChains: map[uint64]tokensapi.RemoteChainConfig[*datastore.AddressRef, datastore.AddressRef]{ evmB.Chain.Selector: { OutboundRateLimiterConfig: &defaultRL, + TokenTransferFeeConfig: &evmA.FeeConfig, OutboundCCVs: []datastore.AddressRef{}, InboundCCVs: []datastore.AddressRef{}, RemoteToken: &datastore.AddressRef{ @@ -571,6 +706,7 @@ func TestTokensAndTokenPools(t *testing.T) { RemoteChains: map[uint64]tokensapi.RemoteChainConfig[*datastore.AddressRef, datastore.AddressRef]{ evmA.Chain.Selector: { OutboundRateLimiterConfig: &defaultRL, + TokenTransferFeeConfig: &evmB.FeeConfig, OutboundCCVs: []datastore.AddressRef{}, InboundCCVs: []datastore.AddressRef{}, RemoteToken: &datastore.AddressRef{ @@ -620,15 +756,77 @@ func TestTokensAndTokenPools(t *testing.T) { require.True(t, inboundRateLimitAB.IsEnabled) // Verify that the remote token pool was set correctly - poolB, err := evmAdapter.FindLatestAddressRef(env.DataStore, datastore.AddressRef{ChainSelector: evmB.Chain.Selector, Qualifier: evmB.TokenPoolQualifier, Type: datastore.ContractType(evmTokenPoolType)}) - require.NoError(t, err) require.Len(t, remotePoolsAB, 1) require.True(t, bytes.Equal(remotePoolsAB[0], common.LeftPadBytes(poolB.Bytes(), 32))) // Verify that the remote token was set correctly - tokB, err := evmAdapter.FindOneTokenAddress(env.DataStore, evmB.Chain.Selector, &datastore.AddressRef{Qualifier: evmB.Token.Symbol}) - require.NoError(t, err) require.True(t, bytes.Equal(remoteTokenAB, common.LeftPadBytes(tokB.Bytes(), 32))) + + // Verify that the fee config on chain A for transfers to chain B matches what we set in the input, merged with any defaults from the adapter + feeA, err = feeAdapterA.GetOnchainTokenTransferFeeConfig(*env, fqA, evmA.Chain.Selector, evmB.Chain.Selector, tokA.Hex()) + require.NoError(t, err) + feeDefaultsA := tokensapi.GetDefaultChainAgnosticTokenTransferFeeConfig(evmA.Chain.Selector, evmB.Chain.Selector) + evmA.FeeConfig.DefaultFinalityTransferFeeBps = evmInitDeciBpsA // seed value should still be present + expectedFeeA := evmA.FeeConfig.MergeWith(feeDefaultsA) + require.Equal(t, expectedFeeA.DefaultFinalityTransferFeeBps, feeA.DeciBps) + require.Equal(t, expectedFeeA.DefaultFinalityFeeUSDCents, feeA.MinFeeUSDCents) + require.Equal(t, expectedFeeA.DestBytesOverhead, feeA.DestBytesOverhead) + require.Equal(t, expectedFeeA.DestGasOverhead, feeA.DestGasOverhead) + require.Equal(t, expectedFeeA.IsEnabled, feeA.IsEnabled) + require.Equal(t, uint32(math.MaxUint32), feeA.MaxFeeUSDCents) + + // Verify that the fee config on chain B for transfers to chain A matches what we set in the input, merged with any defaults from the adapter + feeB, err = feeAdapterB.GetOnchainTokenTransferFeeConfig(*env, fqB, evmB.Chain.Selector, evmA.Chain.Selector, tokB.Hex()) + require.NoError(t, err) + feeDefaultsB := tokensapi.GetDefaultChainAgnosticTokenTransferFeeConfig(evmB.Chain.Selector, evmA.Chain.Selector) + evmB.FeeConfig.DefaultFinalityTransferFeeBps = evmInitDeciBpsB // seed value should still be present + expectedFeeB := evmB.FeeConfig.MergeWith(feeDefaultsB) + require.Equal(t, expectedFeeB.DefaultFinalityTransferFeeBps, feeB.DeciBps) + require.Equal(t, expectedFeeB.DefaultFinalityFeeUSDCents, feeB.MinFeeUSDCents) + require.Equal(t, expectedFeeB.DestBytesOverhead, feeB.DestBytesOverhead) + require.Equal(t, expectedFeeB.DestGasOverhead, feeB.DestGasOverhead) + require.Equal(t, expectedFeeB.IsEnabled, feeB.IsEnabled) + require.Equal(t, uint32(math.MaxUint32), feeB.MaxFeeUSDCents) + + // Now reset + out, err := fees.SetTokenTransferFee().Apply(*env, fees.SetTokenTransferFeeInput{ + Version: nil, // inferred + MCMS: NewDefaultInputForMCMS("Set Token Transfer Fee"), + Args: []fees.TokenTransferFeeForSrc{ + { + Selector: evmChainSelA, + Settings: []fees.TokenTransferFeeForDst{ + { + Selector: evmChainSelB, + Settings: []fees.TokenTransferFee{ + {Address: tokA.Hex(), FeeArgs: fees.UnresolvedTokenTransferFeeArgs{IsEnabled: cciputils.NewOptional(false)}}, + }, + }, + }, + }, + { + Selector: evmChainSelB, + Settings: []fees.TokenTransferFeeForDst{ + { + Selector: evmChainSelA, + Settings: []fees.TokenTransferFee{ + {Address: tokB.Hex(), FeeArgs: fees.UnresolvedTokenTransferFeeArgs{IsEnabled: cciputils.NewOptional(false)}}, + }, + }, + }, + }, + }, + }) + require.NoError(t, err) + testhelpers.ProcessTimelockProposals(t, *env, out.MCMSTimelockProposals, false) + + // Verify configs were reset + feeA, err = feeAdapterA.GetOnchainTokenTransferFeeConfig(*env, fqA, evmA.Chain.Selector, evmB.Chain.Selector, tokA.Hex()) + require.NoError(t, err) + require.False(t, feeA.IsEnabled, "fee should be disabled after reset") + feeB, err = feeAdapterB.GetOnchainTokenTransferFeeConfig(*env, fqB, evmB.Chain.Selector, evmA.Chain.Selector, tokB.Hex()) + require.NoError(t, err) + require.False(t, feeB.IsEnabled, "fee should be disabled after reset") } }) }) @@ -734,6 +932,7 @@ func TestTokensAndTokenPools(t *testing.T) { }) require.NoError(t, err) MergeAddresses(t, env, output.DataStore) + testhelpers.ProcessTimelockProposals(t, *env, output.MCMSTimelockProposals, false) // Verify that the token exists in datastore tokenAddr, err := datastore_utils.FindAndFormatRef(env.DataStore, datastore.AddressRef{ @@ -871,6 +1070,9 @@ func TestTokensAndTokenPools(t *testing.T) { }) t.Run("Validate ConfigureTokenForTransfers", func(t *testing.T) { + // Reset the operation cache + env.OperationsBundle = operations.NewBundle(t.Context, env.OperationsBundle.Logger, operations.NewMemoryReporter()) + evmA, evmB := evmTestData[0], evmTestData[1] solbnm, _ := solTestData[0], solTestData[1] defaultRL := tokensapi.RateLimiterConfigFloatInput{ @@ -933,6 +1135,7 @@ func TestTokensAndTokenPools(t *testing.T) { RegistryRef: datastore.AddressRef{}, // inferred RemoteChains: map[uint64]tokensapi.RemoteChainConfig[*datastore.AddressRef, datastore.AddressRef]{ evmA.Chain.Selector: { + TokenTransferFeeConfig: &solbnm.FeeConfig, OutboundRateLimiterConfig: &defaultRL, OutboundCCVs: []datastore.AddressRef{}, InboundCCVs: []datastore.AddressRef{}, @@ -949,6 +1152,7 @@ func TestTokensAndTokenPools(t *testing.T) { }, }, evmB.Chain.Selector: { + TokenTransferFeeConfig: &solbnm.FeeConfig, OutboundRateLimiterConfig: &defaultRL, OutboundCCVs: []datastore.AddressRef{}, InboundCCVs: []datastore.AddressRef{}, @@ -981,6 +1185,7 @@ func TestTokensAndTokenPools(t *testing.T) { RegistryRef: datastore.AddressRef{}, // inferred RemoteChains: map[uint64]tokensapi.RemoteChainConfig[*datastore.AddressRef, datastore.AddressRef]{ solbnm.Chain.Selector: { + TokenTransferFeeConfig: &evmA.FeeConfig, OutboundRateLimiterConfig: &defaultRL, OutboundCCVs: []datastore.AddressRef{}, InboundCCVs: []datastore.AddressRef{}, @@ -996,6 +1201,15 @@ func TestTokensAndTokenPools(t *testing.T) { Version: cciputils.Version_1_6_0, }, }, + evmB.Chain.Selector: { + TokenTransferFeeConfig: &evmA.FeeConfig, + OutboundRateLimiterConfig: &defaultRL, + OutboundCCVs: []datastore.AddressRef{}, + InboundCCVs: []datastore.AddressRef{}, + RemotePool: &datastore.AddressRef{ + Qualifier: evmB.TokenPoolQualifier, + }, + }, }, }, }, @@ -1013,6 +1227,7 @@ func TestTokensAndTokenPools(t *testing.T) { RegistryRef: datastore.AddressRef{}, // inferred RemoteChains: map[uint64]tokensapi.RemoteChainConfig[*datastore.AddressRef, datastore.AddressRef]{ solbnm.Chain.Selector: { + TokenTransferFeeConfig: &evmB.FeeConfig, OutboundRateLimiterConfig: &defaultRL, OutboundCCVs: []datastore.AddressRef{}, InboundCCVs: []datastore.AddressRef{}, @@ -1028,6 +1243,15 @@ func TestTokensAndTokenPools(t *testing.T) { Version: cciputils.Version_1_6_0, }, }, + evmA.Chain.Selector: { + OutboundRateLimiterConfig: &defaultRL, + TokenTransferFeeConfig: &evmB.FeeConfig, + OutboundCCVs: []datastore.AddressRef{}, + InboundCCVs: []datastore.AddressRef{}, + RemotePool: &datastore.AddressRef{ + Qualifier: evmA.TokenPoolQualifier, + }, + }, }, }, }, @@ -1064,6 +1288,57 @@ func TestTokensAndTokenPools(t *testing.T) { expectedRLA = pk } require.Equal(t, expectedRLA, tokenPoolStateAccountAfter.Config.RateLimitAdmin) + + // Define token transfer fee configs to check, along with their corresponding token info and chain selectors + cfgs := []struct { + fee tokensapi.PartialTokenTransferFeeConfig + tok *tokensapi.DeployTokenInput + sel uint64 + }{ + {sel: solbnm.Chain.Selector, fee: solbnm.FeeConfig, tok: solbnm.Token}, + {sel: evmA.Chain.Selector, fee: evmA.FeeConfig, tok: evmA.Token}, + {sel: evmB.Chain.Selector, fee: evmB.FeeConfig, tok: evmB.Token}, + } + + // Check all fee configs + for _, src := range cfgs { + for _, dst := range cfgs { + if src.sel == dst.sel { + continue + } + + // Get the fee token on the source chain + filters := datastore_utils.AddressRefToFilters(datastore.AddressRef{ChainSelector: src.sel, Qualifier: src.tok.Symbol}) + results := env.DataStore.Addresses().Filter(filters...) + require.Len(t, results, 1, fmt.Sprintf("token address for symbol %q on chain selector %d should be found in datastore", src.tok.Symbol, src.sel)) + token := results[0].Address + fam, err := chainsel.GetSelectorFamily(src.sel) + require.NoError(t, err) + + // Get the on ramp contract on the source chain + feeResolver, ok := feesRegistry.GetFeeResolver(fam) + require.True(t, ok, "EVM fee resolver should be registered") + onRampSrc, err := feeResolver.GetOnRampRef(*env, src.sel, dst.sel) + require.NoError(t, err) + + // Get the fee contract on the source chain + feeAdapter, ok := feesRegistry.GetFeeAdapter(fam, onRampSrc.Version) + require.True(t, ok, fmt.Sprintf("fee adapter for version %q should be registered", onRampSrc.Version)) + fqSrc, err := feeAdapter.GetFeeContractRef(*env, onRampSrc, src.sel, dst.sel) + require.NoError(t, err) + + // Verify token transfer fee config is correct + expectedFee := src.fee.MergeWith(tokensapi.GetDefaultChainAgnosticTokenTransferFeeConfig(src.sel, dst.sel)) + actualFee, err := feeAdapter.GetOnchainTokenTransferFeeConfig(*env, fqSrc, src.sel, dst.sel, token) + require.NoError(t, err) + require.Equal(t, expectedFee.DefaultFinalityTransferFeeBps, actualFee.DeciBps) + require.Equal(t, expectedFee.DefaultFinalityFeeUSDCents, actualFee.MinFeeUSDCents) + require.Equal(t, expectedFee.DestBytesOverhead, actualFee.DestBytesOverhead) + require.Equal(t, expectedFee.DestGasOverhead, actualFee.DestGasOverhead) + require.Equal(t, expectedFee.IsEnabled, actualFee.IsEnabled) + require.Equal(t, uint32(math.MaxUint32), actualFee.MaxFeeUSDCents) + } + } }) }) } diff --git a/integration-tests/go.mod b/integration-tests/go.mod index 86e3c029f8..77a566d2e1 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -29,7 +29,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260121163256-85accaf3d28d github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment v0.0.0-00010101000000-000000000000 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260312233953-f588f8dc6d7c - github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260520205139-e02dace3eefa + github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260526052449-0ceed63f1a5a github.com/smartcontractkit/chainlink-deployments-framework v0.100.0 github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260410162948-2dca02f24e98 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260119171452-39c98c3b33cd