diff --git a/chains/evm/deployment/tokens/tokenimpl/helpers.go b/chains/evm/deployment/tokens/tokenimpl/helpers.go new file mode 100644 index 000000000..be6cff75b --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/helpers.go @@ -0,0 +1,101 @@ +package tokenimpl + +import ( + "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/burn_mint_erc20" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/erc20" + "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" + bnm_erc20_bindings "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc20" +) + +func revokeDefaultAdminRoleBurnMintERC20(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 { + return nil, fmt.Errorf("failed to instantiate BurnMintERC20 contract: %w", err) + } + role, err := tokenContract.DEFAULTADMINROLE(&bind.CallOpts{Context: b.GetContext()}) + if err != nil { + return nil, fmt.Errorf("failed to get default admin role constant: %w", err) + } + report, err := cldf_ops.ExecuteOperation(b, burn_mint_erc20.RevokeAdminRole, chain, contract.FunctionInput[burn_mint_erc20.RoleAssignment]{ + ChainSelector: chain.Selector, + Address: token, + Args: burn_mint_erc20.RoleAssignment{ + Role: role, + To: user, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to revoke default admin role: %w", err) + } + return []contract.WriteOutput{report.Output}, 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 { + return nil, fmt.Errorf("failed to instantiate BurnMintERC20 contract: %w", err) + } + role, err := tokenContract.DEFAULTADMINROLE(&bind.CallOpts{Context: b.GetContext()}) + if err != nil { + return nil, fmt.Errorf("failed to get default admin role constant: %w", err) + } + report, err := cldf_ops.ExecuteOperation(b, burn_mint_erc20.GrantAdminRole, chain, contract.FunctionInput[burn_mint_erc20.RoleAssignment]{ + ChainSelector: chain.Selector, + Address: token, + Args: burn_mint_erc20.RoleAssignment{ + Role: role, + To: user, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to grant default admin role: %w", err) + } + return []contract.WriteOutput{report.Output}, nil +} + +func grantMintAndBurnRolesBurnMintERC20(b cldf_ops.Bundle, chain evm.Chain, token, pool common.Address) ([]contract.WriteOutput, error) { + report, err := cldf_ops.ExecuteOperation(b, burn_mint_erc20.GrantMintAndBurnRoles, chain, contract.FunctionInput[common.Address]{ + ChainSelector: chain.Selector, + Address: token, + Args: pool, + }) + if err != nil { + return nil, fmt.Errorf("failed to grant mint and burn roles: %w", err) + } + return []contract.WriteOutput{report.Output}, nil +} + +func setCCIPAdminBurnMintERC20(b cldf_ops.Bundle, chain evm.Chain, token, ccipAdmin common.Address) ([]contract.WriteOutput, error) { + report, err := cldf_ops.ExecuteOperation(b, burn_mint_erc20.SetCCIPAdmin, chain, contract.FunctionInput[string]{ + ChainSelector: chain.Selector, + Address: token, + Args: ccipAdmin.Hex(), + }) + if err != nil { + return nil, fmt.Errorf("failed to set CCIP admin: %w", err) + } + return []contract.WriteOutput{report.Output}, nil +} + +func transferTokensERC20(b cldf_ops.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) { + report, err := cldf_ops.ExecuteOperation(b, erc20.Transfer, chain, contract.FunctionInput[erc20.TransferArgs]{ + ChainSelector: chain.Selector, + Address: token, + Args: erc20.TransferArgs{ + Amount: scaledAmount, + Receiver: to, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to transfer ERC20 tokens: %w", err) + } + return []contract.WriteOutput{report.Output}, nil +} diff --git a/chains/evm/deployment/tokens/tokenimpl/impl.go b/chains/evm/deployment/tokens/tokenimpl/impl.go new file mode 100644 index 000000000..74ef7e8d5 --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/impl.go @@ -0,0 +1,78 @@ +package tokenimpl + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "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" +) + +// CapabilitySet reports the optional flow steps a token contract type +// participates in. The orchestrating sequence reads these flags to +// decide whether to invoke the corresponding step. +type CapabilitySet struct { + // ParticipatesInPoolRoleGrant is true when the token requires token-side + // role grants for the pool to operate; GrantPoolRoles must emit those writes. + ParticipatesInPoolRoleGrant bool + + // SupportsAdminRole is true when the token exposes a manageable admin or + // default-admin role; GrantAdminRole and RevokeAdminRole must implement it. + SupportsAdminRole bool + + // SupportsCCIPAdmin is true when the token has a token-level CCIP admin; + // SetCCIPAdmin must emit the write that updates it. + SupportsCCIPAdmin bool + + // SupportsPreMint is true when the token can mint during deployment and + // transfer those tokens afterward to the configured recipient. + SupportsPreMint bool +} + +// Token encapsulates everything specific to one EVM token contract type. +type Token interface { + // ContractType returns the deployment.ContractType used as the registry key. + ContractType() deployment.ContractType + + // Capabilities returns the static feature flags for this token type. + Capabilities() CapabilitySet + + // RevokeAdminRole revokes the default-admin or contract-specific admin + // role from user. Callers should consult SupportsAdminRole first. + RevokeAdminRole(b cldf_ops.Bundle, chain evm.Chain, token, user common.Address) ([]contract.WriteOutput, 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 + // that flag first. + GrantAdminRole(b cldf_ops.Bundle, chain evm.Chain, token, user common.Address) ([]contract.WriteOutput, error) + + // GrantPoolRoles emits the writes that authorize a freshly-deployed pool + // to mint/burn (or its TIP-20 issuer-role equivalent) against this token. + // proposalExecutor is the MCMS timelock (or zero when unused); BurnMintERC677 + // uses it for PrepareGrantMintAndBurnRoles. Other token types ignore it. + // Returns an error for token types that don't participate in pool role + // granting; ParticipatesInPoolRoleGrant is the authoritative flag, callers + // should consult it first. + GrantPoolRoles(b cldf_ops.Bundle, chain evm.Chain, token, pool, proposalExecutor common.Address) ([]contract.WriteOutput, error) + + // SetCCIPAdmin sets the token-level CCIP admin where the token contract + // supports one. Callers should consult SupportsCCIPAdmin first. + SetCCIPAdmin(b cldf_ops.Bundle, chain evm.Chain, token, admin common.Address) ([]contract.WriteOutput, error) + + // Transfer emits the writes that transfer already-scaled token units from + // the deployer to to, typically for post-deploy pre-mint distribution. + Transfer(b cldf_ops.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) + + // Deploy performs the token contract deployment, returning the + // resulting datastore reference and any token-side write outputs + // produced during deployment. Implementations may call lower-level + // deployment operations or helpers, but batching is handled by the + // outer token deployment sequence. + Deploy(b cldf_ops.Bundle, chain evm.Chain, in tokensapi.DeployTokenInput) (datastore.AddressRef, []contract.WriteOutput, error) +} diff --git a/chains/evm/deployment/tokens/tokenimpl/lookup.go b/chains/evm/deployment/tokens/tokenimpl/lookup.go new file mode 100644 index 000000000..d1ebc6978 --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/lookup.go @@ -0,0 +1,35 @@ +package tokenimpl + +import ( + bnmERC20 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + dripV1_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" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/tip20" + dripV1_5_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/burn_mint_erc20_with_drip" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +var tokenImpls = map[deployment.ContractType]Token{ + dripV1_5_0.ContractType: tokenBurnMintERC20WithDripV1_5_0{}, + dripV1_0_0.ContractType: tokenBurnMintERC20WithDripV1_0_0{}, + utils.ERC677TokenHelper: tokenBurnMintERC677{}, + utils.BurnMintToken: tokenBurnMintERC677{}, + bnmERC20.ContractType: tokenBurnMintERC20{}, + erc20.ContractType: tokenERC20{}, + tip20.ContractType: tokenTIP20{}, +} + +// Get returns the token implementation for an EVM token contract type. +func Get(ct deployment.ContractType) (Token, bool) { + s, ok := tokenImpls[ct] + return s, ok +} + +// Capabilities returns the capability set for an EVM token contract type, or the zero value if the token implementation does not exist. +func Capabilities(ct deployment.ContractType) CapabilitySet { + if s, ok := Get(ct); ok { + return s.Capabilities() + } + return CapabilitySet{} +} diff --git a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20.go b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20.go new file mode 100644 index 000000000..34d2e1a0c --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20.go @@ -0,0 +1,86 @@ +package tokenimpl + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +type tokenBurnMintERC20 struct{} + +func (tokenBurnMintERC20) ContractType() deployment.ContractType { + return burn_mint_erc20.ContractType +} + +func (tokenBurnMintERC20) Capabilities() CapabilitySet { + return CapabilitySet{ + ParticipatesInPoolRoleGrant: true, + SupportsAdminRole: true, + SupportsCCIPAdmin: true, + SupportsPreMint: true, + } +} + +func (tokenBurnMintERC20) RevokeAdminRole(b operations.Bundle, chain evm.Chain, token, user common.Address) ([]contract.WriteOutput, error) { + return revokeDefaultAdminRoleBurnMintERC20(b, 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) +} + +func (tokenBurnMintERC20) GrantPoolRoles(b operations.Bundle, chain evm.Chain, token, pool, _ common.Address) ([]contract.WriteOutput, error) { + return grantMintAndBurnRolesBurnMintERC20(b, chain, token, pool) +} + +func (tokenBurnMintERC20) SetCCIPAdmin(b operations.Bundle, chain evm.Chain, token, ccipAdmin common.Address) ([]contract.WriteOutput, error) { + return setCCIPAdminBurnMintERC20(b, chain, token, ccipAdmin) +} + +func (tokenBurnMintERC20) Transfer(b operations.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) { + // NOTE: BnM ERC20 tokens inherit from a standard ERC20 implementation, so we can use the same transfer helper function as the plain ERC20 token. + return transferTokensERC20(b, chain, token, to, scaledAmount) +} + +func (tokenBurnMintERC20) Deploy(b operations.Bundle, chain evm.Chain, in tokensapi.DeployTokenInput) (datastore.AddressRef, []contract.WriteOutput, error) { + maxSupply := big.NewInt(0) + if in.Supply != nil { + maxSupply = tokensapi.ScaleTokenAmount(new(big.Int).SetUint64(*in.Supply), in.Decimals) + } + + preMint := big.NewInt(0) + if in.PreMint != nil { + preMint = tokensapi.ScaleTokenAmount(new(big.Int).SetUint64(*in.PreMint), in.Decimals) + } + + ref, err := contract.MaybeDeployContract(b, burn_mint_erc20.Deploy, chain, + contract.DeployInput[burn_mint_erc20.ConstructorArgs]{ + TypeAndVersion: deployment.NewTypeAndVersion(burn_mint_erc20.ContractType, *utils.Version_1_0_0), + ChainSelector: chain.Selector, + Qualifier: &in.Symbol, + Args: burn_mint_erc20.ConstructorArgs{ + Name: in.Name, + Symbol: in.Symbol, + Decimals: in.Decimals, + MaxSupply: maxSupply, + PreMint: preMint, + }, + }, + nil, + ) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("failed to deploy BurnMintERC20 token: %w", err) + } + + return ref, nil, nil +} 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 new file mode 100644 index 000000000..8efcf108a --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_0_0.go @@ -0,0 +1,89 @@ +package tokenimpl + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20_with_drip" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// Deprecated: BurnMintERC20WithDripToken has no actual drip functionality - it +// is retained only for compatibility with existing tests and should be removed +// in a future cleanup. +type tokenBurnMintERC20WithDripV1_0_0 struct{} + +func (tokenBurnMintERC20WithDripV1_0_0) ContractType() deployment.ContractType { + return burn_mint_erc20_with_drip.ContractType +} + +func (tokenBurnMintERC20WithDripV1_0_0) Capabilities() CapabilitySet { + return CapabilitySet{ + ParticipatesInPoolRoleGrant: true, + SupportsAdminRole: true, + SupportsCCIPAdmin: true, + SupportsPreMint: true, + } +} + +func (tokenBurnMintERC20WithDripV1_0_0) RevokeAdminRole(b operations.Bundle, chain evm.Chain, token, externalAdmin common.Address) ([]contract.WriteOutput, error) { + return revokeDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) +} + +func (tokenBurnMintERC20WithDripV1_0_0) GrantAdminRole(b operations.Bundle, chain evm.Chain, token, externalAdmin common.Address) ([]contract.WriteOutput, error) { + return grantDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) +} + +func (tokenBurnMintERC20WithDripV1_0_0) GrantPoolRoles(b operations.Bundle, chain evm.Chain, token, pool, _ common.Address) ([]contract.WriteOutput, error) { + return grantMintAndBurnRolesBurnMintERC20(b, chain, token, pool) +} + +func (tokenBurnMintERC20WithDripV1_0_0) SetCCIPAdmin(b operations.Bundle, chain evm.Chain, token, ccipAdmin common.Address) ([]contract.WriteOutput, error) { + return setCCIPAdminBurnMintERC20(b, chain, token, ccipAdmin) +} + +func (tokenBurnMintERC20WithDripV1_0_0) Transfer(b operations.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) { + // NOTE: BnM ERC20 drip tokens inherit from a standard ERC20 implementation, so we can use the same transfer helper function as the plain ERC20 token. + return transferTokensERC20(b, chain, token, to, scaledAmount) +} + +func (tokenBurnMintERC20WithDripV1_0_0) Deploy(b operations.Bundle, chain evm.Chain, in tokensapi.DeployTokenInput) (datastore.AddressRef, []contract.WriteOutput, error) { + maxSupply := big.NewInt(0) + if in.Supply != nil { + maxSupply = tokensapi.ScaleTokenAmount(new(big.Int).SetUint64(*in.Supply), in.Decimals) + } + + preMint := big.NewInt(0) + if in.PreMint != nil { + preMint = tokensapi.ScaleTokenAmount(new(big.Int).SetUint64(*in.PreMint), in.Decimals) + } + + ref, err := contract.MaybeDeployContract(b, burn_mint_erc20_with_drip.Deploy, chain, + contract.DeployInput[burn_mint_erc20_with_drip.ConstructorArgs]{ + TypeAndVersion: deployment.NewTypeAndVersion(burn_mint_erc20_with_drip.ContractType, *utils.Version_1_0_0), + ChainSelector: chain.Selector, + Qualifier: &in.Symbol, + Args: burn_mint_erc20_with_drip.ConstructorArgs{ + Name: in.Name, + Symbol: in.Symbol, + Decimals: in.Decimals, + MaxSupply: maxSupply, + PreMint: preMint, + }, + }, + nil, + ) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("failed to deploy BurnMintERC20WithDripToken token: %w", err) + } + + return ref, nil, nil +} 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 new file mode 100644 index 000000000..89f8ea51d --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc20_with_drip_v1_5_0.go @@ -0,0 +1,72 @@ +package tokenimpl + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/burn_mint_erc20_with_drip" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +type tokenBurnMintERC20WithDripV1_5_0 struct{} + +func (tokenBurnMintERC20WithDripV1_5_0) ContractType() deployment.ContractType { + return burn_mint_erc20_with_drip.ContractType +} + +func (tokenBurnMintERC20WithDripV1_5_0) Capabilities() CapabilitySet { + return CapabilitySet{ + ParticipatesInPoolRoleGrant: true, + SupportsAdminRole: true, + SupportsCCIPAdmin: true, + SupportsPreMint: false, + } +} + +func (tokenBurnMintERC20WithDripV1_5_0) RevokeAdminRole(b operations.Bundle, chain evm.Chain, token, externalAdmin common.Address) ([]contract.WriteOutput, error) { + return revokeDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) +} + +func (tokenBurnMintERC20WithDripV1_5_0) GrantAdminRole(b operations.Bundle, chain evm.Chain, token, externalAdmin common.Address) ([]contract.WriteOutput, error) { + return grantDefaultAdminRoleBurnMintERC20(b, chain, token, externalAdmin) +} + +func (tokenBurnMintERC20WithDripV1_5_0) GrantPoolRoles(b operations.Bundle, chain evm.Chain, token, pool, _ common.Address) ([]contract.WriteOutput, error) { + return grantMintAndBurnRolesBurnMintERC20(b, chain, token, pool) +} + +func (tokenBurnMintERC20WithDripV1_5_0) SetCCIPAdmin(b operations.Bundle, chain evm.Chain, token, ccipAdmin common.Address) ([]contract.WriteOutput, error) { + return setCCIPAdminBurnMintERC20(b, chain, token, ccipAdmin) +} + +func (tokenBurnMintERC20WithDripV1_5_0) Transfer(b operations.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) { + // NOTE: BnM ERC20 drip tokens inherit from a standard ERC20 implementation, so we can use the same transfer helper function as the plain ERC20 token. + return transferTokensERC20(b, chain, token, to, scaledAmount) +} + +func (tokenBurnMintERC20WithDripV1_5_0) Deploy(b operations.Bundle, chain evm.Chain, in tokensapi.DeployTokenInput) (datastore.AddressRef, []contract.WriteOutput, error) { + ref, err := contract.MaybeDeployContract(b, burn_mint_erc20_with_drip.Deploy, chain, + contract.DeployInput[burn_mint_erc20_with_drip.ConstructorArgs]{ + TypeAndVersion: deployment.NewTypeAndVersion(burn_mint_erc20_with_drip.ContractType, *burn_mint_erc20_with_drip.Version), + ChainSelector: chain.Selector, + Qualifier: &in.Symbol, + Args: burn_mint_erc20_with_drip.ConstructorArgs{ + Name: in.Name, + Symbol: in.Symbol, + }, + }, + nil, + ) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("failed to deploy BurnMintERC20WithDrip (v1.5.0) token: %w", err) + } + + return ref, nil, nil +} diff --git a/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc677.go b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc677.go new file mode 100644 index 000000000..812787f06 --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/token_burn_mint_erc677.go @@ -0,0 +1,90 @@ +package tokenimpl + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +type tokenBurnMintERC677 struct{} + +func (tokenBurnMintERC677) ContractType() deployment.ContractType { + return utils.BurnMintToken +} + +func (tokenBurnMintERC677) Capabilities() CapabilitySet { + return CapabilitySet{ + ParticipatesInPoolRoleGrant: true, + // Admin tidy uses BurnMintERC20 operations in tidyTokenRoles; ERC677 uses a + // different binding until dedicated admin ops exist for this type. + SupportsAdminRole: false, + SupportsCCIPAdmin: false, + SupportsPreMint: false, + } +} + +func (tokenBurnMintERC677) RevokeAdminRole(_ operations.Bundle, _ evm.Chain, _, _ common.Address) ([]contract.WriteOutput, error) { + return nil, fmt.Errorf("admin role revoke 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") +} + +func (tokenBurnMintERC677) GrantPoolRoles(b operations.Bundle, chain evm.Chain, token, pool, proposalExecutor common.Address) ([]contract.WriteOutput, error) { + return burn_mint_erc677.PrepareGrantMintAndBurnRoles( + b, + chain, + contract.FunctionInput[common.Address]{ + ChainSelector: chain.Selector, + Address: token, + Args: pool, + }, + proposalExecutor, + ) +} + +func (tokenBurnMintERC677) SetCCIPAdmin(_ operations.Bundle, _ evm.Chain, _, _ common.Address) ([]contract.WriteOutput, error) { + return nil, fmt.Errorf("CCIP admin not supported for BurnMintERC677 token type via this deployment path") +} + +func (tokenBurnMintERC677) Transfer(b operations.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) { + return transferTokensERC20(b, chain, token, to, scaledAmount) +} + +func (tokenBurnMintERC677) Deploy(b operations.Bundle, chain evm.Chain, in tokensapi.DeployTokenInput) (datastore.AddressRef, []contract.WriteOutput, error) { + maxSupply := big.NewInt(0) + if in.Supply != nil { + maxSupply = tokensapi.ScaleTokenAmount(new(big.Int).SetUint64(*in.Supply), in.Decimals) + } + + ref, err := contract.MaybeDeployContract(b, burn_mint_erc677.Deploy, chain, + contract.DeployInput[burn_mint_erc677.ConstructorArgs]{ + TypeAndVersion: deployment.NewTypeAndVersion(burn_mint_erc677.ContractType, *utils.Version_1_0_0), + ChainSelector: chain.Selector, + Qualifier: &in.Symbol, + Args: burn_mint_erc677.ConstructorArgs{ + Name: in.Name, + Symbol: in.Symbol, + Decimals: in.Decimals, + MaxSupply: maxSupply, + }, + }, + nil, + ) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("failed to deploy BurnMintERC677 token: %w", err) + } + + return ref, nil, nil +} diff --git a/chains/evm/deployment/tokens/tokenimpl/token_erc20.go b/chains/evm/deployment/tokens/tokenimpl/token_erc20.go new file mode 100644 index 000000000..a27555dc0 --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/token_erc20.go @@ -0,0 +1,72 @@ +package tokenimpl + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/erc20" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +type tokenERC20 struct{} + +func (tokenERC20) ContractType() deployment.ContractType { + return erc20.ContractType +} + +func (tokenERC20) Capabilities() CapabilitySet { + return CapabilitySet{ + ParticipatesInPoolRoleGrant: false, + SupportsAdminRole: false, + SupportsCCIPAdmin: false, + SupportsPreMint: false, + } +} + +func (tokenERC20) RevokeAdminRole(_ operations.Bundle, _ evm.Chain, _, _ common.Address) ([]contract.WriteOutput, error) { + return nil, fmt.Errorf("admin role 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") +} + +func (tokenERC20) GrantPoolRoles(_ operations.Bundle, _ evm.Chain, _, _, _ common.Address) ([]contract.WriteOutput, error) { + return nil, fmt.Errorf("pool role granting not supported for plain ERC20 token") +} + +func (tokenERC20) SetCCIPAdmin(_ operations.Bundle, _ evm.Chain, _, _ common.Address) ([]contract.WriteOutput, error) { + return nil, fmt.Errorf("CCIP admin role not supported for plain ERC20 token") +} + +func (tokenERC20) Transfer(b operations.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) { + return transferTokensERC20(b, chain, token, to, scaledAmount) +} + +func (tokenERC20) Deploy(b operations.Bundle, chain evm.Chain, in tokensapi.DeployTokenInput) (datastore.AddressRef, []contract.WriteOutput, error) { + ref, err := contract.MaybeDeployContract(b, erc20.Deploy, chain, + contract.DeployInput[erc20.ConstructorArgs]{ + TypeAndVersion: deployment.NewTypeAndVersion(erc20.ContractType, *utils.Version_1_0_0), + ChainSelector: chain.Selector, + Qualifier: &in.Symbol, + Args: erc20.ConstructorArgs{ + Name: in.Name, + Symbol: in.Symbol, + }, + }, + nil, + ) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("failed to deploy ERC20 token: %w", err) + } + + return ref, nil, nil +} diff --git a/chains/evm/deployment/tokens/tokenimpl/token_tip20.go b/chains/evm/deployment/tokens/tokenimpl/token_tip20.go new file mode 100644 index 000000000..41ef4323b --- /dev/null +++ b/chains/evm/deployment/tokens/tokenimpl/token_tip20.go @@ -0,0 +1,103 @@ +package tokenimpl + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/tip20" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "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" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +type tokenTIP20 struct{} + +func (tokenTIP20) ContractType() deployment.ContractType { + return tip20.ContractType +} + +func (tokenTIP20) Capabilities() CapabilitySet { + return CapabilitySet{ + ParticipatesInPoolRoleGrant: true, + SupportsAdminRole: true, + SupportsCCIPAdmin: false, + SupportsPreMint: false, + } +} + +func (tokenTIP20) RevokeAdminRole(b operations.Bundle, chain evm.Chain, token, user common.Address) ([]contract.WriteOutput, error) { + report, err := operations.ExecuteOperation(b, tip20.RevokeAdminRole, chain, contract.FunctionInput[common.Address]{ + ChainSelector: chain.Selector, + Address: token, + Args: user, + }) + if err != nil { + return nil, fmt.Errorf("failed to revoke TIP-20 admin role: %w", err) + } + return []contract.WriteOutput{report.Output}, 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, + Address: token, + Args: user, + }) + if err != nil { + return nil, fmt.Errorf("failed to grant TIP-20 admin role: %w", err) + } + return []contract.WriteOutput{report.Output}, nil +} + +func (tokenTIP20) GrantPoolRoles(b operations.Bundle, chain evm.Chain, token, pool, _ common.Address) ([]contract.WriteOutput, error) { + report, err := operations.ExecuteOperation(b, tip20.GrantIssuerRole, chain, contract.FunctionInput[common.Address]{ + ChainSelector: chain.Selector, + Address: token, + Args: pool, + }) + if err != nil { + return nil, fmt.Errorf("failed to grant TIP-20 issuer role: %w", err) + } + return []contract.WriteOutput{report.Output}, nil +} + +func (tokenTIP20) SetCCIPAdmin(b operations.Bundle, chain evm.Chain, token, ccipAdmin common.Address) ([]contract.WriteOutput, error) { + return nil, fmt.Errorf("CCIP admin role not supported for TIP-20 tokens") +} + +func (tokenTIP20) Transfer(b operations.Bundle, chain evm.Chain, token, to common.Address, scaledAmount *big.Int) ([]contract.WriteOutput, error) { + report, err := operations.ExecuteOperation(b, tip20.Transfer, chain, contract.FunctionInput[tip20.TransferArgs]{ + ChainSelector: chain.Selector, + Address: token, + Args: tip20.TransferArgs{ + Amount: scaledAmount, + Receiver: to, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to transfer TIP-20 tokens: %w", err) + } + + return []contract.WriteOutput{report.Output}, nil +} + +func (tokenTIP20) Deploy(b operations.Bundle, chain evm.Chain, in tokensapi.DeployTokenInput) (datastore.AddressRef, []contract.WriteOutput, error) { + tokenRef, writes, err := tip20.DeployTokenViaFactory(b, chain, tip20.FactoryDeployArgs{ + QuoteToken: common.Address{}, + Currency: in.Currency, + Salt: [32]byte{}, + Symbol: in.Symbol, + Admin: chain.DeployerKey.From, + Name: in.Name, + }) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("failed to deploy TIP-20 token via factory: %w", err) + } + + return tokenRef, writes, nil +} 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 cff6940d6..04c1b2790 100644 --- a/chains/evm/deployment/v1_0_0/adapters/pool_adapter.go +++ b/chains/evm/deployment/v1_0_0/adapters/pool_adapter.go @@ -8,12 +8,8 @@ import ( "github.com/Masterminds/semver/v3" "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" - bnmERC20ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" - bnmDripERC20ops "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" - tip20ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/tip20" - bnmDripOps150 "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" tarseq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/sequences" tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" @@ -263,6 +259,8 @@ 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 + if a.DeployTokenPoolSeq == nil { return sequences.OnChainOutput{}, errors.New("DeployTokenPoolSeq is not set on EVMPoolAdapter") } @@ -289,128 +287,60 @@ func (a *EVMPoolAdapter) DeployTokenPoolForToken() *cldf_ops.Sequence[tokensapi. if input.TokenRef.Type != "" { toknFilterDS.Type = input.TokenRef.Type } + 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) } - - if input.RateLimitAdmin != "" && len(out.Output.Addresses) >= 1 { - poolBytes, err := a.AddressRefToBytes(out.Output.Addresses[0]) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token pool address ref to bytes: %w", err) - } - poolAddr := common.BytesToAddress(poolBytes) - if poolAddr == (common.Address{}) { - return sequences.OnChainOutput{}, errors.New("deployed token pool address is zero address") - } - - rlAdminHex := input.RateLimitAdmin - 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") - } - - output, err := a.Ops.SetRateLimitAdmin(b, chain, poolAddr, rlAdminAddr) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to set rate limit admin: %w", err) - } - batchOp, err := evm_contract.NewBatchOperationFromWrites([]evm_contract.WriteOutput{output}) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation: %w", err) - } - result.BatchOps = append(result.BatchOps, batchOp) + 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) } - isPoolTypeBnM := input.PoolType == cciputils.BurnMintTokenPool.String() - if isPoolTypeBnM && len(out.Output.Addresses) >= 1 { - poolRef := out.Output.Addresses[0] - - poolAddrBytes, addrErr := a.AddressRefToBytes(poolRef) - if addrErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to convert deployed token pool address ref to bytes: %w", addrErr) - } - toknAddrBytes, addrErr := a.AddressRefToBytes(toknRef) - if addrErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to convert token address ref to bytes: %w", addrErr) - } + var poolRef datastore.AddressRef + if len(out.Output.Addresses) >= 1 { + poolRef = out.Output.Addresses[0] + } - poolAddr := common.BytesToAddress(poolAddrBytes) - if poolAddr == (common.Address{}) { - return sequences.OnChainOutput{}, errors.New("deployed token pool address is zero address") - } - toknAddr := common.BytesToAddress(toknAddrBytes) - if toknAddr == (common.Address{}) { - return sequences.OnChainOutput{}, fmt.Errorf("token address for symbol %q is zero address", input.TokenRef.Qualifier) + if !datastore_utils.IsAddressRefEmpty(poolRef) { + if tokenPoolRolesWrites, err := tidyTokenPoolRoles(b, chain, input, poolRef, toknRef); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to tidy token pool roles: %w", err) + } else { + writes = append(writes, tokenPoolRolesWrites...) } - - writes := []evm_contract.WriteOutput{} - switch toknRef.Type.String() { - case bnmDripERC20ops.ContractType.String(), bnmERC20ops.ContractType.String(), bnmDripOps150.ContractType.String(): - report, execErr := cldf_ops.ExecuteOperation(b, - bnmERC20ops.GrantMintAndBurnRoles, chain, - evm_contract.FunctionInput[common.Address]{ - ChainSelector: input.ChainSelector, - Address: toknAddr, - Args: poolAddr, - }, - ) - if execErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to grant mint and burn roles to token pool %q for token %q on chain %d: %w", poolAddr.Hex(), input.TokenRef.Qualifier, input.ChainSelector, execErr) + if input.RateLimitAdmin != "" { + rlAdminHex := input.RateLimitAdmin + if !common.IsHexAddress(rlAdminHex) { + return sequences.OnChainOutput{}, fmt.Errorf("rate limit admin address %q is not a valid hex address", input.RateLimitAdmin) } - writes = append(writes, report.Output) - - case cciputils.BurnMintToken.String(), cciputils.ERC677TokenHelper.String(): - grantWrites, execErr := bnmERC677ops.PrepareGrantMintAndBurnRoles(b, chain, - evm_contract.FunctionInput[common.Address]{ - ChainSelector: input.ChainSelector, - Address: toknAddr, - Args: poolAddr, - }, - common.HexToAddress(input.TimelockAddress), - ) - if execErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to grant mint and burn roles to token pool %q for token %q on chain %d: %w", poolAddr.Hex(), input.TokenRef.Qualifier, input.ChainSelector, execErr) + rlAdminAddr := common.HexToAddress(rlAdminHex) + if rlAdminAddr == (common.Address{}) { + return sequences.OnChainOutput{}, errors.New("rate limit admin address cannot be the zero address") } - writes = append(writes, grantWrites...) - - case tip20ops.ContractType.String(): - report, execErr := cldf_ops.ExecuteOperation(b, - tip20ops.GrantIssuerRole, chain, - evm_contract.FunctionInput[common.Address]{ - ChainSelector: input.ChainSelector, - Address: toknAddr, - Args: poolAddr, - }, - ) - if execErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to grant TIP-20 issuer role to token pool %q for token %q on chain %d: %w", poolAddr.Hex(), input.TokenRef.Qualifier, input.ChainSelector, execErr) + poolAddr, err := datastore_utils_evm.ToEVMAddress(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) } - writes = append(writes, report.Output) - - default: - // pass through for unknown token types since we don't want to block pool deployment, but log a warning since it likely indicates a missing case in the adapter - b.Logger.Warnf( - "token type %q does not have a defined role granting strategy in EVMPoolAdapter, skipping grant of mint and burn roles to token pool %q for token %q on chain %d", - toknRef.Type.String(), poolAddr.Hex(), input.TokenRef.Qualifier, input.ChainSelector, - ) - } - - if len(writes) > 0 { - batchOp, bErr := evm_contract.NewBatchOperationFromWrites(writes) - if bErr != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation for granting mint and burn roles: %w", bErr) + 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) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to set rate limit admin: %w", err) } - result.BatchOps = append(result.BatchOps, batchOp) + writes = append(writes, output) } } - writes, err := tidyTokenRoles(b, chain, input, toknRef) - if err != nil { + if tokenRolesWrites, err := tidyTokenRoles(b, chain, input, toknRef); err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to tidy token roles: %w", err) + } else { + writes = append(writes, tokenRolesWrites...) } + if len(writes) > 0 { batchOp, bErr := evm_contract.NewBatchOperationFromWrites(writes) if bErr != nil { @@ -424,20 +354,88 @@ 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( + b cldf_ops.Bundle, + chain evm.Chain, + input tokensapi.DeployTokenPoolInput, + poolRef datastore.AddressRef, + tokenRef datastore.AddressRef, +) ([]evm_contract.WriteOutput, error) { + tokenAddr, err := datastore_utils_evm.ToEVMAddress(tokenRef) + if err != nil { + return nil, fmt.Errorf("failed to convert token ref to EVM address for chain %d: %w", input.ChainSelector, err) + } + poolAddress, err := datastore_utils_evm.ToEVMAddress(poolRef) + if err != nil { + return nil, fmt.Errorf("failed to convert token pool ref to EVM address for chain %d: %w", input.ChainSelector, err) + } + + if input.PoolType == cciputils.BurnMintTokenPool.String() { + tokenImpl, ok := tokenimpl.Get(deployment.ContractType(tokenRef.Type)) + if !ok { + b.Logger.Warnf( + "unsupported token type %q for token at ref (%s); skipping pool role grants for this token on chain %d", + tokenRef.Type.String(), datastore_utils.SprintRef(tokenRef), input.ChainSelector, + ) + return nil, nil + } + + tokenCaps := tokenImpl.Capabilities() + 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, + ) + 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) + } else { + return grantWrites, nil + } + } + + return nil, nil +} + // tidyTokenRoles will grant timelock admin rights on the token and remove // the deployer EOA as an admin. If timelock is not found in the datastore // (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. -// -// TODO: we should refactor this such that we follow a token-type adapter -// pattern thereby avoiding this switch statement altogether. func 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) + if err != nil { + return nil, fmt.Errorf("failed to convert token ref to EVM address for chain %d: %w", input.ChainSelector, err) + } + + tokenImpl, ok := tokenimpl.Get(deployment.ContractType(tokenRef.Type)) + if !ok { + b.Logger.Warnf( + "unsupported token type %q for token at ref (%s); skipping admin role tidy for this token on chain %d", + tokenRef.Type.String(), datastore_utils.SprintRef(tokenRef), input.ChainSelector, + ) + return nil, nil + } + + tokenCaps := tokenImpl.Capabilities() + if !tokenCaps.SupportsAdminRole { + b.Logger.Warnf( + "token type %q does not support admin role management; skipping tidy of token admin roles for token at ref (%s) on chain %d", + tokenRef.Type.String(), datastore_utils.SprintRef(tokenRef), input.ChainSelector, + ) + return nil, nil + } + timelockRef := datastore_utils.GetAddressRef( input.ExistingDataStore.Addresses().Filter(), input.ChainSelector, @@ -449,84 +447,20 @@ func tidyTokenRoles( 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) if err != nil { return nil, fmt.Errorf("failed to convert timelock ref to EVM address for chain %d: %w", input.ChainSelector, err) } - - tokenAddr, err := datastore_utils_evm.ToEVMAddress(tokenRef) + grantWrites, err := tokenImpl.GrantAdminRole(b, chain, tokenAddr, timelockAddr) 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 grant timelock admin role for token %q on chain %d: %w", tokenAddr.Hex(), input.ChainSelector, err) } - - switch tokenRef.Type.String() { - - // BnM ERC-20 - case bnmDripERC20ops.ContractType.String(), bnmERC20ops.ContractType.String(), bnmDripOps150.ContractType.String(): - defaultAdminRole, err := cldf_ops.ExecuteOperation(b, bnmERC20ops.GetDefaultAdminRole, chain, evm_contract.FunctionInput[struct{}]{ - ChainSelector: input.ChainSelector, - Address: tokenAddr, - Args: struct{}{}, - }) - if err != nil { - return nil, fmt.Errorf("failed to get default admin role for token %q on chain %d: %w", tokenAddr.Hex(), input.ChainSelector, err) - } - grantReport, err := cldf_ops.ExecuteOperation(b, bnmERC20ops.GrantAdminRole, chain, evm_contract.FunctionInput[bnmERC20ops.RoleAssignment]{ - ChainSelector: input.ChainSelector, - Address: tokenAddr, - Args: bnmERC20ops.RoleAssignment{ - Role: defaultAdminRole.Output, - To: timelockAddr, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to grant default admin role to timelock %q for token %q on chain %d: %w", timelockAddr.Hex(), tokenAddr.Hex(), input.ChainSelector, err) - } - revokeReport, err := cldf_ops.ExecuteOperation(b, bnmERC20ops.RevokeAdminRole, chain, evm_contract.FunctionInput[bnmERC20ops.RoleAssignment]{ - ChainSelector: input.ChainSelector, - Address: tokenAddr, - Args: bnmERC20ops.RoleAssignment{ - Role: defaultAdminRole.Output, - To: chain.DeployerKey.From, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to revoke default admin role from deployer %q for token %q on chain %d: %w", chain.DeployerKey.From.Hex(), tokenAddr.Hex(), input.ChainSelector, err) - } - return []evm_contract.WriteOutput{grantReport.Output, revokeReport.Output}, nil - - // TIP-20 - case tip20ops.ContractType.String(): - grantReport, err := cldf_ops.ExecuteOperation(b, tip20ops.GrantAdminRole, chain, evm_contract.FunctionInput[common.Address]{ - ChainSelector: input.ChainSelector, - Address: tokenAddr, - Args: timelockAddr, - }) - if err != nil { - return nil, fmt.Errorf("failed to grant TIP-20 default admin role to timelock %q for token %q on chain %d: %w", timelockAddr.Hex(), tokenAddr.Hex(), input.ChainSelector, err) - } - revokeReport, err := cldf_ops.ExecuteOperation(b, tip20ops.RevokeAdminRole, chain, evm_contract.FunctionInput[common.Address]{ - ChainSelector: input.ChainSelector, - Address: tokenAddr, - Args: chain.DeployerKey.From, - }) - if err != nil { - return nil, fmt.Errorf("failed to revoke TIP-20 default admin role from deployer %q for token %q on chain %d: %w", chain.DeployerKey.From.Hex(), tokenAddr.Hex(), input.ChainSelector, err) - } - return []evm_contract.WriteOutput{grantReport.Output, revokeReport.Output}, nil - + revokeWrites, err := tokenImpl.RevokeAdminRole(b, chain, tokenAddr, chain.DeployerKey.From) + if err != nil { + return nil, fmt.Errorf("failed to revoke deployer admin role for token %q on chain %d: %w", tokenAddr.Hex(), input.ChainSelector, err) } - b.Logger.Warnf( - "unsupported token type %q for token %q on chain %d; timelock %q is present but admin-role hardening was not applied and deployer may remain token admin", - tokenRef.Type.String(), - tokenAddr.Hex(), - input.ChainSelector, - timelockAddr.Hex(), - ) - - return nil, nil + return append(grantWrites, revokeWrites...), nil } // GetTokenAdminRegistryAddress looks up the TAR (v1.5.0) address from the datastore. diff --git a/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677.go b/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677.go index 0099cfbd7..36d07998c 100644 --- a/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677.go +++ b/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677.go @@ -4,179 +4,48 @@ import ( "context" "errors" "fmt" - "strings" + "math/big" - "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "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" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" cciputils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" - cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc677" ) var ContractType cldf_deployment.ContractType = cciputils.BurnMintToken -const burnMintERC677ABI = `[{"inputs":[],"name":"BURN_MINT_ADMIN_ROLE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"DEFAULT_ADMIN_ROLE","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"}],"name":"getRoleAdmin","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"grantRole","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"role","type":"bytes32"},{"internalType":"address","name":"account","type":"address"}],"name":"hasRole","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"burnAndMinter","type":"address"}],"name":"grantMintAndBurnRoles","outputs":[],"stateMutability":"nonpayable","type":"function"}]` - +// AuthorityKind records whether an address may call grantMintAndBurnRoles on BurnMintERC677. +// The chainlink-evm token is Ownable (not AccessControl): only the owner may grant pool roles. type AuthorityKind string const ( - AuthorityBurnMintAdmin AuthorityKind = "burn-mint-admin" - AuthorityDefaultAdmin AuthorityKind = "default-admin" - AuthorityOwner AuthorityKind = "owner" - AuthorityUnauthorized AuthorityKind = "unauthorized" + AuthorityOwner AuthorityKind = "owner" + AuthorityUnauthorized AuthorityKind = "unauthorized" ) -type GrantMintAndBurnRolesAuthority struct { - Kind AuthorityKind - BurnMintAdminRole [32]byte - AdminRole [32]byte - Owner common.Address -} - -func (a GrantMintAndBurnRolesAuthority) CanGrantMintAndBurnRoles() bool { - return a.Kind == AuthorityBurnMintAdmin || a.Kind == AuthorityOwner +type ConstructorArgs struct { + Name string + Symbol string + Decimals uint8 + MaxSupply *big.Int } -type RoleAssignment struct { - Role [32]byte - To common.Address -} - -type burnMintERC677 struct { - address common.Address - contract *bind.BoundContract -} - -func newBurnMintERC677(address common.Address, backend bind.ContractBackend) (*burnMintERC677, error) { - parsed, err := abi.JSON(strings.NewReader(burnMintERC677ABI)) - if err != nil { - return nil, err - } - - return &burnMintERC677{ - address: address, - contract: bind.NewBoundContract(address, parsed, backend, backend, backend), - }, nil -} - -func (token *burnMintERC677) Owner(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := token.contract.Call(opts, &out, "owner") - if err != nil { - return common.Address{}, err - } - - owner := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - return owner, nil -} - -func (token *burnMintERC677) BurnMintAdminRole(opts *bind.CallOpts) ([32]byte, error) { - var out []interface{} - err := token.contract.Call(opts, &out, "BURN_MINT_ADMIN_ROLE") - if err != nil { - return [32]byte{}, err - } - - role := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - return role, nil -} - -func (token *burnMintERC677) GetRoleAdmin(opts *bind.CallOpts, role [32]byte) ([32]byte, error) { - var out []interface{} - err := token.contract.Call(opts, &out, "getRoleAdmin", role) - if err != nil { - return [32]byte{}, err - } - - adminRole := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - return adminRole, nil -} - -func (token *burnMintERC677) HasRole(opts *bind.CallOpts, role [32]byte, account common.Address) (bool, error) { - var out []interface{} - err := token.contract.Call(opts, &out, "hasRole", role, account) - if err != nil { - return false, err - } - - hasRole := *abi.ConvertType(out[0], new(bool)).(*bool) - return hasRole, nil -} - -func (token *burnMintERC677) GrantRole(opts *bind.TransactOpts, role [32]byte, account common.Address) (*types.Transaction, error) { - return token.contract.Transact(opts, "grantRole", role, account) -} - -func (token *burnMintERC677) GrantMintAndBurnRoles(opts *bind.TransactOpts, burnAndMinter common.Address) (*types.Transaction, error) { - return token.contract.Transact(opts, "grantMintAndBurnRoles", burnAndMinter) +type GrantMintAndBurnRolesAuthority struct { + Kind AuthorityKind + Owner common.Address } -func (token *burnMintERC677) resolveGrantMintAndBurnRolesAuthority( - opts *bind.CallOpts, - caller common.Address, -) (GrantMintAndBurnRolesAuthority, error) { - burnMintAdminRole, accessControlErr := token.BurnMintAdminRole(opts) - if accessControlErr == nil { - hasBurnMintAdminRole, err := token.HasRole(opts, burnMintAdminRole, caller) - if err != nil { - return GrantMintAndBurnRolesAuthority{}, fmt.Errorf("failed to check burn/mint admin role for %s: %w", caller, err) - } - if hasBurnMintAdminRole { - return GrantMintAndBurnRolesAuthority{ - Kind: AuthorityBurnMintAdmin, - BurnMintAdminRole: burnMintAdminRole, - }, nil - } - - adminRole, err := token.GetRoleAdmin(opts, burnMintAdminRole) - if err != nil { - return GrantMintAndBurnRolesAuthority{}, fmt.Errorf("failed to get burn/mint admin role admin: %w", err) - } - hasRoleAdmin, err := token.HasRole(opts, adminRole, caller) - if err != nil { - return GrantMintAndBurnRolesAuthority{}, fmt.Errorf("failed to check burn/mint role admin for %s: %w", caller, err) - } - if hasRoleAdmin { - return GrantMintAndBurnRolesAuthority{ - Kind: AuthorityDefaultAdmin, - BurnMintAdminRole: burnMintAdminRole, - AdminRole: adminRole, - }, nil - } - - return GrantMintAndBurnRolesAuthority{ - Kind: AuthorityUnauthorized, - BurnMintAdminRole: burnMintAdminRole, - AdminRole: adminRole, - }, nil - } - - owner, ownerErr := token.Owner(opts) - if ownerErr == nil { - if owner == caller { - return GrantMintAndBurnRolesAuthority{ - Kind: AuthorityOwner, - Owner: owner, - }, nil - } - return GrantMintAndBurnRolesAuthority{ - Kind: AuthorityUnauthorized, - Owner: owner, - }, nil - } - - return GrantMintAndBurnRolesAuthority{}, fmt.Errorf( - "token does not expose a supported burn/mint role authority interface: BURN_MINT_ADMIN_ROLE failed: %w; owner failed: %w", - accessControlErr, - ownerErr, - ) +func (a GrantMintAndBurnRolesAuthority) CanGrantMintAndBurnRoles() bool { + return a.Kind == AuthorityOwner } +// ResolveGrantMintAndBurnRolesAuthority returns whether caller is the Ownable owner of the token. func ResolveGrantMintAndBurnRolesAuthority( ctx context.Context, backend bind.ContractBackend, @@ -190,36 +59,46 @@ func ResolveGrantMintAndBurnRolesAuthority( return GrantMintAndBurnRolesAuthority{}, errors.New("caller address cannot be zero") } - token, err := newBurnMintERC677(tokenAddress, backend) + token, err := burn_mint_erc677.NewBurnMintERC677(tokenAddress, backend) if err != nil { return GrantMintAndBurnRolesAuthority{}, err } - return token.resolveGrantMintAndBurnRolesAuthority(&bind.CallOpts{Context: ctx}, caller) + owner, err := token.Owner(&bind.CallOpts{Context: ctx}) + if err != nil { + return GrantMintAndBurnRolesAuthority{}, fmt.Errorf("failed to read token owner: %w", err) + } + if owner == caller { + return GrantMintAndBurnRolesAuthority{ + Kind: AuthorityOwner, + Owner: owner, + }, nil + } + return GrantMintAndBurnRolesAuthority{ + Kind: AuthorityUnauthorized, + Owner: owner, + }, nil } +// PrepareGrantMintAndBurnRoles plans grantMintAndBurnRoles for the pool on a BurnMintERC677 token. +// The on-chain function is owner-gated. IsAllowedCaller on GrantMintAndBurnRoles uses AllCallersAllowed +// because MCMS simulations often use the deployer key while the token owner is the timelock. +// When proposalExecutor is set and differs from the deployer, it must be the token owner. func PrepareGrantMintAndBurnRoles( b cldf_ops.Bundle, chain cldf_evm.Chain, input contract.FunctionInput[common.Address], proposalExecutor common.Address, ) ([]contract.WriteOutput, error) { - writes := []contract.WriteOutput{} - deployer := chain.DeployerKey.From - if proposalExecutor == (common.Address{}) || proposalExecutor == deployer { - deployerAuthority, err := ResolveGrantMintAndBurnRolesAuthority(b.GetContext(), chain.Client, input.Address, deployer) - if err == nil && deployerAuthority.Kind == AuthorityDefaultAdmin { - grantAdminReport, execErr := cldf_ops.ExecuteOperation(b, GrantRole, chain, contract.FunctionInput[RoleAssignment]{ - ChainSelector: input.ChainSelector, - Address: input.Address, - Args: RoleAssignment{ - Role: deployerAuthority.BurnMintAdminRole, - To: deployer, - }, - }) - if execErr != nil { - return nil, fmt.Errorf("failed to grant burn/mint admin role to deployer %s: %w", deployer, execErr) - } - writes = append(writes, grantAdminReport.Output) + if proposalExecutor != (common.Address{}) && proposalExecutor != chain.DeployerKey.From { + auth, err := ResolveGrantMintAndBurnRolesAuthority(b.GetContext(), chain.Client, input.Address, proposalExecutor) + if err != nil { + return nil, fmt.Errorf("failed to validate proposal executor %s: %w", proposalExecutor, err) + } + if auth.Kind != AuthorityOwner { + return nil, fmt.Errorf( + "proposal executor %s is not the token owner (owner=%s) for token %s; cannot grant mint/burn roles", + proposalExecutor, auth.Owner, input.Address, + ) } } @@ -227,82 +106,51 @@ func PrepareGrantMintAndBurnRoles( if err != nil { return nil, err } - writes = append(writes, grantReport.Output) - if grantReport.Output.Executed() || proposalExecutor == (common.Address{}) { - return writes, nil - } - proposalAuthority, err := ResolveGrantMintAndBurnRolesAuthority(b.GetContext(), chain.Client, input.Address, proposalExecutor) - if err != nil { - return nil, fmt.Errorf("failed to validate proposal executor %s can grant burn/mint roles: %w", proposalExecutor, err) - } - switch proposalAuthority.Kind { - case AuthorityBurnMintAdmin, AuthorityOwner: - return writes, nil - case AuthorityDefaultAdmin: - grantAdminReport, execErr := cldf_ops.ExecuteOperation(b, GrantRole, chain, contract.FunctionInput[RoleAssignment]{ - ChainSelector: input.ChainSelector, - Address: input.Address, - Args: RoleAssignment{ - Role: proposalAuthority.BurnMintAdminRole, - To: proposalExecutor, - }, - }) - if execErr != nil { - return nil, fmt.Errorf("failed to grant burn/mint admin role to proposal executor %s: %w", proposalExecutor, execErr) - } - return append([]contract.WriteOutput{grantAdminReport.Output}, writes...), nil - default: - return nil, fmt.Errorf("proposal executor %s cannot grant burn/mint roles for token %s", proposalExecutor, input.Address) - } + return []contract.WriteOutput{grantReport.Output}, nil } -var GrantRole = contract.NewWrite(contract.WriteParams[RoleAssignment, *burnMintERC677]{ - Name: "burn_mint_erc677:grant-role", +var GrantMintAndBurnRoles = contract.NewWrite(contract.WriteParams[common.Address, *burn_mint_erc677.BurnMintERC677]{ + Name: "burn_mint_erc677:grant-mint-and-burn-roles", Version: cciputils.Version_1_0_0, - Description: "Grant role on AccessControl-compatible burn/mint token contract", + Description: "Grant mint and burn roles on BurnMintERC677 (owner-only on-chain)", ContractType: ContractType, - ContractABI: burnMintERC677ABI, - NewContract: newBurnMintERC677, - IsAllowedCaller: func(token *burnMintERC677, opts *bind.CallOpts, caller common.Address, input RoleAssignment) (bool, error) { - roleAdmin, err := token.GetRoleAdmin(opts, input.Role) - if err != nil { - return false, err - } - return token.HasRole(opts, roleAdmin, caller) - }, - Validate: func(input RoleAssignment) error { - if input.To == (common.Address{}) { - return errors.New("role assignee address cannot be zero") + ContractABI: burn_mint_erc677.BurnMintERC677ABI, + NewContract: burn_mint_erc677.NewBurnMintERC677, + // On-chain only the owner may call grantMintAndBurnRoles. Do not use OnlyOwner here: + // MCMS/timelock flows simulate with the deployer key while ownership is the timelock + IsAllowedCaller: contract.AllCallersAllowed[*burn_mint_erc677.BurnMintERC677, common.Address], + Validate: func(address common.Address) error { + if address == (common.Address{}) { + return errors.New("burn and minter address cannot be zero") } return nil }, - CallContract: func(token *burnMintERC677, opts *bind.TransactOpts, input RoleAssignment) (*types.Transaction, error) { - return token.GrantRole(opts, input.Role, input.To) + CallContract: func(token *burn_mint_erc677.BurnMintERC677, opts *bind.TransactOpts, input common.Address) (*types.Transaction, error) { + return token.GrantMintAndBurnRoles(opts, input) }, }) -var GrantMintAndBurnRoles = contract.NewWrite(contract.WriteParams[common.Address, *burnMintERC677]{ - Name: "burn_mint_erc677:grant-mint-and-burn-roles", - Version: cciputils.Version_1_0_0, - Description: "Grant mint and burn role on BurnMintERC677 token contract", - ContractType: ContractType, - ContractABI: burnMintERC677ABI, - NewContract: newBurnMintERC677, - IsAllowedCaller: func(token *burnMintERC677, opts *bind.CallOpts, caller common.Address, input common.Address) (bool, error) { - authority, err := token.resolveGrantMintAndBurnRolesAuthority(opts, caller) - if err != nil { - return false, err - } - return authority.CanGrantMintAndBurnRoles(), nil +var Deploy = contract.NewDeploy(contract.DeployParams[ConstructorArgs]{ + Name: "burn_mint_erc677:deploy", + Version: cciputils.Version_1_0_0, + Description: "Deploys the BurnMintERC677 token contract", + ContractMetadata: burn_mint_erc677.BurnMintERC677MetaData, + BytecodeByTypeAndVersion: map[string]contract.Bytecode{ + cldf_deployment.NewTypeAndVersion(ContractType, *cciputils.Version_1_0_0).String(): { + EVM: common.FromHex(burn_mint_erc677.BurnMintERC677Bin), + }, }, - Validate: func(address common.Address) error { - if address == (common.Address{}) { - return errors.New("burn and minter address cannot be zero") + Validate: func(args ConstructorArgs) error { + if args.Name == "" { + return errors.New("name is required") + } + if args.Symbol == "" { + return errors.New("symbol is required") + } + if args.MaxSupply == nil { + return errors.New("maxSupply is required") } return nil }, - CallContract: func(token *burnMintERC677, opts *bind.TransactOpts, input common.Address) (*types.Transaction, error) { - return token.GrantMintAndBurnRoles(opts, input) - }, }) diff --git a/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677_test.go b/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677_test.go index eaba694c8..f01737729 100644 --- a/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677_test.go +++ b/chains/evm/deployment/v1_0_0/operations/burn_mint_erc677/burn_mint_erc677_test.go @@ -10,72 +10,108 @@ import ( "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/latest/cross_chain_token" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + wrappers "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc677" ) -func TestPrepareGrantMintAndBurnRolesAddsAdminGrantForDefaultAdminProposalExecutor(t *testing.T) { +func TestResolveGrantMintAndBurnRolesAuthority_owner(t *testing.T) { const selector uint64 = 5009297550715157269 e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{selector})) require.NoError(t, err) chain := e.BlockChains.EVMChains()[selector] - timelock := common.HexToAddress("0x000000000000000000000000000000000000dEaD") pool := common.HexToAddress("0x000000000000000000000000000000000000bEEF") - tokenAddress, tx, _, err := cross_chain_token.DeployCrossChainToken( + tokenAddress, tx, _, err := wrappers.DeployBurnMintERC677( chain.DeployerKey, chain.Client, - cross_chain_token.BaseERC20ConstructorParams{ - Name: "Cross Chain Test Token", - Symbol: "CCTT", - MaxSupply: big.NewInt(0), - PreMint: big.NewInt(0), - PreMintRecipient: common.Address{}, - Decimals: 18, - CcipAdmin: timelock, - }, - common.Address{}, - timelock, + "Test Token", + "TT", + 18, + big.NewInt(0), ) require.NoError(t, err) _, err = chain.Confirm(tx) require.NoError(t, err) - authority, err := ResolveGrantMintAndBurnRolesAuthority(t.Context(), chain.Client, tokenAddress, timelock) + auth, err := ResolveGrantMintAndBurnRolesAuthority(t.Context(), chain.Client, tokenAddress, chain.DeployerKey.From) require.NoError(t, err) - require.Equal(t, AuthorityDefaultAdmin, authority.Kind) + require.Equal(t, AuthorityOwner, auth.Kind) + require.Equal(t, chain.DeployerKey.From, auth.Owner) writes, err := PrepareGrantMintAndBurnRoles(e.OperationsBundle, chain, contract.FunctionInput[common.Address]{ ChainSelector: selector, Address: tokenAddress, Args: pool, - }, timelock) + }, common.Address{}) + require.NoError(t, err) + require.Len(t, writes, 1) + + parsedABI, err := abi.JSON(strings.NewReader(wrappers.BurnMintERC677ABI)) + require.NoError(t, err) + wantData, err := parsedABI.Pack("grantMintAndBurnRoles", pool) + require.NoError(t, err) + require.Equal(t, wantData, writes[0].Tx.Data) +} + +func TestResolveGrantMintAndBurnRolesAuthority_unauthorizedCaller(t *testing.T) { + const selector uint64 = 5009297550715157269 + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{selector})) require.NoError(t, err) - require.Len(t, writes, 2) - require.False(t, writes[0].Executed()) - require.False(t, writes[1].Executed()) - batchOp, err := contract.NewBatchOperationFromWrites(writes) + chain := e.BlockChains.EVMChains()[selector] + tokenAddress, tx, _, err := wrappers.DeployBurnMintERC677( + chain.DeployerKey, + chain.Client, + "Test Token", + "TT", + 18, + big.NewInt(0), + ) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + stranger := common.HexToAddress("0x000000000000000000000000000000000000CAFE") + pool := common.HexToAddress("0x000000000000000000000000000000000000bEEF") + + auth, err := ResolveGrantMintAndBurnRolesAuthority(t.Context(), chain.Client, tokenAddress, stranger) require.NoError(t, err) - require.Len(t, batchOp.Transactions, 2) - require.Equal(t, tokenAddress.Hex(), batchOp.Transactions[0].To) - require.Equal(t, tokenAddress.Hex(), batchOp.Transactions[1].To) + require.Equal(t, AuthorityUnauthorized, auth.Kind) + + _, err = PrepareGrantMintAndBurnRoles(e.OperationsBundle, chain, contract.FunctionInput[common.Address]{ + ChainSelector: selector, + Address: tokenAddress, + Args: pool, + }, stranger) + require.ErrorContains(t, err, "not the token owner") +} - parsedABI, err := abi.JSON(strings.NewReader(burnMintERC677ABI)) +func TestPrepareGrantMintAndBurnRoles_timelockMustBeOwnerWhenSet(t *testing.T) { + const selector uint64 = 5009297550715157269 + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{selector})) require.NoError(t, err) - grantRoleData, err := parsedABI.Pack("grantRole", authority.BurnMintAdminRole, timelock) + + chain := e.BlockChains.EVMChains()[selector] + timelock := common.HexToAddress("0x000000000000000000000000000000000000dEaD") + pool := common.HexToAddress("0x000000000000000000000000000000000000bEEF") + + tokenAddress, tx, _, err := wrappers.DeployBurnMintERC677( + chain.DeployerKey, + chain.Client, + "Test Token", + "TT", + 18, + big.NewInt(0), + ) require.NoError(t, err) - grantMintAndBurnRolesData, err := parsedABI.Pack("grantMintAndBurnRoles", pool) + _, err = chain.Confirm(tx) require.NoError(t, err) - require.Equal(t, grantRoleData, batchOp.Transactions[0].Data) - require.Equal(t, grantMintAndBurnRolesData, batchOp.Transactions[1].Data) - unauthorizedExecutor := common.HexToAddress("0x000000000000000000000000000000000000CAFE") _, err = PrepareGrantMintAndBurnRoles(e.OperationsBundle, chain, contract.FunctionInput[common.Address]{ ChainSelector: selector, Address: tokenAddress, Args: pool, - }, unauthorizedExecutor) - require.ErrorContains(t, err, "cannot grant burn/mint roles") + }, timelock) + require.ErrorContains(t, err, "not the token owner") } diff --git a/chains/evm/deployment/v1_0_0/operations/tip20/tip20_contract.go b/chains/evm/deployment/v1_0_0/operations/tip20/tip20_contract.go index b8c8d8887..691d94d22 100644 --- a/chains/evm/deployment/v1_0_0/operations/tip20/tip20_contract.go +++ b/chains/evm/deployment/v1_0_0/operations/tip20/tip20_contract.go @@ -1,6 +1,7 @@ package tip20 import ( + "math/big" "strings" "github.com/ethereum/go-ethereum/accounts/abi" @@ -9,11 +10,11 @@ import ( "github.com/ethereum/go-ethereum/core/types" ) -// TIP20TokenABI is a minimal ABI for TIP-20 token role management (ITIP20 ISSUER_ROLE + ITIP20RolesAuth). +// TIP20TokenABI is a minimal ABI for TIP-20 token role management and transfers (ITIP20 ISSUER_ROLE + ITIP20RolesAuth). // Role layout follows TIP20RolesAuth: https://github.com/tempoxyz/tempo/blob/main/tips/ref-impls/src/abstracts/TIP20RolesAuth.sol // hasRole is a public mapping, so the getter is hasRole(address,bytes32), not OpenZeppelin AccessControl order. // ISSUER_ROLE: https://github.com/tempoxyz/tempo-std/blob/master/src/interfaces/ITIP20.sol -const TIP20TokenABI = `[{"type":"function","name":"grantRole","inputs":[{"name":"role","type":"bytes32"},{"name":"account","type":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeRole","inputs":[{"name":"role","type":"bytes32"},{"name":"account","type":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"hasRole","inputs":[{"name":"account","type":"address"},{"name":"role","type":"bytes32"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"},{"type":"function","name":"ISSUER_ROLE","inputs":[],"outputs":[{"name":"","type":"bytes32"}],"stateMutability":"view"}]` +const TIP20TokenABI = `[{"type":"function","name":"grantRole","inputs":[{"name":"role","type":"bytes32"},{"name":"account","type":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"revokeRole","inputs":[{"name":"role","type":"bytes32"},{"name":"account","type":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"hasRole","inputs":[{"name":"account","type":"address"},{"name":"role","type":"bytes32"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"view"},{"type":"function","name":"ISSUER_ROLE","inputs":[],"outputs":[{"name":"","type":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"transfer","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable"}]` // DefaultAdminRole is TIP20RolesAuth.DEFAULT_ADMIN_ROLE (bytes32(0)). var DefaultAdminRole [32]byte @@ -47,6 +48,10 @@ func (t *TIP20Token) RevokeRole(opts *bind.TransactOpts, role [32]byte, account return t.contract.Transact(opts, "revokeRole", role, account) } +func (t *TIP20Token) Transfer(opts *bind.TransactOpts, to common.Address, amount *big.Int) (*types.Transaction, error) { + return t.contract.Transact(opts, "transfer", to, amount) +} + func (t *TIP20Token) HasRole(opts *bind.CallOpts, account common.Address, role [32]byte) (bool, error) { var out []any err := t.contract.Call(opts, &out, "hasRole", account, role) diff --git a/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops.go b/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops.go index edd7ba011..4ab082d50 100644 --- a/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops.go +++ b/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops.go @@ -3,15 +3,14 @@ package tip20 import ( "errors" "fmt" + "math/big" "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - mcms_types "github.com/smartcontractkit/mcms/types" chainsel "github.com/smartcontractkit/chain-selectors" - "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" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" @@ -38,7 +37,7 @@ const ( // If we want to deploy a token with currency USD, then its quote token must also be USD. const DefaultQuoteToken = TokenPathUSD -// NOTE: we chose USD as the default currency since most of the well-known TIP20 tokens on Tempo +// NOTE: we chose USD as the default currency since most of the well-known TIP-20 tokens on Tempo // use USD as their currency (e.g. ThetaUSD, BetaUSD, AlphaUSD, PathUSD). const DefaultCurrency = "USD" @@ -46,121 +45,132 @@ type FactoryDeployArgs struct { Currency string // The token currency. Defaults to USD if not provided. Symbol string // The token symbol. This is a required input. Name string // The token name. This is a required input. - QuoteToken common.Address // Address of a pre-existing TIP20 token to use as the quote token. Defaults to PathUSD if not provided. + QuoteToken common.Address // Address of a pre-existing TIP-20 token to use as the quote token. Defaults to PathUSD if not provided. Admin common.Address // The token admin. Defaults to the deployer address if not provided. Salt [32]byte // Optional salt for deterministic deployment. Defaults to a random salt if not provided. } -// Deploy deploys the TIP20 token contract with the provided deploy arguments. The TIP20 token is ERC20 compliant and includes additional -// features as defined in the TIP20 standard: https://www.mintlify.com/tempoxyz/tempo/protocol/tip20/overview#erc-20-compatibility. This -// sequence is only applicable for Tempo testnet / mainnet. The token is deployed via the factory contract as recommended in the docs. We -// use sensible defaults for QuoteToken, Currency, Admin, and Salt to reduce the configuration burden on the user when deploying a TIP20 -// token. +type TransferArgs struct { + Receiver common.Address + Amount *big.Int +} + +// DeployTokenViaFactory deploys the TIP-20 token contract with the provided deploy arguments. The TIP-20 token is ERC20 compliant and includes +// additional features as defined in the TIP-20 standard: https://www.mintlify.com/tempoxyz/tempo/protocol/tip20/overview#erc-20-compatibility. +// This function is only applicable for Tempo testnet / mainnet. The token is deployed via the factory contract as recommended in the docs - we +// use sensible defaults for QuoteToken, Currency, Admin, and Salt to reduce the configuration burden on the user when deploying a TIP-20 token // // Factory Contract: https://github.com/tempoxyz/tempo/blob/a20e2e46c7cba6164ef95c91bf83d5fc614750f3/tips/ref-impls/src/TIP20Factory.sol#L1 // Token Contract: https://github.com/tempoxyz/tempo/blob/a20e2e46c7cba6164ef95c91bf83d5fc614750f3/tips/ref-impls/src/TIP20.sol#L1 // Docs: https://www.mintlify.com/tempoxyz/tempo/protocol/tip20/overview -var Deploy = operations.NewSequence( - "tip20:deploy", - Version, - "Deploys a TIP20 token via the TIP20 factory. Only applicable for Tempo testnet / mainnet.", - func(b operations.Bundle, chain evm.Chain, input FactoryDeployArgs) (sequences.OnChainOutput, error) { - isTempoTestnet := chainsel.TEMPO_TESTNET_MODERATO.Selector == chain.Selector || chainsel.TEMPO_TESTNET.Selector == chain.Selector - isTempoMainnet := chainsel.TEMPO_MAINNET.Selector == chain.Selector - if !isTempoTestnet && !isTempoMainnet { - return sequences.OnChainOutput{}, errors.New("TIP20 token deployment is only supported on Tempo testnet and mainnet") - } - - factoryAddr := common.HexToAddress(TokenFactoryAddress) - deployerKey := chain.DeployerKey.From - if input.Symbol == "" { - return sequences.OnChainOutput{}, errors.New("symbol is required") - } - if input.Name == "" { - return sequences.OnChainOutput{}, errors.New("name is required") - } - if input.QuoteToken == (common.Address{}) { - input.QuoteToken = common.HexToAddress(DefaultQuoteToken) - } - if input.Currency == "" { - input.Currency = DefaultCurrency - } - if input.Admin == (common.Address{}) { - input.Admin = deployerKey - } - if input.Salt == [32]byte{} { - if salt, err := generateValidSalt(b, chain, factoryAddr, deployerKey); err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to produce a valid salt for token deployment: %w", err) - } else { - input.Salt = salt - } - } - - b.Logger.Infof("Validating quote token address: %s", input.QuoteToken.Hex()) - isQuoteTokenValid, err := operations.ExecuteOperation(b, IsTIP20, chain, contract.FunctionInput[common.Address]{ - ChainSelector: chain.Selector, - Address: factoryAddr, - Args: input.QuoteToken, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("isTIP20 quote token: %w", err) - } - if !isQuoteTokenValid.Output { - return sequences.OnChainOutput{}, errors.New("quoteToken must be a valid TIP-20 token address") - } +func DeployTokenViaFactory(b operations.Bundle, chain evm.Chain, input FactoryDeployArgs) (datastore.AddressRef, []contract.WriteOutput, error) { + isTempoTestnet := chainsel.TEMPO_TESTNET_MODERATO.Selector == chain.Selector || chainsel.TEMPO_TESTNET.Selector == chain.Selector + isTempoMainnet := chainsel.TEMPO_MAINNET.Selector == chain.Selector + if !isTempoTestnet && !isTempoMainnet { + return datastore.AddressRef{}, nil, errors.New("TIP-20 token deployment is only supported on Tempo testnet and mainnet") + } + + factoryAddr := common.HexToAddress(TokenFactoryAddress) + deployerKey := chain.DeployerKey.From + if input.Symbol == "" { + return datastore.AddressRef{}, nil, errors.New("symbol is required") + } + if input.Name == "" { + return datastore.AddressRef{}, nil, errors.New("name is required") + } + if input.QuoteToken == (common.Address{}) { + input.QuoteToken = common.HexToAddress(DefaultQuoteToken) + } + if input.Currency == "" { + input.Currency = DefaultCurrency + } + if input.Admin == (common.Address{}) { + input.Admin = deployerKey + } + if input.Salt == [32]byte{} { + if salt, err := generateValidSalt(b, chain, factoryAddr, deployerKey); err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("failed to produce a valid salt for token deployment: %w", err) + } else { + input.Salt = salt + } + } + + b.Logger.Infof("Validating quote token address: %s", input.QuoteToken.Hex()) + isQuoteTokenValid, err := operations.ExecuteOperation(b, IsTIP20, chain, contract.FunctionInput[common.Address]{ + ChainSelector: chain.Selector, + Address: factoryAddr, + Args: input.QuoteToken, + }) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("isTIP20 quote token: %w", err) + } + if !isQuoteTokenValid.Output { + return datastore.AddressRef{}, nil, errors.New("quoteToken must be a valid TIP-20 token address") + } + + b.Logger.Infof("Deploying TIP-20 token: %+v", input) + createTokenReport, err := operations.ExecuteOperation(b, CreateToken, chain, contract.FunctionInput[CreateTokenArgs]{ + ChainSelector: chain.Selector, + Address: factoryAddr, + Args: CreateTokenArgs{ + QuoteToken: input.QuoteToken, + Currency: input.Currency, + Symbol: input.Symbol, + Admin: input.Admin, + Name: input.Name, + Salt: input.Salt, + }, + }) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("createToken: %w", err) + } + + b.Logger.Info("Retrieving address of deployed token via factory's getTokenAddress function") + tokenAddrReport, err := operations.ExecuteOperation(b, GetTokenAddress, chain, contract.FunctionInput[GetTokenAddressArgs]{ + ChainSelector: chain.Selector, + Address: factoryAddr, + Args: GetTokenAddressArgs{ + Sender: deployerKey, + Salt: input.Salt, + }, + }) + if err != nil { + return datastore.AddressRef{}, nil, fmt.Errorf("getTokenAddress after deploy: %w", err) + } + + b.Logger.Infof("Deployed TIP-20 token at address: %s", tokenAddrReport.Output.Hex()) + tokenRef := datastore.AddressRef{ + ChainSelector: chain.Selector, + Address: tokenAddrReport.Output.Hex(), + Qualifier: input.Symbol, + Type: datastore.ContractType(ContractType), + Version: Version, + } + + return tokenRef, []contract.WriteOutput{createTokenReport.Output}, nil +} - b.Logger.Infof("Deploying TIP20 token: %+v", input) - createTokenReport, err := operations.ExecuteOperation(b, CreateToken, chain, contract.FunctionInput[CreateTokenArgs]{ - ChainSelector: chain.Selector, - Address: factoryAddr, - Args: CreateTokenArgs{ - QuoteToken: input.QuoteToken, - Currency: input.Currency, - Symbol: input.Symbol, - Admin: input.Admin, - Name: input.Name, - Salt: input.Salt, - }, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("createToken: %w", err) +var Transfer = contract.NewWrite(contract.WriteParams[TransferArgs, *TIP20Token]{ + Name: "tip20:transfer", + Version: Version, + Description: "Transfer TIP-20 tokens to a specified address", + ContractType: ContractType, + ContractABI: TIP20TokenABI, + NewContract: NewTIP20Token, + IsAllowedCaller: contract.AllCallersAllowed[*TIP20Token, TransferArgs], + Validate: func(args TransferArgs) error { + if args.Amount == nil || args.Amount.Cmp(big.NewInt(0)) <= 0 { + return errors.New("amount must be greater than 0") + } + if args.Receiver == (common.Address{}) { + return errors.New("receiver address is required") } - - b.Logger.Info("Retrieving address of deployed token via factory's getTokenAddress function") - tokenAddrReport, err := operations.ExecuteOperation(b, GetTokenAddress, chain, contract.FunctionInput[GetTokenAddressArgs]{ - ChainSelector: chain.Selector, - Address: factoryAddr, - Args: GetTokenAddressArgs{ - Sender: deployerKey, - Salt: input.Salt, - }, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("getTokenAddress after deploy: %w", err) - } - - b.Logger.Infof("Deployed TIP20 token at address: %s", tokenAddrReport.Output.Hex()) - batchOp, err := contract.NewBatchOperationFromWrites([]contract.WriteOutput{createTokenReport.Output}) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("batch operation: %w", err) - } - - return sequences.OnChainOutput{ - Addresses: []datastore.AddressRef{ - { - ChainSelector: chain.Selector, - Address: tokenAddrReport.Output.Hex(), - Qualifier: input.Symbol, - Type: datastore.ContractType(ContractType), - Version: Version, - }, - }, - BatchOps: []mcms_types.BatchOperation{ - batchOp, - }, - }, nil + return nil }, -) + CallContract: func(token *TIP20Token, opts *bind.TransactOpts, args TransferArgs) (*types.Transaction, error) { + return token.Transfer(opts, args.Receiver, args.Amount) + }, +}) var GrantIssuerRole = contract.NewWrite(contract.WriteParams[common.Address, *TIP20Token]{ Name: "tip20:grant-issuer-role", diff --git a/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops_test.go b/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops_test.go index 369eaee00..aaa846a20 100644 --- a/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops_test.go +++ b/chains/evm/deployment/v1_0_0/operations/tip20/tip20_ops_test.go @@ -9,10 +9,9 @@ import ( "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" - "github.com/smartcontractkit/chainlink-deployments-framework/operations" ) -func TestDeploy_RejectsNonTempoChain(t *testing.T) { +func TestDeployTokenViaFactory_RejectsNonTempoChain(t *testing.T) { t.Parallel() evmSel := chainsel.ETHEREUM_MAINNET.Selector @@ -24,7 +23,7 @@ func TestDeploy_RejectsNonTempoChain(t *testing.T) { chain, ok := e.BlockChains.EVMChains()[evmSel] require.True(t, ok) - _, err = operations.ExecuteSequence(e.OperationsBundle, tip20.Deploy, chain, tip20.FactoryDeployArgs{ + _, _, err = tip20.DeployTokenViaFactory(e.OperationsBundle, chain, tip20.FactoryDeployArgs{ QuoteToken: common.Address{}, // defaults to sensible value Currency: "", // defaults to sensible value Salt: [32]byte{}, // generate random salt diff --git a/chains/evm/deployment/v1_0_0/sequences/token.go b/chains/evm/deployment/v1_0_0/sequences/token.go index b60521d26..f0a603774 100644 --- a/chains/evm/deployment/v1_0_0/sequences/token.go +++ b/chains/evm/deployment/v1_0_0/sequences/token.go @@ -1,79 +1,32 @@ package sequences import ( - "errors" "fmt" "math/big" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" mcms_types "github.com/smartcontractkit/mcms/types" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" - "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" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/tip20" - drip_v150 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/burn_mint_erc20_with_drip" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/tokens/tokenimpl" + datastore_utils_evm "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" tokenapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" common_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" 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" - bnm_erc20_bindings "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc20" ) -func tokenSupportsAdminRole(tokenType deployment.ContractType) bool { - switch tokenType { - case burn_mint_erc20.ContractType, - burn_mint_erc20_with_drip.ContractType, - drip_v150.ContractType, - tip20.ContractType: - return true - default: - return false - } -} - -func tokenSupportsCCIPAdmin(tokenType deployment.ContractType) bool { - switch tokenType { - case burn_mint_erc20.ContractType, - burn_mint_erc20_with_drip.ContractType, - drip_v150.ContractType: - return true - default: - return false - } -} - -func tokenSupportsPreMint(tokenType deployment.ContractType) bool { - switch tokenType { - // drip_v150 has no supply/decimals in its constructor so pre-mint is not supported - case burn_mint_erc20.ContractType, burn_mint_erc20_with_drip.ContractType: - return true - default: - return false - } -} - var DeployToken = cldf_ops.NewSequence( "deploy-token", common_utils.Version_1_0_0, "Deploy given type of token contracts", func(b cldf_ops.Bundle, chains cldf_chain.BlockChains, input tokenapi.DeployTokenInput) (sequences.OnChainOutput, error) { - addresses := make([]datastore.AddressRef, 0) - writes := make([]contract.WriteOutput, 0) - chain := chains.EVMChains()[input.ChainSelector] - var err error - var tokenRef datastore.AddressRef - qualifier := input.Symbol - - maxSupply := big.NewInt(0) - if input.Supply != nil { - maxSupply = tokenapi.ScaleTokenAmount(new(big.Int).SetUint64(*input.Supply), input.Decimals) + chain, ok := chains.EVMChains()[input.ChainSelector] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not found among provided chains", input.ChainSelector) } preMint := big.NewInt(0) @@ -95,172 +48,56 @@ var DeployToken = cldf_ops.NewSequence( ccipAdmin = common.HexToAddress(input.CCIPAdmin) } - switch input.Type { - case erc20.ContractType: - tokenRef, err = contract.MaybeDeployContract(b, erc20.Deploy, chain, contract.DeployInput[erc20.ConstructorArgs]{ - TypeAndVersion: deployment.NewTypeAndVersion(erc20.ContractType, *common_utils.Version_1_0_0), - ChainSelector: chain.Selector, - Args: erc20.ConstructorArgs{ - Name: input.Name, - Symbol: input.Symbol, - }, - Qualifier: &qualifier, - }, nil) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy ERC20 token: %w", err) - } + tokenImpl, ok := tokenimpl.Get(input.Type) + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("unsupported token type: %s", input.Type) + } + tokenRefr, deployWrites, err := tokenImpl.Deploy(b, chain, input) + if err != nil { + return sequences.OnChainOutput{}, err + } + tokenAddr, err := datastore_utils_evm.ToEVMAddress(tokenRefr) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("invalid token address reference: %w", err) + } - case burn_mint_erc20.ContractType: - tokenRef, err = contract.MaybeDeployContract(b, burn_mint_erc20.Deploy, chain, contract.DeployInput[burn_mint_erc20.ConstructorArgs]{ - TypeAndVersion: deployment.NewTypeAndVersion(burn_mint_erc20.ContractType, *common_utils.Version_1_0_0), - ChainSelector: chain.Selector, - Args: burn_mint_erc20.ConstructorArgs{ - Name: input.Name, - Symbol: input.Symbol, - Decimals: input.Decimals, - MaxSupply: maxSupply, - PreMint: preMint, - }, - Qualifier: &qualifier, - }, nil) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnMintERC20 token: %w", err) + caps := tokenImpl.Capabilities() + recv := common.Address{} + if len(input.Senders) >= 1 && preMint.Cmp(big.NewInt(0)) > 0 && caps.SupportsPreMint { + address := input.Senders[0] + if !common.IsHexAddress(address) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid pre-mint recipient address: %s", address) } - - case burn_mint_erc20_with_drip.ContractType: - tokenRef, err = contract.MaybeDeployContract(b, burn_mint_erc20_with_drip.Deploy, chain, contract.DeployInput[burn_mint_erc20_with_drip.ConstructorArgs]{ - TypeAndVersion: deployment.NewTypeAndVersion(burn_mint_erc20_with_drip.ContractType, *common_utils.Version_1_0_0), - ChainSelector: chain.Selector, - Args: burn_mint_erc20_with_drip.ConstructorArgs{ - Name: input.Name, - Symbol: input.Symbol, - Decimals: input.Decimals, - MaxSupply: maxSupply, - PreMint: preMint, - }, - Qualifier: &qualifier, - }, nil) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnMintERC20WithDrip token: %w", err) + recv = common.HexToAddress(address) + if recv == (common.Address{}) { + return sequences.OnChainOutput{}, fmt.Errorf("pre-mint recipient address cannot be the zero address") } - - case drip_v150.ContractType: - tokenRef, err = contract.MaybeDeployContract(b, drip_v150.Deploy, chain, contract.DeployInput[drip_v150.ConstructorArgs]{ - TypeAndVersion: deployment.NewTypeAndVersion(drip_v150.ContractType, *drip_v150.Version), - ChainSelector: chain.Selector, - Args: drip_v150.ConstructorArgs{ - Name: input.Name, - Symbol: input.Symbol, - }, - Qualifier: &qualifier, - }, nil) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy BurnMintERC20WithDrip (v1.5.0) token: %w", err) + if len(input.Senders) != 1 { + b.Logger.Warnf("Multiple sender addresses provided, but adapter only supports one. Only the first address will receive the tokens: %s", address) } + } - case tip20.ContractType: - // Initial admin must be the deployer so subsequent ops (e.g. GrantIssuerRole) run as the same - // identity pass IsAllowedCaller; ExternalAdmin receives DEFAULT_ADMIN_ROLE in a follow-up grant. - report, err := cldf_ops.ExecuteSequence(b, tip20.Deploy, chain, tip20.FactoryDeployArgs{ - QuoteToken: common.Address{}, // defaults to sensible value - Currency: input.Currency, // defaults to sensible value if empty - Salt: [32]byte{}, // defaults to random salt - Symbol: input.Symbol, - Admin: chain.DeployerKey.From, - Name: input.Name, - }) + writes := append([]contract.WriteOutput{}, deployWrites...) + if recv != (common.Address{}) && caps.SupportsPreMint { + transferWrites, err := tokenImpl.Transfer(b, chain, tokenAddr, recv, preMint) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to deploy TIP20 token via factory: %w", err) - } - if len(report.Output.Addresses) == 0 { - return sequences.OnChainOutput{}, errors.New("no address returned from TIP20 factory deployment") + return sequences.OnChainOutput{}, fmt.Errorf("failed to transfer pre-minted tokens: %w", err) } - tokenRef = report.Output.Addresses[0] - - default: - return sequences.OnChainOutput{}, fmt.Errorf("unsupported token type: %s", input.Type) + writes = append(writes, transferWrites...) } - - tokenAddr := common.HexToAddress(tokenRef.Address) - addresses = append(addresses, tokenRef) - - if tokenSupportsPreMint(input.Type) && preMint.Cmp(big.NewInt(0)) > 0 && len(input.Senders) > 0 { - firstSender := input.Senders[0] - if !common.IsHexAddress(firstSender) { - return sequences.OnChainOutput{}, fmt.Errorf("invalid sender address: %s", firstSender) - } - tokReceiver := common.HexToAddress(firstSender) - if tokReceiver == (common.Address{}) { - return sequences.OnChainOutput{}, errors.New("refusing to transfer pre-minted tokens to the zero address") - } - if len(input.Senders) > 1 { - b.Logger.Warnf("Multiple senders provided but only the first one (%s) will receive the pre-minted tokens", tokReceiver.Hex()) - } - transferReport, err := cldf_ops.ExecuteOperation(b, erc20.Transfer, chain, contract.FunctionInput[erc20.TransferArgs]{ - ChainSelector: chain.Selector, - Address: tokenAddr, - Args: erc20.TransferArgs{ - Receiver: tokReceiver, - Amount: preMint, - }, - }) + if input.ExternalAdmin != "" && caps.SupportsAdminRole { + grantWrites, err := tokenImpl.GrantAdminRole(b, chain, tokenAddr, externalAdmin) if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to transfer pre-minted tokens to sender %s: %w", tokReceiver.Hex(), err) + return sequences.OnChainOutput{}, fmt.Errorf("failed to grant admin role to %s: %w", input.ExternalAdmin, err) } - writes = append(writes, transferReport.Output) + writes = append(writes, grantWrites...) } - - if input.CCIPAdmin != "" && tokenSupportsCCIPAdmin(input.Type) { - setCCIPAdminReport, err := cldf_ops.ExecuteOperation(b, burn_mint_erc20.SetCCIPAdmin, chain, contract.FunctionInput[string]{ - ChainSelector: chain.Selector, - Address: tokenAddr, - Args: ccipAdmin.Hex(), - }) + if input.CCIPAdmin != "" && caps.SupportsCCIPAdmin { + adminWrites, err := tokenImpl.SetCCIPAdmin(b, chain, tokenAddr, ccipAdmin) if err != nil { return sequences.OnChainOutput{}, fmt.Errorf("failed to set CCIP admin: %w", err) } - writes = append(writes, setCCIPAdminReport.Output) - } - - if input.ExternalAdmin != "" && tokenSupportsAdminRole(input.Type) { - switch input.Type { - case burn_mint_erc20.ContractType, burn_mint_erc20_with_drip.ContractType, drip_v150.ContractType: - token, err := bnm_erc20_bindings.NewBurnMintERC20(tokenAddr, chain.Client) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to instantiate BurnMintERC20 contract: %w", err) - } - role, err := token.DEFAULTADMINROLE(&bind.CallOpts{Context: b.GetContext()}) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to get default admin role constant: %w", err) - } - - grantReport, err := cldf_ops.ExecuteOperation(b, burn_mint_erc20.GrantAdminRole, chain, contract.FunctionInput[burn_mint_erc20.RoleAssignment]{ - ChainSelector: chain.Selector, - Address: tokenAddr, - Args: burn_mint_erc20.RoleAssignment{ - Role: role, - To: externalAdmin, - }, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to grant admin role to %s: %w", input.ExternalAdmin, err) - } - writes = append(writes, grantReport.Output) - - case tip20.ContractType: - grantReport, err := cldf_ops.ExecuteOperation(b, tip20.GrantAdminRole, chain, contract.FunctionInput[common.Address]{ - ChainSelector: chain.Selector, - Address: tokenAddr, - Args: externalAdmin, - }) - if err != nil { - return sequences.OnChainOutput{}, fmt.Errorf("failed to grant admin role to %s: %w", input.ExternalAdmin, err) - } - writes = append(writes, grantReport.Output) - - default: - return sequences.OnChainOutput{}, fmt.Errorf("unsupported token type for admin role grant: %s", input.Type) - } + writes = append(writes, adminWrites...) } batchOp, err := contract.NewBatchOperationFromWrites(writes) @@ -269,7 +106,7 @@ var DeployToken = cldf_ops.NewSequence( } return sequences.OnChainOutput{ - Addresses: addresses, + Addresses: []datastore.AddressRef{tokenRefr}, BatchOps: []mcms_types.BatchOperation{batchOp}, }, nil }, diff --git a/chains/evm/deployment/v1_0_0/sequences/token_test.go b/chains/evm/deployment/v1_0_0/sequences/token_test.go index 038256aa8..c08be6665 100644 --- a/chains/evm/deployment/v1_0_0/sequences/token_test.go +++ b/chains/evm/deployment/v1_0_0/sequences/token_test.go @@ -14,13 +14,15 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/tokens/tokenimpl" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" - "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" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/tip20" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/burn_mint_erc20_with_drip" bnm_bindings "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/burn_mint_erc20" tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" ) // TestEVMTokenDeployments tests various EVM token deployments using the DeployToken sequence directly. @@ -71,16 +73,12 @@ func TestEVMTokenDeployments(t *testing.T) { requiresSupply: true, }, { - name: "BurnMintERC20WithDripToken", - tokenType: burn_mint_erc20_with_drip.ContractType, - tokenName: "Test BurnMint ERC20 With Drip", - tokenSymbol: "TBMDRIP", - decimals: 18, - ccipAdmin: "0x1111111111111111111111111111111111111111", - sender: "", - supply: &maxSupply, - preMint: &preMint, - requiresSupply: true, + name: "BurnMintERC20WithDrip", + tokenType: burn_mint_erc20_with_drip.ContractType, + tokenName: "Test BurnMint ERC20 With Drip", + tokenSymbol: "TBMDRIP", + decimals: 18, + ccipAdmin: "0x1111111111111111111111111111111111111111", }, } @@ -196,8 +194,8 @@ func TestEVMTokenDeployments(t *testing.T) { } } - tokenSupportsAdmin := tokenSupportsAdminRole(tc.tokenType) - if tokenSupportsAdmin { + caps := tokenimpl.Capabilities(tc.tokenType) + if caps.SupportsCCIPAdmin { // Verify CCIP Admin was set correctly t.Log(" Verifying CCIP Admin...") onChainCCIPAdmin, err := tokenContract.GetCCIPAdmin(&bind.CallOpts{}) @@ -235,8 +233,16 @@ func TestEVMTokenDeployments(t *testing.T) { func TestTokenSupportsAdminRole(t *testing.T) { t.Parallel() - require.True(t, tokenSupportsAdminRole(burn_mint_erc20.ContractType)) - require.True(t, tokenSupportsAdminRole(burn_mint_erc20_with_drip.ContractType)) - require.True(t, tokenSupportsAdminRole(tip20.ContractType)) - require.False(t, tokenSupportsAdminRole(erc20.ContractType)) + tokenTypes := map[cldf.ContractType]bool{ + burn_mint_erc20_with_drip.ContractType: true, + burn_mint_erc20.ContractType: true, + utils.ERC677TokenHelper: false, + utils.BurnMintToken: false, + tip20.ContractType: true, + erc20.ContractType: false, + } + + for tt, supportsAdmin := range tokenTypes { + require.Equal(t, supportsAdmin, tokenimpl.Capabilities(tt).SupportsAdminRole, "Token type %s admin role support mismatch", tt) + } }