From d53ded5643ea1d79fb5bdbc67550422674198f40 Mon Sep 17 00:00:00 2001 From: Pedro Barbosa Date: Fri, 4 Sep 2026 16:05:36 +0100 Subject: [PATCH 1/2] feat(cct-sdk): Add EVM mint/burn role management ops --- ccip-sdk/src/cct/evm/index.test.ts | 122 ++++++++ ccip-sdk/src/cct/evm/index.ts | 265 ++++++++++++++++++ ccip-sdk/src/cct/evm/token/contracts.ts | 125 ++++++++- .../evm/token/operations/grant-burn-role.ts | 87 ++++++ .../operations/grant-mint-and-burn-roles.ts | 95 +++++++ .../evm/token/operations/grant-mint-role.ts | 87 ++++++ .../evm/token/operations/revoke-burn-role.ts | 87 ++++++ .../evm/token/operations/revoke-mint-role.ts | 87 ++++++ 8 files changed, 951 insertions(+), 4 deletions(-) create mode 100644 ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts create mode 100644 ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.ts create mode 100644 ccip-sdk/src/cct/evm/token/operations/grant-mint-role.ts create mode 100644 ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts create mode 100644 ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts diff --git a/ccip-sdk/src/cct/evm/index.test.ts b/ccip-sdk/src/cct/evm/index.test.ts index ff92a7131..427b48458 100644 --- a/ccip-sdk/src/cct/evm/index.test.ts +++ b/ccip-sdk/src/cct/evm/index.test.ts @@ -722,4 +722,126 @@ describe('EVMTokenManager (cct/evm)', () => { assert.equal(called, false, 'validation fails before TAR discovery') }) }) + + 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', + ) + }) + } + }) }) diff --git a/ccip-sdk/src/cct/evm/index.ts b/ccip-sdk/src/cct/evm/index.ts index 2276cf019..4647d380c 100644 --- a/ccip-sdk/src/cct/evm/index.ts +++ b/ccip-sdk/src/cct/evm/index.ts @@ -84,12 +84,25 @@ import { TransferOwnership, } from './token-pool/operations/transfer-ownership.ts' import { type DeployTokenParams, DeployToken } from './token/operations/deploy-token.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 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 { readonly chain: EVMChain // Token operations readonly #deployToken = new DeployToken() + readonly #grantMintAndBurnRoles = new GrantMintAndBurnRoles() + readonly #grantMintRole = new GrantMintRole() + readonly #grantBurnRole = new GrantBurnRole() + readonly #revokeMintRole = new RevokeMintRole() + readonly #revokeBurnRole = new RevokeBurnRole() // Token admin registry operations readonly #registerAdmin = new RegisterAdmin() @@ -672,6 +685,253 @@ 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. + * @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 grant to an account that already holds the role is + * rejected: the token's role set is an `EnumerableSet`, so on-chain it would mine 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...', + * }) + * ``` + */ + 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. + * @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...', + * }) + * ``` + */ + 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 from an account that does not hold the role is + * rejected — on-chain it would mine as a silent no-op, so the rejection is what tells you the + * address (or the token) was not the one you meant. + * @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...', + * }) + * ``` + */ + 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...', + * }) + * ``` + */ + 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 pool deployment tx (for multisig / offline signing). `type` selects * the pool contract — a `DeployableTokenPoolType` (`BurnMintTokenPool`, `BurnFromMintTokenPool`, @@ -1206,6 +1466,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 * from './token/contracts.ts' export type { DeployTokenPoolParams, diff --git a/ccip-sdk/src/cct/evm/token/contracts.ts b/ccip-sdk/src/cct/evm/token/contracts.ts index cdd3aa180..298ea66a3 100644 --- a/ccip-sdk/src/cct/evm/token/contracts.ts +++ b/ccip-sdk/src/cct/evm/token/contracts.ts @@ -1,20 +1,30 @@ /** * EVM token contract layer for CCT: cached {@link Interface}s per {@link TokenVersion} - * ({@link getTokenInterface}) for read/write (e.g. ownership) ops, and the deployable - * `CrossChainToken` (v2.0.0) artifact ({@link getTokenArtifact}). `2.0.0` is `CrossChainToken`; + * ({@link getTokenInterface}) for read/write (e.g. ownership) ops, the deployable + * `CrossChainToken` (v2.0.0) artifact ({@link getTokenArtifact}), and the narrow reads the + * role-gated writes are built on ({@link readTokenRole}, {@link readTokenOwner}) plus the + * owner-only guard over the latter ({@link assertTokenOwner}). `2.0.0` is `CrossChainToken`; * `1.5.1` / `1.6.2` are `FactoryBurnMintERC20`. Mirrors `token-pool/contracts.ts`. * * @packageDocumentation */ -import { Interface } from 'ethers' +import { Interface, getAddress, isError } from 'ethers' +import type { TypedContract } from 'ethers-abitype' -import { CCTContractVersionUnsupportedError } from '../../errors.ts' +import type { EVMChain } from '../../../evm/index.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' import CROSS_CHAIN_TOKEN_V2_0_0_BYTECODE from '../artifacts/bytecode/V2_0_0/cross-chain-token.ts' import type { DeployArtifact } from '../operation.ts' +import { getTypedContract } from '../query.ts' /** * Known token versions, low to high. `2.0.0` is `CrossChainToken`; `1.5.1` / `1.6.2` @@ -45,6 +55,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 @@ -67,3 +88,99 @@ export function getTokenArtifact(version: TokenVersion): DeployArtifact { if (!artifact) throw new CCTContractVersionUnsupportedError('token', version) return artifact } + +/** + * 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 + * error or rate limit must not be read as "this contract lacks the function". + */ +function isMissingFunction(err: unknown): boolean { + return isError(err, 'CALL_EXCEPTION') || isError(err, 'BAD_DATA') +} + +/** The two role predicates, declared identically by every BurnMintERC677 token. */ +type TokenRoleReader = Pick< + TypedContract, + 'isMinter' | 'isBurner' +> + +/** + * Reads whether `account` holds one of a BurnMintERC677 token's roles, in a single `eth_call`. + * + * Doubles as the family check every role/mint write needs: only the BurnMintERC677 family + * declares these predicates, so a v2.0.0 `CrossChainToken`, a token pool, or an EOA fails here + * before an op can hand back calldata aimed at code that cannot run it. No `version` parameter — + * both predicates are identical at v1.5.1 and v1.6.2 (see {@link getErc20Token}). + * @param chain - Chain to read from. + * @param tokenAddress - Token contract to read from. + * @param read - Which role predicate to call. + * @param account - Address to test. + * @returns Whether `account` currently holds that role. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` does not declare `read` — it is + * not a BurnMintERC677 token + */ +export async function readTokenRole( + chain: EVMChain, + tokenAddress: string, + read: 'isMinter' | 'isBurner', + account: string, +): Promise { + const token: TokenRoleReader = getTypedContract( + chain, + tokenAddress, + FACTORY_BURN_MINT_ERC20_V1_5_1_ABI, + ) + try { + return await token[read](account) + } catch (err) { + if (!isMissingFunction(err)) throw err + throw new CCTContractTypeInvalidError( + tokenAddress, + 'BurnMintERC677 token (FactoryBurnMintERC20 v1.5.1 / v1.6.2)', + // the type is genuinely unknown: the contract answered nothing + 'unknown', + `it does not declare ${read}(address) — a v2.0.0 CrossChainToken gates mint/burn through AccessControl instead, and support for it ships separately`, + { cause: err instanceof Error ? err : undefined }, + ) + } +} + +/** `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.ts b/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts new file mode 100644 index 000000000..9343ed281 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts @@ -0,0 +1,87 @@ +/** + * 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. + * + * @remarks The role read comes first because it is also the family check: only a + * BurnMintERC677 token declares `isBurner`, so a v2.0.0 `CrossChainToken`, a token pool, or an + * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone + * would not catch any of them, since all three declare it. + * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path + * gets them too — `generateUnsignedGrantBurnRole` with an unauthorized `sender` would otherwise hand + * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. + * @remarks Rejecting a redundant grant is deliberately stricter than the chain: the role set is + * an `EnumerableSet`, so granting twice is a silent on-chain no-op, not a revert. Surfacing + * it here stops a multisig from spending a review cycle on a transaction that changes + * nothing. + * @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.ts b/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.ts new file mode 100644 index 000000000..51d89443f --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-mint-and-burn-roles.ts @@ -0,0 +1,95 @@ +/** + * 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. + * + * @remarks Rejected only when `burnAndMinter` already holds **both** roles: holding just one + * still builds, since completing the pair is exactly what this call is for. + * @remarks The role reads come first because they are also the family check: only a + * BurnMintERC677 token declares `isMinter`/`isBurner`, so a v2.0.0 `CrossChainToken` — which + * declares `grantMintAndBurnRoles` too, but gates the roles through AccessControl — a token + * pool, or an EOA fails there rather than producing calldata it cannot honour (see + * {@link readTokenRole}). `owner()` alone would not catch any of them. + * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path + * gets them too — `generateUnsignedGrantMintAndBurnRoles` with an unauthorized `sender` would + * otherwise hand back a fully-formed transaction that reverts `OnlyOwner` after being reviewed + * and signed. + * @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.ts b/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.ts new file mode 100644 index 000000000..b3e3155d3 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/grant-mint-role.ts @@ -0,0 +1,87 @@ +/** + * 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. + * + * @remarks The role read comes first because it is also the family check: only a + * BurnMintERC677 token declares `isMinter`, so a v2.0.0 `CrossChainToken`, a token pool, or an + * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone + * would not catch any of them, since all three declare it. + * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path + * gets them too — `generateUnsignedGrantMintRole` with an unauthorized `sender` would otherwise hand + * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. + * @remarks Rejecting a redundant grant is deliberately stricter than the chain: the role set is + * an `EnumerableSet`, so granting twice is a silent on-chain no-op, not a revert. Surfacing + * it here stops a multisig from spending a review cycle on a transaction that changes + * nothing. + * @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.ts b/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts new file mode 100644 index 000000000..058247c12 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts @@ -0,0 +1,87 @@ +/** + * 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. + * + * @remarks The role read comes first because it is also the family check: only a + * BurnMintERC677 token declares `isBurner`, so a v2.0.0 `CrossChainToken`, a token pool, or an + * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone + * would not catch any of them, since all three declare it. + * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path + * gets them too — `generateUnsignedRevokeBurnRole` with an unauthorized `sender` would otherwise hand + * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. + * @remarks Rejecting a revoke of a role never held is deliberately stricter than the chain: the + * role set is an `EnumerableSet`, so removing an absent member is a silent on-chain no-op, + * not a revert. Surfacing it here is what tells you the address (or the token) was not the + * one you meant, instead of a mined transaction that changed nothing. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `burner` does not currently 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.ts b/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts new file mode 100644 index 000000000..a7992b9d4 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts @@ -0,0 +1,87 @@ +/** + * 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. + * + * @remarks The role read comes first because it is also the family check: only a + * BurnMintERC677 token declares `isMinter`, so a v2.0.0 `CrossChainToken`, a token pool, or an + * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone + * would not catch any of them, since all three declare it. + * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path + * gets them too — `generateUnsignedRevokeMintRole` with an unauthorized `sender` would otherwise hand + * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. + * @remarks Rejecting a revoke of a role never held is deliberately stricter than the chain: the + * role set is an `EnumerableSet`, so removing an absent member is a silent on-chain no-op, + * not a revert. Surfacing it here is what tells you the address (or the token) was not the + * one you meant, instead of a mined transaction that changed nothing. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `minter` does not currently 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 }) + } +} From 7b9e8740b4baf8b2cd5fe14f487986e33414971b Mon Sep 17 00:00:00 2001 From: Pedro Barbosa Date: Thu, 10 Sep 2026 12:16:13 +0100 Subject: [PATCH 2/2] tight tsdoc --- ccip-sdk/src/cct/evm/index.ts | 50 +++++++++---------- .../evm/token/operations/grant-burn-role.ts | 19 +++---- .../operations/grant-mint-and-burn-roles.ts | 17 +++---- .../evm/token/operations/grant-mint-role.ts | 19 +++---- .../evm/token/operations/revoke-burn-role.ts | 19 +++---- .../evm/token/operations/revoke-mint-role.ts | 19 +++---- 6 files changed, 54 insertions(+), 89 deletions(-) diff --git a/ccip-sdk/src/cct/evm/index.ts b/ccip-sdk/src/cct/evm/index.ts index 27f7e4797..d27f0c1e9 100644 --- a/ccip-sdk/src/cct/evm/index.ts +++ b/ccip-sdk/src/cct/evm/index.ts @@ -697,8 +697,8 @@ export class EVMTokenManager extends TokenManager { * @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. - * @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 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 @@ -722,8 +722,8 @@ export class EVMTokenManager extends TokenManager { * 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 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 @@ -750,11 +750,10 @@ export class EVMTokenManager extends TokenManager { * 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 grant to an account that already holds the role is - * rejected: the token's role set is an `EnumerableSet`, so on-chain it would mine 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 + * @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 @@ -775,8 +774,8 @@ export class EVMTokenManager extends TokenManager { * `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 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 @@ -800,8 +799,8 @@ export class EVMTokenManager extends TokenManager { * BurnMintERC677 token's burn role to one account. * @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 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 @@ -822,8 +821,8 @@ export class EVMTokenManager extends TokenManager { * `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 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 @@ -845,11 +844,10 @@ export class EVMTokenManager extends TokenManager { /** * 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 from an account that does not hold the role is - * rejected — on-chain it would mine as a silent no-op, so the rejection is what tells you the - * address (or the token) was not the one you meant. - * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token — - * a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl + * @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 @@ -870,8 +868,8 @@ export class EVMTokenManager extends TokenManager { * `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 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 @@ -895,8 +893,8 @@ export class EVMTokenManager extends TokenManager { * 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 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 @@ -917,8 +915,8 @@ export class EVMTokenManager extends TokenManager { * `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 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 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 index 9343ed281..7517355e1 100644 --- a/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts +++ b/ccip-sdk/src/cct/evm/token/operations/grant-burn-role.ts @@ -39,20 +39,13 @@ export class GrantBurnRole extends EVMOperation { /** * Reads the current role state, then — when `sender` is known — confirms it owns the token. * - * @remarks The role read comes first because it is also the family check: only a - * BurnMintERC677 token declares `isBurner`, so a v2.0.0 `CrossChainToken`, a token pool, or an - * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone - * would not catch any of them, since all three declare it. - * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path - * gets them too — `generateUnsignedGrantBurnRole` with an unauthorized `sender` would otherwise hand - * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. - * @remarks Rejecting a redundant grant is deliberately stricter than the chain: the role set is - * an `EnumerableSet`, so granting twice is a silent on-chain no-op, not a revert. Surfacing - * it here stops a multisig from spending a review cycle on a transaction that changes - * nothing. + * 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 + * @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, 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 index 51d89443f..16865cc69 100644 --- 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 @@ -39,18 +39,13 @@ export class GrantMintAndBurnRoles extends EVMOperation { /** * Reads the current role state, then — when `sender` is known — confirms it owns the token. * - * @remarks The role read comes first because it is also the family check: only a - * BurnMintERC677 token declares `isMinter`, so a v2.0.0 `CrossChainToken`, a token pool, or an - * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone - * would not catch any of them, since all three declare it. - * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path - * gets them too — `generateUnsignedGrantMintRole` with an unauthorized `sender` would otherwise hand - * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. - * @remarks Rejecting a redundant grant is deliberately stricter than the chain: the role set is - * an `EnumerableSet`, so granting twice is a silent on-chain no-op, not a revert. Surfacing - * it here stops a multisig from spending a review cycle on a transaction that changes - * nothing. + * 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 + * @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, 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 index 058247c12..4c981693d 100644 --- a/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-burn-role.ts @@ -39,20 +39,13 @@ export class RevokeBurnRole extends EVMOperation { /** * Reads the current role state, then — when `sender` is known — confirms it owns the token. * - * @remarks The role read comes first because it is also the family check: only a - * BurnMintERC677 token declares `isBurner`, so a v2.0.0 `CrossChainToken`, a token pool, or an - * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone - * would not catch any of them, since all three declare it. - * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path - * gets them too — `generateUnsignedRevokeBurnRole` with an unauthorized `sender` would otherwise hand - * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. - * @remarks Rejecting a revoke of a role never held is deliberately stricter than the chain: the - * role set is an `EnumerableSet`, so removing an absent member is a silent on-chain no-op, - * not a revert. Surfacing it here is what tells you the address (or the token) was not the - * one you meant, instead of a mined transaction that changed nothing. + * 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 currently hold the burn role, or `sender` is given and is - * not the token owner + * @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, 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 index a7992b9d4..152c14398 100644 --- a/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts +++ b/ccip-sdk/src/cct/evm/token/operations/revoke-mint-role.ts @@ -39,20 +39,13 @@ export class RevokeMintRole extends EVMOperation { /** * Reads the current role state, then — when `sender` is known — confirms it owns the token. * - * @remarks The role read comes first because it is also the family check: only a - * BurnMintERC677 token declares `isMinter`, so a v2.0.0 `CrossChainToken`, a token pool, or an - * EOA fails there rather than one step later (see {@link readTokenRole}). `owner()` alone - * would not catch any of them, since all three declare it. - * @remarks Both checks live here, not only in {@link execute}, so the offline / multisig path - * gets them too — `generateUnsignedRevokeMintRole` with an unauthorized `sender` would otherwise hand - * back a fully-formed transaction that reverts `OnlyOwner` after being reviewed and signed. - * @remarks Rejecting a revoke of a role never held is deliberately stricter than the chain: the - * role set is an `EnumerableSet`, so removing an absent member is a silent on-chain no-op, - * not a revert. Surfacing it here is what tells you the address (or the token) was not the - * one you meant, instead of a mined transaction that changed nothing. + * 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 currently hold the mint role, or `sender` is given and is - * not the token owner + * @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,