diff --git a/ccip-sdk/src/cct/evm/index.test.ts b/ccip-sdk/src/cct/evm/index.test.ts index ff92a713..f0ce135e 100644 --- a/ccip-sdk/src/cct/evm/index.test.ts +++ b/ccip-sdk/src/cct/evm/index.test.ts @@ -722,4 +722,110 @@ describe('EVMTokenManager (cct/evm)', () => { assert.equal(called, false, 'validation fails before TAR discovery') }) }) + + describe('mint and role reads', () => { + const MINTER = '0x' + '99'.repeat(20) + const RECIPIENT = '0x' + 'aa'.repeat(20) + const AMOUNT = 1_000000000000000000n + /** Fresh Interface — the manager's own cached one must not be what these assertions compare to. */ + const TOKEN_FNS = new Interface([ + 'function mint(address account, uint256 amount)', + 'function isMinter(address minter) view returns (bool)', + 'function isBurner(address burner) view returns (bool)', + 'function getMinters() view returns (address[])', + 'function getBurners() view returns (address[])', + ]) + + /** Chain stub for a BurnMintERC677 token on which `MINTER` holds the mint role. */ + function tokenChain(isMinter = true) { + const results: Record = { + isMinter: [isMinter], + isBurner: [isMinter], + getMinters: [[MINTER]], + getBurners: [[POOL]], + } + return stubChain({ + provider: { + call: ({ data }: { data: string }) => { + const fn = TOKEN_FNS.getFunction(data.slice(0, 10))!.name + return Promise.resolve(TOKEN_FNS.encodeFunctionResult(fn, results[fn])) + }, + } as never, + }) + } + + it('generateUnsignedMint encodes mint(account, amount) to the token', async () => { + const cct = EVMTokenManager.fromChain(tokenChain()) + const unsigned = await cct.generateUnsignedMint({ + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + sender: MINTER, + }) + + 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, MINTER) + assert.equal(tx.data, TOKEN_FNS.encodeFunctionData('mint', [RECIPIENT, AMOUNT])) + }) + + it('mint submits as the minting wallet', async () => { + const cct = EVMTokenManager.fromChain(tokenChain()) + const { hash } = await cct.mint({ + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + wallet: fakeSigner(MINTER), + }) + assert.equal(hash, HASH) + }) + + it('mint rejects a wallet without the mint role', async () => { + const cct = EVMTokenManager.fromChain(tokenChain(false)) + await assert.rejects( + () => + cct.mint({ + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + wallet: fakeSigner(ADMIN), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('getMinters lists the mint-role holders', async () => { + const cct = EVMTokenManager.fromChain(tokenChain()) + assert.deepEqual(await cct.getMinters({ tokenAddress: TOKEN }), [MINTER]) + }) + + it('getBurners lists the burn-role holders', async () => { + const cct = EVMTokenManager.fromChain(tokenChain()) + assert.deepEqual(await cct.getBurners({ tokenAddress: TOKEN }), [POOL]) + }) + + it('isMinter answers the single-address mint-role check', async () => { + assert.equal( + await EVMTokenManager.fromChain(tokenChain()).isMinter({ + tokenAddress: TOKEN, + account: MINTER, + }), + true, + ) + assert.equal( + await EVMTokenManager.fromChain(tokenChain(false)).isMinter({ + tokenAddress: TOKEN, + account: RECIPIENT, + }), + false, + ) + }) + + it('isBurner answers the single-address burn-role check', async () => { + const cct = EVMTokenManager.fromChain(tokenChain()) + assert.equal(await cct.isBurner({ tokenAddress: TOKEN, account: POOL }), true) + }) + }) }) diff --git a/ccip-sdk/src/cct/evm/index.ts b/ccip-sdk/src/cct/evm/index.ts index 78905e5e..48b11e18 100644 --- a/ccip-sdk/src/cct/evm/index.ts +++ b/ccip-sdk/src/cct/evm/index.ts @@ -88,12 +88,30 @@ import { TransferOwnership, } from './token-pool/operations/transfer-ownership.ts' import { type DeployTokenParams, DeployToken } from './token/operations/deploy-token.ts' +import { + type GetBurnersParams, + type GetBurnersResult, + GetBurners, +} from './token/operations/get-burners.ts' +import { + type GetMintersParams, + type GetMintersResult, + GetMinters, +} from './token/operations/get-minters.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' /** 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 #mint = new Mint() + readonly #getMinters = new GetMinters() + readonly #getBurners = new GetBurners() + readonly #isMinter = new IsMinter() + readonly #isBurner = new IsBurner() // Token admin registry operations readonly #registerAdmin = new RegisterAdmin() @@ -677,6 +695,135 @@ export class EVMTokenManager extends TokenManager { return this.#deployToken.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 + * supply — not the bridge path, which mints through the pool. + * @remarks v1.5.1 / v1.6.2 tokens only; v2.0.0's `CrossChainToken` gates minting through + * AccessControl, which ships separately. `sender` is checked against the token's + * `isMinter(address)`, **not** its owner: `mint` is `onlyMinter`, and the owner is the role + * admin, who need not hold the role. Grant it first with `grantMintRole`. The full sequence: + * {@link deployToken} → `grantMintRole` → {@link generateUnsignedMint}, checking the grant + * landed with {@link isMinter} (or {@link getMinters} for the whole set). + * @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, or `sender` is given and does + * not hold the token's mint role + * @example + * ```typescript + * // build only — sign later (multisig / offline). `sender` must hold the mint role. + * const unsigned = await cct.generateUnsignedMint({ + * tokenAddress: '0xToken...', + * account: '0xRecipient...', + * amount: 1_000_000000000000000000n, // 1000 tokens at 18 decimals + * sender: '0xMinter...', + * }) + * ``` + */ + generateUnsignedMint(opts: MintParams): Promise { + return this.#mint.generate(this.chain, opts) + } + + /** + * Mints new supply of a BurnMintERC677 token to `account`, signing + submitting with + * `opts.wallet` (an address holding the token's mint role). + * @remarks See {@link generateUnsignedMint} for the version and role rules. `sender` defaults + * to the wallet's address, so the mint-role check 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, or the wallet does not hold the token's mint role + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain — e.g. the mint would + * exceed the token's `maxSupply`, which is not pre-flighted + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.mint({ + * tokenAddress: '0xToken...', + * account: '0xRecipient...', + * amount: 1_000_000000000000000000n, + * wallet, // must hold the mint role + * }) + * ``` + */ + mint(opts: EVMExecuteParams): Promise { + return this.#mint.execute(this.chain, opts) + } + + /** + * Lists every account holding a BurnMintERC677 token's mint role, via `getMinters()`. + * @remarks Informational, for audit and UX. To check *one* address, use {@link isMinter} — one + * call instead of an unbounded set plus a client-side scan. + * @remarks v1.5.1 / v1.6.2 tokens only: v2.0.0's `CrossChainToken` uses AccessControl, which + * does not enumerate role members, so there is no equivalent read. + * @throws {@link CCTParamsInvalidError} if `tokenAddress` is not a valid, non-zero address + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @example + * ```typescript + * const minters = await cct.getMinters({ tokenAddress: '0xToken...' }) + * console.log(minters) // ['0xPool...', '0xOpsKey...'] + * ``` + */ + getMinters(opts: GetMintersParams): Promise { + return this.#getMinters.query(this.chain, opts) + } + + /** + * Lists every account holding a BurnMintERC677 token's burn role, via `getBurners()`. + * @remarks Same shape and caveats as {@link getMinters}; to check one address, use + * {@link isBurner}. + * @throws {@link CCTParamsInvalidError} if `tokenAddress` is not a valid, non-zero address + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @example + * ```typescript + * const burners = await cct.getBurners({ tokenAddress: '0xToken...' }) + * ``` + */ + getBurners(opts: GetBurnersParams): Promise { + return this.#getBurners.query(this.chain, opts) + } + + /** + * Reads whether `account` holds a BurnMintERC677 token's mint role, via `isMinter(address)`. + * @remarks The pre-flight for a {@link mint}: the token's `mint` is `onlyMinter`, and the owner + * is only the role admin, who need not hold the role. Prefer this over scanning + * {@link getMinters} — one call, and it stays a single call as the role set grows. + * @throws {@link CCTParamsInvalidError} if `tokenAddress` or `account` is not a valid, non-zero + * address + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @example + * ```typescript + * if (await cct.isMinter({ tokenAddress: '0xToken...', account: '0xOpsKey...' })) { + * await cct.mint({ tokenAddress: '0xToken...', account: '0xRecipient...', amount, wallet }) + * } + * ``` + */ + isMinter(opts: IsMinterParams): Promise { + return this.#isMinter.query(this.chain, opts) + } + + /** + * Reads whether `account` holds a BurnMintERC677 token's burn role, via `isBurner(address)`. + * @remarks Same shape and caveats as {@link isMinter}; the burn-role counterpart of the set + * read {@link getBurners}. + * @throws {@link CCTParamsInvalidError} if `tokenAddress` or `account` is not a valid, non-zero + * address + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * (a v2.0.0 `CrossChainToken` included, since it gates mint/burn through AccessControl) + * @example + * ```typescript + * const poolCanBurn = await cct.isBurner({ tokenAddress: '0xToken...', account: '0xPool...' }) + * ``` + */ + isBurner(opts: IsBurnerParams): Promise { + return this.#isBurner.query(this.chain, opts) + } + /** * Builds an unsigned pool deployment tx (for multisig / offline signing). `type` selects * the pool contract — a `DeployableTokenPoolType` (`BurnMintTokenPool`, `BurnFromMintTokenPool`, @@ -1287,6 +1434,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 { 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' +export type { IsMinterParams, IsMinterResult } from './token/operations/is-minter.ts' +export type { IsBurnerParams, IsBurnerResult } from './token/operations/is-burner.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 cdd3aa18..81e823b2 100644 --- a/ccip-sdk/src/cct/evm/token/contracts.ts +++ b/ccip-sdk/src/cct/evm/token/contracts.ts @@ -1,20 +1,26 @@ /** * 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`; - * `1.5.1` / `1.6.2` are `FactoryBurnMintERC20`. Mirrors `token-pool/contracts.ts`. + * ({@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 + * `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 { CCTContractTypeInvalidError, CCTContractVersionUnsupportedError } 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` @@ -67,3 +73,116 @@ export function getTokenArtifact(version: TokenVersion): DeployArtifact { if (!artifact) throw new CCTContractVersionUnsupportedError('token', version) 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 + * 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 }, + ) + } +} + +/** The two role-set getters, declared identically by every BurnMintERC677 token. */ +type TokenRoleHolderReader = Pick< + TypedContract, + 'getMinters' | 'getBurners' +> + +/** + * Reads the full set of accounts holding one of a BurnMintERC677 token's roles, in a single + * `eth_call`. + * + * Informational, for audit and UX; checking one address is {@link readTokenRole}, not this set + * plus a client-side scan. Same family check and version reasoning as that read: only this family + * enumerates its role members, and both getters are identical at v1.5.1 and v1.6.2. + * @param chain - Chain to read from. + * @param tokenAddress - Token contract to read from. + * @param read - Which role set to enumerate. + * @returns The current holders, checksummed, in the order the token returns them. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` does not declare `read` — it is + * not a BurnMintERC677 token + */ +export async function readTokenRoleHolders( + chain: EVMChain, + tokenAddress: string, + read: 'getMinters' | 'getBurners', +): Promise { + const token: TokenRoleHolderReader = getTypedContract( + chain, + tokenAddress, + FACTORY_BURN_MINT_ERC20_V1_5_1_ABI, + ) + try { + // the abitype handle types an `address[]` return as `(string | Addressable)[]` + return (await token[read]()).map((holder) => getAddress(holder as string)) + } 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}() — a v2.0.0 CrossChainToken gates mint/burn through AccessControl, which does not enumerate role members`, + { cause: err instanceof Error ? err : undefined }, + ) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/get-burners.test.ts b/ccip-sdk/src/cct/evm/token/operations/get-burners.test.ts new file mode 100644 index 00000000..26e0d9d0 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/get-burners.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { GetBurners } from './get-burners.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const HOLDER_A = '0x' + '22'.repeat(20) +const HOLDER_B = '0x' + '33'.repeat(20) + +/** Read results from a fresh Interface, never the SDK's cached one. */ +const FRESH = new Interface(['function getBurners() view returns (address[])']) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** EVMChain stub answering `getBurners()` with `holders`. */ +function stubChain({ + holders = [HOLDER_A, HOLDER_B] as string[], + callError, + seen = newSeen(), +}: { + holders?: string[] + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + 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, [holders])) + }, + }, + } as unknown as EVMChain +} + +const op = new GetBurners() + +describe('GetBurners (cct/evm)', () => { + it('lists the holders in one call', async () => { + const seen = newSeen() + const holders = await op.query(stubChain({ seen }), { tokenAddress: TOKEN }) + + assert.deepEqual(holders, [HOLDER_A, HOLDER_B]) + assert.deepEqual(seen.calls, ['getBurners']) + }) + + it('returns an empty list when no account holds the role', async () => { + assert.deepEqual(await op.query(stubChain({ holders: [] }), { tokenAddress: TOKEN }), []) + }) + + it('checksums the addresses the token returns', async () => { + const chain = stubChain({ holders: [HOLDER_A.toLowerCase()] }) + assert.deepEqual(await op.query(chain, { tokenAddress: TOKEN }), [HOLDER_A]) + }) + + for (const value of ['not-an-address', ZeroAddress]) { + it(`rejects tokenAddress = ${value} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => op.query(stubChain({ seen }), { tokenAddress: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'getBurners' && + err.context.param === 'tokenAddress', + ) + assert.deepEqual(seen.calls, []) + }) + } + + it('rejects a contract that does not declare getBurners()', async () => { + // a v2.0.0 CrossChainToken does not enumerate its AccessControl role members + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => op.query(stubChain({ callError: revert }), { tokenAddress: TOKEN }), + (err: unknown) => err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/get-burners.ts b/ccip-sdk/src/cct/evm/token/operations/get-burners.ts new file mode 100644 index 00000000..9fc624de --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/get-burners.ts @@ -0,0 +1,44 @@ +/** + * getBurners — lists every account holding a BurnMintERC677 token's burn role. Informational + * (audit / UX): a *check* of one address is `isBurner`, not a scan of this set. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import { EVMQuery } from '../../query.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { readTokenRoleHolders } from '../contracts.ts' + +/** Parameters for {@link GetBurners}. */ +export type GetBurnersParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) to read. */ + tokenAddress: string +} + +/** Result of {@link GetBurners}: the burn-role holders, checksummed, in the token's own order. */ +export type GetBurnersResult = string[] + +/** Lists the accounts holding a BurnMintERC677 token's burn role, via `getBurners()`. */ +export class GetBurners extends EVMQuery { + readonly name = 'getBurners' + + /** + * Validates the token address; nothing to convert for {@link read}. + * @throws {@link CCTParamsInvalidError} if `tokenAddress` is not a valid, non-zero address + */ + protected prepare(params: GetBurnersParams): GetBurnersParams { + validateNonZeroAddress(this.name, 'tokenAddress', params.tokenAddress) + return params + } + + /** + * Enumerates the role set in a single `eth_call`. + * @remarks No version resolution: `getBurners()` is identical at v1.5.1 and v1.6.2, and a + * contract that does not declare it is reported by {@link readTokenRoleHolders}. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + */ + protected read(chain: EVMChain, { tokenAddress }: GetBurnersParams): Promise { + return readTokenRoleHolders(chain, tokenAddress, 'getBurners') + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/get-minters.test.ts b/ccip-sdk/src/cct/evm/token/operations/get-minters.test.ts new file mode 100644 index 00000000..eb8710d2 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/get-minters.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { GetMinters } from './get-minters.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const HOLDER_A = '0x' + '22'.repeat(20) +const HOLDER_B = '0x' + '33'.repeat(20) + +/** Read results from a fresh Interface, never the SDK's cached one. */ +const FRESH = new Interface(['function getMinters() view returns (address[])']) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** EVMChain stub answering `getMinters()` with `holders`. */ +function stubChain({ + holders = [HOLDER_A, HOLDER_B] as string[], + callError, + seen = newSeen(), +}: { + holders?: string[] + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + 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, [holders])) + }, + }, + } as unknown as EVMChain +} + +const op = new GetMinters() + +describe('GetMinters (cct/evm)', () => { + it('lists the holders in one call', async () => { + const seen = newSeen() + const holders = await op.query(stubChain({ seen }), { tokenAddress: TOKEN }) + + assert.deepEqual(holders, [HOLDER_A, HOLDER_B]) + assert.deepEqual(seen.calls, ['getMinters']) + }) + + it('returns an empty list when no account holds the role', async () => { + assert.deepEqual(await op.query(stubChain({ holders: [] }), { tokenAddress: TOKEN }), []) + }) + + it('checksums the addresses the token returns', async () => { + const chain = stubChain({ holders: [HOLDER_A.toLowerCase()] }) + assert.deepEqual(await op.query(chain, { tokenAddress: TOKEN }), [HOLDER_A]) + }) + + for (const value of ['not-an-address', ZeroAddress]) { + it(`rejects tokenAddress = ${value} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => op.query(stubChain({ seen }), { tokenAddress: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'getMinters' && + err.context.param === 'tokenAddress', + ) + assert.deepEqual(seen.calls, []) + }) + } + + it('rejects a contract that does not declare getMinters()', async () => { + // a v2.0.0 CrossChainToken does not enumerate its AccessControl role members + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => op.query(stubChain({ callError: revert }), { tokenAddress: TOKEN }), + (err: unknown) => err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/get-minters.ts b/ccip-sdk/src/cct/evm/token/operations/get-minters.ts new file mode 100644 index 00000000..f25f3ff2 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/get-minters.ts @@ -0,0 +1,44 @@ +/** + * getMinters — lists every account holding a BurnMintERC677 token's mint role. Informational + * (audit / UX): a *check* of one address is `isMinter`, not a scan of this set. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import { EVMQuery } from '../../query.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { readTokenRoleHolders } from '../contracts.ts' + +/** Parameters for {@link GetMinters}. */ +export type GetMintersParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) to read. */ + tokenAddress: string +} + +/** Result of {@link GetMinters}: the mint-role holders, checksummed, in the token's own order. */ +export type GetMintersResult = string[] + +/** Lists the accounts holding a BurnMintERC677 token's mint role, via `getMinters()`. */ +export class GetMinters extends EVMQuery { + readonly name = 'getMinters' + + /** + * Validates the token address; nothing to convert for {@link read}. + * @throws {@link CCTParamsInvalidError} if `tokenAddress` is not a valid, non-zero address + */ + protected prepare(params: GetMintersParams): GetMintersParams { + validateNonZeroAddress(this.name, 'tokenAddress', params.tokenAddress) + return params + } + + /** + * Enumerates the role set in a single `eth_call`. + * @remarks No version resolution: `getMinters()` is identical at v1.5.1 and v1.6.2, and a + * contract that does not declare it is reported by {@link readTokenRoleHolders}. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + */ + protected read(chain: EVMChain, { tokenAddress }: GetMintersParams): Promise { + return readTokenRoleHolders(chain, tokenAddress, 'getMinters') + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/is-burner.test.ts b/ccip-sdk/src/cct/evm/token/operations/is-burner.test.ts new file mode 100644 index 00000000..569cefc1 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/is-burner.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { IsBurner } from './is-burner.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const ACCOUNT = '0x' + '22'.repeat(20) + +/** Read results from a fresh Interface, never the SDK's cached one. */ +const FRESH = new Interface(['function isBurner(address) view returns (bool)']) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[]; args: string[] } +const newSeen = (): Seen => ({ calls: [], args: [] }) + +/** EVMChain stub answering `isBurner(address)` with `holds`. */ +function stubChain({ + holds = true, + callError, + seen = newSeen(), +}: { + holds?: boolean + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + 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))! + seen.calls.push(fn.name) + seen.args.push(FRESH.decodeFunctionData(fn, data)[0] as string) + return Promise.resolve(FRESH.encodeFunctionResult(fn, [holds])) + }, + }, + } as unknown as EVMChain +} + +const op = new IsBurner() + +describe('IsBurner (cct/evm)', () => { + it('answers true for a role holder in one call', async () => { + const seen = newSeen() + const holds = await op.query(stubChain({ seen }), { tokenAddress: TOKEN, account: ACCOUNT }) + + assert.equal(holds, true) + assert.deepEqual(seen.calls, ['isBurner']) + assert.deepEqual(seen.args, [ACCOUNT]) + }) + + it('answers false for an account without the role', async () => { + const chain = stubChain({ holds: false }) + assert.equal(await op.query(chain, { tokenAddress: TOKEN, account: ACCOUNT }), false) + }) + + for (const param of ['tokenAddress', 'account'] as const) { + for (const value of ['not-an-address', ZeroAddress]) { + it(`rejects ${param} = ${value} before any RPC`, async () => { + const seen = newSeen() + const params = { tokenAddress: TOKEN, account: ACCOUNT, [param]: value } + await assert.rejects( + () => op.query(stubChain({ seen }), params), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'isBurner' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + } + + it('rejects a contract that does not declare isBurner(address)', async () => { + // a v2.0.0 CrossChainToken gates burning through AccessControl instead + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => op.query(stubChain({ callError: revert }), { tokenAddress: TOKEN, account: ACCOUNT }), + (err: unknown) => err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/is-burner.ts b/ccip-sdk/src/cct/evm/token/operations/is-burner.ts new file mode 100644 index 00000000..6dc6029b --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/is-burner.ts @@ -0,0 +1,49 @@ +/** + * isBurner — whether one account holds a BurnMintERC677 token's burn role. The check a caller + * wants before a burn; enumerating the whole set is `getBurners`. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import { EVMQuery } from '../../query.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { readTokenRole } from '../contracts.ts' + +/** Parameters for {@link IsBurner}. */ +export type IsBurnerParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) to read. */ + tokenAddress: string + /** Account to test for the burn role. */ + account: string +} + +/** Result of {@link IsBurner}: whether `account` currently holds the token's burn role. */ +export type IsBurnerResult = boolean + +/** Reads whether an account holds a BurnMintERC677 token's burn role, via `isBurner(address)`. */ +export class IsBurner extends EVMQuery { + readonly name = 'isBurner' + + /** + * Validates both addresses; nothing to convert for {@link read}. + * @remarks `account` is rejected as the zero address for the same reason as in + * {@link IsMinter.prepare}: the call could only ever answer `false`. + * @throws {@link CCTParamsInvalidError} if either address is not a valid, non-zero address + */ + protected prepare(params: IsBurnerParams): IsBurnerParams { + validateNonZeroAddress(this.name, 'tokenAddress', params.tokenAddress) + validateNonZeroAddress(this.name, 'account', params.account) + return params + } + + /** + * Reads the role predicate in a single `eth_call`. + * @remarks No version resolution: `isBurner(address)` is identical at v1.5.1 and v1.6.2, and a + * contract that does not declare it is reported by {@link readTokenRole}. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + */ + protected read(chain: EVMChain, { tokenAddress, account }: IsBurnerParams): Promise { + return readTokenRole(chain, tokenAddress, 'isBurner', account) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/is-minter.test.ts b/ccip-sdk/src/cct/evm/token/operations/is-minter.test.ts new file mode 100644 index 00000000..1172b8f1 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/is-minter.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import { CCTContractTypeInvalidError, CCTParamsInvalidError } from '../../../errors.ts' +import { IsMinter } from './is-minter.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const ACCOUNT = '0x' + '22'.repeat(20) + +/** Read results from a fresh Interface, never the SDK's cached one. */ +const FRESH = new Interface(['function isMinter(address) view returns (bool)']) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[]; args: string[] } +const newSeen = (): Seen => ({ calls: [], args: [] }) + +/** EVMChain stub answering `isMinter(address)` with `holds`. */ +function stubChain({ + holds = true, + callError, + seen = newSeen(), +}: { + holds?: boolean + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + 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))! + seen.calls.push(fn.name) + seen.args.push(FRESH.decodeFunctionData(fn, data)[0] as string) + return Promise.resolve(FRESH.encodeFunctionResult(fn, [holds])) + }, + }, + } as unknown as EVMChain +} + +const op = new IsMinter() + +describe('IsMinter (cct/evm)', () => { + it('answers true for a role holder in one call', async () => { + const seen = newSeen() + const holds = await op.query(stubChain({ seen }), { tokenAddress: TOKEN, account: ACCOUNT }) + + assert.equal(holds, true) + assert.deepEqual(seen.calls, ['isMinter']) + assert.deepEqual(seen.args, [ACCOUNT]) + }) + + it('answers false for an account without the role', async () => { + const chain = stubChain({ holds: false }) + assert.equal(await op.query(chain, { tokenAddress: TOKEN, account: ACCOUNT }), false) + }) + + for (const param of ['tokenAddress', 'account'] as const) { + for (const value of ['not-an-address', ZeroAddress]) { + it(`rejects ${param} = ${value} before any RPC`, async () => { + const seen = newSeen() + const params = { tokenAddress: TOKEN, account: ACCOUNT, [param]: value } + await assert.rejects( + () => op.query(stubChain({ seen }), params), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'isMinter' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + } + + it('rejects a contract that does not declare isMinter(address)', async () => { + // a v2.0.0 CrossChainToken gates minting through AccessControl instead + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => op.query(stubChain({ callError: revert }), { tokenAddress: TOKEN, account: ACCOUNT }), + (err: unknown) => err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/is-minter.ts b/ccip-sdk/src/cct/evm/token/operations/is-minter.ts new file mode 100644 index 00000000..09081ec2 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/is-minter.ts @@ -0,0 +1,50 @@ +/** + * isMinter — whether one account holds a BurnMintERC677 token's mint role. The check a caller + * wants before a `mint`; enumerating the whole set is `getMinters`. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import { EVMQuery } from '../../query.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { readTokenRole } from '../contracts.ts' + +/** Parameters for {@link IsMinter}. */ +export type IsMinterParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) to read. */ + tokenAddress: string + /** Account to test for the mint role. */ + account: string +} + +/** Result of {@link IsMinter}: whether `account` currently holds the token's mint role. */ +export type IsMinterResult = boolean + +/** Reads whether an account holds a BurnMintERC677 token's mint role, via `isMinter(address)`. */ +export class IsMinter extends EVMQuery { + readonly name = 'isMinter' + + /** + * Validates both addresses; nothing to convert for {@link read}. + * @remarks `account` is rejected as the zero address: the token can never grant a role to it, + * so the call could only ever answer `false` — a caller passing it has a bug worth surfacing + * rather than an answer worth an RPC. + * @throws {@link CCTParamsInvalidError} if either address is not a valid, non-zero address + */ + protected prepare(params: IsMinterParams): IsMinterParams { + validateNonZeroAddress(this.name, 'tokenAddress', params.tokenAddress) + validateNonZeroAddress(this.name, 'account', params.account) + return params + } + + /** + * Reads the role predicate in a single `eth_call`. + * @remarks No version resolution: `isMinter(address)` is identical at v1.5.1 and v1.6.2, and a + * contract that does not declare it is reported by {@link readTokenRole}. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + */ + protected read(chain: EVMChain, { tokenAddress, account }: IsMinterParams): Promise { + return readTokenRole(chain, tokenAddress, 'isMinter', account) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/mint.test.ts b/ccip-sdk/src/cct/evm/token/operations/mint.test.ts new file mode 100644 index 00000000..e0ee3dab --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/mint.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 MintParams, Mint } from './mint.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const MINTER = '0x' + '22'.repeat(20) +const RECIPIENT = '0x' + '33'.repeat(20) +const NOT_A_MINTER = '0x' + '44'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) +const AMOUNT = 1_000000000000000000n + +/** Calldata built from a fresh Interface — never the SDK's cached one, or this proves nothing. */ +const FRESH = new Interface([ + 'function mint(address account, uint256 amount)', + 'function isMinter(address minter) view returns (bool)', +]) +const expectedData = (account = RECIPIENT, amount = AMOUNT) => + FRESH.encodeFunctionData('mint', [account, amount]) + +/** The `eth_call`s the op makes, in order, as decoded function names. */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** EVMChain stub answering `isMinter` off a fresh Interface. */ +function stubChain({ + isMinter = true, + callError, + seen = newSeen(), +}: { + isMinter?: boolean + /** Fails every `eth_call`, standing in for a contract that is not a BurnMintERC677 token. */ + callError?: Error + seen?: Seen +} = {}): EVMChain { + 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, [isMinter])) + }, + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(waitError?: Error, address = MINTER) { + 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 })), + }), + } +} + +const op = new Mint() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + sender: MINTER, + ...overrides, + }) +} + +describe('Mint (cct/evm)', () => { + describe('generate', () => { + it('encodes mint(address,uint256) to the token', async () => { + const unsigned = await generate(stubChain()) + 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, MINTER) + assert.equal(tx.data, expectedData()) + }) + + it('encodes the full uint256 range', async () => { + const amount = 2n ** 256n - 1n + const unsigned = await generate(stubChain(), { amount }) + assert.equal(unsigned.transactions[0]!.data, expectedData(RECIPIENT, amount)) + }) + + it('accepts a zero amount, which the token mines as a Transfer of nothing', async () => { + const unsigned = await generate(stubChain(), { amount: 0n }) + assert.equal(unsigned.transactions[0]!.data, expectedData(RECIPIENT, 0n)) + }) + + 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()) + // the read doubles as the family check, so it runs with no sender to compare + assert.deepEqual(seen.calls, ['isMinter']) + }) + + it('pre-flights with exactly one isMinter read', async () => { + const seen = newSeen() + await generate(stubChain({ seen })) + 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], + ['account', 'not-an-address'], + // the token's own _mint reverts on a zero recipient + ['account', ZeroAddress], + ['amount', 1 as never], + ['amount', -1n], + ['amount', 2n ** 256n], + ['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 === 'mint' && + 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 + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => generate(stubChain({ callError: revert })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && err.context.address === TOKEN, + ) + }) + + it('rejects an unrelated address even with no sender to check', async () => { + // the case a role gate alone would miss: a mint tx to codeless address mines as a no-op + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => generate(stubChain({ callError: revert }), { sender: undefined }), + (err: unknown) => err instanceof CCTContractTypeInvalidError, + ) + }) + }) + + describe('role gate', () => { + it('rejects a sender that does not hold the mint role', async () => { + await assert.rejects( + () => generate(stubChain({ isMinter: false }), { sender: NOT_A_MINTER }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'mint' && + err.context.param === 'sender' && + /must hold the mint role/.test(String(err.context.reason)), + ) + }) + + it('does not gate on the owner — a minter that is not the owner still builds', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen })) + assert.equal(unsigned.transactions[0]!.data, expectedData()) + assert.ok(!seen.calls.includes('owner'), 'mint is onlyMinter, not onlyOwner') + }) + }) + + describe('execute', () => { + it('submits as the minting wallet and returns the tx hash', async () => { + const { hash } = await op.execute(stubChain(), { + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + 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, + account: RECIPIENT, + amount: AMOUNT, + sender: NOT_A_MINTER, + wallet: fakeSigner(), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that does not hold the mint role', async () => { + await assert.rejects( + () => + op.execute(stubChain({ isMinter: false }), { + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + wallet: fakeSigner(undefined, NOT_A_MINTER), + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('surfaces an on-chain revert — e.g. a mint past maxSupply, which is not pre-flighted', async () => { + const revert = makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'sendTransaction', + data: '0x', + reason: 'MaxSupplyExceeded', + transaction: { to: TOKEN, data: '0x' }, + invocation: null, + revert: null, + }) + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + wallet: fakeSigner(revert), + }), + (err: unknown) => err instanceof CCIPExecTxRevertedError, + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + tokenAddress: TOKEN, + account: RECIPIENT, + amount: AMOUNT, + wallet: {}, + }), + (err: unknown) => err instanceof CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/mint.ts b/ccip-sdk/src/cct/evm/token/operations/mint.ts new file mode 100644 index 00000000..11c3c637 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/mint.ts @@ -0,0 +1,95 @@ +/** + * mint — mints new supply of a BurnMintERC677 token to an account. A role-gated manual mint, for + * seeding liquidity or topping up test supply; the bridge path mints through the pool instead. + * + * @packageDocumentation + */ + +import { ZeroAddress } from 'ethers' + +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, validateUint256 } from '../../validate.ts' +import { getErc20Token, readTokenRole } from '../contracts.ts' + +/** Parameters for {@link Mint}. */ +export type MintParams = { + /** BurnMintERC677 token (v1.5.1 / v1.6.2) to mint. */ + tokenAddress: string + /** Account credited with the newly minted supply. */ + account: string + /** Amount to mint, in the token's smallest unit (`uint256`). */ + amount: bigint + /** Address holding the token's mint role; sets `tx.from` for offline / multisig signing. */ + sender?: string +} + +/** Mints new supply of a BurnMintERC677 token to an account. Gated on the token's mint role. */ +export class Mint extends EVMOperation { + readonly name = 'mint' + + /** + * Validates the token, recipient and amount before any RPC. + * @remarks `account` is rejected as the zero address, which the token's own `_mint` reverts on + * (`ERC20: mint to the zero address`). A zero `amount` is *not* rejected: it mines successfully + * as a `Transfer` of nothing, and accepting it keeps this op's contract the token's own. + * @throws {@link CCTParamsInvalidError} if any param is invalid + */ + protected override validate({ tokenAddress, account, amount }: MintParams): void { + validateNonZeroAddress(this.name, 'tokenAddress', tokenAddress) + validateNonZeroAddress(this.name, 'account', account) + validateUint256(this.name, 'amount', amount) + } + + /** + * Confirms `sender` holds the token's mint role before encoding. + * + * Gated on `isMinter(sender)`, not `owner()`: `mint` is `onlyMinter`, and the owner is only the + * role admin, who need not hold the role. The read runs even with no `sender` to compare + * (against the zero address, answer discarded) because it is also the family check + * ({@link readTokenRole}) — a `mint` built for an address with no code would otherwise mine + * successfully and mint nothing. It runs here rather than in {@link execute} so the offline / + * multisig path is gated too. A mint past a capped token's `maxSupply` is not pre-flighted. + * @throws {@link CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `sender` is given and does not hold the mint role + */ + protected async buildUnsigned( + chain: EVMChain, + { tokenAddress, account, amount, sender }: MintParams, + ): Promise { + const isMinter = await readTokenRole(chain, tokenAddress, 'isMinter', sender ?? ZeroAddress) + if (sender !== undefined && !isMinter) + throw new CCTParamsInvalidError( + this.name, + 'sender', + `must hold the mint role on ${tokenAddress} — grant it with grantMintRole (or grantMintAndBurnRoles) as the token owner`, + ) + + return callTx(tokenAddress, getErc20Token().encodeFunctionData('mint', [account, amount])) + } + + /** + * Signs and submits as a minter, defaulting `sender` to the signing wallet — the only address + * that can satisfy {@link buildUnsigned}'s role 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 CCTContractTypeInvalidError} if `tokenAddress` is not a BurnMintERC677 token + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, if + * the wallet does not hold the mint role, or if any other param is invalid (see + * {@link buildUnsigned}) + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain — e.g. the mint would + * exceed the token's `maxSupply`, which is not pre-flighted + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +}