diff --git a/ccip-sdk/src/cct/evm/index.test.ts b/ccip-sdk/src/cct/evm/index.test.ts index f0ce135e..015467a2 100644 --- a/ccip-sdk/src/cct/evm/index.test.ts +++ b/ccip-sdk/src/cct/evm/index.test.ts @@ -723,6 +723,128 @@ describe('EVMTokenManager (cct/evm)', () => { }) }) + describe('mint/burn role management', () => { + const ROLE_TOKEN_OWNER = '0x' + '99'.repeat(20) + const ROLE_ACCOUNT = '0x' + 'aa'.repeat(20) + /** Fresh Interface — the manager's own cached one must not be what these assertions compare to. */ + const ROLES = new Interface([ + 'function grantMintAndBurnRoles(address burnAndMinter)', + 'function grantMintRole(address minter)', + 'function grantBurnRole(address burner)', + 'function revokeMintRole(address minter)', + 'function revokeBurnRole(address burner)', + 'function owner() view returns (address)', + 'function isMinter(address minter) view returns (bool)', + 'function isBurner(address burner) view returns (bool)', + ]) + + /** + * Chain stub for a v1.6.2 `FactoryBurnMintERC20` owned by `ROLE_TOKEN_OWNER`, on which + * `ROLE_ACCOUNT` holds `roles` — enough for both the owner gate and the role-state pre-flight. + */ + function roleChain(roles: { isMinter?: boolean; isBurner?: boolean } = {}) { + const results: Record = { + owner: [ROLE_TOKEN_OWNER], + isMinter: [roles.isMinter ?? false], + isBurner: [roles.isBurner ?? false], + } + return stubChain({ + provider: { + call: ({ data }: { data: string }) => { + const fn = ROLES.getFunction(data.slice(0, 10))!.name + return Promise.resolve(ROLES.encodeFunctionResult(fn, results[fn])) + }, + } as never, + }) + } + + /** + * One case per wired op: the two manager methods, the account parameter, and the role state + * that makes its call a real change. The methods are named explicitly rather than indexed by + * string, so a renamed or unwired method is a compile error here. + */ + const CASES = [ + { + fn: 'grantMintAndBurnRoles', + param: 'burnAndMinter', + roles: {}, + generate: (cct: EVMTokenManager, o: object) => + cct.generateUnsignedGrantMintAndBurnRoles(o as never), + submit: (cct: EVMTokenManager, o: object) => cct.grantMintAndBurnRoles(o as never), + }, + { + fn: 'grantMintRole', + param: 'minter', + roles: {}, + generate: (cct: EVMTokenManager, o: object) => + cct.generateUnsignedGrantMintRole(o as never), + submit: (cct: EVMTokenManager, o: object) => cct.grantMintRole(o as never), + }, + { + fn: 'grantBurnRole', + param: 'burner', + roles: {}, + generate: (cct: EVMTokenManager, o: object) => + cct.generateUnsignedGrantBurnRole(o as never), + submit: (cct: EVMTokenManager, o: object) => cct.grantBurnRole(o as never), + }, + { + fn: 'revokeMintRole', + param: 'minter', + roles: { isMinter: true }, + generate: (cct: EVMTokenManager, o: object) => + cct.generateUnsignedRevokeMintRole(o as never), + submit: (cct: EVMTokenManager, o: object) => cct.revokeMintRole(o as never), + }, + { + fn: 'revokeBurnRole', + param: 'burner', + roles: { isBurner: true }, + generate: (cct: EVMTokenManager, o: object) => + cct.generateUnsignedRevokeBurnRole(o as never), + submit: (cct: EVMTokenManager, o: object) => cct.revokeBurnRole(o as never), + }, + ] as const + + for (const { fn, param, roles, generate, submit } of CASES) { + const expected = ROLES.encodeFunctionData(fn, [ROLE_ACCOUNT]) + + it(`generateUnsigned* encodes ${fn}(address) to the token`, async () => { + const cct = EVMTokenManager.fromChain(roleChain(roles)) + const unsigned = await generate(cct, { + tokenAddress: TOKEN, + [param]: ROLE_ACCOUNT, + sender: ROLE_TOKEN_OWNER, + }) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal(tx.to, TOKEN) + assert.equal(tx.from, ROLE_TOKEN_OWNER) + assert.equal(tx.data, expected) + }) + + it(`${fn} submits as the token owner`, async () => { + const cct = EVMTokenManager.fromChain(roleChain(roles)) + const { hash } = await submit(cct, { + tokenAddress: TOKEN, + [param]: ROLE_ACCOUNT, + wallet: fakeSigner(ROLE_TOKEN_OWNER), + }) + assert.equal(hash, HASH) + }) + + it(`${fn} rejects a sender that does not own the token`, async () => { + const cct = EVMTokenManager.fromChain(roleChain(roles)) + await assert.rejects( + () => generate(cct, { tokenAddress: TOKEN, [param]: ROLE_ACCOUNT, sender: ADMIN }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + } + }) + describe('mint and role reads', () => { const MINTER = '0x' + '99'.repeat(20) const RECIPIENT = '0x' + 'aa'.repeat(20) diff --git a/ccip-sdk/src/cct/evm/index.ts b/ccip-sdk/src/cct/evm/index.ts index 48b11e18..811e32be 100644 --- a/ccip-sdk/src/cct/evm/index.ts +++ b/ccip-sdk/src/cct/evm/index.ts @@ -98,9 +98,17 @@ import { type GetMintersResult, GetMinters, } from './token/operations/get-minters.ts' +import { type GrantBurnRoleParams, GrantBurnRole } from './token/operations/grant-burn-role.ts' +import { + type GrantMintAndBurnRolesParams, + GrantMintAndBurnRoles, +} from './token/operations/grant-mint-and-burn-roles.ts' +import { type GrantMintRoleParams, GrantMintRole } from './token/operations/grant-mint-role.ts' import { type IsBurnerParams, type IsBurnerResult, IsBurner } from './token/operations/is-burner.ts' import { type IsMinterParams, type IsMinterResult, IsMinter } from './token/operations/is-minter.ts' import { type MintParams, Mint } from './token/operations/mint.ts' +import { type RevokeBurnRoleParams, RevokeBurnRole } from './token/operations/revoke-burn-role.ts' +import { type RevokeMintRoleParams, RevokeMintRole } from './token/operations/revoke-mint-role.ts' /** CCT admin operations for EVM chains, delegating each op to an operation class. */ export class EVMTokenManager extends TokenManager { @@ -108,6 +116,11 @@ export class EVMTokenManager extends TokenManager { // Token operations readonly #deployToken = new DeployToken() readonly #mint = new Mint() + readonly #grantMintAndBurnRoles = new GrantMintAndBurnRoles() + readonly #grantMintRole = new GrantMintRole() + readonly #grantBurnRole = new GrantBurnRole() + readonly #revokeMintRole = new RevokeMintRole() + readonly #revokeBurnRole = new RevokeBurnRole() readonly #getMinters = new GetMinters() readonly #getBurners = new GetBurners() readonly #isMinter = new IsMinter() @@ -695,6 +708,264 @@ export class EVMTokenManager extends TokenManager { return this.#deployToken.execute(this.chain, opts) } + /** + * Builds an unsigned `grantMintAndBurnRoles` tx (for multisig / offline signing): grants a + * BurnMintERC677 token's mint **and** burn roles to one account, in a single transaction. This + * is the call that lets a freshly deployed burn/mint pool bridge the token. + * @remarks v1.5.1 / v1.6.2 tokens only — v2.0.0's `CrossChainToken` gates mint/burn through + * AccessControl, which ships separately. Rejected only when `burnAndMinter` already holds + * *both* roles; holding just one still builds, since this call is what completes the pair. + * @remarks {@link deployToken} deploys v2.0.0, so it is not a source of a token these ops + * accept: a v1.5.1 / v1.6.2 `FactoryBurnMintERC20` comes from the CCIP token factory or your + * own deployment, outside this SDK. + * @see {@link deployTokenPool} — the primary use case is granting these roles to a freshly + * deployed pool + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the token owner, or `burnAndMinter` already holds both roles + * @example + * ```typescript + * // build only — sign later (multisig / offline). `sender` must be the token owner. + * const unsigned = await cct.generateUnsignedGrantMintAndBurnRoles({ + * tokenAddress: '0xToken...', + * burnAndMinter: '0xPool...', // the token's burn/mint pool + * sender: '0xTokenOwner...', + * }) + * ``` + */ + generateUnsignedGrantMintAndBurnRoles(opts: GrantMintAndBurnRolesParams): Promise { + return this.#grantMintAndBurnRoles.generate(this.chain, opts) + } + + /** + * Grants a BurnMintERC677 token's mint and burn roles to one account, signing + submitting with + * `opts.wallet` (the token owner). + * @remarks See {@link generateUnsignedGrantMintAndBurnRoles} for the version and redundancy + * rules. `sender` defaults to the wallet's address, so the owner gate always runs before this + * submits. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, the wallet is not the token owner, or `burnAndMinter` already holds + * both roles + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.grantMintAndBurnRoles({ + * tokenAddress: '0xToken...', + * burnAndMinter: '0xPool...', + * wallet, // must be the token owner + * }) + * ``` + */ + grantMintAndBurnRoles( + opts: EVMExecuteParams, + ): Promise { + return this.#grantMintAndBurnRoles.execute(this.chain, opts) + } + + /** + * Builds an unsigned `grantMintRole` tx (for multisig / offline signing): grants a + * BurnMintERC677 token's mint role to one account. Pair it with + * {@link generateUnsignedGrantBurnRole}, or use + * {@link generateUnsignedGrantMintAndBurnRoles} to grant both in one transaction. + * @remarks v1.5.1 / v1.6.2 tokens only; a redundant grant is rejected, since the chain would + * mine it as a silent no-op rather than revert. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the token owner, or `minter` already holds the mint role + * @example + * ```typescript + * const unsigned = await cct.generateUnsignedGrantMintRole({ + * tokenAddress: '0xToken...', + * minter: '0xMinter...', + * sender: '0xTokenOwner...', + * }) + * ``` + * @see {@link deployTokenPool} — the primary use case is granting this role to a freshly + * deployed pool + */ + generateUnsignedGrantMintRole(opts: GrantMintRoleParams): Promise { + return this.#grantMintRole.generate(this.chain, opts) + } + + /** + * Grants a BurnMintERC677 token's mint role to one account, signing + submitting with + * `opts.wallet` (the token owner). + * @remarks See {@link generateUnsignedGrantMintRole} for the version and redundancy rules. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, the wallet is not the token owner, or `minter` already holds the role + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.grantMintRole({ + * tokenAddress: '0xToken...', + * minter: '0xMinter...', + * wallet, // must be the token owner + * }) + * ``` + */ + grantMintRole(opts: EVMExecuteParams): Promise { + return this.#grantMintRole.execute(this.chain, opts) + } + + /** + * Builds an unsigned `grantBurnRole` tx (for multisig / offline signing): grants a + * BurnMintERC677 token's burn role to one account. Pair it with + * {@link generateUnsignedGrantMintRole}, or use + * {@link generateUnsignedGrantMintAndBurnRoles} to grant both in one transaction. + * @remarks v1.5.1 / v1.6.2 tokens only; a redundant grant is rejected — see + * {@link generateUnsignedGrantMintRole}. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the token owner, or `burner` already holds the burn role + * @example + * ```typescript + * const unsigned = await cct.generateUnsignedGrantBurnRole({ + * tokenAddress: '0xToken...', + * burner: '0xBurner...', + * sender: '0xTokenOwner...', + * }) + * ``` + * @see {@link deployTokenPool} — the primary use case is granting this role to a freshly + * deployed pool + */ + generateUnsignedGrantBurnRole(opts: GrantBurnRoleParams): Promise { + return this.#grantBurnRole.generate(this.chain, opts) + } + + /** + * Grants a BurnMintERC677 token's burn role to one account, signing + submitting with + * `opts.wallet` (the token owner). + * @remarks See {@link generateUnsignedGrantBurnRole} for the version and redundancy rules. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, the wallet is not the token owner, or `burner` already holds the role + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.grantBurnRole({ + * tokenAddress: '0xToken...', + * burner: '0xBurner...', + * wallet, // must be the token owner + * }) + * ``` + */ + grantBurnRole(opts: EVMExecuteParams): Promise { + return this.#grantBurnRole.execute(this.chain, opts) + } + + /** + * Builds an unsigned `revokeMintRole` tx (for multisig / offline signing): removes a + * BurnMintERC677 token's mint role from one account. + * @remarks v1.5.1 / v1.6.2 tokens only; revoking a role the account does not hold is rejected, + * since the chain would mine it as a silent no-op and tell you nothing. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the token owner, or `minter` does not currently hold the mint role + * @example + * ```typescript + * const unsigned = await cct.generateUnsignedRevokeMintRole({ + * tokenAddress: '0xToken...', + * minter: '0xOldPool...', // must currently hold the role + * sender: '0xTokenOwner...', + * }) + * ``` + * @see {@link deployTokenPool} — the mirror of the grant made to a freshly deployed pool + */ + generateUnsignedRevokeMintRole(opts: RevokeMintRoleParams): Promise { + return this.#revokeMintRole.generate(this.chain, opts) + } + + /** + * Removes a BurnMintERC677 token's mint role from one account, signing + submitting with + * `opts.wallet` (the token owner). + * @remarks See {@link generateUnsignedRevokeMintRole} for the version and role-state rules. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, the wallet is not the token owner, or `minter` does not hold the role + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.revokeMintRole({ + * tokenAddress: '0xToken...', + * minter: '0xOldPool...', + * wallet, // must be the token owner + * }) + * ``` + */ + revokeMintRole(opts: EVMExecuteParams): Promise { + return this.#revokeMintRole.execute(this.chain, opts) + } + + /** + * Builds an unsigned `revokeBurnRole` tx (for multisig / offline signing): removes a + * BurnMintERC677 token's burn role from one account. + * @remarks v1.5.1 / v1.6.2 tokens only; revoking a role the account does not hold is rejected — + * see {@link generateUnsignedRevokeMintRole}. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the token owner, or `burner` does not currently hold the burn role + * @example + * ```typescript + * const unsigned = await cct.generateUnsignedRevokeBurnRole({ + * tokenAddress: '0xToken...', + * burner: '0xOldPool...', // must currently hold the role + * sender: '0xTokenOwner...', + * }) + * ``` + * @see {@link deployTokenPool} — the mirror of the grant made to a freshly deployed pool + */ + generateUnsignedRevokeBurnRole(opts: RevokeBurnRoleParams): Promise { + return this.#revokeBurnRole.generate(this.chain, opts) + } + + /** + * Removes a BurnMintERC677 token's burn role from one account, signing + submitting with + * `opts.wallet` (the token owner). + * @remarks See {@link generateUnsignedRevokeBurnRole} for the version and role-state rules. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, the wallet is not the token owner, or `burner` does not hold the role + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.revokeBurnRole({ + * tokenAddress: '0xToken...', + * burner: '0xOldPool...', + * wallet, // must be the token owner + * }) + * ``` + */ + revokeBurnRole(opts: EVMExecuteParams): Promise { + return this.#revokeBurnRole.execute(this.chain, opts) + } + /** * Builds an unsigned `mint` tx (for multisig / offline signing): mints new supply of a * BurnMintERC677 token to `account`. The manual mint — seeding liquidity, topping up test @@ -1434,6 +1705,11 @@ export type { } from './token-admin-registry/operations/get-supported-tokens.ts' export * from './token-admin-registry/contracts.ts' export type { DeployTokenParams } from './token/operations/deploy-token.ts' +export type { GrantMintAndBurnRolesParams } from './token/operations/grant-mint-and-burn-roles.ts' +export type { GrantMintRoleParams } from './token/operations/grant-mint-role.ts' +export type { GrantBurnRoleParams } from './token/operations/grant-burn-role.ts' +export type { RevokeMintRoleParams } from './token/operations/revoke-mint-role.ts' +export type { RevokeBurnRoleParams } from './token/operations/revoke-burn-role.ts' export type { MintParams } from './token/operations/mint.ts' export type { GetMintersParams, GetMintersResult } from './token/operations/get-minters.ts' export type { GetBurnersParams, GetBurnersResult } from './token/operations/get-burners.ts' diff --git a/ccip-sdk/src/cct/evm/token/contracts.ts b/ccip-sdk/src/cct/evm/token/contracts.ts index 81e823b2..3f0b1012 100644 --- a/ccip-sdk/src/cct/evm/token/contracts.ts +++ b/ccip-sdk/src/cct/evm/token/contracts.ts @@ -1,10 +1,11 @@ /** * EVM token contract layer for CCT: cached {@link Interface}s per {@link TokenVersion} * ({@link getTokenInterface}) for read/write (e.g. ownership) ops, the deployable - * `CrossChainToken` (v2.0.0) artifact ({@link getTokenArtifact}), and the token's role reads — - * the narrow predicate a role-gated write pre-flights ({@link readTokenRole}) and the - * informational role-set enumerations ({@link readTokenRoleHolders}). `2.0.0` is - * `CrossChainToken`; `1.5.1` / `1.6.2` are `FactoryBurnMintERC20`. Mirrors + * `CrossChainToken` (v2.0.0) artifact ({@link getTokenArtifact}), the token's role reads — the + * narrow predicate a role-gated write pre-flights ({@link readTokenRole}) and the informational + * role-set enumerations ({@link readTokenRoleHolders}) — and the owner read + * ({@link readTokenOwner}) plus the owner-only guard over it ({@link assertTokenOwner}). `2.0.0` + * is `CrossChainToken`; `1.5.1` / `1.6.2` are `FactoryBurnMintERC20`. Mirrors * `token-pool/contracts.ts`. * * @packageDocumentation @@ -14,7 +15,12 @@ import { Interface, getAddress, isError } from 'ethers' import type { TypedContract } from 'ethers-abitype' import type { EVMChain } from '../../../evm/index.ts' -import { CCTContractTypeInvalidError, CCTContractVersionUnsupportedError } from '../../errors.ts' +import { resultToObject } from '../../../evm/types.ts' +import { + CCTContractTypeInvalidError, + CCTContractVersionUnsupportedError, + CCTParamsInvalidError, +} from '../../errors.ts' import FACTORY_BURN_MINT_ERC20_V1_5_1_ABI from '../artifacts/abi/V1_5_1/factory-burn-mint-erc20.ts' import FACTORY_BURN_MINT_ERC20_V1_6_2_ABI from '../artifacts/abi/V1_6_2/factory-burn-mint-erc20.ts' import CROSS_CHAIN_TOKEN_V2_0_0_ABI from '../artifacts/abi/V2_0_0/cross-chain-token.ts' @@ -51,6 +57,17 @@ export function getTokenInterface(version: TokenVersion): Interface { return TOKEN_INTERFACES[version] } +/** + * The interface every BurnMintERC677 role/mint write encodes through. + * + * Pinned to v1.5.1: the role functions, `mint`, and the role reads are identical at v1.6.2 and + * on `HyperLiquidCompatibleERC20 1.6.2`, so there is nothing to dispatch on. v2.0.0's + * `CrossChainToken` is a different contract, ruled out by {@link readTokenRole}. + */ +export function getErc20Token(): Interface { + return TOKEN_INTERFACES[TokenVersion.V1_5_1] +} + /** * Deploy artifacts ({@link DeployArtifact}: contract name + ctor {@link Interface} + creation * bytecode) keyed by {@link TokenVersion}, built once; read via {@link getTokenArtifact}. Only @@ -74,17 +91,6 @@ export function getTokenArtifact(version: TokenVersion): DeployArtifact { return artifact } -/** - * The interface every BurnMintERC677 role/mint write encodes through. - * - * Pinned to v1.5.1: the role functions, `mint`, and the role reads are identical at v1.6.2 and - * on `HyperLiquidCompatibleERC20 1.6.2`, so there is nothing to dispatch on. v2.0.0's - * `CrossChainToken` is a different contract, ruled out by {@link readTokenRole}. - */ -export function getErc20Token(): Interface { - return TOKEN_INTERFACES[TokenVersion.V1_5_1] -} - /** * True for the two failure shapes a call to a function a contract does not declare produces: * `CALL_EXCEPTION` (revert) and `BAD_DATA` (node answers `0x`). Deliberately narrow — a transport @@ -186,3 +192,43 @@ export async function readTokenRoleHolders( ) } } + +/** `Ownable2Step.owner()`, declared identically by every supported token. */ +type TokenOwnerGetter = Pick, 'owner'> + +/** + * Reads a token's Ownable2Step `owner()` in a single `eth_call`. On the BurnMintERC677 family the + * owner *is* the mint/burn role admin — `grantMintRole` and its siblings are `onlyOwner`. + * @param chain - Chain to read from. + * @param tokenAddress - Token contract to read `owner()` from. + * @returns The current owner, checksummed. + */ +export async function readTokenOwner(chain: EVMChain, tokenAddress: string): Promise { + const token: TokenOwnerGetter = getTypedContract( + chain, + tokenAddress, + FACTORY_BURN_MINT_ERC20_V1_5_1_ABI, + ) + return getAddress(resultToObject(await token.owner())) +} + +/** + * Pre-flights `sender` against the token's on-chain `owner()` for an owner-gated write, so an + * unauthorized caller fails as a {@link CCTParamsInvalidError} here instead of as an opaque + * `OnlyOwner` revert after a multisig has already reviewed and signed. + * @param operation - Operation name, for the error's `operation` field. + * @param chain - Chain to read the owner from. + * @param tokenAddress - Token being written to. + * @param sender - The address the tx will be sent from; compared checksummed. + * @throws {@link CCTParamsInvalidError} if `sender` is not the token owner + */ +export async function assertTokenOwner( + operation: string, + chain: EVMChain, + tokenAddress: string, + sender: string, +): Promise { + const owner = await readTokenOwner(chain, tokenAddress) + if (getAddress(sender) === owner) return + throw new CCTParamsInvalidError(operation, 'sender', `must be the current token owner (${owner})`) +} diff --git a/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.test.ts b/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.test.ts new file mode 100644 index 00000000..0f6169a5 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { type GrantBurnRoleParams, GrantBurnRole } from './grant-burn-role.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const OWNER = '0x' + '22'.repeat(20) +const ACCOUNT = '0x' + '33'.repeat(20) +const NOT_THE_OWNER = '0x' + '44'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) + +/** Calldata built from a fresh Interface — never the SDK's cached one, or this proves nothing. */ +const FRESH = new Interface([ + 'function grantBurnRole(address burner)', + 'function isBurner(address burner) view returns (bool)', + 'function owner() view returns (address)', +]) +const expectedData = (burner = ACCOUNT) => FRESH.encodeFunctionData('grantBurnRole', [burner]) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** + * EVMChain stub for a BurnMintERC677 token owned by `OWNER`. `holdsRole` is the pre-existing + * role state of the account being granted — false is the state that makes this call a real change. + */ +function stubChain({ + holdsRole = false, + owner = OWNER, + callError, + seen = newSeen(), +}: { + holdsRole?: boolean + owner?: string + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + const results: Record = { isBurner: [holdsRole], owner: [owner] } + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + provider: { + call: ({ data }: { data: string }) => { + if (callError) return Promise.reject(callError) + const fn = FRESH.getFunction(data.slice(0, 10))!.name + seen.calls.push(fn) + return Promise.resolve(FRESH.encodeFunctionResult(fn, results[fn])) + }, + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(waitError?: Error, address = OWNER) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +/** A revert with no data, the shape a call to an undeclared function produces. */ +const missingFunction = () => + makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + +const op = new GrantBurnRole() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { tokenAddress: TOKEN, burner: ACCOUNT, sender: OWNER, ...overrides }) +} + +describe('GrantBurnRole (cct/evm)', () => { + describe('generate', () => { + it('encodes grantBurnRole(address) to the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, TOKEN) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, expectedData()) + // the role read comes first: it is also the family check, so it gates the owner read + assert.deepEqual(seen.calls, ['isBurner', 'owner']) + }) + + it('omits from when sender is not supplied, but still probes the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + assert.equal(unsigned.transactions[0]!.data, expectedData()) + // no sender to compare, so the owner read is skipped — the family check is not + assert.deepEqual(seen.calls, ['isBurner']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['tokenAddress', 'not-an-address'], + // a tx to `0x0` hits no code, so it mines as a successful no-op instead of reverting + ['tokenAddress', ZeroAddress], + ['burner', 'not-an-address'], + ['burner', ZeroAddress], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'grantBurnRole' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('family check', () => { + it('rejects a contract that is not a BurnMintERC677 token', async () => { + // a v2.0.0 CrossChainToken, a token pool, and an EOA all fail the isBurner read + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) + + it('rejects an unrelated address even with no sender to check', async () => { + // the case an owner gate alone would miss: a token pool declares owner() too + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() }), { sender: undefined }), + (err: unknown) => err instanceof CCTContractTypeInvalidError, + ) + }) + }) + + describe('no-op guard', () => { + it('rejects a grant when the account already holds the burn role', async () => { + // stricter than the chain: the role set is an EnumerableSet, so this would mine as a + // silent no-op rather than revert + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ holdsRole: true, seen })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'grantBurnRole' && + err.context.param === 'burner' && + /already holds the burn role/.test(String(err.context.reason)), + ) + // rejected on the role read alone, before the owner read + assert.deepEqual(seen.calls, ['isBurner']) + }) + + it('rejects the no-op with no sender supplied too', async () => { + await assert.rejects( + () => generate(stubChain({ holdsRole: true }), { sender: undefined }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'burner', + ) + }) + }) + + describe('owner gate', () => { + it('rejects a sender that does not own the token', async () => { + await assert.rejects( + () => generate(stubChain(), { sender: NOT_THE_OWNER }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'sender' && + /must be the current token owner/.test(String(err.context.reason)), + ) + }) + }) + + describe('execute', () => { + it('submits as the token owner and returns the tx hash', async () => { + const { hash } = await op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + wallet: fakeSigner(), + }) + assert.equal(hash, HASH) + }) + + it('rejects a sender that is not the signing wallet', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + sender: NOT_THE_OWNER, + wallet: fakeSigner(), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that does not own the token', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + wallet: fakeSigner(undefined, NOT_THE_OWNER), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('surfaces an on-chain revert', async () => { + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'sendTransaction', + data: '0x', + reason: 'OnlyOwner', + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + wallet: fakeSigner(revert), + }), + (err: unknown) => err instanceof CCIPExecTxRevertedError, + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { tokenAddress: TOKEN, burner: ACCOUNT, wallet: {} }), + (err: unknown) => err instanceof CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts b/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts new file mode 100644 index 00000000..7517355e --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts @@ -0,0 +1,80 @@ +/** + * grantBurnRole: grants a BurnMintERC677 token's burn role to one account. Owner-gated + * (`onlyOwner`); the owner is the token's mint/burn role admin. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { assertTokenOwner, getErc20Token, readTokenRole } from '../contracts.ts' + +/** Parameters for {@link GrantBurnRole}. */ +export type GrantBurnRoleParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) whose roles are being changed. */ + tokenAddress: string + /** Account receiving the burn role; must not already hold it. */ + burner: string + /** Current token owner (the role admin); sets `tx.from` for offline / multisig signing. */ + sender?: string +} + +/** Grants the burn role on a BurnMintERC677 token via `grantBurnRole`. */ +export class GrantBurnRole extends EVMOperation { + readonly name = 'grantBurnRole' + + /** + * Validates both addresses before any RPC. Neither may be zero: a tx to `0x0` hits no code, and + * granting a role to `0x0` mines as a no-op nobody can use. + */ + protected override validate({ tokenAddress, burner }: GrantBurnRoleParams): void { + validateNonZeroAddress(this.name, 'tokenAddress', tokenAddress) + validateNonZeroAddress(this.name, 'burner', burner) + } + + /** + * Reads the current role state, then — when `sender` is known — confirms it owns the token. + * + * The role read runs first because it is also the family check ({@link readTokenRole}), which + * `owner()` cannot make: a token pool and a v2.0.0 `CrossChainToken` declare `owner()` too. Both + * checks run here rather than in {@link execute}, so the offline / multisig path gets them, and + * a redundant grant is rejected even though the chain would mine it as a silent no-op. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `burner` already holds the burn role, or `sender` + * is given and is not the token owner + */ + protected async buildUnsigned( + chain: EVMChain, + { tokenAddress, burner, sender }: GrantBurnRoleParams, + ): Promise { + if (await readTokenRole(chain, tokenAddress, 'isBurner', burner)) + throw new CCTParamsInvalidError( + this.name, + 'burner', + `already holds the burn role on ${tokenAddress}; granting it again changes nothing`, + ) + if (sender !== undefined) await assertTokenOwner(this.name, chain, tokenAddress, sender) + + return callTx(tokenAddress, getErc20Token().encodeFunctionData('grantBurnRole', [burner])) + } + + /** + * Signs and submits as the token owner, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s owner check for a broadcast tx. See + * {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or if any other param is invalid (see {@link buildUnsigned}) + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.test.ts b/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.test.ts new file mode 100644 index 00000000..e50a745d --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.test.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { + type GrantMintAndBurnRolesParams, + GrantMintAndBurnRoles, +} from './grant-mint-and-burn-roles.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const OWNER = '0x' + '22'.repeat(20) +const POOL = '0x' + '33'.repeat(20) +const NOT_THE_OWNER = '0x' + '44'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) + +/** Calldata built from a fresh Interface — never the SDK's cached one, or this proves nothing. */ +const FRESH = new Interface([ + 'function grantMintAndBurnRoles(address burnAndMinter)', + 'function isMinter(address minter) view returns (bool)', + 'function isBurner(address burner) view returns (bool)', + 'function owner() view returns (address)', +]) +const expectedData = (burnAndMinter = POOL) => + FRESH.encodeFunctionData('grantMintAndBurnRoles', [burnAndMinter]) + +/** The `eth_call`s the op makes, as decoded function names. The two role reads race, so unordered. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** + * EVMChain stub for a BurnMintERC677 token owned by `OWNER`, on which `burnAndMinter` already + * holds `roles`. Defaults to holding neither — the fresh-pool case this op exists for. + */ +function stubChain({ + roles = {}, + owner = OWNER, + callError, + seen = newSeen(), +}: { + roles?: { isMinter?: boolean; isBurner?: boolean } + owner?: string + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + const results: Record = { + isMinter: [roles.isMinter ?? false], + isBurner: [roles.isBurner ?? false], + owner: [owner], + } + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + provider: { + call: ({ data }: { data: string }) => { + if (callError) return Promise.reject(callError) + const fn = FRESH.getFunction(data.slice(0, 10))!.name + seen.calls.push(fn) + return Promise.resolve(FRESH.encodeFunctionResult(fn, results[fn])) + }, + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(waitError?: Error, address = OWNER) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +/** A revert with no data, the shape a call to an undeclared function produces. */ +const missingFunction = () => + makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + +const op = new GrantMintAndBurnRoles() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { + tokenAddress: TOKEN, + burnAndMinter: POOL, + sender: OWNER, + ...overrides, + }) +} + +describe('GrantMintAndBurnRoles (cct/evm)', () => { + describe('generate', () => { + it('encodes grantMintAndBurnRoles(address) to the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + // one native on-chain function, so one tx — not a grantMintRole + grantBurnRole pair + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, TOKEN) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, expectedData()) + assert.deepEqual(seen.calls.slice(0, 2).sort(), ['isBurner', 'isMinter']) + assert.equal(seen.calls[2], 'owner') + }) + + it('omits from when sender is not supplied, but still probes the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + assert.equal(unsigned.transactions[0]!.data, expectedData()) + // no sender to compare, so the owner read is skipped — the family check is not + assert.deepEqual(seen.calls.sort(), ['isBurner', 'isMinter']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['tokenAddress', 'not-an-address'], + // a tx to `0x0` hits no code, so it mines as a successful no-op instead of reverting + ['tokenAddress', ZeroAddress], + ['burnAndMinter', 'not-an-address'], + ['burnAndMinter', ZeroAddress], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'grantMintAndBurnRoles' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('family check', () => { + it('rejects a contract that is not a BurnMintERC677 token', async () => { + // v2.0.0's CrossChainToken declares grantMintAndBurnRoles too, but gates it through + // AccessControl — the isMinter/isBurner reads are what tell the two apart + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) + + it('rejects an unrelated address even with no sender to check', async () => { + // the case an owner gate alone would miss: a token pool declares owner() too + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() }), { sender: undefined }), + (err: unknown) => err instanceof CCTContractTypeInvalidError, + ) + }) + }) + + describe('no-op guard', () => { + it('rejects an account that already holds both roles', async () => { + // stricter than the chain: the role sets are EnumerableSets, so this would mine as a + // silent no-op rather than revert + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ roles: { isMinter: true, isBurner: true }, seen })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'grantMintAndBurnRoles' && + err.context.param === 'burnAndMinter' && + /already holds the mint and burn roles/.test(String(err.context.reason)), + ) + // rejected on the role reads alone, before the owner read + assert.ok(!seen.calls.includes('owner')) + }) + + for (const roles of [{ isMinter: true }, { isBurner: true }] as const) { + const held = 'isMinter' in roles ? 'mint' : 'burn' + it(`builds for an account holding only the ${held} role — completing the pair is the point`, async () => { + const unsigned = await generate(stubChain({ roles })) + assert.equal(unsigned.transactions[0]!.data, expectedData()) + }) + } + }) + + describe('owner gate', () => { + it('rejects a sender that does not own the token', async () => { + await assert.rejects( + () => generate(stubChain(), { sender: NOT_THE_OWNER }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'sender' && + /must be the current token owner/.test(String(err.context.reason)), + ) + }) + }) + + describe('execute', () => { + it('submits as the token owner and returns the tx hash', async () => { + const { hash } = await op.execute(stubChain(), { + tokenAddress: TOKEN, + burnAndMinter: POOL, + wallet: fakeSigner(), + }) + assert.equal(hash, HASH) + }) + + it('rejects a sender that is not the signing wallet', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burnAndMinter: POOL, + sender: NOT_THE_OWNER, + wallet: fakeSigner(), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that does not own the token', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burnAndMinter: POOL, + wallet: fakeSigner(undefined, NOT_THE_OWNER), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('surfaces an on-chain revert', async () => { + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'sendTransaction', + data: '0x', + reason: 'OnlyOwner', + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burnAndMinter: POOL, + wallet: fakeSigner(revert), + }), + (err: unknown) => err instanceof CCIPExecTxRevertedError, + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { tokenAddress: TOKEN, burnAndMinter: POOL, wallet: {} }), + (err: unknown) => err instanceof CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.ts b/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.ts new file mode 100644 index 00000000..16865cc6 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.ts @@ -0,0 +1,90 @@ +/** + * grantMintAndBurnRoles: grants a BurnMintERC677 token's mint *and* burn roles to one account in + * a single transaction — the call a token owner makes for a newly deployed pool, which needs both. + * Owner-gated (`onlyOwner`); the owner is the token's mint/burn role admin. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { assertTokenOwner, getErc20Token, readTokenRole } from '../contracts.ts' + +/** Parameters for {@link GrantMintAndBurnRoles}. */ +export type GrantMintAndBurnRolesParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) whose roles are being changed. */ + tokenAddress: string + /** Account receiving both roles, typically the token's pool; must not already hold both. */ + burnAndMinter: string + /** Current token owner (the role admin); sets `tx.from` for offline / multisig signing. */ + sender?: string +} + +/** Grants both mint and burn roles on a BurnMintERC677 token via `grantMintAndBurnRoles`. */ +export class GrantMintAndBurnRoles extends EVMOperation { + readonly name = 'grantMintAndBurnRoles' + + /** + * Validates both addresses before any RPC. Neither may be zero: a tx to `0x0` hits no code, and + * granting roles to `0x0` mines as a no-op nobody can use. + */ + protected override validate({ tokenAddress, burnAndMinter }: GrantMintAndBurnRolesParams): void { + validateNonZeroAddress(this.name, 'tokenAddress', tokenAddress) + validateNonZeroAddress(this.name, 'burnAndMinter', burnAndMinter) + } + + /** + * Reads both role states, then — when `sender` is known — confirms it owns the token. + * Rejected only when the account holds both roles already: holding one still builds, since + * completing the pair is what this call is for. + * + * The role reads run first because they are also the family check ({@link readTokenRole}), which + * `owner()` cannot make: a token pool and a v2.0.0 `CrossChainToken` declare `owner()` too, and + * v2.0.0 declares `grantMintAndBurnRoles` itself. Both checks run here rather than in + * {@link execute}, so the offline / multisig path gets them. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `burnAndMinter` already holds both roles, or `sender` + * is given and is not the token owner + */ + protected async buildUnsigned( + chain: EVMChain, + { tokenAddress, burnAndMinter, sender }: GrantMintAndBurnRolesParams, + ): Promise { + const [isMinter, isBurner] = await Promise.all([ + readTokenRole(chain, tokenAddress, 'isMinter', burnAndMinter), + readTokenRole(chain, tokenAddress, 'isBurner', burnAndMinter), + ]) + if (isMinter && isBurner) + throw new CCTParamsInvalidError( + this.name, + 'burnAndMinter', + `already holds the mint and burn roles on ${tokenAddress}; granting them again changes nothing`, + ) + if (sender !== undefined) await assertTokenOwner(this.name, chain, tokenAddress, sender) + + return callTx( + tokenAddress, + getErc20Token().encodeFunctionData('grantMintAndBurnRoles', [burnAndMinter]), + ) + } + + /** + * Signs and submits as the token owner, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s owner check for a broadcast tx. See + * {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, or + * if any other param is invalid (see {@link buildUnsigned}) + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.test.ts b/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.test.ts new file mode 100644 index 00000000..8b045fca --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { type GrantMintRoleParams, GrantMintRole } from './grant-mint-role.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const OWNER = '0x' + '22'.repeat(20) +const ACCOUNT = '0x' + '33'.repeat(20) +const NOT_THE_OWNER = '0x' + '44'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) + +/** Calldata built from a fresh Interface — never the SDK's cached one, or this proves nothing. */ +const FRESH = new Interface([ + 'function grantMintRole(address minter)', + 'function isMinter(address minter) view returns (bool)', + 'function owner() view returns (address)', +]) +const expectedData = (minter = ACCOUNT) => FRESH.encodeFunctionData('grantMintRole', [minter]) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** + * EVMChain stub for a BurnMintERC677 token owned by `OWNER`. `holdsRole` is the pre-existing + * role state of the account being granted — false is the state that makes this call a real change. + */ +function stubChain({ + holdsRole = false, + owner = OWNER, + callError, + seen = newSeen(), +}: { + holdsRole?: boolean + owner?: string + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + const results: Record = { isMinter: [holdsRole], owner: [owner] } + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + provider: { + call: ({ data }: { data: string }) => { + if (callError) return Promise.reject(callError) + const fn = FRESH.getFunction(data.slice(0, 10))!.name + seen.calls.push(fn) + return Promise.resolve(FRESH.encodeFunctionResult(fn, results[fn])) + }, + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(waitError?: Error, address = OWNER) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +/** A revert with no data, the shape a call to an undeclared function produces. */ +const missingFunction = () => + makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + +const op = new GrantMintRole() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { tokenAddress: TOKEN, minter: ACCOUNT, sender: OWNER, ...overrides }) +} + +describe('GrantMintRole (cct/evm)', () => { + describe('generate', () => { + it('encodes grantMintRole(address) to the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, TOKEN) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, expectedData()) + // the role read comes first: it is also the family check, so it gates the owner read + assert.deepEqual(seen.calls, ['isMinter', 'owner']) + }) + + it('omits from when sender is not supplied, but still probes the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + assert.equal(unsigned.transactions[0]!.data, expectedData()) + // no sender to compare, so the owner read is skipped — the family check is not + assert.deepEqual(seen.calls, ['isMinter']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['tokenAddress', 'not-an-address'], + // a tx to `0x0` hits no code, so it mines as a successful no-op instead of reverting + ['tokenAddress', ZeroAddress], + ['minter', 'not-an-address'], + ['minter', ZeroAddress], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'grantMintRole' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('family check', () => { + it('rejects a contract that is not a BurnMintERC677 token', async () => { + // a v2.0.0 CrossChainToken, a token pool, and an EOA all fail the isMinter read + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) + + it('rejects an unrelated address even with no sender to check', async () => { + // the case an owner gate alone would miss: a token pool declares owner() too + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() }), { sender: undefined }), + (err: unknown) => err instanceof CCTContractTypeInvalidError, + ) + }) + }) + + describe('no-op guard', () => { + it('rejects a grant when the account already holds the mint role', async () => { + // stricter than the chain: the role set is an EnumerableSet, so this would mine as a + // silent no-op rather than revert + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ holdsRole: true, seen })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'grantMintRole' && + err.context.param === 'minter' && + /already holds the mint role/.test(String(err.context.reason)), + ) + // rejected on the role read alone, before the owner read + assert.deepEqual(seen.calls, ['isMinter']) + }) + + it('rejects the no-op with no sender supplied too', async () => { + await assert.rejects( + () => generate(stubChain({ holdsRole: true }), { sender: undefined }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'minter', + ) + }) + }) + + describe('owner gate', () => { + it('rejects a sender that does not own the token', async () => { + await assert.rejects( + () => generate(stubChain(), { sender: NOT_THE_OWNER }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'sender' && + /must be the current token owner/.test(String(err.context.reason)), + ) + }) + }) + + describe('execute', () => { + it('submits as the token owner and returns the tx hash', async () => { + const { hash } = await op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + wallet: fakeSigner(), + }) + assert.equal(hash, HASH) + }) + + it('rejects a sender that is not the signing wallet', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + sender: NOT_THE_OWNER, + wallet: fakeSigner(), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that does not own the token', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + wallet: fakeSigner(undefined, NOT_THE_OWNER), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('surfaces an on-chain revert', async () => { + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'sendTransaction', + data: '0x', + reason: 'OnlyOwner', + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + wallet: fakeSigner(revert), + }), + (err: unknown) => err instanceof CCIPExecTxRevertedError, + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { tokenAddress: TOKEN, minter: ACCOUNT, wallet: {} }), + (err: unknown) => err instanceof CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.ts b/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.ts new file mode 100644 index 00000000..b31ba7c1 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.ts @@ -0,0 +1,80 @@ +/** + * grantMintRole: grants a BurnMintERC677 token's mint role to one account. Owner-gated + * (`onlyOwner`); the owner is the token's mint/burn role admin. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { assertTokenOwner, getErc20Token, readTokenRole } from '../contracts.ts' + +/** Parameters for {@link GrantMintRole}. */ +export type GrantMintRoleParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) whose roles are being changed. */ + tokenAddress: string + /** Account receiving the mint role; must not already hold it. */ + minter: string + /** Current token owner (the role admin); sets `tx.from` for offline / multisig signing. */ + sender?: string +} + +/** Grants the mint role on a BurnMintERC677 token via `grantMintRole`. */ +export class GrantMintRole extends EVMOperation { + readonly name = 'grantMintRole' + + /** + * Validates both addresses before any RPC. Neither may be zero: a tx to `0x0` hits no code, and + * granting a role to `0x0` mines as a no-op nobody can use. + */ + protected override validate({ tokenAddress, minter }: GrantMintRoleParams): void { + validateNonZeroAddress(this.name, 'tokenAddress', tokenAddress) + validateNonZeroAddress(this.name, 'minter', minter) + } + + /** + * Reads the current role state, then — when `sender` is known — confirms it owns the token. + * + * The role read runs first because it is also the family check ({@link readTokenRole}), which + * `owner()` cannot make: a token pool and a v2.0.0 `CrossChainToken` declare `owner()` too. Both + * checks run here rather than in {@link execute}, so the offline / multisig path gets them, and + * a redundant grant is rejected even though the chain would mine it as a silent no-op. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `minter` already holds the mint role, or `sender` + * is given and is not the token owner + */ + protected async buildUnsigned( + chain: EVMChain, + { tokenAddress, minter, sender }: GrantMintRoleParams, + ): Promise { + if (await readTokenRole(chain, tokenAddress, 'isMinter', minter)) + throw new CCTParamsInvalidError( + this.name, + 'minter', + `already holds the mint role on ${tokenAddress}; granting it again changes nothing`, + ) + if (sender !== undefined) await assertTokenOwner(this.name, chain, tokenAddress, sender) + + return callTx(tokenAddress, getErc20Token().encodeFunctionData('grantMintRole', [minter])) + } + + /** + * Signs and submits as the token owner, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s owner check for a broadcast tx. See + * {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or if any other param is invalid (see {@link buildUnsigned}) + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.test.ts b/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.test.ts new file mode 100644 index 00000000..72f41574 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { type RevokeBurnRoleParams, RevokeBurnRole } from './revoke-burn-role.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const OWNER = '0x' + '22'.repeat(20) +const ACCOUNT = '0x' + '33'.repeat(20) +const NOT_THE_OWNER = '0x' + '44'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) + +/** Calldata built from a fresh Interface — never the SDK's cached one, or this proves nothing. */ +const FRESH = new Interface([ + 'function revokeBurnRole(address burner)', + 'function isBurner(address burner) view returns (bool)', + 'function owner() view returns (address)', +]) +const expectedData = (burner = ACCOUNT) => FRESH.encodeFunctionData('revokeBurnRole', [burner]) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** + * EVMChain stub for a BurnMintERC677 token owned by `OWNER`. `holdsRole` is the pre-existing + * role state of the account being revokeed — true is the state that makes this call a real change. + */ +function stubChain({ + holdsRole = true, + owner = OWNER, + callError, + seen = newSeen(), +}: { + holdsRole?: boolean + owner?: string + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + const results: Record = { isBurner: [holdsRole], owner: [owner] } + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + provider: { + call: ({ data }: { data: string }) => { + if (callError) return Promise.reject(callError) + const fn = FRESH.getFunction(data.slice(0, 10))!.name + seen.calls.push(fn) + return Promise.resolve(FRESH.encodeFunctionResult(fn, results[fn])) + }, + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(waitError?: Error, address = OWNER) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +/** A revert with no data, the shape a call to an undeclared function produces. */ +const missingFunction = () => + makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + +const op = new RevokeBurnRole() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { tokenAddress: TOKEN, burner: ACCOUNT, sender: OWNER, ...overrides }) +} + +describe('RevokeBurnRole (cct/evm)', () => { + describe('generate', () => { + it('encodes revokeBurnRole(address) to the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, TOKEN) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, expectedData()) + // the role read comes first: it is also the family check, so it gates the owner read + assert.deepEqual(seen.calls, ['isBurner', 'owner']) + }) + + it('omits from when sender is not supplied, but still probes the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + assert.equal(unsigned.transactions[0]!.data, expectedData()) + // no sender to compare, so the owner read is skipped — the family check is not + assert.deepEqual(seen.calls, ['isBurner']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['tokenAddress', 'not-an-address'], + // a tx to `0x0` hits no code, so it mines as a successful no-op instead of reverting + ['tokenAddress', ZeroAddress], + ['burner', 'not-an-address'], + ['burner', ZeroAddress], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'revokeBurnRole' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('family check', () => { + it('rejects a contract that is not a BurnMintERC677 token', async () => { + // a v2.0.0 CrossChainToken, a token pool, and an EOA all fail the isBurner read + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) + + it('rejects an unrelated address even with no sender to check', async () => { + // the case an owner gate alone would miss: a token pool declares owner() too + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() }), { sender: undefined }), + (err: unknown) => err instanceof CCTContractTypeInvalidError, + ) + }) + }) + + describe('no-op guard', () => { + it('rejects a revoke when the account does not hold the burn role', async () => { + // stricter than the chain: the role set is an EnumerableSet, so this would mine as a + // silent no-op rather than revert + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ holdsRole: false, seen })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'revokeBurnRole' && + err.context.param === 'burner' && + /does not hold the burn role/.test(String(err.context.reason)), + ) + // rejected on the role read alone, before the owner read + assert.deepEqual(seen.calls, ['isBurner']) + }) + + it('rejects the no-op with no sender supplied too', async () => { + await assert.rejects( + () => generate(stubChain({ holdsRole: false }), { sender: undefined }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'burner', + ) + }) + }) + + describe('owner gate', () => { + it('rejects a sender that does not own the token', async () => { + await assert.rejects( + () => generate(stubChain(), { sender: NOT_THE_OWNER }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'sender' && + /must be the current token owner/.test(String(err.context.reason)), + ) + }) + }) + + describe('execute', () => { + it('submits as the token owner and returns the tx hash', async () => { + const { hash } = await op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + wallet: fakeSigner(), + }) + assert.equal(hash, HASH) + }) + + it('rejects a sender that is not the signing wallet', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + sender: NOT_THE_OWNER, + wallet: fakeSigner(), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that does not own the token', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + wallet: fakeSigner(undefined, NOT_THE_OWNER), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('surfaces an on-chain revert', async () => { + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'sendTransaction', + data: '0x', + reason: 'OnlyOwner', + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + burner: ACCOUNT, + wallet: fakeSigner(revert), + }), + (err: unknown) => err instanceof CCIPExecTxRevertedError, + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { tokenAddress: TOKEN, burner: ACCOUNT, wallet: {} }), + (err: unknown) => err instanceof CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts b/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts new file mode 100644 index 00000000..4c981693 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts @@ -0,0 +1,80 @@ +/** + * revokeBurnRole: removes a BurnMintERC677 token's burn role from one account. Owner-gated + * (`onlyOwner`); the owner is the token's mint/burn role admin. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { assertTokenOwner, getErc20Token, readTokenRole } from '../contracts.ts' + +/** Parameters for {@link RevokeBurnRole}. */ +export type RevokeBurnRoleParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) whose roles are being changed. */ + tokenAddress: string + /** Account losing the burn role; must currently hold it. */ + burner: string + /** Current token owner (the role admin); sets `tx.from` for offline / multisig signing. */ + sender?: string +} + +/** Removes the burn role from an account on a BurnMintERC677 token via `revokeBurnRole`. */ +export class RevokeBurnRole extends EVMOperation { + readonly name = 'revokeBurnRole' + + /** + * Validates both addresses before any RPC. Neither may be zero: a tx to `0x0` hits no code, and + * revoking a role from `0x0` mines as a no-op. + */ + protected override validate({ tokenAddress, burner }: RevokeBurnRoleParams): void { + validateNonZeroAddress(this.name, 'tokenAddress', tokenAddress) + validateNonZeroAddress(this.name, 'burner', burner) + } + + /** + * Reads the current role state, then — when `sender` is known — confirms it owns the token. + * + * The role read runs first because it is also the family check ({@link readTokenRole}), which + * `owner()` cannot make: a token pool and a v2.0.0 `CrossChainToken` declare `owner()` too. Both + * checks run here rather than in {@link execute}, so the offline / multisig path gets them, and + * revoking a role never held is rejected even though the chain would mine it as a silent no-op. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `burner` does not hold the burn role, or `sender` + * is given and is not the token owner + */ + protected async buildUnsigned( + chain: EVMChain, + { tokenAddress, burner, sender }: RevokeBurnRoleParams, + ): Promise { + if (!(await readTokenRole(chain, tokenAddress, 'isBurner', burner))) + throw new CCTParamsInvalidError( + this.name, + 'burner', + `does not hold the burn role on ${tokenAddress}; revoking it changes nothing`, + ) + if (sender !== undefined) await assertTokenOwner(this.name, chain, tokenAddress, sender) + + return callTx(tokenAddress, getErc20Token().encodeFunctionData('revokeBurnRole', [burner])) + } + + /** + * Signs and submits as the token owner, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s owner check for a broadcast tx. See + * {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or if any other param is invalid (see {@link buildUnsigned}) + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.test.ts b/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.test.ts new file mode 100644 index 00000000..80f72e9e --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { type RevokeMintRoleParams, RevokeMintRole } from './revoke-mint-role.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const OWNER = '0x' + '22'.repeat(20) +const ACCOUNT = '0x' + '33'.repeat(20) +const NOT_THE_OWNER = '0x' + '44'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) + +/** Calldata built from a fresh Interface — never the SDK's cached one, or this proves nothing. */ +const FRESH = new Interface([ + 'function revokeMintRole(address minter)', + 'function isMinter(address minter) view returns (bool)', + 'function owner() view returns (address)', +]) +const expectedData = (minter = ACCOUNT) => FRESH.encodeFunctionData('revokeMintRole', [minter]) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** + * EVMChain stub for a BurnMintERC677 token owned by `OWNER`. `holdsRole` is the pre-existing + * role state of the account being revokeed — true is the state that makes this call a real change. + */ +function stubChain({ + holdsRole = true, + owner = OWNER, + callError, + seen = newSeen(), +}: { + holdsRole?: boolean + owner?: string + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + const results: Record = { isMinter: [holdsRole], owner: [owner] } + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + provider: { + call: ({ data }: { data: string }) => { + if (callError) return Promise.reject(callError) + const fn = FRESH.getFunction(data.slice(0, 10))!.name + seen.calls.push(fn) + return Promise.resolve(FRESH.encodeFunctionResult(fn, results[fn])) + }, + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(waitError?: Error, address = OWNER) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +/** A revert with no data, the shape a call to an undeclared function produces. */ +const missingFunction = () => + makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + +const op = new RevokeMintRole() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { tokenAddress: TOKEN, minter: ACCOUNT, sender: OWNER, ...overrides }) +} + +describe('RevokeMintRole (cct/evm)', () => { + describe('generate', () => { + it('encodes revokeMintRole(address) to the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, TOKEN) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, expectedData()) + // the role read comes first: it is also the family check, so it gates the owner read + assert.deepEqual(seen.calls, ['isMinter', 'owner']) + }) + + it('omits from when sender is not supplied, but still probes the token', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + assert.equal(unsigned.transactions[0]!.data, expectedData()) + // no sender to compare, so the owner read is skipped — the family check is not + assert.deepEqual(seen.calls, ['isMinter']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['tokenAddress', 'not-an-address'], + // a tx to `0x0` hits no code, so it mines as a successful no-op instead of reverting + ['tokenAddress', ZeroAddress], + ['minter', 'not-an-address'], + ['minter', ZeroAddress], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'revokeMintRole' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('family check', () => { + it('rejects a contract that is not a BurnMintERC677 token', async () => { + // a v2.0.0 CrossChainToken, a token pool, and an EOA all fail the isMinter read + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) + + it('rejects an unrelated address even with no sender to check', async () => { + // the case an owner gate alone would miss: a token pool declares owner() too + await assert.rejects( + () => generate(stubChain({ callError: missingFunction() }), { sender: undefined }), + (err: unknown) => err instanceof CCTContractTypeInvalidError, + ) + }) + }) + + describe('no-op guard', () => { + it('rejects a revoke when the account does not hold the mint role', async () => { + // stricter than the chain: the role set is an EnumerableSet, so this would mine as a + // silent no-op rather than revert + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ holdsRole: false, seen })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'revokeMintRole' && + err.context.param === 'minter' && + /does not hold the mint role/.test(String(err.context.reason)), + ) + // rejected on the role read alone, before the owner read + assert.deepEqual(seen.calls, ['isMinter']) + }) + + it('rejects the no-op with no sender supplied too', async () => { + await assert.rejects( + () => generate(stubChain({ holdsRole: false }), { sender: undefined }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'minter', + ) + }) + }) + + describe('owner gate', () => { + it('rejects a sender that does not own the token', async () => { + await assert.rejects( + () => generate(stubChain(), { sender: NOT_THE_OWNER }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'sender' && + /must be the current token owner/.test(String(err.context.reason)), + ) + }) + }) + + describe('execute', () => { + it('submits as the token owner and returns the tx hash', async () => { + const { hash } = await op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + wallet: fakeSigner(), + }) + assert.equal(hash, HASH) + }) + + it('rejects a sender that is not the signing wallet', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + sender: NOT_THE_OWNER, + wallet: fakeSigner(), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that does not own the token', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + wallet: fakeSigner(undefined, NOT_THE_OWNER), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('surfaces an on-chain revert', async () => { + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'sendTransaction', + data: '0x', + reason: 'OnlyOwner', + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + minter: ACCOUNT, + wallet: fakeSigner(revert), + }), + (err: unknown) => err instanceof CCIPExecTxRevertedError, + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { tokenAddress: TOKEN, minter: ACCOUNT, wallet: {} }), + (err: unknown) => err instanceof CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts b/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts new file mode 100644 index 00000000..152c1439 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts @@ -0,0 +1,80 @@ +/** + * revokeMintRole: removes a BurnMintERC677 token's mint role from one account. Owner-gated + * (`onlyOwner`); the owner is the token's mint/burn role admin. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { assertTokenOwner, getErc20Token, readTokenRole } from '../contracts.ts' + +/** Parameters for {@link RevokeMintRole}. */ +export type RevokeMintRoleParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) whose roles are being changed. */ + tokenAddress: string + /** Account losing the mint role; must currently hold it. */ + minter: string + /** Current token owner (the role admin); sets `tx.from` for offline / multisig signing. */ + sender?: string +} + +/** Removes the mint role from an account on a BurnMintERC677 token via `revokeMintRole`. */ +export class RevokeMintRole extends EVMOperation { + readonly name = 'revokeMintRole' + + /** + * Validates both addresses before any RPC. Neither may be zero: a tx to `0x0` hits no code, and + * revoking a role from `0x0` mines as a no-op. + */ + protected override validate({ tokenAddress, minter }: RevokeMintRoleParams): void { + validateNonZeroAddress(this.name, 'tokenAddress', tokenAddress) + validateNonZeroAddress(this.name, 'minter', minter) + } + + /** + * Reads the current role state, then — when `sender` is known — confirms it owns the token. + * + * The role read runs first because it is also the family check ({@link readTokenRole}), which + * `owner()` cannot make: a token pool and a v2.0.0 `CrossChainToken` declare `owner()` too. Both + * checks run here rather than in {@link execute}, so the offline / multisig path gets them, and + * revoking a role never held is rejected even though the chain would mine it as a silent no-op. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `minter` does not hold the mint role, or `sender` + * is given and is not the token owner + */ + protected async buildUnsigned( + chain: EVMChain, + { tokenAddress, minter, sender }: RevokeMintRoleParams, + ): Promise { + if (!(await readTokenRole(chain, tokenAddress, 'isMinter', minter))) + throw new CCTParamsInvalidError( + this.name, + 'minter', + `does not hold the mint role on ${tokenAddress}; revoking it changes nothing`, + ) + if (sender !== undefined) await assertTokenOwner(this.name, chain, tokenAddress, sender) + + return callTx(tokenAddress, getErc20Token().encodeFunctionData('revokeMintRole', [minter])) + } + + /** + * Signs and submits as the token owner, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s owner check for a broadcast tx. See + * {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or if any other param is invalid (see {@link buildUnsigned}) + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +}