Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions ccip-sdk/src/cct/evm/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,4 +722,86 @@ 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 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<string, unknown[]> = {
isMinter: [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])
})
})
})
107 changes: 107 additions & 0 deletions ccip-sdk/src/cct/evm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,26 @@ 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 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<typeof ChainFamily.EVM> {
readonly chain: EVMChain
// Token operations
readonly #deployToken = new DeployToken()
readonly #mint = new Mint()
readonly #getMinters = new GetMinters()
readonly #getBurners = new GetBurners()

// Token admin registry operations
readonly #registerAdmin = new RegisterAdmin()
Expand Down Expand Up @@ -677,6 +691,96 @@ export class EVMTokenManager extends TokenManager<typeof ChainFamily.EVM> {
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`.
* @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<UnsignedEVMTx> {
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<MintParams>): Promise<TransactionResult> {
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, the token answers directly
* with `isMinter(address)` — 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<GetMintersResult> {
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 the token's
* `isBurner(address)`.
* @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<GetBurnersResult> {
return this.#getBurners.query(this.chain, opts)
}

/**
* Builds an unsigned pool deployment tx (for multisig / offline signing). `type` selects
* the pool contract — a `DeployableTokenPoolType` (`BurnMintTokenPool`, `BurnFromMintTokenPool`,
Expand Down Expand Up @@ -1287,6 +1391,9 @@ 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 * from './token/contracts.ts'
export type {
DeployTokenPoolParams,
Expand Down
129 changes: 124 additions & 5 deletions ccip-sdk/src/cct/evm/token/contracts.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand Down Expand Up @@ -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<typeof FACTORY_BURN_MINT_ERC20_V1_5_1_ABI>,
'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<boolean> {
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<typeof FACTORY_BURN_MINT_ERC20_V1_5_1_ABI>,
'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<string[]> {
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 },
)
}
}
Loading
Loading