diff --git a/ccip-sdk/src/cct/evm/index.ts b/ccip-sdk/src/cct/evm/index.ts index 81458fe2..a49a0820 100644 --- a/ccip-sdk/src/cct/evm/index.ts +++ b/ccip-sdk/src/cct/evm/index.ts @@ -133,7 +133,7 @@ export class EVMTokenManager extends TokenManager { /** * Builds an unsigned `CrossChainToken` (v2.0.0) deployment tx (for multisig / offline * signing). The deployed address is only known once mined, so it is NOT returned here — - * use {@link deployToken} to deploy and receive `{ hash, contractAddress }`. + * use {@link deployToken} to deploy and receive `{ hash, contractAddress, verification }`. * @remarks Same post-deploy roles caveat as {@link deployToken} — the pool needs * `grantMintAndBurnRoles` before it can bridge. * @throws {@link CCTParamsInvalidError} if any param is invalid @@ -155,7 +155,8 @@ export class EVMTokenManager extends TokenManager { /** * Deploys a `CrossChainToken` (v2.0.0), signing + submitting with `opts.wallet`; resolves - * to the tx hash and the newly deployed token address. + * to the tx hash, the newly deployed token address, and a `verification` + * ({@link ExplorerVerificationInput}) for verifying the source on a block explorer. * @remarks Mint/burn are role-gated (`MINTER_ROLE`/`BURNER_ROLE`); the token grants neither * to any pool at deploy. `preMint` mints initial supply to `preMintRecipient`, but before a * pool can bridge, `burnMintRoleAdmin` must `grantMintAndBurnRoles(pool)`. @@ -164,7 +165,7 @@ export class EVMTokenManager extends TokenManager { * @throws {@link CCTTxFailedError} if the tx reverts, fails, or mines without an address * @example * ```typescript - * const { hash, contractAddress } = await cct.deployToken({ + * const { hash, contractAddress, verification } = await cct.deployToken({ * name: 'My Token', * symbol: 'MTK', * decimals: 18, @@ -183,7 +184,7 @@ export class EVMTokenManager extends TokenManager { * the pool contract — a `DeployableTokenPoolType` (`BurnMintTokenPool`, `BurnFromMintTokenPool`, * `BurnWithFromMintTokenPool`, or `LockReleaseTokenPool`; all v2.0.0). The deployed address is * only known once mined, so it is NOT returned here — use {@link deployTokenPool} to receive - * `{ hash, contractAddress }`. + * `{ hash, contractAddress, verification }`. * @remarks Same post-deploy setup caveat as {@link deployTokenPool} — a fresh pool must be * registered, role-granted, and lane-configured before it can bridge. `LockReleaseTokenPool` * additionally requires a pre-deployed `lockbox` ({@link DeployLockReleaseTokenPoolParams}) @@ -208,8 +209,9 @@ export class EVMTokenManager extends TokenManager { } /** - * Deploys a token pool, signing + submitting with `opts.wallet`; resolves to the tx hash - * and the newly deployed pool address. `type` selects the pool contract (a + * Deploys a token pool, signing + submitting with `opts.wallet`; resolves to the tx hash, the + * newly deployed pool address, and a `verification` ({@link ExplorerVerificationInput}) for + * verifying the source on a block explorer. `type` selects the pool contract (a * `DeployableTokenPoolType`, v2.0.0). * @remarks Deploying the pool alone doesn't make it usable: register it with {@link setPool}, * grant it the token's mint/burn roles (`grantMintAndBurnRoles`), and configure its remote @@ -223,7 +225,7 @@ export class EVMTokenManager extends TokenManager { * @throws {@link CCTTxFailedError} if the tx reverts, fails, or mines without an address * @example * ```typescript - * const { hash, contractAddress } = await cct.deployTokenPool({ + * const { hash, contractAddress, verification } = await cct.deployTokenPool({ * type: 'LockReleaseTokenPool', * token: '0xToken...', * localTokenDecimals: 18, @@ -242,7 +244,7 @@ export class EVMTokenManager extends TokenManager { * Builds an unsigned `ERC20LockBox` (v2.0.0) deployment tx (for multisig / offline signing). * A lockbox escrows a single `token` for `LockReleaseTokenPool`s. The deployed address is * only known once mined, so it is NOT returned here — use {@link deployLockbox} to receive - * `{ hash, contractAddress }`. + * `{ hash, contractAddress, verification }`. * @remarks Deploy the lockbox before its pool, then authorize the pool on it with * {@link authorizeLockboxCallers} before the pool can lock/release. * @throws {@link CCTParamsInvalidError} if any param is invalid @@ -260,7 +262,8 @@ export class EVMTokenManager extends TokenManager { /** * Deploys an `ERC20LockBox` (v2.0.0), signing + submitting with `opts.wallet`; resolves to the - * tx hash and the newly deployed lockbox address. + * tx hash, the newly deployed lockbox address, and a `verification` + * ({@link ExplorerVerificationInput}) for verifying the source on a block explorer. * @remarks Step two of the lock/release flow: {@link deployToken} → {@link deployLockbox} → * {@link deployTokenPool} (passing this lockbox) → {@link authorizeLockboxCallers} * (`addedCallers: [pool]`) → {@link setPool} → configure lanes. @@ -269,7 +272,7 @@ export class EVMTokenManager extends TokenManager { * @throws {@link CCTTxFailedError} if the tx reverts, fails, or mines without an address * @example * ```typescript - * const { hash, contractAddress } = await cct.deployLockbox({ + * const { hash, contractAddress, verification } = await cct.deployLockbox({ * token: '0xToken...', * wallet, * }) @@ -302,10 +305,7 @@ export class EVMTokenManager extends TokenManager { /** * Adds/removes authorized callers on an `ERC20LockBox`, signing + submitting with `opts.wallet` - * (the lockbox owner). Authorize the `LockReleaseTokenPool` before it can lock/release — until - * then its lock/release reverts `UnauthorizedCaller(pool)`. - * @remarks Depositing the lockbox's initial liquidity is a manual final step with no SDK op: the - * depositor must itself be an authorized caller and have ERC20-approved the lockbox. + * (the lockbox owner). Authorize the `LockReleaseTokenPool` before it can lock/release. * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer * @throws {@link CCTParamsInvalidError} if any param is invalid, or if no caller is supplied * @throws {@link CCTTxFailedError} if the tx reverts or fails @@ -335,5 +335,10 @@ export type { } from './token-pool/operations/deploy-token-pool.ts' export type { DeployLockboxParams } from './lockbox/operations/deploy-lockbox.ts' export type { AuthorizeLockboxCallersParams } from './lockbox/operations/authorize-callers.ts' -export type { DeployResult, EVMExecuteParams } from './operation.ts' +export type { + DeployArtifact, + DeployResult, + EVMExecuteParams, + ExplorerVerificationInput, +} from './operation.ts' export type { TransactionResult } from '../operation.ts' diff --git a/ccip-sdk/src/cct/evm/lockbox/contracts.ts b/ccip-sdk/src/cct/evm/lockbox/contracts.ts new file mode 100644 index 00000000..afbe7086 --- /dev/null +++ b/ccip-sdk/src/cct/evm/lockbox/contracts.ts @@ -0,0 +1,29 @@ +/** + * EVM lockbox contract layer for CCT: the cached `ERC20LockBox` {@link Interface} + * ({@link LOCKBOX_INTERFACE}) for calldata encoding, and its deploy artifact + * ({@link getLockboxArtifact}). Only one lockbox version is deployable, so there is no version + * framework here. Mirrors `token/contracts.ts`. + * + * @packageDocumentation + */ + +import { Interface } from 'ethers' + +import ERC20_LOCKBOX_V2_0_0_ABI from '../artifacts/abi/V2_0_0/erc20-lockbox.ts' +import ERC20_LOCKBOX_V2_0_0_BYTECODE from '../artifacts/bytecode/V2_0_0/erc20-lockbox.ts' +import type { DeployArtifact } from '../operation.ts' + +/** Shared, cached `ERC20LockBox` interface for constructor and calldata encoding. */ +export const LOCKBOX_INTERFACE = new Interface(ERC20_LOCKBOX_V2_0_0_ABI) + +/** `ERC20LockBox` creation bytecode for `deployLockbox`. */ +export const LOCKBOX_BYTECODE = ERC20_LOCKBOX_V2_0_0_BYTECODE + +/** `ERC20LockBox` deploy artifact: contract name + ctor {@link Interface} + creation bytecode. */ +export function getLockboxArtifact(): DeployArtifact { + return { + contract: 'ERC20LockBox', + iface: LOCKBOX_INTERFACE, + bytecode: LOCKBOX_BYTECODE, + } +} diff --git a/ccip-sdk/src/cct/evm/lockbox/interface.ts b/ccip-sdk/src/cct/evm/lockbox/interface.ts deleted file mode 100644 index 0f0176e3..00000000 --- a/ccip-sdk/src/cct/evm/lockbox/interface.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Deploy artifacts for `ERC20LockBox`: the cached {@link Interface} (constructor + calldata - * encoding) and the creation {@link LOCKBOX_BYTECODE}, built/loaded once from the vendored - * `artifacts/`. Only one lockbox version is deployable, so there is no version framework here — - * ops import these directly. - * - * @packageDocumentation - */ - -import { Interface } from 'ethers' - -import ERC20_LOCKBOX_V2_0_0_ABI from '../artifacts/abi/V2_0_0/erc20-lockbox.ts' -import ERC20_LOCKBOX_V2_0_0_BYTECODE from '../artifacts/bytecode/V2_0_0/erc20-lockbox.ts' - -/** Shared, cached `ERC20LockBox` interface for constructor and calldata encoding. */ -export const LOCKBOX_INTERFACE = new Interface(ERC20_LOCKBOX_V2_0_0_ABI) - -/** `ERC20LockBox` creation bytecode for `deployLockbox`. */ -export const LOCKBOX_BYTECODE = ERC20_LOCKBOX_V2_0_0_BYTECODE diff --git a/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.test.ts b/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.test.ts index 2b3d1734..72b7a585 100644 --- a/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.test.ts +++ b/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { ZeroAddress, makeError } from 'ethers' +import { ZeroAddress, getIcapAddress, makeError } from 'ethers' import { AuthorizeLockboxCallers } from './authorize-callers.ts' import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' @@ -129,6 +129,33 @@ describe('AuthorizeLockboxCallers (cct/evm lockbox operation)', () => { ) }) + it('rejects a zero-address lockbox', async () => { + // a call to 0x0 hits no code, so it would mine as a successful no-op + await assert.rejects( + () => + new AuthorizeLockboxCallers().generate(stubChain(), { + lockbox: ZeroAddress, + addedCallers: [POOL], + }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'authorizeLockboxCallers' && + err.context.param === 'lockbox', + ) + }) + + it('rejects the zero address written in ICAP form', async () => { + // isAddress() accepts ICAP, and this never equals ZeroAddress literally + await assert.rejects( + () => + new AuthorizeLockboxCallers().generate(stubChain(), { + lockbox: getIcapAddress(ZeroAddress), + addedCallers: [POOL], + }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'lockbox', + ) + }) + it('rejects when no callers are supplied', async () => { await assert.rejects( () => new AuthorizeLockboxCallers().generate(stubChain(), { lockbox: LOCKBOX }), diff --git a/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.ts b/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.ts index 7696f5b2..e2650fc6 100644 --- a/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.ts +++ b/ccip-sdk/src/cct/evm/lockbox/operations/authorize-callers.ts @@ -6,24 +6,18 @@ * @packageDocumentation */ -import { ZeroAddress } from 'ethers' - import type { EVMChain } from '../../../../evm/index.ts' import type { UnsignedEVMTx } from '../../../../evm/types.ts' -import { ChainFamily } from '../../../../networks.ts' import { CCTParamsInvalidError } from '../../../errors.ts' -import { EVMOperation } from '../../operation.ts' -import { validateAddress } from '../../validate.ts' -import { LOCKBOX_INTERFACE } from '../interface.ts' +import { EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { LOCKBOX_INTERFACE } from '../contracts.ts' /** * Parameters for {@link AuthorizeLockboxCallers}. At least one caller across both arrays is required. * @remarks `AuthorizedCallers._applyAuthorizedCallerUpdates` applies `removedCallers` first, so an * address in both arrays ends up authorized. The list is a set: re-adding an existing caller is a * no-op (though `AuthorizedCallerAdded` still fires), and removing an absent one emits nothing. - * - * Two validations here are SDK-side strictness, not contract behaviour: on-chain only *adds* revert - * `ZeroAddressNotAllowed`, and an update with both arrays empty is a successful owner-only no-op. */ export interface AuthorizeLockboxCallersParams { /** Address of the `ERC20LockBox` to update. */ @@ -46,7 +40,7 @@ export class AuthorizeLockboxCallers extends EVMOperation { - validateAddress(this.name, `${field}[${i}]`, c) - if (c === ZeroAddress) { - throw new CCTParamsInvalidError(this.name, `${field}[${i}]`, 'must not be the zero address') - } - } + const validateCaller = (field: string, c: string, i: number): void => + validateNonZeroAddress(this.name, `${field}[${i}]`, c) addedCallers.forEach((c, i) => validateCaller('addedCallers', c, i)) removedCallers.forEach((c, i) => validateCaller('removedCallers', c, i)) } @@ -72,6 +62,6 @@ export class AuthorizeLockboxCallers extends EVMOperation { token: TOKEN, wallet: fakeSigner({ contractAddress: DEPLOYED }), }) - assert.deepEqual(result, { hash: HASH, contractAddress: DEPLOYED }) + assert.deepEqual(result, { + hash: HASH, + contractAddress: DEPLOYED, + verification: { contract: 'ERC20LockBox', encodedConstructorArgs: '0x' + W_TOKEN }, + }) }) it('throws CCTTxFailedError when the receipt carries no contract address', async () => { diff --git a/ccip-sdk/src/cct/evm/lockbox/operations/deploy-lockbox.ts b/ccip-sdk/src/cct/evm/lockbox/operations/deploy-lockbox.ts index cbfff189..70309fe4 100644 --- a/ccip-sdk/src/cct/evm/lockbox/operations/deploy-lockbox.ts +++ b/ccip-sdk/src/cct/evm/lockbox/operations/deploy-lockbox.ts @@ -7,20 +7,11 @@ * @packageDocumentation */ -import { ZeroAddress } from 'ethers' +import type { Interface } from 'ethers' -import type { EVMChain } from '../../../../evm/index.ts' -import type { UnsignedEVMTx } from '../../../../evm/types.ts' -import { CCTParamsInvalidError, CCTTxFailedError } from '../../../errors.ts' -import { - type DeployResult, - type EVMExecuteParams, - EVMOperation, - deploymentTx, -} from '../../operation.ts' -import { submit } from '../../submit.ts' -import { validateAddress } from '../../validate.ts' -import { LOCKBOX_BYTECODE, LOCKBOX_INTERFACE } from '../interface.ts' +import { type DeployArtifact, EVMDeployOperation } from '../../operation.ts' +import { validateNonZeroAddress } from '../../validate.ts' +import { getLockboxArtifact } from '../contracts.ts' /** Parameters for {@link DeployLockbox} — deploys `ERC20LockBox` (v2.0.0). */ export interface DeployLockboxParams { @@ -30,41 +21,22 @@ export interface DeployLockboxParams { sender?: string } -/** Deploys an `ERC20LockBox`; `execute` resolves to `{ hash, contractAddress }`. */ -export class DeployLockbox extends EVMOperation { +/** Deploys an `ERC20LockBox`; `execute` resolves to `{ hash, contractAddress, verification }`. */ +export class DeployLockbox extends EVMDeployOperation { readonly name = 'deployLockbox' /** Validates the constructor params before building init-code. */ protected validate(params: DeployLockboxParams): void { - validateAddress(this.name, 'token', params.token) - if (params.token === ZeroAddress) - throw new CCTParamsInvalidError(this.name, 'token', 'must not be the zero address') + validateNonZeroAddress(this.name, 'token', params.token) } - /** Builds a deployment tx (no `to`): creation bytecode + ABI-encoded constructor args. */ - protected buildUnsigned(_chain: EVMChain, params: DeployLockboxParams): UnsignedEVMTx { - return deploymentTx(LOCKBOX_BYTECODE, LOCKBOX_INTERFACE.encodeDeploy([params.token])) + /** Deploy artifact for `ERC20LockBox` (v2.0.0). */ + protected artifact(): DeployArtifact { + return getLockboxArtifact() } - /** - * {@link generate}, then sign and submit; resolves to the tx hash and the newly deployed - * lockbox address (read from the mined receipt). - * @throws {@link CCTTxFailedError} if the tx mined without producing a contract address - */ - override async execute( - chain: EVMChain, - params: EVMExecuteParams, - ): Promise { - const { response, receipt } = await submit( - chain, - params.wallet, - await this.generate(chain, params), - this.name, - ) - if (!receipt.contractAddress) - throw new CCTTxFailedError(this.name, 'deployment produced no contract address', { - context: { txHash: response.hash }, - }) - return { hash: response.hash, contractAddress: receipt.contractAddress } + /** ABI-encodes the `ERC20LockBox` (v2.0.0) constructor args. */ + protected encode(iface: Interface, p: DeployLockboxParams): string { + return iface.encodeDeploy([p.token]) } } diff --git a/ccip-sdk/src/cct/evm/operation.ts b/ccip-sdk/src/cct/evm/operation.ts index d0f29432..4dc0b157 100644 --- a/ccip-sdk/src/cct/evm/operation.ts +++ b/ccip-sdk/src/cct/evm/operation.ts @@ -1,38 +1,86 @@ /** * EVM {@link Operation} lifecycle: validate → encode → submit. * Concrete ops implement {@link EVMOperation.buildUnsigned}; the base wires - * {@link generate} and {@link execute}. Ops needing more than a tx hash (e.g. a - * deployment's address) override {@link execute}, reusing {@link submit}. + * {@link generate} and {@link execute}. Deployment ops instead extend + * {@link EVMDeployOperation}, supplying a {@link DeployArtifact} and constructor-arg + * encoding while inheriting a deploy-aware {@link execute} that also returns the + * deployed address, reusing {@link submit}. * * @packageDocumentation */ +import type { Interface } from 'ethers' + import type { EVMChain } from '../../evm/index.ts' import type { UnsignedEVMTx } from '../../evm/types.ts' import { ChainFamily } from '../../networks.ts' +import { CCTTxFailedError } from '../errors.ts' import { type ExecuteParams, type TransactionResult, Operation } from '../operation.ts' import { submit } from './submit.ts' import { validateAddress } from './validate.ts' /** Assembles a contract-deployment tx (no `to`): creation bytecode + ABI-encoded ctor args. */ -export function deploymentTx(bytecode: `0x${string}`, ctorArgs: string): UnsignedEVMTx { +export function deployTx(bytecode: `0x${string}`, ctorArgs: string): UnsignedEVMTx { return { family: ChainFamily.EVM, transactions: [{ data: bytecode + ctorArgs.slice(2) }] } } +/** Assembles an unsigned call to an existing contract: `to` + ABI-encoded calldata. */ +export function callTx(to: string, data: string): UnsignedEVMTx { + return { family: ChainFamily.EVM, transactions: [{ to, data }] } +} + +/** + * The deploy-side inputs a block explorer needs to verify a contract's source: its name and + * ABI-encoded constructor args, captured while deploying with no extra RPC. + * @remarks A constructor-args companion, *not* proof of verification — nothing here is read back + * from the chain or submitted anywhere. A full submission also needs the source/compiler side + * (standard-json input plus the matching solc version and settings), which this SDK does not + * vendor; those ship in the `@chainlink/contracts-ccip` package. + * + * Only available from `execute`, which deploys and so learns the address. The + * `generateUnsigned*` builders return the unsigned tx alone. + * @example Verifying on Etherscan, whose "Constructor Arguments" field wants the args bare: + * ```typescript + * const { contractAddress, verification } = await cct.deployTokenPool({ ...params, wallet }) + * console.log(verification.contract) // 'LockReleaseTokenPool' + * console.log(verification.encodedConstructorArgs.slice(2)) // drop the `0x` + * ``` + */ +export interface ExplorerVerificationInput { + /** Contract name as compiled, e.g. `BurnMintTokenPool`; unqualified, matching the artifact. */ + contract: string + /** 0x-prefixed ABI-encoded constructor args, or just `0x` when the constructor takes none. */ + encodedConstructorArgs: string +} + +/** + * A contract deploy artifact: the contract name (for verification), the cached constructor + * {@link Interface}, and the creation bytecode. Field is `iface` (not `interface`, a reserved word). + */ +export interface DeployArtifact { + contract: string + iface: Interface + bytecode: `0x${string}` +} + /** EVM {@link ExecuteParams} — EVM ops need nothing beyond the signing `wallet`. */ export type EVMExecuteParams

= ExecuteParams

/** * Result of a successful EVM deployment write: the tx hash plus the deployed - * contract address (token, pool, etc.). No block-explorer verification handle - * yet; it's recoverable from the init-code, so adding one later is non-breaking. + * contract address (token, pool, etc.). Also carries the + * {@link ExplorerVerificationInput} needed to verify the contract's source on a + * block explorer — additive, so readers of `{ hash, contractAddress }` are unaffected. */ -export type DeployResult = TransactionResult & { contractAddress: string } +export type DeployResult = TransactionResult & { + contractAddress: string + verification: ExplorerVerificationInput +} /** * EVM CCT write base. Subclasses supply {@link validate} and {@link buildUnsigned}; * {@link execute} signs and submits, returning the confirmed tx hash. Ops that - * resolve to more (e.g. a deployed address) override {@link execute}. + * resolve to more (e.g. a deployed address) extend {@link EVMDeployOperation}. */ export abstract class EVMOperation

extends Operation< EVMChain, @@ -66,3 +114,47 @@ export abstract class EVMOperation

extends Operat return { hash: response.hash } } } + +/** + * EVM contract-deployment base. Subclasses supply {@link validate}, {@link artifact} (name + + * ctor {@link Interface} + creation bytecode), and {@link encode}; the base wires + * {@link buildUnsigned} (init-code = bytecode + encoded ctor args) and {@link execute} (submit, + * then read the deployed address and pair it with an {@link ExplorerVerificationInput}). + */ +export abstract class EVMDeployOperation

extends EVMOperation

{ + /** Contract name, ctor {@link Interface}, and creation bytecode for this deployment. */ + protected abstract artifact(params: P): DeployArtifact + + /** ABI-encodes the constructor args (0x-prefixed) for this deployment. */ + protected abstract encode(iface: Interface, params: P): string + + /** Builds a deployment tx (no `to`): creation bytecode + ABI-encoded constructor args. */ + protected buildUnsigned(_chain: EVMChain, params: P): UnsignedEVMTx { + const a = this.artifact(params) + return deployTx(a.bytecode, this.encode(a.iface, params)) + } + + /** + * {@link generate}, then sign and submit; resolves to the tx hash and the newly deployed + * contract address (read from the mined receipt), plus the + * {@link ExplorerVerificationInput} for verifying its source on a block explorer. + * @throws {@link CCTTxFailedError} if the tx mined without producing a contract address + */ + override async execute(chain: EVMChain, params: EVMExecuteParams

): Promise { + const unsigned = await this.generate(chain, params) + const { contract, iface } = this.artifact(params) + // Same value `buildUnsigned` appended to the bytecode. Taken from `encode` rather than + // sliced back out of the init-code, so it stays correct regardless of the tx layout. + const encodedConstructorArgs = this.encode(iface, params) + const { response, receipt } = await submit(chain, params.wallet, unsigned, this.name) + if (!receipt.contractAddress) + throw new CCTTxFailedError(this.name, 'deployment produced no contract address', { + context: { txHash: response.hash }, + }) + return { + hash: response.hash, + contractAddress: receipt.contractAddress, + verification: { contract, encodedConstructorArgs }, + } + } +} diff --git a/ccip-sdk/src/cct/evm/submit.ts b/ccip-sdk/src/cct/evm/submit.ts index 7e0c8add..45c5dc41 100644 --- a/ccip-sdk/src/cct/evm/submit.ts +++ b/ccip-sdk/src/cct/evm/submit.ts @@ -48,10 +48,13 @@ export async function submit( const sender = await wallet.getAddress() chain.logger.debug(`${operation}: submitting...`) + const [first] = unsigned.transactions + if (!first) throw new CCTTxFailedError(operation, 'no transaction to submit') + let response: TransactionResponse let nonceConsumed = false try { - let tx: TransactionRequest = { ...unsigned.transactions[0]! } + let tx: TransactionRequest = { ...first } tx.from = undefined // drop any builder-set sender before populate, else ethers throws on a from/signer mismatch if (tx.nonce == null) { tx.nonce = await chain.nextNonce(sender) diff --git a/ccip-sdk/src/cct/evm/token-admin-registry/operations/set-pool.ts b/ccip-sdk/src/cct/evm/token-admin-registry/operations/set-pool.ts index a31f5736..ed48c3ab 100644 --- a/ccip-sdk/src/cct/evm/token-admin-registry/operations/set-pool.ts +++ b/ccip-sdk/src/cct/evm/token-admin-registry/operations/set-pool.ts @@ -8,8 +8,7 @@ import { interfaces } from '../../../../evm/const.ts' import type { EVMChain } from '../../../../evm/index.ts' import type { UnsignedEVMTx } from '../../../../evm/types.ts' -import { ChainFamily } from '../../../../networks.ts' -import { EVMOperation } from '../../operation.ts' +import { EVMOperation, callTx } from '../../operation.ts' import { validateAddress } from '../../validate.ts' /** Parameters for `setPool`. Zero `poolAddress` delists the token. */ @@ -45,6 +44,6 @@ export class SetPool extends EVMOperation { p.tokenAddress, p.poolAddress, ]) - return { family: ChainFamily.EVM, transactions: [{ to, data }] } + return callTx(to, data) } } diff --git a/ccip-sdk/src/cct/evm/token-pool/version.test.ts b/ccip-sdk/src/cct/evm/token-pool/contracts.test.ts similarity index 99% rename from ccip-sdk/src/cct/evm/token-pool/version.test.ts rename to ccip-sdk/src/cct/evm/token-pool/contracts.test.ts index 48ad8c85..01108172 100644 --- a/ccip-sdk/src/cct/evm/token-pool/version.test.ts +++ b/ccip-sdk/src/cct/evm/token-pool/contracts.test.ts @@ -14,7 +14,7 @@ import { isTokenPoolVersion, parseTokenPoolVersion, resolveEncoder, -} from './version.ts' +} from './contracts.ts' import { CCTContractTypeInvalidError, CCTContractVersionUnsupportedError, diff --git a/ccip-sdk/src/cct/evm/token-pool/version.ts b/ccip-sdk/src/cct/evm/token-pool/contracts.ts similarity index 75% rename from ccip-sdk/src/cct/evm/token-pool/version.ts rename to ccip-sdk/src/cct/evm/token-pool/contracts.ts index 9ac18255..511c59bb 100644 --- a/ccip-sdk/src/cct/evm/token-pool/version.ts +++ b/ccip-sdk/src/cct/evm/token-pool/contracts.ts @@ -1,7 +1,8 @@ /** - * EVM token-pool version axis for CCT: resolve an on-chain pool's type + version - * ({@link resolveTokenPool}), select its cached ABI ({@link getTokenPoolInterface}), and - * floor-match version-keyed encoders ({@link resolveEncoder}). + * EVM token-pool contract layer for CCT: cached {@link Interface}s + on-chain type/version + * resolution ({@link resolveTokenPool}, {@link getTokenPoolInterface}, floor-matched via + * {@link resolveEncoder}) for read/write ops, plus the deployable pools' creation artifacts + * ({@link getTokenPoolArtifact}). Mirrors `token/contracts.ts`. * * @packageDocumentation */ @@ -22,6 +23,11 @@ import BURN_MINT_TOKEN_POOL_V1_6_1_ABI from '../artifacts/abi/V1_6_1/burn-mint-t import LOCK_RELEASE_TOKEN_POOL_V1_6_1_ABI from '../artifacts/abi/V1_6_1/lock-release-token-pool.ts' import BURN_MINT_TOKEN_POOL_V2_0_0_ABI from '../artifacts/abi/V2_0_0/burn-mint-token-pool.ts' import LOCK_RELEASE_TOKEN_POOL_V2_0_0_ABI from '../artifacts/abi/V2_0_0/lock-release-token-pool.ts' +import BURN_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE from '../artifacts/bytecode/V2_0_0/burn-from-mint-token-pool.ts' +import BURN_MINT_TOKEN_POOL_V2_0_0_BYTECODE from '../artifacts/bytecode/V2_0_0/burn-mint-token-pool.ts' +import BURN_WITH_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE from '../artifacts/bytecode/V2_0_0/burn-with-from-mint-token-pool.ts' +import LOCK_RELEASE_TOKEN_POOL_V2_0_0_BYTECODE from '../artifacts/bytecode/V2_0_0/lock-release-token-pool.ts' +import type { DeployArtifact } from '../operation.ts' /** * ABI families for pool resolution. The burn-* variants are interface-compatible for CCT @@ -150,6 +156,38 @@ export function getTokenPoolInterface(type: TokenPoolType, version: TokenPoolVer return TOKEN_POOL_INTERFACES[getTokenPoolFamily(type)][version] } +/** + * Creation bytecode per deployable pool type (2.0.0 only — pre-2.0.0 bytecode is not vendored). + * The keys define the deployable set ({@link DeployableTokenPoolType}). The burn-* variants share + * the `BurnMint` constructor ABI but are distinct contracts with distinct bytecode. + */ +const TOKEN_POOL_BYTECODE = { + BurnMintTokenPool: BURN_MINT_TOKEN_POOL_V2_0_0_BYTECODE, + BurnFromMintTokenPool: BURN_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE, + BurnWithFromMintTokenPool: BURN_WITH_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE, + LockReleaseTokenPool: LOCK_RELEASE_TOKEN_POOL_V2_0_0_BYTECODE, +} satisfies Partial> + +/** A pool contract type that can be deployed (has vendored 2.0.0 creation bytecode). */ +export type DeployableTokenPoolType = keyof typeof TOKEN_POOL_BYTECODE + +/** Type guard for {@link DeployableTokenPoolType} (has vendored 2.0.0 creation bytecode). */ +export function isDeployableTokenPoolType(type: string): type is DeployableTokenPoolType { + return Object.hasOwn(TOKEN_POOL_BYTECODE, type) +} + +/** + * Deploy artifact for a deployable pool `type` (v2.0.0): contract name (= `type`), the cached + * constructor {@link Interface}, and the creation bytecode. + */ +export function getTokenPoolArtifact(type: DeployableTokenPoolType): DeployArtifact { + return { + contract: type, + iface: getTokenPoolInterface(type, TokenPoolVersion.V2_0_0), + bytecode: TOKEN_POOL_BYTECODE[type], + } +} + /** * Returns the encoder registered at the greatest version less than or equal to * `version`. One entry per calldata change covers all higher versions via floor-match. diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.test.ts b/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.test.ts index 673e0c91..21576292 100644 --- a/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.test.ts +++ b/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.test.ts @@ -244,9 +244,27 @@ describe('DeployTokenPool (cct/evm token-pool operation)', () => { ...params, wallet: fakeSigner({ contractAddress: DEPLOYED }), }) - assert.deepEqual(result, { hash: HASH, contractAddress: DEPLOYED }) + assert.deepEqual(result, { + hash: HASH, + contractAddress: DEPLOYED, + verification: { + contract: 'BurnMintTokenPool', + encodedConstructorArgs: '0x' + BURN_MINT_ARGS, + }, + }) }) + for (const { label, params: caseParams, ctorArgs } of CASES) { + it(`carries the verification handle for ${label}`, async () => { + const result = await new DeployTokenPool().execute(stubChain(), { + ...caseParams, + wallet: fakeSigner({ contractAddress: DEPLOYED }), + }) + assert.equal(result.verification.contract, caseParams.type) + assert.equal(result.verification.encodedConstructorArgs, '0x' + ctorArgs) + }) + } + it('throws CCTTxFailedError when the receipt carries no contract address', async () => { await assert.rejects( () => diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.ts b/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.ts index 06c18248..d35b16c9 100644 --- a/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.ts +++ b/ccip-sdk/src/cct/evm/token-pool/operations/deploy-token-pool.ts @@ -8,44 +8,19 @@ import { type Interface, ZeroAddress } from 'ethers' -import type { EVMChain } from '../../../../evm/index.ts' -import type { UnsignedEVMTx } from '../../../../evm/types.ts' -import { CCTParamsInvalidError, CCTTxFailedError } from '../../../errors.ts' -import BURN_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE from '../../artifacts/bytecode/V2_0_0/burn-from-mint-token-pool.ts' -import BURN_MINT_TOKEN_POOL_V2_0_0_BYTECODE from '../../artifacts/bytecode/V2_0_0/burn-mint-token-pool.ts' -import BURN_WITH_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE from '../../artifacts/bytecode/V2_0_0/burn-with-from-mint-token-pool.ts' -import LOCK_RELEASE_TOKEN_POOL_V2_0_0_BYTECODE from '../../artifacts/bytecode/V2_0_0/lock-release-token-pool.ts' -import { - type DeployResult, - type EVMExecuteParams, - EVMOperation, - deploymentTx, -} from '../../operation.ts' -import { submit } from '../../submit.ts' -import { validateAddress, validateUint8 } from '../../validate.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { type DeployArtifact, EVMDeployOperation } from '../../operation.ts' +import { validateAddress, validateNonZeroAddress, validateUint8 } from '../../validate.ts' import { + type DeployableTokenPoolType, type TokenPoolFamily, - type TokenPoolType, - TokenPoolVersion, + getTokenPoolArtifact, getTokenPoolFamily, - getTokenPoolInterface, -} from '../version.ts' - -/** - * Creation bytecode per deployable pool type (2.0.0 only — pre-2.0.0 bytecode is not vendored). - * The keys define the deployable set ({@link DeployableTokenPoolType} derives from them). The - * burn-* variants share the `BurnMint` constructor ABI but are distinct contracts with distinct - * bytecode. - */ -const TOKEN_POOL_BYTECODE = { - BurnMintTokenPool: BURN_MINT_TOKEN_POOL_V2_0_0_BYTECODE, - BurnFromMintTokenPool: BURN_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE, - BurnWithFromMintTokenPool: BURN_WITH_FROM_MINT_TOKEN_POOL_V2_0_0_BYTECODE, - LockReleaseTokenPool: LOCK_RELEASE_TOKEN_POOL_V2_0_0_BYTECODE, -} satisfies Partial> + isDeployableTokenPoolType, +} from '../contracts.ts' -/** A pool contract type that can be deployed (has vendored 2.0.0 creation bytecode). */ -export type DeployableTokenPoolType = keyof typeof TOKEN_POOL_BYTECODE +/** Deployable pool types + their creation bytecode/artifact live in `../contracts.ts`. */ +export type { DeployableTokenPoolType } /** Fields shared by every deployable token pool. */ interface DeployTokenPoolBaseParams { @@ -111,8 +86,8 @@ const encodeLockReleaseTokenPool: TokenPoolConstructorEncoder = (iface, p) => p.type === 'LockReleaseTokenPool' ? p.lockbox : ZeroAddress, ]) -/** Deploys a token pool; `execute` resolves to `{ hash, contractAddress }`. */ -export class DeployTokenPool extends EVMOperation { +/** Deploys a token pool; `execute` resolves to `{ hash, contractAddress, verification }`. */ +export class DeployTokenPool extends EVMDeployOperation { readonly name = 'deployTokenPool' /** Constructor encoder per ABI {@link TokenPoolFamily}; `type` narrows to its family. */ @@ -123,7 +98,7 @@ export class DeployTokenPool extends EVMOperation { /** Validates the constructor params before building init-code. */ protected validate(params: DeployTokenPoolParams): void { - if (!Object.hasOwn(TOKEN_POOL_BYTECODE, params.type)) + if (!isDeployableTokenPoolType(params.type)) throw new CCTParamsInvalidError( this.name, 'type', @@ -135,39 +110,17 @@ export class DeployTokenPool extends EVMOperation { validateAddress(this.name, 'router', params.router) if (params.advancedPoolHooks !== undefined) validateAddress(this.name, 'advancedPoolHooks', params.advancedPoolHooks) - if (params.type === 'LockReleaseTokenPool') { - validateAddress(this.name, 'lockbox', params.lockbox) - if (params.lockbox === ZeroAddress) - throw new CCTParamsInvalidError(this.name, 'lockbox', 'must not be the zero address') - } + if (params.type === 'LockReleaseTokenPool') + validateNonZeroAddress(this.name, 'lockbox', params.lockbox) } - /** Builds a deployment tx (no `to`): creation bytecode + ABI-encoded constructor args. */ - protected buildUnsigned(_chain: EVMChain, params: DeployTokenPoolParams): UnsignedEVMTx { - const iface = getTokenPoolInterface(params.type, TokenPoolVersion.V2_0_0) - const encode = this.encoders[getTokenPoolFamily(params.type)] - return deploymentTx(TOKEN_POOL_BYTECODE[params.type], encode(iface, params)) + /** Deploy artifact for the selected pool `type` (v2.0.0): name + ctor interface + bytecode. */ + protected artifact(p: DeployTokenPoolParams): DeployArtifact { + return getTokenPoolArtifact(p.type) } - /** - * {@link generate}, then sign and submit; resolves to the tx hash and the newly deployed - * pool address (read from the mined receipt). - * @throws {@link CCTTxFailedError} if the tx mined without producing a contract address - */ - override async execute( - chain: EVMChain, - params: EVMExecuteParams, - ): Promise { - const { response, receipt } = await submit( - chain, - params.wallet, - await this.generate(chain, params), - this.name, - ) - if (!receipt.contractAddress) - throw new CCTTxFailedError(this.name, 'deployment produced no contract address', { - context: { txHash: response.hash }, - }) - return { hash: response.hash, contractAddress: receipt.contractAddress } + /** ABI-encodes the pool constructor args via the encoder for the type's ABI family. */ + protected encode(iface: Interface, p: DeployTokenPoolParams): string { + return this.encoders[getTokenPoolFamily(p.type)](iface, p) } } diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/transfer-ownership.ts b/ccip-sdk/src/cct/evm/token-pool/operations/transfer-ownership.ts index acf2f604..ae9ce1db 100644 --- a/ccip-sdk/src/cct/evm/token-pool/operations/transfer-ownership.ts +++ b/ccip-sdk/src/cct/evm/token-pool/operations/transfer-ownership.ts @@ -9,15 +9,14 @@ import type { Interface } from 'ethers' import type { EVMChain } from '../../../../evm/index.ts' import type { UnsignedEVMTx } from '../../../../evm/types.ts' -import { ChainFamily } from '../../../../networks.ts' -import { EVMOperation } from '../../operation.ts' +import { EVMOperation, callTx } from '../../operation.ts' import { validateAddress } from '../../validate.ts' import { TokenPoolVersion, getTokenPoolInterface, resolveEncoder, resolveTokenPool, -} from '../version.ts' +} from '../contracts.ts' /** Parameters for {@link TransferOwnership}. */ export interface TransferOwnershipParams { @@ -30,10 +29,8 @@ export interface TransferOwnershipParams { /** Encodes `transferOwnership` calldata against the resolved pool {@link Interface}. */ type Encoder = (iface: Interface, params: TransferOwnershipParams) => UnsignedEVMTx -const encodeTransferOwnership: Encoder = (iface, { newOwner, poolAddress }) => { - const data = iface.encodeFunctionData('transferOwnership', [newOwner]) - return { family: ChainFamily.EVM, transactions: [{ to: poolAddress, data }] } -} +const encodeTransferOwnership: Encoder = (iface, { newOwner, poolAddress }) => + callTx(poolAddress, iface.encodeFunctionData('transferOwnership', [newOwner])) /** Proposes a new TokenPool owner via Ownable2Step `transferOwnership`. */ export class TransferOwnership extends EVMOperation { diff --git a/ccip-sdk/src/cct/evm/token/contracts.ts b/ccip-sdk/src/cct/evm/token/contracts.ts new file mode 100644 index 00000000..cdd3aa18 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/contracts.ts @@ -0,0 +1,69 @@ +/** + * 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`. + * + * @packageDocumentation + */ + +import { Interface } from 'ethers' + +import { 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' + +/** + * Known token versions, low to high. `2.0.0` is `CrossChainToken`; `1.5.1` / `1.6.2` + * are `FactoryBurnMintERC20`. + */ +export const TokenVersion = { + V1_5_1: '1.5.1', + V1_6_2: '1.6.2', + V2_0_0: '2.0.0', +} as const + +/** A known token version. */ +export type TokenVersion = (typeof TokenVersion)[keyof typeof TokenVersion] + +/** + * Cached token {@link Interface}s per {@link TokenVersion}, built once from the vendored ABIs + * (no per-call `new Interface`) — for read/write (e.g. ownership) ops. Mirrors + * `TOKEN_POOL_INTERFACES` in `token-pool/contracts.ts`. + */ +export const TOKEN_INTERFACES: Record = { + [TokenVersion.V1_5_1]: new Interface(FACTORY_BURN_MINT_ERC20_V1_5_1_ABI), + [TokenVersion.V1_6_2]: new Interface(FACTORY_BURN_MINT_ERC20_V1_6_2_ABI), + [TokenVersion.V2_0_0]: new Interface(CROSS_CHAIN_TOKEN_V2_0_0_ABI), +} + +/** Returns the cached token {@link Interface} for `version`. */ +export function getTokenInterface(version: TokenVersion): Interface { + return TOKEN_INTERFACES[version] +} + +/** + * Deploy artifacts ({@link DeployArtifact}: contract name + ctor {@link Interface} + creation + * bytecode) keyed by {@link TokenVersion}, built once; read via {@link getTokenArtifact}. Only + * `2.0.0` (`CrossChainToken`) is deployable. + */ +export const TOKEN_ARTIFACTS: Partial> = { + [TokenVersion.V2_0_0]: { + contract: 'CrossChainToken', + iface: TOKEN_INTERFACES[TokenVersion.V2_0_0], + bytecode: CROSS_CHAIN_TOKEN_V2_0_0_BYTECODE, + }, +} + +/** + * Returns the cached deploy artifact for `version`. + * @throws {@link CCTContractVersionUnsupportedError} if `version` has no vendored deploy bytecode + */ +export function getTokenArtifact(version: TokenVersion): DeployArtifact { + const artifact = TOKEN_ARTIFACTS[version] + if (!artifact) throw new CCTContractVersionUnsupportedError('token', version) + return artifact +} diff --git a/ccip-sdk/src/cct/evm/token/operations/deploy-token.test.ts b/ccip-sdk/src/cct/evm/token/operations/deploy-token.test.ts index e8980ca5..f24f6072 100644 --- a/ccip-sdk/src/cct/evm/token/operations/deploy-token.test.ts +++ b/ccip-sdk/src/cct/evm/token/operations/deploy-token.test.ts @@ -218,7 +218,20 @@ describe('DeployToken (cct/evm)', () => { ...INPUTS, wallet: fakeSigner({ contractAddress: DEPLOYED }), }) - assert.deepEqual(result, { hash: HASH, contractAddress: DEPLOYED }) + assert.deepEqual(result, { + hash: HASH, + contractAddress: DEPLOYED, + verification: { contract: 'CrossChainToken', encodedConstructorArgs: '0x' + CTOR_ARGS }, + }) + }) + + it('carries the verification handle recovered from the init-code', async () => { + const result = await new DeployToken().execute(stubChain(), { + ...INPUTS, + wallet: fakeSigner({ contractAddress: DEPLOYED }), + }) + assert.equal(result.verification.contract, 'CrossChainToken') + assert.equal(result.verification.encodedConstructorArgs, '0x' + CTOR_ARGS) }) it('throws CCTTxFailedError when the receipt carries no contract address', async () => { diff --git a/ccip-sdk/src/cct/evm/token/operations/deploy-token.ts b/ccip-sdk/src/cct/evm/token/operations/deploy-token.ts index dbb0fe5e..69e2c3d5 100644 --- a/ccip-sdk/src/cct/evm/token/operations/deploy-token.ts +++ b/ccip-sdk/src/cct/evm/token/operations/deploy-token.ts @@ -7,23 +7,15 @@ import { type Interface, ZeroAddress } from 'ethers' -import type { EVMChain } from '../../../../evm/index.ts' -import type { UnsignedEVMTx } from '../../../../evm/types.ts' -import { CCTParamsInvalidError, CCTTxFailedError } from '../../../errors.ts' -import { - type DeployResult, - type EVMExecuteParams, - EVMOperation, - deploymentTx, -} from '../../operation.ts' -import { submit } from '../../submit.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { type DeployArtifact, EVMDeployOperation } from '../../operation.ts' import { validateAddress, validateNonEmptyString, validateUint256, validateUint8, } from '../../validate.ts' -import { TokenVersion, tokenArtifact } from '../version.ts' +import { TokenVersion, getTokenArtifact } from '../contracts.ts' /** Parameters for {@link DeployToken} — deploys `CrossChainToken` (v2.0.0). */ export interface DeployTokenParams { @@ -63,8 +55,8 @@ function encodeCrossChainToken(iface: Interface, p: DeployTokenParams): string { ]) } -/** Deploys a `CrossChainToken`; `execute` resolves to `{ hash, contractAddress }`. */ -export class DeployToken extends EVMOperation { +/** Deploys a `CrossChainToken`; `execute` resolves to `{ hash, contractAddress, verification }`. */ +export class DeployToken extends EVMDeployOperation { readonly name = 'deployToken' /** Validates the constructor params before building init-code. */ @@ -109,32 +101,13 @@ export class DeployToken extends EVMOperation { validateAddress(this.name, 'burnMintRoleAdmin', params.burnMintRoleAdmin) } - /** Builds a deployment tx (no `to`): creation bytecode + ABI-encoded constructor args. */ - protected buildUnsigned(_chain: EVMChain, params: DeployTokenParams): UnsignedEVMTx { - // hardcoded to deploy CrossChainToken 2.0.0 - const { iface, bytecode } = tokenArtifact(TokenVersion.V2_0_0) - return deploymentTx(bytecode, encodeCrossChainToken(iface, params)) + /** Deploy artifact for `CrossChainToken` (v2.0.0). */ + protected artifact(): DeployArtifact { + return getTokenArtifact(TokenVersion.V2_0_0) } - /** - * {@link generate}, then sign and submit; resolves to the tx hash and the newly - * deployed contract address (read from the mined receipt). - * @throws {@link CCTTxFailedError} if the tx mined without producing a contract address - */ - override async execute( - chain: EVMChain, - params: EVMExecuteParams, - ): Promise { - const { response, receipt } = await submit( - chain, - params.wallet, - await this.generate(chain, params), - this.name, - ) - if (!receipt.contractAddress) - throw new CCTTxFailedError(this.name, 'deployment produced no contract address', { - context: { txHash: response.hash }, - }) - return { hash: response.hash, contractAddress: receipt.contractAddress } + /** ABI-encodes the `CrossChainToken` (v2.0.0) constructor args. */ + protected encode(iface: Interface, params: DeployTokenParams): string { + return encodeCrossChainToken(iface, params) } } diff --git a/ccip-sdk/src/cct/evm/token/version.ts b/ccip-sdk/src/cct/evm/token/version.ts deleted file mode 100644 index ae164bb0..00000000 --- a/ccip-sdk/src/cct/evm/token/version.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * EVM token version axis for CCT. {@link TokenVersion} + {@link TOKEN_ABIS} cover every - * known token contract so read/write ops can resolve the right interface; - * {@link TOKEN_ARTIFACTS} / {@link tokenArtifact} add creation bytecode. `2.0.0` is - * `CrossChainToken`; `1.5.1` / `1.6.2` are `FactoryBurnMintERC20`. Mirrors - * `token-pool/version.ts`. - * - * @packageDocumentation - */ - -import { type InterfaceAbi, Interface } from 'ethers' - -import { 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' - -/** - * Known token versions, low to high. `2.0.0` is `CrossChainToken`; `1.5.1` / `1.6.2` - * are `FactoryBurnMintERC20`. - */ -export const TokenVersion = { - V1_5_1: '1.5.1', - V1_6_2: '1.6.2', - V2_0_0: '2.0.0', -} as const - -/** A known token version. */ -export type TokenVersion = (typeof TokenVersion)[keyof typeof TokenVersion] - -/** Contract ABI per {@link TokenVersion} — lets read/write ops resolve the right interface. */ -export const TOKEN_ABIS: Record = { - [TokenVersion.V1_5_1]: FACTORY_BURN_MINT_ERC20_V1_5_1_ABI, - [TokenVersion.V1_6_2]: FACTORY_BURN_MINT_ERC20_V1_6_2_ABI, - [TokenVersion.V2_0_0]: CROSS_CHAIN_TOKEN_V2_0_0_ABI, -} - -/** - * Returns the contract ABI for `version`. - * @throws {@link CCTContractVersionUnsupportedError} if `version` has no vendored ABI - */ -export function tokenAbi(version: TokenVersion): InterfaceAbi { - const abi = TOKEN_ABIS[version] - if (!abi) throw new CCTContractVersionUnsupportedError('token', version) - return abi -} - -/** A token deploy artifact: the cached constructor {@link Interface} and creation bytecode. */ -export interface TokenArtifact { - iface: Interface - bytecode: `0x${string}` -} - -/** - * Deploy artifacts (ctor {@link Interface} + creation bytecode) keyed by {@link TokenVersion}, - * built once. Only versions with vendored bytecode appear; read via {@link tokenArtifact}. - */ -export const TOKEN_ARTIFACTS: Partial> = { - [TokenVersion.V2_0_0]: { - iface: new Interface(CROSS_CHAIN_TOKEN_V2_0_0_ABI), - bytecode: CROSS_CHAIN_TOKEN_V2_0_0_BYTECODE, - }, -} - -/** - * Returns the cached deploy artifact for `version`. - * @throws {@link CCTContractVersionUnsupportedError} if `version` has no vendored deploy bytecode - */ -export function tokenArtifact(version: TokenVersion): TokenArtifact { - const artifact = TOKEN_ARTIFACTS[version] - if (!artifact) throw new CCTContractVersionUnsupportedError('token', version) - return artifact -} diff --git a/ccip-sdk/src/cct/evm/validate.ts b/ccip-sdk/src/cct/evm/validate.ts index 279f2e2a..5740169a 100644 --- a/ccip-sdk/src/cct/evm/validate.ts +++ b/ccip-sdk/src/cct/evm/validate.ts @@ -5,19 +5,23 @@ * @packageDocumentation */ -import { isAddress } from 'ethers' +import { ZeroAddress, getAddress, isAddress } from 'ethers' import { CCIPAddressInvalidError } from '../../errors/index.ts' import { ChainFamily } from '../../networks.ts' import { CCTParamsInvalidError } from '../errors.ts' /** - * Asserts `value` is a valid EVM address. Links the canonical - * {@link CCIPAddressInvalidError} as the `cause`, keeping the + * Asserts `value` is a valid EVM address, narrowing it to `string` for callers. Links the + * canonical {@link CCIPAddressInvalidError} as the `cause`, keeping the * {@link operation}/{@link param} context on top. * @throws {@link CCTParamsInvalidError} if `value` is not a valid address */ -export function validateAddress(operation: string, param: string, value: unknown): void { +export function validateAddress( + operation: string, + param: string, + value: unknown, +): asserts value is string { if (typeof value === 'string' && isAddress(value)) return throw new CCTParamsInvalidError( operation, @@ -29,6 +33,18 @@ export function validateAddress(operation: string, param: string, value: unknown ) } +/** + * Asserts `value` is a valid, non-zero EVM address. + * @remarks Normalises with `getAddress` first: a literal `=== ZeroAddress` misses the ICAP + * spelling, and a tx to `0x0` hits no code, so it mines as a successful no-op. + * @throws {@link CCTParamsInvalidError} if `value` is not a valid address, or is the zero address + */ +export function validateNonZeroAddress(operation: string, param: string, value: unknown): void { + validateAddress(operation, param, value) + if (getAddress(value) === ZeroAddress) + throw new CCTParamsInvalidError(operation, param, 'must not be the zero address') +} + /** * Asserts `value` is a non-empty (non-blank) string. * @throws {@link CCTParamsInvalidError} if `value` is not a non-empty string