diff --git a/ccip-cli/package.json b/ccip-cli/package.json index 47a10f05..5a8e7510 100644 --- a/ccip-cli/package.json +++ b/ccip-cli/package.json @@ -21,7 +21,7 @@ "typecheck": "tsc --noEmit", "check": "npm run lint && npm run typecheck", "build": "npm run clean && tsc -p ./tsconfig.build.json && npm run patch-dist", - "patch-dist": "chmod +x ./dist/index.js && find ./dist -type f -name \"*.js\" -exec sed -i.bkp 's|@chainlink/ccip-sdk/src/.*\\.ts|@chainlink/ccip-sdk|g' {} + && find ./dist -type f -name \"*.bkp\" -delete", + "patch-dist": "chmod +x ./dist/index.js && find ./dist -type f -name \"*.js\" -exec sed -i.bkp -e 's|@chainlink/ccip-sdk/src/token-admin/\\([^/]*\\)/index\\.ts|@chainlink/ccip-sdk/token-admin/\\1|g' -e 's|@chainlink/ccip-sdk/src/token-admin/types\\.ts|@chainlink/ccip-sdk/token-admin/types|g' -e 's|@chainlink/ccip-sdk/src/.*\\.ts|@chainlink/ccip-sdk|g' {} + && find ./dist -type f -name \"*.bkp\" -delete", "start": "./ccip-cli", "clean": "rm -rfv ./dist", "prepare": "npm run build" diff --git a/ccip-cli/src/commands/pool.ts b/ccip-cli/src/commands/pool.ts new file mode 100644 index 00000000..c2144753 --- /dev/null +++ b/ccip-cli/src/commands/pool.ts @@ -0,0 +1,24 @@ +/** + * Pool operations command group. + * Dispatches to subcommands: deploy. + */ + +import type { Argv } from 'yargs' + +export const command = 'pool' +export const describe = + 'Pool operations (deploy, apply-chain-updates, append-remote-pool-addresses, remove-remote-pool-addresses, delete-chain-config, get-config, set-rate-limiter-config, set-rate-limit-admin, transfer-ownership, accept-ownership, execute-ownership-transfer)' + +/** + * Yargs builder for the pool command group. + * Loads subcommands from the `pool/` directory. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with subcommands. + */ +export const builder = (yargs: Argv) => + yargs + .commandDir('pool', { + extensions: [new URL(import.meta.url).pathname.split('.').pop()!], + exclude: /\.test\.[tj]s$/, + }) + .demandCommand(1) diff --git a/ccip-cli/src/commands/pool/accept-ownership.ts b/ccip-cli/src/commands/pool/accept-ownership.ts new file mode 100644 index 00000000..788d06a3 --- /dev/null +++ b/ccip-cli/src/commands/pool/accept-ownership.ts @@ -0,0 +1,134 @@ +/** + * Pool accept-ownership subcommand. + * Accepts proposed pool ownership (2-step ownership transfer). + */ + +import { + type AcceptOwnershipParams, + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'accept-ownership' +export const describe = 'Accept proposed pool ownership (2-step ownership transfer)' + +/** + * Yargs builder for the pool accept-ownership subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pending/proposed owner)', + }) + .option('pool-address', { + type: 'string', + demandOption: true, + describe: 'Pool address', + }) + .example([ + [ + 'ccip-cli pool accept-ownership -n sepolia --pool-address 0x...', + 'Accept proposed pool ownership', + ], + ]) + +/** + * Handler for the pool accept-ownership subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doAcceptOwnership(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type AcceptOwnershipArgv = Awaited['argv']> & GlobalOpts + +/** Calls acceptOwnership on the appropriate chain-family admin. */ +function acceptForChain(chain: Chain, wallet: unknown, params: AcceptOwnershipParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.acceptOwnership(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.acceptOwnership(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.acceptOwnership(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doAcceptOwnership(ctx: Ctx, argv: AcceptOwnershipArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const params: AcceptOwnershipParams = { + poolAddress: argv.poolAddress, + } + + logger.debug(`Accepting ownership: pool=${params.poolAddress}`) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await acceptForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: params.poolAddress, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Ownership accepted, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/append-remote-pool-addresses.ts b/ccip-cli/src/commands/pool/append-remote-pool-addresses.ts new file mode 100644 index 00000000..f6e5d895 --- /dev/null +++ b/ccip-cli/src/commands/pool/append-remote-pool-addresses.ts @@ -0,0 +1,160 @@ +/** + * Pool append-remote-pool-addresses subcommand. + * Appends remote pool addresses to a CCIP token pool for a given remote chain. + */ + +import { + type AppendRemotePoolAddressesParams, + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPArgumentInvalidError, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'append-remote-pool-addresses' +export const describe = 'Append remote pool addresses to a CCIP token pool for a given remote chain' + +/** + * Yargs builder for the pool append-remote-pool-addresses subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pool owner)', + }) + .option('pool-address', { + type: 'string', + describe: 'Local pool address', + }) + .option('remote-chain', { + type: 'string', + describe: 'Remote chain: chainId, name, or selector', + }) + .option('remote-pool-addresses', { + type: 'string', + describe: 'Comma-separated list of remote pool addresses', + }) + .check((argv) => { + if (!argv.network) throw new CCIPArgumentInvalidError('network', 'required argument missing') + if (!argv.poolAddress) + throw new CCIPArgumentInvalidError('pool-address', 'required argument missing') + if (!argv.remoteChain) + throw new CCIPArgumentInvalidError('remote-chain', 'required argument missing') + if (!argv.remotePoolAddresses) + throw new CCIPArgumentInvalidError('remote-pool-addresses', 'required argument missing') + return true + }) + .example([ + [ + 'ccip-cli pool append-remote-pool-addresses -n sepolia --pool-address 0x... --remote-chain avalanche-fuji --remote-pool-addresses 0xaaa,0xbbb', + 'Append remote pool addresses for a remote chain', + ], + ]) + +/** + * Handler for the pool append-remote-pool-addresses subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doAppendRemotePoolAddresses(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type AppendArgv = Awaited['argv']> & GlobalOpts + +/** Calls appendRemotePoolAddresses on the appropriate chain-family admin. */ +function appendForChain(chain: Chain, wallet: unknown, params: AppendRemotePoolAddressesParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.appendRemotePoolAddresses(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.appendRemotePoolAddresses(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.appendRemotePoolAddresses(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doAppendRemotePoolAddresses(ctx: Ctx, argv: AppendArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network!).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const remoteChainSelector = networkInfo(argv.remoteChain!).chainSelector + const remotePoolAddresses = argv.remotePoolAddresses!.split(',').map((a) => a.trim()) + + const params: AppendRemotePoolAddressesParams = { + poolAddress: argv.poolAddress!, + remoteChainSelector, + remotePoolAddresses, + } + + logger.debug( + `Appending ${remotePoolAddresses.length} remote pool address(es) for remote chain ${remoteChainSelector}`, + ) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await appendForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: argv.poolAddress!, + remoteChainSelector: String(remoteChainSelector), + addressesAdded: remotePoolAddresses.join(', '), + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Remote pool addresses appended, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/apply-chain-updates.ts b/ccip-cli/src/commands/pool/apply-chain-updates.ts new file mode 100644 index 00000000..db0e281e --- /dev/null +++ b/ccip-cli/src/commands/pool/apply-chain-updates.ts @@ -0,0 +1,254 @@ +/** + * Pool apply-chain-updates subcommand. + * Configures remote chains on a CCIP token pool. + */ + +import { + type ApplyChainUpdatesParams, + type AptosChain, + type Chain, + type EVMChain, + type RateLimiterConfig, + type RemoteChainConfig, + type SolanaChain, + CCIPArgumentInvalidError, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'apply-chain-updates' +export const describe = 'Configure remote chains on a CCIP token pool' + +// ── Config file schema ── + +interface ConfigFile { + chainsToRemove?: string[] + chainsToAdd?: Array<{ + remoteChainSelector: string + remotePoolAddresses: string[] + remoteTokenAddress: string + remoteTokenDecimals?: number + outboundRateLimiterConfig?: RateLimiterConfig + inboundRateLimiterConfig?: RateLimiterConfig + }> +} + +// ── Generate config template ── + +const CONFIG_TEMPLATE: ConfigFile = { + chainsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector: + '', + remotePoolAddresses: [''], + remoteTokenAddress: '', + remoteTokenDecimals: 18, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], +} + +/** + * Yargs builder for the pool apply-chain-updates subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pool owner)', + }) + .option('pool-address', { + type: 'string', + describe: 'Local pool address', + }) + .option('config', { + type: 'string', + describe: 'Path to JSON config file with remote chain configurations', + }) + .option('generate-config', { + type: 'boolean', + describe: 'Output a sample JSON config template to stdout', + }) + .check((argv) => { + if (!argv.generateConfig) { + if (!argv.network) + throw new CCIPArgumentInvalidError('network', 'required argument missing') + if (!argv.poolAddress) + throw new CCIPArgumentInvalidError('pool-address', 'required argument missing') + } + return true + }) + .example([ + [ + 'ccip-cli pool apply-chain-updates -n sepolia --pool-address 0x... --config config.json', + 'Apply chain updates from a config file', + ], + [ + 'ccip-cli pool apply-chain-updates --generate-config > config.json', + 'Generate a template config file', + ], + [ + 'cat config.json | ccip-cli pool apply-chain-updates -n sepolia --pool-address 0x...', + 'Read config from stdin', + ], + ]) + +/** + * Handler for the pool apply-chain-updates subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + // Handle --generate-config + if (argv.generateConfig) { + console.log(JSON.stringify(CONFIG_TEMPLATE, null, 2)) + return + } + + const [ctx, destroy] = getCtx(argv) + return doApplyChainUpdates(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type ApplyArgv = Awaited['argv']> & GlobalOpts + +/** Reads and parses config from file path or stdin. */ +async function readConfig(argv: ApplyArgv): Promise { + const { readFileSync } = await import('node:fs') + + if (argv.config) { + // Read from file + const raw = readFileSync(argv.config, 'utf8') + return JSON.parse(raw) as ConfigFile + } + + // Try stdin (piped input) + if (!process.stdin.isTTY) { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + const raw = Buffer.concat(chunks).toString('utf8') + return JSON.parse(raw) as ConfigFile + } + + throw new CCIPArgumentInvalidError( + 'config', + 'No config provided. Use --config or pipe JSON via stdin. Use --generate-config to see the expected format.', + ) +} + +/** + * Resolves a chain identifier (name, chainId, or selector) to a numeric selector string. + * Uses `networkInfo()` which accepts all three formats. + */ +function resolveChainSelector(input: string): bigint { + return networkInfo(input).chainSelector +} + +/** Converts a config file to ApplyChainUpdatesParams. */ +function configToParams(poolAddress: string, config: ConfigFile): ApplyChainUpdatesParams { + const defaultRateLimit: RateLimiterConfig = { isEnabled: false, capacity: '0', rate: '0' } + + const chainsToAdd: RemoteChainConfig[] = (config.chainsToAdd ?? []).map((c) => ({ + remoteChainSelector: resolveChainSelector(c.remoteChainSelector), + remotePoolAddresses: c.remotePoolAddresses, + remoteTokenAddress: c.remoteTokenAddress, + remoteTokenDecimals: c.remoteTokenDecimals, + outboundRateLimiterConfig: c.outboundRateLimiterConfig ?? defaultRateLimit, + inboundRateLimiterConfig: c.inboundRateLimiterConfig ?? defaultRateLimit, + })) + + return { + poolAddress, + remoteChainSelectorsToRemove: (config.chainsToRemove ?? []).map(resolveChainSelector), + chainsToAdd, + } +} + +/** Calls applyChainUpdates on the appropriate chain-family admin. */ +function applyForChain(chain: Chain, wallet: unknown, params: ApplyChainUpdatesParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.applyChainUpdates(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.applyChainUpdates(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.applyChainUpdates(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doApplyChainUpdates(ctx: Ctx, argv: ApplyArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network!).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const config = await readConfig(argv) + const params = configToParams(argv.poolAddress!, config) + + logger.debug( + `Applying chain updates: ${params.chainsToAdd.length} add(s), ${params.remoteChainSelectorsToRemove.length} remove(s)`, + ) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await applyForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: argv.poolAddress!, + txHash: result.txHash, + chainsAdded: String(params.chainsToAdd.length), + chainsRemoved: String(params.remoteChainSelectorsToRemove.length), + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Chain updates applied, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/delete-chain-config.ts b/ccip-cli/src/commands/pool/delete-chain-config.ts new file mode 100644 index 00000000..465b8783 --- /dev/null +++ b/ccip-cli/src/commands/pool/delete-chain-config.ts @@ -0,0 +1,149 @@ +/** + * Pool delete-chain-config subcommand. + * Removes a remote chain configuration from a CCIP token pool. + */ + +import { + type AptosChain, + type Chain, + type DeleteChainConfigParams, + type EVMChain, + type SolanaChain, + CCIPArgumentInvalidError, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'delete-chain-config' +export const describe = 'Remove a remote chain configuration from a CCIP token pool' + +/** + * Yargs builder for the pool delete-chain-config subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pool owner)', + }) + .option('pool-address', { + type: 'string', + describe: 'Local pool address', + }) + .option('remote-chain', { + type: 'string', + describe: 'Remote chain: chainId, name, or selector', + }) + .check((argv) => { + if (!argv.network) throw new CCIPArgumentInvalidError('network', 'required argument missing') + if (!argv.poolAddress) + throw new CCIPArgumentInvalidError('pool-address', 'required argument missing') + if (!argv.remoteChain) + throw new CCIPArgumentInvalidError('remote-chain', 'required argument missing') + return true + }) + .example([ + [ + 'ccip-cli pool delete-chain-config -n sepolia --pool-address 0x... --remote-chain avalanche-fuji', + 'Remove a remote chain config from a pool', + ], + ]) + +/** + * Handler for the pool delete-chain-config subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doDeleteChainConfig(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type DeleteArgv = Awaited['argv']> & GlobalOpts + +/** Calls deleteChainConfig on the appropriate chain-family admin. */ +function deleteForChain(chain: Chain, wallet: unknown, params: DeleteChainConfigParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.deleteChainConfig(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.deleteChainConfig(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.deleteChainConfig(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doDeleteChainConfig(ctx: Ctx, argv: DeleteArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network!).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const remoteChainSelector = networkInfo(argv.remoteChain!).chainSelector + + const params: DeleteChainConfigParams = { + poolAddress: argv.poolAddress!, + remoteChainSelector, + } + + logger.debug(`Deleting chain config for remote chain ${remoteChainSelector}`) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await deleteForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: argv.poolAddress!, + remoteChainSelector: String(remoteChainSelector), + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Chain config deleted, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/deploy.test.ts b/ccip-cli/src/commands/pool/deploy.test.ts new file mode 100644 index 00000000..a9320c47 --- /dev/null +++ b/ccip-cli/src/commands/pool/deploy.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import * as deploy from './deploy.ts' + +// ============================================================================= +// Module shape +// ============================================================================= + +describe('pool deploy — module shape', () => { + it('should export command as "deploy"', () => { + assert.equal(deploy.command, 'deploy') + }) + + it('should export a describe string', () => { + assert.equal(typeof deploy.describe, 'string') + assert.ok(deploy.describe.length > 0) + }) + + it('should export a builder function', () => { + assert.equal(typeof deploy.builder, 'function') + }) + + it('should export a handler function', () => { + assert.equal(typeof deploy.handler, 'function') + }) +}) diff --git a/ccip-cli/src/commands/pool/deploy.ts b/ccip-cli/src/commands/pool/deploy.ts new file mode 100644 index 00000000..07ca2145 --- /dev/null +++ b/ccip-cli/src/commands/pool/deploy.ts @@ -0,0 +1,244 @@ +/** + * Pool deploy subcommand. + * Deploys a new CCIP token pool (BurnMintTokenPool / LockReleaseTokenPool / Aptos pool). + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPArgumentInvalidError, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'deploy' +export const describe = 'Deploy a new CCIP token pool' + +/** + * Yargs builder for the pool deploy subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key', + }) + .option('pool-type', { + type: 'string', + choices: ['burn-mint', 'lock-release'] as const, + demandOption: true, + describe: + 'Pool type: burn-mint (burns on source, mints on dest) or lock-release (locks on source, releases on dest)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address (ERC20, SPL mint, or Aptos FA metadata)', + }) + .option('local-token-decimals', { + type: 'number', + demandOption: true, + describe: 'Token decimals on this chain', + }) + // EVM-specific + .option('router-address', { + type: 'string', + describe: 'CCIP Router address (required for EVM and Aptos)', + }) + .option('allowlist', { + type: 'array', + string: true, + describe: 'Allowlisted sender addresses (EVM only)', + }) + // Solana-specific + .option('pool-program-id', { + type: 'string', + describe: 'Pre-deployed pool program ID (required for Solana)', + }) + // Aptos-specific + .option('token-module', { + type: 'string', + choices: ['managed', 'generic', 'regulated'] as const, + describe: "Aptos token module variant (default: 'managed')", + }) + .option('mcms-address', { + type: 'string', + describe: 'Deployed mcms package address (required for Aptos)', + }) + .option('admin-address', { + type: 'string', + describe: 'Admin address for regulated token access control (Aptos regulated only)', + }) + .check((argv) => { + const { family } = networkInfo(argv.network) + if (family === ChainFamily.EVM) { + if (!argv.routerAddress) + throw new CCIPArgumentInvalidError( + 'router-address', + '--router-address is required for EVM and Aptos networks', + ) + } else if (family === ChainFamily.Aptos) { + if (!argv.routerAddress) + throw new CCIPArgumentInvalidError( + 'router-address', + '--router-address is required for EVM and Aptos networks', + ) + if (!argv.mcmsAddress) + throw new CCIPArgumentInvalidError( + 'mcms-address', + '--mcms-address is required for Aptos networks', + ) + } else if (family === ChainFamily.Solana) { + if (!argv.poolProgramId) + throw new CCIPArgumentInvalidError( + 'pool-program-id', + '--pool-program-id is required for Solana networks', + ) + } + return true + }) + .example([ + [ + 'ccip-cli pool deploy -n ethereum-testnet-sepolia --pool-type burn-mint --token-address 0xa42B... --local-token-decimals 18 --router-address 0x0BF3...', + 'Deploy BurnMintTokenPool on Sepolia', + ], + [ + 'ccip-cli pool deploy -n solana-devnet --pool-type burn-mint --token-address J6fE... --local-token-decimals 9 --pool-program-id ', + 'Deploy pool on Solana devnet', + ], + [ + 'ccip-cli pool deploy -n aptos-testnet --pool-type burn-mint --token-address 0x89fd... --local-token-decimals 8 --router-address 0xabc... --mcms-address 0x123...', + 'Deploy managed_token_pool on Aptos testnet', + ], + ]) + +/** + * Handler for the pool deploy subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doDeployPool(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type DeployArgv = Awaited['argv']> & GlobalOpts + +/** Deploys a pool using the appropriate chain-family admin with typed params. */ +function deployPoolForChain(chain: Chain, wallet: unknown, argv: DeployArgv) { + const poolType = argv.poolType + + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.deployPool(wallet, { + poolType, + tokenAddress: argv.tokenAddress, + localTokenDecimals: argv.localTokenDecimals, + routerAddress: argv.routerAddress!, + allowlist: argv.allowlist, + }) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.deployPool(wallet, { + poolType, + tokenAddress: argv.tokenAddress, + localTokenDecimals: argv.localTokenDecimals, + poolProgramId: argv.poolProgramId!, + }) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.deployPool(wallet, { + poolType, + tokenAddress: argv.tokenAddress, + localTokenDecimals: argv.localTokenDecimals, + routerAddress: argv.routerAddress!, + mcmsAddress: argv.mcmsAddress!, + ...(argv.tokenModule && { tokenModule: argv.tokenModule }), + ...(argv.adminAddress && { adminAddress: argv.adminAddress }), + }) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doDeployPool(ctx: Ctx, argv: DeployArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await deployPoolForChain(chain, wallet, argv) + + const output: Record = { + network: networkName, + poolAddress: result.poolAddress, + txHash: result.txHash, + } + + if (result.initialized === false) { + const poolModule = + argv.poolType === 'burn-mint' ? 'burn_mint_token_pool' : 'lock_release_token_pool' + const warning = + `WARNING: Generic pool deployed but NOT initialized. ` + + `The token creator module must call ${poolModule}::initialize() ` + + `with stored capability refs (BurnRef/MintRef/TransferRef) ` + + `before this pool can be used for CCIP operations.` + output.initialized = 'false' + output.warning = warning + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Pool deployed:', result.poolAddress, 'tx:', result.txHash) + if (result.initialized === false) { + logger.warn(output.warning) + } + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/execute-ownership-transfer.ts b/ccip-cli/src/commands/pool/execute-ownership-transfer.ts new file mode 100644 index 00000000..6db2dd55 --- /dev/null +++ b/ccip-cli/src/commands/pool/execute-ownership-transfer.ts @@ -0,0 +1,118 @@ +/** + * Pool execute-ownership-transfer subcommand. + * Aptos-only: finalizes pool ownership transfer (3rd step of Aptos 3-step process). + * + * Aptos ownership transfer flow: + * 1. `transfer-ownership` — current owner proposes new owner + * 2. `accept-ownership` — proposed owner signals acceptance + * 3. `execute-ownership-transfer` — current owner finalizes the AptosFramework object transfer + */ + +import { + type AptosChain, + type ExecuteOwnershipTransferParams, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'execute-ownership-transfer' +export const describe = + 'Aptos-only: finalize pool ownership transfer (3rd step after transfer + accept)' + +/** + * Yargs builder for the pool execute-ownership-transfer subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (must be an Aptos network)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be current pool owner)', + }) + .option('pool-address', { + type: 'string', + demandOption: true, + describe: 'Pool address', + }) + .option('new-owner', { + type: 'string', + demandOption: true, + describe: 'Address of the new owner (must match the address that called accept-ownership)', + }) + .example([ + [ + 'ccip-cli pool execute-ownership-transfer -n aptos-testnet --pool-address 0x... --new-owner 0x...', + 'Finalize Aptos pool ownership transfer', + ], + ]) + +/** + * Handler for the pool execute-ownership-transfer subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doExecuteOwnershipTransfer(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type ExecuteOwnershipTransferArgv = Awaited['argv']> & GlobalOpts + +async function doExecuteOwnershipTransfer(ctx: Ctx, argv: ExecuteOwnershipTransferArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + + const params: ExecuteOwnershipTransferParams = { + poolAddress: argv.poolAddress, + newOwner: argv.newOwner, + } + + logger.debug( + `Executing ownership transfer: pool=${params.poolAddress}, newOwner=${params.newOwner}`, + ) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await admin.executeOwnershipTransfer(wallet, params) + + const output: Record = { + network: networkName, + poolAddress: params.poolAddress, + newOwner: params.newOwner, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Ownership transfer executed, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/get-config.ts b/ccip-cli/src/commands/pool/get-config.ts new file mode 100644 index 00000000..7169e444 --- /dev/null +++ b/ccip-cli/src/commands/pool/get-config.ts @@ -0,0 +1,183 @@ +/** + * Pool get-config subcommand. + * Reads pool configuration and remote chain settings from on-chain state. + */ + +import { + type RateLimiterState, + CCIPArgumentInvalidError, + bigIntReplacer, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { formatUnits } from 'ethers' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { formatDuration, getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'get-config' +export const describe = 'Show pool configuration and remote chain settings' + +/** + * Yargs builder for the pool get-config subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + describe: 'Network: chainId, selector, or name (e.g., ethereum-testnet-sepolia)', + }) + .option('pool-address', { + type: 'string', + describe: 'Pool address', + }) + .option('remote-chain', { + type: 'string', + describe: 'Filter remotes by chain name, selector, or chainId (shows only this remote)', + }) + .check((argv) => { + if (!argv.network) throw new CCIPArgumentInvalidError('network', 'required argument missing') + if (!argv.poolAddress) + throw new CCIPArgumentInvalidError('pool-address', 'required argument missing') + return true + }) + .example([ + [ + 'ccip-cli pool get-config -n sepolia --pool-address 0x...', + 'Show pool config and all remotes', + ], + [ + 'ccip-cli pool get-config -n sepolia --pool-address 0x... --remote-chain solana-devnet', + 'Show config for a specific remote chain only', + ], + [ + 'ccip-cli pool get-config -n solana-devnet --pool-address -f json', + 'Show pool config as JSON', + ], + ]) + +/** + * Handler for the pool get-config subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doGetConfig(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +function prettyRateLimiter(state: RateLimiterState, info: { decimals: number; symbol: string }) { + if (!state) return null + return { + capacity: formatUnits(state.capacity, info.decimals) + ' ' + info.symbol, + tokens: `${formatUnits(state.tokens, info.decimals)} (${Math.round((Number(state.tokens) / Number(state.capacity)) * 100)}%)`, + rate: `${formatUnits(state.rate, info.decimals)}/s (0-to-full in ${formatDuration(Number(state.capacity / state.rate))})`, + ...(state.tokens < state.capacity && { + timeToFull: formatDuration(Number(state.capacity - state.tokens) / Number(state.rate)), + }), + } +} + +async function doGetConfig( + ctx: Ctx, + argv: Awaited['argv']> & GlobalOpts, +) { + const { logger } = ctx + const networkName = networkInfo(argv.network!).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const poolAddress = argv.poolAddress! + + // Resolve optional --remote-chain filter to a chain selector + const remoteFilter = argv.remoteChain ? networkInfo(argv.remoteChain).chainSelector : undefined + + const [poolConfig, remotes, tokenInfo] = await Promise.all([ + chain.getTokenPoolConfig(poolAddress), + chain.getTokenPoolRemotes(poolAddress, remoteFilter), + chain.getTokenPoolConfig(poolAddress).then((c) => chain.getTokenInfo(c.token)), + ]) + + const remotesEntries = Object.entries(remotes) + + switch (argv.format) { + case Format.json: { + const output = { + network: networkName, + poolAddress, + token: poolConfig.token, + ...tokenInfo, + owner: poolConfig.owner, + ...('proposedOwner' in poolConfig && { proposedOwner: poolConfig.proposedOwner }), + ...(poolConfig.rateLimitAdmin && { rateLimitAdmin: poolConfig.rateLimitAdmin }), + ...(poolConfig.feeAdmin && { feeAdmin: poolConfig.feeAdmin }), + router: poolConfig.router, + typeAndVersion: poolConfig.typeAndVersion, + remotes: Object.fromEntries(remotesEntries.map(([name, remote]) => [name, remote])), + } + logger.log(JSON.stringify(output, bigIntReplacer, 2)) + return + } + case Format.log: + logger.log('Pool:', poolAddress) + logger.log('Token:', poolConfig.token, tokenInfo) + logger.log('Owner:', poolConfig.owner) + if (poolConfig.proposedOwner) logger.log('Proposed Owner:', poolConfig.proposedOwner) + if (poolConfig.rateLimitAdmin) logger.log('Rate Limit Admin:', poolConfig.rateLimitAdmin) + if (poolConfig.feeAdmin) logger.log('Fee Admin:', poolConfig.feeAdmin) + logger.log('Router:', poolConfig.router) + logger.log('Type:', poolConfig.typeAndVersion) + logger.log('Remotes:', remotesEntries.length) + for (const [name, remote] of remotesEntries) { + logger.log(` ${name}:`, remote) + } + return + case Format.pretty: + default: { + prettyTable.call(ctx, { + network: `${networkName} [${networkInfo(networkName).chainSelector}]`, + poolAddress, + token: poolConfig.token, + symbol: tokenInfo.symbol, + name: tokenInfo.name, + decimals: tokenInfo.decimals, + owner: poolConfig.owner, + ...(poolConfig.proposedOwner && { proposedOwner: poolConfig.proposedOwner }), + ...(poolConfig.rateLimitAdmin && { rateLimitAdmin: poolConfig.rateLimitAdmin }), + ...(poolConfig.feeAdmin && { feeAdmin: poolConfig.feeAdmin }), + typeAndVersion: poolConfig.typeAndVersion, + router: poolConfig.router, + }) + + if (remotesEntries.length > 0) logger.info('Remotes [', remotesEntries.length, ']:') + for (const [name, remote] of remotesEntries) { + prettyTable.call(ctx, { + remoteNetwork: `${name} [${networkInfo(name).chainSelector}]`, + remoteToken: remote.remoteToken, + remotePool: remote.remotePools, + outbound: prettyRateLimiter(remote.outboundRateLimiterState, tokenInfo), + inbound: prettyRateLimiter(remote.inboundRateLimiterState, tokenInfo), + // FTF = Faster-Than-Finality: separate rate limiters for messages confirmed + // with fewer block confirmations (EVM v2.0+ pools only) + ...('customBlockConfirmationsOutboundRateLimiterState' in remote && { + ['[ftf: Faster-Than-Finality]outbound']: prettyRateLimiter( + remote.customBlockConfirmationsOutboundRateLimiterState, + tokenInfo, + ), + ['[ftf: Faster-Than-Finality]inbound']: prettyRateLimiter( + remote.customBlockConfirmationsInboundRateLimiterState, + tokenInfo, + ), + }), + }) + } + return + } + } +} diff --git a/ccip-cli/src/commands/pool/remove-remote-pool-addresses.ts b/ccip-cli/src/commands/pool/remove-remote-pool-addresses.ts new file mode 100644 index 00000000..a7ae0276 --- /dev/null +++ b/ccip-cli/src/commands/pool/remove-remote-pool-addresses.ts @@ -0,0 +1,161 @@ +/** + * Pool remove-remote-pool-addresses subcommand. + * Removes specific remote pool addresses from a CCIP token pool for a given remote chain. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type RemoveRemotePoolAddressesParams, + type SolanaChain, + CCIPArgumentInvalidError, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'remove-remote-pool-addresses' +export const describe = + 'Remove specific remote pool addresses from a CCIP token pool for a given remote chain' + +/** + * Yargs builder for the pool remove-remote-pool-addresses subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pool owner)', + }) + .option('pool-address', { + type: 'string', + describe: 'Local pool address', + }) + .option('remote-chain', { + type: 'string', + describe: 'Remote chain: chainId, name, or selector', + }) + .option('remote-pool-addresses', { + type: 'string', + describe: 'Comma-separated list of remote pool addresses to remove', + }) + .check((argv) => { + if (!argv.network) throw new CCIPArgumentInvalidError('network', 'required argument missing') + if (!argv.poolAddress) + throw new CCIPArgumentInvalidError('pool-address', 'required argument missing') + if (!argv.remoteChain) + throw new CCIPArgumentInvalidError('remote-chain', 'required argument missing') + if (!argv.remotePoolAddresses) + throw new CCIPArgumentInvalidError('remote-pool-addresses', 'required argument missing') + return true + }) + .example([ + [ + 'ccip-cli pool remove-remote-pool-addresses -n sepolia --pool-address 0x... --remote-chain avalanche-fuji --remote-pool-addresses 0xaaa,0xbbb', + 'Remove remote pool addresses for a remote chain', + ], + ]) + +/** + * Handler for the pool remove-remote-pool-addresses subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doRemoveRemotePoolAddresses(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type RemoveArgv = Awaited['argv']> & GlobalOpts + +/** Calls removeRemotePoolAddresses on the appropriate chain-family admin. */ +function removeForChain(chain: Chain, wallet: unknown, params: RemoveRemotePoolAddressesParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.removeRemotePoolAddresses(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.removeRemotePoolAddresses(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.removeRemotePoolAddresses(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doRemoveRemotePoolAddresses(ctx: Ctx, argv: RemoveArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network!).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const remoteChainSelector = networkInfo(argv.remoteChain!).chainSelector + const remotePoolAddresses = argv.remotePoolAddresses!.split(',').map((a) => a.trim()) + + const params: RemoveRemotePoolAddressesParams = { + poolAddress: argv.poolAddress!, + remoteChainSelector, + remotePoolAddresses, + } + + logger.debug( + `Removing ${remotePoolAddresses.length} remote pool address(es) for remote chain ${remoteChainSelector}`, + ) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await removeForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: argv.poolAddress!, + remoteChainSelector: String(remoteChainSelector), + addressesRemoved: remotePoolAddresses.join(', '), + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Remote pool addresses removed, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/set-rate-limit-admin.ts b/ccip-cli/src/commands/pool/set-rate-limit-admin.ts new file mode 100644 index 00000000..e5b292fe --- /dev/null +++ b/ccip-cli/src/commands/pool/set-rate-limit-admin.ts @@ -0,0 +1,143 @@ +/** + * Pool set-rate-limit-admin subcommand. + * Sets the rate limit admin on a CCIP token pool. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SetRateLimitAdminParams, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'set-rate-limit-admin' +export const describe = 'Set the rate limit admin on a CCIP token pool' + +/** + * Yargs builder for the pool set-rate-limit-admin subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pool owner)', + }) + .option('pool-address', { + type: 'string', + demandOption: true, + describe: 'Local pool address', + }) + .option('rate-limit-admin', { + type: 'string', + demandOption: true, + describe: 'Address of the new rate limit admin', + }) + .example([ + [ + 'ccip-cli pool set-rate-limit-admin -n sepolia --pool-address 0x... --rate-limit-admin 0x...', + 'Set the rate limit admin on a pool', + ], + ]) + +/** + * Handler for the pool set-rate-limit-admin subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doSetRateLimitAdmin(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type SetRateLimitAdminArgv = Awaited['argv']> & GlobalOpts + +/** Calls setRateLimitAdmin on the appropriate chain-family admin. */ +function setForChain(chain: Chain, wallet: unknown, params: SetRateLimitAdminParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.setRateLimitAdmin(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.setRateLimitAdmin(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.setRateLimitAdmin(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doSetRateLimitAdmin(ctx: Ctx, argv: SetRateLimitAdminArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const params: SetRateLimitAdminParams = { + poolAddress: argv.poolAddress, + rateLimitAdmin: argv.rateLimitAdmin, + } + + logger.debug( + `Setting rate limit admin: pool=${params.poolAddress}, admin=${params.rateLimitAdmin}`, + ) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await setForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: params.poolAddress, + rateLimitAdmin: params.rateLimitAdmin, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Rate limit admin updated, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/set-rate-limiter-config.ts b/ccip-cli/src/commands/pool/set-rate-limiter-config.ts new file mode 100644 index 00000000..f169318a --- /dev/null +++ b/ccip-cli/src/commands/pool/set-rate-limiter-config.ts @@ -0,0 +1,241 @@ +/** + * Pool set-rate-limiter-config subcommand. + * Updates rate limiter configurations on a CCIP token pool. + */ + +import { + type AptosChain, + type Chain, + type ChainRateLimiterConfig, + type EVMChain, + type RateLimiterConfig, + type SetChainRateLimiterConfigParams, + type SolanaChain, + CCIPArgumentInvalidError, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'set-rate-limiter-config' +export const describe = 'Update rate limiter configurations on a CCIP token pool' + +// ── Config file schema ── + +interface ConfigFile { + chainConfigs: Array<{ + remoteChainSelector: string + outboundRateLimiterConfig: RateLimiterConfig + inboundRateLimiterConfig: RateLimiterConfig + customBlockConfirmations?: boolean + }> +} + +// ── Generate config template ── + +const CONFIG_TEMPLATE: ConfigFile = { + chainConfigs: [ + { + remoteChainSelector: + '', + outboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + inboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + // customBlockConfirmations: true, + // ^ Faster-Than-Finality (FTF) — set to true to apply these rate limiters + // to the FTF (customBlockConfirmations) path. EVM v2.0+ pools only. + }, + ], +} + +/** + * Yargs builder for the pool set-rate-limiter-config subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pool owner or rate-limit admin)', + }) + .option('pool-address', { + type: 'string', + describe: 'Local pool address', + }) + .option('config', { + type: 'string', + describe: 'Path to JSON config file with rate limiter configurations', + }) + .option('generate-config', { + type: 'boolean', + describe: 'Output a sample JSON config template to stdout', + }) + .check((argv) => { + if (!argv.generateConfig) { + if (!argv.network) + throw new CCIPArgumentInvalidError('network', 'required argument missing') + if (!argv.poolAddress) + throw new CCIPArgumentInvalidError('pool-address', 'required argument missing') + } + return true + }) + .example([ + [ + 'ccip-cli pool set-rate-limiter-config -n sepolia --pool-address 0x... --config config.json', + 'Set rate limiter config from a config file', + ], + [ + 'ccip-cli pool set-rate-limiter-config --generate-config > config.json', + 'Generate a template config file', + ], + ]) + +/** + * Handler for the pool set-rate-limiter-config subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + // Handle --generate-config + if (argv.generateConfig) { + console.log(JSON.stringify(CONFIG_TEMPLATE, null, 2)) + return + } + + const [ctx, destroy] = getCtx(argv) + return doSetRateLimiterConfig(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type SetRateLimiterArgv = Awaited['argv']> & GlobalOpts + +/** Reads and parses config from file path or stdin. */ +async function readConfig(argv: SetRateLimiterArgv): Promise { + const { readFileSync } = await import('node:fs') + + if (argv.config) { + const raw = readFileSync(argv.config, 'utf8') + return JSON.parse(raw) as ConfigFile + } + + // Try stdin (piped input) + if (!process.stdin.isTTY) { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer) + } + const raw = Buffer.concat(chunks).toString('utf8') + return JSON.parse(raw) as ConfigFile + } + + throw new CCIPArgumentInvalidError( + 'config', + 'No config provided. Use --config or pipe JSON via stdin. Use --generate-config to see the expected format.', + ) +} + +/** + * Resolves a chain identifier (name, chainId, or selector) to a numeric selector string. + */ +function resolveChainSelector(input: string): bigint { + return networkInfo(input).chainSelector +} + +/** Converts a config file to SetChainRateLimiterConfigParams. */ +function configToParams(poolAddress: string, config: ConfigFile): SetChainRateLimiterConfigParams { + const chainConfigs: ChainRateLimiterConfig[] = config.chainConfigs.map((c) => ({ + remoteChainSelector: resolveChainSelector(c.remoteChainSelector), + outboundRateLimiterConfig: c.outboundRateLimiterConfig, + inboundRateLimiterConfig: c.inboundRateLimiterConfig, + customBlockConfirmations: c.customBlockConfirmations, + })) + + return { poolAddress, chainConfigs } +} + +/** Calls setChainRateLimiterConfig on the appropriate chain-family admin. */ +function setForChain(chain: Chain, wallet: unknown, params: SetChainRateLimiterConfigParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.setChainRateLimiterConfig(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.setChainRateLimiterConfig(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.setChainRateLimiterConfig(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doSetRateLimiterConfig(ctx: Ctx, argv: SetRateLimiterArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network!).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const config = await readConfig(argv) + const params = configToParams(argv.poolAddress!, config) + + logger.debug(`Setting rate limiter config: ${params.chainConfigs.length} chain config(s)`) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await setForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: argv.poolAddress!, + txHash: result.txHash, + chainsConfigured: String(params.chainConfigs.length), + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Rate limiter config updated, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/pool/transfer-ownership.ts b/ccip-cli/src/commands/pool/transfer-ownership.ts new file mode 100644 index 00000000..120672b9 --- /dev/null +++ b/ccip-cli/src/commands/pool/transfer-ownership.ts @@ -0,0 +1,141 @@ +/** + * Pool transfer-ownership subcommand. + * Proposes a new owner for a CCIP token pool (2-step ownership transfer). + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + type TransferOwnershipParams, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'transfer-ownership' +export const describe = 'Propose a new owner for a CCIP token pool (2-step ownership transfer)' + +/** + * Yargs builder for the pool transfer-ownership subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be current pool owner)', + }) + .option('pool-address', { + type: 'string', + demandOption: true, + describe: 'Pool address', + }) + .option('new-owner', { + type: 'string', + demandOption: true, + describe: 'Address of the proposed new owner', + }) + .example([ + [ + 'ccip-cli pool transfer-ownership -n sepolia --pool-address 0x... --new-owner 0x...', + 'Propose a new pool owner', + ], + ]) + +/** + * Handler for the pool transfer-ownership subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doTransferOwnership(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type TransferOwnershipArgv = Awaited['argv']> & GlobalOpts + +/** Calls transferOwnership on the appropriate chain-family admin. */ +function transferForChain(chain: Chain, wallet: unknown, params: TransferOwnershipParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.transferOwnership(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.transferOwnership(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.transferOwnership(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doTransferOwnership(ctx: Ctx, argv: TransferOwnershipArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const params: TransferOwnershipParams = { + poolAddress: argv.poolAddress, + newOwner: argv.newOwner, + } + + logger.debug(`Transferring ownership: pool=${params.poolAddress}, newOwner=${params.newOwner}`) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await transferForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + poolAddress: params.poolAddress, + newOwner: params.newOwner, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Ownership transfer proposed, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token-admin.ts b/ccip-cli/src/commands/token-admin.ts new file mode 100644 index 00000000..a7be3b75 --- /dev/null +++ b/ccip-cli/src/commands/token-admin.ts @@ -0,0 +1,24 @@ +/** + * Token admin operations command group. + * Dispatches to subcommands: propose-admin, accept-admin, get-config. + */ + +import type { Argv } from 'yargs' + +export const command = 'token-admin' +export const describe = + 'Token admin operations (propose-admin, accept-admin, transfer-admin, get-config, create-token-alt, set-pool)' + +/** + * Yargs builder for the token-admin command group. + * Loads subcommands from the `token-admin/` directory. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with subcommands. + */ +export const builder = (yargs: Argv) => + yargs + .commandDir('token-admin', { + extensions: [new URL(import.meta.url).pathname.split('.').pop()!], + exclude: /\.test\.[tj]s$/, + }) + .demandCommand(1) diff --git a/ccip-cli/src/commands/token-admin/accept-admin.ts b/ccip-cli/src/commands/token-admin/accept-admin.ts new file mode 100644 index 00000000..e8bca0d2 --- /dev/null +++ b/ccip-cli/src/commands/token-admin/accept-admin.ts @@ -0,0 +1,148 @@ +/** + * Accept admin subcommand. + * Accepts an administrator role for a token in the TokenAdminRegistry. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'accept-admin' +export const describe = 'Accept an administrator role for a token in the TokenAdminRegistry' + +/** + * Yargs builder for the accept-admin subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be pending administrator)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address to accept admin role for', + }) + .option('router-address', { + type: 'string', + demandOption: true, + describe: + 'CCIP Router address (EVM/Aptos: discovers registry; Solana: router is the registry)', + }) + .example([ + [ + 'ccip-cli token-admin accept-admin -n ethereum-testnet-sepolia --token-address 0xa42B... --router-address 0x0BF3...', + 'Accept admin on Sepolia', + ], + [ + 'ccip-cli token-admin accept-admin -n solana-devnet --wallet ~/.config/solana/id.json --token-address J6fE... --router-address Ccip...', + 'Accept admin on Solana devnet', + ], + [ + 'ccip-cli token-admin accept-admin -n aptos-testnet --token-address 0x89fd... --router-address 0xc748...', + 'Accept admin on Aptos testnet', + ], + ]) + +/** + * Handler for the accept-admin subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doAcceptAdmin(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type AcceptArgv = Awaited['argv']> & GlobalOpts + +/** Accepts admin using the appropriate chain-family admin with typed params. */ +function acceptAdminForChain(chain: Chain, wallet: unknown, argv: AcceptArgv) { + const params = { + tokenAddress: argv.tokenAddress, + routerAddress: argv.routerAddress, + } + + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.acceptAdminRole(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.acceptAdminRole(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.acceptAdminRole(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doAcceptAdmin(ctx: Ctx, argv: AcceptArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await acceptAdminForChain(chain, wallet, argv) + + const output: Record = { + network: networkName, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Admin accepted, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token-admin/create-token-alt.ts b/ccip-cli/src/commands/token-admin/create-token-alt.ts new file mode 100644 index 00000000..71ef4f7f --- /dev/null +++ b/ccip-cli/src/commands/token-admin/create-token-alt.ts @@ -0,0 +1,135 @@ +/** + * Token-admin create-token-alt subcommand (Solana only). + * Creates an Address Lookup Table (ALT) with CCIP base addresses for a token's pool. + */ + +import { + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'create-token-alt' +export const describe = 'Create Address Lookup Table for a token pool (Solana only)' + +/** + * Yargs builder for the token-admin create-token-alt subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., solana-devnet)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'SPL token mint address (base58)', + }) + .option('pool-address', { + type: 'string', + demandOption: true, + describe: 'Pool state PDA (base58). SDK derives pool program ID from its on-chain owner.', + }) + .option('router-address', { + type: 'string', + demandOption: true, + describe: 'CCIP Router program ID (base58). SDK discovers feeQuoter from config.', + }) + .option('authority', { + type: 'string', + describe: + 'ALT authority (base58). Defaults to wallet. Can differ for multisig setups (e.g., Squads vault).', + }) + .option('additional-addresses', { + type: 'array', + string: true, + describe: + 'Extra addresses for ALT (e.g., SPL Token Multisig address when using multisig mint authority for burn-mint pools)', + }) + .example([ + [ + 'ccip-cli token-admin create-token-alt -n solana-devnet --token-address J6fE... --pool-address 2pGY... --router-address Ccip...', + 'Create ALT with 10 base CCIP addresses', + ], + [ + 'ccip-cli token-admin create-token-alt -n solana-devnet --token-address J6fE... --pool-address 2pGY... --router-address Ccip... --additional-addresses 6c5U...', + 'Create ALT with base addresses + SPL Token Multisig', + ], + ]) + +/** + * Handler for the token-admin create-token-alt subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doCreateTokenAlt(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type CreateTokenAltArgv = Awaited['argv']> & GlobalOpts + +async function doCreateTokenAlt(ctx: Ctx, argv: CreateTokenAltArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + if (chain.network.family !== ChainFamily.Solana) { + throw new CCIPChainFamilyUnsupportedError(chain.network.family, { + context: { reason: 'create-token-alt is only supported on Solana' }, + }) + } + + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await admin.createTokenAlt(wallet, { + tokenAddress: argv.tokenAddress, + poolAddress: argv.poolAddress, + routerAddress: argv.routerAddress, + ...(argv.authority && { authority: argv.authority }), + ...(argv.additionalAddresses && { additionalAddresses: argv.additionalAddresses }), + }) + + const output: Record = { + network: networkName, + lookupTableAddress: result.lookupTableAddress, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('ALT created:', result.lookupTableAddress, 'tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token-admin/get-config.ts b/ccip-cli/src/commands/token-admin/get-config.ts new file mode 100644 index 00000000..d0db008f --- /dev/null +++ b/ccip-cli/src/commands/token-admin/get-config.ts @@ -0,0 +1,141 @@ +/** + * Get config subcommand. + * Queries the TokenAdminRegistry for a token's admin configuration. + */ + +import { + type Chain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'get-config' +export const describe = 'Query token admin configuration from the TokenAdminRegistry' + +/** + * Yargs builder for the get-config subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address to query config for', + }) + .option('router-address', { + type: 'string', + demandOption: true, + describe: + 'CCIP Router address (EVM/Aptos: discovers registry; Solana: router is the registry)', + }) + .example([ + [ + 'ccip-cli token-admin get-config -n ethereum-testnet-sepolia --token-address 0xa42B... --router-address 0x0BF3...', + 'Query token admin config on Sepolia', + ], + [ + 'ccip-cli token-admin get-config -n solana-devnet --token-address J6fE... --router-address Ccip...', + 'Query token admin config on Solana devnet', + ], + ]) + +/** + * Handler for the get-config subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doGetConfig(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type GetConfigArgv = Awaited['argv']> & GlobalOpts + +/** Queries the TokenAdminRegistry for a token's config. */ +async function getConfigForChain(chain: Chain, argv: GetConfigArgv) { + switch (chain.network.family) { + case ChainFamily.EVM: + case ChainFamily.Solana: + case ChainFamily.Aptos: { + const registryAddress = await chain.getTokenAdminRegistryFor(argv.routerAddress) + return { + registryAddress, + ...(await chain.getRegistryTokenConfig(registryAddress, argv.tokenAddress)), + } + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doGetConfig(ctx: Ctx, argv: GetConfigArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const result = await getConfigForChain(chain, argv) + + const output: Record = { + network: networkName, + registryAddress: result.registryAddress, + administrator: result.administrator, + } + if (result.pendingAdministrator) { + output.pendingAdministrator = result.pendingAdministrator + } + if (result.tokenPool) { + output.tokenPool = result.tokenPool + } + if (result.poolLookupTable) { + output.poolLookupTable = result.poolLookupTable + } + + switch (argv.format) { + case Format.json: + logger.log( + JSON.stringify( + result.poolLookupTableEntries + ? { ...output, poolLookupTableEntries: result.poolLookupTableEntries } + : output, + null, + 2, + ), + ) + return + case Format.log: + logger.log('administrator:', result.administrator) + if (result.pendingAdministrator) + logger.log('pendingAdministrator:', result.pendingAdministrator) + if (result.tokenPool) logger.log('tokenPool:', result.tokenPool) + if (output.poolLookupTable) logger.log('poolLookupTable:', output.poolLookupTable) + if (result.poolLookupTableEntries) { + logger.log('poolLookupTableEntries:') + result.poolLookupTableEntries.forEach((entry, i) => logger.log(` [${i}]: ${entry}`)) + } + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token-admin/propose-admin.ts b/ccip-cli/src/commands/token-admin/propose-admin.ts new file mode 100644 index 00000000..afd0a80d --- /dev/null +++ b/ccip-cli/src/commands/token-admin/propose-admin.ts @@ -0,0 +1,183 @@ +/** + * Propose admin subcommand. + * Proposes an administrator for a token in the TokenAdminRegistry. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { + type EVMRegistrationMethod, + EVMTokenAdmin, +} from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'propose-admin' +export const describe = 'Propose an administrator for a token in the TokenAdminRegistry' + +/** + * Yargs builder for the propose-admin subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be token owner)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address to propose admin for', + }) + // Solana & Aptos only — on EVM the admin is always the caller + .option('administrator', { + type: 'string', + describe: 'Address of the proposed administrator (Solana, Aptos only)', + }) + // EVM-specific + .option('registry-module-address', { + type: 'string', + describe: 'RegistryModuleOwnerCustom address (EVM only, from CCIP API registryModule field)', + }) + .option('registration-method', { + type: 'string', + choices: ['owner', 'get-ccip-admin', 'access-control-default-admin'] as const, + default: 'owner', + describe: 'EVM registration method (EVM only)', + }) + // Solana & Aptos + .option('router-address', { + type: 'string', + describe: 'CCIP Router address (Solana, Aptos)', + }) + .example([ + [ + 'ccip-cli token-admin propose-admin -n ethereum-testnet-sepolia --token-address 0xa42B... --registry-module-address 0xa3c7...', + 'Propose admin on Sepolia (owner method, default)', + ], + [ + 'ccip-cli token-admin propose-admin -n ethereum-testnet-sepolia --token-address 0xa42B... --registry-module-address 0xa3c7... --registration-method get-ccip-admin', + 'Propose admin via getCCIPAdmin method', + ], + [ + 'ccip-cli token-admin propose-admin -n solana-devnet --wallet ~/.config/solana/id.json --token-address J6fE... --administrator 5YNm... --router-address Ccip...', + 'Propose admin on Solana devnet', + ], + [ + 'ccip-cli token-admin propose-admin -n aptos-testnet --token-address 0x89fd... --administrator 0x0650... --router-address 0xc748...', + 'Propose admin on Aptos testnet', + ], + ]) + +/** + * Handler for the propose-admin subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doProposeAdmin(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type ProposeArgv = Awaited['argv']> & GlobalOpts + +/** Proposes an admin using the appropriate chain-family admin with typed params. */ +function proposeAdminForChain(chain: Chain, wallet: unknown, argv: ProposeArgv) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + // Map CLI kebab-case to SDK type + const cliToSdk: Record = { + owner: 'owner', + 'get-ccip-admin': 'getCCIPAdmin', + 'access-control-default-admin': 'accessControlDefaultAdmin', + } + return admin.proposeAdminRole(wallet, { + tokenAddress: argv.tokenAddress, + registryModuleAddress: argv.registryModuleAddress!, + registrationMethod: cliToSdk[argv.registrationMethod], + }) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.proposeAdminRole(wallet, { + tokenAddress: argv.tokenAddress, + administrator: argv.administrator!, + routerAddress: argv.routerAddress!, + }) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.proposeAdminRole(wallet, { + tokenAddress: argv.tokenAddress, + administrator: argv.administrator!, + routerAddress: argv.routerAddress!, + }) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doProposeAdmin(ctx: Ctx, argv: ProposeArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await proposeAdminForChain(chain, wallet, argv) + + const output: Record = { + network: networkName, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Admin proposed, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token-admin/set-pool.ts b/ccip-cli/src/commands/token-admin/set-pool.ts new file mode 100644 index 00000000..a6b0dc74 --- /dev/null +++ b/ccip-cli/src/commands/token-admin/set-pool.ts @@ -0,0 +1,164 @@ +/** + * Set pool subcommand. + * Registers a pool in the TokenAdminRegistry for a token. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPArgumentInvalidError, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'set-pool' +export const describe = 'Register a pool in the TokenAdminRegistry for a token' + +/** + * Yargs builder for the set-pool subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be token administrator)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address to register pool for', + }) + .option('pool-address', { + type: 'string', + demandOption: true, + describe: 'Pool address to register', + }) + .option('router-address', { + type: 'string', + demandOption: true, + describe: + 'CCIP Router address (EVM/Aptos: discovers registry; Solana: router is the registry)', + }) + .option('pool-lookup-table', { + type: 'string', + describe: 'Address Lookup Table (Solana only, required)', + }) + .example([ + [ + 'ccip-cli token-admin set-pool -n ethereum-testnet-sepolia --token-address 0xa42B... --pool-address 0xd7BF... --router-address 0x0BF3...', + 'Set pool on Sepolia', + ], + [ + 'ccip-cli token-admin set-pool -n solana-devnet --wallet ~/.config/solana/id.json --token-address J6fE... --pool-address 99Ux... --router-address Ccip... --pool-lookup-table C6jB...', + 'Set pool on Solana devnet', + ], + [ + 'ccip-cli token-admin set-pool -n aptos-testnet --token-address 0x89fd... --pool-address 0xeb63... --router-address 0xc748...', + 'Set pool on Aptos testnet', + ], + ]) + +/** + * Handler for the set-pool subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doSetPool(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type SetPoolArgv = Awaited['argv']> & GlobalOpts + +/** Sets pool using the appropriate chain-family admin with typed params. */ +function setPoolForChain(chain: Chain, wallet: unknown, argv: SetPoolArgv) { + const baseParams = { + tokenAddress: argv.tokenAddress, + poolAddress: argv.poolAddress, + routerAddress: argv.routerAddress, + } + + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.setPool(wallet, baseParams) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + if (!argv.poolLookupTable) { + throw new CCIPArgumentInvalidError('pool-lookup-table', 'required for Solana') + } + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.setPool(wallet, { ...baseParams, poolLookupTable: argv.poolLookupTable }) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.setPool(wallet, baseParams) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doSetPool(ctx: Ctx, argv: SetPoolArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await setPoolForChain(chain, wallet, argv) + + const output: Record = { + network: networkName, + tokenAddress: argv.tokenAddress, + poolAddress: argv.poolAddress, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Pool registered, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token-admin/transfer-admin.ts b/ccip-cli/src/commands/token-admin/transfer-admin.ts new file mode 100644 index 00000000..d23127cb --- /dev/null +++ b/ccip-cli/src/commands/token-admin/transfer-admin.ts @@ -0,0 +1,155 @@ +/** + * Transfer admin subcommand. + * Transfers the administrator role for a token in the TokenAdminRegistry to a new address. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'transfer-admin' +export const describe = + 'Transfer the administrator role for a token in the TokenAdminRegistry to a new address' + +/** + * Yargs builder for the transfer-admin subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be current administrator)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address to transfer admin role for', + }) + .option('new-admin', { + type: 'string', + demandOption: true, + describe: 'Address of the new administrator', + }) + .option('router-address', { + type: 'string', + demandOption: true, + describe: + 'CCIP Router address (EVM/Aptos: discovers registry; Solana: router is the registry)', + }) + .example([ + [ + 'ccip-cli token-admin transfer-admin -n ethereum-testnet-sepolia --token-address 0xa42B... --new-admin 0x1234... --router-address 0x0BF3...', + 'Transfer admin on Sepolia', + ], + [ + 'ccip-cli token-admin transfer-admin -n solana-devnet --wallet ~/.config/solana/id.json --token-address J6fE... --new-admin 5y76... --router-address Ccip...', + 'Transfer admin on Solana devnet', + ], + [ + 'ccip-cli token-admin transfer-admin -n aptos-testnet --token-address 0x89fd... --new-admin 0xabe0... --router-address 0xc748...', + 'Transfer admin on Aptos testnet', + ], + ]) + +/** + * Handler for the transfer-admin subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doTransferAdmin(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type TransferArgv = Awaited['argv']> & GlobalOpts + +/** Transfers admin using the appropriate chain-family admin with typed params. */ +function transferAdminForChain(chain: Chain, wallet: unknown, argv: TransferArgv) { + const params = { + tokenAddress: argv.tokenAddress, + newAdmin: argv.newAdmin, + routerAddress: argv.routerAddress, + } + + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.transferAdminRole(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.transferAdminRole(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.transferAdminRole(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doTransferAdmin(ctx: Ctx, argv: TransferArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await transferAdminForChain(chain, wallet, argv) + + const output: Record = { + network: networkName, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Admin transferred, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token.ts b/ccip-cli/src/commands/token.ts index 7d7c38ba..34af0a76 100644 --- a/ccip-cli/src/commands/token.ts +++ b/ccip-cli/src/commands/token.ts @@ -1,132 +1,24 @@ /** - * Token balance query command. - * Queries native or token balance for an address. + * Token operations command group. + * Dispatches to subcommands: balance (default), deploy. */ -import { type ChainStatic, networkInfo } from '@chainlink/ccip-sdk/src/index.ts' -import { formatUnits } from 'ethers' import type { Argv } from 'yargs' -import { type Ctx, Format } from './types.ts' -import { getCtx, logParsedError, prettyTable } from './utils.ts' -import type { GlobalOpts } from '../index.ts' -import { fetchChainsFromRpcs } from '../providers/index.ts' - export const command = 'token' -export const describe = 'Query token balance for an address' +export const describe = + 'Token operations (balance, deploy, create-multisig, transfer-mint-authority, grant-mint-burn-access, revoke-mint-burn-access, get-mint-burn-info)' /** - * Yargs builder for the token command. + * Yargs builder for the token command group. + * Loads subcommands from the `token/` directory. * @param yargs - Yargs instance. - * @returns Configured yargs instance with command options. + * @returns Configured yargs instance with subcommands. */ export const builder = (yargs: Argv) => yargs - .option('network', { - alias: 'n', - type: 'string', - demandOption: true, - describe: 'Network: chainId or name (e.g., ethereum-mainnet, solana-devnet)', - }) - .option('holder', { - alias: 'H', - type: 'string', - demandOption: true, - describe: 'Wallet address to query balance for', - }) - .option('token', { - alias: 't', - type: 'string', - demandOption: false, - describe: 'Token address (omit for native token balance)', - }) - .example([ - ['ccip-cli token -n ethereum-mainnet -H 0x1234...abcd', 'Query native ETH balance'], - [ - 'ccip-cli token -n ethereum-mainnet -H 0x1234... -t 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', - 'Query USDC token balance', - ], - [ - 'ccip-cli token -n solana-devnet -H EPUjBP3Xf76K1VKsDSc6GupBWE8uykNksCLJgXZn87CB', - 'Query native SOL balance', - ], - ]) - -/** - * Handler for the token command. - * @param argv - Command line arguments. - */ -export async function handler(argv: Awaited['argv']> & GlobalOpts) { - const [ctx, destroy] = getCtx(argv) - return queryTokenBalance(ctx, argv) - .catch((err) => { - process.exitCode = 1 - if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + .commandDir('token', { + extensions: [new URL(import.meta.url).pathname.split('.').pop()!], + exclude: /\.test\.[tj]s$/, }) - .finally(destroy) -} - -async function queryTokenBalance(ctx: Ctx, argv: Parameters[0]) { - const { logger } = ctx - const networkName = networkInfo(argv.network).name - const getChain = fetchChainsFromRpcs(ctx, argv) - const chain = await getChain(networkName) - - const balance = await chain.getBalance({ - holder: argv.holder, - token: argv.token, - }) - - // Get token info for formatting (only for tokens, not native) - let tokenInfo - if (argv.token) { - argv.token = (chain.constructor as ChainStatic).getAddress(argv.token) - tokenInfo = await chain.getTokenInfo(argv.token) - } - - const tokenLabel = tokenInfo?.symbol ?? 'native' - const formatted = formatUnits( - balance, - tokenInfo ? tokenInfo.decimals : (chain.constructor as ChainStatic).decimals, - ) - - switch (argv.format) { - case Format.json: - logger.log( - JSON.stringify( - { - network: networkName, - holder: argv.holder, - token: tokenLabel, - balance: balance.toString(), - formatted, - ...tokenInfo, - }, - null, - 2, - ), - ) - return - case Format.log: - logger.log( - `Balance of`, - tokenInfo ? argv.token : tokenLabel, - ':', - balance, - `=`, - tokenInfo ? `${formatted} ${tokenLabel}` : formatted, - ) - return - case Format.pretty: - default: - prettyTable.call(ctx, { - network: networkName, - holder: argv.holder, - token: argv.token ?? tokenLabel, - balance, - formatted, - ...tokenInfo, - }) - return - } -} + .demandCommand(0) diff --git a/ccip-cli/src/commands/token/balance.ts b/ccip-cli/src/commands/token/balance.ts new file mode 100644 index 00000000..ba7c6e87 --- /dev/null +++ b/ccip-cli/src/commands/token/balance.ts @@ -0,0 +1,132 @@ +/** + * Token balance query subcommand (default for `ccip-cli token`). + * Queries native or token balance for an address. + */ + +import { type ChainStatic, networkInfo } from '@chainlink/ccip-sdk/src/index.ts' +import { formatUnits } from 'ethers' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = '$0' +export const describe = 'Query token balance for an address' + +/** + * Yargs builder for the token balance subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-mainnet, solana-devnet)', + }) + .option('holder', { + alias: 'H', + type: 'string', + demandOption: true, + describe: 'Wallet address to query balance for', + }) + .option('token', { + alias: 't', + type: 'string', + demandOption: false, + describe: 'Token address (omit for native token balance)', + }) + .example([ + ['ccip-cli token -n ethereum-mainnet -H 0x1234...abcd', 'Query native ETH balance'], + [ + 'ccip-cli token -n ethereum-mainnet -H 0x1234... -t 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + 'Query USDC token balance', + ], + [ + 'ccip-cli token -n solana-devnet -H EPUjBP3Xf76K1VKsDSc6GupBWE8uykNksCLJgXZn87CB', + 'Query native SOL balance', + ], + ]) + +/** + * Handler for the token balance subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return queryTokenBalance(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +async function queryTokenBalance(ctx: Ctx, argv: Parameters[0]) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const balance = await chain.getBalance({ + holder: argv.holder, + token: argv.token, + }) + + // Get token info for formatting (only for tokens, not native) + let tokenInfo + if (argv.token) { + argv.token = (chain.constructor as ChainStatic).getAddress(argv.token) + tokenInfo = await chain.getTokenInfo(argv.token) + } + + const tokenLabel = tokenInfo?.symbol ?? 'native' + const formatted = formatUnits( + balance, + tokenInfo ? tokenInfo.decimals : (chain.constructor as ChainStatic).decimals, + ) + + switch (argv.format) { + case Format.json: + logger.log( + JSON.stringify( + { + network: networkName, + holder: argv.holder, + token: tokenLabel, + balance: balance.toString(), + formatted, + ...tokenInfo, + }, + null, + 2, + ), + ) + return + case Format.log: + logger.log( + `Balance of`, + tokenInfo ? argv.token : tokenLabel, + ':', + balance, + `=`, + tokenInfo ? `${formatted} ${tokenLabel}` : formatted, + ) + return + case Format.pretty: + default: + prettyTable.call(ctx, { + network: networkName, + holder: argv.holder, + token: argv.token ?? tokenLabel, + balance, + formatted, + ...tokenInfo, + }) + return + } +} diff --git a/ccip-cli/src/commands/token/create-multisig.ts b/ccip-cli/src/commands/token/create-multisig.ts new file mode 100644 index 00000000..2cfd1ca1 --- /dev/null +++ b/ccip-cli/src/commands/token/create-multisig.ts @@ -0,0 +1,133 @@ +/** + * Pool create-multisig subcommand (Solana only). + * Creates an SPL Token native multisig with the Pool Signer PDA auto-included. + */ + +import { + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'create-multisig' +export const describe = 'Create SPL Token multisig with Pool Signer PDA (Solana only)' + +/** + * Yargs builder for the token create-multisig subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., solana-devnet)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key', + }) + .option('mint', { + alias: 'token-address', + type: 'string', + demandOption: true, + describe: 'SPL token mint address (base58)', + }) + .option('pool-program-id', { + type: 'string', + demandOption: true, + describe: 'Pool program ID for PDA derivation', + }) + .option('additional-signers', { + type: 'array', + string: true, + demandOption: true, + describe: 'Additional signer pubkeys (Pool Signer PDA is auto-included)', + }) + .option('threshold', { + type: 'number', + demandOption: true, + describe: 'Required number of signers (m-of-n)', + }) + .option('seed', { + type: 'string', + describe: 'Optional seed for deterministic multisig address derivation', + }) + .example([ + [ + 'ccip-cli token create-multisig -n solana-devnet --mint J6fE... --pool-program-id 41FG... --additional-signers 59eN... --threshold 1', + 'Create multisig with Pool Signer PDA + one additional signer, threshold 1', + ], + ]) + +/** + * Handler for the token create-multisig subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doCreateMultisig(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type CreateMultisigArgv = Awaited['argv']> & GlobalOpts + +async function doCreateMultisig(ctx: Ctx, argv: CreateMultisigArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + if (chain.network.family !== ChainFamily.Solana) { + throw new CCIPChainFamilyUnsupportedError(chain.network.family, { + context: { reason: 'create-multisig is only supported on Solana' }, + }) + } + + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await admin.createPoolMintAuthorityMultisig(wallet, { + mint: argv.mint, + poolProgramId: argv.poolProgramId, + additionalSigners: argv.additionalSigners, + threshold: argv.threshold, + ...(argv.seed && { seed: argv.seed }), + }) + + const output: Record = { + network: networkName, + multisigAddress: result.multisigAddress, + poolSignerPda: result.poolSignerPda, + allSigners: result.allSigners, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Multisig created:', result.multisigAddress, 'tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, { ...output, allSigners: result.allSigners.join(', ') }) + return + } +} diff --git a/ccip-cli/src/commands/token/deploy.test.ts b/ccip-cli/src/commands/token/deploy.test.ts new file mode 100644 index 00000000..0ef62e53 --- /dev/null +++ b/ccip-cli/src/commands/token/deploy.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import * as balance from './balance.ts' +import * as deploy from './deploy.ts' + +// ============================================================================= +// Module shape +// ============================================================================= + +describe('token deploy — module shape', () => { + it('should export command as "deploy"', () => { + assert.equal(deploy.command, 'deploy') + }) + + it('should export a describe string', () => { + assert.equal(typeof deploy.describe, 'string') + assert.ok(deploy.describe.length > 0) + }) + + it('should export a builder function', () => { + assert.equal(typeof deploy.builder, 'function') + }) + + it('should export a handler function', () => { + assert.equal(typeof deploy.handler, 'function') + }) +}) + +// ============================================================================= +// token balance — module shape (backward compat) +// ============================================================================= + +describe('token balance — module shape', () => { + it('should export command as "$0" (default subcommand)', () => { + assert.equal(balance.command, '$0') + }) + + it('should export a describe string', () => { + assert.equal(typeof balance.describe, 'string') + }) + + it('should export a builder function', () => { + assert.equal(typeof balance.builder, 'function') + }) + + it('should export a handler function', () => { + assert.equal(typeof balance.handler, 'function') + }) +}) diff --git a/ccip-cli/src/commands/token/deploy.ts b/ccip-cli/src/commands/token/deploy.ts new file mode 100644 index 00000000..2e98b25e --- /dev/null +++ b/ccip-cli/src/commands/token/deploy.ts @@ -0,0 +1,214 @@ +/** + * Token deploy subcommand. + * Deploys a new CCIP-compatible token (BurnMintERC20 / SPL mint / managed_token). + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import { parseUnits } from 'ethers' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'deploy' +export const describe = 'Deploy a new CCIP-compatible token' + +/** + * Yargs builder for the token deploy subcommand. + * @param yargs - Yargs instance. + * @returns Configured yargs instance with command options. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia, solana-devnet)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key', + }) + .option('name', { + type: 'string', + demandOption: true, + describe: 'Token name', + }) + .option('symbol', { + type: 'string', + demandOption: true, + describe: 'Token symbol', + }) + .option('decimals', { + type: 'number', + demandOption: true, + describe: 'Token decimals', + }) + .option('max-supply', { + type: 'string', + describe: 'Max supply in whole units (omit for unlimited; Solana: must fit in u64)', + }) + .option('initial-supply', { + type: 'string', + default: '0', + describe: 'Initial supply in whole units (Solana: must fit in u64)', + }) + // EVM-specific + .option('token-type', { + type: 'string', + choices: ['burnMintERC20', 'factoryBurnMintERC20'] as const, + default: 'burnMintERC20', + describe: 'EVM token contract type (EVM only)', + }) + .option('owner', { + type: 'string', + describe: 'Owner address for FactoryBurnMintERC20 (defaults to signer; EVM only)', + }) + // Solana-specific + .option('token-program', { + type: 'string', + choices: ['spl-token', 'token-2022'] as const, + default: 'spl-token', + describe: 'Solana token program (Solana only)', + }) + .option('metadata-uri', { + type: 'string', + describe: 'Metaplex metadata JSON URI (Solana only)', + }) + // Aptos-specific + .option('icon', { + type: 'string', + describe: 'Token icon URI (Aptos only)', + }) + .option('project', { + type: 'string', + describe: 'Project URL (Aptos only)', + }) + .example([ + [ + 'ccip-cli token deploy -n ethereum-testnet-sepolia --name "My Token" --symbol MTK --decimals 18', + 'Deploy ERC20 on Sepolia', + ], + [ + 'ccip-cli token deploy -n solana-devnet --name "My Token" --symbol MTK --decimals 9', + 'Deploy SPL token on Solana devnet', + ], + [ + 'ccip-cli token deploy -n aptos-testnet --name "My Token" --symbol MTK --decimals 8', + 'Deploy managed_token on Aptos testnet', + ], + ]) + +/** + * Handler for the token deploy subcommand. + * @param argv - Command line arguments. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doDeployToken(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +function getTokenAdmin(chain: Chain) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + return new EVMTokenAdmin(evmChain.provider, evmChain.network, { logger: evmChain.logger }) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + return new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + return new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doDeployToken( + ctx: Ctx, + argv: Awaited['argv']> & GlobalOpts, +) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const [, wallet] = await loadChainWallet(chain, argv) + const admin = getTokenAdmin(chain) + + const maxSupply = argv.maxSupply ? parseUnits(argv.maxSupply, argv.decimals) : undefined + const initialSupply = + argv.initialSupply !== '0' ? parseUnits(argv.initialSupply, argv.decimals) : undefined + + const result = await admin.deployToken(wallet, { + name: argv.name, + symbol: argv.symbol, + decimals: argv.decimals, + maxSupply, + initialSupply, + // EVM-specific + ...(argv.tokenType && { + tokenType: argv.tokenType as 'burnMintERC20' | 'factoryBurnMintERC20', + }), + ...(argv.owner && { ownerAddress: argv.owner }), + // Solana-specific + ...(argv.tokenProgram && { + tokenProgram: argv.tokenProgram as 'spl-token' | 'token-2022', + }), + ...(argv.metadataUri && { metadataUri: argv.metadataUri }), + // Aptos-specific + ...(argv.icon && { icon: argv.icon }), + ...(argv.project && { project: argv.project }), + }) + + // Build output object, including chain-specific optional fields when present + const output: Record = { + network: networkName, + tokenAddress: result.tokenAddress, + txHash: result.txHash, + } + if (result.codeObjectAddress) output.codeObjectAddress = result.codeObjectAddress + if (result.metadataAddress) output.metadataAddress = result.metadataAddress + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Token deployed:', result.tokenAddress, 'tx:', result.txHash) + if (result.codeObjectAddress) logger.log('Code object:', result.codeObjectAddress) + if (result.metadataAddress) logger.log('Metadata:', result.metadataAddress) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token/get-mint-burn-info.ts b/ccip-cli/src/commands/token/get-mint-burn-info.ts new file mode 100644 index 00000000..8a2d8b7e --- /dev/null +++ b/ccip-cli/src/commands/token/get-mint-burn-info.ts @@ -0,0 +1,270 @@ +/** + * Token get-mint-burn-info subcommand. + * Read-only command that shows mint/burn role holders on a token. + */ + +import { + type AptosChain, + type EVMChain, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'get-mint-burn-info' +export const describe = 'Show mint/burn role holders on a token (read-only)' + +/** + * Yargs builder for the token get-mint-burn-info subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia, solana-devnet)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address (EVM contract, Solana mint, Aptos FA metadata)', + }) + .example([ + [ + 'ccip-cli token get-mint-burn-info -n sepolia --token-address 0x...', + 'Show minters and burners on an EVM BurnMintERC20', + ], + [ + 'ccip-cli token get-mint-burn-info -n solana-devnet --token-address J6fE...', + 'Show Solana mint authority and multisig members', + ], + [ + 'ccip-cli token get-mint-burn-info -n aptos-testnet --token-address 0x...', + 'Show Aptos managed/regulated token roles', + ], + ]) + +/** + * Handler for the token get-mint-burn-info subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doGetMintBurnInfo(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type GetMintBurnInfoArgv = Awaited['argv']> & GlobalOpts + +async function doGetMintBurnInfo(ctx: Ctx, argv: GetMintBurnInfoArgv) { + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const tokenAddress = argv.tokenAddress + + switch (chain.network.family) { + case ChainFamily.EVM: + return handleEVM(ctx, chain as EVMChain, networkName, tokenAddress, argv) + case ChainFamily.Solana: + return handleSolana(ctx, chain as SolanaChain, networkName, tokenAddress, argv) + case ChainFamily.Aptos: + return handleAptos(ctx, chain as AptosChain, networkName, tokenAddress, argv) + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function handleEVM( + ctx: Ctx, + chain: EVMChain, + networkName: string, + tokenAddress: string, + argv: GetMintBurnInfoArgv, +) { + const admin = new EVMTokenAdmin(chain.provider, chain.network, { logger: chain.logger }) + const result = await admin.getMintBurnRoles(tokenAddress) + + const output = { + network: networkName, + tokenAddress, + minters: result.minters, + burners: result.burners, + } + + switch (argv.format) { + case Format.json: + ctx.logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + ctx.logger.log('Token:', tokenAddress) + ctx.logger.log('Minters:', result.minters.length ? result.minters.join(', ') : '(none)') + ctx.logger.log('Burners:', result.burners.length ? result.burners.join(', ') : '(none)') + return + case Format.pretty: + default: + prettyTable.call(ctx, { + network: networkName, + tokenAddress, + ['minters (' + result.minters.length + ')']: result.minters.length + ? result.minters.join('\n') + : '(none)', + ['burners (' + result.burners.length + ')']: result.burners.length + ? result.burners.join('\n') + : '(none)', + }) + return + } +} + +async function handleSolana( + ctx: Ctx, + chain: SolanaChain, + networkName: string, + tokenAddress: string, + argv: GetMintBurnInfoArgv, +) { + const admin = new SolanaTokenAdmin(chain.connection, chain.network, { logger: chain.logger }) + const result = await admin.getMintBurnRoles({ tokenAddress }) + + const output: Record = { + network: networkName, + tokenAddress, + mintAuthority: result.mintAuthority ?? '(disabled)', + isMultisig: result.isMultisig, + } + + if (result.isMultisig) { + output.multisigThreshold = `${result.multisigThreshold}-of-${result.multisigMembers!.length}` + output.multisigMembers = result.multisigMembers + } + + switch (argv.format) { + case Format.json: + ctx.logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + ctx.logger.log('Token:', tokenAddress) + ctx.logger.log('Mint Authority:', result.mintAuthority ?? '(disabled)') + if (result.isMultisig) { + ctx.logger.log( + 'Multisig:', + `${result.multisigThreshold}-of-${result.multisigMembers!.length}`, + ) + for (const member of result.multisigMembers!) { + ctx.logger.log(' -', member.address) + } + } + return + case Format.pretty: + default: { + const table: Record = { + network: networkName, + tokenAddress, + mintAuthority: result.mintAuthority ?? '(disabled)', + } + if (result.isMultisig) { + table.multisigThreshold = `${result.multisigThreshold}-of-${result.multisigMembers!.length}` + for (let i = 0; i < result.multisigMembers!.length; i++) { + table[`member[${i}]`] = result.multisigMembers![i]!.address + } + } + prettyTable.call(ctx, table) + return + } + } +} + +async function handleAptos( + ctx: Ctx, + chain: AptosChain, + networkName: string, + tokenAddress: string, + argv: GetMintBurnInfoArgv, +) { + const admin = new AptosTokenAdmin(chain.provider, chain.network, { logger: chain.logger }) + const result = await admin.getMintBurnRoles(tokenAddress) + + const output: Record = { + network: networkName, + tokenAddress, + tokenModule: result.tokenModule, + } + + if (result.owner) output.owner = result.owner + if (result.allowedMinters) output.allowedMinters = result.allowedMinters + if (result.allowedBurners) output.allowedBurners = result.allowedBurners + if (result.bridgeMintersOrBurners) output.bridgeMintersOrBurners = result.bridgeMintersOrBurners + + switch (argv.format) { + case Format.json: + ctx.logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + ctx.logger.log('Token:', tokenAddress) + ctx.logger.log('Module:', result.tokenModule) + if (result.owner) ctx.logger.log('Owner:', result.owner) + if (result.allowedMinters) { + ctx.logger.log( + 'Minters:', + result.allowedMinters.length ? result.allowedMinters.join(', ') : '(none)', + ) + } + if (result.allowedBurners) { + ctx.logger.log( + 'Burners:', + result.allowedBurners.length ? result.allowedBurners.join(', ') : '(none)', + ) + } + if (result.bridgeMintersOrBurners) { + ctx.logger.log( + 'Bridge Minters/Burners:', + result.bridgeMintersOrBurners.length + ? result.bridgeMintersOrBurners.join(', ') + : '(none)', + ) + } + return + case Format.pretty: + default: { + const table: Record = { + network: networkName, + tokenAddress, + tokenModule: result.tokenModule, + } + if (result.owner) table.owner = result.owner + if (result.allowedMinters) { + table['minters (' + result.allowedMinters.length + ')'] = result.allowedMinters.length + ? result.allowedMinters.join('\n') + : '(none)' + } + if (result.allowedBurners) { + table['burners (' + result.allowedBurners.length + ')'] = result.allowedBurners.length + ? result.allowedBurners.join('\n') + : '(none)' + } + if (result.bridgeMintersOrBurners) { + table['bridge minters/burners (' + result.bridgeMintersOrBurners.length + ')'] = result + .bridgeMintersOrBurners.length + ? result.bridgeMintersOrBurners.join('\n') + : '(none)' + } + prettyTable.call(ctx, table) + return + } + } +} diff --git a/ccip-cli/src/commands/token/grant-mint-burn-access.ts b/ccip-cli/src/commands/token/grant-mint-burn-access.ts new file mode 100644 index 00000000..c5273587 --- /dev/null +++ b/ccip-cli/src/commands/token/grant-mint-burn-access.ts @@ -0,0 +1,164 @@ +/** + * Token grant-mint-burn-access subcommand. + * Grants mint and burn permissions on a token to a pool or address. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type GrantMintBurnAccessParams, + type MintBurnRole, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'grant-mint-burn-access' +export const describe = 'Grant mint and burn permissions on a token to a pool or address' + +/** + * Yargs builder for the token grant-mint-burn-access subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia, solana-devnet)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be token owner/authority)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address (EVM contract, Solana mint, Aptos FA metadata)', + }) + .option('authority', { + type: 'string', + demandOption: true, + describe: 'Address to grant mint/burn access to (pool, multisig, etc.)', + }) + .option('role', { + type: 'string', + choices: ['mint', 'burn', 'mintAndBurn'] as const, + default: 'mintAndBurn' as const, + describe: 'Which role(s) to grant (default: mintAndBurn)', + }) + .option('token-type', { + type: 'string', + choices: ['burnMintERC20', 'factoryBurnMintERC20'] as const, + default: 'burnMintERC20', + describe: 'EVM token type — controls grant ABI (EVM only)', + }) + .example([ + [ + 'ccip-cli token grant-mint-burn-access -n sepolia --token-address 0x... --authority 0x...', + 'Grant pool mint/burn roles on EVM', + ], + [ + 'ccip-cli token grant-mint-burn-access -n solana-devnet --token-address J6fE... --authority 2e8X...', + 'Transfer Solana mint authority to multisig', + ], + ]) + +/** + * Handler for the token grant-mint-burn-access subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doGrantMintBurnAccess(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type GrantMintBurnAccessArgv = Awaited['argv']> & GlobalOpts + +/** Calls grantMintBurnAccess on the appropriate chain-family admin. */ +function grantForChain(chain: Chain, wallet: unknown, params: GrantMintBurnAccessParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.grantMintBurnAccess(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.grantMintBurnAccess(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.grantMintBurnAccess(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doGrantMintBurnAccess(ctx: Ctx, argv: GrantMintBurnAccessArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const params: GrantMintBurnAccessParams = { + tokenAddress: argv.tokenAddress, + authority: argv.authority, + role: argv.role as MintBurnRole, + ...(argv.tokenType && { + tokenType: argv.tokenType as 'burnMintERC20' | 'factoryBurnMintERC20', + }), + } + + logger.debug( + `Granting mint/burn access: token=${params.tokenAddress}, authority=${params.authority}, role=${params.role}`, + ) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await grantForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + tokenAddress: params.tokenAddress, + authority: params.authority, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Mint/burn access granted, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token/revoke-mint-burn-access.ts b/ccip-cli/src/commands/token/revoke-mint-burn-access.ts new file mode 100644 index 00000000..d9766419 --- /dev/null +++ b/ccip-cli/src/commands/token/revoke-mint-burn-access.ts @@ -0,0 +1,160 @@ +/** + * Token revoke-mint-burn-access subcommand. + * Revokes mint or burn permissions on a token from a pool or address. + */ + +import { + type AptosChain, + type Chain, + type EVMChain, + type RevokeMintBurnAccessParams, + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' +import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' +import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'revoke-mint-burn-access' +export const describe = 'Revoke mint or burn permissions on a token from a pool or address' + +/** + * Yargs builder for the token revoke-mint-burn-access subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., ethereum-testnet-sepolia)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be token owner/authority)', + }) + .option('token-address', { + type: 'string', + demandOption: true, + describe: 'Token address (EVM contract, Aptos FA metadata)', + }) + .option('authority', { + type: 'string', + demandOption: true, + describe: 'Address to revoke mint/burn access from (pool, multisig, etc.)', + }) + .option('role', { + type: 'string', + choices: ['mint', 'burn'] as const, + demandOption: true, + describe: 'Which role to revoke: mint or burn', + }) + .option('token-type', { + type: 'string', + choices: ['burnMintERC20', 'factoryBurnMintERC20'] as const, + default: 'burnMintERC20', + describe: 'EVM token type — controls revoke ABI (EVM only)', + }) + .example([ + [ + 'ccip-cli token revoke-mint-burn-access -n sepolia --token-address 0x... --authority 0x... --role mint', + 'Revoke mint role on EVM', + ], + ]) + +/** + * Handler for the token revoke-mint-burn-access subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doRevokeMintBurnAccess(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type RevokeMintBurnAccessArgv = Awaited['argv']> & GlobalOpts + +/** Calls revokeMintBurnAccess on the appropriate chain-family admin. */ +function revokeForChain(chain: Chain, wallet: unknown, params: RevokeMintBurnAccessParams) { + switch (chain.network.family) { + case ChainFamily.EVM: { + const evmChain = chain as EVMChain + const admin = new EVMTokenAdmin(evmChain.provider, evmChain.network, { + logger: evmChain.logger, + }) + return admin.revokeMintBurnAccess(wallet, params) + } + case ChainFamily.Solana: { + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + return admin.revokeMintBurnAccess(wallet, params) + } + case ChainFamily.Aptos: { + const aptosChain = chain as AptosChain + const admin = new AptosTokenAdmin(aptosChain.provider, aptosChain.network, { + logger: aptosChain.logger, + }) + return admin.revokeMintBurnAccess(wallet, params) + } + default: + throw new CCIPChainFamilyUnsupportedError(chain.network.family) + } +} + +async function doRevokeMintBurnAccess(ctx: Ctx, argv: RevokeMintBurnAccessArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + const params: RevokeMintBurnAccessParams = { + tokenAddress: argv.tokenAddress, + authority: argv.authority, + role: argv.role, + ...(argv.tokenType && { + tokenType: argv.tokenType as 'burnMintERC20' | 'factoryBurnMintERC20', + }), + } + + logger.debug( + `Revoking ${params.role} access: token=${params.tokenAddress}, authority=${params.authority}`, + ) + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await revokeForChain(chain, wallet, params) + + const output: Record = { + network: networkName, + tokenAddress: params.tokenAddress, + authority: params.authority, + role: params.role, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log(`${params.role} access revoked, tx:`, result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/commands/token/transfer-mint-authority.ts b/ccip-cli/src/commands/token/transfer-mint-authority.ts new file mode 100644 index 00000000..c88b0d70 --- /dev/null +++ b/ccip-cli/src/commands/token/transfer-mint-authority.ts @@ -0,0 +1,118 @@ +/** + * Token transfer-mint-authority subcommand (Solana only). + * Transfers SPL token mint authority to a new address (typically a multisig). + */ + +import { + type SolanaChain, + CCIPChainFamilyUnsupportedError, + ChainFamily, + networkInfo, +} from '@chainlink/ccip-sdk/src/index.ts' +import { + type TransferMintAuthorityParams, + SolanaTokenAdmin, +} from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' +import type { Argv } from 'yargs' + +import type { GlobalOpts } from '../../index.ts' +import { fetchChainsFromRpcs, loadChainWallet } from '../../providers/index.ts' +import { type Ctx, Format } from '../types.ts' +import { getCtx, logParsedError, prettyTable } from '../utils.ts' + +export const command = 'transfer-mint-authority' +export const describe = 'Transfer SPL token mint authority to a new address (Solana only)' + +/** + * Yargs builder for the token transfer-mint-authority subcommand. + */ +export const builder = (yargs: Argv) => + yargs + .option('network', { + alias: 'n', + type: 'string', + demandOption: true, + describe: 'Network: chainId or name (e.g., solana-devnet)', + }) + .option('wallet', { + alias: 'w', + type: 'string', + describe: 'Wallet: ledger[:index] or private key (must be current mint authority)', + }) + .option('mint', { + type: 'string', + demandOption: true, + describe: 'SPL token mint address (base58)', + }) + .option('new-mint-authority', { + type: 'string', + demandOption: true, + describe: 'New mint authority address (base58) — typically a multisig', + }) + .example([ + [ + 'ccip-cli token transfer-mint-authority -n solana-devnet --mint J6fE... --new-mint-authority 2e8X...', + 'Transfer mint authority to a multisig', + ], + ]) + +/** + * Handler for the token transfer-mint-authority subcommand. + */ +export async function handler(argv: Awaited['argv']> & GlobalOpts) { + const [ctx, destroy] = getCtx(argv) + return doTransferMintAuthority(ctx, argv) + .catch((err) => { + process.exitCode = 1 + if (!logParsedError.call(ctx, err)) ctx.logger.error(err) + }) + .finally(destroy) +} + +type TransferMintAuthorityArgv = Awaited['argv']> & GlobalOpts + +async function doTransferMintAuthority(ctx: Ctx, argv: TransferMintAuthorityArgv) { + const { logger } = ctx + const networkName = networkInfo(argv.network).name + const getChain = fetchChainsFromRpcs(ctx, argv) + const chain = await getChain(networkName) + + if (chain.network.family !== ChainFamily.Solana) { + throw new CCIPChainFamilyUnsupportedError(chain.network.family, { + context: { reason: 'transfer-mint-authority is only supported on Solana' }, + }) + } + + const solanaChain = chain as SolanaChain + const admin = new SolanaTokenAdmin(solanaChain.connection, solanaChain.network, { + logger: solanaChain.logger, + }) + + const params: TransferMintAuthorityParams = { + mint: argv.mint, + newMintAuthority: argv.newMintAuthority, + } + + const [, wallet] = await loadChainWallet(chain, argv) + const result = await admin.transferMintAuthority(wallet, params) + + const output: Record = { + network: networkName, + mint: params.mint, + newMintAuthority: params.newMintAuthority, + txHash: result.txHash, + } + + switch (argv.format) { + case Format.json: + logger.log(JSON.stringify(output, null, 2)) + return + case Format.log: + logger.log('Mint authority transferred, tx:', result.txHash) + return + case Format.pretty: + default: + prettyTable.call(ctx, output) + return + } +} diff --git a/ccip-cli/src/index.ts b/ccip-cli/src/index.ts index 514203db..262d2a08 100755 --- a/ccip-cli/src/index.ts +++ b/ccip-cli/src/index.ts @@ -11,7 +11,7 @@ import { Format } from './commands/index.ts' util.inspect.defaultOptions.depth = 6 // print down to tokenAmounts in requests // generate:nofail // `const VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -const VERSION = '1.4.2-7f8e132' +const VERSION = '1.4.2-03ff112' // generate:end const globalOpts = { diff --git a/ccip-sdk/package.json b/ccip-sdk/package.json index cab9510b..6a16c63e 100644 --- a/ccip-sdk/package.json +++ b/ccip-sdk/package.json @@ -27,6 +27,22 @@ "types": "./dist/all-chains.d.ts", "default": "./dist/all-chains.js" }, + "./token-admin/evm": { + "types": "./dist/token-admin/evm/index.d.ts", + "default": "./dist/token-admin/evm/index.js" + }, + "./token-admin/solana": { + "types": "./dist/token-admin/solana/index.d.ts", + "default": "./dist/token-admin/solana/index.js" + }, + "./token-admin/aptos": { + "types": "./dist/token-admin/aptos/index.d.ts", + "default": "./dist/token-admin/aptos/index.js" + }, + "./token-admin/types": { + "types": "./dist/token-admin/types.d.ts", + "default": "./dist/token-admin/types.js" + }, "./src/*": "./src/*" }, "scripts": { diff --git a/ccip-sdk/src/api/index.ts b/ccip-sdk/src/api/index.ts index 17957bc9..8aaa454a 100644 --- a/ccip-sdk/src/api/index.ts +++ b/ccip-sdk/src/api/index.ts @@ -60,7 +60,7 @@ export const DEFAULT_TIMEOUT_MS = 30000 /** SDK version string for telemetry header */ // generate:nofail // `export const SDK_VERSION = '${require('./package.json').version}-${require('child_process').execSync('git rev-parse --short HEAD').toString().trim()}'` -export const SDK_VERSION = '1.4.2-7f8e132' +export const SDK_VERSION = '1.4.2-03ff112' // generate:end /** SDK telemetry header name */ diff --git a/ccip-sdk/src/aptos/index.ts b/ccip-sdk/src/aptos/index.ts index 3f44f4e7..4c30ee72 100644 --- a/ccip-sdk/src/aptos/index.ts +++ b/ccip-sdk/src/aptos/index.ts @@ -671,7 +671,8 @@ export class AptosChain extends Chain { functionArguments: [token], }, }) - if (administrator.match(/^0x0*$/)) throw new CCIPAptosTokenNotRegisteredError(token, registry) + if (administrator.match(/^0x0*$/) && pendingAdministrator.match(/^0x0*$/)) + throw new CCIPAptosTokenNotRegisteredError(token, registry) return { administrator, ...(!pendingAdministrator.match(/^0x0*$/) && { pendingAdministrator }), @@ -686,6 +687,7 @@ export class AptosChain extends Chain { ): Promise<{ token: string router: string + owner: string typeAndVersion?: string }> { const modulesNames = (await this._getAccountModulesNames(tokenPool)) @@ -694,7 +696,7 @@ export class AptosChain extends Chain { let firstErr for (const name of modulesNames) { try { - const [typeAndVersion, token, router] = await Promise.all([ + const [typeAndVersion, token, router, owner] = await Promise.all([ this.typeAndVersion(`${tokenPool}::${name}`), this.provider.view<[string]>({ payload: { @@ -708,10 +710,17 @@ export class AptosChain extends Chain { functionArguments: [], }, }), + this.provider.view<[string]>({ + payload: { + function: `${tokenPool}::${name}::owner`, + functionArguments: [], + }, + }), ]) return { token: token[0], router: router[0], + owner: owner[0], typeAndVersion: typeAndVersion[2], } } catch (err) { diff --git a/ccip-sdk/src/aptos/types.ts b/ccip-sdk/src/aptos/types.ts index d6e0b706..6dbe7a25 100644 --- a/ccip-sdk/src/aptos/types.ts +++ b/ccip-sdk/src/aptos/types.ts @@ -92,5 +92,5 @@ export function serializeExecutionReport( */ export type UnsignedAptosTx = { family: typeof ChainFamily.Aptos - transactions: [Uint8Array] + transactions: [Uint8Array, ...Uint8Array[]] } diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index a709679f..787eb92e 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -401,6 +401,10 @@ export type TokenPoolConfig = { token: string /** Address of the CCIP router this pool is registered with. */ router: string + /** Current owner of the token pool. */ + owner: string + /** Proposed new owner (if an ownership transfer is pending). */ + proposedOwner?: string /** * Version identifier string (e.g., "BurnMintTokenPool 1.5.1"). * @@ -408,6 +412,23 @@ export type TokenPoolConfig = { * May be undefined for older pool implementations that don't expose this method. */ typeAndVersion?: string + /** + * Address of the rate limit admin, if set. + * + * @remarks + * The rate limit admin can update rate limiter configs without being the pool owner. + * Not available on Aptos (setRateLimitAdmin is unsupported). + * A zero-address value indicates no rate limit admin is set. + */ + rateLimitAdmin?: string + /** + * Address of the fee admin (EVM v2.0+ only). + * + * @remarks + * The fee admin can configure token transfer fees. + * Only available on EVM pools v2.0+ (from `getDynamicConfig()`). + */ + feeAdmin?: string /** * Min custom block confirmations for Faster-Than-Finality (FTF), * if TokenPool version \>= v2.0.0 and FTF is supported on this lane. @@ -437,6 +458,10 @@ export type RegistryTokenConfig = { pendingAdministrator?: string /** Address of the token pool authorized to handle this token's transfers. */ tokenPool?: string + /** Address Lookup Table for pool accounts (Solana only). */ + poolLookupTable?: string + /** Addresses stored in the pool lookup table (Solana only). */ + poolLookupTableEntries?: string[] } /** diff --git a/ccip-sdk/src/commits.test.ts b/ccip-sdk/src/commits.test.ts index 79c0d35b..859336e3 100644 --- a/ccip-sdk/src/commits.test.ts +++ b/ccip-sdk/src/commits.test.ts @@ -131,9 +131,15 @@ class MockChain extends Chain { async getTokenPoolConfig(_tokenPool: string): Promise<{ token: string router: string + owner: string typeAndVersion?: string }> { - return { token: '0xToken', router: '0xRouter', typeAndVersion: 'TokenPool 1.5.0' } + return { + token: '0xToken', + router: '0xRouter', + owner: '0xOwner', + typeAndVersion: 'TokenPool 1.5.0', + } } async getTokenPoolRemotes(_pool: string, _remoteChainSelector: bigint): Promise { diff --git a/ccip-sdk/src/errors/codes.ts b/ccip-sdk/src/errors/codes.ts index d6dd78f2..19b785f5 100644 --- a/ccip-sdk/src/errors/codes.ts +++ b/ccip-sdk/src/errors/codes.ts @@ -160,6 +160,91 @@ export const CCIPErrorCode = { ARGUMENT_INVALID: 'ARGUMENT_INVALID', INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + // Token Deployment + TOKEN_DEPLOY_PARAMS_INVALID: 'TOKEN_DEPLOY_PARAMS_INVALID', + TOKEN_DEPLOY_FAILED: 'TOKEN_DEPLOY_FAILED', + + // Pool Deployment + POOL_DEPLOY_PARAMS_INVALID: 'POOL_DEPLOY_PARAMS_INVALID', + POOL_DEPLOY_FAILED: 'POOL_DEPLOY_FAILED', + POOL_NOT_INITIALIZED: 'POOL_NOT_INITIALIZED', + + // Propose Admin Role + PROPOSE_ADMIN_ROLE_PARAMS_INVALID: 'PROPOSE_ADMIN_ROLE_PARAMS_INVALID', + PROPOSE_ADMIN_ROLE_FAILED: 'PROPOSE_ADMIN_ROLE_FAILED', + + // Accept Admin Role + ACCEPT_ADMIN_ROLE_PARAMS_INVALID: 'ACCEPT_ADMIN_ROLE_PARAMS_INVALID', + ACCEPT_ADMIN_ROLE_FAILED: 'ACCEPT_ADMIN_ROLE_FAILED', + + // Transfer Admin Role + TRANSFER_ADMIN_ROLE_PARAMS_INVALID: 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID', + TRANSFER_ADMIN_ROLE_FAILED: 'TRANSFER_ADMIN_ROLE_FAILED', + + // Apply Chain Updates + APPLY_CHAIN_UPDATES_PARAMS_INVALID: 'APPLY_CHAIN_UPDATES_PARAMS_INVALID', + APPLY_CHAIN_UPDATES_FAILED: 'APPLY_CHAIN_UPDATES_FAILED', + + // Append Remote Pool Addresses + APPEND_REMOTE_POOL_ADDRESSES_PARAMS_INVALID: 'APPEND_REMOTE_POOL_ADDRESSES_PARAMS_INVALID', + APPEND_REMOTE_POOL_ADDRESSES_FAILED: 'APPEND_REMOTE_POOL_ADDRESSES_FAILED', + + // Delete Chain Config + DELETE_CHAIN_CONFIG_PARAMS_INVALID: 'DELETE_CHAIN_CONFIG_PARAMS_INVALID', + DELETE_CHAIN_CONFIG_FAILED: 'DELETE_CHAIN_CONFIG_FAILED', + + // Remove Remote Pool Addresses + REMOVE_REMOTE_POOL_ADDRESSES_PARAMS_INVALID: 'REMOVE_REMOTE_POOL_ADDRESSES_PARAMS_INVALID', + REMOVE_REMOTE_POOL_ADDRESSES_FAILED: 'REMOVE_REMOTE_POOL_ADDRESSES_FAILED', + + // Set Chain Rate Limiter Config + SET_RATE_LIMITER_CONFIG_PARAMS_INVALID: 'SET_RATE_LIMITER_CONFIG_PARAMS_INVALID', + SET_RATE_LIMITER_CONFIG_FAILED: 'SET_RATE_LIMITER_CONFIG_FAILED', + + // Set Rate Limit Admin + SET_RATE_LIMIT_ADMIN_PARAMS_INVALID: 'SET_RATE_LIMIT_ADMIN_PARAMS_INVALID', + SET_RATE_LIMIT_ADMIN_FAILED: 'SET_RATE_LIMIT_ADMIN_FAILED', + + // Create Pool Mint Authority Multisig (Solana-only) + CREATE_POOL_MULTISIG_PARAMS_INVALID: 'CREATE_POOL_MULTISIG_PARAMS_INVALID', + CREATE_POOL_MULTISIG_FAILED: 'CREATE_POOL_MULTISIG_FAILED', + + // Transfer Mint Authority (Solana-only) + TRANSFER_MINT_AUTHORITY_PARAMS_INVALID: 'TRANSFER_MINT_AUTHORITY_PARAMS_INVALID', + TRANSFER_MINT_AUTHORITY_FAILED: 'TRANSFER_MINT_AUTHORITY_FAILED', + + // Grant Mint/Burn Access + GRANT_MINT_BURN_ACCESS_PARAMS_INVALID: 'GRANT_MINT_BURN_ACCESS_PARAMS_INVALID', + GRANT_MINT_BURN_ACCESS_FAILED: 'GRANT_MINT_BURN_ACCESS_FAILED', + + // Revoke Mint/Burn Access + REVOKE_MINT_BURN_ACCESS_PARAMS_INVALID: 'REVOKE_MINT_BURN_ACCESS_PARAMS_INVALID', + REVOKE_MINT_BURN_ACCESS_FAILED: 'REVOKE_MINT_BURN_ACCESS_FAILED', + + // Create Pool Token Account (Solana-only) + CREATE_POOL_TOKEN_ACCOUNT_PARAMS_INVALID: 'CREATE_POOL_TOKEN_ACCOUNT_PARAMS_INVALID', + CREATE_POOL_TOKEN_ACCOUNT_FAILED: 'CREATE_POOL_TOKEN_ACCOUNT_FAILED', + + // Create Token Address Lookup Table (Solana-only) + CREATE_TOKEN_ALT_PARAMS_INVALID: 'CREATE_TOKEN_ALT_PARAMS_INVALID', + CREATE_TOKEN_ALT_FAILED: 'CREATE_TOKEN_ALT_FAILED', + + // Set Pool (register pool in TokenAdminRegistry) + SET_POOL_PARAMS_INVALID: 'SET_POOL_PARAMS_INVALID', + SET_POOL_FAILED: 'SET_POOL_FAILED', + + // Transfer Ownership (2-step pool ownership transfer) + TRANSFER_OWNERSHIP_PARAMS_INVALID: 'TRANSFER_OWNERSHIP_PARAMS_INVALID', + TRANSFER_OWNERSHIP_FAILED: 'TRANSFER_OWNERSHIP_FAILED', + + // Accept Ownership (2-step pool ownership acceptance) + ACCEPT_OWNERSHIP_PARAMS_INVALID: 'ACCEPT_OWNERSHIP_PARAMS_INVALID', + ACCEPT_OWNERSHIP_FAILED: 'ACCEPT_OWNERSHIP_FAILED', + + // Execute Ownership Transfer (Aptos 3rd step — owner finalizes transfer) + EXECUTE_OWNERSHIP_TRANSFER_PARAMS_INVALID: 'EXECUTE_OWNERSHIP_TRANSFER_PARAMS_INVALID', + EXECUTE_OWNERSHIP_TRANSFER_FAILED: 'EXECUTE_OWNERSHIP_TRANSFER_FAILED', + // Internal NOT_IMPLEMENTED: 'NOT_IMPLEMENTED', UNKNOWN: 'UNKNOWN', diff --git a/ccip-sdk/src/errors/index.ts b/ccip-sdk/src/errors/index.ts index d01d1580..e9481185 100644 --- a/ccip-sdk/src/errors/index.ts +++ b/ccip-sdk/src/errors/index.ts @@ -186,6 +186,125 @@ export { CCIPSourceChainUnsupportedError } from './specialized.ts' // Specialized errors - CLI & Validation export { CCIPArgumentInvalidError, CCIPInsufficientBalanceError } from './specialized.ts' +// Specialized errors - Token Deployment +export { CCIPTokenDeployFailedError, CCIPTokenDeployParamsInvalidError } from './specialized.ts' + +// Specialized errors - Pool Deployment +export { + CCIPPoolDeployFailedError, + CCIPPoolDeployParamsInvalidError, + CCIPPoolNotInitializedError, +} from './specialized.ts' + +// Specialized errors - Propose Admin Role +export { + CCIPProposeAdminRoleFailedError, + CCIPProposeAdminRoleParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Accept Admin Role +export { CCIPAcceptAdminRoleParamsInvalidError } from './specialized.ts' +export { CCIPAcceptAdminRoleFailedError } from './specialized.ts' + +// Specialized errors - Transfer Admin Role +export { + CCIPTransferAdminRoleFailedError, + CCIPTransferAdminRoleParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Apply Chain Updates +export { + CCIPApplyChainUpdatesFailedError, + CCIPApplyChainUpdatesParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Append Remote Pool Addresses +export { + CCIPAppendRemotePoolAddressesFailedError, + CCIPAppendRemotePoolAddressesParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Delete Chain Config +export { + CCIPDeleteChainConfigFailedError, + CCIPDeleteChainConfigParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Remove Remote Pool Addresses +export { + CCIPRemoveRemotePoolAddressesFailedError, + CCIPRemoveRemotePoolAddressesParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Set Chain Rate Limiter Config +export { + CCIPSetRateLimiterConfigFailedError, + CCIPSetRateLimiterConfigParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Set Rate Limit Admin +export { + CCIPSetRateLimitAdminFailedError, + CCIPSetRateLimitAdminParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Create Pool Mint Authority Multisig (Solana-only) +export { + CCIPCreatePoolMultisigFailedError, + CCIPCreatePoolMultisigParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Transfer Mint Authority (Solana-only) +export { + CCIPTransferMintAuthorityFailedError, + CCIPTransferMintAuthorityParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Grant Mint/Burn Access +export { + CCIPGrantMintBurnAccessFailedError, + CCIPGrantMintBurnAccessParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Revoke Mint/Burn Access +export { + CCIPRevokeMintBurnAccessFailedError, + CCIPRevokeMintBurnAccessParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Create Pool Token Account (Solana-only) +export { + CCIPCreatePoolTokenAccountFailedError, + CCIPCreatePoolTokenAccountParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Create Token Address Lookup Table (Solana-only) +export { + CCIPCreateTokenAltFailedError, + CCIPCreateTokenAltParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Set Pool +export { CCIPSetPoolFailedError, CCIPSetPoolParamsInvalidError } from './specialized.ts' + +// Specialized errors - Transfer Ownership +export { + CCIPTransferOwnershipFailedError, + CCIPTransferOwnershipParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Accept Ownership +export { + CCIPAcceptOwnershipFailedError, + CCIPAcceptOwnershipParamsInvalidError, +} from './specialized.ts' + +// Specialized errors - Execute Ownership Transfer (Aptos 3rd step) +export { + CCIPExecuteOwnershipTransferFailedError, + CCIPExecuteOwnershipTransferParamsInvalidError, +} from './specialized.ts' + // HTTP Status codes (re-exported from root) export { HttpStatus, isServerError, isTransientHttpStatus } from '../http-status.ts' diff --git a/ccip-sdk/src/errors/recovery.ts b/ccip-sdk/src/errors/recovery.ts index cc61408e..01b80a79 100644 --- a/ccip-sdk/src/errors/recovery.ts +++ b/ccip-sdk/src/errors/recovery.ts @@ -49,7 +49,7 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { LANE_NOT_FOUND: 'This lane may not exist or is not yet supported by CCIP. Check the CCIP Directory for supported lanes: https://docs.chain.link/ccip/directory', - COMMIT_NOT_FOUND: 'Wait for the commit report. DON commit typically takes a few minutes.', + COMMIT_NOT_FOUND: 'Wait for the commit report. A DON commit typically takes a few minutes.', MERKLE_ROOT_MISMATCH: 'The computed merkle root does not match the committed root. Ensure all messages in the batch are included and ordered correctly.', MERKLE_TREE_EMPTY: 'Provide at least one leaf hash.', @@ -71,7 +71,8 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { VERSION_FEATURE_UNAVAILABLE: 'This feature requires CCIP v1.6 or later.', VERSION_REQUIRES_LANE: 'Decoding commits from CCIP <= v1.5 requires lane information.', - EXTRA_ARGS_PARSE_FAILED: 'Verify the format matches the source chain family.', + EXTRA_ARGS_PARSE_FAILED: + 'Verify the extraArgs bytes are properly encoded. Use EVMExtraArgsV1/V2 for EVM sources, SVMExtraArgsV1 for Solana sources. Check the source chain family.', EXTRA_ARGS_UNKNOWN: 'Use EVMExtraArgsV1/V2, SVMExtraArgsV1, or SuiExtraArgsV1.', EXTRA_ARGS_INVALID_EVM: 'ExtraArgs must be EVMExtraArgsV1 or EVMExtraArgsV2 format.', EXTRA_ARGS_INVALID_SVM: 'ExtraArgs must be SVMExtraArgsV1 format for Solana.', @@ -190,6 +191,114 @@ export const DEFAULT_RECOVERY_HINTS: Partial> = { ARGUMENT_INVALID: 'Check the command-line argument format and requirements.', INSUFFICIENT_BALANCE: 'Fund the wallet to cover the transaction fee.', + TOKEN_DEPLOY_PARAMS_INVALID: + 'Verify the token deployment parameters: name and symbol must be non-empty, decimals must be within range for the chain family (0-18 EVM, 0-9 Solana).', + TOKEN_DEPLOY_FAILED: + 'The token deployment transaction failed. Check the transaction hash on a block explorer for revert reason. Ensure the wallet has sufficient funds for gas.', + + POOL_DEPLOY_PARAMS_INVALID: + 'Verify the pool deployment parameters: tokenAddress, poolType, localTokenDecimals, and chain-specific addresses (routerAddress, poolProgramId, mcmsAddress) must be valid.', + POOL_DEPLOY_FAILED: + 'The pool deployment transaction failed. Check the transaction hash on a block explorer for revert reason. Ensure the wallet has sufficient funds for gas.', + POOL_NOT_INITIALIZED: + 'This Aptos generic pool requires initialization by the token creator module. ' + + 'The token creator must call burn_mint_token_pool::initialize() or lock_release_token_pool::initialize() ' + + 'with stored capability refs (BurnRef/MintRef/TransferRef). This cannot be done via the SDK.', + + PROPOSE_ADMIN_ROLE_PARAMS_INVALID: + 'Verify the propose admin role parameters: tokenAddress, administrator, and routerAddress must be non-empty valid addresses.', + PROPOSE_ADMIN_ROLE_FAILED: + 'The propose admin role transaction failed. Ensure the caller is the TokenAdminRegistry owner or has permission to propose administrators.', + + ACCEPT_ADMIN_ROLE_PARAMS_INVALID: + 'Check that tokenAddress and routerAddress are valid, non-empty addresses.', + ACCEPT_ADMIN_ROLE_FAILED: + 'The accept admin role transaction failed. Ensure the caller is the pending administrator for this token.', + + TRANSFER_ADMIN_ROLE_PARAMS_INVALID: + 'Check that tokenAddress, newAdmin, and routerAddress are valid, non-empty addresses.', + TRANSFER_ADMIN_ROLE_FAILED: + 'The transfer admin role transaction failed. Ensure the caller is the current administrator for this token.', + + APPLY_CHAIN_UPDATES_PARAMS_INVALID: + 'Check that poolAddress is valid and chainsToAdd entries have valid remoteChainSelector, remotePoolAddresses, and remoteTokenAddress.', + APPLY_CHAIN_UPDATES_FAILED: + 'The apply chain updates transaction failed. Ensure the caller is the pool owner and the remote chain selectors are valid.', + + APPEND_REMOTE_POOL_ADDRESSES_PARAMS_INVALID: + 'Check that poolAddress, remoteChainSelector, and remotePoolAddresses are valid and non-empty.', + APPEND_REMOTE_POOL_ADDRESSES_FAILED: + 'The append remote pool addresses transaction failed. Ensure the caller is the pool owner and the remote chain config exists.', + + DELETE_CHAIN_CONFIG_PARAMS_INVALID: + 'Check that poolAddress and remoteChainSelector are valid and non-empty.', + DELETE_CHAIN_CONFIG_FAILED: + 'The delete chain config transaction failed. Ensure the caller is the pool owner and the remote chain config exists.', + + REMOVE_REMOTE_POOL_ADDRESSES_PARAMS_INVALID: + 'Check that poolAddress, remoteChainSelector, and remotePoolAddresses are valid and non-empty.', + REMOVE_REMOTE_POOL_ADDRESSES_FAILED: + 'The remove remote pool addresses transaction failed. Ensure the caller is the pool owner and the remote chain config exists with the specified pool addresses.', + + SET_RATE_LIMITER_CONFIG_PARAMS_INVALID: + 'Check that poolAddress is valid and each chain config has a valid remoteChainSelector with valid rate limiter configs (capacity and rate as non-negative string integers).', + SET_RATE_LIMITER_CONFIG_FAILED: + 'The set rate limiter config transaction failed. Ensure the caller is the pool owner or rate limit admin, and the remote chain selectors are configured.', + + SET_RATE_LIMIT_ADMIN_PARAMS_INVALID: + 'Check that poolAddress and rateLimitAdmin are valid non-empty addresses.', + SET_RATE_LIMIT_ADMIN_FAILED: + 'The set rate limit admin transaction failed. Ensure the caller is the pool owner.', + + CREATE_POOL_MULTISIG_PARAMS_INVALID: + 'Check that mint and poolProgramId are valid public keys, additionalSigners is non-empty with valid public keys, and threshold is a positive integer not exceeding total signers (max 11).', + CREATE_POOL_MULTISIG_FAILED: + 'The create pool mint authority multisig transaction failed. Ensure the wallet has sufficient SOL for rent exemption and the mint account exists on-chain.', + + TRANSFER_MINT_AUTHORITY_PARAMS_INVALID: + 'Check that mint and newMintAuthority are valid public keys.', + TRANSFER_MINT_AUTHORITY_FAILED: + 'The transfer mint authority transaction failed. Ensure the caller is the current mint authority.', + + GRANT_MINT_BURN_ACCESS_PARAMS_INVALID: + 'Check that tokenAddress and authority are valid addresses for this chain.', + GRANT_MINT_BURN_ACCESS_FAILED: + 'The grant mint/burn access transaction failed. Ensure the caller is the token owner/admin and the authority address is valid.', + + REVOKE_MINT_BURN_ACCESS_PARAMS_INVALID: + 'Check that tokenAddress, authority, and role are valid. Role must be "mint" or "burn".', + REVOKE_MINT_BURN_ACCESS_FAILED: + 'The revoke mint/burn access transaction failed. Ensure the caller is the token owner/admin and the authority currently holds the role.', + + CREATE_POOL_TOKEN_ACCOUNT_PARAMS_INVALID: + 'Check that tokenAddress and poolAddress are valid Solana public keys and exist on-chain.', + CREATE_POOL_TOKEN_ACCOUNT_FAILED: + 'The create pool token account transaction failed. Ensure the wallet has sufficient SOL for rent exemption and the pool/mint accounts exist on-chain.', + + CREATE_TOKEN_ALT_PARAMS_INVALID: + 'Check that tokenAddress, poolAddress, and routerAddress are valid Solana public keys. If authority is provided, it must also be a valid public key.', + CREATE_TOKEN_ALT_FAILED: + 'The create token ALT transaction failed. Ensure the wallet has sufficient SOL for rent exemption and the pool/mint accounts exist on-chain.', + + SET_POOL_PARAMS_INVALID: + 'Check that tokenAddress, poolAddress, and routerAddress are valid. On Solana, poolLookupTable is also required.', + SET_POOL_FAILED: + 'The set pool transaction failed. Ensure the caller is the token administrator in the TokenAdminRegistry.', + + TRANSFER_OWNERSHIP_PARAMS_INVALID: + 'Check that poolAddress is valid and newOwner is a valid address for the target chain.', + TRANSFER_OWNERSHIP_FAILED: + 'The transfer ownership transaction failed. Ensure the caller is the current pool owner.', + + ACCEPT_OWNERSHIP_PARAMS_INVALID: 'Check that poolAddress is valid for the target chain.', + ACCEPT_OWNERSHIP_FAILED: + 'The accept ownership transaction failed. Ensure the caller is the pending (proposed) owner.', + + EXECUTE_OWNERSHIP_TRANSFER_PARAMS_INVALID: + 'Check that poolAddress is valid and newOwner matches the address that accepted ownership.', + EXECUTE_OWNERSHIP_TRANSFER_FAILED: + 'The execute ownership transfer failed. Ensure the caller is the current owner and the proposed owner has already called acceptOwnership. This step is Aptos-only.', + NOT_IMPLEMENTED: 'This feature is not yet implemented.', UNKNOWN: 'An unknown error occurred. Check the error details.', } diff --git a/ccip-sdk/src/errors/specialized.ts b/ccip-sdk/src/errors/specialized.ts index f0051c87..2338c6f7 100644 --- a/ccip-sdk/src/errors/specialized.ts +++ b/ccip-sdk/src/errors/specialized.ts @@ -3472,3 +3472,841 @@ export class CCIPViemAdapterError extends CCIPError { }) } } + +// ─── Token Deployment ───────────────────────────────────────────────────────── + +/** + * Thrown when token deployment parameters are invalid (e.g., empty name, decimals out of range). + * + * @example + * ```typescript + * try { + * await admin.deployToken({ name: '', symbol: 'MTK', decimals: 18 }) + * } catch (error) { + * if (error instanceof CCIPTokenDeployParamsInvalidError) { + * console.log(`Invalid param: ${error.context.param} — ${error.context.reason}`) + * } + * } + * ``` + */ +export class CCIPTokenDeployParamsInvalidError extends CCIPError { + override readonly name = 'CCIPTokenDeployParamsInvalidError' + /** Creates a token deploy params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.TOKEN_DEPLOY_PARAMS_INVALID, + `Invalid token deployment parameter "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** + * Thrown when a token deployment transaction fails (reverts or is not confirmed). + * + * @example + * ```typescript + * try { + * await admin.deployToken({ name: 'My Token', symbol: 'MTK', decimals: 18 }) + * } catch (error) { + * if (error instanceof CCIPTokenDeployFailedError) { + * console.log(`Deploy failed: ${error.context.txHash}`) + * } + * } + * ``` + */ +export class CCIPTokenDeployFailedError extends CCIPError { + override readonly name = 'CCIPTokenDeployFailedError' + /** Creates a token deploy failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.TOKEN_DEPLOY_FAILED, `Token deployment failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Pool Deployment ────────────────────────────────────────────────────────── + +/** + * Thrown when pool deployment parameters fail validation. + * + * @example + * ```typescript + * try { + * await admin.deployPool(wallet, { poolType: 'burn-mint', tokenAddress: '', localTokenDecimals: 18, routerAddress: '0x...' }) + * } catch (error) { + * if (error instanceof CCIPPoolDeployParamsInvalidError) { + * console.log(`Invalid param: ${error.context.param} — ${error.context.reason}`) + * } + * } + * ``` + */ +export class CCIPPoolDeployParamsInvalidError extends CCIPError { + override readonly name = 'CCIPPoolDeployParamsInvalidError' + /** Creates a pool deploy params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.POOL_DEPLOY_PARAMS_INVALID, + `Invalid pool deployment parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** + * Thrown when pool deployment transaction fails on-chain. + * + * @example + * ```typescript + * try { + * await admin.deployPool(wallet, params) + * } catch (error) { + * if (error instanceof CCIPPoolDeployFailedError) { + * console.log(`Pool deploy failed: ${error.message}`) + * } + * } + * ``` + */ +export class CCIPPoolDeployFailedError extends CCIPError { + override readonly name = 'CCIPPoolDeployFailedError' + /** Creates a pool deploy failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.POOL_DEPLOY_FAILED, `Pool deployment failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +/** + * Thrown when an operation is attempted on an uninitialized Aptos generic pool. + * + * Generic pools (`burn_mint_token_pool`, `lock_release_token_pool`) require a + * separate `initialize()` call from the token creator module with capability refs + * (`BurnRef`/`MintRef`/`TransferRef`) that cannot be provided from TypeScript. + * + * @example + * ```typescript + * try { + * await admin.transferOwnership(wallet, { poolAddress, newOwner }) + * } catch (error) { + * if (error instanceof CCIPPoolNotInitializedError) { + * console.log(`Pool not initialized: ${error.message}`) + * } + * } + * ``` + */ +export class CCIPPoolNotInitializedError extends CCIPError { + override readonly name = 'CCIPPoolNotInitializedError' + /** Creates a pool not initialized error. */ + constructor(poolAddress: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.POOL_NOT_INITIALIZED, + `Pool at ${poolAddress} is not initialized. ` + + `The token creator module must call initialize() with capability refs ` + + `(BurnRef/MintRef/TransferRef) before this operation can be used.`, + { + ...options, + isTransient: false, + context: { ...options?.context, poolAddress }, + }, + ) + } +} + +// ── Propose Admin Role ────────────────────────────────────────────────────── + +/** + * Thrown when proposeAdminRole parameters are invalid. + * + * @example + * ```typescript + * try { + * await admin.proposeAdminRole(wallet, params) + * } catch (error) { + * if (error instanceof CCIPProposeAdminRoleParamsInvalidError) { + * console.log(`Invalid param: ${error.context.param} — ${error.context.reason}`) + * } + * } + * ``` + */ +export class CCIPProposeAdminRoleParamsInvalidError extends CCIPError { + override readonly name = 'CCIPProposeAdminRoleParamsInvalidError' + /** Creates a propose admin role params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.PROPOSE_ADMIN_ROLE_PARAMS_INVALID, + `Invalid proposeAdminRole parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** + * Thrown when proposeAdminRole transaction fails on-chain. + * + * @example + * ```typescript + * try { + * await admin.proposeAdminRole(wallet, params) + * } catch (error) { + * if (error instanceof CCIPProposeAdminRoleFailedError) { + * console.log(`Propose admin role failed: ${error.message}`) + * } + * } + * ``` + */ +export class CCIPProposeAdminRoleFailedError extends CCIPError { + override readonly name = 'CCIPProposeAdminRoleFailedError' + /** Creates a propose admin role failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.PROPOSE_ADMIN_ROLE_FAILED, `Propose admin role failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +/** + * Thrown when acceptAdminRole parameters are invalid. + * + * @example + * ```typescript + * try { + * await admin.acceptAdminRole(wallet, params) + * } catch (error) { + * if (error instanceof CCIPAcceptAdminRoleParamsInvalidError) { + * console.log(`Invalid param: ${error.context.param} — ${error.context.reason}`) + * } + * } + * ``` + */ +export class CCIPAcceptAdminRoleParamsInvalidError extends CCIPError { + override readonly name = 'CCIPAcceptAdminRoleParamsInvalidError' + /** Creates an accept admin role params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.ACCEPT_ADMIN_ROLE_PARAMS_INVALID, + `Invalid acceptAdminRole parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** + * Thrown when acceptAdminRole transaction fails on-chain. + * + * @example + * ```typescript + * try { + * await admin.acceptAdminRole(wallet, params) + * } catch (error) { + * if (error instanceof CCIPAcceptAdminRoleFailedError) { + * console.log(`Accept admin role failed: ${error.message}`) + * } + * } + * ``` + */ +export class CCIPAcceptAdminRoleFailedError extends CCIPError { + override readonly name = 'CCIPAcceptAdminRoleFailedError' + /** Creates an accept admin role failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.ACCEPT_ADMIN_ROLE_FAILED, `Accept admin role failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Transfer Admin Role ───────────────────────────────────────────────────── + +/** + * Thrown when transferAdminRole parameters are invalid. + * + * @example + * ```typescript + * try { + * await admin.transferAdminRole(wallet, params) + * } catch (error) { + * if (error instanceof CCIPTransferAdminRoleParamsInvalidError) { + * console.log(`Invalid param: ${error.context.param} — ${error.context.reason}`) + * } + * } + * ``` + */ +export class CCIPTransferAdminRoleParamsInvalidError extends CCIPError { + override readonly name = 'CCIPTransferAdminRoleParamsInvalidError' + /** Creates a transfer admin role params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.TRANSFER_ADMIN_ROLE_PARAMS_INVALID, + `Invalid transferAdminRole parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** + * Thrown when transferAdminRole transaction fails on-chain. + * + * @example + * ```typescript + * try { + * await admin.transferAdminRole(wallet, params) + * } catch (error) { + * if (error instanceof CCIPTransferAdminRoleFailedError) { + * console.log(`Transfer admin role failed: ${error.message}`) + * } + * } + * ``` + */ +export class CCIPTransferAdminRoleFailedError extends CCIPError { + override readonly name = 'CCIPTransferAdminRoleFailedError' + /** Creates a transfer admin role failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.TRANSFER_ADMIN_ROLE_FAILED, `Transfer admin role failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Apply Chain Updates ───────────────────────────────────────────────────── + +/** Thrown when applyChainUpdates parameters are invalid. */ +export class CCIPApplyChainUpdatesParamsInvalidError extends CCIPError { + override readonly name = 'CCIPApplyChainUpdatesParamsInvalidError' + /** Creates a params-invalid error for apply chain updates. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.APPLY_CHAIN_UPDATES_PARAMS_INVALID, + `Invalid applyChainUpdates param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the applyChainUpdates transaction fails. */ +export class CCIPApplyChainUpdatesFailedError extends CCIPError { + override readonly name = 'CCIPApplyChainUpdatesFailedError' + /** Creates an apply chain updates failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.APPLY_CHAIN_UPDATES_FAILED, `Apply chain updates failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Set Chain Rate Limiter Config ─────────────────────────────────────────── + +/** Thrown when setChainRateLimiterConfig parameters are invalid. */ +export class CCIPSetRateLimiterConfigParamsInvalidError extends CCIPError { + override readonly name = 'CCIPSetRateLimiterConfigParamsInvalidError' + /** Creates a params-invalid error for set rate limiter config. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.SET_RATE_LIMITER_CONFIG_PARAMS_INVALID, + `Invalid setChainRateLimiterConfig param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the setChainRateLimiterConfig transaction fails. */ +export class CCIPSetRateLimiterConfigFailedError extends CCIPError { + override readonly name = 'CCIPSetRateLimiterConfigFailedError' + /** Creates a set rate limiter config failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.SET_RATE_LIMITER_CONFIG_FAILED, + `Set rate limiter config failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// ── Set Rate Limit Admin ──────────────────────────────────────────────────── + +/** Thrown when setRateLimitAdmin parameters are invalid. */ +export class CCIPSetRateLimitAdminParamsInvalidError extends CCIPError { + override readonly name = 'CCIPSetRateLimitAdminParamsInvalidError' + /** Creates a params-invalid error for set rate limit admin. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.SET_RATE_LIMIT_ADMIN_PARAMS_INVALID, + `Invalid setRateLimitAdmin param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the setRateLimitAdmin transaction fails. */ +export class CCIPSetRateLimitAdminFailedError extends CCIPError { + override readonly name = 'CCIPSetRateLimitAdminFailedError' + /** Creates a set rate limit admin failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.SET_RATE_LIMIT_ADMIN_FAILED, `Set rate limit admin failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Create Pool Mint Authority Multisig (Solana-only) ─────────────────────── + +/** Thrown when createPoolMintAuthorityMultisig parameters are invalid. */ +export class CCIPCreatePoolMultisigParamsInvalidError extends CCIPError { + override readonly name = 'CCIPCreatePoolMultisigParamsInvalidError' + /** Creates a params-invalid error for create pool multisig. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.CREATE_POOL_MULTISIG_PARAMS_INVALID, + `Invalid createPoolMintAuthorityMultisig param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the createPoolMintAuthorityMultisig transaction fails. */ +export class CCIPCreatePoolMultisigFailedError extends CCIPError { + override readonly name = 'CCIPCreatePoolMultisigFailedError' + /** Creates a create pool multisig failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.CREATE_POOL_MULTISIG_FAILED, + `Create pool mint authority multisig failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// Transfer Mint Authority (Solana-only) + +/** Thrown when transferMintAuthority params are invalid. */ +export class CCIPTransferMintAuthorityParamsInvalidError extends CCIPError { + override readonly name = 'CCIPTransferMintAuthorityParamsInvalidError' + /** Creates a params-invalid error for transfer mint authority. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.TRANSFER_MINT_AUTHORITY_PARAMS_INVALID, + `Invalid transferMintAuthority param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the transferMintAuthority transaction fails. */ +export class CCIPTransferMintAuthorityFailedError extends CCIPError { + override readonly name = 'CCIPTransferMintAuthorityFailedError' + /** Creates a transfer mint authority failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.TRANSFER_MINT_AUTHORITY_FAILED, + `Transfer mint authority failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// Grant Mint/Burn Access + +/** Thrown when grantMintBurnAccess params are invalid. */ +export class CCIPGrantMintBurnAccessParamsInvalidError extends CCIPError { + override readonly name = 'CCIPGrantMintBurnAccessParamsInvalidError' + /** Creates a params-invalid error for grant mint/burn access. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.GRANT_MINT_BURN_ACCESS_PARAMS_INVALID, + `Invalid grantMintBurnAccess param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the grantMintBurnAccess transaction fails. */ +export class CCIPGrantMintBurnAccessFailedError extends CCIPError { + override readonly name = 'CCIPGrantMintBurnAccessFailedError' + /** Creates a grant mint/burn access failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.GRANT_MINT_BURN_ACCESS_FAILED, `Grant mint/burn access failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// Revoke Mint/Burn Access + +/** Thrown when revokeMintBurnAccess params are invalid. */ +export class CCIPRevokeMintBurnAccessParamsInvalidError extends CCIPError { + override readonly name = 'CCIPRevokeMintBurnAccessParamsInvalidError' + /** Creates a params-invalid error for revoke mint/burn access. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.REVOKE_MINT_BURN_ACCESS_PARAMS_INVALID, + `Invalid revokeMintBurnAccess param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the revokeMintBurnAccess transaction fails. */ +export class CCIPRevokeMintBurnAccessFailedError extends CCIPError { + override readonly name = 'CCIPRevokeMintBurnAccessFailedError' + /** Creates a revoke mint/burn access failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.REVOKE_MINT_BURN_ACCESS_FAILED, + `Revoke mint/burn access failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// ── Create Pool Token Account (Solana-only) ───────────────────────────────── + +/** Thrown when createPoolTokenAccount params are invalid. */ +export class CCIPCreatePoolTokenAccountParamsInvalidError extends CCIPError { + override readonly name = 'CCIPCreatePoolTokenAccountParamsInvalidError' + /** Creates a params-invalid error for create pool token account. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.CREATE_POOL_TOKEN_ACCOUNT_PARAMS_INVALID, + `Invalid createPoolTokenAccount param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the createPoolTokenAccount transaction fails. */ +export class CCIPCreatePoolTokenAccountFailedError extends CCIPError { + override readonly name = 'CCIPCreatePoolTokenAccountFailedError' + /** Creates a create pool token account failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.CREATE_POOL_TOKEN_ACCOUNT_FAILED, + `Create pool token account failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// ── Create Token Address Lookup Table (Solana-only) ───────────────────────── + +/** Thrown when createTokenAlt params are invalid. */ +export class CCIPCreateTokenAltParamsInvalidError extends CCIPError { + override readonly name = 'CCIPCreateTokenAltParamsInvalidError' + /** Creates a params-invalid error for create token ALT. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.CREATE_TOKEN_ALT_PARAMS_INVALID, + `Invalid createTokenAlt param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the createTokenAlt transaction fails. */ +export class CCIPCreateTokenAltFailedError extends CCIPError { + override readonly name = 'CCIPCreateTokenAltFailedError' + /** Creates a create token ALT failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.CREATE_TOKEN_ALT_FAILED, + `Create token address lookup table failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// ── Set Pool ──────────────────────────────────────────────────────────────── + +/** Thrown when setPool parameters are invalid. */ +export class CCIPSetPoolParamsInvalidError extends CCIPError { + override readonly name = 'CCIPSetPoolParamsInvalidError' + /** Creates a set pool params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.SET_POOL_PARAMS_INVALID, + `Invalid setPool parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** Thrown when the setPool transaction fails on-chain. */ +export class CCIPSetPoolFailedError extends CCIPError { + override readonly name = 'CCIPSetPoolFailedError' + /** Creates a set pool failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.SET_POOL_FAILED, `Set pool failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Transfer Ownership ────────────────────────────────────────────────────── + +/** Thrown when transferOwnership parameters are invalid. */ +export class CCIPTransferOwnershipParamsInvalidError extends CCIPError { + override readonly name = 'CCIPTransferOwnershipParamsInvalidError' + /** Creates a transfer ownership params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.TRANSFER_OWNERSHIP_PARAMS_INVALID, + `Invalid transferOwnership parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** Thrown when the transferOwnership transaction fails on-chain. */ +export class CCIPTransferOwnershipFailedError extends CCIPError { + override readonly name = 'CCIPTransferOwnershipFailedError' + /** Creates a transfer ownership failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.TRANSFER_OWNERSHIP_FAILED, `Transfer ownership failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Accept Ownership ──────────────────────────────────────────────────────── + +/** Thrown when acceptOwnership parameters are invalid. */ +export class CCIPAcceptOwnershipParamsInvalidError extends CCIPError { + override readonly name = 'CCIPAcceptOwnershipParamsInvalidError' + /** Creates an accept ownership params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.ACCEPT_OWNERSHIP_PARAMS_INVALID, + `Invalid acceptOwnership parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** Thrown when the acceptOwnership transaction fails on-chain. */ +export class CCIPAcceptOwnershipFailedError extends CCIPError { + override readonly name = 'CCIPAcceptOwnershipFailedError' + /** Creates an accept ownership failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.ACCEPT_OWNERSHIP_FAILED, `Accept ownership failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +// ── Execute Ownership Transfer (Aptos 3rd step) ───────────────────────────── + +/** Thrown when executeOwnershipTransfer parameters are invalid. */ +export class CCIPExecuteOwnershipTransferParamsInvalidError extends CCIPError { + override readonly name = 'CCIPExecuteOwnershipTransferParamsInvalidError' + /** Creates an execute ownership transfer params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.EXECUTE_OWNERSHIP_TRANSFER_PARAMS_INVALID, + `Invalid executeOwnershipTransfer parameter "${param}": ${reason}`, + { ...options, isTransient: false, context: { ...options?.context, param, reason } }, + ) + } +} + +/** Thrown when the executeOwnershipTransfer transaction fails on-chain. */ +export class CCIPExecuteOwnershipTransferFailedError extends CCIPError { + override readonly name = 'CCIPExecuteOwnershipTransferFailedError' + /** Creates an execute ownership transfer failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.EXECUTE_OWNERSHIP_TRANSFER_FAILED, + `Execute ownership transfer failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// ── Append Remote Pool Addresses ───────────────────────────────────────────── + +/** Thrown when appendRemotePoolAddresses parameters are invalid. */ +export class CCIPAppendRemotePoolAddressesParamsInvalidError extends CCIPError { + override readonly name = 'CCIPAppendRemotePoolAddressesParamsInvalidError' + /** Creates a params-invalid error for append remote pool addresses. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.APPEND_REMOTE_POOL_ADDRESSES_PARAMS_INVALID, + `Invalid appendRemotePoolAddresses param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the appendRemotePoolAddresses transaction fails. */ +export class CCIPAppendRemotePoolAddressesFailedError extends CCIPError { + override readonly name = 'CCIPAppendRemotePoolAddressesFailedError' + /** Creates an append remote pool addresses failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.APPEND_REMOTE_POOL_ADDRESSES_FAILED, + `Append remote pool addresses failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} + +// ── Delete Chain Config ───────────────────────────────────────────────────── + +/** Thrown when deleteChainConfig parameters are invalid. */ +export class CCIPDeleteChainConfigParamsInvalidError extends CCIPError { + override readonly name = 'CCIPDeleteChainConfigParamsInvalidError' + /** Creates a params-invalid error for delete chain config. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.DELETE_CHAIN_CONFIG_PARAMS_INVALID, + `Invalid deleteChainConfig param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the deleteChainConfig transaction fails. */ +export class CCIPDeleteChainConfigFailedError extends CCIPError { + override readonly name = 'CCIPDeleteChainConfigFailedError' + /** Creates a delete chain config failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super(CCIPErrorCode.DELETE_CHAIN_CONFIG_FAILED, `Delete chain config failed: ${reason}`, { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }) + } +} + +/** Thrown when removeRemotePoolAddresses params are invalid. */ +export class CCIPRemoveRemotePoolAddressesParamsInvalidError extends CCIPError { + override readonly name = 'CCIPRemoveRemotePoolAddressesParamsInvalidError' + /** Creates a remove remote pool addresses params invalid error. */ + constructor(param: string, reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.REMOVE_REMOTE_POOL_ADDRESSES_PARAMS_INVALID, + `Invalid removeRemotePoolAddresses param "${param}": ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, param, reason }, + }, + ) + } +} + +/** Thrown when the removeRemotePoolAddresses transaction fails. */ +export class CCIPRemoveRemotePoolAddressesFailedError extends CCIPError { + override readonly name = 'CCIPRemoveRemotePoolAddressesFailedError' + /** Creates a remove remote pool addresses failed error. */ + constructor(reason: string, options?: CCIPErrorOptions) { + super( + CCIPErrorCode.REMOVE_REMOTE_POOL_ADDRESSES_FAILED, + `Remove remote pool addresses failed: ${reason}`, + { + ...options, + isTransient: false, + context: { ...options?.context, reason }, + }, + ) + } +} diff --git a/ccip-sdk/src/evm/abi/RegistryModuleOwnerCustom_1_6.ts b/ccip-sdk/src/evm/abi/RegistryModuleOwnerCustom_1_6.ts new file mode 100644 index 00000000..5fd9f05e --- /dev/null +++ b/ccip-sdk/src/evm/abi/RegistryModuleOwnerCustom_1_6.ts @@ -0,0 +1,85 @@ +export default [ + // generate: + // fetch('https://raw.githubusercontent.com/smartcontractkit/chainlink-ccip/release/contracts-ccip-1.6.2/chains/evm/gobindings/generated/v1_6_0/registry_module_owner_custom/registry_module_owner_custom.go') + // .then((res) => res.text()) + // .then((body) => body.match(/^\s*ABI: "(.*?)",$/m)?.[1]) + // .then((abi) => JSON.parse(abi.replace(/\\"/g, '"'))) + // .then((obj) => require('util').inspect(obj, {depth:99}).split('\n').slice(1, -1)) + { + type: 'constructor', + inputs: [ + { + name: 'tokenAdminRegistry', + type: 'address', + internalType: 'address', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'registerAccessControlDefaultAdmin', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'registerAdminViaGetCCIPAdmin', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'registerAdminViaOwner', + inputs: [{ name: 'token', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'typeAndVersion', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'event', + name: 'AdministratorRegistered', + inputs: [ + { + name: 'token', + type: 'address', + indexed: true, + internalType: 'address', + }, + { + name: 'administrator', + type: 'address', + indexed: true, + internalType: 'address', + }, + ], + anonymous: false, + }, + { type: 'error', name: 'AddressZero', inputs: [] }, + { + type: 'error', + name: 'CanOnlySelfRegister', + inputs: [ + { name: 'admin', type: 'address', internalType: 'address' }, + { name: 'token', type: 'address', internalType: 'address' }, + ], + }, + { + type: 'error', + name: 'RequiredRoleNotFound', + inputs: [ + { name: 'msgSender', type: 'address', internalType: 'address' }, + { name: 'role', type: 'bytes32', internalType: 'bytes32' }, + { name: 'token', type: 'address', internalType: 'address' }, + ], + }, + // generate:end +] as const diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index d562e86a..924b30f7 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -140,8 +140,8 @@ function toRateLimiterState(b: RateLimiterBucket): RateLimiterState { return b.isEnabled ? { tokens: b.tokens, capacity: b.capacity, rate: b.rate } : null } -/** typeguard for ethers Signer interface (used for `wallet`s) */ -function isSigner(wallet: unknown): wallet is Signer { +/** Typeguard for ethers Signer interface (used for `wallet`s). */ +export function isSigner(wallet: unknown): wallet is Signer { return ( typeof wallet === 'object' && wallet !== null && @@ -155,7 +155,7 @@ function isSigner(wallet: unknown): wallet is Signer { * Try sendTransaction() first (works with browser wallets), * fallback to signTransaction() + broadcastTransaction() if unsupported. */ -async function submitTransaction( +export async function submitTransaction( wallet: Signer, tx: TransactionRequest, provider: JsonRpcApiProvider, @@ -1688,14 +1688,14 @@ export class EVMChain extends Chain { const config = (await resultToObject(contract.getTokenConfig(token))) as CleanAddressable< Partial>> > - if (!config.administrator || config.administrator === ZeroAddress) + const hasPending = config.pendingAdministrator && config.pendingAdministrator !== ZeroAddress + if ((!config.administrator || config.administrator === ZeroAddress) && !hasPending) throw new CCIPTokenNotConfiguredError(token, registry) - if (!config.pendingAdministrator || config.pendingAdministrator === ZeroAddress) - delete config.pendingAdministrator + if (!hasPending) delete config.pendingAdministrator if (!config.tokenPool || config.tokenPool === ZeroAddress) delete config.tokenPool return { ...config, - administrator: config.administrator, + administrator: config.administrator ?? ZeroAddress, } } @@ -1721,13 +1721,22 @@ export class EVMChain extends Chain { ): Promise<{ token: string router: string + owner: string typeAndVersion: string + rateLimitAdmin?: string + feeAdmin?: string minBlockConfirmations?: number tokenTransferFeeConfig?: TokenTransferFeeConfig }> { const [type, version, typeAndVersion] = await this.typeAndVersion(tokenPool) - let token, router, minBlockConfirmations, tokenTransferFeeConfig + let token, + router, + owner, + rateLimitAdmin, + feeAdmin, + minBlockConfirmations, + tokenTransferFeeConfig if (version < CCIPVersion.V2_0) { const contract = new Contract( tokenPool, @@ -1735,7 +1744,9 @@ export class EVMChain extends Chain { this.provider, ) as unknown as TypedContract token = contract.getToken() + owner = contract.owner() router = contract.getRouter() + rateLimitAdmin = contract.getRateLimitAdmin() } else { if (type === 'USDCTokenPoolProxy') { const proxy = new Contract( @@ -1751,7 +1762,11 @@ export class EVMChain extends Chain { this.provider, ) as unknown as TypedContract token = contract.getToken() - router = contract.getDynamicConfig().then(([router]) => router) + owner = contract.owner() + const dynamicConfig = contract.getDynamicConfig() + router = dynamicConfig.then(([router]) => router) + rateLimitAdmin = dynamicConfig.then(([, rateLimitAdmin]) => rateLimitAdmin) + feeAdmin = dynamicConfig.then(([, , feeAdmin]) => feeAdmin) minBlockConfirmations = contract.getMinBlockConfirmations().catch((err) => { this.logger.debug( typeAndVersion, @@ -1795,12 +1810,31 @@ export class EVMChain extends Chain { } } - return Promise.all([token, router, minBlockConfirmations, tokenTransferFeeConfig]).then( - ([token, router, minBlockConfirmations, tokenTransferFeeConfig]) => { + return Promise.all([ + token, + router, + owner, + rateLimitAdmin, + feeAdmin, + minBlockConfirmations, + tokenTransferFeeConfig, + ]).then( + ([ + token, + router, + owner, + rateLimitAdmin, + feeAdmin, + minBlockConfirmations, + tokenTransferFeeConfig, + ]) => { return { token: token as CleanAddressable, router: router as CleanAddressable, + owner: owner as CleanAddressable, typeAndVersion, + ...(rateLimitAdmin && { rateLimitAdmin: rateLimitAdmin as string }), + ...(feeAdmin && { feeAdmin: feeAdmin as string }), ...(minBlockConfirmations != null && { minBlockConfirmations: Number(minBlockConfirmations), }), diff --git a/ccip-sdk/src/execution.test.ts b/ccip-sdk/src/execution.test.ts index 08bd7257..61f8fe44 100644 --- a/ccip-sdk/src/execution.test.ts +++ b/ccip-sdk/src/execution.test.ts @@ -139,9 +139,15 @@ class MockChain extends Chain { async getTokenPoolConfig(_tokenPool: string): Promise<{ token: string router: string + owner: string typeAndVersion?: string }> { - return { token: '0xToken', router: '0xRouter', typeAndVersion: 'TokenPool 1.5.0' } + return { + token: '0xToken', + router: '0xRouter', + owner: '0xOwner', + typeAndVersion: 'TokenPool 1.5.0', + } } async getTokenPoolRemotes(_pool: string, _remoteChainSelector: bigint): Promise { diff --git a/ccip-sdk/src/index.ts b/ccip-sdk/src/index.ts index d47942f0..f938417f 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -101,6 +101,30 @@ export { // errors export * from './errors/index.ts' +// token-admin shared types +export type { + AcceptOwnershipParams, + AppendRemotePoolAddressesParams, + AppendRemotePoolAddressesResult, + ApplyChainUpdatesParams, + ChainRateLimiterConfig, + DeleteChainConfigParams, + DeleteChainConfigResult, + ExecuteOwnershipTransferParams, + GrantMintBurnAccessParams, + MintBurnRole, + OwnershipResult, + RateLimiterConfig, + RemoteChainConfig, + RemoveRemotePoolAddressesParams, + RemoveRemotePoolAddressesResult, + RevokeMintBurnAccessParams, + RevokeMintBurnAccessResult, + SetChainRateLimiterConfigParams, + SetRateLimitAdminParams, + TransferOwnershipParams, +} from './token-admin/types.ts' + // chains import { AptosChain } from './aptos/index.ts' export type { UnsignedAptosTx } from './aptos/index.ts' diff --git a/ccip-sdk/src/solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts b/ccip-sdk/src/solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts new file mode 100644 index 00000000..5c7eefdd --- /dev/null +++ b/ccip-sdk/src/solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts @@ -0,0 +1,1972 @@ +// generate: +// fetch('https://github.com/smartcontractkit/chainlink-ccip/raw/refs/heads/main/chains/solana/contracts/target/types/lockrelease_token_pool.ts') +// .then((res) => res.text()) +// .then((text) => text.trim()) +export type LockreleaseTokenPool = { + version: '1.6.1' + name: 'lockrelease_token_pool' + instructions: [ + { + name: 'initGlobalConfig' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'routerAddress' + type: 'publicKey' + }, + { + name: 'rmnAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'updateSelfServedAllowed' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'selfServedAllowed' + type: 'bool' + }, + ] + }, + { + name: 'updateDefaultRouter' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'routerAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'updateDefaultRmn' + accounts: [ + { + name: 'config' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'rmnAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'initialize' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + { + name: 'config' + isMut: false + isSigner: false + }, + ] + args: [] + }, + { + name: 'typeVersion' + docs: [ + 'Returns the program type (name) and version.', + 'Used by offchain code to easily determine which program & version is being interacted with.', + '', + '# Arguments', + '* `ctx` - The context', + ] + accounts: [ + { + name: 'clock' + isMut: false + isSigner: false + }, + ] + args: [] + returns: 'string' + }, + { + name: 'transferOwnership' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'proposedOwner' + type: 'publicKey' + }, + ] + }, + { + name: 'acceptOwnership' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [] + }, + { + name: 'setRouter' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'newRouter' + type: 'publicKey' + }, + ] + }, + { + name: 'setRmn' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'program' + isMut: false + isSigner: false + }, + { + name: 'programData' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'rmnAddress' + type: 'publicKey' + }, + ] + }, + { + name: 'initializeStateVersion' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + ] + args: [ + { + name: 'mint' + type: 'publicKey' + }, + ] + }, + { + name: 'initChainRemoteConfig' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'cfg' + type: { + defined: 'RemoteConfig' + } + }, + ] + }, + { + name: 'editChainRemoteConfig' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'cfg' + type: { + defined: 'RemoteConfig' + } + }, + ] + }, + { + name: 'appendRemotePoolAddresses' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'addresses' + type: { + vec: { + defined: 'RemoteAddress' + } + } + }, + ] + }, + { + name: 'setChainRateLimit' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'inbound' + type: { + defined: 'RateLimitConfig' + } + }, + { + name: 'outbound' + type: { + defined: 'RateLimitConfig' + } + }, + ] + }, + { + name: 'setRateLimitAdmin' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + ] + args: [ + { + name: 'mint' + type: 'publicKey' + }, + { + name: 'newRateLimitAdmin' + type: 'publicKey' + }, + ] + }, + { + name: 'deleteChainConfig' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + ] + args: [ + { + name: 'remoteChainSelector' + type: 'u64' + }, + { + name: 'mint' + type: 'publicKey' + }, + ] + }, + { + name: 'configureAllowList' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'add' + type: { + vec: 'publicKey' + } + }, + { + name: 'enabled' + type: 'bool' + }, + ] + }, + { + name: 'removeFromAllowList' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: true + isSigner: true + }, + { + name: 'systemProgram' + isMut: false + isSigner: false + }, + ] + args: [ + { + name: 'remove' + type: { + vec: 'publicKey' + } + }, + ] + }, + { + name: 'releaseOrMintTokens' + accounts: [ + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'offrampProgram' + isMut: false + isSigner: false + docs: [ + 'CHECK offramp program: exists only to derive the allowed offramp PDA', + 'and the authority PDA.', + ] + }, + { + name: 'allowedOfframp' + isMut: false + isSigner: false + docs: [ + 'CHECK PDA of the router program verifying the signer is an allowed offramp.', + "If PDA does not exist, the router doesn't allow this offramp", + ] + }, + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + { + name: 'rmnRemote' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteCurses' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteConfig' + isMut: false + isSigner: false + }, + { + name: 'receiverTokenAccount' + isMut: true + isSigner: false + }, + ] + args: [ + { + name: 'releaseOrMint' + type: { + defined: 'ReleaseOrMintInV1' + } + }, + ] + returns: { + defined: 'ReleaseOrMintOutV1' + } + }, + { + name: 'lockOrBurnTokens' + accounts: [ + { + name: 'authority' + isMut: false + isSigner: true + }, + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'rmnRemote' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteCurses' + isMut: false + isSigner: false + }, + { + name: 'rmnRemoteConfig' + isMut: false + isSigner: false + }, + { + name: 'chainConfig' + isMut: true + isSigner: false + }, + ] + args: [ + { + name: 'lockOrBurn' + type: { + defined: 'LockOrBurnInV1' + } + }, + ] + returns: { + defined: 'LockOrBurnOutV1' + } + }, + { + name: 'setRebalancer' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'rebalancer' + type: 'publicKey' + }, + ] + }, + { + name: 'setCanAcceptLiquidity' + accounts: [ + { + name: 'state' + isMut: true + isSigner: false + }, + { + name: 'mint' + isMut: false + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'allow' + type: 'bool' + }, + ] + }, + { + name: 'provideLiquidity' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'remoteTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'amount' + type: 'u64' + }, + ] + }, + { + name: 'withdrawLiquidity' + accounts: [ + { + name: 'state' + isMut: false + isSigner: false + }, + { + name: 'tokenProgram' + isMut: false + isSigner: false + }, + { + name: 'mint' + isMut: true + isSigner: false + }, + { + name: 'poolSigner' + isMut: false + isSigner: false + }, + { + name: 'poolTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'remoteTokenAccount' + isMut: true + isSigner: false + }, + { + name: 'authority' + isMut: false + isSigner: true + }, + ] + args: [ + { + name: 'amount' + type: 'u64' + }, + ] + }, + ] + accounts: [ + { + name: 'poolConfig' + type: { + kind: 'struct' + fields: [ + { + name: 'version' + type: 'u8' + }, + { + name: 'selfServedAllowed' + type: 'bool' + }, + { + name: 'router' + type: 'publicKey' + }, + { + name: 'rmnRemote' + type: 'publicKey' + }, + ] + } + }, + { + name: 'state' + type: { + kind: 'struct' + fields: [ + { + name: 'version' + type: 'u8' + }, + { + name: 'config' + type: { + defined: 'BaseConfig' + } + }, + ] + } + }, + { + name: 'chainConfig' + type: { + kind: 'struct' + fields: [ + { + name: 'base' + type: { + defined: 'BaseChain' + } + }, + ] + } + }, + ] +} + +export const IDL: LockreleaseTokenPool = { + version: '1.6.1', + name: 'lockrelease_token_pool', + instructions: [ + { + name: 'initGlobalConfig', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'routerAddress', + type: 'publicKey', + }, + { + name: 'rmnAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'updateSelfServedAllowed', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'selfServedAllowed', + type: 'bool', + }, + ], + }, + { + name: 'updateDefaultRouter', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'routerAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'updateDefaultRmn', + accounts: [ + { + name: 'config', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'rmnAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'initialize', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + { + name: 'config', + isMut: false, + isSigner: false, + }, + ], + args: [], + }, + { + name: 'typeVersion', + docs: [ + 'Returns the program type (name) and version.', + 'Used by offchain code to easily determine which program & version is being interacted with.', + '', + '# Arguments', + '* `ctx` - The context', + ], + accounts: [ + { + name: 'clock', + isMut: false, + isSigner: false, + }, + ], + args: [], + returns: 'string', + }, + { + name: 'transferOwnership', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'proposedOwner', + type: 'publicKey', + }, + ], + }, + { + name: 'acceptOwnership', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [], + }, + { + name: 'setRouter', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'newRouter', + type: 'publicKey', + }, + ], + }, + { + name: 'setRmn', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'program', + isMut: false, + isSigner: false, + }, + { + name: 'programData', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'rmnAddress', + type: 'publicKey', + }, + ], + }, + { + name: 'initializeStateVersion', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + ], + args: [ + { + name: 'mint', + type: 'publicKey', + }, + ], + }, + { + name: 'initChainRemoteConfig', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'cfg', + type: { + defined: 'RemoteConfig', + }, + }, + ], + }, + { + name: 'editChainRemoteConfig', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'cfg', + type: { + defined: 'RemoteConfig', + }, + }, + ], + }, + { + name: 'appendRemotePoolAddresses', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'addresses', + type: { + vec: { + defined: 'RemoteAddress', + }, + }, + }, + ], + }, + { + name: 'setChainRateLimit', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'inbound', + type: { + defined: 'RateLimitConfig', + }, + }, + { + name: 'outbound', + type: { + defined: 'RateLimitConfig', + }, + }, + ], + }, + { + name: 'setRateLimitAdmin', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + ], + args: [ + { + name: 'mint', + type: 'publicKey', + }, + { + name: 'newRateLimitAdmin', + type: 'publicKey', + }, + ], + }, + { + name: 'deleteChainConfig', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + ], + args: [ + { + name: 'remoteChainSelector', + type: 'u64', + }, + { + name: 'mint', + type: 'publicKey', + }, + ], + }, + { + name: 'configureAllowList', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'add', + type: { + vec: 'publicKey', + }, + }, + { + name: 'enabled', + type: 'bool', + }, + ], + }, + { + name: 'removeFromAllowList', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: true, + isSigner: true, + }, + { + name: 'systemProgram', + isMut: false, + isSigner: false, + }, + ], + args: [ + { + name: 'remove', + type: { + vec: 'publicKey', + }, + }, + ], + }, + { + name: 'releaseOrMintTokens', + accounts: [ + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'offrampProgram', + isMut: false, + isSigner: false, + docs: [ + 'CHECK offramp program: exists only to derive the allowed offramp PDA', + 'and the authority PDA.', + ], + }, + { + name: 'allowedOfframp', + isMut: false, + isSigner: false, + docs: [ + 'CHECK PDA of the router program verifying the signer is an allowed offramp.', + "If PDA does not exist, the router doesn't allow this offramp", + ], + }, + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + { + name: 'rmnRemote', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteCurses', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteConfig', + isMut: false, + isSigner: false, + }, + { + name: 'receiverTokenAccount', + isMut: true, + isSigner: false, + }, + ], + args: [ + { + name: 'releaseOrMint', + type: { + defined: 'ReleaseOrMintInV1', + }, + }, + ], + returns: { + defined: 'ReleaseOrMintOutV1', + }, + }, + { + name: 'lockOrBurnTokens', + accounts: [ + { + name: 'authority', + isMut: false, + isSigner: true, + }, + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'rmnRemote', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteCurses', + isMut: false, + isSigner: false, + }, + { + name: 'rmnRemoteConfig', + isMut: false, + isSigner: false, + }, + { + name: 'chainConfig', + isMut: true, + isSigner: false, + }, + ], + args: [ + { + name: 'lockOrBurn', + type: { + defined: 'LockOrBurnInV1', + }, + }, + ], + returns: { + defined: 'LockOrBurnOutV1', + }, + }, + { + name: 'setRebalancer', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'rebalancer', + type: 'publicKey', + }, + ], + }, + { + name: 'setCanAcceptLiquidity', + accounts: [ + { + name: 'state', + isMut: true, + isSigner: false, + }, + { + name: 'mint', + isMut: false, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'allow', + type: 'bool', + }, + ], + }, + { + name: 'provideLiquidity', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'remoteTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'amount', + type: 'u64', + }, + ], + }, + { + name: 'withdrawLiquidity', + accounts: [ + { + name: 'state', + isMut: false, + isSigner: false, + }, + { + name: 'tokenProgram', + isMut: false, + isSigner: false, + }, + { + name: 'mint', + isMut: true, + isSigner: false, + }, + { + name: 'poolSigner', + isMut: false, + isSigner: false, + }, + { + name: 'poolTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'remoteTokenAccount', + isMut: true, + isSigner: false, + }, + { + name: 'authority', + isMut: false, + isSigner: true, + }, + ], + args: [ + { + name: 'amount', + type: 'u64', + }, + ], + }, + ], + accounts: [ + { + name: 'poolConfig', + type: { + kind: 'struct', + fields: [ + { + name: 'version', + type: 'u8', + }, + { + name: 'selfServedAllowed', + type: 'bool', + }, + { + name: 'router', + type: 'publicKey', + }, + { + name: 'rmnRemote', + type: 'publicKey', + }, + ], + }, + }, + { + name: 'state', + type: { + kind: 'struct', + fields: [ + { + name: 'version', + type: 'u8', + }, + { + name: 'config', + type: { + defined: 'BaseConfig', + }, + }, + ], + }, + }, + { + name: 'chainConfig', + type: { + kind: 'struct', + fields: [ + { + name: 'base', + type: { + defined: 'BaseChain', + }, + }, + ], + }, + }, + ], +} +// generate:end diff --git a/ccip-sdk/src/solana/index.ts b/ccip-sdk/src/solana/index.ts index eb9c5bfb..614ca228 100644 --- a/ccip-sdk/src/solana/index.ts +++ b/ccip-sdk/src/solana/index.ts @@ -1326,6 +1326,8 @@ export class SolanaChain extends Chain { administrator: string pendingAdministrator?: string tokenPool?: string + poolLookupTable?: string + poolLookupTableEntries?: string[] }> { const registry_ = new PublicKey(registry) const tokenMint = new PublicKey(token) @@ -1342,6 +1344,8 @@ export class SolanaChain extends Chain { administrator: string pendingAdministrator?: string tokenPool?: string + poolLookupTable?: string + poolLookupTableEntries?: string[] } = { administrator: encodeBase58(tokenAdminRegistry.data.subarray(9, 9 + 32)), } @@ -1355,15 +1359,20 @@ export class SolanaChain extends Chain { config.pendingAdministrator = pendingAdministrator.toBase58() } - // Get token pool from lookup table if available + // Get token pool and lookup table from TAR data if available try { const lookupTableAddr = new PublicKey(tokenAdminRegistry.data.subarray(73, 73 + 32)) - const lookupTable = await this.connection.getAddressLookupTable(lookupTableAddr) - if (lookupTable.value) { - // tokenPool state PDA is at index [3] - const tokenPoolAddress = lookupTable.value.state.addresses[3] - if (tokenPoolAddress && !tokenPoolAddress.equals(PublicKey.default)) { - config.tokenPool = tokenPoolAddress.toBase58() + if (!lookupTableAddr.equals(PublicKey.default)) { + config.poolLookupTable = lookupTableAddr.toBase58() + const lookupTable = await this.connection.getAddressLookupTable(lookupTableAddr) + if (lookupTable.value) { + // Return all ALT entries + config.poolLookupTableEntries = lookupTable.value.state.addresses.map((a) => a.toBase58()) + // tokenPool state PDA is at index [3] + const tokenPoolAddress = lookupTable.value.state.addresses[3] + if (tokenPoolAddress && !tokenPoolAddress.equals(PublicKey.default)) { + config.tokenPool = tokenPoolAddress.toBase58() + } } } } catch (_err) { @@ -1382,6 +1391,9 @@ export class SolanaChain extends Chain { ): Promise<{ token: string router: string + owner: string + proposedOwner?: string + rateLimitAdmin?: string tokenPoolProgram: string typeAndVersion?: string }> { @@ -1398,14 +1410,27 @@ export class SolanaChain extends Chain { // TokenPool may not have a typeAndVersion } - // const { config }: { config: IdlTypes['BaseConfig'] } = - // tokenPoolCoder.accounts.decode('state', tokenPoolState.data) - const mint = new PublicKey(tokenPoolState.data.subarray(41, 41 + 32)) - const router = new PublicKey(tokenPoolState.data.subarray(266, 266 + 32)) + const { + config, + }: { + config: { + mint: PublicKey + router: PublicKey + owner: PublicKey + proposedOwner: PublicKey + rateLimitAdmin: PublicKey + } + } = tokenPoolCoder.accounts.decode('state', tokenPoolState.data) + + const isProposedOwnerZero = config.proposedOwner.equals(PublicKey.default) + const isRateLimitAdminZero = config.rateLimitAdmin.equals(PublicKey.default) return { - token: mint.toBase58(), - router: router.toBase58(), + token: config.mint.toBase58(), + router: config.router.toBase58(), + owner: config.owner.toBase58(), + ...(isProposedOwnerZero ? {} : { proposedOwner: config.proposedOwner.toBase58() }), + ...(isRateLimitAdminZero ? {} : { rateLimitAdmin: config.rateLimitAdmin.toBase58() }), tokenPoolProgram, typeAndVersion, } diff --git a/ccip-sdk/src/solana/utils.ts b/ccip-sdk/src/solana/utils.ts index 32977a4b..cb222fb9 100644 --- a/ccip-sdk/src/solana/utils.ts +++ b/ccip-sdk/src/solana/utils.ts @@ -90,6 +90,35 @@ export async function resolveATA( } } +/** CCIP token pool signer PDA seed. */ +const CCIP_TOKENPOOL_SIGNER_SEED = 'ccip_tokenpool_signer' + +/** + * Derives the Pool Signer PDA for a given mint and pool program. + * Seeds: `["ccip_tokenpool_signer", mint]` + * + * The Pool Signer PDA is the authority that signs mint/burn transactions + * autonomously for CCIP cross-chain operations. + * + * @param mint - Token mint public key + * @param poolProgramId - Pool program public key + * @returns `[poolSignerPda, bump]` + * + * @example + * ```typescript + * const [poolSignerPda] = derivePoolSignerPDA(mintPubkey, poolProgramPubkey) + * ``` + */ +export function derivePoolSignerPDA( + mint: PublicKey, + poolProgramId: PublicKey, +): [PublicKey, number] { + return PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_SIGNER_SEED), mint.toBuffer()], + poolProgramId, + ) +} + /** * Generates a hex-encoded discriminator for a Solana event. * @param eventName - Name of the event. diff --git a/ccip-sdk/src/token-admin/apply-chain-updates-utils.ts b/ccip-sdk/src/token-admin/apply-chain-updates-utils.ts new file mode 100644 index 00000000..d2ee1510 --- /dev/null +++ b/ccip-sdk/src/token-admin/apply-chain-updates-utils.ts @@ -0,0 +1,206 @@ +/** + * Shared utilities for applyChainUpdates across all chain families. + * + * Contains validation and address encoding logic used by EVM, Solana, and Aptos + * implementations to avoid code duplication. + * + * @packageDocumentation + */ + +import { hexlify, zeroPadValue } from 'ethers' + +import { + CCIPAppendRemotePoolAddressesParamsInvalidError, + CCIPApplyChainUpdatesParamsInvalidError, + CCIPDeleteChainConfigParamsInvalidError, + CCIPRemoveRemotePoolAddressesParamsInvalidError, +} from '../errors/index.ts' +import { getAddressBytes } from '../utils.ts' +import type { + AppendRemotePoolAddressesParams, + ApplyChainUpdatesParams, + DeleteChainConfigParams, + RemoveRemotePoolAddressesParams, +} from './types.ts' + +/** + * Validates applyChainUpdates parameters. + * + * Checks that poolAddress is non-empty and each chain config has valid fields: + * - `remoteChainSelector` must be non-empty + * - `remotePoolAddresses` must have at least one address + * - `remoteTokenAddress` must be non-empty + * + * @param params - Apply chain updates parameters to validate + * @throws {@link CCIPApplyChainUpdatesParamsInvalidError} on invalid params + */ +export function validateApplyChainUpdatesParams(params: ApplyChainUpdatesParams): void { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPApplyChainUpdatesParamsInvalidError('poolAddress', 'must be non-empty') + } + for (let i = 0; i < params.chainsToAdd.length; i++) { + const chain = params.chainsToAdd[i]! + if (chain.remoteChainSelector == null || chain.remoteChainSelector === 0n) { + throw new CCIPApplyChainUpdatesParamsInvalidError( + `chainsToAdd[${i}].remoteChainSelector`, + 'must be non-zero', + ) + } + if (chain.remotePoolAddresses.length === 0) { + throw new CCIPApplyChainUpdatesParamsInvalidError( + `chainsToAdd[${i}].remotePoolAddresses`, + 'must have at least one address', + ) + } + if (!chain.remoteTokenAddress || chain.remoteTokenAddress.trim().length === 0) { + throw new CCIPApplyChainUpdatesParamsInvalidError( + `chainsToAdd[${i}].remoteTokenAddress`, + 'must be non-empty', + ) + } + } +} + +/** + * Encodes a remote address to 32-byte left-padded hex string. + * + * Handles all chain families: hex (EVM/Aptos), base58 (Solana), base64 (Sui/TON). + * Uses `getAddressBytes()` for universal address decoding + `zeroPadValue()` for 32-byte padding. + * Matches chainlink-deployments' `common.LeftPadBytes(addr.Bytes(), 32)`. + * + * @param address - Address in native format (hex, base58, base64) + * @returns 32-byte left-padded hex string (0x-prefixed) + */ +export function encodeRemoteAddress(address: string): string { + const bytes = getAddressBytes(address) + return zeroPadValue(hexlify(bytes), 32) +} + +/** + * Encodes a remote address to 32-byte left-padded Uint8Array. + * + * Same as {@link encodeRemoteAddress} but returns raw bytes instead of hex string. + * Used by Solana for Borsh encoding. + * + * @param address - Address in native format (hex, base58, base64) + * @returns 32-byte left-padded Uint8Array + */ +export function encodeRemoteAddressBytes(address: string): Uint8Array { + const bytes = getAddressBytes(address) + const hex = zeroPadValue(hexlify(bytes), 32) + return Uint8Array.from(Buffer.from(hex.slice(2), 'hex')) +} + +/** + * Encodes a remote pool address to raw bytes (no padding). + * + * Unlike token addresses which are always left-padded to 32 bytes, pool addresses + * preserve their original byte length (e.g. 20 bytes for EVM, 32 bytes for Solana). + * This matches the on-chain Solana program's expectation for pool address comparison + * during ReleaseOrMintTokens. + * + * @param address - Address in native format (hex, base58, base64) + * @returns Raw bytes Uint8Array (original length, no padding) + */ +export function encodeRemotePoolAddressBytes(address: string): Uint8Array { + return getAddressBytes(address) +} + +/** + * Validates appendRemotePoolAddresses parameters. + * + * Checks that: + * - `poolAddress` is non-empty + * - `remoteChainSelector` is non-empty + * - `remotePoolAddresses` has at least one entry, each non-empty + * + * @param params - Append remote pool addresses parameters to validate + * @throws {@link CCIPAppendRemotePoolAddressesParamsInvalidError} on invalid params + */ +export function validateAppendRemotePoolAddressesParams( + params: AppendRemotePoolAddressesParams, +): void { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPAppendRemotePoolAddressesParamsInvalidError('poolAddress', 'must be non-empty') + } + if (params.remoteChainSelector == null || params.remoteChainSelector === 0n) { + throw new CCIPAppendRemotePoolAddressesParamsInvalidError( + 'remoteChainSelector', + 'must be non-zero', + ) + } + if (params.remotePoolAddresses.length === 0) { + throw new CCIPAppendRemotePoolAddressesParamsInvalidError( + 'remotePoolAddresses', + 'must have at least one address', + ) + } + for (let i = 0; i < params.remotePoolAddresses.length; i++) { + const addr = params.remotePoolAddresses[i]! + if (!addr || addr.trim().length === 0) { + throw new CCIPAppendRemotePoolAddressesParamsInvalidError( + `remotePoolAddresses[${i}]`, + 'must be non-empty', + ) + } + } +} + +/** + * Validates deleteChainConfig parameters. + * + * Checks that: + * - `poolAddress` is non-empty + * - `remoteChainSelector` is non-empty + * + * @param params - Delete chain config parameters to validate + * @throws {@link CCIPDeleteChainConfigParamsInvalidError} on invalid params + */ +export function validateDeleteChainConfigParams(params: DeleteChainConfigParams): void { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPDeleteChainConfigParamsInvalidError('poolAddress', 'must be non-empty') + } + if (params.remoteChainSelector == null || params.remoteChainSelector === 0n) { + throw new CCIPDeleteChainConfigParamsInvalidError('remoteChainSelector', 'must be non-zero') + } +} + +/** + * Validates removeRemotePoolAddresses parameters. + * + * Checks that: + * - `poolAddress` is non-empty + * - `remoteChainSelector` is non-empty + * - `remotePoolAddresses` has at least one entry, each non-empty + * + * @param params - Remove remote pool addresses parameters to validate + * @throws {@link CCIPRemoveRemotePoolAddressesParamsInvalidError} on invalid params + */ +export function validateRemoveRemotePoolAddressesParams( + params: RemoveRemotePoolAddressesParams, +): void { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPRemoveRemotePoolAddressesParamsInvalidError('poolAddress', 'must be non-empty') + } + if (params.remoteChainSelector == null || params.remoteChainSelector === 0n) { + throw new CCIPRemoveRemotePoolAddressesParamsInvalidError( + 'remoteChainSelector', + 'must be non-zero', + ) + } + if (params.remotePoolAddresses.length === 0) { + throw new CCIPRemoveRemotePoolAddressesParamsInvalidError( + 'remotePoolAddresses', + 'must have at least one address', + ) + } + for (let i = 0; i < params.remotePoolAddresses.length; i++) { + const addr = params.remotePoolAddresses[i]! + if (!addr || addr.trim().length === 0) { + throw new CCIPRemoveRemotePoolAddressesParamsInvalidError( + `remotePoolAddresses[${i}]`, + 'must be non-empty', + ) + } + } +} diff --git a/ccip-sdk/src/token-admin/aptos/aptos-accept-admin-role.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-accept-admin-role.test.ts new file mode 100644 index 00000000..172ba912 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-accept-admin-role.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPAcceptAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams = { + tokenAddress: '0x89fd6b14b4a7', + routerAddress: '0xabc123', +} + +// ============================================================================= +// AptosTokenAdmin — acceptAdminRole +// ============================================================================= + +describe('AptosTokenAdmin — acceptAdminRole', () => { + // =========================================================================== + // generateUnsignedAcceptAdminRole — Validation + // =========================================================================== + + describe('generateUnsignedAcceptAdminRole — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAcceptAdminRole(sender, { + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptAdminRoleParamsInvalidError) + assert.equal(err.code, 'ACCEPT_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAcceptAdminRole(sender, { + ...validParams, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider)', async () => { + // Validation passes, fails at buildTransaction (mock provider) + await assert.rejects( + () => admin.generateUnsignedAcceptAdminRole(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPAcceptAdminRoleParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // acceptAdminRole — Wallet Validation + // =========================================================================== + + describe('acceptAdminRole — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.acceptAdminRole({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.acceptAdminRole(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.acceptAdminRole(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-append-remote-pool-addresses.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-append-remote-pool-addresses.test.ts new file mode 100644 index 00000000..5236f757 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-append-remote-pool-addresses.test.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPAppendRemotePoolAddressesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { AppendRemotePoolAddressesParams } from '../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'managed_token_pool' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams: AppendRemotePoolAddressesParams = { + poolAddress: '0xaabbcc', + remoteChainSelector: 16015286601757825753n, + remotePoolAddresses: ['0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD'], +} + +// ============================================================================= +// AptosTokenAdmin — appendRemotePoolAddresses +// ============================================================================= + +describe('AptosTokenAdmin — appendRemotePoolAddresses', () => { + // =========================================================================== + // generateUnsignedAppendRemotePoolAddresses — Validation + // =========================================================================== + + describe('generateUnsignedAppendRemotePoolAddresses — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.code, 'APPEND_REMOTE_POOL_ADDRESSES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + remotePoolAddresses: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty address in remotePoolAddresses array', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + remotePoolAddresses: [''], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses[0]') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider for tx build)', async () => { + // Validation passes, module discovery succeeds, but fails at buildTransaction + await assert.rejects( + () => admin.generateUnsignedAppendRemotePoolAddresses(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // appendRemotePoolAddresses — Wallet Validation + // =========================================================================== + + describe('appendRemotePoolAddresses — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.appendRemotePoolAddresses({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.appendRemotePoolAddresses(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.appendRemotePoolAddresses(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-apply-chain-updates.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-apply-chain-updates.test.ts new file mode 100644 index 00000000..913a9a4d --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-apply-chain-updates.test.ts @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPApplyChainUpdatesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { ApplyChainUpdatesParams } from '../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'managed_token_pool' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams: ApplyChainUpdatesParams = { + poolAddress: '0xaabbcc', + remoteChainSelectorsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector: 16015286601757825753n, + remotePoolAddresses: ['0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD'], + remoteTokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], +} + +// ============================================================================= +// AptosTokenAdmin — applyChainUpdates +// ============================================================================= + +describe('AptosTokenAdmin — applyChainUpdates', () => { + // =========================================================================== + // generateUnsignedApplyChainUpdates — Validation + // =========================================================================== + + describe('generateUnsignedApplyChainUpdates — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.code, 'APPLY_CHAIN_UPDATES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remoteChainSelector: 0n }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remotePoolAddresses: [] }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty remoteTokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remoteTokenAddress: '' }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remoteTokenAddress') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider for tx build)', async () => { + // Validation passes, module discovery succeeds, but fails at buildTransaction + await assert.rejects( + () => admin.generateUnsignedApplyChainUpdates(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPApplyChainUpdatesParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // applyChainUpdates — Wallet Validation + // =========================================================================== + + describe('applyChainUpdates — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.applyChainUpdates({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.applyChainUpdates(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.applyChainUpdates(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-delete-chain-config.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-delete-chain-config.test.ts new file mode 100644 index 00000000..c2ffae62 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-delete-chain-config.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPDeleteChainConfigParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { DeleteChainConfigParams } from '../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'managed_token_pool' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams: DeleteChainConfigParams = { + poolAddress: '0xaabbcc', + remoteChainSelector: 16015286601757825753n, +} + +// ============================================================================= +// AptosTokenAdmin — deleteChainConfig +// ============================================================================= + +describe('AptosTokenAdmin — deleteChainConfig', () => { + // =========================================================================== + // generateUnsignedDeleteChainConfig — Validation + // =========================================================================== + + describe('generateUnsignedDeleteChainConfig — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeleteChainConfig(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPDeleteChainConfigParamsInvalidError) + assert.equal(err.code, 'DELETE_CHAIN_CONFIG_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeleteChainConfig(sender, { + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPDeleteChainConfigParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider for tx build)', async () => { + // Validation passes, module discovery succeeds, but fails at buildTransaction + await assert.rejects( + () => admin.generateUnsignedDeleteChainConfig(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPDeleteChainConfigParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // deleteChainConfig — Wallet Validation + // =========================================================================== + + describe('deleteChainConfig — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.deleteChainConfig({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.deleteChainConfig(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.deleteChainConfig(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-get-mint-burn-roles.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-get-mint-burn-roles.test.ts new file mode 100644 index 00000000..8ec0c460 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-get-mint-burn-roles.test.ts @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const CODE_OBJECT = '0xcode_object' +const CODE_OBJECT_OWNER = '0xcode_object_owner' +const TOKEN_STATE_OWNER = '0xtoken_state_owner' +const TOKEN_ADDRESS = '0x89fd6b14b4a7' + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +function makeAdmin(provider: Aptos): AptosTokenAdmin { + return new AptosTokenAdmin(provider, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +/** + * Creates a mock provider for managed_token that returns the given minters and burners. + */ +function mockProviderManaged(minters: string[], burners: string[]) { + return { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [], + view: async ({ + payload, + }: { + payload: { function: string; typeArguments?: string[]; functionArguments?: string[] } + }) => { + const fn = payload.function + // resolveTokenCodeObject: first owner call → tokenStateOwner + if ( + fn === '0x1::object::owner' && + payload.typeArguments?.[0] === '0x1::fungible_asset::Metadata' + ) { + return [TOKEN_STATE_OWNER] + } + // resolveTokenCodeObject: second owner call (tokenState → codeObject) + // + owner resolution call (codeObject → owner) + if (fn === '0x1::object::owner' && payload.typeArguments?.[0] === '0x1::object::ObjectCore') { + if (payload.functionArguments?.[0] === TOKEN_STATE_OWNER) return [CODE_OBJECT] + if (payload.functionArguments?.[0] === CODE_OBJECT) return [CODE_OBJECT_OWNER] + } + // managed_token view calls + if (fn === `${CODE_OBJECT}::managed_token::get_allowed_minters`) return [minters] + if (fn === `${CODE_OBJECT}::managed_token::get_allowed_burners`) return [burners] + throw new Error(`Unexpected view call: ${fn}`) + }, + } as unknown as Aptos +} + +/** + * Creates a mock provider for regulated_token that returns the given minters, burners, + * and bridgeMintersOrBurners. + */ +function mockProviderRegulated( + minters: string[], + burners: string[], + bridgeMintersOrBurners: string[], +) { + return { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [], + view: async ({ + payload, + }: { + payload: { function: string; typeArguments?: string[]; functionArguments?: string[] } + }) => { + const fn = payload.function + // resolveTokenCodeObject + if ( + fn === '0x1::object::owner' && + payload.typeArguments?.[0] === '0x1::fungible_asset::Metadata' + ) { + return [TOKEN_STATE_OWNER] + } + if (fn === '0x1::object::owner' && payload.typeArguments?.[0] === '0x1::object::ObjectCore') { + if (payload.functionArguments?.[0] === TOKEN_STATE_OWNER) return [CODE_OBJECT] + if (payload.functionArguments?.[0] === CODE_OBJECT) return [CODE_OBJECT_OWNER] + } + // managed_token calls should fail so we fall through to regulated + if (fn.includes('managed_token::')) throw new Error('not managed') + // regulated_token view calls + if (fn === `${CODE_OBJECT}::regulated_token::get_minters`) return [minters] + if (fn === `${CODE_OBJECT}::regulated_token::get_burners`) return [burners] + if (fn === `${CODE_OBJECT}::regulated_token::get_bridge_minters_or_burners`) + return [bridgeMintersOrBurners] + throw new Error(`Unexpected view call: ${fn}`) + }, + } as unknown as Aptos +} + +/** + * Creates a mock provider where both managed and regulated calls fail, + * so getMintBurnRoles returns unknown. + */ +function mockProviderUnknown() { + return { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [], + view: async ({ + payload, + }: { + payload: { function: string; typeArguments?: string[]; functionArguments?: string[] } + }) => { + const fn = payload.function + // resolveTokenCodeObject + if ( + fn === '0x1::object::owner' && + payload.typeArguments?.[0] === '0x1::fungible_asset::Metadata' + ) { + return [TOKEN_STATE_OWNER] + } + if (fn === '0x1::object::owner' && payload.typeArguments?.[0] === '0x1::object::ObjectCore') { + if (payload.functionArguments?.[0] === TOKEN_STATE_OWNER) return [CODE_OBJECT] + if (payload.functionArguments?.[0] === CODE_OBJECT) return [CODE_OBJECT_OWNER] + } + // Both managed and regulated fail + throw new Error('unknown module') + }, + } as unknown as Aptos +} + +// ============================================================================= +// AptosTokenAdmin — getMintBurnRoles +// ============================================================================= + +describe('AptosTokenAdmin — getMintBurnRoles', () => { + // =========================================================================== + // Managed token + // =========================================================================== + + describe('managed token', () => { + it('should return managed token roles with correct minters and burners', async () => { + const minters = ['0xminter1', '0xminter2'] + const burners = ['0xburner1'] + const admin = makeAdmin(mockProviderManaged(minters, burners)) + + const result = await admin.getMintBurnRoles(TOKEN_ADDRESS) + + assert.equal(result.tokenModule, 'managed') + assert.equal(result.owner, CODE_OBJECT_OWNER) + assert.deepEqual(result.allowedMinters, minters) + assert.deepEqual(result.allowedBurners, burners) + assert.equal(result.bridgeMintersOrBurners, undefined) + }) + + it('should return empty arrays when no roles granted', async () => { + const admin = makeAdmin(mockProviderManaged([], [])) + + const result = await admin.getMintBurnRoles(TOKEN_ADDRESS) + + assert.equal(result.tokenModule, 'managed') + assert.equal(result.owner, CODE_OBJECT_OWNER) + assert.deepEqual(result.allowedMinters, []) + assert.deepEqual(result.allowedBurners, []) + }) + }) + + // =========================================================================== + // Regulated token + // =========================================================================== + + describe('regulated token', () => { + it('should return regulated token roles with minters, burners, and bridgeMintersOrBurners', async () => { + const minters = ['0xreg_minter'] + const burners = ['0xreg_burner1', '0xreg_burner2'] + const bridge = ['0xbridge1'] + const admin = makeAdmin(mockProviderRegulated(minters, burners, bridge)) + + const result = await admin.getMintBurnRoles(TOKEN_ADDRESS) + + assert.equal(result.tokenModule, 'regulated') + assert.equal(result.owner, CODE_OBJECT_OWNER) + assert.deepEqual(result.allowedMinters, minters) + assert.deepEqual(result.allowedBurners, burners) + assert.deepEqual(result.bridgeMintersOrBurners, bridge) + }) + + it('should return empty arrays when no roles granted', async () => { + const admin = makeAdmin(mockProviderRegulated([], [], [])) + + const result = await admin.getMintBurnRoles(TOKEN_ADDRESS) + + assert.equal(result.tokenModule, 'regulated') + assert.equal(result.owner, CODE_OBJECT_OWNER) + assert.deepEqual(result.allowedMinters, []) + assert.deepEqual(result.allowedBurners, []) + assert.deepEqual(result.bridgeMintersOrBurners, []) + }) + }) + + // =========================================================================== + // Unknown token module + // =========================================================================== + + describe('unknown token module', () => { + it('should return unknown when neither managed nor regulated', async () => { + const admin = makeAdmin(mockProviderUnknown()) + + const result = await admin.getMintBurnRoles(TOKEN_ADDRESS) + + assert.equal(result.tokenModule, 'unknown') + assert.equal(result.owner, CODE_OBJECT_OWNER) + assert.equal(result.allowedMinters, undefined) + assert.equal(result.allowedBurners, undefined) + assert.equal(result.bridgeMintersOrBurners, undefined) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-grant-mint-burn-access.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-grant-mint-burn-access.test.ts new file mode 100644 index 00000000..3dd9f78e --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-grant-mint-burn-access.test.ts @@ -0,0 +1,257 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPGrantMintBurnAccessParamsInvalidError, + CCIPTokenPoolInfoNotFoundError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +/** Creates a mock provider that returns the given pool module name. */ +function mockProviderWithPool(moduleName: string) { + return { + getTransactionByVersion: async () => ({}), + getAccountInfo: async () => ({ sequence_number: '0' }), + getAccountModules: async () => [{ abi: { name: moduleName } }], + view: async ({ payload }: { payload: { function: string } }) => { + const fn = payload.function + // get_store_address: return a deterministic pool resource signer address + if (fn.includes('get_store_address')) return ['0xpool_resource_signer'] + // object::owner calls for resolveTokenCodeObject + if (fn.includes('object::owner')) return ['0xcode_object'] + // get_token: used by discoverPoolModule + if (fn.includes('get_token')) return ['0xtoken_address'] + return ['0x123'] + }, + transaction: { + build: { + simple: async () => ({ + bcsToBytes: () => new Uint8Array([1, 2, 3]), + }), + }, + }, + } as unknown as Aptos +} + +/** Mock provider with no pool modules. */ +const mockProviderNoPool = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'unrelated_module' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +function makeAdmin(provider?: Aptos): AptosTokenAdmin { + return new AptosTokenAdmin(provider ?? mockProviderWithPool('managed_token_pool'), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams = { + tokenAddress: '0x89fd6b14b4a7', + authority: '0xabc123pool', +} + +// ============================================================================= +// AptosTokenAdmin — grantMintBurnAccess +// ============================================================================= + +describe('AptosTokenAdmin — grantMintBurnAccess', () => { + // =========================================================================== + // generateUnsignedGrantMintBurnAccess — Validation + // =========================================================================== + + describe('generateUnsignedGrantMintBurnAccess — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.code, 'GRANT_MINT_BURN_ACCESS_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty authority', async () => { + await assert.rejects( + () => + admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + authority: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + return true + }, + ) + }) + }) + + // =========================================================================== + // Pool Type Detection + // =========================================================================== + + describe('generateUnsignedGrantMintBurnAccess — pool type detection', () => { + it('should return 2 transactions for managed pool (minter + burner updates)', async () => { + const admin = makeAdmin(mockProviderWithPool('managed_token_pool')) + const { transactions } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + assert.equal(transactions.length, 2) + assert.equal(transactions[0]!.family, ChainFamily.Aptos) + assert.equal(transactions[1]!.family, ChainFamily.Aptos) + }) + + it('should return 1 transaction for managed pool with role: mint', async () => { + const admin = makeAdmin(mockProviderWithPool('managed_token_pool')) + const { transactions } = await admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + role: 'mint', + }) + + assert.equal(transactions.length, 1, 'should have 1 tx (minter update only)') + assert.equal(transactions[0]!.family, ChainFamily.Aptos) + }) + + it('should return 1 transaction for managed pool with role: burn', async () => { + const admin = makeAdmin(mockProviderWithPool('managed_token_pool')) + const { transactions } = await admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + role: 'burn', + }) + + assert.equal(transactions.length, 1, 'should have 1 tx (burner update only)') + assert.equal(transactions[0]!.family, ChainFamily.Aptos) + }) + + it('should return 1 transaction for regulated pool (grant_role)', async () => { + const admin = makeAdmin(mockProviderWithPool('regulated_token_pool')) + const { transactions } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + assert.equal(transactions.length, 1) + assert.equal(transactions[0]!.family, ChainFamily.Aptos) + }) + + it('should return 1 transaction for regulated pool with role: mint', async () => { + const admin = makeAdmin(mockProviderWithPool('regulated_token_pool')) + const { transactions } = await admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + role: 'mint', + }) + + assert.equal(transactions.length, 1) + }) + + it('should return 1 transaction for regulated pool with role: burn', async () => { + const admin = makeAdmin(mockProviderWithPool('regulated_token_pool')) + const { transactions } = await admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + role: 'burn', + }) + + assert.equal(transactions.length, 1) + }) + + it('should reject lock-release pool (does not mint/burn)', async () => { + const admin = makeAdmin(mockProviderWithPool('lock_release_token_pool')) + await assert.rejects( + () => admin.generateUnsignedGrantMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + assert.ok(err.message.includes('lock-release')) + return true + }, + ) + }) + + it('should reject burn-mint pool (requires initialize with BurnRef/MintRef)', async () => { + const admin = makeAdmin(mockProviderWithPool('burn_mint_token_pool')) + await assert.rejects( + () => admin.generateUnsignedGrantMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + assert.ok(err.message.includes('initialize()')) + return true + }, + ) + }) + + it('should throw when no pool module found', async () => { + const admin = makeAdmin(mockProviderNoPool) + await assert.rejects( + () => admin.generateUnsignedGrantMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenPoolInfoNotFoundError) + return true + }, + ) + }) + }) + + // =========================================================================== + // grantMintBurnAccess — Wallet Validation + // =========================================================================== + + describe('grantMintBurnAccess — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject string wallet', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess('not-a-wallet', validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-pool-deploy.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-pool-deploy.test.ts new file mode 100644 index 00000000..113a2f08 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-pool-deploy.test.ts @@ -0,0 +1,319 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { CCIPPoolDeployParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +/** Valid params for managed pool (default tokenModule). */ +const managedParams = { + poolType: 'burn-mint' as const, + tokenAddress: '0x89fd6b14b4a7', + localTokenDecimals: 8, + routerAddress: '0xabc123', + mcmsAddress: '0x789abc', +} + +/** Valid params for generic burn-mint pool. */ +const genericBurnMintParams = { + poolType: 'burn-mint' as const, + tokenModule: 'generic' as const, + tokenAddress: '0x89fd6b14b4a7', + localTokenDecimals: 8, + routerAddress: '0xabc123', + mcmsAddress: '0x789abc', +} + +/** Valid params for generic lock-release pool. */ +const genericLockReleaseParams = { + ...genericBurnMintParams, + poolType: 'lock-release' as const, +} + +/** Valid params for regulated pool. */ +const regulatedParams = { + poolType: 'burn-mint' as const, + tokenModule: 'regulated' as const, + tokenAddress: '0x89fd6b14b4a7', + localTokenDecimals: 8, + routerAddress: '0xabc123', + adminAddress: '0x456abc', + mcmsAddress: '0x789abc', +} + +// ============================================================================= +// AptosTokenAdmin — deployPool +// ============================================================================= + +describe('AptosTokenAdmin — deployPool', () => { + // =========================================================================== + // generateUnsignedDeployPool — Shared Validation + // =========================================================================== + + describe('generateUnsignedDeployPool — shared validation', () => { + const admin = makeAdmin() + const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + it('should reject invalid poolType', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...managedParams, + poolType: 'invalid' as 'burn-mint', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.code, 'POOL_DEPLOY_PARAMS_INVALID') + assert.equal(err.context.param, 'poolType') + return true + }, + ) + }) + + it('should reject invalid tokenModule', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...managedParams, + tokenModule: 'invalid' as 'managed', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'tokenModule') + return true + }, + ) + }) + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...managedParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...managedParams, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + + it('should reject empty mcmsAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...managedParams, + mcmsAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'mcmsAddress') + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedDeployPool — Managed Token Pool Validation + // =========================================================================== + + describe('generateUnsignedDeployPool — managed', () => { + const admin = makeAdmin() + const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + it('should reject lock-release for managed tokens', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...managedParams, + poolType: 'lock-release', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'poolType') + assert.ok(err.message.includes('managed')) + return true + }, + ) + }) + + it('should default tokenModule to managed', async () => { + // Passes validation (will fail at ensureAptosCli, not validation) + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress: '0x89fd6b14b4a7', + localTokenDecimals: 8, + routerAddress: '0xabc123', + mcmsAddress: '0x789abc', + }), + (err: unknown) => { + assert.ok(!(err instanceof CCIPPoolDeployParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedDeployPool — Generic Token Pool Validation + // =========================================================================== + + describe('generateUnsignedDeployPool — generic', () => { + const admin = makeAdmin() + const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + it('should accept burn-mint for generic tokens', async () => { + await assert.rejects( + () => admin.generateUnsignedDeployPool(sender, genericBurnMintParams), + (err: unknown) => { + // Passes validation but fails later (ensureAptosCli or provider) + assert.ok(!(err instanceof CCIPPoolDeployParamsInvalidError)) + return true + }, + ) + }) + + it('should accept lock-release for generic tokens', async () => { + await assert.rejects( + () => admin.generateUnsignedDeployPool(sender, genericLockReleaseParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPPoolDeployParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedDeployPool — Regulated Token Pool Validation + // =========================================================================== + + describe('generateUnsignedDeployPool — regulated', () => { + const admin = makeAdmin() + const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + it('should reject lock-release for regulated tokens', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...regulatedParams, + poolType: 'lock-release', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'poolType') + assert.ok(err.message.includes('regulated')) + return true + }, + ) + }) + + it('should reject empty adminAddress for regulated', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + ...regulatedParams, + adminAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'adminAddress') + return true + }, + ) + }) + + it('should accept burn-mint for regulated tokens', async () => { + await assert.rejects( + () => admin.generateUnsignedDeployPool(sender, regulatedParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPPoolDeployParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // deployPool — Wallet Validation + // =========================================================================== + + describe('deployPool — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.deployPool({}, managedParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.deployPool(null, managedParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.deployPool(undefined, managedParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-propose-admin-role.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-propose-admin-role.test.ts new file mode 100644 index 00000000..815de61d --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-propose-admin-role.test.ts @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPProposeAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams = { + tokenAddress: '0x89fd6b14b4a7', + administrator: '0xabcdef123456', + routerAddress: '0xabc123', +} + +// ============================================================================= +// AptosTokenAdmin — proposeAdminRole +// ============================================================================= + +describe('AptosTokenAdmin — proposeAdminRole', () => { + // =========================================================================== + // generateUnsignedProposeAdminRole — Validation + // =========================================================================== + + describe('generateUnsignedProposeAdminRole — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedProposeAdminRole(sender, { + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.code, 'PROPOSE_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty administrator', async () => { + await assert.rejects( + () => + admin.generateUnsignedProposeAdminRole(sender, { + ...validParams, + administrator: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'administrator') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedProposeAdminRole(sender, { + ...validParams, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider)', async () => { + // Validation passes, fails at buildTransaction (mock provider) + await assert.rejects( + () => admin.generateUnsignedProposeAdminRole(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPProposeAdminRoleParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // proposeAdminRole — Wallet Validation + // =========================================================================== + + describe('proposeAdminRole — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.proposeAdminRole({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.proposeAdminRole(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.proposeAdminRole(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-remove-remote-pool-addresses.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-remove-remote-pool-addresses.test.ts new file mode 100644 index 00000000..364317c6 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-remove-remote-pool-addresses.test.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPRemoveRemotePoolAddressesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { RemoveRemotePoolAddressesParams } from '../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'managed_token_pool' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams: RemoveRemotePoolAddressesParams = { + poolAddress: '0xaabbcc', + remoteChainSelector: 16015286601757825753n, + remotePoolAddresses: ['0x1234567890abcdef1234567890abcdef12345678'], +} + +// ============================================================================= +// AptosTokenAdmin — removeRemotePoolAddresses +// ============================================================================= + +describe('AptosTokenAdmin — removeRemotePoolAddresses', () => { + // =========================================================================== + // generateUnsignedRemoveRemotePoolAddresses — Validation + // =========================================================================== + + describe('generateUnsignedRemoveRemotePoolAddresses — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.code, 'REMOVE_REMOTE_POOL_ADDRESSES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses(sender, { + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses array', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses(sender, { + ...validParams, + remotePoolAddresses: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty address in array', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses(sender, { + ...validParams, + remotePoolAddresses: [''], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses[0]') + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedRemoveRemotePoolAddresses — valid params + // =========================================================================== + + describe('generateUnsignedRemoveRemotePoolAddresses — valid params (hits provider mock)', () => { + const admin = makeAdmin() + + it('should pass validation and fail at provider/module discovery', async () => { + // With our mock provider, discoverPoolModule will succeed + // but ensurePoolInitialized or build.simple will fail — that's expected + await assert.rejects( + () => admin.generateUnsignedRemoveRemotePoolAddresses(sender, validParams), + (err: unknown) => { + // Any error is fine — the point is validation passed + assert.ok(err instanceof Error) + return true + }, + ) + }) + }) + + // =========================================================================== + // removeRemotePoolAddresses — Wallet Validation + // =========================================================================== + + describe('removeRemotePoolAddresses — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-account wallet', async () => { + await assert.rejects( + () => admin.removeRemotePoolAddresses({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.removeRemotePoolAddresses(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-revoke-mint-burn-access.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-revoke-mint-burn-access.test.ts new file mode 100644 index 00000000..cd3ef912 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-revoke-mint-burn-access.test.ts @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPRevokeMintBurnAccessParamsInvalidError, + CCIPTokenPoolInfoNotFoundError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +/** Creates a mock provider that returns the given pool module name. */ +function mockProviderWithPool(moduleName: string) { + return { + getTransactionByVersion: async () => ({}), + getAccountInfo: async () => ({ sequence_number: '0' }), + getAccountModules: async () => [{ abi: { name: moduleName } }], + view: async ({ payload }: { payload: { function: string } }) => { + const fn = payload.function + if (fn.includes('get_store_address')) return ['0xpool_resource_signer'] + if (fn.includes('object::owner')) return ['0xcode_object'] + if (fn.includes('get_token')) return ['0xtoken_address'] + return ['0x123'] + }, + transaction: { + build: { + simple: async () => ({ + bcsToBytes: () => new Uint8Array([1, 2, 3]), + }), + }, + }, + } as unknown as Aptos +} + +const mockProviderNoPool = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'unrelated_module' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +function makeAdmin(provider?: Aptos): AptosTokenAdmin { + return new AptosTokenAdmin(provider ?? mockProviderWithPool('managed_token_pool'), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams = { + tokenAddress: '0x89fd6b14b4a7', + authority: '0xabc123pool', + role: 'mint' as const, +} + +// ============================================================================= +// AptosTokenAdmin — revokeMintBurnAccess +// ============================================================================= + +describe('AptosTokenAdmin — revokeMintBurnAccess', () => { + // =========================================================================== + // generateUnsignedRevokeMintBurnAccess — Validation + // =========================================================================== + + describe('generateUnsignedRevokeMintBurnAccess — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedRevokeMintBurnAccess(sender, { + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.equal(err.code, 'REVOKE_MINT_BURN_ACCESS_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty authority', async () => { + await assert.rejects( + () => + admin.generateUnsignedRevokeMintBurnAccess(sender, { + ...validParams, + authority: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + return true + }, + ) + }) + + it('should reject invalid role', async () => { + await assert.rejects( + () => + admin.generateUnsignedRevokeMintBurnAccess(sender, { + ...validParams, + role: 'invalid' as 'mint', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'role') + return true + }, + ) + }) + }) + + // =========================================================================== + // Pool Type Detection + // =========================================================================== + + describe('generateUnsignedRevokeMintBurnAccess — pool type detection', () => { + it('should return 1 transaction for managed pool with role: mint', async () => { + const admin = makeAdmin(mockProviderWithPool('managed_token_pool')) + const { transactions } = await admin.generateUnsignedRevokeMintBurnAccess(sender, { + ...validParams, + role: 'mint', + }) + assert.equal(transactions.length, 1) + assert.equal(transactions[0]!.family, ChainFamily.Aptos) + }) + + it('should return 1 transaction for managed pool with role: burn', async () => { + const admin = makeAdmin(mockProviderWithPool('managed_token_pool')) + const { transactions } = await admin.generateUnsignedRevokeMintBurnAccess(sender, { + ...validParams, + role: 'burn', + }) + assert.equal(transactions.length, 1) + assert.equal(transactions[0]!.family, ChainFamily.Aptos) + }) + + it('should return 1 transaction for regulated pool with role: mint', async () => { + const admin = makeAdmin(mockProviderWithPool('regulated_token_pool')) + const { transactions } = await admin.generateUnsignedRevokeMintBurnAccess(sender, { + ...validParams, + role: 'mint', + }) + assert.equal(transactions.length, 1) + }) + + it('should return 1 transaction for regulated pool with role: burn', async () => { + const admin = makeAdmin(mockProviderWithPool('regulated_token_pool')) + const { transactions } = await admin.generateUnsignedRevokeMintBurnAccess(sender, { + ...validParams, + role: 'burn', + }) + assert.equal(transactions.length, 1) + }) + + it('should reject lock-release pool', async () => { + const admin = makeAdmin(mockProviderWithPool('lock_release_token_pool')) + await assert.rejects( + () => admin.generateUnsignedRevokeMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.ok(err.message.includes('lock-release')) + return true + }, + ) + }) + + it('should reject burn-mint pool', async () => { + const admin = makeAdmin(mockProviderWithPool('burn_mint_token_pool')) + await assert.rejects( + () => admin.generateUnsignedRevokeMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.ok(err.message.includes('initialization')) + return true + }, + ) + }) + + it('should throw when no pool module found', async () => { + const admin = makeAdmin(mockProviderNoPool) + await assert.rejects( + () => admin.generateUnsignedRevokeMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenPoolInfoNotFoundError) + return true + }, + ) + }) + }) + + // =========================================================================== + // revokeMintBurnAccess — Wallet Validation + // =========================================================================== + + describe('revokeMintBurnAccess — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.revokeMintBurnAccess({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.revokeMintBurnAccess(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject string wallet', async () => { + await assert.rejects( + () => admin.revokeMintBurnAccess('not-a-wallet', validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-set-pool.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-set-pool.test.ts new file mode 100644 index 00000000..3a0543f9 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-set-pool.test.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { CCIPSetPoolParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams = { + tokenAddress: '0x89fd6b14b4a7', + poolAddress: '0xeb6334947b4', + routerAddress: '0xabc123', +} + +// ============================================================================= +// AptosTokenAdmin — setPool +// ============================================================================= + +describe('AptosTokenAdmin — setPool', () => { + // =========================================================================== + // generateUnsignedSetPool — Validation + // =========================================================================== + + describe('generateUnsignedSetPool — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetPoolParamsInvalidError) + assert.equal(err.code, 'SET_POOL_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetPoolParamsInvalidError) + assert.equal(err.code, 'SET_POOL_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetPoolParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider)', async () => { + // Validation passes, fails at buildTransaction (mock provider) + await assert.rejects( + () => admin.generateUnsignedSetPool(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPSetPoolParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // setPool — Wallet Validation + // =========================================================================== + + describe('setPool — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.setPool({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.setPool(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.setPool(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-set-rate-limit-admin.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-set-rate-limit-admin.test.ts new file mode 100644 index 00000000..e648cc8a --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-set-rate-limit-admin.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { CCIPMethodUnsupportedError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'managed_token_pool' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +describe('AptosTokenAdmin — setRateLimitAdmin', () => { + describe('generateUnsignedSetRateLimitAdmin — not supported', () => { + const admin = makeAdmin() + + it('should throw CCIPMethodUnsupportedError', () => { + assert.throws( + () => + admin.generateUnsignedSetRateLimitAdmin(sender, { + poolAddress: '0xaabbcc', + rateLimitAdmin: '0xddeeff', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPMethodUnsupportedError) + assert.equal(err.code, 'METHOD_UNSUPPORTED') + assert.equal(err.context.class, 'AptosTokenAdmin') + assert.equal(err.context.method, 'setRateLimitAdmin') + return true + }, + ) + }) + }) + + describe('setRateLimitAdmin — not supported', () => { + const admin = makeAdmin() + + it('should throw CCIPMethodUnsupportedError for any wallet', () => { + assert.throws( + () => + admin.setRateLimitAdmin( + { signTransaction: async () => ({}) }, + { poolAddress: '0xaabbcc', rateLimitAdmin: '0xddeeff' }, + ), + (err: unknown) => { + assert.ok(err instanceof CCIPMethodUnsupportedError) + assert.equal(err.code, 'METHOD_UNSUPPORTED') + return true + }, + ) + }) + + it('should throw CCIPMethodUnsupportedError for null wallet', () => { + assert.throws( + () => + admin.setRateLimitAdmin(null, { + poolAddress: '0xaabbcc', + rateLimitAdmin: '0xddeeff', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPMethodUnsupportedError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-set-rate-limiter-config.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-set-rate-limiter-config.test.ts new file mode 100644 index 00000000..3d651cf9 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-set-rate-limiter-config.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPSetRateLimiterConfigParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { SetChainRateLimiterConfigParams } from '../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'managed_token_pool' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams: SetChainRateLimiterConfigParams = { + poolAddress: '0xaabbcc', + chainConfigs: [ + { + remoteChainSelector: 16015286601757825753n, + outboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + inboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + }, + ], +} + +describe('AptosTokenAdmin — setChainRateLimiterConfig', () => { + // =========================================================================== + // generateUnsignedSetChainRateLimiterConfig — Validation + // =========================================================================== + + describe('generateUnsignedSetChainRateLimiterConfig — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.code, 'SET_RATE_LIMITER_CONFIG_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty chainConfigs', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + chainConfigs: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + chainConfigs: [{ ...validParams.chainConfigs[0]!, remoteChainSelector: 0n }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs[0].remoteChainSelector') + return true + }, + ) + }) + + it('should reject invalid capacity string', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + chainConfigs: [ + { + ...validParams.chainConfigs[0]!, + outboundRateLimiterConfig: { isEnabled: true, capacity: 'xyz', rate: '0' }, + }, + ], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs[0].outboundRateLimiterConfig.capacity') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider for tx build)', async () => { + // Validation passes, module discovery succeeds, but fails at buildTransaction + await assert.rejects( + () => admin.generateUnsignedSetChainRateLimiterConfig(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPSetRateLimiterConfigParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // setChainRateLimiterConfig — Wallet Validation + // =========================================================================== + + describe('setChainRateLimiterConfig — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.setChainRateLimiterConfig({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.setChainRateLimiterConfig(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.setChainRateLimiterConfig(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-token-admin.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-token-admin.test.ts new file mode 100644 index 00000000..61ffd0cb --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-token-admin.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { CCIPTokenDeployParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +// ============================================================================= +// AptosTokenAdmin — Construction +// ============================================================================= + +describe('AptosTokenAdmin', () => { + describe('constructor', () => { + it('should create instance with provider', () => { + const admin = new AptosTokenAdmin(mockProvider, dummyNetwork, { apiClient: null }) + assert.equal(admin.provider, mockProvider) + }) + }) + + // =========================================================================== + // generateUnsignedDeployToken — Validation + // =========================================================================== + + describe('generateUnsignedDeployToken', () => { + const admin = makeAdmin() + const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + + it('should reject empty name', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: '', + symbol: 'MTK', + decimals: 8, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.code, 'TOKEN_DEPLOY_PARAMS_INVALID') + assert.equal(err.context.param, 'name') + return true + }, + ) + }) + + it('should reject empty symbol', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: '', + decimals: 8, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'symbol') + return true + }, + ) + }) + + it('should reject negative initialSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: 'MTK', + decimals: 8, + initialSupply: -1n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'initialSupply') + return true + }, + ) + }) + + it('should reject negative maxSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: 'MTK', + decimals: 8, + maxSupply: -1n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'maxSupply') + return true + }, + ) + }) + + it('should reject initialSupply > maxSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: 'MTK', + decimals: 8, + maxSupply: 100n, + initialSupply: 200n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'initialSupply') + return true + }, + ) + }) + }) + + // =========================================================================== + // deployToken — Wallet Validation + // =========================================================================== + + describe('deployToken', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.deployToken({}, { name: 'Token', symbol: 'MTK', decimals: 8 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.deployToken(null, { name: 'Token', symbol: 'MTK', decimals: 8 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.deployToken(undefined, { name: 'Token', symbol: 'MTK', decimals: 8 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-transfer-admin-role.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-transfer-admin-role.test.ts new file mode 100644 index 00000000..fdf845dd --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-transfer-admin-role.test.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPTransferAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validParams = { + tokenAddress: '0x89fd6b14b4a7', + newAdmin: '0xabe0ac8b56eb54a1', + routerAddress: '0xabc123', +} + +// ============================================================================= +// AptosTokenAdmin — transferAdminRole +// ============================================================================= + +describe('AptosTokenAdmin — transferAdminRole', () => { + // =========================================================================== + // generateUnsignedTransferAdminRole — Validation + // =========================================================================== + + describe('generateUnsignedTransferAdminRole — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferAdminRole(sender, { + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty newAdmin', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferAdminRole(sender, { + ...validParams, + newAdmin: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'newAdmin') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferAdminRole(sender, { + ...validParams, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider)', async () => { + // Validation passes, fails at buildTransaction (mock provider) + await assert.rejects( + () => admin.generateUnsignedTransferAdminRole(sender, validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPTransferAdminRoleParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // transferAdminRole — Wallet Validation + // =========================================================================== + + describe('transferAdminRole — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.transferAdminRole({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.transferAdminRole(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.transferAdminRole(undefined, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/aptos-transfer-ownership.test.ts b/ccip-sdk/src/token-admin/aptos/aptos-transfer-ownership.test.ts new file mode 100644 index 00000000..5dd059a6 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/aptos-transfer-ownership.test.ts @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { Aptos } from '@aptos-labs/ts-sdk' + +import { AptosTokenAdmin } from './index.ts' +import { + CCIPAcceptOwnershipParamsInvalidError, + CCIPExecuteOwnershipTransferParamsInvalidError, + CCIPTransferOwnershipParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockProvider = { + getTransactionByVersion: async () => ({}), + getAccountModules: async () => [{ abi: { name: 'managed_token_pool' } }], + view: async () => ['0x123'], +} as unknown as Aptos + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'aptos-testnet', + family: ChainFamily.Aptos, + chainSelector: 1n, + chainId: 'aptos:2' as `aptos:${number}`, + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): AptosTokenAdmin { + return new AptosTokenAdmin(mockProvider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const sender = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' + +const validTransferParams = { + poolAddress: '0xeb6334947b4', + newOwner: '0xabc123', +} + +const validAcceptParams = { + poolAddress: '0xeb6334947b4', +} + +// ============================================================================= +// AptosTokenAdmin — transferOwnership +// ============================================================================= + +describe('AptosTokenAdmin — transferOwnership', () => { + // =========================================================================== + // generateUnsignedTransferOwnership — Validation + // =========================================================================== + + describe('generateUnsignedTransferOwnership — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferOwnership(sender, { + ...validTransferParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferOwnershipParamsInvalidError) + assert.equal(err.code, 'TRANSFER_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty newOwner', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferOwnership(sender, { + ...validTransferParams, + newOwner: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferOwnershipParamsInvalidError) + assert.equal(err.code, 'TRANSFER_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'newOwner') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider)', async () => { + await assert.rejects( + () => admin.generateUnsignedTransferOwnership(sender, validTransferParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPTransferOwnershipParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // transferOwnership — Wallet Validation + // =========================================================================== + + describe('transferOwnership — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.transferOwnership({}, validTransferParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.transferOwnership(null, validTransferParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.transferOwnership(undefined, validTransferParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) + +// ============================================================================= +// AptosTokenAdmin — acceptOwnership +// ============================================================================= + +describe('AptosTokenAdmin — acceptOwnership', () => { + // =========================================================================== + // generateUnsignedAcceptOwnership — Validation + // =========================================================================== + + describe('generateUnsignedAcceptOwnership — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAcceptOwnership(sender, { + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptOwnershipParamsInvalidError) + assert.equal(err.code, 'ACCEPT_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider)', async () => { + await assert.rejects( + () => admin.generateUnsignedAcceptOwnership(sender, validAcceptParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPAcceptOwnershipParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // acceptOwnership — Wallet Validation + // =========================================================================== + + describe('acceptOwnership — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.acceptOwnership({}, validAcceptParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.acceptOwnership(null, validAcceptParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.acceptOwnership(undefined, validAcceptParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) + +// ============================================================================= +// AptosTokenAdmin — executeOwnershipTransfer (Aptos 3rd step) +// ============================================================================= + +const validExecuteParams = { + poolAddress: '0xeb6334947b4', + newOwner: '0xabc123', +} + +describe('AptosTokenAdmin — executeOwnershipTransfer', () => { + // =========================================================================== + // generateUnsignedExecuteOwnershipTransfer — Validation + // =========================================================================== + + describe('generateUnsignedExecuteOwnershipTransfer — validation', () => { + const admin = makeAdmin() + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedExecuteOwnershipTransfer(sender, { + ...validExecuteParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPExecuteOwnershipTransferParamsInvalidError) + assert.equal(err.code, 'EXECUTE_OWNERSHIP_TRANSFER_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty newOwner', async () => { + await assert.rejects( + () => + admin.generateUnsignedExecuteOwnershipTransfer(sender, { + ...validExecuteParams, + newOwner: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPExecuteOwnershipTransferParamsInvalidError) + assert.equal(err.code, 'EXECUTE_OWNERSHIP_TRANSFER_PARAMS_INVALID') + assert.equal(err.context.param, 'newOwner') + return true + }, + ) + }) + + it('should accept valid params (fails at Aptos provider)', async () => { + await assert.rejects( + () => admin.generateUnsignedExecuteOwnershipTransfer(sender, validExecuteParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPExecuteOwnershipTransferParamsInvalidError)) + return true + }, + ) + }) + }) + + // =========================================================================== + // executeOwnershipTransfer — Wallet Validation + // =========================================================================== + + describe('executeOwnershipTransfer — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.executeOwnershipTransfer({}, validExecuteParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.executeOwnershipTransfer(null, validExecuteParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.executeOwnershipTransfer(undefined, validExecuteParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/aptos/bytecodes/burn_mint_token_pool.ts b/ccip-sdk/src/token-admin/aptos/bytecodes/burn_mint_token_pool.ts new file mode 100644 index 00000000..f53ebfb1 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/bytecodes/burn_mint_token_pool.ts @@ -0,0 +1,811 @@ +/** + * BurnMintTokenPool Move package source files. + * + * Source: chainlink-aptos contracts/ccip/ccip_token_pools/burn_mint_token_pool + * AptosFramework rev: 16beac69835f3a71564c96164a606a23f259099a + * ChainlinkCCIP + MCMS: embedded as local dependencies + * + * For standard Aptos Fungible Asset tokens with BurnRef/MintRef. + * Use managed_token_pool.ts for tokens deployed with the managed_token package. + * + * Vendored as source (not compiled bytecodes) because Aptos Move modules + * must be compiled with the deployer's address at deploy time. + * + * Lazy-loaded via dynamic import() — same pattern as EVM BurnMintERC20 bytecode. + */ + +/** Move.toml for the BurnMintTokenPool package. */ +export const BURN_MINT_POOL_MOVE_TOML = `[package] +name = "BurnMintTokenPool" +version = "1.0.0" +authors = [] + +[addresses] +ccip = "_" +ccip_token_pool = "_" +burn_mint_token_pool = "_" +mcms = "_" +mcms_register_entrypoints = "_" +burn_mint_local_token = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +ChainlinkCCIP = { local = "../ccip" } +CCIPTokenPool = { local = "../token_pool" } +` + +/** burn_mint_token_pool.move — pool logic (test functions stripped). */ +export const BURN_MINT_TOKEN_POOL_MOVE = `module burn_mint_token_pool::burn_mint_token_pool { + use std::account::{Self, SignerCapability}; + use std::error; + use std::fungible_asset::{Self, FungibleAsset, Metadata, TransferRef}; + use std::primary_fungible_store; + use std::object::{Self, Object, ObjectCore}; + use std::option::{Self, Option}; + use std::signer; + use std::string::{Self, String}; + use std::fungible_asset::{BurnRef, MintRef}; + + use ccip::token_admin_registry::{Self, ReleaseOrMintInputV1, LockOrBurnInputV1}; + use ccip_token_pool::ownable; + use ccip_token_pool::rate_limiter; + use ccip_token_pool::token_pool; + + use mcms::mcms_registry; + use mcms::bcs_stream; + + const STORE_OBJECT_SEED: vector = b"CcipBurnMintTokenPool"; + + struct BurnMintTokenPoolDeployment has key { + store_signer_cap: SignerCapability, + ownable_state: ownable::OwnableState, + token_pool_state: token_pool::TokenPoolState + } + + struct BurnMintTokenPoolState has key, store { + store_signer_cap: SignerCapability, + ownable_state: ownable::OwnableState, + token_pool_state: token_pool::TokenPoolState, + store_signer_address: address, + burn_ref: Option, + mint_ref: Option + } + + const E_NOT_PUBLISHER: u64 = 1; + const E_ALREADY_INITIALIZED: u64 = 2; + const E_INVALID_FUNGIBLE_ASSET: u64 = 3; + const E_LOCAL_TOKEN_MISMATCH: u64 = 4; + const E_INVALID_ARGUMENTS: u64 = 5; + const E_UNKNOWN_FUNCTION: u64 = 6; + const E_MINT_REF_NOT_SET: u64 = 7; + const E_BURN_REF_NOT_SET: u64 = 8; + + // ================================================================ + // | Init | + // ================================================================ + #[view] + public fun type_and_version(): String { + string::utf8(b"BurnMintTokenPool 1.6.0") + } + + fun init_module(publisher: &signer) { + // register the pool on deployment, because in the case of object code deployment, + // this is the only time we have a signer ref to @ccip_burn_mint_pool. + assert!( + object::object_exists(@burn_mint_local_token), + error::invalid_argument(E_INVALID_FUNGIBLE_ASSET) + ); + let metadata = object::address_to_object(@burn_mint_local_token); + + // create an Account on the object for event handles. + account::create_account_if_does_not_exist(@burn_mint_token_pool); + + // the name of this module. if incorrect, callbacks will fail to be registered and + // register_pool will revert. + let token_pool_module_name = b"burn_mint_token_pool"; + + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher, token_pool_module_name); + }; + + // Register V2 pool with closure-based callbacks + register_v2_callbacks(publisher); + + // create a resource account to be the owner of the primary FungibleStore we will use. + let (store_signer, store_signer_cap) = + account::create_resource_account(publisher, STORE_OBJECT_SEED); + + // make sure this is a valid fungible asset that is primary fungible store enabled, + // ie. created with primary_fungible_store::create_primary_store_enabled_fungible_asset + primary_fungible_store::ensure_primary_store_exists( + signer::address_of(&store_signer), metadata + ); + + move_to( + publisher, + BurnMintTokenPoolDeployment { + store_signer_cap, + ownable_state: ownable::new(&store_signer, @burn_mint_token_pool), + token_pool_state: token_pool::initialize( + &store_signer, @burn_mint_local_token, vector[] + ) + } + ); + } + + public fun initialize( + caller: &signer, burn_ref: BurnRef, mint_ref: MintRef + ) acquires BurnMintTokenPoolDeployment { + assert_can_initialize(signer::address_of(caller)); + + assert!( + exists(@burn_mint_token_pool), + error::invalid_argument(E_ALREADY_INITIALIZED) + ); + + let metadata = object::address_to_object(@burn_mint_local_token); + let burn_ref_metadata = fungible_asset::burn_ref_metadata(&burn_ref); + let mint_ref_metadata = fungible_asset::mint_ref_metadata(&mint_ref); + + assert!( + metadata == burn_ref_metadata && metadata == mint_ref_metadata, + error::invalid_argument(E_LOCAL_TOKEN_MISMATCH) + ); + + let BurnMintTokenPoolDeployment { + store_signer_cap, + ownable_state, + token_pool_state + } = move_from(@burn_mint_token_pool); + + let store_signer = account::create_signer_with_capability(&store_signer_cap); + + let pool = BurnMintTokenPoolState { + ownable_state, + store_signer_address: signer::address_of(&store_signer), + store_signer_cap, + token_pool_state, + burn_ref: option::some(burn_ref), + mint_ref: option::some(mint_ref) + }; + + move_to(&store_signer, pool); + } + + public fun register_v2_callbacks(publisher: &signer) { + assert!( + signer::address_of(publisher) == @burn_mint_token_pool, + error::permission_denied(E_NOT_PUBLISHER) + ); + token_admin_registry::register_pool_v2( + publisher, + @burn_mint_local_token, + lock_or_burn_v2, + release_or_mint_v2 + ); + } + + // ================================================================ + // | Exposing token_pool functions | + // ================================================================ + #[view] + public fun get_token(): address acquires BurnMintTokenPoolState { + token_pool::get_token(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_router(): address { + token_pool::get_router() + } + + #[view] + public fun get_token_decimals(): u8 acquires BurnMintTokenPoolState { + token_pool::get_token_decimals(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_remote_pools( + remote_chain_selector: u64 + ): vector> acquires BurnMintTokenPoolState { + token_pool::get_remote_pools( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun is_remote_pool( + remote_chain_selector: u64, remote_pool_address: vector + ): bool acquires BurnMintTokenPoolState { + token_pool::is_remote_pool( + &borrow_pool().token_pool_state, + remote_chain_selector, + remote_pool_address + ) + } + + #[view] + public fun get_remote_token( + remote_chain_selector: u64 + ): vector acquires BurnMintTokenPoolState { + let pool = borrow_pool(); + token_pool::get_remote_token(&pool.token_pool_state, remote_chain_selector) + } + + public entry fun add_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::add_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + public entry fun remove_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::remove_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + #[view] + public fun is_supported_chain(remote_chain_selector: u64): bool acquires BurnMintTokenPoolState { + let pool = borrow_pool(); + token_pool::is_supported_chain(&pool.token_pool_state, remote_chain_selector) + } + + #[view] + public fun get_supported_chains(): vector acquires BurnMintTokenPoolState { + let pool = borrow_pool(); + token_pool::get_supported_chains(&pool.token_pool_state) + } + + public entry fun apply_chain_updates( + caller: &signer, + remote_chain_selectors_to_remove: vector, + remote_chain_selectors_to_add: vector, + remote_pool_addresses_to_add: vector>>, + remote_token_addresses_to_add: vector> + ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::apply_chain_updates( + &mut pool.token_pool_state, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } + + #[view] + public fun get_allowlist_enabled(): bool acquires BurnMintTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist_enabled(&pool.token_pool_state) + } + + public entry fun set_allowlist_enabled( + caller: &signer, enabled: bool + ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::set_allowlist_enabled(&mut pool.token_pool_state, enabled); + } + + #[view] + public fun get_allowlist(): vector
acquires BurnMintTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist(&pool.token_pool_state) + } + + public entry fun apply_allowlist_updates( + caller: &signer, removes: vector
, adds: vector
+ ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::apply_allowlist_updates(&mut pool.token_pool_state, removes, adds); + } + + // ================================================================ + // | Burn/Mint | + // ================================================================ + + // the callback proof type used as authentication to retrieve and set input and output arguments. + struct CallbackProof has drop {} + + public fun lock_or_burn( + _store: Object, fa: FungibleAsset, _transfer_ref: &TransferRef + ) acquires BurnMintTokenPoolState { + // retrieve the input for this lock or burn operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_lock_or_burn_input_v1( + @burn_mint_token_pool, CallbackProof {} + ); + + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + // This method validates various aspects of the lock or burn operation. If any of the + // validations fail, the transaction will abort. + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + // Construct lock_or_burn output before we lose access to fa + let dest_pool_data = token_pool::encode_local_decimals(&pool.token_pool_state); + + // Burn the funds + assert!(pool.burn_ref.is_some(), E_BURN_REF_NOT_SET); + fungible_asset::burn(pool.burn_ref.borrow(), fa); + + // set the output for this lock or burn operation. + token_admin_registry::set_lock_or_burn_output_v1( + @burn_mint_token_pool, + CallbackProof {}, + dest_token_address, + dest_pool_data + ); + + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + } + + public fun release_or_mint( + _store: Object, _amount: u64, _transfer_ref: &TransferRef + ): FungibleAsset acquires BurnMintTokenPoolState { + // retrieve the input for this release or mint operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_release_or_mint_input_v1( + @burn_mint_token_pool, CallbackProof {} + ); + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + // Mint the amount for release. + assert!(pool.mint_ref.is_some(), E_MINT_REF_NOT_SET); + let fa = fungible_asset::mint(pool.mint_ref.borrow(), local_amount); + + // set the output for this release or mint operation. + token_admin_registry::set_release_or_mint_output_v1( + @burn_mint_token_pool, CallbackProof {}, local_amount + ); + + let recipient = token_admin_registry::get_release_or_mint_receiver(&input); + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + recipient, + local_amount, + remote_chain_selector + ); + + // return the withdrawn fungible asset. + fa + } + + #[persistent] + fun lock_or_burn_v2( + fa: FungibleAsset, input: LockOrBurnInputV1 + ): (vector, vector) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + // Burn the token + assert!(pool.burn_ref.is_some(), E_BURN_REF_NOT_SET); + fungible_asset::burn(pool.burn_ref.borrow(), fa); + + // Emit event + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + + (dest_token_address, token_pool::encode_local_decimals(&pool.token_pool_state)) + } + + #[persistent] + fun release_or_mint_v2( + input: ReleaseOrMintInputV1 + ): (FungibleAsset, u64) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + // Mint the amount for release + assert!(pool.mint_ref.is_some(), E_MINT_REF_NOT_SET); + let fa = fungible_asset::mint(pool.mint_ref.borrow(), local_amount); + + let recipient = token_admin_registry::get_release_or_mint_receiver(&input); + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + recipient, + local_amount, + remote_chain_selector + ); + + (fa, local_amount) + } + + // ================================================================ + // | Rate limit config | + // ================================================================ + public entry fun set_chain_rate_limiter_configs( + caller: &signer, + remote_chain_selectors: vector, + outbound_is_enableds: vector, + outbound_capacities: vector, + outbound_rates: vector, + inbound_is_enableds: vector, + inbound_capacities: vector, + inbound_rates: vector + ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + let number_of_chains = remote_chain_selectors.length(); + + assert!( + number_of_chains == outbound_is_enableds.length() + && number_of_chains == outbound_capacities.length() + && number_of_chains == outbound_rates.length() + && number_of_chains == inbound_is_enableds.length() + && number_of_chains == inbound_capacities.length() + && number_of_chains == inbound_rates.length(), + error::invalid_argument(E_INVALID_ARGUMENTS) + ); + + for (i in 0..number_of_chains) { + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selectors[i], + outbound_is_enableds[i], + outbound_capacities[i], + outbound_rates[i], + inbound_is_enableds[i], + inbound_capacities[i], + inbound_rates[i] + ); + }; + } + + public entry fun set_chain_rate_limiter_config( + caller: &signer, + remote_chain_selector: u64, + outbound_is_enabled: bool, + outbound_capacity: u64, + outbound_rate: u64, + inbound_is_enabled: bool, + inbound_capacity: u64, + inbound_rate: u64 + ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } + + #[view] + public fun get_current_inbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires BurnMintTokenPoolState { + token_pool::get_current_inbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun get_current_outbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires BurnMintTokenPoolState { + token_pool::get_current_outbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + // ================================================================ + // | Storage helpers | + // ================================================================ + #[view] + public fun get_store_address(): address { + store_address() + } + + inline fun store_address(): address { + account::create_resource_address(&@burn_mint_token_pool, STORE_OBJECT_SEED) + } + + fun assert_can_initialize(caller_address: address) { + if (caller_address == @burn_mint_token_pool) { return }; + + if (object::is_object(@burn_mint_token_pool)) { + let burn_mint_token_pool_object = + object::address_to_object(@burn_mint_token_pool); + if (caller_address == object::owner(burn_mint_token_pool_object) + || caller_address == object::root_owner(burn_mint_token_pool_object)) { + return + }; + }; + + abort error::permission_denied(E_NOT_PUBLISHER) + } + + inline fun borrow_pool(): &BurnMintTokenPoolState { + borrow_global(store_address()) + } + + inline fun borrow_pool_mut(): &mut BurnMintTokenPoolState { + borrow_global_mut(store_address()) + } + + // ================================================================ + // | Expose ownable | + // ================================================================ + #[view] + public fun owner(): address acquires BurnMintTokenPoolState { + ownable::owner(&borrow_pool().ownable_state) + } + + #[view] + public fun has_pending_transfer(): bool acquires BurnMintTokenPoolState { + ownable::has_pending_transfer(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_from(): Option
acquires BurnMintTokenPoolState { + ownable::pending_transfer_from(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_to(): Option
acquires BurnMintTokenPoolState { + ownable::pending_transfer_to(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_accepted(): Option acquires BurnMintTokenPoolState { + ownable::pending_transfer_accepted(&borrow_pool().ownable_state) + } + + public entry fun transfer_ownership(caller: &signer, to: address) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::transfer_ownership(caller, &mut pool.ownable_state, to) + } + + public entry fun accept_ownership(caller: &signer) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::accept_ownership(caller, &mut pool.ownable_state) + } + + public entry fun execute_ownership_transfer( + caller: &signer, to: address + ) acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::execute_ownership_transfer(caller, &mut pool.ownable_state, to) + } + + // ================================================================ + // | Ref Migration | + // ================================================================ + public fun migrate_mint_ref(caller: &signer): MintRef acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + assert!(pool.mint_ref.is_some(), E_MINT_REF_NOT_SET); + + pool.mint_ref.extract() + } + + public fun migrate_burn_ref(caller: &signer): BurnRef acquires BurnMintTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + assert!(pool.burn_ref.is_some(), E_BURN_REF_NOT_SET); + + pool.burn_ref.extract() + } + + // ================================================================ + // | MCMS entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: object::Object + ): option::Option acquires BurnMintTokenPoolState { + let (caller, function, data) = + mcms_registry::get_callback_params(@burn_mint_token_pool, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"add_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + add_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"remove_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + remove_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"apply_chain_updates") { + let remote_chain_selectors_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_chain_selectors_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_pool_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, + |stream| bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ) + ); + let remote_token_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_chain_updates( + &caller, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } else if (function_bytes == b"set_allowlist_enabled") { + let enabled = bcs_stream::deserialize_bool(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_allowlist_enabled(&caller, enabled); + } else if (function_bytes == b"apply_allowlist_updates") { + let removes = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let adds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_allowlist_updates(&caller, removes, adds); + } else if (function_bytes == b"set_chain_rate_limiter_configs") { + let remote_chain_selectors = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let outbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let inbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_configs( + &caller, + remote_chain_selectors, + outbound_is_enableds, + outbound_capacities, + outbound_rates, + inbound_is_enableds, + inbound_capacities, + inbound_rates + ); + } else if (function_bytes == b"set_chain_rate_limiter_config") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let outbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let outbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let outbound_rate = bcs_stream::deserialize_u64(&mut stream); + let inbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let inbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let inbound_rate = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_config( + &caller, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } else if (function_bytes == b"transfer_ownership") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + transfer_ownership(&caller, to); + } else if (function_bytes == b"accept_ownership") { + bcs_stream::assert_is_consumed(&stream); + accept_ownership(&caller); + } else if (function_bytes == b"execute_ownership_transfer") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + execute_ownership_transfer(&caller, to) + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint( + publisher: &signer, module_name: vector + ) { + mcms_registry::register_entrypoint( + publisher, string::utf8(module_name), McmsCallback {} + ); + } +} +` diff --git a/ccip-sdk/src/token-admin/aptos/bytecodes/ccip.ts b/ccip-sdk/src/token-admin/aptos/bytecodes/ccip.ts new file mode 100644 index 00000000..bba40d29 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/bytecodes/ccip.ts @@ -0,0 +1,5891 @@ +/** + * ChainlinkCCIP Move sources — embedded from chainlink-aptos. + * + * These sources are compiled locally alongside pool packages so that + * the compiled bytecode matches the on-chain modules exactly. + * + * @packageDocumentation + */ + +/** Move.toml for ChainlinkCCIP — uses local path for MCMS dependency. */ +export const CCIP_MOVE_TOML = `[package] +name = "ChainlinkCCIP" +version = "1.0.0" +upgrade_policy = "compatible" + +[addresses] +ccip = "_" +mcms = "_" +mcms_owner = "0x0" +mcms_register_entrypoints = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +ChainlinkManyChainMultisig = { local = "../mcms" } +` + +/** sources/allowlist.move */ +export const CCIP_ALLOWLIST_MOVE = `module ccip::allowlist { + use std::account; + use std::event::{Self, EventHandle}; + use std::error; + use std::string::{Self, String}; + + struct AllowlistState has store { + allowlist_name: String, + allowlist_enabled: bool, + allowlist: vector
, + allowlist_add_events: EventHandle, + allowlist_remove_events: EventHandle + } + + #[event] + struct AllowlistRemove has store, drop { + allowlist_name: String, + removed_address: address + } + + #[event] + struct AllowlistAdd has store, drop { + allowlist_name: String, + added_address: address + } + + const E_ALLOWLIST_NOT_ENABLED: u64 = 1; + + public fun new(event_account: &signer, allowlist: vector
): AllowlistState { + new_with_name(event_account, allowlist, string::utf8(b"default")) + } + + public fun new_with_name( + event_account: &signer, allowlist: vector
, allowlist_name: String + ): AllowlistState { + AllowlistState { + allowlist_name, + allowlist_enabled: !allowlist.is_empty(), + allowlist, + allowlist_add_events: account::new_event_handle(event_account), + allowlist_remove_events: account::new_event_handle(event_account) + } + } + + public fun get_allowlist_enabled(state: &AllowlistState): bool { + state.allowlist_enabled + } + + public fun set_allowlist_enabled( + state: &mut AllowlistState, enabled: bool + ) { + state.allowlist_enabled = enabled; + } + + public fun get_allowlist(state: &AllowlistState): vector
{ + state.allowlist + } + + public fun is_allowed(state: &AllowlistState, sender: address): bool { + if (!state.allowlist_enabled) { + return true + }; + + state.allowlist.contains(&sender) + } + + public fun apply_allowlist_updates( + state: &mut AllowlistState, removes: vector
, adds: vector
+ ) { + removes.for_each_ref( + |removed_address| { + let removed_address: address = *removed_address; + let (found, i) = state.allowlist.index_of(&removed_address); + if (found) { + state.allowlist.swap_remove(i); + event::emit_event( + &mut state.allowlist_remove_events, + AllowlistRemove { + allowlist_name: state.allowlist_name, + removed_address + } + ); + } + } + ); + + if (!adds.is_empty()) { + assert!( + state.allowlist_enabled, + error::invalid_state(E_ALLOWLIST_NOT_ENABLED) + ); + + adds.for_each_ref( + |added_address| { + let added_address: address = *added_address; + if (added_address != @0x0 + && !state.allowlist.contains(&added_address)) { + state.allowlist.push_back(added_address); + event::emit_event( + &mut state.allowlist_add_events, + AllowlistAdd { + allowlist_name: state.allowlist_name, + added_address + } + ); + } + } + ); + } + } + + public fun destroy_allowlist(state: AllowlistState) { + let AllowlistState { + allowlist_name: _, + allowlist_enabled: _, + allowlist: _, + allowlist_add_events: add_events, + allowlist_remove_events: remove_events + } = state; + + event::destroy_handle(add_events); + event::destroy_handle(remove_events); + } + + #[test_only] + public fun new_add_event(add: address): AllowlistAdd { + AllowlistAdd { + added_address: add, + allowlist_name: string::utf8(b"default") + } + } + + #[test_only] + public fun new_remove_event(remove: address): AllowlistRemove { + AllowlistRemove { + removed_address: remove, + allowlist_name: string::utf8(b"default") + } + } + + #[test_only] + public fun get_allowlist_add_events(state: &AllowlistState): &EventHandle { + &state.allowlist_add_events + } + + #[test_only] + public fun get_allowlist_remove_events(state: &AllowlistState) + : &EventHandle { + &state.allowlist_remove_events + } +} + +#[test_only] +module ccip::allowlist_test { + use std::account; + use std::event; + use std::signer; + use std::vector; + + use ccip::allowlist::{Self, AllowlistAdd, AllowlistRemove}; + + #[test(owner = @0x0)] + fun init_empty_is_empty_and_disabled(owner: &signer) { + let state = set_up_test(owner, vector::empty()); + + assert!(!allowlist::get_allowlist_enabled(&state)); + assert!(allowlist::get_allowlist(&state).is_empty()); + + // Any address is allowed when the allowlist is disabled + assert!(allowlist::is_allowed(&state, @0x1111111111111)); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + fun init_non_empty_is_non_empty_and_enabled(owner: &signer) { + let init_allowlist = vector[@0x1, @0x2]; + + let state = set_up_test(owner, init_allowlist); + + assert!(allowlist::get_allowlist_enabled(&state)); + assert!(allowlist::get_allowlist(&state).length() == 2); + + // The given addresses are allowed + assert!(allowlist::is_allowed(&state, init_allowlist[0])); + assert!(allowlist::is_allowed(&state, init_allowlist[1])); + + // Other addresses are not allowed + assert!(!allowlist::is_allowed(&state, @0x3)); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + #[expected_failure(abort_code = 0x30001, location = allowlist)] + fun cannot_add_to_disabled_allowlist(owner: &signer) { + let state = set_up_test(owner, vector::empty()); + + let adds = vector[@0x1]; + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), adds); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + fun apply_allowlist_updates_mutates_state(owner: &signer) { + let state = set_up_test(owner, vector::empty()); + allowlist::set_allowlist_enabled(&mut state, true); + + assert!(allowlist::get_allowlist(&state).is_empty()); + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), vector::empty()); + + assert!(allowlist::get_allowlist(&state).is_empty()); + + let adds = vector[@0x1, @0x2]; + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), adds); + + assert_add_events_emitted(adds, &state); + + let removes = vector[@0x1]; + + allowlist::apply_allowlist_updates(&mut state, removes, vector::empty()); + + assert_remove_events_emitted(removes, &state); + + assert!(allowlist::get_allowlist(&state).length() == 1); + assert!(allowlist::is_allowed(&state, @0x2)); + assert!(!allowlist::is_allowed(&state, @0x1)); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + fun apply_allowlist_updates_removes_before_adds(owner: &signer) { + let account_to_allow = @0x1; + let state = set_up_test(owner, vector::empty()); + allowlist::set_allowlist_enabled(&mut state, true); + + let adds_and_removes = vector[account_to_allow]; + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), adds_and_removes); + + assert!(allowlist::get_allowlist(&state).length() == 1); + assert!(allowlist::is_allowed(&state, account_to_allow)); + + allowlist::apply_allowlist_updates(&mut state, adds_and_removes, adds_and_removes); + + // Since removes happen before adds, the account should still be allowed + assert!(allowlist::is_allowed(&state, account_to_allow)); + + assert_remove_events_emitted(adds_and_removes, &state); + // Events don't get purged after calling event::emitted_events so we'll have + // both the first and the second add event in the emitted events + adds_and_removes.push_back(account_to_allow); + assert_add_events_emitted(adds_and_removes, &state); + + allowlist::destroy_allowlist(state); + } + + inline fun assert_add_events_emitted( + added_addresses: vector
, state: &allowlist::AllowlistState + ) { + let expected = + added_addresses.map:: ( + |add| allowlist::new_add_event(add) + ); + let got = + event::emitted_events_by_handle( + allowlist::get_allowlist_add_events(state) + ); + let number_of_adds = expected.length(); + + // Assert that exactly one event was emitted for each add + assert!(got.length() == number_of_adds); + + // Assert that the emitted events match the expected events + for (i in 0..number_of_adds) { + assert!(expected.borrow(i) == got.borrow(i)); + } + } + + inline fun assert_remove_events_emitted( + added_addresses: vector
, state: &allowlist::AllowlistState + ) { + let expected = + added_addresses.map:: ( + |add| allowlist::new_remove_event(add) + ); + let got = + event::emitted_events_by_handle( + allowlist::get_allowlist_remove_events(state) + ); + let number_of_adds = expected.length(); + + // Assert that exactly one event was emitted for each add + assert!(got.length() == number_of_adds); + + // Assert that the emitted events match the expected events + for (i in 0..number_of_adds) { + assert!(expected.borrow(i) == got.borrow(i)); + } + } + + inline fun set_up_test(owner: &signer, allowlist: vector
) + : allowlist::AllowlistState { + account::create_account_for_test(signer::address_of(owner)); + + allowlist::new(owner, allowlist) + } +} +` + +/** sources/auth.move */ +export const CCIP_AUTH_MOVE = `module ccip::auth { + use std::error; + use std::object; + use std::option::{Self, Option}; + use std::signer; + use std::string; + + use ccip::allowlist; + use ccip::ownable; + use ccip::state_object; + + use mcms::bcs_stream; + use mcms::mcms_registry; + + struct AuthState has key { + ownable_state: ownable::OwnableState, + allowed_onramps: allowlist::AllowlistState, + allowed_offramps: allowlist::AllowlistState + } + + const E_UNKNOWN_FUNCTION: u64 = 1; + const E_NOT_ALLOWED_ONRAMP: u64 = 2; + const E_NOT_ALLOWED_OFFRAMP: u64 = 3; + const E_NOT_OWNER_OR_CCIP: u64 = 4; + + fun init_module(publisher: &signer) { + let state_object_signer = &state_object::object_signer(); + + let allowed_onramps = + allowlist::new_with_name( + state_object_signer, vector[], string::utf8(b"onramps") + ); + allowlist::set_allowlist_enabled(&mut allowed_onramps, true); + + let allowed_offramps = + allowlist::new_with_name( + state_object_signer, vector[], string::utf8(b"offramps") + ); + allowlist::set_allowlist_enabled(&mut allowed_offramps, true); + + move_to( + state_object_signer, + AuthState { + ownable_state: ownable::new(state_object_signer, @ccip), + allowed_onramps, + allowed_offramps + } + ); + + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher); + }; + } + + #[view] + public fun get_allowed_onramps(): vector
acquires AuthState { + allowlist::get_allowlist(&borrow_state().allowed_onramps) + } + + #[view] + public fun get_allowed_offramps(): vector
acquires AuthState { + allowlist::get_allowlist(&borrow_state().allowed_offramps) + } + + #[view] + public fun is_onramp_allowed(onramp_address: address): bool acquires AuthState { + allowlist::is_allowed(&borrow_state().allowed_onramps, onramp_address) + } + + #[view] + public fun is_offramp_allowed(offramp_address: address): bool acquires AuthState { + allowlist::is_allowed(&borrow_state().allowed_offramps, offramp_address) + } + + public entry fun apply_allowed_onramp_updates( + caller: &signer, onramps_to_remove: vector
, onramps_to_add: vector
+ ) acquires AuthState { + let state = borrow_state_mut(); + + assert_is_owner_or_ccip(signer::address_of(caller), &state.ownable_state); + + allowlist::apply_allowlist_updates( + &mut state.allowed_onramps, onramps_to_remove, onramps_to_add + ); + } + + public entry fun apply_allowed_offramp_updates( + caller: &signer, + offramps_to_remove: vector
, + offramps_to_add: vector
+ ) acquires AuthState { + let state = borrow_state_mut(); + + assert_is_owner_or_ccip(signer::address_of(caller), &state.ownable_state); + + allowlist::apply_allowlist_updates( + &mut state.allowed_offramps, offramps_to_remove, offramps_to_add + ); + } + + inline fun borrow_state(): &AuthState { + borrow_global(state_object::object_address()) + } + + inline fun borrow_state_mut(): &mut AuthState { + borrow_global_mut(state_object::object_address()) + } + + inline fun assert_is_owner_or_ccip( + caller: address, ownable_state: &ownable::OwnableState + ) { + assert!( + caller == @ccip || caller == ownable::owner(ownable_state), + error::permission_denied(E_NOT_OWNER_OR_CCIP) + ); + } + + public fun assert_is_allowed_onramp(caller: address) acquires AuthState { + assert!( + allowlist::is_allowed(&borrow_state().allowed_onramps, caller), + error::permission_denied(E_NOT_ALLOWED_ONRAMP) + ); + } + + public fun assert_is_allowed_offramp(caller: address) acquires AuthState { + assert!( + allowlist::is_allowed(&borrow_state().allowed_offramps, caller), + error::permission_denied(E_NOT_ALLOWED_OFFRAMP) + ); + } + + // ================================================================ + // | Ownable | + // ================================================================ + #[view] + public fun owner(): address acquires AuthState { + ownable::owner(&borrow_state().ownable_state) + } + + #[view] + public fun has_pending_transfer(): bool acquires AuthState { + ownable::has_pending_transfer(&borrow_state().ownable_state) + } + + #[view] + public fun pending_transfer_from(): Option
acquires AuthState { + ownable::pending_transfer_from(&borrow_state().ownable_state) + } + + #[view] + public fun pending_transfer_to(): Option
acquires AuthState { + ownable::pending_transfer_to(&borrow_state().ownable_state) + } + + #[view] + public fun pending_transfer_accepted(): Option acquires AuthState { + ownable::pending_transfer_accepted(&borrow_state().ownable_state) + } + + public fun assert_only_owner(caller: address) acquires AuthState { + ownable::assert_only_owner(caller, &borrow_state().ownable_state) + } + + public entry fun transfer_ownership(caller: &signer, to: address) acquires AuthState { + let state = borrow_state_mut(); + ownable::transfer_ownership(caller, &mut state.ownable_state, to) + } + + public entry fun accept_ownership(caller: &signer) acquires AuthState { + let state = borrow_state_mut(); + ownable::accept_ownership(caller, &mut state.ownable_state) + } + + public entry fun execute_ownership_transfer( + caller: &signer, to: address + ) acquires AuthState { + let state = borrow_state_mut(); + ownable::execute_ownership_transfer(caller, &mut state.ownable_state, to) + } + + // ================================================================ + // | MCMS Entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: object::Object + ): option::Option acquires AuthState { + let (caller, function, data) = + mcms_registry::get_callback_params(@ccip, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"apply_allowed_onramp_updates") { + let onramps_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let onramps_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_allowed_onramp_updates(&caller, onramps_to_remove, onramps_to_add) + } else if (function_bytes == b"apply_allowed_offramp_updates") { + let offramps_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let offramps_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_allowed_offramp_updates(&caller, offramps_to_remove, offramps_to_add) + } else if (function_bytes == b"transfer_ownership") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + transfer_ownership(&caller, to) + } else if (function_bytes == b"accept_ownership") { + bcs_stream::assert_is_consumed(&stream); + accept_ownership(&caller) + } else if (function_bytes == b"execute_ownership_transfer") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + execute_ownership_transfer(&caller, to) + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint(publisher: &signer) { + mcms_registry::register_entrypoint( + publisher, string::utf8(b"auth"), McmsCallback {} + ); + } + + // ========================== TEST ONLY ========================== + #[test_only] + public fun test_init_module(publisher: &signer) { + init_module(publisher); + } + + #[test_only] + public fun test_register_mcms_entrypoint(publisher: &signer) { + mcms_registry::register_entrypoint( + publisher, string::utf8(b"auth"), McmsCallback {} + ); + } +} +` + +/** sources/client.move */ +export const CCIP_CLIENT_MOVE = `/// This module defines messages for end users to interact with Aptos CCIP. +module ccip::client { + use std::bcs; + + const GENERIC_EXTRA_ARGS_V2_TAG: vector = x"181dcf10"; + const SVM_EXTRA_ARGS_V1_TAG: vector = x"1f3b3aba"; + + const E_INVALID_SVM_TOKEN_RECEIVER_LENGTH: u64 = 1; + const E_INVALID_SVM_ACCOUNT_LENGTH: u64 = 2; + + #[view] + public fun generic_extra_args_v2_tag(): vector { + GENERIC_EXTRA_ARGS_V2_TAG + } + + #[view] + public fun svm_extra_args_v1_tag(): vector { + SVM_EXTRA_ARGS_V1_TAG + } + + #[view] + public fun encode_generic_extra_args_v2( + gas_limit: u256, allow_out_of_order_execution: bool + ): vector { + let extra_args = vector[]; + extra_args.append(GENERIC_EXTRA_ARGS_V2_TAG); + extra_args.append(bcs::to_bytes(&gas_limit)); + extra_args.append(bcs::to_bytes(&allow_out_of_order_execution)); + extra_args + } + + #[view] + public fun encode_svm_extra_args_v1( + compute_units: u32, + account_is_writable_bitmap: u64, + allow_out_of_order_execution: bool, + token_receiver: vector, + accounts: vector> + ): vector { + let extra_args = vector[]; + extra_args.append(SVM_EXTRA_ARGS_V1_TAG); + extra_args.append(bcs::to_bytes(&compute_units)); + extra_args.append(bcs::to_bytes(&account_is_writable_bitmap)); + extra_args.append(bcs::to_bytes(&allow_out_of_order_execution)); + + assert!(token_receiver.length() == 32, E_INVALID_SVM_TOKEN_RECEIVER_LENGTH); + accounts.for_each_ref( + |account| { + assert!(account.length() == 32, E_INVALID_SVM_ACCOUNT_LENGTH); + } + ); + + extra_args.append(bcs::to_bytes(&token_receiver)); + extra_args.append(bcs::to_bytes(&accounts)); + extra_args + } + + struct Any2AptosMessage has store, drop, copy { + message_id: vector, + source_chain_selector: u64, + sender: vector, + data: vector, + dest_token_amounts: vector + } + + struct Any2AptosTokenAmount has store, drop, copy { + token: address, + amount: u64 + } + + public fun new_any2aptos_message( + message_id: vector, + source_chain_selector: u64, + sender: vector, + data: vector, + dest_token_amounts: vector + ): Any2AptosMessage { + Any2AptosMessage { + message_id, + source_chain_selector, + sender, + data, + dest_token_amounts + } + } + + public fun new_dest_token_amounts( + token_addresses: vector
, token_amounts: vector + ): vector { + token_addresses.zip_map_ref( + &token_amounts, + |token_address, token_amount| { + Any2AptosTokenAmount { token: *token_address, amount: *token_amount } + } + ) + } + + // Any2AptosMessage accessors + public fun get_message_id(input: &Any2AptosMessage): vector { + input.message_id + } + + public fun get_source_chain_selector(input: &Any2AptosMessage): u64 { + input.source_chain_selector + } + + public fun get_sender(input: &Any2AptosMessage): vector { + input.sender + } + + public fun get_data(input: &Any2AptosMessage): vector { + input.data + } + + public fun get_dest_token_amounts(input: &Any2AptosMessage) + : vector { + input.dest_token_amounts + } + + // Any2AptosTokenAmount accessors + public fun get_token(input: &Any2AptosTokenAmount): address { + input.token + } + + public fun get_amount(input: &Any2AptosTokenAmount): u64 { + input.amount + } +} +` + +/** sources/eth_abi.move */ +export const CCIP_ETH_ABI_MOVE = `// module to do the equivalent packing as ethereum's abi.encode and abi.encodePacked +module ccip::eth_abi { + use std::bcs; + use std::error; + use std::from_bcs; + use std::vector; + + const ENCODED_BOOL_FALSE: vector = vector[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const ENCODED_BOOL_TRUE: vector = vector[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + + const E_OUT_OF_BYTES: u64 = 1; + const E_INVALID_ADDRESS: u64 = 2; + const E_INVALID_BOOL: u64 = 3; + const E_INVALID_SELECTOR: u64 = 4; + const E_INVALID_U256_LENGTH: u64 = 5; + const E_INTEGER_OVERFLOW: u64 = 6; + const E_INVALID_BYTES32_LENGTH: u64 = 7; + + public inline fun encode_address(out: &mut vector, value: address) { + out.append(bcs::to_bytes(&value)) + } + + public inline fun encode_u8(out: &mut vector, value: u8) { + encode_u256(out, value as u256); + } + + public inline fun encode_u32(out: &mut vector, value: u32) { + encode_u256(out, value as u256) + } + + public inline fun encode_u64(out: &mut vector, value: u64) { + encode_u256(out, value as u256) + } + + public inline fun encode_u256(out: &mut vector, value: u256) { + let value_bytes = bcs::to_bytes(&value); + // little endian to big endian + value_bytes.reverse(); + out.append(value_bytes) + } + + public fun encode_bool(out: &mut vector, value: bool) { + out.append(if (value) ENCODED_BOOL_TRUE else ENCODED_BOOL_FALSE) + } + + /// For numeric types (address, uint, int) - left padded with zeros + public inline fun encode_left_padded_bytes32( + out: &mut vector, value: vector + ) { + assert!(value.length() <= 32, error::invalid_argument(E_INVALID_U256_LENGTH)); + + let padding_len = 32 - value.length(); + for (i in 0..padding_len) { + out.push_back(0); + }; + out.append(value); + } + + /// For byte array types (bytes32, bytes4, etc.) - right padded with zeros + public inline fun encode_right_padded_bytes32( + out: &mut vector, value: vector + ) { + assert!(value.length() <= 32, E_INVALID_BYTES32_LENGTH); + + out.append(value); + let padding_len = 32 - value.length(); + for (i in 0..padding_len) { + out.push_back(0); + }; + } + + public inline fun encode_bytes(out: &mut vector, value: vector) { + encode_u256(out, (value.length() as u256)); + + out.append(value); + if (value.length() % 32 != 0) { + let padding_len = 32 - (value.length() % 32); + for (i in 0..padding_len) { + out.push_back(0); + } + } + } + + public fun encode_selector(out: &mut vector, value: vector) { + assert!(value.length() == 4, error::invalid_argument(E_INVALID_SELECTOR)); + out.append(value); + } + + public inline fun encode_packed_address( + out: &mut vector, value: address + ) { + out.append(bcs::to_bytes(&value)) + } + + public inline fun encode_packed_bytes( + out: &mut vector, value: vector + ) { + out.append(value) + } + + public inline fun encode_packed_bytes32( + out: &mut vector, value: vector + ) { + assert!(value.length() <= 32, E_INVALID_BYTES32_LENGTH); + + out.append(value); + let padding_len = 32 - value.length(); + for (i in 0..padding_len) { + out.push_back(0); + }; + } + + public inline fun encode_packed_u8(out: &mut vector, value: u8) { + out.push_back(value) + } + + public inline fun encode_packed_u32(out: &mut vector, value: u32) { + let value_bytes = bcs::to_bytes(&value); + // little endian to big endian + value_bytes.reverse(); + out.append(value_bytes) + } + + public inline fun encode_packed_u64(out: &mut vector, value: u64) { + let value_bytes = bcs::to_bytes(&value); + // little endian to big endian + value_bytes.reverse(); + out.append(value_bytes) + } + + public inline fun encode_packed_u256(out: &mut vector, value: u256) { + let value_bytes = bcs::to_bytes(&value); + // little endian to big endian + value_bytes.reverse(); + out.append(value_bytes) + } + + struct ABIStream has drop { + data: vector, + cur: u64 + } + + public fun new_stream(data: vector): ABIStream { + ABIStream { data, cur: 0 } + } + + public fun decode_address(stream: &mut ABIStream): address { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 32 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + + // Verify first 12 bytes are zero + for (i in 0..12) { + assert!( + data[cur + i] == 0, error::invalid_argument(E_INVALID_ADDRESS) + ); + }; + + // Extract last 20 bytes for address + let addr_bytes = data.slice(cur + 12, cur + 32); + stream.cur = cur + 32; + + from_bcs::to_address(addr_bytes) + } + + public fun decode_u256(stream: &mut ABIStream): u256 { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 32 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + + let value_bytes = data.slice(cur, cur + 32); + // Convert from big endian to little endian + value_bytes.reverse(); + + stream.cur = cur + 32; + from_bcs::to_u256(value_bytes) + } + + public fun decode_u8(stream: &mut ABIStream): u8 { + let value = decode_u256(stream); + assert!(value <= 0xFF, error::invalid_argument(E_INTEGER_OVERFLOW)); + (value as u8) + } + + public fun decode_u32(stream: &mut ABIStream): u32 { + let value = decode_u256(stream); + assert!(value <= 0xFFFFFFFF, error::invalid_argument(E_INTEGER_OVERFLOW)); + (value as u32) + } + + public fun decode_u64(stream: &mut ABIStream): u64 { + let value = decode_u256(stream); + assert!(value <= 0xFFFFFFFFFFFFFFFF, error::invalid_argument(E_INTEGER_OVERFLOW)); + (value as u64) + } + + public fun decode_bool(stream: &mut ABIStream): bool { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 32 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + + let value = data.slice(cur, cur + 32); + stream.cur = cur + 32; + + if (value == ENCODED_BOOL_FALSE) { false } + else if (value == ENCODED_BOOL_TRUE) { true } + else { + abort error::invalid_argument(E_INVALID_BOOL) + } + } + + public fun decode_bytes32(stream: &mut ABIStream): vector { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 32 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + + let bytes = data.slice(cur, cur + 32); + stream.cur = cur + 32; + bytes + } + + public fun decode_bytes(stream: &mut ABIStream): vector { + // First read length as u256 + let length = (decode_u256(stream) as u64); + + let padding_len = if (length % 32 == 0) { 0 } + else { + 32 - (length % 32) + }; + + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + length + padding_len <= data.length(), + error::out_of_range(E_OUT_OF_BYTES) + ); + + let bytes = data.slice(cur, cur + length); + + // Skip padding bytes + stream.cur = cur + length + padding_len; + + bytes + } + + public inline fun decode_vector( + stream: &mut ABIStream, elem_decoder: |&mut ABIStream| E + ): vector { + let len = decode_u256(stream); + let v = vector::empty(); + + for (i in 0..len) { + v.push_back(elem_decoder(stream)); + }; + + v + } + + public fun decode_u256_value(value_bytes: vector): u256 { + assert!( + value_bytes.length() == 32, + error::invalid_argument(E_INVALID_U256_LENGTH) + ); + value_bytes.reverse(); + from_bcs::to_u256(value_bytes) + } +} +` + +/** sources/fee_quoter.move */ +export const CCIP_FEE_QUOTER_MOVE = `/// This module is responsible for storage and retrieval of fee token and token transfer +/// information and pricing. +module ccip::fee_quoter { + use std::account; + use std::bcs; + use std::error; + use std::event::{Self, EventHandle}; + use std::fungible_asset::Metadata; + use std::object; + use std::option; + use std::signer; + use std::string::{Self, String}; + use std::smart_table::{Self, SmartTable}; + use std::timestamp; + + use ccip::auth; + use ccip::client; + use ccip::eth_abi; + use ccip::state_object; + + use mcms::bcs_stream; + use mcms::mcms_registry; + + const CHAIN_FAMILY_SELECTOR_EVM: vector = x"2812d52c"; + const CHAIN_FAMILY_SELECTOR_SVM: vector = x"1e10bdc4"; + const CHAIN_FAMILY_SELECTOR_APTOS: vector = x"ac77ffec"; + const CHAIN_FAMILY_SELECTOR_SUI: vector = x"c4e05953"; + + /// @dev We disallow the first 1024 addresses to avoid calling into a range known for hosting precompiles. Calling + /// into precompiles probably won't cause any issues, but to be safe we can disallow this range. It is extremely + /// unlikely that anyone would ever be able to generate an address in this range. There is no official range of + /// precompiles, but EIP-7587 proposes to reserve the range 0x100 to 0x1ff. Our range is more conservative, even + /// though it might not be exhaustive for all chains, which is OK. We also disallow the zero address, which is a + /// common practice. + const EVM_PRECOMPILE_SPACE: u256 = 1024; + + /// @dev According to the Aptos docs, the first 0xa addresses are reserved for precompiles. + /// https://github.com/aptos-labs/aptos-core/blob/main/aptos-move/framework/aptos-framework/doc/account.md#function-create_framework_reserved_account-1 + /// We use the same range for SUI, even though there is one documented reserved address outside of this range. + /// Since sending a message to this address would not cause any negative side effects, as it would never register + /// a callback with CCIP, there is no negative impact. + /// https://move-book.com/appendix/reserved-addresses.html + const MOVE_PRECOMPILE_SPACE: u256 = 0x0b; + + const ALLOW_OUT_OF_ORDER_EXECUTION: bool = true; + + const GAS_PRICE_BITS: u8 = 112; + const GAS_PRICE_MASK_112_BITS: u256 = 0xffffffffffffffffffffffffffff; // 28 f's + + const MESSAGE_FIXED_BYTES: u64 = 32 * 15; + const MESSAGE_FIXED_BYTES_PER_TOKEN: u64 = 32 * (4 + (3 + 2)); + + const CCIP_LOCK_OR_BURN_V1_RET_BYTES: u32 = 32; + + /// The maximum number of accounts that can be passed in SVMExtraArgs. + const SVM_EXTRA_ARGS_MAX_ACCOUNTS: u64 = 64; + + /// Number of overhead accounts needed for message execution on SVM. + /// These are message.receiver, and the OffRamp Signer PDA specific to the receiver. + const SVM_MESSAGING_ACCOUNTS_OVERHEAD: u64 = 2; + + /// The size of each SVM account (in bytes). + const SVM_ACCOUNT_BYTE_SIZE: u64 = 32; + + /// The expected static payload size of a token transfer when Borsh encoded and submitted to SVM. + /// TokenPool extra data and offchain data sizes are dynamic, and should be accounted for separately. + const SVM_TOKEN_TRANSFER_DATA_OVERHEAD: u64 = (4 + 32) // source_pool + + 32 // token_address + + 4 // gas_amount + + 4 // extra_data overhead + + 32 // amount + + 32 // size of the token lookup table account + + 32 // token-related accounts in the lookup table, over-estimated to 32, typically between 11 - 13 + + 32 // token account belonging to the token receiver, e.g ATA, not included in the token lookup table + + 32 // per-chain token pool config, not included in the token lookup table + + 32 // per-chain token billing config, not always included in the token lookup table + + 32; // OffRamp pool signer PDA, not included in the token lookup table; + + const MAX_U64: u256 = 18446744073709551615; + const MAX_U160: u256 = 1461501637330902918203684832716283019655932542975; + const MAX_U256: u256 = + 115792089237316195423570985008687907853269984665640564039457584007913129639935; + const VAL_1E5: u256 = 100_000; + const VAL_1E14: u256 = 100_000_000_000_000; + const VAL_1E16: u256 = 10_000_000_000_000_000; + const VAL_1E18: u256 = 1_000_000_000_000_000_000; + + // Link has 8 decimals on Aptos and 18 decimals on it's native chain, Ethereum. We want to emit + // the fee in juels (1e18) denomination for consistency across chains. This means we multiply + // the fee by 1e10 on Aptos before we emit it in the event. + const LOCAL_8_TO_18_DECIMALS_LINK_MULTIPLIER: u256 = 10_000_000_000; + + struct FeeQuoterState has key, store { + // max_fee_juels_per_msg is in juels (1e18) denomination for consistency across chains. + max_fee_juels_per_msg: u256, + link_token: address, + token_price_staleness_threshold: u64, + fee_tokens: vector
, + usd_per_unit_gas_by_dest_chain: SmartTable, + usd_per_token: SmartTable, + dest_chain_configs: SmartTable, + // dest chain selector -> local token -> TokenTransferFeeConfig + token_transfer_fee_configs: SmartTable>, + premium_multiplier_wei_per_eth: SmartTable, + fee_token_added_events: EventHandle, + fee_token_removed_events: EventHandle, + token_transfer_fee_config_added_events: EventHandle, + token_transfer_fee_config_removed_events: EventHandle, + usd_per_token_updated_events: EventHandle, + usd_per_unit_gas_updated_events: EventHandle, + dest_chain_added_events: EventHandle, + dest_chain_config_updated_events: EventHandle, + premium_multiplier_wei_per_eth_updated_events: EventHandle< + PremiumMultiplierWeiPerEthUpdated> + } + + struct StaticConfig has drop { + max_fee_juels_per_msg: u256, + link_token: address, + token_price_staleness_threshold: u64 + } + + struct DestChainConfig has store, drop, copy { + is_enabled: bool, + max_number_of_tokens_per_msg: u16, + max_data_bytes: u32, + max_per_msg_gas_limit: u32, + dest_gas_overhead: u32, + dest_gas_per_payload_byte_base: u8, + dest_gas_per_payload_byte_high: u8, + dest_gas_per_payload_byte_threshold: u16, + dest_data_availability_overhead_gas: u32, + dest_gas_per_data_availability_byte: u16, + dest_data_availability_multiplier_bps: u16, + chain_family_selector: vector, + enforce_out_of_order: bool, + default_token_fee_usd_cents: u16, + default_token_dest_gas_overhead: u32, + default_tx_gas_limit: u32, + // Multiplier for gas costs, 1e18 based so 11e17 = 10% extra cost. + gas_multiplier_wei_per_eth: u64, + gas_price_staleness_threshold: u32, + network_fee_usd_cents: u32 + } + + struct TokenTransferFeeConfig has store, drop, copy { + min_fee_usd_cents: u32, + max_fee_usd_cents: u32, + deci_bps: u16, + dest_gas_overhead: u32, + dest_bytes_overhead: u32, + is_enabled: bool + } + + struct TimestampedPrice has store, drop, copy { + value: u256, + timestamp: u64 + } + + #[event] + struct FeeTokenAdded has store, drop { + fee_token: address + } + + #[event] + struct FeeTokenRemoved has store, drop { + fee_token: address + } + + #[event] + struct TokenTransferFeeConfigAdded has store, drop { + dest_chain_selector: u64, + token: address, + token_transfer_fee_config: TokenTransferFeeConfig + } + + #[event] + struct TokenTransferFeeConfigRemoved has store, drop { + dest_chain_selector: u64, + token: address + } + + #[event] + struct UsdPerTokenUpdated has store, drop { + token: address, + usd_per_token: u256, + timestamp: u64 + } + + #[event] + struct UsdPerUnitGasUpdated has store, drop { + dest_chain_selector: u64, + usd_per_unit_gas: u256, + timestamp: u64 + } + + #[event] + struct DestChainAdded has store, drop { + dest_chain_selector: u64, + dest_chain_config: DestChainConfig + } + + #[event] + struct DestChainConfigUpdated has store, drop { + dest_chain_selector: u64, + dest_chain_config: DestChainConfig + } + + #[event] + struct PremiumMultiplierWeiPerEthUpdated has store, drop { + token: address, + premium_multiplier_wei_per_eth: u64 + } + + const E_ALREADY_INITIALIZED: u64 = 1; + const E_INVALID_LINK_TOKEN: u64 = 2; + const E_UNKNOWN_DEST_CHAIN_SELECTOR: u64 = 3; + const E_UNKNOWN_TOKEN: u64 = 4; + const E_DEST_CHAIN_NOT_ENABLED: u64 = 5; + const E_TOKEN_UPDATE_MISMATCH: u64 = 6; + const E_GAS_UPDATE_MISMATCH: u64 = 7; + const E_TOKEN_TRANSFER_FEE_CONFIG_MISMATCH: u64 = 8; + const E_FEE_TOKEN_NOT_SUPPORTED: u64 = 9; + const E_TOKEN_NOT_SUPPORTED: u64 = 10; + const E_UNKNOWN_CHAIN_FAMILY_SELECTOR: u64 = 11; + const E_STALE_GAS_PRICE: u64 = 12; + const E_MESSAGE_TOO_LARGE: u64 = 13; + const E_UNSUPPORTED_NUMBER_OF_TOKENS: u64 = 14; + const E_INVALID_EVM_ADDRESS: u64 = 15; + const E_INVALID_32BYTES_ADDRESS: u64 = 16; + const E_FEE_TOKEN_COST_TOO_HIGH: u64 = 17; + const E_MESSAGE_GAS_LIMIT_TOO_HIGH: u64 = 18; + const E_EXTRA_ARG_OUT_OF_ORDER_EXECUTION_MUST_BE_TRUE: u64 = 19; + const E_INVALID_EXTRA_ARGS_TAG: u64 = 20; + const E_INVALID_EXTRA_ARGS_DATA: u64 = 21; + const E_INVALID_TOKEN_RECEIVER: u64 = 22; + const E_MESSAGE_COMPUTE_UNIT_LIMIT_TOO_HIGH: u64 = 23; + const E_MESSAGE_FEE_TOO_HIGH: u64 = 24; + const E_SOURCE_TOKEN_DATA_TOO_LARGE: u64 = 25; + const E_INVALID_DEST_CHAIN_SELECTOR: u64 = 26; + const E_INVALID_GAS_LIMIT: u64 = 27; + const E_INVALID_CHAIN_FAMILY_SELECTOR: u64 = 28; + const E_TO_TOKEN_AMOUNT_TOO_LARGE: u64 = 29; + const E_UNKNOWN_FUNCTION: u64 = 30; + const E_ZERO_TOKEN_PRICE: u64 = 31; + const E_TOO_MANY_SVM_EXTRA_ARGS_ACCOUNTS: u64 = 32; + const E_INVALID_SVM_EXTRA_ARGS_WRITABLE_BITMAP: u64 = 33; + const E_INVALID_FEE_RANGE: u64 = 34; + const E_INVALID_DEST_BYTES_OVERHEAD: u64 = 35; + const E_INVALID_SVM_RECEIVER_LENGTH: u64 = 36; + const E_TOKEN_AMOUNT_MISMATCH: u64 = 37; + const E_INVALID_SVM_ACCOUNT_LENGTH: u64 = 38; + + #[view] + public fun type_and_version(): String { + string::utf8(b"FeeQuoter 1.6.0") + } + + fun init_module(publisher: &signer) { + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher); + }; + } + + public entry fun initialize( + caller: &signer, + max_fee_juels_per_msg: u256, + link_token: address, + token_price_staleness_threshold: u64, + fee_tokens: vector
+ ) { + auth::assert_only_owner(signer::address_of(caller)); + + assert!( + !exists(state_object::object_address()), + error::invalid_argument(E_ALREADY_INITIALIZED) + ); + + assert!( + object::object_exists(link_token), + error::invalid_argument(E_INVALID_LINK_TOKEN) + ); + + let state_object_signer = state_object::object_signer(); + + let state = FeeQuoterState { + max_fee_juels_per_msg, + link_token, + token_price_staleness_threshold, + fee_tokens, + usd_per_unit_gas_by_dest_chain: smart_table::new(), + usd_per_token: smart_table::new(), + dest_chain_configs: smart_table::new(), + token_transfer_fee_configs: smart_table::new(), + premium_multiplier_wei_per_eth: smart_table::new(), + fee_token_added_events: account::new_event_handle(&state_object_signer), + fee_token_removed_events: account::new_event_handle(&state_object_signer), + token_transfer_fee_config_added_events: account::new_event_handle( + &state_object_signer + ), + token_transfer_fee_config_removed_events: account::new_event_handle( + &state_object_signer + ), + usd_per_token_updated_events: account::new_event_handle(&state_object_signer), + usd_per_unit_gas_updated_events: account::new_event_handle( + &state_object_signer + ), + dest_chain_added_events: account::new_event_handle(&state_object_signer), + dest_chain_config_updated_events: account::new_event_handle( + &state_object_signer + ), + premium_multiplier_wei_per_eth_updated_events: account::new_event_handle( + &state_object_signer + ) + }; + move_to(&state_object_signer, state); + } + + #[view] + public fun get_token_price(token: address): TimestampedPrice acquires FeeQuoterState { + get_token_price_internal(borrow_state(), token) + } + + public fun timestamped_price_value( + timestamped_price: &TimestampedPrice + ): u256 { + timestamped_price.value + } + + public fun timestamped_price_timestamp( + timestamped_price: &TimestampedPrice + ): u64 { + timestamped_price.timestamp + } + + #[view] + public fun get_token_prices( + tokens: vector
+ ): (vector) acquires FeeQuoterState { + let state = borrow_state(); + tokens.map_ref(|token| get_token_price_internal(state, *token)) + } + + #[view] + public fun get_dest_chain_gas_price( + dest_chain_selector: u64 + ): TimestampedPrice acquires FeeQuoterState { + get_dest_chain_gas_price_internal(borrow_state(), dest_chain_selector) + } + + #[view] + public fun get_token_and_gas_prices( + token: address, dest_chain_selector: u64 + ): (u256, u256) acquires FeeQuoterState { + let state = borrow_state(); + let dest_chain_config = get_dest_chain_config_internal( + state, dest_chain_selector + ); + assert!( + dest_chain_config.is_enabled, + error::invalid_argument(E_DEST_CHAIN_NOT_ENABLED) + ); + let token_price = get_token_price_internal(state, token); + let gas_price_value = + get_validated_gas_price_internal( + state, dest_chain_config, dest_chain_selector + ); + (token_price.value, gas_price_value) + } + + #[view] + public fun convert_token_amount( + from_token: address, from_token_amount: u64, to_token: address + ): u64 acquires FeeQuoterState { + let state = borrow_state(); + convert_token_amount_internal(state, from_token, from_token_amount, to_token) + } + + #[view] + public fun get_fee_tokens(): vector
acquires FeeQuoterState { + borrow_state().fee_tokens + } + + public entry fun apply_fee_token_updates( + caller: &signer, + fee_tokens_to_remove: vector
, + fee_tokens_to_add: vector
+ ) acquires FeeQuoterState { + auth::assert_only_owner(signer::address_of(caller)); + + let state = borrow_state_mut(); + + // Remove tokens + fee_tokens_to_remove.for_each_ref( + |fee_token| { + let fee_token = *fee_token; + let (found, index) = state.fee_tokens.index_of(&fee_token); + if (found) { + state.fee_tokens.remove(index); + event::emit_event( + &mut state.fee_token_removed_events, FeeTokenRemoved { fee_token } + ); + }; + } + ); + + // Add new tokens + fee_tokens_to_add.for_each_ref( + |fee_token| { + let fee_token = *fee_token; + let (found, _) = state.fee_tokens.index_of(&fee_token); + if (!found) { + state.fee_tokens.push_back(fee_token); + event::emit_event( + &mut state.fee_token_added_events, FeeTokenAdded { fee_token } + ); + }; + } + ); + } + + #[view] + public fun get_token_transfer_fee_config( + dest_chain_selector: u64, token: address + ): TokenTransferFeeConfig acquires FeeQuoterState { + *get_token_transfer_fee_config_internal( + borrow_state(), dest_chain_selector, token + ) + } + + inline fun get_token_transfer_fee_config_internal( + state: &FeeQuoterState, dest_chain_selector: u64, token: address + ): &TokenTransferFeeConfig { + let empty_fee_config = TokenTransferFeeConfig { + min_fee_usd_cents: 0, + max_fee_usd_cents: 0, + deci_bps: 0, + dest_gas_overhead: 0, + dest_bytes_overhead: 0, + is_enabled: false + }; + + if (!state.token_transfer_fee_configs.contains(dest_chain_selector)) { + &empty_fee_config + } else { + let dest_chain_fee_configs = + state.token_transfer_fee_configs.borrow(dest_chain_selector); + + dest_chain_fee_configs.borrow_with_default(token, &empty_fee_config) + } + } + + // Note that unlike EVM, this only allows changes for a single dest chain selector + // at a time. + public entry fun apply_token_transfer_fee_config_updates( + caller: &signer, + dest_chain_selector: u64, + add_tokens: vector
, + add_min_fee_usd_cents: vector, + add_max_fee_usd_cents: vector, + add_deci_bps: vector, + add_dest_gas_overhead: vector, + add_dest_bytes_overhead: vector, + add_is_enabled: vector, + remove_tokens: vector
+ ) acquires FeeQuoterState { + auth::assert_only_owner(signer::address_of(caller)); + + let state = borrow_state_mut(); + + if (!state.token_transfer_fee_configs.contains(dest_chain_selector)) { + state.token_transfer_fee_configs.add( + dest_chain_selector, smart_table::new() + ); + }; + let token_transfer_fee_configs = + state.token_transfer_fee_configs.borrow_mut(dest_chain_selector); + + let add_tokens_len = add_tokens.length(); + assert!( + add_tokens_len == add_min_fee_usd_cents.length(), + error::invalid_argument(E_TOKEN_TRANSFER_FEE_CONFIG_MISMATCH) + ); + assert!( + add_tokens_len == add_max_fee_usd_cents.length(), + error::invalid_argument(E_TOKEN_TRANSFER_FEE_CONFIG_MISMATCH) + ); + assert!( + add_tokens_len == add_deci_bps.length(), + error::invalid_argument(E_TOKEN_TRANSFER_FEE_CONFIG_MISMATCH) + ); + assert!( + add_tokens_len == add_dest_gas_overhead.length(), + error::invalid_argument(E_TOKEN_TRANSFER_FEE_CONFIG_MISMATCH) + ); + assert!( + add_tokens_len == add_dest_bytes_overhead.length(), + error::invalid_argument(E_TOKEN_TRANSFER_FEE_CONFIG_MISMATCH) + ); + assert!( + add_tokens_len == add_is_enabled.length(), + error::invalid_argument(E_TOKEN_TRANSFER_FEE_CONFIG_MISMATCH) + ); + + for (i in 0..add_tokens_len) { + let token = add_tokens[i]; + let min_fee_usd_cents = add_min_fee_usd_cents[i]; + let max_fee_usd_cents = add_max_fee_usd_cents[i]; + let deci_bps = add_deci_bps[i]; + let dest_gas_overhead = add_dest_gas_overhead[i]; + let dest_bytes_overhead = add_dest_bytes_overhead[i]; + let is_enabled = add_is_enabled[i]; + + let token_transfer_fee_config = TokenTransferFeeConfig { + min_fee_usd_cents, + max_fee_usd_cents, + deci_bps, + dest_gas_overhead, + dest_bytes_overhead, + is_enabled + }; + + if (token_transfer_fee_config.min_fee_usd_cents + >= token_transfer_fee_config.max_fee_usd_cents) { + abort error::invalid_argument(E_INVALID_FEE_RANGE); + }; + if (token_transfer_fee_config.dest_bytes_overhead + < CCIP_LOCK_OR_BURN_V1_RET_BYTES) { + abort error::invalid_argument(E_INVALID_DEST_BYTES_OVERHEAD); + }; + + token_transfer_fee_configs.upsert(token, token_transfer_fee_config); + + event::emit_event( + &mut state.token_transfer_fee_config_added_events, + TokenTransferFeeConfigAdded { + dest_chain_selector, + token, + token_transfer_fee_config + } + ); + }; + + remove_tokens.for_each_ref( + |token| { + let token: address = *token; + if (token_transfer_fee_configs.contains(token)) { + token_transfer_fee_configs.remove(token); + + event::emit_event( + &mut state.token_transfer_fee_config_removed_events, + TokenTransferFeeConfigRemoved { dest_chain_selector, token } + ); + } + } + ); + } + + public fun update_prices( + caller: &signer, + source_tokens: vector
, + source_usd_per_token: vector, + gas_dest_chain_selectors: vector, + gas_usd_per_unit_gas: vector + ) acquires FeeQuoterState { + auth::assert_is_allowed_offramp(signer::address_of(caller)); + + assert!( + source_tokens.length() == source_usd_per_token.length(), + error::invalid_argument(E_TOKEN_UPDATE_MISMATCH) + ); + assert!( + gas_dest_chain_selectors.length() == gas_usd_per_unit_gas.length(), + error::invalid_argument(E_GAS_UPDATE_MISMATCH) + ); + + let state = borrow_state_mut(); + let timestamp = timestamp::now_seconds(); + + source_tokens.zip_ref( + &source_usd_per_token, + |token, usd_per_token| { + let timestamped_price = TimestampedPrice { value: *usd_per_token, timestamp }; + state.usd_per_token.upsert(*token, timestamped_price); + event::emit_event( + &mut state.usd_per_token_updated_events, + UsdPerTokenUpdated { + token: *token, + usd_per_token: *usd_per_token, + timestamp + } + ); + } + ); + + gas_dest_chain_selectors.zip_ref( + &gas_usd_per_unit_gas, + |dest_chain_selector, usd_per_unit_gas| { + let timestamped_price = + TimestampedPrice { value: *usd_per_unit_gas, timestamp }; + state.usd_per_unit_gas_by_dest_chain.upsert( + *dest_chain_selector, timestamped_price + ); + + event::emit_event( + &mut state.usd_per_unit_gas_updated_events, + UsdPerUnitGasUpdated { + dest_chain_selector: *dest_chain_selector, + usd_per_unit_gas: *usd_per_unit_gas, + timestamp + } + ); + } + ); + } + + #[view] + public fun get_validated_fee( + dest_chain_selector: u64, + receiver: vector, + data: vector, + local_token_addresses: vector
, + local_token_amounts: vector, + _token_store_addresses: vector
, + fee_token: address, + _fee_token_store: address, + extra_args: vector + ): u64 acquires FeeQuoterState { + let state = borrow_state(); + + let dest_chain_config = get_dest_chain_config_internal( + state, dest_chain_selector + ); + assert!( + dest_chain_config.is_enabled, + error::invalid_argument(E_DEST_CHAIN_NOT_ENABLED) + ); + + assert!( + state.fee_tokens.contains(&fee_token), + error::invalid_argument(E_FEE_TOKEN_NOT_SUPPORTED) + ); + + let chain_family_selector = dest_chain_config.chain_family_selector; + + let data_len = data.length(); + let tokens_len = local_token_addresses.length(); + validate_message(dest_chain_config, data_len, tokens_len); + + let gas_limit = + if (chain_family_selector == CHAIN_FAMILY_SELECTOR_EVM + || chain_family_selector == CHAIN_FAMILY_SELECTOR_APTOS + || chain_family_selector == CHAIN_FAMILY_SELECTOR_SUI) { + resolve_generic_gas_limit(dest_chain_config, extra_args) + } else if (chain_family_selector == CHAIN_FAMILY_SELECTOR_SVM) { + resolve_svm_gas_limit( + dest_chain_config, + state, + dest_chain_selector, + extra_args, + receiver, + data_len, + tokens_len, + local_token_addresses + ) + } else { + abort error::invalid_argument(E_UNKNOWN_CHAIN_FAMILY_SELECTOR) + }; + + validate_dest_family_address(chain_family_selector, receiver, gas_limit); + + let fee_token_price = get_token_price_internal(state, fee_token); + assert!(fee_token_price.value > 0, error::invalid_state(E_ZERO_TOKEN_PRICE)); + + let packed_gas_price = + get_validated_gas_price_internal( + state, dest_chain_config, dest_chain_selector + ); + + let (premium_fee_usd_wei, token_transfer_gas, token_transfer_bytes_overhead) = + if (tokens_len > 0) { + get_token_transfer_cost( + state, + dest_chain_config, + dest_chain_selector, + fee_token, + fee_token_price, + local_token_addresses, + local_token_amounts + ) + } else { + ((dest_chain_config.network_fee_usd_cents as u256) * VAL_1E16, 0, 0) + }; + let premium_multiplier = + get_premium_multiplier_wei_per_eth_internal(state, fee_token); + premium_fee_usd_wei *=(premium_multiplier as u256); // Apply premium multiplier in wei/eth units + + let data_availability_cost_usd_36_decimals = + if (dest_chain_config.dest_data_availability_multiplier_bps > 0) { + // Extract data availability gas price (upper 112 bits) - matches EVM uint112 behavior + let data_availability_gas_price = + (packed_gas_price >> GAS_PRICE_BITS) & GAS_PRICE_MASK_112_BITS; + get_data_availability_cost( + dest_chain_config, + data_availability_gas_price, + data_len, + tokens_len, + token_transfer_bytes_overhead + ) + } else { 0 }; + + let call_data_length: u256 = + (data_len as u256) + (token_transfer_bytes_overhead as u256); + let dest_call_data_cost = + call_data_length + * (dest_chain_config.dest_gas_per_payload_byte_base as u256); + if (call_data_length + > (dest_chain_config.dest_gas_per_payload_byte_threshold as u256)) { + dest_call_data_cost = + (dest_chain_config.dest_gas_per_payload_byte_base as u256) + * (dest_chain_config.dest_gas_per_payload_byte_threshold as u256) + + ( + call_data_length + - (dest_chain_config.dest_gas_per_payload_byte_threshold as u256) + ) * (dest_chain_config.dest_gas_per_payload_byte_high as u256); + }; + + let total_dest_chain_gas = + (dest_chain_config.dest_gas_overhead as u256) + (token_transfer_gas as u256) + + dest_call_data_cost + gas_limit; + + let gas_cost = packed_gas_price & GAS_PRICE_MASK_112_BITS; + + let total_cost_usd = + ( + total_dest_chain_gas * gas_cost + * (dest_chain_config.gas_multiplier_wei_per_eth as u256) + ) + premium_fee_usd_wei + data_availability_cost_usd_36_decimals; + + let fee_token_cost = total_cost_usd / fee_token_price.value; + + // we need to convert back to a u64 which is what the fungible asset module uses for amounts. + assert!( + fee_token_cost <= MAX_U64, + error::invalid_state(E_FEE_TOKEN_COST_TOO_HIGH) + ); + fee_token_cost as u64 + } + + public entry fun apply_premium_multiplier_wei_per_eth_updates( + caller: &signer, tokens: vector
, premium_multiplier_wei_per_eth: vector + ) acquires FeeQuoterState { + auth::assert_only_owner(signer::address_of(caller)); + + let state = borrow_state_mut(); + + tokens.zip_ref( + &premium_multiplier_wei_per_eth, + |token, premium_multiplier_wei_per_eth| { + let token: address = *token; + let premium_multiplier_wei_per_eth: u64 = *premium_multiplier_wei_per_eth; + state.premium_multiplier_wei_per_eth.upsert( + token, premium_multiplier_wei_per_eth + ); + event::emit_event( + &mut state.premium_multiplier_wei_per_eth_updated_events, + PremiumMultiplierWeiPerEthUpdated { + token, + premium_multiplier_wei_per_eth + } + ); + } + ); + } + + #[view] + public fun get_premium_multiplier_wei_per_eth(token: address): u64 acquires FeeQuoterState { + let state = borrow_state(); + get_premium_multiplier_wei_per_eth_internal(state, token) + } + + inline fun get_premium_multiplier_wei_per_eth_internal( + state: &FeeQuoterState, token: address + ): u64 { + assert!( + state.premium_multiplier_wei_per_eth.contains(token), + error::invalid_argument(E_UNKNOWN_TOKEN) + ); + *state.premium_multiplier_wei_per_eth.borrow(token) + } + + inline fun resolve_generic_gas_limit( + dest_chain_config: &DestChainConfig, extra_args: vector + ): u256 { + let (gas_limit, _allow_out_of_order_execution) = + decode_generic_extra_args(dest_chain_config, extra_args); + assert!( + gas_limit <= (dest_chain_config.max_per_msg_gas_limit as u256), + error::invalid_argument(E_MESSAGE_GAS_LIMIT_TOO_HIGH) + ); + gas_limit + } + + inline fun resolve_svm_gas_limit( + dest_chain_config: &DestChainConfig, + state: &FeeQuoterState, + dest_chain_selector: u64, + extra_args: vector, + receiver: vector, + data_len: u64, + tokens_len: u64, + local_token_addresses: vector
+ ): u256 { + let extra_args_len = extra_args.length(); + assert!(extra_args_len > 0, error::invalid_argument(E_INVALID_EXTRA_ARGS_DATA)); + + let ( + compute_units, + account_is_writable_bitmap, + _allow_out_of_order_execution, + token_receiver, + accounts + ) = decode_svm_extra_args(extra_args); + + let gas_limit = compute_units; + + assert!( + gas_limit <= dest_chain_config.max_per_msg_gas_limit, + error::invalid_argument(E_MESSAGE_COMPUTE_UNIT_LIMIT_TOO_HIGH) + ); + + let accounts_length = accounts.length(); + // The max payload size for SVM is heavily dependent on the accounts passed into extra args and the number of + // tokens. Below, token and account overhead will count towards maxDataBytes. + let svm_expanded_data_length = data_len; + + // The receiver length has not yet been validated before this point. + assert!( + receiver.length() == 32, + error::invalid_argument(E_INVALID_SVM_RECEIVER_LENGTH) + ); + let receiver_uint = eth_abi::decode_u256_value(receiver); + if (receiver_uint == 0) { + // When message receiver is zero, CCIP receiver is not invoked on SVM. + // There should not be additional accounts specified for the receiver. + assert!( + accounts_length == 0, + error::invalid_argument(E_TOO_MANY_SVM_EXTRA_ARGS_ACCOUNTS) + ); + } else { + // The messaging accounts needed for CCIP receiver on SVM are: + // message receiver, offramp PDA signer, + // plus remaining accounts specified in SVM extraArgs. Each account is 32 bytes. + svm_expanded_data_length +=((accounts_length + + SVM_MESSAGING_ACCOUNTS_OVERHEAD) * SVM_ACCOUNT_BYTE_SIZE); + }; + + for (i in 0..accounts_length) { + assert!( + accounts[i].length() == 32, + error::invalid_argument(E_INVALID_SVM_ACCOUNT_LENGTH) + ); + }; + + if (tokens_len > 0) { + assert!( + token_receiver.length() == 32 + && eth_abi::decode_u256_value(token_receiver) != 0, + error::invalid_argument(E_INVALID_TOKEN_RECEIVER) + ); + }; + assert!( + accounts_length <= SVM_EXTRA_ARGS_MAX_ACCOUNTS, + error::invalid_argument(E_TOO_MANY_SVM_EXTRA_ARGS_ACCOUNTS) + ); + assert!( + (account_is_writable_bitmap >> (accounts_length as u8)) == 0, + error::invalid_argument(E_INVALID_SVM_EXTRA_ARGS_WRITABLE_BITMAP) + ); + + svm_expanded_data_length += tokens_len * SVM_TOKEN_TRANSFER_DATA_OVERHEAD; + + // The token destBytesOverhead can be very different per token so we have to take it into account as well. + for (i in 0..tokens_len) { + let local_token_address = local_token_addresses[i]; + let destBytesOverhead = + get_token_transfer_fee_config_internal( + state, dest_chain_selector, local_token_address + ).dest_bytes_overhead; + + // Pools get CCIP_LOCK_OR_BURN_V1_RET_BYTES by default, but if an override is set we use that instead. + if (destBytesOverhead > 0) { + svm_expanded_data_length +=(destBytesOverhead as u64); + } else { + svm_expanded_data_length +=(CCIP_LOCK_OR_BURN_V1_RET_BYTES as u64); + } + }; + + assert!( + svm_expanded_data_length <= (dest_chain_config.max_data_bytes as u64), + error::invalid_argument(E_MESSAGE_TOO_LARGE) + ); + + gas_limit as u256 + } + + inline fun decode_generic_extra_args( + dest_chain_config: &DestChainConfig, extra_args: vector + ): (u256, bool) { + let extra_args_len = extra_args.length(); + if (extra_args_len == 0) { + // If extra args are empty, generate default values. Out-of-order is always true. + ( + dest_chain_config.default_tx_gas_limit as u256, + ALLOW_OUT_OF_ORDER_EXECUTION + ) + } else { + assert!( + extra_args_len >= 4, + error::invalid_argument(E_INVALID_EXTRA_ARGS_DATA) + ); + + let args_tag = extra_args.slice(0, 4); + assert!( + args_tag == client::generic_extra_args_v2_tag(), + error::invalid_argument(E_INVALID_EXTRA_ARGS_TAG) + ); + + let args_data = extra_args.slice(4, extra_args_len); + decode_generic_extra_args_v2(args_data) + } + } + + inline fun decode_generic_extra_args_v2(extra_args: vector): (u256, bool) { + let stream = bcs_stream::new(extra_args); + let gas_limit = bcs_stream::deserialize_u256(&mut stream); + let allow_out_of_order_execution = bcs_stream::deserialize_bool(&mut stream); + bcs_stream::assert_is_consumed(&stream); + (gas_limit, allow_out_of_order_execution) + } + + inline fun decode_svm_extra_args( + extra_args: vector + ): ( + u32, u64, bool, vector, vector> + ) { + let extra_args_len = extra_args.length(); + assert!(extra_args_len >= 4, error::invalid_argument(E_INVALID_EXTRA_ARGS_DATA)); + + let args_tag = extra_args.slice(0, 4); + assert!( + args_tag == client::svm_extra_args_v1_tag(), + error::invalid_argument(E_INVALID_EXTRA_ARGS_TAG) + ); + let args_data = extra_args.slice(4, extra_args_len); + decode_svm_extra_args_v1(args_data) + } + + inline fun decode_svm_extra_args_v1( + extra_args: vector + ): ( + u32, u64, bool, vector, vector> + ) { + let stream = bcs_stream::new(extra_args); + let compute_units = bcs_stream::deserialize_u32(&mut stream); + let account_is_writable_bitmap = bcs_stream::deserialize_u64(&mut stream); + let allow_out_of_order_execution = bcs_stream::deserialize_bool(&mut stream); + let token_receiver = bcs_stream::deserialize_vector_u8(&mut stream); + let accounts = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(&stream); + ( + compute_units, + account_is_writable_bitmap, + allow_out_of_order_execution, + token_receiver, + accounts + ) + } + + inline fun get_data_availability_cost( + dest_chain_config: &DestChainConfig, + data_availability_gas_price: u256, + data_len: u64, + tokens_len: u64, + total_transfer_bytes_overhead: u32 + ): u256 { + let data_availability_length_bytes = + MESSAGE_FIXED_BYTES + data_len + (tokens_len + * MESSAGE_FIXED_BYTES_PER_TOKEN) + + (total_transfer_bytes_overhead as u64); + + let data_availability_gas = + ((data_availability_length_bytes as u256) + * (dest_chain_config.dest_gas_per_data_availability_byte as u256)) + ( + dest_chain_config.dest_data_availability_overhead_gas as u256 + ); + + data_availability_gas * data_availability_gas_price + * (dest_chain_config.dest_data_availability_multiplier_bps as u256) + * VAL_1E14 + } + + inline fun get_token_transfer_cost( + state: &FeeQuoterState, + dest_chain_config: &DestChainConfig, + dest_chain_selector: u64, + fee_token: address, + fee_token_price: TimestampedPrice, + local_token_addresses: vector
, + local_token_amounts: vector + ): (u256, u32, u32) { + let token_transfer_fee_wei: u256 = 0; + let token_transfer_gas: u32 = 0; + let token_transfer_bytes_overhead: u32 = 0; + + local_token_addresses.zip_ref( + &local_token_amounts, + |local_token_address, local_token_amount| { + let local_token_address: address = *local_token_address; + let local_token_amount: u64 = *local_token_amount; + + let transfer_fee_config = + get_token_transfer_fee_config_internal( + state, dest_chain_selector, local_token_address + ); + + if (!transfer_fee_config.is_enabled) { + token_transfer_fee_wei +=( + (dest_chain_config.default_token_fee_usd_cents as u256) + * VAL_1E16 + ); + token_transfer_gas += dest_chain_config.default_token_dest_gas_overhead; + token_transfer_bytes_overhead += CCIP_LOCK_OR_BURN_V1_RET_BYTES; + } else { + let bps_fee_usd_wei = 0; + if (transfer_fee_config.deci_bps > 0) { + let token_price = + if (local_token_address == fee_token) { + fee_token_price + } else { + get_token_price_internal(state, local_token_address) + }; + let token_usd_value = + calc_usd_value_from_token_amount( + local_token_amount, token_price.value + ); + bps_fee_usd_wei = + (token_usd_value * (transfer_fee_config.deci_bps as u256)) + / VAL_1E5; + }; + + token_transfer_gas += transfer_fee_config.dest_gas_overhead; + token_transfer_bytes_overhead += transfer_fee_config.dest_bytes_overhead; + + let min_fee_usd_wei = + (transfer_fee_config.min_fee_usd_cents as u256) * VAL_1E16; + let max_fee_usd_wei = + (transfer_fee_config.max_fee_usd_cents as u256) * VAL_1E16; + let selected_fee_usd_wei = + if (bps_fee_usd_wei < min_fee_usd_wei) { + min_fee_usd_wei + } else if (bps_fee_usd_wei > max_fee_usd_wei) { + max_fee_usd_wei + } else { + bps_fee_usd_wei + }; + token_transfer_fee_wei += selected_fee_usd_wei; + } + } + ); + + (token_transfer_fee_wei, token_transfer_gas, token_transfer_bytes_overhead) + } + + inline fun calc_usd_value_from_token_amount( + token_amount: u64, token_price: u256 + ): u256 { + (token_amount as u256) * token_price / VAL_1E18 + } + + #[view] + public fun get_token_receiver( + dest_chain_selector: u64, extra_args: vector, message_receiver: vector + ): vector acquires FeeQuoterState { + let chain_family_selector = + get_dest_chain_config_internal(borrow_state(), dest_chain_selector).chain_family_selector; + if (chain_family_selector == CHAIN_FAMILY_SELECTOR_EVM + || chain_family_selector == CHAIN_FAMILY_SELECTOR_APTOS + || chain_family_selector == CHAIN_FAMILY_SELECTOR_SUI) { + message_receiver + } else if (chain_family_selector == CHAIN_FAMILY_SELECTOR_SVM) { + let ( + _compute_units, + _account_is_writable_bitmap, + _allow_out_of_order_execution, + token_receiver, + _accounts + ) = decode_svm_extra_args(extra_args); + token_receiver + } else { + abort error::invalid_argument(E_UNKNOWN_CHAIN_FAMILY_SELECTOR) + } + } + + #[view] + /// @returns (msg_fee_juels, is_out_of_order_execution, converted_extra_args, dest_exec_data_per_token) + public fun process_message_args( + dest_chain_selector: u64, + fee_token: address, + fee_token_amount: u64, + extra_args: vector, + local_token_addresses: vector
, + dest_token_addresses: vector>, + dest_pool_datas: vector> + ): ( + u256, bool, vector, vector> + ) acquires FeeQuoterState { + let state = borrow_state(); + // This is the fee in Aptos denomination. We convert it to juels (1e18 based) below. + let msg_fee_link_local_denomination = + if (fee_token == state.link_token) { + fee_token_amount + } else { + convert_token_amount_internal( + state, + fee_token, + fee_token_amount, + state.link_token + ) + }; + + // We convert the local denomination to juels here. This means that the offchain monitoring will always + // get a consistent juels amount regardless of the token denomination on the chain. + let msg_fee_juels = + (msg_fee_link_local_denomination as u256) + * LOCAL_8_TO_18_DECIMALS_LINK_MULTIPLIER; + + // max_fee_juels_per_msg is in juels denomination for consistency across chains. + assert!( + msg_fee_juels <= state.max_fee_juels_per_msg, + error::invalid_argument(E_MESSAGE_FEE_TOO_HIGH) + ); + + let dest_chain_config = get_dest_chain_config_internal( + state, dest_chain_selector + ); + + let (converted_extra_args, is_out_of_order_execution) = + process_chain_family_selector( + dest_chain_config, !dest_token_addresses.is_empty(), extra_args + ); + + let dest_exec_data_per_token = + process_pool_return_data( + state, + dest_chain_config, + dest_chain_selector, + local_token_addresses, + dest_token_addresses, + dest_pool_datas + ); + + ( + msg_fee_juels, + is_out_of_order_execution, + converted_extra_args, + dest_exec_data_per_token + ) + } + + inline fun process_chain_family_selector( + dest_chain_config: &DestChainConfig, + is_message_with_token_transfers: bool, + extra_args: vector + ): (vector, bool) { + let chain_family_selector = dest_chain_config.chain_family_selector; + if (chain_family_selector == CHAIN_FAMILY_SELECTOR_EVM + || chain_family_selector == CHAIN_FAMILY_SELECTOR_APTOS + || chain_family_selector == CHAIN_FAMILY_SELECTOR_SUI) { + let (gas_limit, _allow_out_of_order_execution) = + decode_generic_extra_args(dest_chain_config, extra_args); + let extra_args_v2 = + client::encode_generic_extra_args_v2( + gas_limit, ALLOW_OUT_OF_ORDER_EXECUTION + ); + (extra_args_v2, ALLOW_OUT_OF_ORDER_EXECUTION) + } else if (chain_family_selector == CHAIN_FAMILY_SELECTOR_SVM) { + let ( + compute_units, + _account_is_writable_bitmap, + _allow_out_of_order_execution, + token_receiver, + _accounts + ) = decode_svm_extra_args(extra_args); + if (is_message_with_token_transfers) { + assert!( + token_receiver.length() == 32, + error::invalid_argument(E_INVALID_TOKEN_RECEIVER) + ); + let token_receiver_uint = eth_abi::decode_u256_value(token_receiver); + assert!( + token_receiver_uint > 0, + error::invalid_argument(E_INVALID_TOKEN_RECEIVER) + ); + }; + + assert!( + compute_units <= dest_chain_config.max_per_msg_gas_limit, + error::invalid_argument(E_MESSAGE_COMPUTE_UNIT_LIMIT_TOO_HIGH) + ); + + (extra_args, ALLOW_OUT_OF_ORDER_EXECUTION) + } else { + abort error::invalid_argument(E_UNKNOWN_CHAIN_FAMILY_SELECTOR) + } + } + + inline fun process_pool_return_data( + state: &FeeQuoterState, + dest_chain_config: &DestChainConfig, + dest_chain_selector: u64, + local_token_addresses: vector
, + dest_token_addresses: vector>, + dest_pool_datas: vector> + ): vector> { + let chain_family_selector = dest_chain_config.chain_family_selector; + + let tokens_len = dest_token_addresses.length(); + assert!( + tokens_len == dest_pool_datas.length(), + error::invalid_argument(E_TOKEN_AMOUNT_MISMATCH) + ); + + let dest_exec_data_per_token = vector[]; + for (i in 0..tokens_len) { + let local_token_address = local_token_addresses[i]; + let dest_token_address = dest_token_addresses[i]; + let dest_pool_data_len = dest_pool_datas[i].length(); + + let token_transfer_fee_config = + get_token_transfer_fee_config_internal( + state, dest_chain_selector, local_token_address + ); + if (dest_pool_data_len > (CCIP_LOCK_OR_BURN_V1_RET_BYTES as u64)) { + assert!( + dest_pool_data_len + <= (token_transfer_fee_config.dest_bytes_overhead as u64), + error::invalid_argument(E_SOURCE_TOKEN_DATA_TOO_LARGE) + ); + }; + + // We pass in 1 as gas_limit as this only matters for SVM address validation. This ensures the address + // may not be 0x0. + validate_dest_family_address(chain_family_selector, dest_token_address, 1); + + let dest_gas_amount = + if (token_transfer_fee_config.is_enabled) { + token_transfer_fee_config.dest_gas_overhead + } else { + dest_chain_config.default_token_dest_gas_overhead + }; + + let dest_exec_data = bcs::to_bytes(&dest_gas_amount); + dest_exec_data_per_token.push_back(dest_exec_data); + }; + + dest_exec_data_per_token + } + + #[view] + public fun get_dest_chain_config( + dest_chain_selector: u64 + ): DestChainConfig acquires FeeQuoterState { + *get_dest_chain_config_internal(borrow_state(), dest_chain_selector) + } + + inline fun get_dest_chain_config_internal( + state: &FeeQuoterState, dest_chain_selector: u64 + ): &DestChainConfig { + assert!( + state.dest_chain_configs.contains(dest_chain_selector), + error::invalid_argument(E_UNKNOWN_DEST_CHAIN_SELECTOR) + ); + state.dest_chain_configs.borrow(dest_chain_selector) + } + + public entry fun apply_dest_chain_config_updates( + caller: &signer, + dest_chain_selector: u64, + is_enabled: bool, + max_number_of_tokens_per_msg: u16, + max_data_bytes: u32, + max_per_msg_gas_limit: u32, + dest_gas_overhead: u32, + dest_gas_per_payload_byte_base: u8, + dest_gas_per_payload_byte_high: u8, + dest_gas_per_payload_byte_threshold: u16, + dest_data_availability_overhead_gas: u32, + dest_gas_per_data_availability_byte: u16, + dest_data_availability_multiplier_bps: u16, + chain_family_selector: vector, + enforce_out_of_order: bool, + default_token_fee_usd_cents: u16, + default_token_dest_gas_overhead: u32, + default_tx_gas_limit: u32, + gas_multiplier_wei_per_eth: u64, + gas_price_staleness_threshold: u32, + network_fee_usd_cents: u32 + ) acquires FeeQuoterState { + auth::assert_only_owner(signer::address_of(caller)); + + let state = borrow_state_mut(); + + assert!( + dest_chain_selector != 0, + error::invalid_argument(E_INVALID_DEST_CHAIN_SELECTOR) + ); + assert!( + default_tx_gas_limit != 0 && default_tx_gas_limit <= max_per_msg_gas_limit, + error::invalid_argument(E_INVALID_GAS_LIMIT) + ); + + assert!( + chain_family_selector == CHAIN_FAMILY_SELECTOR_EVM + || chain_family_selector == CHAIN_FAMILY_SELECTOR_SVM + || chain_family_selector == CHAIN_FAMILY_SELECTOR_APTOS + || chain_family_selector == CHAIN_FAMILY_SELECTOR_SUI, + error::invalid_argument(E_INVALID_CHAIN_FAMILY_SELECTOR) + ); + + let dest_chain_config = DestChainConfig { + is_enabled, + max_number_of_tokens_per_msg, + max_data_bytes, + max_per_msg_gas_limit, + dest_gas_overhead, + dest_gas_per_payload_byte_base, + dest_gas_per_payload_byte_high, + dest_gas_per_payload_byte_threshold, + dest_data_availability_overhead_gas, + dest_gas_per_data_availability_byte, + dest_data_availability_multiplier_bps, + chain_family_selector, + enforce_out_of_order, + default_token_fee_usd_cents, + default_token_dest_gas_overhead, + default_tx_gas_limit, + gas_multiplier_wei_per_eth, + gas_price_staleness_threshold, + network_fee_usd_cents + }; + + if (state.dest_chain_configs.contains(dest_chain_selector)) { + let dest_chain_config_ref = + state.dest_chain_configs.borrow_mut(dest_chain_selector); + *dest_chain_config_ref = dest_chain_config; + event::emit_event( + &mut state.dest_chain_config_updated_events, + DestChainConfigUpdated { dest_chain_selector, dest_chain_config } + ); + } else { + state.dest_chain_configs.add(dest_chain_selector, dest_chain_config); + event::emit_event( + &mut state.dest_chain_added_events, + DestChainAdded { dest_chain_selector, dest_chain_config } + ); + } + } + + #[view] + public fun get_static_config(): StaticConfig acquires FeeQuoterState { + let state = borrow_state(); + StaticConfig { + max_fee_juels_per_msg: state.max_fee_juels_per_msg, + link_token: state.link_token, + token_price_staleness_threshold: state.token_price_staleness_threshold + } + } + + inline fun borrow_state(): &FeeQuoterState { + borrow_global(state_object::object_address()) + } + + inline fun borrow_state_mut(): &mut FeeQuoterState { + borrow_global_mut(state_object::object_address()) + } + + inline fun get_validated_token_price( + state: &FeeQuoterState, token: address + ): TimestampedPrice { + let token_price = get_token_price_internal(state, token); + assert!( + token_price.value > 0 && token_price.timestamp > 0, + error::invalid_state(E_TOKEN_NOT_SUPPORTED) + ); + token_price + } + + // Token prices can be stale. On EVM we have additional fallbacks to a price feed, if configured. Since these + // fallbacks don't exist on Aptos, we simply return the price as is. + inline fun get_token_price_internal( + state: &FeeQuoterState, token: address + ): TimestampedPrice { + assert!( + state.usd_per_token.contains(token), + error::invalid_argument(E_UNKNOWN_TOKEN) + ); + *state.usd_per_token.borrow(token) + } + + inline fun get_dest_chain_gas_price_internal( + state: &FeeQuoterState, dest_chain_selector: u64 + ): TimestampedPrice { + assert!( + state.usd_per_unit_gas_by_dest_chain.contains(dest_chain_selector), + error::invalid_argument(E_UNKNOWN_DEST_CHAIN_SELECTOR) + ); + *state.usd_per_unit_gas_by_dest_chain.borrow(dest_chain_selector) + } + + inline fun get_validated_gas_price_internal( + state: &FeeQuoterState, dest_chain_config: &DestChainConfig, dest_chain_selector: u64 + ): u256 { + let gas_price = get_dest_chain_gas_price_internal(state, dest_chain_selector); + if (dest_chain_config.gas_price_staleness_threshold > 0) { + let time_passed_seconds = timestamp::now_seconds() - gas_price.timestamp; + assert!( + time_passed_seconds + <= (dest_chain_config.gas_price_staleness_threshold as u64), + error::invalid_state(E_STALE_GAS_PRICE) + ); + }; + gas_price.value + } + + inline fun convert_token_amount_internal( + state: &FeeQuoterState, + from_token: address, + from_token_amount: u64, + to_token: address + ): u64 { + let from_token_price = get_validated_token_price(state, from_token); + let to_token_price = get_validated_token_price(state, to_token); + let to_token_amount = + ((from_token_amount as u256) * from_token_price.value) / to_token_price.value; + assert!( + to_token_amount <= MAX_U64, + error::invalid_argument(E_TO_TOKEN_AMOUNT_TOO_LARGE) + ); + to_token_amount as u64 + } + + inline fun validate_message( + dest_chain_config: &DestChainConfig, data_len: u64, tokens_len: u64 + ) { + assert!( + data_len <= (dest_chain_config.max_data_bytes as u64), + error::invalid_argument(E_MESSAGE_TOO_LARGE) + ); + assert!( + tokens_len <= (dest_chain_config.max_number_of_tokens_per_msg as u64), + error::invalid_argument(E_UNSUPPORTED_NUMBER_OF_TOKENS) + ); + } + + inline fun validate_dest_family_address( + chain_family_selector: vector, encoded_address: vector, gas_limit: u256 + ) { + if (chain_family_selector == CHAIN_FAMILY_SELECTOR_EVM) { + validate_evm_address(encoded_address); + } else if (chain_family_selector == CHAIN_FAMILY_SELECTOR_SVM) { + // SVM addresses don't have a precompile space at the first X addresses, instead we validate that if the gasLimit + // is non-zero, the address must not be 0x0. + let min_address = 0; + if (gas_limit > 0) { + min_address = 1; + }; + validate_32byte_address(encoded_address, min_address); + } else if (chain_family_selector == CHAIN_FAMILY_SELECTOR_APTOS + || chain_family_selector == CHAIN_FAMILY_SELECTOR_SUI) { + validate_32byte_address(encoded_address, MOVE_PRECOMPILE_SPACE); + }; + } + + inline fun validate_evm_address(encoded_address: vector) { + assert!( + encoded_address.length() == 32, + error::invalid_argument(E_INVALID_EVM_ADDRESS) + ); + + let encoded_address_uint = eth_abi::decode_u256_value(encoded_address); + + assert!( + encoded_address_uint >= EVM_PRECOMPILE_SPACE, + error::invalid_argument(E_INVALID_EVM_ADDRESS) + ); + assert!( + encoded_address_uint <= MAX_U160, + error::invalid_argument(E_INVALID_EVM_ADDRESS) + ); + } + + inline fun validate_32byte_address( + encoded_address: vector, min_value: u256 + ) { + assert!( + encoded_address.length() == 32, + error::invalid_argument(E_INVALID_32BYTES_ADDRESS) + ); + + let encoded_address_uint = eth_abi::decode_u256_value(encoded_address); + assert!( + encoded_address_uint >= min_value, + error::invalid_argument(E_INVALID_32BYTES_ADDRESS) + ); + } + + // ================================================================ + // | MCMS Entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: object::Object + ): option::Option acquires FeeQuoterState { + let (caller, function, data) = + mcms_registry::get_callback_params(@ccip, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"initialize") { + let max_fee_juels_per_msg = bcs_stream::deserialize_u256(&mut stream); + let link_token = bcs_stream::deserialize_address(&mut stream); + let token_price_staleness_threshold = bcs_stream::deserialize_u64( + &mut stream + ); + let fee_tokens = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + initialize( + &caller, + max_fee_juels_per_msg, + link_token, + token_price_staleness_threshold, + fee_tokens + ) + } else if (function_bytes == b"apply_fee_token_updates") { + let fee_tokens_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let fee_tokens_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_fee_token_updates(&caller, fee_tokens_to_remove, fee_tokens_to_add) + } else if (function_bytes == b"apply_token_transfer_fee_config_updates") { + let dest_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let add_tokens = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let add_min_fee_usd_cents = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u32(stream) + ); + let add_max_fee_usd_cents = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u32(stream) + ); + let add_deci_bps = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u16(stream) + ); + let add_dest_gas_overhead = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u32(stream) + ); + let add_dest_bytes_overhead = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u32(stream) + ); + let add_is_enabled = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let remove_tokens = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_token_transfer_fee_config_updates( + &caller, + dest_chain_selector, + add_tokens, + add_min_fee_usd_cents, + add_max_fee_usd_cents, + add_deci_bps, + add_dest_gas_overhead, + add_dest_bytes_overhead, + add_is_enabled, + remove_tokens + ) + } else if (function_bytes == b"update_prices") { + let source_tokens = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let source_usd_per_token = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u256(stream) + ); + let gas_dest_chain_selectors = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let gas_usd_per_unit_gas = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u256(stream) + ); + bcs_stream::assert_is_consumed(&stream); + update_prices( + &caller, + source_tokens, + source_usd_per_token, + gas_dest_chain_selectors, + gas_usd_per_unit_gas + ) + } else if (function_bytes == b"apply_premium_multiplier_wei_per_eth_updates") { + let tokens = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let premium_multiplier_wei_per_eth = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_premium_multiplier_wei_per_eth_updates( + &caller, tokens, premium_multiplier_wei_per_eth + ) + } else if (function_bytes == b"apply_dest_chain_config_updates") { + let dest_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let is_enabled = bcs_stream::deserialize_bool(&mut stream); + let max_number_of_tokens_per_msg = bcs_stream::deserialize_u16(&mut stream); + let max_data_bytes = bcs_stream::deserialize_u32(&mut stream); + let max_per_msg_gas_limit = bcs_stream::deserialize_u32(&mut stream); + let dest_gas_overhead = bcs_stream::deserialize_u32(&mut stream); + let dest_gas_per_payload_byte_base = bcs_stream::deserialize_u8(&mut stream); + let dest_gas_per_payload_byte_high = bcs_stream::deserialize_u8(&mut stream); + let dest_gas_per_payload_byte_threshold = + bcs_stream::deserialize_u16(&mut stream); + let dest_data_availability_overhead_gas = + bcs_stream::deserialize_u32(&mut stream); + let dest_gas_per_data_availability_byte = + bcs_stream::deserialize_u16(&mut stream); + let dest_data_availability_multiplier_bps = + bcs_stream::deserialize_u16(&mut stream); + let chain_family_selector = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u8(stream) + ); + let enforce_out_of_order = bcs_stream::deserialize_bool(&mut stream); + let default_token_fee_usd_cents = bcs_stream::deserialize_u16(&mut stream); + let default_token_dest_gas_overhead = bcs_stream::deserialize_u32( + &mut stream + ); + let default_tx_gas_limit = bcs_stream::deserialize_u32(&mut stream); + let gas_multiplier_wei_per_eth = bcs_stream::deserialize_u64(&mut stream); + let gas_price_staleness_threshold = bcs_stream::deserialize_u32(&mut stream); + let network_fee_usd_cents = bcs_stream::deserialize_u32(&mut stream); + bcs_stream::assert_is_consumed(&stream); + apply_dest_chain_config_updates( + &caller, + dest_chain_selector, + is_enabled, + max_number_of_tokens_per_msg, + max_data_bytes, + max_per_msg_gas_limit, + dest_gas_overhead, + dest_gas_per_payload_byte_base, + dest_gas_per_payload_byte_high, + dest_gas_per_payload_byte_threshold, + dest_data_availability_overhead_gas, + dest_gas_per_data_availability_byte, + dest_data_availability_multiplier_bps, + chain_family_selector, + enforce_out_of_order, + default_token_fee_usd_cents, + default_token_dest_gas_overhead, + default_tx_gas_limit, + gas_multiplier_wei_per_eth, + gas_price_staleness_threshold, + network_fee_usd_cents + ) + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint(publisher: &signer) { + mcms_registry::register_entrypoint( + publisher, string::utf8(b"fee_quoter"), McmsCallback {} + ); + } + + public fun dest_chain_config_values( + config: DestChainConfig + ): ( + bool, + u16, + u32, + u32, + u32, + u8, + u8, + u16, + u32, + u16, + u16, + vector, + bool, + u16, + u32, + u32, + u64, + u32, + u32 + ) { + ( + config.is_enabled, + config.max_number_of_tokens_per_msg, + config.max_data_bytes, + config.max_per_msg_gas_limit, + config.dest_gas_overhead, + config.dest_gas_per_payload_byte_base, + config.dest_gas_per_payload_byte_high, + config.dest_gas_per_payload_byte_threshold, + config.dest_data_availability_overhead_gas, + config.dest_gas_per_data_availability_byte, + config.dest_data_availability_multiplier_bps, + config.chain_family_selector, + config.enforce_out_of_order, + config.default_token_fee_usd_cents, + config.default_token_dest_gas_overhead, + config.default_tx_gas_limit, + config.gas_multiplier_wei_per_eth, + config.gas_price_staleness_threshold, + config.network_fee_usd_cents + ) + } + + public fun token_transfer_fee_config_values( + config: TokenTransferFeeConfig + ): (u32, u32, u16, u32, u32, bool) { + ( + config.min_fee_usd_cents, + config.max_fee_usd_cents, + config.deci_bps, + config.dest_gas_overhead, + config.dest_bytes_overhead, + config.is_enabled + ) + } + + // ========================== TEST ONLY ========================== + #[test_only] + public fun test_register_mcms_entrypoint(publisher: &signer) { + mcms_registry::register_entrypoint( + publisher, string::utf8(b"fee_quoter"), McmsCallback {} + ); + } + + #[test_only] + public fun test_decode_svm_extra_args( + extra_args: vector + ): ( + u32, u64, bool, vector, vector> + ) { + decode_svm_extra_args(extra_args) + } + + #[test_only] + public fun test_decode_generic_extra_args( + dest_chain_config: &DestChainConfig, extra_args: vector + ): (u256, bool) { + decode_generic_extra_args(dest_chain_config, extra_args) + } + + #[test_only] + public fun test_decode_generic_extra_args_v2(extra_args: vector): (u256, bool) { + decode_generic_extra_args_v2(extra_args) + } + + #[test_only] + public fun test_decode_svm_extra_args_v1( + extra_args: vector + ): ( + u32, u64, bool, vector, vector> + ) { + decode_svm_extra_args_v1(extra_args) + } + + #[test_only] + public fun test_create_dest_chain_config( + is_enabled: bool, + max_number_of_tokens_per_msg: u16, + max_data_bytes: u32, + max_per_msg_gas_limit: u32, + dest_gas_overhead: u32, + dest_gas_per_payload_byte_base: u8, + dest_gas_per_payload_byte_high: u8, + dest_gas_per_payload_byte_threshold: u16, + dest_data_availability_overhead_gas: u32, + dest_gas_per_data_availability_byte: u16, + dest_data_availability_multiplier_bps: u16, + chain_family_selector: vector, + enforce_out_of_order: bool, + default_token_fee_usd_cents: u16, + default_token_dest_gas_overhead: u32, + default_tx_gas_limit: u32, + gas_multiplier_wei_per_eth: u64, + gas_price_staleness_threshold: u32, + network_fee_usd_cents: u32 + ): DestChainConfig { + DestChainConfig { + is_enabled, + max_number_of_tokens_per_msg, + max_data_bytes, + max_per_msg_gas_limit, + dest_gas_overhead, + dest_gas_per_payload_byte_base, + dest_gas_per_payload_byte_high, + dest_gas_per_payload_byte_threshold, + dest_data_availability_overhead_gas, + dest_gas_per_data_availability_byte, + dest_data_availability_multiplier_bps, + chain_family_selector, + enforce_out_of_order, + default_token_fee_usd_cents, + default_token_dest_gas_overhead, + default_tx_gas_limit, + gas_multiplier_wei_per_eth, + gas_price_staleness_threshold, + network_fee_usd_cents + } + } +} +` + +/** sources/merkle_proof.move */ +export const CCIP_MERKLE_PROOF_MOVE = `module ccip::merkle_proof { + use std::aptos_hash; + use std::error; + + const LEAF_DOMAIN_SEPARATOR: vector = x"0000000000000000000000000000000000000000000000000000000000000000"; + const INTERNAL_DOMAIN_SEPARATOR: vector = x"0000000000000000000000000000000000000000000000000000000000000001"; + + const E_VECTOR_LENGTH_MISMATCH: u64 = 1; + + public fun leaf_domain_separator(): vector { + LEAF_DOMAIN_SEPARATOR + } + + public fun merkle_root(leaf: vector, proofs: vector>): vector { + proofs.fold(leaf, |acc, proof| hash_pair(acc, proof)) + } + + public fun vector_u8_gt(a: &vector, b: &vector): bool { + let len = a.length(); + assert!(len == b.length(), error::invalid_argument(E_VECTOR_LENGTH_MISMATCH)); + + // compare each byte until not equal + for (i in 0..len) { + let byte_a = a[i]; + let byte_b = b[i]; + if (byte_a > byte_b) { + return true + } else if (byte_a < byte_b) { + return false + }; + }; + + // vectors are equal, a == b + false + } + + /// Hashes two byte vectors using SHA3-256 after concatenating them with the internal domain separator + inline fun hash_internal_node(left: vector, right: vector): vector { + let data = INTERNAL_DOMAIN_SEPARATOR; + data.append(left); + data.append(right); + aptos_hash::keccak256(data) + } + + /// Hashes a pair of byte vectors, ordering them lexographically + inline fun hash_pair(a: vector, b: vector): vector { + if (!vector_u8_gt(&a, &b)) { + hash_internal_node(a, b) + } else { + hash_internal_node(b, a) + } + } +} +` + +/** sources/nonce_manager.move */ +export const CCIP_NONCE_MANAGER_MOVE = `module ccip::nonce_manager { + use std::signer; + use std::smart_table::{Self, SmartTable}; + use std::string::{Self, String}; + + use ccip::auth; + use ccip::state_object; + + struct NonceManagerState has key, store { + // dest chain selector -> sender -> nonce + outbound_nonces: SmartTable> + } + + #[view] + public fun type_and_version(): String { + string::utf8(b"NonceManager 1.6.0") + } + + fun init_module(_publisher: &signer) { + let state_object_signer = state_object::object_signer(); + + move_to( + &state_object_signer, + NonceManagerState { outbound_nonces: smart_table::new() } + ); + } + + #[view] + public fun get_outbound_nonce( + dest_chain_selector: u64, sender: address + ): u64 acquires NonceManagerState { + let state = borrow_state(); + + if (!state.outbound_nonces.contains(dest_chain_selector)) { + return 0; + }; + + let dest_chain_nonces = state.outbound_nonces.borrow(dest_chain_selector); + *dest_chain_nonces.borrow_with_default(sender, &0) + } + + public fun get_incremented_outbound_nonce( + caller: &signer, dest_chain_selector: u64, sender: address + ): u64 acquires NonceManagerState { + auth::assert_is_allowed_onramp(signer::address_of(caller)); + + let state = borrow_state_mut(); + + if (!state.outbound_nonces.contains(dest_chain_selector)) { + state.outbound_nonces.add(dest_chain_selector, smart_table::new()); + }; + + let dest_chain_nonces = state.outbound_nonces.borrow_mut(dest_chain_selector); + let nonce_ref = dest_chain_nonces.borrow_mut_with_default(sender, 0); + let incremented_nonce = *nonce_ref + 1; + *nonce_ref = incremented_nonce; + incremented_nonce + } + + inline fun borrow_state(): &NonceManagerState { + borrow_global(state_object::object_address()) + } + + inline fun borrow_state_mut(): &mut NonceManagerState { + borrow_global_mut(state_object::object_address()) + } + + // ========================== TEST ONLY ========================== + #[test_only] + public fun test_init_module(publisher: &signer) { + init_module(publisher); + } +} +` + +/** sources/ownable.move */ +export const CCIP_OWNABLE_MOVE = `/// This module implements an Ownable component similar to Ownable2Step.sol for managing +/// object ownership. +/// +/// Due to Aptos's security model requiring the original owner's signer for 0x1::object::transfer, +/// this implementation uses a 3-step ownership transfer flow: +/// +/// 1. Initial owner calls transfer_ownership with the new owner's address +/// 2. Pending owner calls accept_ownership to confirm the transfer +/// 3. Initial owner calls execute_ownership_transfer to complete the transfer +/// +/// The execute_ownership_transfer function requires a signer in order to perform the +/// object transfer, while other operations only require the caller address to maintain the +/// principle of least privilege. +/// +/// Note that direct ownership transfers via 0x1::object::transfer are still possible. +/// This module handles such cases gracefully by reading the current owner directly +/// from the object. +module ccip::ownable { + use std::account; + use std::error; + use std::event::{Self, EventHandle}; + use std::object::{Self, Object, ObjectCore}; + use std::option::{Self, Option}; + use std::signer; + + struct OwnableState has store { + target_object: Object, + pending_transfer: Option, + ownership_transfer_requested_events: EventHandle, + ownership_transfer_accepted_events: EventHandle, + ownership_transferred_events: EventHandle + } + + struct PendingTransfer has store, drop { + from: address, + to: address, + accepted: bool + } + + const E_MUST_BE_PROPOSED_OWNER: u64 = 1; + const E_CANNOT_TRANSFER_TO_SELF: u64 = 2; + const E_ONLY_CALLABLE_BY_OWNER: u64 = 3; + const E_PROPOSED_OWNER_MISMATCH: u64 = 4; + const E_OWNER_CHANGED: u64 = 5; + const E_NO_PENDING_TRANSFER: u64 = 6; + const E_TRANSFER_NOT_ACCEPTED: u64 = 7; + const E_TRANSFER_ALREADY_ACCEPTED: u64 = 8; + + #[event] + struct OwnershipTransferRequested has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferAccepted has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferred has store, drop { + from: address, + to: address + } + + public fun new(event_account: &signer, object_address: address): OwnableState { + let new_state = OwnableState { + target_object: object::address_to_object(object_address), + pending_transfer: option::none(), + ownership_transfer_requested_events: account::new_event_handle(event_account), + ownership_transfer_accepted_events: account::new_event_handle(event_account), + ownership_transferred_events: account::new_event_handle(event_account) + }; + + new_state + } + + public fun owner(state: &OwnableState): address { + owner_internal(state) + } + + public fun has_pending_transfer(state: &OwnableState): bool { + state.pending_transfer.is_some() + } + + public fun pending_transfer_from(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.from) + } + + public fun pending_transfer_to(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.to) + } + + public fun pending_transfer_accepted(state: &OwnableState): Option { + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.accepted) + } + + inline fun owner_internal(state: &OwnableState): address { + object::owner(state.target_object) + } + + public fun transfer_ownership( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + assert!(caller_address != to, error::invalid_argument(E_CANNOT_TRANSFER_TO_SELF)); + + state.pending_transfer = option::some( + PendingTransfer { from: caller_address, to, accepted: false } + ); + + event::emit_event( + &mut state.ownership_transfer_requested_events, + OwnershipTransferRequested { from: caller_address, to } + ); + } + + public fun accept_ownership(caller: &signer, state: &mut OwnableState) { + let caller_address = signer::address_of(caller); + assert!( + state.pending_transfer.is_some(), + error::permission_denied(E_NO_PENDING_TRANSFER) + ); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.borrow_mut(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == caller_address, + error::permission_denied(E_MUST_BE_PROPOSED_OWNER) + ); + assert!( + !pending_transfer.accepted, + error::invalid_state(E_TRANSFER_ALREADY_ACCEPTED) + ); + + pending_transfer.accepted = true; + + event::emit_event( + &mut state.ownership_transfer_accepted_events, + OwnershipTransferAccepted { from: pending_transfer.from, to: caller_address } + ); + } + + public fun execute_ownership_transfer( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.extract(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == to, + error::permission_denied(E_PROPOSED_OWNER_MISMATCH) + ); + assert!( + pending_transfer.accepted, + error::invalid_state(E_TRANSFER_NOT_ACCEPTED) + ); + + object::transfer(caller, state.target_object, pending_transfer.to); + state.pending_transfer = option::none(); + + event::emit_event( + &mut state.ownership_transferred_events, + OwnershipTransferred { from: caller_address, to } + ); + } + + public fun assert_only_owner(caller: address, state: &OwnableState) { + assert_only_owner_internal(caller, state) + } + + inline fun assert_only_owner_internal( + caller: address, state: &OwnableState + ) { + assert!( + caller == owner_internal(state), + error::permission_denied(E_ONLY_CALLABLE_BY_OWNER) + ); + } + + public fun destroy(state: OwnableState) { + let OwnableState { + target_object: _, + pending_transfer: _, + ownership_transfer_requested_events, + ownership_transfer_accepted_events, + ownership_transferred_events + } = state; + + event::destroy_handle(ownership_transfer_requested_events); + event::destroy_handle(ownership_transfer_accepted_events); + event::destroy_handle(ownership_transferred_events); + } + + #[test_only] + public fun get_ownership_transfer_requested_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transfer_requested_events + } + + #[test_only] + public fun get_ownership_transfer_accepted_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transfer_accepted_events + } + + #[test_only] + public fun get_ownership_transferred_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transferred_events + } +} +` + +/** sources/receiver_dispatcher.move */ +export const CCIP_RECEIVER_DISPATCHER_MOVE = `module ccip::receiver_dispatcher { + use std::dispatchable_fungible_asset; + use std::signer; + + use ccip::auth; + use ccip::client; + use ccip::receiver_registry; + + public fun dispatch_receive( + caller: &signer, receiver_address: address, message: client::Any2AptosMessage + ) { + auth::assert_is_allowed_offramp(signer::address_of(caller)); + + if (receiver_registry::is_registered_receiver_v2(receiver_address)) { + receiver_registry::invoke_ccip_receive_v2(receiver_address, message); + } else { + let dispatch_metadata = + receiver_registry::start_receive(receiver_address, message); + dispatchable_fungible_asset::derived_supply(dispatch_metadata); + receiver_registry::finish_receive(receiver_address); + } + } +} +` + +/** sources/receiver_registry.move */ +export const CCIP_RECEIVER_REGISTRY_MOVE = `module ccip::receiver_registry { + use std::account; + use std::bcs; + use std::dispatchable_fungible_asset; + use std::error; + use std::event::{Self, EventHandle}; + use std::function_info::{Self, FunctionInfo}; + use std::type_info::{Self, TypeInfo}; + use std::fungible_asset::{Self, Metadata}; + use std::object::{Self, ExtendRef, Object, TransferRef}; + use std::option::{Self, Option}; + use std::signer; + use std::string::{Self, String}; + + use ccip::client; + use ccip::state_object; + + friend ccip::receiver_dispatcher; + + struct ReceiverRegistryState has key, store { + extend_ref: ExtendRef, + transfer_ref: TransferRef, + receiver_registered_events: EventHandle + } + + struct ReceiverRegistryEventsV2 has key { + receiver_registered_v2_events: EventHandle + } + + struct CCIPReceiverRegistration has key { + ccip_receive_function: FunctionInfo, + proof_typeinfo: TypeInfo, + dispatch_metadata: Object, + dispatch_extend_ref: ExtendRef, + dispatch_transfer_ref: TransferRef, + executing_input: Option + } + + struct CCIPReceiverRegistrationV2 has key { + callback: |client::Any2AptosMessage| has copy + drop + store + } + + #[event] + struct ReceiverRegistered has store, drop { + receiver_address: address, + receiver_module_name: vector + } + + #[event] + struct ReceiverRegisteredV2 has drop, store { + receiver_address: address, + callback: |client::Any2AptosMessage| has copy + drop + store + } + + const E_ALREADY_REGISTERED: u64 = 1; + const E_UNKNOWN_RECEIVER: u64 = 2; + const E_UNKNOWN_PROOF_TYPE: u64 = 3; + const E_MISSING_INPUT: u64 = 4; + const E_NON_EMPTY_INPUT: u64 = 5; + const E_PROOF_TYPE_ACCOUNT_MISMATCH: u64 = 6; + const E_PROOF_TYPE_MODULE_MISMATCH: u64 = 7; + const E_UNAUTHORIZED: u64 = 8; + + #[view] + public fun type_and_version(): String { + string::utf8(b"ReceiverRegistry 1.6.0") + } + + fun init_module(_publisher: &signer) { + let state_object_signer = state_object::object_signer(); + let constructor_ref = + object::create_named_object(&state_object_signer, b"CCIPReceiverRegistry"); + let extend_ref = object::generate_extend_ref(&constructor_ref); + let transfer_ref = object::generate_transfer_ref(&constructor_ref); + + let state = ReceiverRegistryState { + extend_ref, + transfer_ref, + receiver_registered_events: account::new_event_handle(&state_object_signer) + }; + + move_to(&state_object_signer, state); + } + + public fun register_receiver( + receiver_account: &signer, receiver_module_name: vector, _proof: ProofType + ) acquires ReceiverRegistryState { + let receiver_address = signer::address_of(receiver_account); + assert!( + !exists(receiver_address) + && !exists(receiver_address), + error::invalid_argument(E_ALREADY_REGISTERED) + ); + + let ccip_receive_function = + function_info::new_function_info( + receiver_account, + string::utf8(receiver_module_name), + string::utf8(b"ccip_receive") + ); + let proof_typeinfo = type_info::type_of(); + assert!( + proof_typeinfo.account_address() == receiver_address, + E_PROOF_TYPE_ACCOUNT_MISMATCH + ); + assert!( + proof_typeinfo.module_name() == receiver_module_name, + E_PROOF_TYPE_MODULE_MISMATCH + ); + + let state = borrow_state_mut(); + let dispatch_signer = object::generate_signer_for_extending(&state.extend_ref); + + let dispatch_object_seed = bcs::to_bytes(&receiver_address); + dispatch_object_seed.append(b"CCIPReceiverRegistration"); + + let dispatch_constructor_ref = + object::create_named_object(&dispatch_signer, dispatch_object_seed); + let dispatch_extend_ref = object::generate_extend_ref(&dispatch_constructor_ref); + let dispatch_transfer_ref = + object::generate_transfer_ref(&dispatch_constructor_ref); + let dispatch_metadata = + fungible_asset::add_fungibility( + &dispatch_constructor_ref, + option::none(), + // max name length is 32 chars + string::utf8(b"CCIPReceiverRegistration"), + // max symbol length is 10 chars + string::utf8(b"CCIPRR"), + 0, + string::utf8(b""), + string::utf8(b"") + ); + + dispatchable_fungible_asset::register_derive_supply_dispatch_function( + &dispatch_constructor_ref, option::some(ccip_receive_function) + ); + + move_to( + receiver_account, + CCIPReceiverRegistration { + ccip_receive_function, + proof_typeinfo, + dispatch_metadata, + dispatch_extend_ref, + dispatch_transfer_ref, + executing_input: option::none() + } + ); + + event::emit_event( + &mut state.receiver_registered_events, + ReceiverRegistered { receiver_address, receiver_module_name } + ); + } + + /// Registers a V2 CCIP receiver using a function-value callback (closure). + /// + /// Upgrade path: existing legacy receivers can upgrade to V2 by calling this function, + /// which supersedes the legacy registration without requiring unregistration. + /// New receivers should use V2 directly. Once V2 is registered, legacy registration + /// via \`register_receiver()\` is rejected. + /// + /// SECURITY: The callback MUST wrap a private \`#[persistent]\` function. Exposing the + /// receive function as \`public fun\` allows any caller to construct an \`Any2AptosMessage\` + /// and invoke the receiver directly, + /// + /// Correct pattern: + /// \`\`\` + /// #[persistent] + /// fun ccip_receive_v2(message: client::Any2AptosMessage) { ... } + /// + /// fun init_module(publisher: &signer) { + /// receiver_registry::register_receiver_v2( + /// publisher, |message| ccip_receive_v2(message) + /// ); + /// } + /// \`\`\` + public fun register_receiver_v2( + receiver_account: &signer, callback: |client::Any2AptosMessage| has copy + drop + store + ) { + let receiver_address = signer::address_of(receiver_account); + assert!( + !exists(receiver_address), + error::invalid_argument(E_ALREADY_REGISTERED) + ); + + move_to(receiver_account, CCIPReceiverRegistrationV2 { callback }); + + event::emit_event( + &mut borrow_events_v2_mut().receiver_registered_v2_events, + ReceiverRegisteredV2 { receiver_address, callback } + ); + } + + #[view] + public fun is_registered_receiver(receiver_address: address): bool { + exists(receiver_address) + || exists(receiver_address) + } + + #[view] + public fun is_registered_receiver_v2(receiver_address: address): bool { + exists(receiver_address) + } + + public fun get_receiver_input( + receiver_address: address, _proof: ProofType + ): client::Any2AptosMessage acquires CCIPReceiverRegistration { + let registration = get_registration_mut(receiver_address); + + assert!( + registration.proof_typeinfo == type_info::type_of(), + error::permission_denied(E_UNKNOWN_PROOF_TYPE) + ); + + assert!( + registration.executing_input.is_some(), + error::invalid_state(E_MISSING_INPUT) + ); + + registration.executing_input.extract() + } + + public(friend) fun start_receive( + receiver_address: address, message: client::Any2AptosMessage + ): Object acquires CCIPReceiverRegistration { + let registration = get_registration_mut(receiver_address); + + assert!( + registration.executing_input.is_none(), + error::invalid_state(E_NON_EMPTY_INPUT) + ); + + registration.executing_input.fill(message); + + registration.dispatch_metadata + } + + public(friend) fun finish_receive(receiver_address: address) acquires CCIPReceiverRegistration { + let registration = get_registration_mut(receiver_address); + + assert!( + registration.executing_input.is_none(), + error::invalid_state(E_NON_EMPTY_INPUT) + ); + } + + public(friend) fun invoke_ccip_receive_v2( + receiver_address: address, message: client::Any2AptosMessage + ) acquires CCIPReceiverRegistrationV2 { + assert!( + exists(receiver_address), + error::invalid_argument(E_UNKNOWN_RECEIVER) + ); + + let registration = borrow_global(receiver_address); + (registration.callback) (message); + } + + inline fun borrow_state(): &ReceiverRegistryState { + borrow_global(state_object::object_address()) + } + + inline fun borrow_state_mut(): &mut ReceiverRegistryState { + borrow_global_mut(state_object::object_address()) + } + + inline fun get_registration_mut(receiver_address: address) + : &mut CCIPReceiverRegistration { + assert!( + exists(receiver_address), + error::invalid_argument(E_UNKNOWN_RECEIVER) + ); + borrow_global_mut(receiver_address) + } + + inline fun borrow_events_v2_mut(): &mut ReceiverRegistryEventsV2 { + let state_signer = &state_object::object_signer(); + let state_address = state_object::object_address(); + + if (!exists(state_address)) { + move_to( + state_signer, + ReceiverRegistryEventsV2 { + receiver_registered_v2_events: account::new_event_handle(state_signer) + } + ); + }; + + borrow_global_mut(state_address) + } + + #[test_only] + public fun init_module_for_testing(publisher: &signer) { + init_module(publisher); + } +} +` + +/** sources/rmn_remote.move */ +export const CCIP_RMN_REMOTE_MOVE = `module ccip::rmn_remote { + use std::account; + use std::aptos_hash; + use std::bcs; + use std::chain_id; + use std::error; + use std::event::{Self, EventHandle}; + use std::object; + use std::option; + use std::secp256k1; + use std::signer; + use std::string::{Self, String}; + use std::smart_table::{Self, SmartTable}; + use std::ordered_map::{Self, OrderedMap}; + + use ccip::auth; + use ccip::eth_abi; + use ccip::merkle_proof; + use ccip::state_object; + + use mcms::bcs_stream; + use mcms::mcms_registry; + + const GLOBAL_CURSE_SUBJECT: vector = x"01000000000000000000000000000001"; + + struct RMNRemoteState has key { + local_chain_selector: u64, + config: Config, + config_count: u32, + signers: SmartTable, bool>, + cursed_subjects: SmartTable, bool>, + config_set_events: EventHandle, + cursed_events: EventHandle, + uncursed_events: EventHandle + } + + struct Config has copy, drop, store { + rmn_home_contract_config_digest: vector, + signers: vector, + f_sign: u64 + } + + struct Signer has copy, drop, store { + onchain_public_key: vector, + node_index: u64 + } + + struct Report has drop { + dest_chain_id: u64, + dest_chain_selector: u64, + rmn_remote_contract_address: address, + off_ramp_address: address, + rmn_home_contract_config_digest: vector, + merkle_roots: vector + } + + struct MerkleRoot has drop { + source_chain_selector: u64, + on_ramp_address: vector, + min_seq_nr: u64, + max_seq_nr: u64, + merkle_root: vector + } + + #[event] + struct ConfigSet has store, drop { + version: u32, + config: Config + } + + #[event] + struct Cursed has store, drop { + subjects: vector> + } + + #[event] + struct Uncursed has store, drop { + subjects: vector> + } + + // ================================================================ + // | AllowedCursersV2 (Fast Cursing) | + // ================================================================ + struct AllowedCursersV2 has key { + allowed_cursers: OrderedMap, + allowed_cursers_added_events: EventHandle, + allowed_cursers_removed_events: EventHandle + } + + #[event] + struct AllowedCursersAdded has store, drop { + cursers: vector
+ } + + #[event] + struct AllowedCursersRemoved has store, drop { + cursers: vector
+ } + + const E_ALREADY_INITIALIZED: u64 = 1; + const E_ALREADY_CURSED: u64 = 2; + const E_CONFIG_NOT_SET: u64 = 3; + const E_DUPLICATE_SIGNER: u64 = 4; + const E_INVALID_SIGNATURE: u64 = 5; + const E_INVALID_SIGNER_ORDER: u64 = 6; + const E_NOT_ENOUGH_SIGNERS: u64 = 7; + const E_NOT_CURSED: u64 = 8; + const E_OUT_OF_ORDER_SIGNATURES: u64 = 9; + const E_THRESHOLD_NOT_MET: u64 = 10; + const E_UNEXPECTED_SIGNER: u64 = 11; + const E_ZERO_VALUE_NOT_ALLOWED: u64 = 12; + const E_MERKLE_ROOT_LENGTH_MISMATCH: u64 = 13; + const E_INVALID_DIGEST_LENGTH: u64 = 14; + const E_SIGNERS_MISMATCH: u64 = 15; + const E_INVALID_SUBJECT_LENGTH: u64 = 16; + const E_INVALID_PUBLIC_KEY_LENGTH: u64 = 17; + const E_UNKNOWN_FUNCTION: u64 = 18; + const E_NOT_OWNER_OR_ALLOWED_CURSER: u64 = 19; + const E_ALLOWED_CURSERS_V2_ALREADY_INITIALIZED: u64 = 20; + const E_ALLOWED_CURSERS_V2_NOT_INITIALIZED: u64 = 21; + const E_CURSER_ALREADY_ALLOWED: u64 = 22; + const E_CURSER_NOT_ALLOWED: u64 = 23; + + #[view] + public fun type_and_version(): String { + string::utf8(b"RMNRemote 1.6.0") + } + + fun init_module(publisher: &signer) { + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher); + }; + } + + public entry fun initialize(caller: &signer, local_chain_selector: u64) { + auth::assert_only_owner(signer::address_of(caller)); + + assert!( + local_chain_selector != 0, + error::invalid_argument(E_ZERO_VALUE_NOT_ALLOWED) + ); + assert!( + !exists(state_object::object_address()), + error::invalid_argument(E_ALREADY_INITIALIZED) + ); + + let state_object_signer = state_object::object_signer(); + + // Create V1 state (RMNRemoteState) + let state = RMNRemoteState { + local_chain_selector, + config: Config { + rmn_home_contract_config_digest: vector[], + signers: vector[], + f_sign: 0 + }, + config_count: 0, + signers: smart_table::new(), + cursed_subjects: smart_table::new(), + config_set_events: account::new_event_handle(&state_object_signer), + cursed_events: account::new_event_handle(&state_object_signer), + uncursed_events: account::new_event_handle(&state_object_signer) + }; + move_to(&state_object_signer, state); + + // Create V2 state (AllowedCursersV2) - new deployments get both + move_to( + &state_object_signer, + AllowedCursersV2 { + allowed_cursers: ordered_map::new(), + allowed_cursers_added_events: account::new_event_handle( + &state_object_signer + ), + allowed_cursers_removed_events: account::new_event_handle( + &state_object_signer + ) + } + ); + } + + #[test_only] + /// Legacy initialization that only creates RMNRemoteState (V1). + /// Used for testing migration scenarios from V1 to V2. + public entry fun initialize_v1( + caller: &signer, local_chain_selector: u64 + ) { + auth::assert_only_owner(signer::address_of(caller)); + + assert!( + local_chain_selector != 0, + error::invalid_argument(E_ZERO_VALUE_NOT_ALLOWED) + ); + assert!( + !exists(state_object::object_address()), + error::invalid_argument(E_ALREADY_INITIALIZED) + ); + + let state_object_signer = state_object::object_signer(); + let state = RMNRemoteState { + local_chain_selector, + config: Config { + rmn_home_contract_config_digest: vector[], + signers: vector[], + f_sign: 0 + }, + config_count: 0, + signers: smart_table::new(), + cursed_subjects: smart_table::new(), + config_set_events: account::new_event_handle(&state_object_signer), + cursed_events: account::new_event_handle(&state_object_signer), + uncursed_events: account::new_event_handle(&state_object_signer) + }; + + move_to(&state_object_signer, state); + } + + inline fun calculate_digest(report: &Report): vector { + let digest = vector[]; + eth_abi::encode_right_padded_bytes32(&mut digest, get_report_digest_header()); + eth_abi::encode_u64(&mut digest, report.dest_chain_id); + eth_abi::encode_u64(&mut digest, report.dest_chain_selector); + eth_abi::encode_address(&mut digest, report.rmn_remote_contract_address); + eth_abi::encode_address(&mut digest, report.off_ramp_address); + eth_abi::encode_right_padded_bytes32( + &mut digest, report.rmn_home_contract_config_digest + ); + report.merkle_roots.for_each_ref( + |merkle_root| { + let merkle_root: &MerkleRoot = merkle_root; + eth_abi::encode_u64(&mut digest, merkle_root.source_chain_selector); + eth_abi::encode_bytes(&mut digest, merkle_root.on_ramp_address); + eth_abi::encode_u64(&mut digest, merkle_root.min_seq_nr); + eth_abi::encode_u64(&mut digest, merkle_root.max_seq_nr); + eth_abi::encode_right_padded_bytes32( + &mut digest, merkle_root.merkle_root + ); + } + ); + aptos_hash::keccak256(digest) + } + + #[view] + public fun verify( + off_ramp_address: address, + merkle_root_source_chain_selectors: vector, + merkle_root_on_ramp_addresses: vector>, + merkle_root_min_seq_nrs: vector, + merkle_root_max_seq_nrs: vector, + merkle_root_values: vector>, + signatures: vector> + ): bool acquires RMNRemoteState { + let state = borrow_state(); + + assert!(state.config_count > 0, error::invalid_argument(E_CONFIG_NOT_SET)); + + let signatures_len = signatures.length(); + assert!( + signatures_len >= (state.config.f_sign + 1), + error::invalid_argument(E_THRESHOLD_NOT_MET) + ); + + let merkle_root_len = merkle_root_source_chain_selectors.length(); + assert!( + merkle_root_len == merkle_root_on_ramp_addresses.length(), + error::invalid_argument(E_MERKLE_ROOT_LENGTH_MISMATCH) + ); + assert!( + merkle_root_len == merkle_root_min_seq_nrs.length(), + error::invalid_argument(E_MERKLE_ROOT_LENGTH_MISMATCH) + ); + assert!( + merkle_root_len == merkle_root_max_seq_nrs.length(), + error::invalid_argument(E_MERKLE_ROOT_LENGTH_MISMATCH) + ); + assert!( + merkle_root_len == merkle_root_values.length(), + error::invalid_argument(E_MERKLE_ROOT_LENGTH_MISMATCH) + ); + + // Since we cannot pass structs, we need to reconstruct it from the individual components. + let merkle_roots = vector[]; + for (i in 0..merkle_root_len) { + let source_chain_selector = merkle_root_source_chain_selectors[i]; + let on_ramp_address = merkle_root_on_ramp_addresses[i]; + let min_seq_nr = merkle_root_min_seq_nrs[i]; + let max_seq_nr = merkle_root_max_seq_nrs[i]; + let merkle_root = merkle_root_values[i]; + merkle_roots.push_back( + MerkleRoot { + source_chain_selector, + on_ramp_address, + min_seq_nr, + max_seq_nr, + merkle_root + } + ); + }; + + let report = Report { + dest_chain_id: (chain_id::get() as u64), + dest_chain_selector: state.local_chain_selector, + rmn_remote_contract_address: @ccip, + off_ramp_address, + rmn_home_contract_config_digest: state.config.rmn_home_contract_config_digest, + merkle_roots + }; + + let digest = calculate_digest(&report); + + let previous_eth_address = vector[]; + for (i in 0..signatures_len) { + let signature_bytes = signatures[i]; + let signature = secp256k1::ecdsa_signature_from_bytes(signature_bytes); + + // rmn only generates signatures with v = 27, subtract the ethereum recover id offset of 27 to get zero. + let v = 0; + let maybe_public_key = secp256k1::ecdsa_recover(digest, v, &signature); + assert!( + maybe_public_key.is_some(), + error::invalid_argument(E_INVALID_SIGNATURE) + ); + + let public_key_bytes = + secp256k1::ecdsa_raw_public_key_to_bytes(&maybe_public_key.extract()); + // trim the first 12 bytes of the hash to recover the ethereum address. + let eth_address = aptos_hash::keccak256(public_key_bytes).trim(12); + + assert!( + state.signers.contains(eth_address), + error::invalid_argument(E_UNEXPECTED_SIGNER) + ); + if (i > 0) { + assert!( + merkle_proof::vector_u8_gt(ð_address, &previous_eth_address), + error::invalid_argument(E_OUT_OF_ORDER_SIGNATURES) + ); + }; + previous_eth_address = eth_address; + }; + + true + } + + #[view] + public fun get_arm(): address { + @ccip + } + + public entry fun set_config( + caller: &signer, + rmn_home_contract_config_digest: vector, + signer_onchain_public_keys: vector>, + node_indexes: vector, + f_sign: u64 + ) acquires RMNRemoteState { + auth::assert_only_owner(signer::address_of(caller)); + + let state = borrow_state_mut(); + + assert!( + rmn_home_contract_config_digest.length() == 32, + error::invalid_argument(E_INVALID_DIGEST_LENGTH) + ); + + assert!( + eth_abi::decode_u256_value(rmn_home_contract_config_digest) != 0, + error::invalid_argument(E_ZERO_VALUE_NOT_ALLOWED) + ); + + let signers_len = signer_onchain_public_keys.length(); + assert!( + signers_len == node_indexes.length(), + error::invalid_argument(E_SIGNERS_MISMATCH) + ); + + for (i in 1..signers_len) { + let previous_node_index = node_indexes[i - 1]; + let current_node_index = node_indexes[i]; + assert!( + previous_node_index < current_node_index, + error::invalid_argument(E_INVALID_SIGNER_ORDER) + ); + }; + + assert!( + signers_len >= (2 * f_sign + 1), + error::invalid_argument(E_NOT_ENOUGH_SIGNERS) + ); + + state.signers.clear(); + + let signers = + signer_onchain_public_keys.zip_map_ref( + &node_indexes, + |signer_public_key_bytes, node_indexes| { + let signer_public_key_bytes: vector = *signer_public_key_bytes; + let node_index: u64 = *node_indexes; + // expect an ethereum address of 20 bytes. + assert!( + signer_public_key_bytes.length() == 20, + error::invalid_argument(E_INVALID_PUBLIC_KEY_LENGTH) + ); + assert!( + !state.signers.contains(signer_public_key_bytes), + error::invalid_argument(E_DUPLICATE_SIGNER) + ); + state.signers.add(signer_public_key_bytes, true); + Signer { + onchain_public_key: signer_public_key_bytes, + node_index + } + } + ); + + let new_config = Config { + rmn_home_contract_config_digest, + signers, + f_sign + }; + state.config = new_config; + + let new_config_count = state.config_count + 1; + state.config_count = new_config_count; + + event::emit_event( + &mut state.config_set_events, + ConfigSet { version: new_config_count, config: new_config } + ); + } + + #[view] + public fun get_versioned_config(): (u32, Config) acquires RMNRemoteState { + let state = borrow_state(); + (state.config_count, state.config) + } + + #[view] + public fun get_local_chain_selector(): u64 acquires RMNRemoteState { + borrow_state().local_chain_selector + } + + #[view] + public fun get_report_digest_header(): vector { + aptos_hash::keccak256(b"RMN_V1_6_ANY2APTOS_REPORT") + } + + public entry fun curse( + caller: &signer, subject: vector + ) acquires RMNRemoteState, AllowedCursersV2 { + curse_multiple(caller, vector[subject]); + } + + public entry fun curse_multiple( + caller: &signer, subjects: vector> + ) acquires RMNRemoteState, AllowedCursersV2 { + assert_owner_or_allowed_curser(signer::address_of(caller)); + + let state = borrow_state_mut(); + + subjects.for_each_ref( + |subject| { + let subject: vector = *subject; + assert!( + subject.length() == 16, + error::invalid_argument(E_INVALID_SUBJECT_LENGTH) + ); + assert!( + !state.cursed_subjects.contains(subject), + error::invalid_argument(E_ALREADY_CURSED) + ); + state.cursed_subjects.add(subject, true); + } + ); + event::emit_event(&mut state.cursed_events, Cursed { subjects }); + } + + public entry fun uncurse( + caller: &signer, subject: vector + ) acquires RMNRemoteState, AllowedCursersV2 { + uncurse_multiple(caller, vector[subject]); + } + + public entry fun uncurse_multiple( + caller: &signer, subjects: vector> + ) acquires RMNRemoteState, AllowedCursersV2 { + assert_owner_or_allowed_curser(signer::address_of(caller)); + + let state = borrow_state_mut(); + + subjects.for_each_ref( + |subject| { + let subject: vector = *subject; + assert!( + state.cursed_subjects.contains(subject), + error::invalid_argument(E_NOT_CURSED) + ); + state.cursed_subjects.remove(subject); + } + ); + event::emit_event(&mut state.uncursed_events, Uncursed { subjects }); + } + + #[view] + public fun get_cursed_subjects(): vector> acquires RMNRemoteState { + borrow_state().cursed_subjects.keys() + } + + #[view] + public fun is_cursed_global(): bool acquires RMNRemoteState { + borrow_state().cursed_subjects.contains(GLOBAL_CURSE_SUBJECT) + } + + #[view] + public fun is_cursed(subject: vector): bool acquires RMNRemoteState { + borrow_state().cursed_subjects.contains(subject) || is_cursed_global() + } + + #[view] + public fun is_cursed_u128(subject_value: u128): bool acquires RMNRemoteState { + let subject = bcs::to_bytes(&subject_value); + subject.reverse(); + is_cursed(subject) + } + + inline fun borrow_state(): &RMNRemoteState { + borrow_global(state_object::object_address()) + } + + inline fun borrow_state_mut(): &mut RMNRemoteState { + borrow_global_mut(state_object::object_address()) + } + + // ================================================================ + // | AllowedCursersV2 Helper Functions | + // ================================================================ + inline fun borrow_allowed_cursers_v2(): &AllowedCursersV2 { + borrow_global(state_object::object_address()) + } + + inline fun borrow_allowed_cursers_v2_mut(): &mut AllowedCursersV2 { + borrow_global_mut(state_object::object_address()) + } + + #[view] + /// Check if an address is an allowed curser. + /// Returns false if AllowedCursersV2 is not initialized (V1 behavior: only owner can curse). + public fun is_allowed_curser(curser: address): bool acquires AllowedCursersV2 { + if (!exists(state_object::object_address())) { false } + else { + borrow_allowed_cursers_v2().allowed_cursers.contains(&curser) + } + } + + #[view] + /// Get the list of allowed cursers. + /// Returns empty vector if AllowedCursersV2 is not initialized. + public fun get_allowed_cursers(): vector
acquires AllowedCursersV2 { + if (!exists(state_object::object_address())) { + vector[] + } else { + borrow_allowed_cursers_v2().allowed_cursers.keys() + } + } + + inline fun assert_owner_or_allowed_curser(caller: address) { + assert!( + caller == auth::owner() || is_allowed_curser(caller), + error::permission_denied(E_NOT_OWNER_OR_ALLOWED_CURSER) + ); + } + + // ================================================================ + // | AllowedCursersV2 Admin Functions (Owner Only) | + // ================================================================ + + /// Initialize the AllowedCursersV2 resource. Owner only. + /// This must be called before adding allowed cursers. + public entry fun initialize_allowed_cursers_v2( + caller: &signer, initial_cursers: vector
+ ) { + auth::assert_only_owner(signer::address_of(caller)); + + assert!( + !exists(state_object::object_address()), + error::already_exists(E_ALLOWED_CURSERS_V2_ALREADY_INITIALIZED) + ); + + let state_object_signer = state_object::object_signer(); + let allowed_cursers = ordered_map::new(); + + initial_cursers.for_each_ref( + |curser| { + allowed_cursers.add(*curser, true); + } + ); + + move_to( + &state_object_signer, + AllowedCursersV2 { + allowed_cursers, + allowed_cursers_added_events: account::new_event_handle( + &state_object_signer + ), + allowed_cursers_removed_events: account::new_event_handle( + &state_object_signer + ) + } + ); + + if (!initial_cursers.is_empty()) { + event::emit(AllowedCursersAdded { cursers: initial_cursers }); + }; + } + + /// Add allowed cursers. Owner only. + /// AllowedCursersV2 must be initialized first. + public entry fun add_allowed_cursers( + caller: &signer, cursers_to_add: vector
+ ) acquires AllowedCursersV2 { + auth::assert_only_owner(signer::address_of(caller)); + + assert!( + exists(state_object::object_address()), + error::invalid_state(E_ALLOWED_CURSERS_V2_NOT_INITIALIZED) + ); + + let state = borrow_allowed_cursers_v2_mut(); + + cursers_to_add.for_each_ref( + |curser| { + assert!( + !state.allowed_cursers.contains(curser), + error::already_exists(E_CURSER_ALREADY_ALLOWED) + ); + state.allowed_cursers.add(*curser, true); + } + ); + + event::emit_event( + &mut state.allowed_cursers_added_events, + AllowedCursersAdded { cursers: cursers_to_add } + ); + } + + /// Remove allowed cursers. Owner only. + /// AllowedCursersV2 must be initialized first. + public entry fun remove_allowed_cursers( + caller: &signer, cursers_to_remove: vector
+ ) acquires AllowedCursersV2 { + auth::assert_only_owner(signer::address_of(caller)); + + assert!( + exists(state_object::object_address()), + error::invalid_state(E_ALLOWED_CURSERS_V2_NOT_INITIALIZED) + ); + + let state = borrow_allowed_cursers_v2_mut(); + + cursers_to_remove.for_each_ref( + |curser| { + assert!( + state.allowed_cursers.contains(curser), + error::not_found(E_CURSER_NOT_ALLOWED) + ); + state.allowed_cursers.remove(curser); + } + ); + + event::emit_event( + &mut state.allowed_cursers_removed_events, + AllowedCursersRemoved { cursers: cursers_to_remove } + ); + } + + // ================================================================ + // | MCMS Entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: object::Object + ): option::Option acquires RMNRemoteState, AllowedCursersV2 { + let (caller, function, data) = + mcms_registry::get_callback_params(@ccip, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"initialize") { + let local_chain_selector = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + initialize(&caller, local_chain_selector); + } else if (function_bytes == b"set_config") { + let rmn_home_contract_config_digest = + bcs_stream::deserialize_vector_u8(&mut stream); + let signer_onchain_public_keys = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + let node_indexes = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let f_sign = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_config( + &caller, + rmn_home_contract_config_digest, + signer_onchain_public_keys, + node_indexes, + f_sign + ) + } else if (function_bytes == b"curse") { + let subject = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + curse(&caller, subject) + } else if (function_bytes == b"curse_multiple") { + let subjects = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(&stream); + curse_multiple(&caller, subjects) + } else if (function_bytes == b"uncurse") { + let subject = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + uncurse(&caller, subject) + } else if (function_bytes == b"uncurse_multiple") { + let subjects = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(&stream); + uncurse_multiple(&caller, subjects) + } else if (function_bytes == b"initialize_allowed_cursers_v2") { + let initial_cursers = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + initialize_allowed_cursers_v2(&caller, initial_cursers) + } else if (function_bytes == b"add_allowed_cursers") { + let cursers_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + add_allowed_cursers(&caller, cursers_to_add) + } else if (function_bytes == b"remove_allowed_cursers") { + let cursers_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + remove_allowed_cursers(&caller, cursers_to_remove) + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint(publisher: &signer) { + mcms_registry::register_entrypoint( + publisher, string::utf8(b"rmn_remote"), McmsCallback {} + ); + } +} +` + +/** sources/state_object.move */ +export const CCIP_STATE_OBJECT_MOVE = `/// This module creates a single object for storing CCIP state resources in order to: +/// +/// - simplify ownership management +/// - simplify observability: all resources and events can be queried and viewed at a single address +/// - decouple module deployment and initialization: the CCIP module will be deployed using the +/// recommended object code deployment approach, but initialization requires various +/// "constructor" parameters that cannot be passed it at deploy (ie. init_module()) time. +/// Object code deployment only allows for publishing and upgrading modules, with no way to +/// retrieve a signer to store resources (see: 0x1::object_code_deployment), so a different +/// object is necessary. +module ccip::state_object { + use std::account; + use std::error; + use std::object::{Self, ExtendRef, TransferRef}; + use std::signer; + + friend ccip::auth; + friend ccip::fee_quoter; + friend ccip::nonce_manager; + friend ccip::receiver_registry; + friend ccip::rmn_remote; + friend ccip::token_admin_registry; + + struct StateObjectRefs has key { + extend_ref: ExtendRef, + transfer_ref: TransferRef + } + + const E_NOT_OBJECT_DEPLOYMENT: u64 = 1; + + fun init_module(publisher: &signer) { + assert!( + object::is_object(signer::address_of(publisher)), + error::invalid_state(E_NOT_OBJECT_DEPLOYMENT) + ); + + init_module_internal(publisher); + } + + inline fun init_module_internal(publisher: &signer) { + let constructor_ref = object::create_named_object(publisher, b"CCIPStateObject"); + + let extend_ref = object::generate_extend_ref(&constructor_ref); + let transfer_ref = object::generate_transfer_ref(&constructor_ref); + let object_signer = object::generate_signer(&constructor_ref); + + // create an Account on the object for event handles. + account::create_account_if_does_not_exist( + object::address_from_constructor_ref(&constructor_ref) + ); + + move_to(&object_signer, StateObjectRefs { extend_ref, transfer_ref }); + } + + #[view] + public fun get_object_address(): address { + object_address() + } + + public(friend) inline fun object_address(): address { + // hard code the object seed directly in order to keep the function inline. + object::create_object_address(&@ccip, b"CCIPStateObject") + } + + public(friend) fun object_signer(): signer acquires StateObjectRefs { + let store = borrow_global(object_address()); + object::generate_signer_for_extending(&store.extend_ref) + } + + #[test_only] + public fun init_module_for_testing(publisher: &signer) { + init_module_internal(publisher); + } +} +` + +/** sources/token_admin_dispatcher.move */ +export const CCIP_TOKEN_ADMIN_DISPATCHER_MOVE = `module ccip::token_admin_dispatcher { + use std::dispatchable_fungible_asset; + use std::fungible_asset::FungibleAsset; + use std::signer; + + use ccip::auth; + use ccip::token_admin_registry; + + public fun dispatch_lock_or_burn( + caller: &signer, + token_pool_address: address, + fa: FungibleAsset, + sender: address, + remote_chain_selector: u64, + receiver: vector + ): (vector, vector) { + auth::assert_is_allowed_onramp(signer::address_of(caller)); + + if (token_admin_registry::has_token_pool_registration_v2(token_pool_address)) { + token_admin_registry::lock_or_burn_v2( + token_pool_address, + fa, + sender, + remote_chain_selector, + receiver + ) + } else { + let dispatch_fungible_store = + token_admin_registry::start_lock_or_burn( + token_pool_address, + sender, + remote_chain_selector, + receiver + ); + + dispatchable_fungible_asset::deposit(dispatch_fungible_store, fa); + + token_admin_registry::finish_lock_or_burn(token_pool_address) + } + } + + public fun dispatch_release_or_mint( + caller: &signer, + token_pool_address: address, + sender: vector, + receiver: address, + source_amount: u256, + local_token: address, + remote_chain_selector: u64, + source_pool_address: vector, + source_pool_data: vector, + offchain_token_data: vector + ): (FungibleAsset, u64) { + auth::assert_is_allowed_offramp(signer::address_of(caller)); + + if (token_admin_registry::has_token_pool_registration_v2(token_pool_address)) { + token_admin_registry::release_or_mint_v2( + token_pool_address, + sender, + receiver, + source_amount, + local_token, + remote_chain_selector, + source_pool_address, + source_pool_data, + offchain_token_data + ) + } else { + let (dispatch_owner, dispatch_fungible_store) = + token_admin_registry::start_release_or_mint( + token_pool_address, + sender, + receiver, + source_amount, + local_token, + remote_chain_selector, + source_pool_address, + source_pool_data, + offchain_token_data + ); + + let fa = + dispatchable_fungible_asset::withdraw( + &dispatch_owner, dispatch_fungible_store, 0 + ); + + let destination_amount = + token_admin_registry::finish_release_or_mint(token_pool_address); + + (fa, destination_amount) + } + } +} +` + +/** sources/token_admin_registry.move */ +export const CCIP_TOKEN_ADMIN_REGISTRY_MOVE = `module ccip::token_admin_registry { + use std::account; + use std::dispatchable_fungible_asset; + use std::error; + use std::event::{Self, EventHandle}; + use std::function_info::{Self, FunctionInfo}; + use std::fungible_asset::{Self, Metadata, FungibleStore, FungibleAsset}; + use std::object::{Self, Object, ExtendRef, TransferRef}; + use std::option::{Self, Option}; + use std::signer; + use std::big_ordered_map::{Self, BigOrderedMap}; + use std::string::{Self, String}; + use std::type_info::{Self, TypeInfo}; + + use ccip::auth; + use ccip::state_object; + + use mcms::bcs_stream; + use mcms::mcms_registry; + + friend ccip::token_admin_dispatcher; + + enum ExecutionState has store, drop, copy { + IDLE, + LOCK_OR_BURN, + RELEASE_OR_MINT + } + + struct TokenAdminRegistryState has key, store { + extend_ref: ExtendRef, + transfer_ref: TransferRef, + + // fungible asset metadata address -> TokenConfig + token_configs: BigOrderedMap, + pool_set_events: EventHandle, + administrator_transfer_requested_events: EventHandle, + administrator_transferred_events: EventHandle, + token_unregistered_events: EventHandle + } + + struct TokenConfig has store, drop, copy { + token_pool_address: address, + administrator: address, + pending_administrator: address + } + + struct TokenPoolRegistration has key, store { + lock_or_burn_function: FunctionInfo, + release_or_mint_function: FunctionInfo, + proof_typeinfo: TypeInfo, + dispatch_metadata: Object, + dispatch_deposit_fungible_store: Object, + dispatch_extend_ref: ExtendRef, + dispatch_transfer_ref: TransferRef, + dispatch_fa_transfer_ref: fungible_asset::TransferRef, + execution_state: ExecutionState, + executing_lock_or_burn_input_v1: Option, + executing_release_or_mint_input_v1: Option, + executing_lock_or_burn_output_v1: Option, + executing_release_or_mint_output_v1: Option, + local_token: address + } + + struct LockOrBurnInputV1 has store, drop { + sender: address, + remote_chain_selector: u64, + receiver: vector + } + + struct LockOrBurnOutputV1 has store, drop { + dest_token_address: vector, + dest_pool_data: vector + } + + struct ReleaseOrMintInputV1 has store, drop { + sender: vector, + receiver: address, + source_amount: u256, + local_token: address, + remote_chain_selector: u64, + source_pool_address: vector, + source_pool_data: vector, + offchain_token_data: vector + } + + struct ReleaseOrMintOutputV1 has store, drop { + destination_amount: u64 + } + + struct TokenPoolCallbacks has copy, drop, store { + lock_or_burn: |FungibleAsset, LockOrBurnInputV1| (vector, vector), + release_or_mint: |ReleaseOrMintInputV1| (FungibleAsset, u64) + } + + struct TokenPoolRegistrationV2 has key { + callbacks: TokenPoolCallbacks, + local_token: address + } + + #[event] + struct PoolSet has store, drop { + local_token: address, + previous_pool_address: address, + new_pool_address: address + } + + #[event] + struct AdministratorTransferRequested has store, drop { + local_token: address, + current_admin: address, + new_admin: address + } + + #[event] + struct AdministratorTransferred has store, drop { + local_token: address, + new_admin: address + } + + #[event] + struct TokenUnregistered has store, drop { + local_token: address, + previous_pool_address: address + } + + const E_INVALID_FUNGIBLE_ASSET: u64 = 1; + const E_NOT_FUNGIBLE_ASSET_OWNER: u64 = 2; + const E_INVALID_TOKEN_POOL: u64 = 3; + const E_ALREADY_REGISTERED: u64 = 4; + const E_UNKNOWN_FUNCTION: u64 = 5; + const E_PROOF_NOT_IN_TOKEN_POOL_MODULE: u64 = 6; + const E_PROOF_NOT_AT_TOKEN_POOL_ADDRESS: u64 = 7; + const E_UNKNOWN_PROOF_TYPE: u64 = 8; + const E_NOT_IN_IDLE_STATE: u64 = 9; + const E_NOT_IN_LOCK_OR_BURN_STATE: u64 = 10; + const E_NOT_IN_RELEASE_OR_MINT_STATE: u64 = 11; + const E_NON_EMPTY_LOCK_OR_BURN_INPUT: u64 = 12; + const E_NON_EMPTY_LOCK_OR_BURN_OUTPUT: u64 = 13; + const E_NON_EMPTY_RELEASE_OR_MINT_INPUT: u64 = 14; + const E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT: u64 = 15; + const E_MISSING_LOCK_OR_BURN_INPUT: u64 = 16; + const E_MISSING_LOCK_OR_BURN_OUTPUT: u64 = 17; + const E_MISSING_RELEASE_OR_MINT_INPUT: u64 = 18; + const E_MISSING_RELEASE_OR_MINT_OUTPUT: u64 = 19; + const E_TOKEN_POOL_NOT_OBJECT: u64 = 20; + const E_ADMIN_FOR_TOKEN_ALREADY_SET: u64 = 21; + const E_FUNGIBLE_ASSET_NOT_REGISTERED: u64 = 22; + const E_NOT_ADMINISTRATOR: u64 = 23; + const E_NOT_PENDING_ADMINISTRATOR: u64 = 24; + const E_NOT_AUTHORIZED: u64 = 25; + const E_INVALID_TOKEN_FOR_POOL: u64 = 26; + const E_ADMIN_NOT_SET_FOR_TOKEN: u64 = 27; + const E_ADMIN_ALREADY_SET_FOR_TOKEN: u64 = 28; + const E_ZERO_ADDRESS: u64 = 29; + const E_POOL_NOT_REGISTERED: u64 = 30; + const E_TOKEN_MISMATCH: u64 = 31; + + #[view] + public fun type_and_version(): String { + string::utf8(b"TokenAdminRegistry 1.6.0") + } + + fun init_module(publisher: &signer) { + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher); + }; + + let state_object_signer = state_object::object_signer(); + + let constructor_ref = + object::create_named_object( + &state_object_signer, b"CCIPTokenAdminRegistry" + ); + let extend_ref = object::generate_extend_ref(&constructor_ref); + let transfer_ref = object::generate_transfer_ref(&constructor_ref); + + let state = TokenAdminRegistryState { + extend_ref, + transfer_ref, + token_configs: big_ordered_map::new(), + pool_set_events: account::new_event_handle(&state_object_signer), + administrator_transfer_requested_events: account::new_event_handle( + &state_object_signer + ), + administrator_transferred_events: account::new_event_handle( + &state_object_signer + ), + token_unregistered_events: account::new_event_handle(&state_object_signer) + }; + + move_to(&state_object_signer, state); + } + + #[view] + public fun get_pools( + local_tokens: vector
+ ): vector
acquires TokenAdminRegistryState { + let state = borrow_state(); + + local_tokens.map_ref( + |local_token| { + let local_token: address = *local_token; + if (state.token_configs.contains(&local_token)) { + let token_config = state.token_configs.borrow(&local_token); + token_config.token_pool_address + } else { + // returns @0x0 for assets without token pools. + @0x0 + } + } + ) + } + + #[view] + /// returns the token pool address for the given local token, or @0x0 if the token is not registered. + public fun get_pool(local_token: address): address acquires TokenAdminRegistryState { + let state = borrow_state(); + if (state.token_configs.contains(&local_token)) { + let token_config = state.token_configs.borrow(&local_token); + token_config.token_pool_address + } else { + // returns @0x0 for assets without token pools. + @0x0 + } + } + + #[view] + /// Returns the local token address for the token pool (supports both V1 and V2). + public fun get_pool_local_token( + token_pool_address: address + ): address acquires TokenPoolRegistration, TokenPoolRegistrationV2 { + if (exists(token_pool_address)) { + TokenPoolRegistrationV2[token_pool_address].local_token + } else if (exists(token_pool_address)) { + get_registration(token_pool_address).local_token + } else { + abort error::invalid_argument(E_POOL_NOT_REGISTERED) + } + } + + #[view] + /// Returns the local token address for the token pool. + public fun get_pool_local_token_v2( + token_pool_address: address + ): address acquires TokenPoolRegistrationV2 { + TokenPoolRegistrationV2[token_pool_address].local_token + } + + #[view] + /// Returns true if token pool has TokenPoolRegistrationV2 resource + public fun has_token_pool_registration_v2( + token_pool_address: address + ): bool { + exists(token_pool_address) + } + + #[view] + /// returns (token_pool_address, administrator, pending_administrator) + public fun get_token_config( + local_token: address + ): (address, address, address) acquires TokenAdminRegistryState { + let state = borrow_state(); + if (state.token_configs.contains(&local_token)) { + let token_config = state.token_configs.borrow(&local_token); + ( + token_config.token_pool_address, + token_config.administrator, + token_config.pending_administrator + ) + } else { + (@0x0, @0x0, @0x0) + } + } + + #[view] + /// Get configured tokens paginated using a start key and limit. + /// Caller should call this on a certain block to ensure you the same state for every call. + /// + /// This function retrieves a batch of token addresses from the registry, starting from + /// the token address that comes after the provided start_key. + /// + /// @param start_key - Address to start pagination from (returns tokens AFTER this address) + /// @param max_count - Maximum number of tokens to return + /// + /// @return: + /// - vector
: List of token addresses (up to max_count) + /// - address: Next key to use for pagination (pass this as start_key in next call) + /// - bool: Whether there are more tokens after this batch + public fun get_all_configured_tokens( + start_key: address, max_count: u64 + ): (vector
, address, bool) acquires TokenAdminRegistryState { + let token_configs = &borrow_state().token_configs; + let result = vector[]; + + let current_key_opt = token_configs.next_key(&start_key); + if (max_count == 0 || current_key_opt.is_none()) { + return (result, start_key, current_key_opt.is_some()) + }; + + let current_key = *current_key_opt.borrow(); + + result.push_back(current_key); + + if (max_count == 1) { + let has_more = token_configs.next_key(¤t_key).is_some(); + return (result, current_key, has_more); + }; + + for (i in 1..max_count) { + let next_key_opt = token_configs.next_key(¤t_key); + if (next_key_opt.is_none()) { + return (result, current_key, false) + }; + + current_key = *next_key_opt.borrow(); + result.push_back(current_key); + }; + + // Check if there are more tokens after the last key + let has_more = token_configs.next_key(¤t_key).is_some(); + (result, current_key, has_more) + } + + // ================================================================ + // | Register Pool | + // ================================================================ + #[deprecated] + /// @deprecated: Use \`register_pool_v2()\` instead. + /// + /// Registers pool with \`TokenPoolRegistration\` and sets up dynamic dispatch for a token pool + /// Registry token config mapping must be done separately via \`set_pool()\` + /// by token owner or ccip owner. + public fun register_pool( + token_pool_account: &signer, + token_pool_module_name: vector, + local_token: address, + _proof: ProofType + ) acquires TokenAdminRegistryState { + let token_pool_address = signer::address_of(token_pool_account); + assert!( + !exists(token_pool_address) + && !exists(token_pool_address), + error::invalid_argument(E_ALREADY_REGISTERED) + ); + assert!( + object::object_exists(local_token), + error::invalid_argument(E_INVALID_FUNGIBLE_ASSET) + ); + + let state = borrow_state_mut(); + + let lock_or_burn_function = + function_info::new_function_info( + token_pool_account, + string::utf8(token_pool_module_name), + string::utf8(b"lock_or_burn") + ); + let proof_typeinfo = type_info::type_of(); + assert!( + proof_typeinfo.account_address() == token_pool_address, + error::invalid_argument(E_PROOF_NOT_AT_TOKEN_POOL_ADDRESS) + ); + assert!( + proof_typeinfo.module_name() == token_pool_module_name, + error::invalid_argument(E_PROOF_NOT_IN_TOKEN_POOL_MODULE) + ); + + let release_or_mint_function = + function_info::new_function_info( + token_pool_account, + string::utf8(token_pool_module_name), + string::utf8(b"release_or_mint") + ); + + let dispatch_constructor_ref = + object::create_sticky_object( + object::address_from_extend_ref(&state.extend_ref) + ); + let dispatch_extend_ref = object::generate_extend_ref(&dispatch_constructor_ref); + let dispatch_transfer_ref = + object::generate_transfer_ref(&dispatch_constructor_ref); + + let dispatch_metadata = + fungible_asset::add_fungibility( + &dispatch_constructor_ref, + option::none(), + // max name length is 32 chars + string::utf8(b"CCIPTokenAdminRegistry"), + // max symbol length is 10 chars + string::utf8(b"CCIPTAR"), + 0, + string::utf8(b""), + string::utf8(b"") + ); + + let dispatch_fa_transfer_ref = + fungible_asset::generate_transfer_ref(&dispatch_constructor_ref); + + // create a FungibleStore for dispatchable_deposit(). it's valid for the FungibleStore to be on the same object + // as the fungible asset Metadata itself. + let dispatch_deposit_fungible_store = + fungible_asset::create_store(&dispatch_constructor_ref, dispatch_metadata); + + dispatchable_fungible_asset::register_dispatch_functions( + &dispatch_constructor_ref, + /* withdraw_function= */ option::some(release_or_mint_function), + /* deposit_function= */ option::some(lock_or_burn_function), + /* derived_balance_function= */ option::none() + ); + + move_to( + token_pool_account, + TokenPoolRegistration { + lock_or_burn_function, + release_or_mint_function, + proof_typeinfo, + dispatch_metadata, + dispatch_deposit_fungible_store, + dispatch_extend_ref, + dispatch_transfer_ref, + dispatch_fa_transfer_ref, + execution_state: ExecutionState::IDLE, + executing_lock_or_burn_input_v1: option::none(), + executing_release_or_mint_input_v1: option::none(), + executing_lock_or_burn_output_v1: option::none(), + executing_release_or_mint_output_v1: option::none(), + local_token + } + ); + } + + /// Registers a V2 token pool using function-value callbacks (closures). + /// + /// Upgrade path: existing legacy pools can upgrade to V2 by calling this function, + /// which supersedes the legacy registration without requiring \`unregister_pool()\`. + /// New pools should use V2 directly. Once V2 is registered, legacy registration + /// via \`register_pool()\` is rejected. + public fun register_pool_v2( + token_pool_account: &signer, + local_token: address, + lock_or_burn: |FungibleAsset, LockOrBurnInputV1| (vector, vector) has copy + + drop + store, + release_or_mint: |ReleaseOrMintInputV1| (FungibleAsset, u64) has copy + drop + store + ) { + let token_pool_address = signer::address_of(token_pool_account); + assert!( + !exists(token_pool_address), + error::invalid_argument(E_ALREADY_REGISTERED) + ); + assert!( + object::object_exists(local_token), + error::invalid_argument(E_INVALID_FUNGIBLE_ASSET) + ); + if (exists(token_pool_address)) { + assert!( + get_registration(token_pool_address).local_token == local_token, + error::invalid_argument(E_TOKEN_MISMATCH) + ); + }; + + move_to( + token_pool_account, + TokenPoolRegistrationV2 { + callbacks: TokenPoolCallbacks { lock_or_burn, release_or_mint }, + local_token + } + ); + } + + public entry fun unregister_pool( + caller: &signer, local_token: address + ) acquires TokenAdminRegistryState, TokenPoolRegistration, TokenPoolRegistrationV2 { + let state = borrow_state_mut(); + assert!( + state.token_configs.contains(&local_token), + error::invalid_argument(E_FUNGIBLE_ASSET_NOT_REGISTERED) + ); + + let token_config = state.token_configs.remove(&local_token); + assert!( + token_config.administrator == signer::address_of(caller), + error::permission_denied(E_NOT_ADMINISTRATOR) + ); + + let previous_pool_address = token_config.token_pool_address; + if (exists(previous_pool_address)) { + let TokenPoolRegistration { + lock_or_burn_function: _, + release_or_mint_function: _, + proof_typeinfo: _, + dispatch_metadata: _, + dispatch_deposit_fungible_store: _, + dispatch_extend_ref: _, + dispatch_transfer_ref: _, + dispatch_fa_transfer_ref: _, + execution_state: _, + executing_lock_or_burn_input_v1: _, + executing_release_or_mint_input_v1: _, + executing_lock_or_burn_output_v1: _, + executing_release_or_mint_output_v1: _, + local_token: _ + } = move_from(previous_pool_address); + }; + + if (exists(previous_pool_address)) { + let TokenPoolRegistrationV2 { callbacks: _, local_token: _ } = + move_from(previous_pool_address); + }; + + event::emit_event( + &mut state.token_unregistered_events, + TokenUnregistered { + local_token, + previous_pool_address: token_config.token_pool_address + } + ); + } + + public entry fun set_pool( + caller: &signer, local_token: address, token_pool_address: address + ) acquires TokenAdminRegistryState, TokenPoolRegistration, TokenPoolRegistrationV2 { + assert!( + object::object_exists(local_token), + error::invalid_argument(E_INVALID_FUNGIBLE_ASSET) + ); + + let caller_addr = signer::address_of(caller); + + let pool_local_token = + if (exists(token_pool_address)) { + get_pool_local_token_v2(token_pool_address) + } else if (exists(token_pool_address)) { + get_registration(token_pool_address).local_token + } else { + abort error::invalid_argument(E_POOL_NOT_REGISTERED) + }; + + assert!( + pool_local_token == local_token, + error::invalid_argument(E_INVALID_TOKEN_FOR_POOL) + ); + + let state = borrow_state_mut(); + assert!( + state.token_configs.contains(&local_token), + error::invalid_argument(E_ADMIN_NOT_SET_FOR_TOKEN) + ); + + let config = state.token_configs.borrow_mut(&local_token); + assert!( + config.administrator == caller_addr, + error::permission_denied(E_NOT_ADMINISTRATOR) + ); + + let previous_pool_address = config.token_pool_address; + config.token_pool_address = token_pool_address; + + if (previous_pool_address != token_pool_address) { + event::emit_event( + &mut state.pool_set_events, + PoolSet { + local_token, + previous_pool_address, + new_pool_address: token_pool_address + } + ); + } + } + + public entry fun propose_administrator( + caller: &signer, local_token: address, administrator: address + ) acquires TokenAdminRegistryState { + assert!( + object::object_exists(local_token), + error::invalid_argument(E_INVALID_FUNGIBLE_ASSET) + ); + + let metadata = object::address_to_object(local_token); + let caller_addr = signer::address_of(caller); + + // Allow CCIP owner or token owner to propose administrator + assert!( + object::owns(metadata, caller_addr) || caller_addr == auth::owner(), + error::permission_denied(E_NOT_AUTHORIZED) + ); + + assert!(administrator != @0x0, error::invalid_argument(E_ZERO_ADDRESS)); + + let state = borrow_state_mut(); + if (state.token_configs.contains(&local_token)) { + let config = state.token_configs.borrow_mut(&local_token); + assert!( + config.administrator == @0x0, + error::invalid_argument(E_ADMIN_FOR_TOKEN_ALREADY_SET) + ); + config.pending_administrator = administrator; + } else { + state.token_configs.add( + local_token, + TokenConfig { + token_pool_address: @0x0, + administrator: @0x0, + pending_administrator: administrator + } + ); + }; + + event::emit_event( + &mut state.administrator_transfer_requested_events, + AdministratorTransferRequested { + local_token, + current_admin: @0x0, + new_admin: administrator + } + ); + } + + public entry fun transfer_admin_role( + caller: &signer, local_token: address, new_admin: address + ) acquires TokenAdminRegistryState { + let state = borrow_state_mut(); + + assert!( + state.token_configs.contains(&local_token), + error::invalid_argument(E_FUNGIBLE_ASSET_NOT_REGISTERED) + ); + + let token_config = state.token_configs.borrow_mut(&local_token); + + assert!( + token_config.administrator == signer::address_of(caller), + error::permission_denied(E_NOT_ADMINISTRATOR) + ); + + // can be @0x0 to cancel a pending transfer. + token_config.pending_administrator = new_admin; + + event::emit_event( + &mut state.administrator_transfer_requested_events, + AdministratorTransferRequested { + local_token, + current_admin: token_config.administrator, + new_admin + } + ); + } + + public entry fun accept_admin_role( + caller: &signer, local_token: address + ) acquires TokenAdminRegistryState { + let state = borrow_state_mut(); + + assert!( + state.token_configs.contains(&local_token), + error::invalid_argument(E_FUNGIBLE_ASSET_NOT_REGISTERED) + ); + + let token_config = state.token_configs.borrow_mut(&local_token); + + assert!( + token_config.pending_administrator == signer::address_of(caller), + error::permission_denied(E_NOT_PENDING_ADMINISTRATOR) + ); + + token_config.administrator = token_config.pending_administrator; + token_config.pending_administrator = @0x0; + + event::emit_event( + &mut state.administrator_transferred_events, + AdministratorTransferred { + local_token, + new_admin: token_config.administrator + } + ); + } + + #[view] + public fun is_administrator( + local_token: address, administrator: address + ): bool acquires TokenAdminRegistryState { + let state = borrow_state(); + assert!( + state.token_configs.contains(&local_token), + error::invalid_argument(E_FUNGIBLE_ASSET_NOT_REGISTERED) + ); + + let token_config = state.token_configs.borrow(&local_token); + token_config.administrator == administrator + } + + // ================================================================ + // | Pool I/O V1 | + // ================================================================ + public fun get_lock_or_burn_input_v1( + token_pool_address: address, _proof: ProofType + ): LockOrBurnInputV1 acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + type_info::type_of() == registration.proof_typeinfo, + error::permission_denied(E_UNKNOWN_PROOF_TYPE) + ); + + assert!( + registration.execution_state is ExecutionState::LOCK_OR_BURN, + error::invalid_state(E_NOT_IN_LOCK_OR_BURN_STATE) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_some(), + error::invalid_state(E_MISSING_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_OUTPUT) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT) + ); + + registration.executing_lock_or_burn_input_v1.extract() + } + + public fun set_lock_or_burn_output_v1( + token_pool_address: address, + _proof: ProofType, + dest_token_address: vector, + dest_pool_data: vector + ) acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + type_info::type_of() == registration.proof_typeinfo, + error::permission_denied(E_UNKNOWN_PROOF_TYPE) + ); + + assert!( + registration.execution_state is ExecutionState::LOCK_OR_BURN, + error::invalid_state(E_NOT_IN_LOCK_OR_BURN_STATE) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_OUTPUT) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT) + ); + + registration.executing_lock_or_burn_output_v1.fill( + LockOrBurnOutputV1 { dest_token_address, dest_pool_data } + ) + } + + public fun get_release_or_mint_input_v1( + token_pool_address: address, _proof: ProofType + ): ReleaseOrMintInputV1 acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + type_info::type_of() == registration.proof_typeinfo, + error::permission_denied(E_UNKNOWN_PROOF_TYPE) + ); + + assert!( + registration.execution_state is ExecutionState::RELEASE_OR_MINT, + error::invalid_state(E_NOT_IN_RELEASE_OR_MINT_STATE) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_some(), + error::invalid_state(E_MISSING_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_OUTPUT) + ); + + registration.executing_release_or_mint_input_v1.extract() + } + + public fun set_release_or_mint_output_v1( + token_pool_address: address, _proof: ProofType, destination_amount: u64 + ) acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + type_info::type_of() == registration.proof_typeinfo, + error::permission_denied(E_UNKNOWN_PROOF_TYPE) + ); + + assert!( + registration.execution_state is ExecutionState::RELEASE_OR_MINT, + error::invalid_state(E_NOT_IN_RELEASE_OR_MINT_STATE) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_OUTPUT) + ); + + registration.executing_release_or_mint_output_v1.fill( + ReleaseOrMintOutputV1 { destination_amount } + ) + } + + // LockOrBurnInput accessors + public fun get_lock_or_burn_sender(input: &LockOrBurnInputV1): address { + input.sender + } + + public fun get_lock_or_burn_remote_chain_selector( + input: &LockOrBurnInputV1 + ): u64 { + input.remote_chain_selector + } + + public fun get_lock_or_burn_receiver(input: &LockOrBurnInputV1): vector { + input.receiver + } + + // ReleaseOrMintInput accessors + public fun get_release_or_mint_sender(input: &ReleaseOrMintInputV1): vector { + input.sender + } + + public fun get_release_or_mint_receiver( + input: &ReleaseOrMintInputV1 + ): address { + input.receiver + } + + public fun get_release_or_mint_source_amount( + input: &ReleaseOrMintInputV1 + ): u256 { + input.source_amount + } + + public fun get_release_or_mint_local_token( + input: &ReleaseOrMintInputV1 + ): address { + input.local_token + } + + public fun get_release_or_mint_remote_chain_selector( + input: &ReleaseOrMintInputV1 + ): u64 { + input.remote_chain_selector + } + + public fun get_release_or_mint_source_pool_address( + input: &ReleaseOrMintInputV1 + ): vector { + input.source_pool_address + } + + public fun get_release_or_mint_source_pool_data( + input: &ReleaseOrMintInputV1 + ): vector { + input.source_pool_data + } + + public fun get_release_or_mint_offchain_token_data( + input: &ReleaseOrMintInputV1 + ): vector { + input.offchain_token_data + } + + // ================================================================ + // | Lock or Burn | + // ================================================================ + public(friend) fun start_lock_or_burn( + token_pool_address: address, + sender: address, + remote_chain_selector: u64, + receiver: vector + ): Object acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + registration.execution_state is ExecutionState::IDLE, + error::invalid_state(E_NOT_IN_IDLE_STATE) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_OUTPUT) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT) + ); + + registration.execution_state = ExecutionState::LOCK_OR_BURN; + registration.executing_lock_or_burn_input_v1.fill( + LockOrBurnInputV1 { sender, remote_chain_selector, receiver } + ); + + registration.dispatch_deposit_fungible_store + } + + public(friend) fun finish_lock_or_burn( + token_pool_address: address + ): (vector, vector) acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + registration.execution_state is ExecutionState::LOCK_OR_BURN, + error::invalid_state(E_NOT_IN_LOCK_OR_BURN_STATE) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_some(), + error::invalid_state(E_MISSING_LOCK_OR_BURN_OUTPUT) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT) + ); + + registration.execution_state = ExecutionState::IDLE; + + // the dispatch callback is passed a fungible_asset::TransferRef reference which could allow the store to be frozen, + // causing future deposit/withdraw callbacks to fail. note that this fungible store is only used as part of the dispatch + // mechanism. + // ref: https://github.com/aptos-labs/aptos-core/blob/7fc73792e9db11462c9a42038c4a9eb41cc00192/aptos-move/framework/aptos-framework/sources/fungible_asset.move#L923 + if (fungible_asset::is_frozen(registration.dispatch_deposit_fungible_store)) { + fungible_asset::set_frozen_flag( + ®istration.dispatch_fa_transfer_ref, + registration.dispatch_deposit_fungible_store, + false + ); + }; + + let output = registration.executing_lock_or_burn_output_v1.extract(); + (output.dest_token_address, output.dest_pool_data) + } + + // ================================================================ + // | Release or Mint | + // ================================================================ + public(friend) fun start_release_or_mint( + token_pool_address: address, + sender: vector, + receiver: address, + source_amount: u256, + local_token: address, + remote_chain_selector: u64, + source_pool_address: vector, + source_pool_data: vector, + offchain_token_data: vector + ): (signer, Object) acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + registration.execution_state is ExecutionState::IDLE, + error::invalid_state(E_NOT_IN_IDLE_STATE) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_OUTPUT) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_OUTPUT) + ); + + registration.execution_state = ExecutionState::RELEASE_OR_MINT; + registration.executing_release_or_mint_input_v1.fill( + ReleaseOrMintInputV1 { + sender, + receiver, + source_amount, + local_token, + remote_chain_selector, + source_pool_address, + source_pool_data, + offchain_token_data + } + ); + + ( + object::generate_signer_for_extending(®istration.dispatch_extend_ref), + registration.dispatch_deposit_fungible_store + ) + } + + public(friend) fun finish_release_or_mint( + token_pool_address: address + ): u64 acquires TokenPoolRegistration { + let registration = get_registration_mut(token_pool_address); + + assert!( + registration.execution_state is ExecutionState::RELEASE_OR_MINT, + error::invalid_state(E_NOT_IN_RELEASE_OR_MINT_STATE) + ); + assert!( + registration.executing_release_or_mint_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_RELEASE_OR_MINT_INPUT) + ); + assert!( + registration.executing_release_or_mint_output_v1.is_some(), + error::invalid_state(E_MISSING_RELEASE_OR_MINT_OUTPUT) + ); + assert!( + registration.executing_lock_or_burn_input_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_INPUT) + ); + assert!( + registration.executing_lock_or_burn_output_v1.is_none(), + error::invalid_state(E_NON_EMPTY_LOCK_OR_BURN_OUTPUT) + ); + + registration.execution_state = ExecutionState::IDLE; + + // the dispatch callback is passed a fungible_asset::TransferRef reference which could allow the store to be frozen, + // causing future deposit/withdraw callbacks to fail. note that this fungible store is only used as part of the dispatch + // mechanism. + // ref: https://github.com/aptos-labs/aptos-core/blob/7fc73792e9db11462c9a42038c4a9eb41cc00192/aptos-move/framework/aptos-framework/sources/fungible_asset.move#L936 + if (fungible_asset::is_frozen(registration.dispatch_deposit_fungible_store)) { + fungible_asset::set_frozen_flag( + ®istration.dispatch_fa_transfer_ref, + registration.dispatch_deposit_fungible_store, + false + ); + }; + + let output = registration.executing_release_or_mint_output_v1.extract(); + + output.destination_amount + } + + public(friend) fun lock_or_burn_v2( + token_pool_address: address, + fa: fungible_asset::FungibleAsset, + sender: address, + remote_chain_selector: u64, + receiver: vector + ): (vector, vector) acquires TokenPoolRegistrationV2 { + let pool_config = &TokenPoolRegistrationV2[token_pool_address]; + let input = LockOrBurnInputV1 { sender, remote_chain_selector, receiver }; + + (pool_config.callbacks.lock_or_burn) + (fa, input) + } + + public(friend) fun release_or_mint_v2( + token_pool_address: address, + sender: vector, + receiver: address, + source_amount: u256, + local_token: address, + remote_chain_selector: u64, + source_pool_address: vector, + source_pool_data: vector, + offchain_token_data: vector + ): (FungibleAsset, u64) acquires TokenPoolRegistrationV2 { + let pool_config = &TokenPoolRegistrationV2[token_pool_address]; + let input = + ReleaseOrMintInputV1 { + sender, + receiver, + source_amount, + local_token, + remote_chain_selector, + source_pool_address, + source_pool_data, + offchain_token_data + }; + + (pool_config.callbacks.release_or_mint) + (input) + } + + inline fun borrow_state(): &TokenAdminRegistryState { + borrow_global(state_object::object_address()) + } + + inline fun borrow_state_mut(): &mut TokenAdminRegistryState { + borrow_global_mut(state_object::object_address()) + } + + inline fun get_registration(token_pool_address: address): &TokenPoolRegistration { + freeze(get_registration_mut(token_pool_address)) + } + + inline fun get_registration_mut(token_pool_address: address) + : &mut TokenPoolRegistration { + assert!( + exists(token_pool_address), + error::invalid_argument(E_INVALID_TOKEN_POOL) + ); + borrow_global_mut(token_pool_address) + } + + // ================================================================ + // | MCMS Entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: Object + ): option::Option acquires TokenAdminRegistryState, TokenPoolRegistration, TokenPoolRegistrationV2 { + let (caller, function, data) = + mcms_registry::get_callback_params(@ccip, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"set_pool") { + let local_token = bcs_stream::deserialize_address(&mut stream); + let token_pool_address = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_pool(&caller, local_token, token_pool_address) + } else if (function_bytes == b"propose_administrator") { + let local_token = bcs_stream::deserialize_address(&mut stream); + let administrator = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + propose_administrator(&caller, local_token, administrator) + } else if (function_bytes == b"transfer_admin_role") { + let local_token = bcs_stream::deserialize_address(&mut stream); + let new_admin = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + transfer_admin_role(&caller, local_token, new_admin) + } else if (function_bytes == b"accept_admin_role") { + let local_token = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + accept_admin_role(&caller, local_token) + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint(publisher: &signer) { + mcms_registry::register_entrypoint( + publisher, string::utf8(b"token_admin_registry"), McmsCallback {} + ); + } + + #[test_only] + public fun init_module_for_testing(publisher: &signer) { + init_module(publisher); + } + + #[test_only] + public fun get_token_unregistered_events(): vector acquires TokenAdminRegistryState { + event::emitted_events_by_handle( + &borrow_state().token_unregistered_events + ) + } + + #[test_only] + fun insert_token_addresses_for_test( + token_addresses: vector
+ ) acquires TokenAdminRegistryState { + let state = borrow_state_mut(); + + token_addresses.for_each( + |token_address| { + state.token_configs.add( + token_address, + TokenConfig { + token_pool_address: @0x0, + administrator: @0x0, + pending_administrator: @0x0 + } + ); + } + ); + } + + #[test(publisher = @ccip)] + fun test_get_all_configured_tokens(publisher: &signer) acquires TokenAdminRegistryState { + state_object::init_module_for_testing(publisher); + init_module_for_testing(publisher); + + insert_token_addresses_for_test(vector[@0x1, @0x2, @0x3]); + + let (res, next_key, has_more) = get_all_configured_tokens(@0x0, 0); + assert!(res.length() == 0); + assert!(next_key == @0x0); + assert!(has_more); + + let (res, next_key, has_more) = get_all_configured_tokens(@0x0, 3); + assert!(res.length() == 3); + assert!(vector[@0x1, @0x2, @0x3] == res); + assert!(next_key == @0x3); + assert!(!has_more); + } + + #[test(publisher = @ccip)] + fun test_get_all_configured_tokens_edge_cases( + publisher: &signer + ) acquires TokenAdminRegistryState { + state_object::init_module_for_testing(publisher); + init_module_for_testing(publisher); + + // Test case 1: Empty state + let (res, next_key, has_more) = get_all_configured_tokens(@0x0, 1); + assert!(res.length() == 0); + assert!(next_key == @0x0); + assert!(!has_more); + + // Test case 2: Single token + insert_token_addresses_for_test(vector[@0x1]); + let (res, _next_key, has_more) = get_all_configured_tokens(@0x0, 1); + assert!(res.length() == 1); + assert!(res[0] == @0x1); + assert!(!has_more); + + // Test case 3: Start from middle + insert_token_addresses_for_test(vector[@0x2, @0x3]); + let (res, _next_key, has_more) = get_all_configured_tokens(@0x1, 2); + assert!(res.length() == 2); + assert!(res[0] == @0x2); + assert!(res[1] == @0x3); + assert!(!has_more); + + // Test case 4: Request more than available + let (res, _next_key, has_more) = get_all_configured_tokens(@0x0, 5); + assert!(res.length() == 3); + assert!(res[0] == @0x1); + assert!(res[1] == @0x2); + assert!(res[2] == @0x3); + assert!(!has_more); + } + + #[test(publisher = @ccip)] + fun test_get_all_configured_tokens_pagination( + publisher: &signer + ) acquires TokenAdminRegistryState { + state_object::init_module_for_testing(publisher); + init_module_for_testing(publisher); + + insert_token_addresses_for_test(vector[@0x1, @0x2, @0x3, @0x4, @0x5]); + + // Test pagination with different chunk sizes + let current_key = @0x0; + let total_tokens = vector[]; + + // First page: get 2 tokens + let (res, next_key, more) = get_all_configured_tokens(current_key, 2); + assert!(res.length() == 2); + assert!(res[0] == @0x1); + assert!(res[1] == @0x2); + assert!(more); + current_key = next_key; + total_tokens.append(res); + + // Second page: get 2 more tokens + let (res, next_key, more) = get_all_configured_tokens(current_key, 2); + assert!(res.length() == 2); + assert!(res[0] == @0x3); + assert!(res[1] == @0x4); + assert!(more); + current_key = next_key; + total_tokens.append(res); + + // Last page: get remaining token + let (res, _next_key, more) = get_all_configured_tokens(current_key, 2); + assert!(res.length() == 1); + assert!(res[0] == @0x5); + assert!(!more); + total_tokens.append(res); + + // Verify we got all tokens in order + assert!(total_tokens.length() == 5); + assert!(total_tokens[0] == @0x1); + assert!(total_tokens[1] == @0x2); + assert!(total_tokens[2] == @0x3); + assert!(total_tokens[3] == @0x4); + assert!(total_tokens[4] == @0x5); + } + + #[test(publisher = @ccip)] + fun test_get_all_configured_tokens_non_existent( + publisher: &signer + ) acquires TokenAdminRegistryState { + state_object::init_module_for_testing(publisher); + init_module_for_testing(publisher); + + insert_token_addresses_for_test(vector[@0x1, @0x2, @0x3]); + + // Test starting from non-existent key + let (res, next_key, has_more) = get_all_configured_tokens(@0x4, 1); + assert!(res.length() == 0); + assert!(next_key == @0x4); + assert!(!has_more); + + // Test starting from key between existing tokens + let (res, _next_key, has_more) = get_all_configured_tokens(@0x1, 1); + assert!(res.length() == 1); + assert!(res[0] == @0x2); + assert!(has_more); + } +} +` + +/** sources/util/address.move */ +export const CCIP_UTIL_ADDRESS_MOVE = `module ccip::address { + + const E_ZERO_ADDRESS_NOT_ALLOWED: u64 = 1; + + public fun assert_non_zero_address_vector(addr: &vector) { + assert!(!addr.is_empty(), E_ZERO_ADDRESS_NOT_ALLOWED); + + let is_zero_address = addr.all(|byte| *byte == 0); + assert!(!is_zero_address, E_ZERO_ADDRESS_NOT_ALLOWED); + } + + public fun assert_non_zero_address(addr: address) { + assert!(addr != @0x0, E_ZERO_ADDRESS_NOT_ALLOWED); + } +} +` diff --git a/ccip-sdk/src/token-admin/aptos/bytecodes/lock_release_token_pool.ts b/ccip-sdk/src/token-admin/aptos/bytecodes/lock_release_token_pool.ts new file mode 100644 index 00000000..bfd8bcbe --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/bytecodes/lock_release_token_pool.ts @@ -0,0 +1,994 @@ +/** + * LockReleaseTokenPool Move package source files. + * + * Source: chainlink-aptos contracts/ccip/ccip_token_pools/lock_release_token_pool + * AptosFramework rev: 16beac69835f3a71564c96164a606a23f259099a + * ChainlinkCCIP + MCMS: embedded as local dependencies + * + * For standard Aptos Fungible Asset tokens using lock/release (custody-based) mechanism. + * Tokens are locked in the pool on outbound and released on inbound. + * + * Vendored as source (not compiled bytecodes) because Aptos Move modules + * must be compiled with the deployer's address at deploy time. + * + * Lazy-loaded via dynamic import() — same pattern as EVM BurnMintERC20 bytecode. + */ + +export const LOCK_RELEASE_POOL_MOVE_TOML = `[package] +name = "LockReleaseTokenPool" +version = "1.0.0" +authors = [] + +[addresses] +ccip = "_" +ccip_token_pool = "_" +lock_release_token_pool = "_" +mcms = "_" +mcms_register_entrypoints = "_" +lock_release_local_token = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +ChainlinkCCIP = { local = "../ccip" } +CCIPTokenPool = { local = "../token_pool" } +` + +export const LOCK_RELEASE_TOKEN_POOL_MOVE = `module lock_release_token_pool::lock_release_token_pool { + use std::account::{Self, SignerCapability}; + use std::error; + use std::fungible_asset::{ + Self, + FungibleAsset, + Metadata, + TransferRef, + FungibleStore + }; + use std::dispatchable_fungible_asset; + use std::primary_fungible_store; + use std::object::{Self, Object, ObjectCore}; + use std::option::{Self, Option}; + use std::signer; + use std::string::{Self, String}; + + use ccip::token_admin_registry::{Self, LockOrBurnInputV1, ReleaseOrMintInputV1}; + use ccip_token_pool::ownable; + use ccip_token_pool::rate_limiter; + use ccip_token_pool::token_pool; + + use mcms::mcms_registry; + use mcms::bcs_stream; + + const STORE_OBJECT_SEED: vector = b"CcipLockReleaseTokenPool"; + + struct LockReleaseTokenPoolDeployment has key { + store_signer_cap: SignerCapability, + ownable_state: ownable::OwnableState, + token_pool_state: token_pool::TokenPoolState + } + + struct LockReleaseTokenPoolState has key, store { + store_signer_cap: SignerCapability, + ownable_state: ownable::OwnableState, + token_pool_state: token_pool::TokenPoolState, + store_signer_address: address, + transfer_ref: Option, + rebalancer: address + } + + const E_NOT_PUBLISHER: u64 = 1; + const E_ALREADY_INITIALIZED: u64 = 2; + const E_INVALID_FUNGIBLE_ASSET: u64 = 3; + const E_INVALID_ARGUMENTS: u64 = 4; + const E_UNKNOWN_FUNCTION: u64 = 5; + const E_LOCAL_TOKEN_MISMATCH: u64 = 6; + const E_DISPATCHABLE_TOKEN_WITHOUT_TRANSFER_REF: u64 = 7; + const E_UNAUTHORIZED: u64 = 8; + const E_INSUFFICIENT_LIQUIDITY: u64 = 9; + const E_TRANSFER_REF_NOT_SET: u64 = 10; + + // ================================================================ + // | Init | + // ================================================================ + #[view] + public fun type_and_version(): String { + string::utf8(b"LockReleaseTokenPool 1.6.0") + } + + fun init_module(publisher: &signer) { + // register the pool on deployment, because in the case of object code deployment, + // this is the only time we have a signer ref to @ccip_lock_release_pool. + assert!( + object::object_exists(@lock_release_local_token), + error::invalid_argument(E_INVALID_FUNGIBLE_ASSET) + ); + let metadata = object::address_to_object(@lock_release_local_token); + + // create an Account on the object for event handles. + account::create_account_if_does_not_exist(@lock_release_token_pool); + + // the name of this module. if incorrect, callbacks will fail to be registered and + // register_pool will revert. + let token_pool_module_name = b"lock_release_token_pool"; + + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher, token_pool_module_name); + }; + + // Register V2 pool with closure-based callbacks + register_v2_callbacks(publisher); + + // create a resource account to be the owner of the primary FungibleStore we will use. + let (store_signer, store_signer_cap) = + account::create_resource_account(publisher, STORE_OBJECT_SEED); + + // make sure this is a valid fungible asset that is primary fungible store enabled, + // ie. created with primary_fungible_store::create_primary_store_enabled_fungible_asset + primary_fungible_store::ensure_primary_store_exists( + signer::address_of(&store_signer), metadata + ); + + move_to( + publisher, + LockReleaseTokenPoolDeployment { + store_signer_cap, + ownable_state: ownable::new(&store_signer, @lock_release_token_pool), + token_pool_state: token_pool::initialize( + &store_signer, @lock_release_local_token, vector[] + ) + } + ); + } + + /// Tokens that have dynamic dispatch enabled must provide a \`TransferRef\` + /// Tokens that do not have dynamic dispatch enabled can provide \`option::none()\` + /// You can still provide a transfer ref for tokens that don't have dynamic dispatch enabled + /// if you choose to do so. + public fun initialize( + caller: &signer, transfer_ref: Option, rebalancer: address + ) acquires LockReleaseTokenPoolDeployment { + assert_can_initialize(signer::address_of(caller)); + + assert!( + exists(@lock_release_token_pool), + error::invalid_argument(E_ALREADY_INITIALIZED) + ); + + let LockReleaseTokenPoolDeployment { + store_signer_cap, + ownable_state, + token_pool_state + } = move_from(@lock_release_token_pool); + + let store_signer = account::create_signer_with_capability(&store_signer_cap); + let store_signer_address = signer::address_of(&store_signer); + + // If transfer ref is not provided, tokens with dynamic dispatch on deposit and withdraw + // are not allowed for this pool + if (transfer_ref.is_none()) { + let store = + primary_fungible_store::primary_store( + store_signer_address, + token_pool::get_fa_metadata(&token_pool_state) + ); + assert!( + fungible_asset::deposit_dispatch_function(store).is_none() + && fungible_asset::withdraw_dispatch_function(store).is_none(), + E_DISPATCHABLE_TOKEN_WITHOUT_TRANSFER_REF + ); + } else { + let metadata = object::address_to_object(@lock_release_local_token); + let transfer_ref_metadata = + fungible_asset::transfer_ref_metadata(transfer_ref.borrow()); + assert!(metadata == transfer_ref_metadata, E_LOCAL_TOKEN_MISMATCH); + }; + + let pool = LockReleaseTokenPoolState { + store_signer_cap, + ownable_state, + token_pool_state, + store_signer_address, + transfer_ref, + rebalancer + }; + move_to(&store_signer, pool); + } + + public fun register_v2_callbacks(publisher: &signer) { + assert!( + signer::address_of(publisher) == @lock_release_token_pool, + error::permission_denied(E_NOT_PUBLISHER) + ); + token_admin_registry::register_pool_v2( + publisher, + @lock_release_local_token, + lock_or_burn_v2, + release_or_mint_v2 + ); + } + + // ================================================================ + // | Exposing token_pool functions | + // ================================================================ + #[view] + public fun get_token(): address acquires LockReleaseTokenPoolState { + token_pool::get_token(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_router(): address { + token_pool::get_router() + } + + #[view] + public fun get_token_decimals(): u8 acquires LockReleaseTokenPoolState { + token_pool::get_token_decimals(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_remote_pools( + remote_chain_selector: u64 + ): vector> acquires LockReleaseTokenPoolState { + token_pool::get_remote_pools( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun is_remote_pool( + remote_chain_selector: u64, remote_pool_address: vector + ): bool acquires LockReleaseTokenPoolState { + token_pool::is_remote_pool( + &borrow_pool().token_pool_state, + remote_chain_selector, + remote_pool_address + ) + } + + #[view] + public fun get_remote_token( + remote_chain_selector: u64 + ): vector acquires LockReleaseTokenPoolState { + let pool = borrow_pool(); + token_pool::get_remote_token(&pool.token_pool_state, remote_chain_selector) + } + + public entry fun add_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::add_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + public entry fun remove_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::remove_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + inline fun has_transfer_ref(pool: &LockReleaseTokenPoolState): bool { + pool.transfer_ref.is_some() + } + + #[view] + public fun pool_primary_store(): Object acquires LockReleaseTokenPoolState { + let pool = borrow_pool(); + primary_fungible_store::primary_store( + pool.store_signer_address, + token_pool::get_fa_metadata(&pool.token_pool_state) + ) + } + + inline fun pool_primary_store_inlined( + pool: &LockReleaseTokenPoolState + ): Object { + primary_fungible_store::primary_store( + pool.store_signer_address, + token_pool::get_fa_metadata(&pool.token_pool_state) + ) + } + + #[view] + public fun balance(): u64 acquires LockReleaseTokenPoolState { + fungible_asset::balance(pool_primary_store()) + } + + #[view] + public fun derived_balance(): u64 acquires LockReleaseTokenPoolState { + dispatchable_fungible_asset::derived_balance(pool_primary_store()) + } + + #[view] + public fun is_supported_chain( + remote_chain_selector: u64 + ): bool acquires LockReleaseTokenPoolState { + let pool = borrow_pool(); + token_pool::is_supported_chain(&pool.token_pool_state, remote_chain_selector) + } + + #[view] + public fun get_supported_chains(): vector acquires LockReleaseTokenPoolState { + let pool = borrow_pool(); + token_pool::get_supported_chains(&pool.token_pool_state) + } + + public entry fun apply_chain_updates( + caller: &signer, + remote_chain_selectors_to_remove: vector, + remote_chain_selectors_to_add: vector, + remote_pool_addresses_to_add: vector>>, + remote_token_addresses_to_add: vector> + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::apply_chain_updates( + &mut pool.token_pool_state, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } + + #[view] + public fun get_allowlist_enabled(): bool acquires LockReleaseTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist_enabled(&pool.token_pool_state) + } + + public entry fun set_allowlist_enabled( + caller: &signer, enabled: bool + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::set_allowlist_enabled(&mut pool.token_pool_state, enabled); + } + + #[view] + public fun get_allowlist(): vector
acquires LockReleaseTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist(&pool.token_pool_state) + } + + public entry fun apply_allowlist_updates( + caller: &signer, removes: vector
, adds: vector
+ ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::apply_allowlist_updates(&mut pool.token_pool_state, removes, adds); + } + + // ================================================================ + // | Lock/Release | + // ================================================================ + + // the callback proof type used as authentication to retrieve and set input and output arguments. + struct CallbackProof has drop {} + + public fun lock_or_burn( + _store: Object, fa: FungibleAsset, _transfer_ref: &TransferRef + ) acquires LockReleaseTokenPoolState { + // retrieve the input for this lock or burn operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_lock_or_burn_input_v1( + @lock_release_token_pool, CallbackProof {} + ); + + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + // This method validates various aspects of the lock or burn operation. If any of the + // validations fail, the transaction will abort. + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + // Construct lock_or_burn output before we lose access to fa + let dest_pool_data = token_pool::encode_local_decimals(&pool.token_pool_state); + let metadata = token_pool::get_fa_metadata(&pool.token_pool_state); + let store = + primary_fungible_store::primary_store(pool.store_signer_address, metadata); + + // Lock the funds in the pool + if (has_transfer_ref(pool)) { + let transfer_ref = pool.transfer_ref.borrow(); + fungible_asset::deposit_with_ref(transfer_ref, store, fa); + } else { + fungible_asset::deposit(store, fa); + }; + + // set the output for this lock or burn operation. + token_admin_registry::set_lock_or_burn_output_v1( + @lock_release_token_pool, + CallbackProof {}, + dest_token_address, + dest_pool_data + ); + + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + } + + public fun release_or_mint( + _store: Object, _amount: u64, _transfer_ref: &TransferRef + ): FungibleAsset acquires LockReleaseTokenPoolState { + // retrieve the input for this release or mint operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_release_or_mint_input_v1( + @lock_release_token_pool, CallbackProof {} + ); + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + let store_signer = account::create_signer_with_capability(&pool.store_signer_cap); + let metadata = token_pool::get_fa_metadata(&pool.token_pool_state); + let store = + primary_fungible_store::primary_store(pool.store_signer_address, metadata); + + // Withdraw the amount from the store for release. this will revert if the store has insufficient balance. + let fa = + if (has_transfer_ref(pool)) { + let transfer_ref = pool.transfer_ref.borrow(); + fungible_asset::withdraw_with_ref(transfer_ref, store, local_amount) + } else { + fungible_asset::withdraw(&store_signer, store, local_amount) + }; + + // set the output for this release or mint operation. + token_admin_registry::set_release_or_mint_output_v1( + @lock_release_token_pool, CallbackProof {}, local_amount + ); + + let recipient = token_admin_registry::get_release_or_mint_receiver(&input); + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + recipient, + local_amount, + remote_chain_selector + ); + + // return the withdrawn fungible asset. + fa + } + + #[persistent] + fun lock_or_burn_v2( + fa: FungibleAsset, input: LockOrBurnInputV1 + ): (vector, vector) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + // Lock the funds in the pool + primary_fungible_store::deposit(pool.store_signer_address, fa); + + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + + (dest_token_address, token_pool::encode_local_decimals(&pool.token_pool_state)) + } + + #[persistent] + fun release_or_mint_v2( + input: ReleaseOrMintInputV1 + ): (FungibleAsset, u64) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + let store_signer = account::create_signer_with_capability(&pool.store_signer_cap); + let metadata = token_pool::get_fa_metadata(&pool.token_pool_state); + + // Withdraw the amount from the store for release + let fa = primary_fungible_store::withdraw(&store_signer, metadata, local_amount); + + let recipient = token_admin_registry::get_release_or_mint_receiver(&input); + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + recipient, + local_amount, + remote_chain_selector + ); + + (fa, local_amount) + } + + // ================================================================ + // | Rate limit config | + // ================================================================ + public entry fun set_chain_rate_limiter_configs( + caller: &signer, + remote_chain_selectors: vector, + outbound_is_enableds: vector, + outbound_capacities: vector, + outbound_rates: vector, + inbound_is_enableds: vector, + inbound_capacities: vector, + inbound_rates: vector + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + let number_of_chains = remote_chain_selectors.length(); + + assert!( + number_of_chains == outbound_is_enableds.length() + && number_of_chains == outbound_capacities.length() + && number_of_chains == outbound_rates.length() + && number_of_chains == inbound_is_enableds.length() + && number_of_chains == inbound_capacities.length() + && number_of_chains == inbound_rates.length(), + error::invalid_argument(E_INVALID_ARGUMENTS) + ); + + for (i in 0..number_of_chains) { + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selectors[i], + outbound_is_enableds[i], + outbound_capacities[i], + outbound_rates[i], + inbound_is_enableds[i], + inbound_capacities[i], + inbound_rates[i] + ); + }; + } + + public entry fun set_chain_rate_limiter_config( + caller: &signer, + remote_chain_selector: u64, + outbound_is_enabled: bool, + outbound_capacity: u64, + outbound_rate: u64, + inbound_is_enabled: bool, + inbound_capacity: u64, + inbound_rate: u64 + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } + + #[view] + public fun get_current_inbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires LockReleaseTokenPoolState { + token_pool::get_current_inbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun get_current_outbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires LockReleaseTokenPoolState { + token_pool::get_current_outbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + // ================================================================ + // | Liquidity Management | + // ================================================================ + + /// @notice Adds liquidity to the pool. The tokens should be sent before calling this function + /// @param amount The amount of liquidity to add + public entry fun provide_liquidity( + caller: &signer, amount: u64 + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + let caller_address = assert_is_rebalancer(caller, pool); + + let (caller_store, pool_store) = get_caller_and_pool_stores( + caller_address, pool + ); + + transfer_tokens(pool, caller, caller_store, pool_store, amount); + + token_pool::emit_liquidity_added( + &mut pool.token_pool_state, caller_address, amount + ); + } + + /// @notice Removes liquidity from the pool + /// @param amount The amount of liquidity to remove + public entry fun withdraw_liquidity( + caller: &signer, amount: u64 + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + let caller_address = assert_is_rebalancer(caller, pool); + + let (caller_store, pool_store) = get_caller_and_pool_stores( + caller_address, pool + ); + assert!(fungible_asset::balance(pool_store) >= amount, E_INSUFFICIENT_LIQUIDITY); + + let store_signer = account::create_signer_with_capability(&pool.store_signer_cap); + + transfer_tokens( + pool, + &store_signer, + pool_store, + caller_store, + amount + ); + + token_pool::emit_liquidity_removed( + &mut pool.token_pool_state, caller_address, amount + ); + } + + inline fun assert_is_rebalancer( + caller: &signer, pool: &LockReleaseTokenPoolState + ): address { + let caller_address = signer::address_of(caller); + assert!(caller_address == pool.rebalancer, E_UNAUTHORIZED); + caller_address + } + + inline fun get_caller_and_pool_stores( + caller_address: address, pool: &LockReleaseTokenPoolState + ): (Object, Object) { + let metadata = token_pool::get_fa_metadata(&pool.token_pool_state); + let caller_store = + primary_fungible_store::ensure_primary_store_exists( + caller_address, metadata + ); + let pool_store = pool_primary_store_inlined(pool); + (caller_store, pool_store) + } + + inline fun transfer_tokens( + pool: &LockReleaseTokenPoolState, + from: &signer, + from_store: Object, + to_store: Object, + amount: u64 + ) { + if (has_transfer_ref(pool)) { + let transfer_ref = pool.transfer_ref.borrow(); + fungible_asset::transfer_with_ref(transfer_ref, from_store, to_store, amount); + } else { + fungible_asset::transfer(from, from_store, to_store, amount); + }; + } + + public entry fun set_rebalancer( + caller: &signer, rebalancer: address + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + let old_rebalancer = pool.rebalancer; + pool.rebalancer = rebalancer; + + token_pool::emit_rebalancer_set( + &mut pool.token_pool_state, old_rebalancer, rebalancer + ); + } + + #[view] + public fun get_rebalancer(): address acquires LockReleaseTokenPoolState { + borrow_pool().rebalancer + } + + // ================================================================ + // | Ref Migration | + // ================================================================ + public fun migrate_transfer_ref(caller: &signer): TransferRef acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + assert!(pool.transfer_ref.is_some(), E_TRANSFER_REF_NOT_SET); + + pool.transfer_ref.extract() + } + + // ================================================================ + // | Storage helpers | + // ================================================================ + #[view] + public fun get_store_address(): address { + store_address() + } + + inline fun store_address(): address { + account::create_resource_address(&@lock_release_token_pool, STORE_OBJECT_SEED) + } + + fun assert_can_initialize(caller_address: address) { + if (caller_address == @lock_release_token_pool) { return }; + + if (object::is_object(@lock_release_token_pool)) { + let ccip_lock_release_pool_object = + object::address_to_object(@lock_release_token_pool); + if (caller_address == object::owner(ccip_lock_release_pool_object) + || caller_address == object::root_owner(ccip_lock_release_pool_object)) { + return + }; + }; + + abort error::permission_denied(E_NOT_PUBLISHER) + } + + inline fun borrow_pool(): &LockReleaseTokenPoolState { + borrow_global(store_address()) + } + + inline fun borrow_pool_mut(): &mut LockReleaseTokenPoolState { + borrow_global_mut(store_address()) + } + + // ================================================================ + // | Expose ownable | + // ================================================================ + #[view] + public fun owner(): address acquires LockReleaseTokenPoolState { + ownable::owner(&borrow_pool().ownable_state) + } + + #[view] + public fun has_pending_transfer(): bool acquires LockReleaseTokenPoolState { + ownable::has_pending_transfer(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_from(): Option
acquires LockReleaseTokenPoolState { + ownable::pending_transfer_from(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_to(): Option
acquires LockReleaseTokenPoolState { + ownable::pending_transfer_to(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_accepted(): Option acquires LockReleaseTokenPoolState { + ownable::pending_transfer_accepted(&borrow_pool().ownable_state) + } + + public entry fun transfer_ownership( + caller: &signer, to: address + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::transfer_ownership(caller, &mut pool.ownable_state, to) + } + + public entry fun accept_ownership(caller: &signer) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::accept_ownership(caller, &mut pool.ownable_state) + } + + public entry fun execute_ownership_transfer( + caller: &signer, to: address + ) acquires LockReleaseTokenPoolState { + let pool = borrow_pool_mut(); + ownable::execute_ownership_transfer(caller, &mut pool.ownable_state, to) + } + + // ================================================================ + // | MCMS entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: object::Object + ): option::Option acquires LockReleaseTokenPoolState { + let (caller, function, data) = + mcms_registry::get_callback_params(@lock_release_token_pool, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"add_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + add_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"remove_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + remove_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"apply_chain_updates") { + let remote_chain_selectors_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_chain_selectors_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_pool_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, + |stream| bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ) + ); + let remote_token_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_chain_updates( + &caller, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } else if (function_bytes == b"set_allowlist_enabled") { + let enabled = bcs_stream::deserialize_bool(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_allowlist_enabled(&caller, enabled); + } else if (function_bytes == b"apply_allowlist_updates") { + let removes = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let adds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_allowlist_updates(&caller, removes, adds); + } else if (function_bytes == b"set_chain_rate_limiter_configs") { + let remote_chain_selectors = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let outbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let inbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_configs( + &caller, + remote_chain_selectors, + outbound_is_enableds, + outbound_capacities, + outbound_rates, + inbound_is_enableds, + inbound_capacities, + inbound_rates + ); + } else if (function_bytes == b"set_chain_rate_limiter_config") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let outbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let outbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let outbound_rate = bcs_stream::deserialize_u64(&mut stream); + let inbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let inbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let inbound_rate = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_config( + &caller, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } else if (function_bytes == b"transfer_ownership") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + transfer_ownership(&caller, to); + } else if (function_bytes == b"accept_ownership") { + bcs_stream::assert_is_consumed(&stream); + accept_ownership(&caller); + } else if (function_bytes == b"execute_ownership_transfer") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + execute_ownership_transfer(&caller, to) + } else if (function_bytes == b"set_rebalancer") { + let rebalancer = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_rebalancer(&caller, rebalancer); + } else if (function_bytes == b"provide_liquidity") { + let amount = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + provide_liquidity(&caller, amount); + } else if (function_bytes == b"withdraw_liquidity") { + let amount = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + withdraw_liquidity(&caller, amount); + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint( + publisher: &signer, module_name: vector + ) { + mcms_registry::register_entrypoint( + publisher, string::utf8(module_name), McmsCallback {} + ); + } +} +` diff --git a/ccip-sdk/src/token-admin/aptos/bytecodes/managed_token.ts b/ccip-sdk/src/token-admin/aptos/bytecodes/managed_token.ts new file mode 100644 index 00000000..17a6edbe --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/bytecodes/managed_token.ts @@ -0,0 +1,1020 @@ +/** + * ManagedToken Move package source files. + * + * Source: chainlink-aptos contracts/managed_token + * AptosFramework rev: 16beac69835f3a71564c96164a606a23f259099a + * + * Vendored as source (not compiled bytecodes) because Aptos Move modules + * must be compiled with the deployer's address at deploy time. + * + * Lazy-loaded via dynamic import() — same pattern as EVM BurnMintERC20 bytecode. + */ + +/** Move.toml package manifest. */ +export const MOVE_TOML = `[package] +name = "ManagedToken" +version = "1.0.0" +authors = [] + +[addresses] +managed_token = "_" + +[dev-addresses] +# Calculated with object::create_named_object() +managed_token = "0x121dfbc38157d675d96eef0bcc54e70e9801714138ce54028b5655459c6376ee" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } + +[dev-dependencies] +` + +/** sources/allowlist.move */ +export const ALLOWLIST_MOVE = `module managed_token::allowlist { + use std::account; + use std::event::{Self, EventHandle}; + use std::error; + use std::string::{Self, String}; + + struct AllowlistState has store { + allowlist_name: String, + allowlist_enabled: bool, + allowlist: vector
, + allowlist_add_events: EventHandle, + allowlist_remove_events: EventHandle + } + + #[event] + struct AllowlistRemove has store, drop { + allowlist_name: String, + sender: address + } + + #[event] + struct AllowlistAdd has store, drop { + allowlist_name: String, + sender: address + } + + const E_ALLOWLIST_NOT_ENABLED: u64 = 1; + + public fun new(event_account: &signer, allowlist: vector
): AllowlistState { + new_with_name(event_account, allowlist, string::utf8(b"default")) + } + + public fun new_with_name( + event_account: &signer, allowlist: vector
, allowlist_name: String + ): AllowlistState { + AllowlistState { + allowlist_name, + allowlist_enabled: !allowlist.is_empty(), + allowlist, + allowlist_add_events: account::new_event_handle(event_account), + allowlist_remove_events: account::new_event_handle(event_account) + } + } + + public fun get_allowlist_enabled(state: &AllowlistState): bool { + state.allowlist_enabled + } + + public fun set_allowlist_enabled( + state: &mut AllowlistState, enabled: bool + ) { + state.allowlist_enabled = enabled; + } + + public fun get_allowlist(state: &AllowlistState): vector
{ + state.allowlist + } + + public fun is_allowed(state: &AllowlistState, sender: address): bool { + if (!state.allowlist_enabled) { + return true + }; + + state.allowlist.contains(&sender) + } + + public fun apply_allowlist_updates( + state: &mut AllowlistState, removes: vector
, adds: vector
+ ) { + removes.for_each_ref( + |remove_address| { + let (found, i) = state.allowlist.index_of(remove_address); + if (found) { + state.allowlist.swap_remove(i); + event::emit_event( + &mut state.allowlist_remove_events, + AllowlistRemove { + allowlist_name: state.allowlist_name, + sender: *remove_address + } + ); + } + } + ); + + if (!adds.is_empty()) { + assert!( + state.allowlist_enabled, + error::invalid_state(E_ALLOWLIST_NOT_ENABLED) + ); + + adds.for_each_ref( + |add_address| { + let add_address: address = *add_address; + let (found, _) = state.allowlist.index_of(&add_address); + if (add_address != @0x0 && !found) { + state.allowlist.push_back(add_address); + event::emit_event( + &mut state.allowlist_add_events, + AllowlistAdd { + allowlist_name: state.allowlist_name, + sender: add_address + } + ); + } + } + ); + } + } + + public fun destroy_allowlist(state: AllowlistState) { + let AllowlistState { + allowlist_name: _, + allowlist_enabled: _, + allowlist: _, + allowlist_add_events: add_events, + allowlist_remove_events: remove_events + } = state; + + event::destroy_handle(add_events); + event::destroy_handle(remove_events); + } + + #[test_only] + public fun new_add_event(add: address): AllowlistAdd { + AllowlistAdd { sender: add, allowlist_name: string::utf8(b"default") } + } + + #[test_only] + public fun new_remove_event(remove: address): AllowlistRemove { + AllowlistRemove { sender: remove, allowlist_name: string::utf8(b"default") } + } + + #[test_only] + public fun get_allowlist_add_events(state: &AllowlistState): &EventHandle { + &state.allowlist_add_events + } + + #[test_only] + public fun get_allowlist_remove_events(state: &AllowlistState): + &EventHandle { + &state.allowlist_remove_events + } +} + +#[test_only] +module managed_token::allowlist_test { + use std::account; + use std::event; + use std::signer; + use std::vector; + + use managed_token::allowlist::{Self, AllowlistAdd, AllowlistRemove}; + + #[test(owner = @0x0)] + fun init_empty_is_empty_and_disabled(owner: &signer) { + let state = set_up_test(owner, vector::empty()); + + assert!(!allowlist::get_allowlist_enabled(&state)); + assert!(allowlist::get_allowlist(&state).is_empty()); + + // Any address is allowed when the allowlist is disabled + assert!(allowlist::is_allowed(&state, @0x1111111111111)); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + fun init_non_empty_is_non_empty_and_enabled(owner: &signer) { + let init_allowlist = vector[@0x1, @0x2]; + + let state = set_up_test(owner, init_allowlist); + + assert!(allowlist::get_allowlist_enabled(&state)); + assert!(allowlist::get_allowlist(&state).length() == 2); + + // The given addresses are allowed + assert!(allowlist::is_allowed(&state, init_allowlist[0])); + assert!(allowlist::is_allowed(&state, init_allowlist[1])); + + // Other addresses are not allowed + assert!(!allowlist::is_allowed(&state, @0x3)); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + #[expected_failure(abort_code = 0x30001, location = allowlist)] + fun cannot_add_to_disabled_allowlist(owner: &signer) { + let state = set_up_test(owner, vector::empty()); + + let adds = vector[@0x1]; + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), adds); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + fun apply_allowlist_updates_mutates_state(owner: &signer) { + let state = set_up_test(owner, vector::empty()); + allowlist::set_allowlist_enabled(&mut state, true); + + assert!(allowlist::get_allowlist(&state).is_empty()); + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), vector::empty()); + + assert!(allowlist::get_allowlist(&state).is_empty()); + + let adds = vector[@0x1, @0x2]; + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), adds); + + assert_add_events_emitted(adds, &state); + + let removes = vector[@0x1]; + + allowlist::apply_allowlist_updates(&mut state, removes, vector::empty()); + + assert_remove_events_emitted(removes, &state); + + assert!(allowlist::get_allowlist(&state).length() == 1); + assert!(allowlist::is_allowed(&state, @0x2)); + assert!(!allowlist::is_allowed(&state, @0x1)); + + allowlist::destroy_allowlist(state); + } + + #[test(owner = @0x0)] + fun apply_allowlist_updates_removes_before_adds(owner: &signer) { + let account_to_allow = @0x1; + let state = set_up_test(owner, vector::empty()); + allowlist::set_allowlist_enabled(&mut state, true); + + let adds_and_removes = vector[account_to_allow]; + + allowlist::apply_allowlist_updates(&mut state, vector::empty(), adds_and_removes); + + assert!(allowlist::get_allowlist(&state).length() == 1); + assert!(allowlist::is_allowed(&state, account_to_allow)); + + allowlist::apply_allowlist_updates(&mut state, adds_and_removes, adds_and_removes); + + // Since removes happen before adds, the account should still be allowed + assert!(allowlist::is_allowed(&state, account_to_allow)); + + assert_remove_events_emitted(adds_and_removes, &state); + // Events don't get purged after calling event::emitted_events so we'll have + // both the first and the second add event in the emitted events + adds_and_removes.push_back(account_to_allow); + assert_add_events_emitted(adds_and_removes, &state); + + allowlist::destroy_allowlist(state); + } + + inline fun assert_add_events_emitted( + added_addresses: vector
, state: &allowlist::AllowlistState + ) { + let expected = + added_addresses.map:: (|add| allowlist::new_add_event(add)); + let got = + event::emitted_events_by_handle( + allowlist::get_allowlist_add_events(state) + ); + let number_of_adds = expected.length(); + + // Assert that exactly one event was emitted for each add + assert!(got.length() == number_of_adds); + + // Assert that the emitted events match the expected events + for (i in 0..number_of_adds) { + assert!(expected.borrow(i) == got.borrow(i)); + } + } + + inline fun assert_remove_events_emitted( + added_addresses: vector
, state: &allowlist::AllowlistState + ) { + let expected = + added_addresses.map:: (|add| allowlist::new_remove_event( + add + )); + let got = + event::emitted_events_by_handle( + allowlist::get_allowlist_remove_events(state) + ); + let number_of_adds = expected.length(); + + // Assert that exactly one event was emitted for each add + assert!(got.length() == number_of_adds); + + // Assert that the emitted events match the expected events + for (i in 0..number_of_adds) { + assert!(expected.borrow(i) == got.borrow(i)); + } + } + + inline fun set_up_test(owner: &signer, allowlist: vector
): + allowlist::AllowlistState { + account::create_account_for_test(signer::address_of(owner)); + + allowlist::new(owner, allowlist) + } +} +` + +/** sources/ownable.move */ +export const OWNABLE_MOVE = `/// This module implements an Ownable component similar to Ownable2Step.sol for managing +/// object ownership. +/// +/// Due to Aptos's security model requiring the original owner's signer for 0x1::object::transfer, +/// this implementation uses a 3-step ownership transfer flow: +/// +/// 1. Initial owner calls transfer_ownership with the new owner's address +/// 2. Pending owner calls accept_ownership to confirm the transfer +/// 3. Initial owner calls execute_ownership_transfer to complete the transfer +/// +/// The execute_ownership_transfer function requires a signer in order to perform the +/// object transfer, while other operations only require the caller address to maintain the +/// principle of least privilege. +/// +/// Note that direct ownership transfers via 0x1::object::transfer are still possible. +/// This module handles such cases gracefully by reading the current owner directly +/// from the object. +module managed_token::ownable { + use std::account; + use std::error; + use std::event::{Self, EventHandle}; + use std::object::{Self, Object, ObjectCore}; + use std::option::{Self, Option}; + use std::signer; + + struct OwnableState has store { + target_object: Object, + pending_transfer: Option, + ownership_transfer_requested_events: EventHandle, + ownership_transfer_accepted_events: EventHandle, + ownership_transferred_events: EventHandle + } + + struct PendingTransfer has store, drop { + from: address, + to: address, + accepted: bool + } + + const E_MUST_BE_PROPOSED_OWNER: u64 = 1; + const E_CANNOT_TRANSFER_TO_SELF: u64 = 2; + const E_ONLY_CALLABLE_BY_OWNER: u64 = 3; + const E_PROPOSED_OWNER_MISMATCH: u64 = 4; + const E_OWNER_CHANGED: u64 = 5; + const E_NO_PENDING_TRANSFER: u64 = 6; + const E_TRANSFER_NOT_ACCEPTED: u64 = 7; + const E_TRANSFER_ALREADY_ACCEPTED: u64 = 8; + + #[event] + struct OwnershipTransferRequested has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferAccepted has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferred has store, drop { + from: address, + to: address + } + + public fun new(event_account: &signer, object_address: address): OwnableState { + let new_state = OwnableState { + target_object: object::address_to_object(object_address), + pending_transfer: option::none(), + ownership_transfer_requested_events: account::new_event_handle(event_account), + ownership_transfer_accepted_events: account::new_event_handle(event_account), + ownership_transferred_events: account::new_event_handle(event_account) + }; + + new_state + } + + public fun owner(state: &OwnableState): address { + owner_internal(state) + } + + public fun has_pending_transfer(state: &OwnableState): bool { + state.pending_transfer.is_some() + } + + public fun pending_transfer_from(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.from) + } + + public fun pending_transfer_to(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.to) + } + + public fun pending_transfer_accepted(state: &OwnableState): Option { + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.accepted) + } + + inline fun owner_internal(state: &OwnableState): address { + object::owner(state.target_object) + } + + public fun transfer_ownership( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + assert!(caller_address != to, error::invalid_argument(E_CANNOT_TRANSFER_TO_SELF)); + + state.pending_transfer = option::some( + PendingTransfer { from: caller_address, to, accepted: false } + ); + + event::emit_event( + &mut state.ownership_transfer_requested_events, + OwnershipTransferRequested { from: caller_address, to } + ); + } + + public fun accept_ownership( + caller: &signer, state: &mut OwnableState + ) { + let caller_address = signer::address_of(caller); + assert!( + state.pending_transfer.is_some(), + error::permission_denied(E_NO_PENDING_TRANSFER) + ); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.borrow_mut(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == caller_address, + error::permission_denied(E_MUST_BE_PROPOSED_OWNER) + ); + assert!( + !pending_transfer.accepted, + error::invalid_state(E_TRANSFER_ALREADY_ACCEPTED) + ); + + pending_transfer.accepted = true; + + event::emit_event( + &mut state.ownership_transfer_accepted_events, + OwnershipTransferAccepted { from: pending_transfer.from, to: caller_address } + ); + } + + public fun execute_ownership_transfer( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.extract(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == to, + error::permission_denied(E_PROPOSED_OWNER_MISMATCH) + ); + assert!( + pending_transfer.accepted, error::invalid_state(E_TRANSFER_NOT_ACCEPTED) + ); + + object::transfer(caller, state.target_object, pending_transfer.to); + state.pending_transfer = option::none(); + + event::emit_event( + &mut state.ownership_transferred_events, + OwnershipTransferred { from: caller_address, to } + ); + } + + public fun assert_only_owner(caller: address, state: &OwnableState) { + assert_only_owner_internal(caller, state) + } + + inline fun assert_only_owner_internal( + caller: address, state: &OwnableState + ) { + assert!( + caller == owner_internal(state), + error::permission_denied(E_ONLY_CALLABLE_BY_OWNER) + ); + } + + public fun destroy(state: OwnableState) { + let OwnableState { + target_object: _, + pending_transfer: _, + ownership_transfer_requested_events, + ownership_transfer_accepted_events, + ownership_transferred_events + } = state; + + event::destroy_handle(ownership_transfer_requested_events); + event::destroy_handle(ownership_transfer_accepted_events); + event::destroy_handle(ownership_transferred_events); + } + + #[test_only] + public fun get_ownership_transfer_requested_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transfer_requested_events + } + + #[test_only] + public fun get_ownership_transfer_accepted_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transfer_accepted_events + } + + #[test_only] + public fun get_ownership_transferred_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transferred_events + } +} +` + +/** sources/managed_token.move */ +export const MANAGED_TOKEN_MOVE = `module managed_token::managed_token { + use std::account; + use std::event::{Self, EventHandle}; + use std::fungible_asset::{Self, BurnRef, Metadata, MintRef, TransferRef}; + use std::object::{Self, ExtendRef, Object, TransferRef as ObjectTransferRef}; + use std::option::{Option}; + use std::primary_fungible_store; + use std::signer; + use std::string::{Self, String}; + + use managed_token::allowlist::{Self, AllowlistState}; + use managed_token::ownable::{Self, OwnableState}; + + const TOKEN_STATE_SEED: vector = b"managed_token::managed_token::token_state"; + + struct TokenStateDeployment has key { + extend_ref: ExtendRef, + transfer_ref: ObjectTransferRef, + ownable_state: OwnableState, + allowed_minters: AllowlistState, + allowed_burners: AllowlistState, + initialize_events: EventHandle, + mint_events: EventHandle, + burn_events: EventHandle + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct TokenState has key { + extend_ref: ExtendRef, + transfer_ref: ObjectTransferRef, + ownable_state: OwnableState, + allowed_minters: AllowlistState, + allowed_burners: AllowlistState, + token: Object, + initialize_events: EventHandle, + mint_events: EventHandle, + burn_events: EventHandle + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct TokenMetadataRefs has key { + extend_ref: ExtendRef, + mint_ref: MintRef, + burn_ref: BurnRef, + transfer_ref: TransferRef + } + + #[event] + struct Initialize has drop, store { + publisher: address, + token: Object, + max_supply: Option, + decimals: u8, + icon: String, + project: String + } + + #[event] + struct Mint has drop, store { + minter: address, + to: address, + amount: u64 + } + + #[event] + struct Burn has drop, store { + burner: address, + from: address, + amount: u64 + } + + const E_NOT_PUBLISHER: u64 = 1; + const E_NOT_ALLOWED_MINTER: u64 = 2; + const E_NOT_ALLOWED_BURNER: u64 = 3; + const E_TOKEN_NOT_INITIALIZED: u64 = 4; + const E_TOKEN_ALREADY_INITIALIZED: u64 = 5; + const E_TOKEN_STATE_DEPLOYMENT_ALREADY_INITIALIZED: u64 = 6; + + #[view] + public fun type_and_version(): String { + string::utf8(b"ManagedToken 1.0.0") + } + + #[view] + public fun token_state_address(): address { + token_state_address_internal() + } + + inline fun token_state_address_internal(): address { + object::create_object_address(&@managed_token, TOKEN_STATE_SEED) + } + + #[view] + public fun token_metadata(): address acquires TokenState { + assert!( + exists(token_state_address_internal()), + E_TOKEN_NOT_INITIALIZED + ); + token_metadata_internal(&TokenState[token_state_address_internal()]) + } + + inline fun token_metadata_internal(state: &TokenState): address { + object::object_address(&state.token) + } + + #[view] + public fun get_allowed_minters(): vector
acquires TokenState { + allowlist::get_allowlist( + &TokenState[token_state_address_internal()].allowed_minters + ) + } + + #[view] + public fun get_allowed_burners(): vector
acquires TokenState { + allowlist::get_allowlist( + &TokenState[token_state_address_internal()].allowed_burners + ) + } + + #[view] + public fun is_minter_allowed(minter: address): bool acquires TokenState { + allowlist::is_allowed( + &TokenState[token_state_address_internal()].allowed_minters, + minter + ) + } + + #[view] + public fun is_burner_allowed(burner: address): bool acquires TokenState { + allowlist::is_allowed( + &TokenState[token_state_address_internal()].allowed_burners, + burner + ) + } + + /// \`publisher\` is the code object, deployed through object_code_deployment + fun init_module(publisher: &signer) { + assert!(object::is_object(@managed_token), E_NOT_PUBLISHER); + + // Create object owned by code object + let constructor_ref = &object::create_named_object(publisher, TOKEN_STATE_SEED); + let extend_ref = object::generate_extend_ref(constructor_ref); + let token_state_signer = &object::generate_signer(constructor_ref); + + // create an Account on the object for event handles. + account::create_account_if_does_not_exist(signer::address_of(token_state_signer)); + + let allowed_minters = + allowlist::new_with_name( + token_state_signer, vector[], string::utf8(b"minters") + ); + allowlist::set_allowlist_enabled(&mut allowed_minters, true); + + let allowed_burners = + allowlist::new_with_name( + token_state_signer, vector[], string::utf8(b"burners") + ); + allowlist::set_allowlist_enabled(&mut allowed_burners, true); + + move_to( + token_state_signer, + TokenStateDeployment { + extend_ref, + transfer_ref: object::generate_transfer_ref(constructor_ref), + ownable_state: ownable::new(token_state_signer, @managed_token), + allowed_minters, + allowed_burners, + initialize_events: account::new_event_handle(token_state_signer), + mint_events: account::new_event_handle(token_state_signer), + burn_events: account::new_event_handle(token_state_signer) + } + ); + } + + // ================================================================ + // | Only Owner Functions | + // ================================================================ + + /// Only owner of this code object can initialize a token once + public entry fun initialize( + publisher: &signer, + max_supply: Option, + name: String, + symbol: String, + decimals: u8, + icon: String, + project: String + ) acquires TokenStateDeployment { + let publisher_addr = signer::address_of(publisher); + let token_state_address = token_state_address_internal(); + + assert!( + exists(token_state_address), + E_TOKEN_STATE_DEPLOYMENT_ALREADY_INITIALIZED + ); + + let TokenStateDeployment { + extend_ref, + transfer_ref, + ownable_state, + allowed_minters, + allowed_burners, + initialize_events, + mint_events, + burn_events + } = move_from(token_state_address); + + assert_only_owner(signer::address_of(publisher), &ownable_state); + + let token_state_signer = &object::generate_signer_for_extending(&extend_ref); + + // Code object owns token state, which owns the fungible asset + // Code object => token state => fungible asset + let constructor_ref = + &object::create_named_object(token_state_signer, *symbol.bytes()); + primary_fungible_store::create_primary_store_enabled_fungible_asset( + constructor_ref, + max_supply, + name, + symbol, + decimals, + icon, + project + ); + + let metadata_object_signer = &object::generate_signer(constructor_ref); + move_to( + metadata_object_signer, + TokenMetadataRefs { + extend_ref: object::generate_extend_ref(constructor_ref), + mint_ref: fungible_asset::generate_mint_ref(constructor_ref), + burn_ref: fungible_asset::generate_burn_ref(constructor_ref), + transfer_ref: fungible_asset::generate_transfer_ref(constructor_ref) + } + ); + + let token = object::object_from_constructor_ref(constructor_ref); + + event::emit_event( + &mut initialize_events, + Initialize { + publisher: publisher_addr, + token, + max_supply, + decimals, + icon, + project + } + ); + + move_to( + token_state_signer, + TokenState { + extend_ref, + transfer_ref, + ownable_state, + allowed_minters, + allowed_burners, + token, + initialize_events, + mint_events, + burn_events + } + ); + } + + public entry fun apply_allowed_minter_updates( + caller: &signer, + minters_to_remove: vector
, + minters_to_add: vector
+ ) acquires TokenState { + let token_state = &mut TokenState[token_state_address_internal()]; + assert_only_owner(signer::address_of(caller), &token_state.ownable_state); + + allowlist::apply_allowlist_updates( + &mut token_state.allowed_minters, + minters_to_remove, + minters_to_add + ); + } + + public entry fun apply_allowed_burner_updates( + caller: &signer, + burners_to_remove: vector
, + burners_to_add: vector
+ ) acquires TokenState { + let token_state = &mut TokenState[token_state_address_internal()]; + assert_only_owner(signer::address_of(caller), &token_state.ownable_state); + + allowlist::apply_allowlist_updates( + &mut token_state.allowed_burners, + burners_to_remove, + burners_to_add + ); + } + + // ================================================================ + // | Mint/Burn Functions | + // ================================================================ + + public entry fun mint( + minter: &signer, to: address, amount: u64 + ) acquires TokenMetadataRefs, TokenState { + let minter_addr = signer::address_of(minter); + let state = &mut TokenState[token_state_address_internal()]; + assert_is_allowed_minter(minter_addr, state); + + if (amount == 0) { return }; + + primary_fungible_store::mint( + &borrow_token_metadata_refs(state).mint_ref, to, amount + ); + + event::emit_event( + &mut state.mint_events, + Mint { minter: minter_addr, to, amount } + ); + } + + public entry fun burn( + burner: &signer, from: address, amount: u64 + ) acquires TokenMetadataRefs, TokenState { + let burner_addr = signer::address_of(burner); + let state = &mut TokenState[token_state_address_internal()]; + assert_is_allowed_burner(burner_addr, state); + + if (amount == 0) { return }; + + primary_fungible_store::burn( + &borrow_token_metadata_refs(state).burn_ref, from, amount + ); + + event::emit_event( + &mut state.burn_events, + Burn { burner: burner_addr, from, amount } + ); + } + + inline fun assert_is_allowed_minter( + caller: address, state: &TokenState + ) { + assert!( + caller == owner_internal(state) + || allowlist::is_allowed(&state.allowed_minters, caller), + E_NOT_ALLOWED_MINTER + ); + } + + inline fun assert_is_allowed_burner( + caller: address, state: &TokenState + ) { + assert!( + caller == owner_internal(state) + || allowlist::is_allowed(&state.allowed_burners, caller), + E_NOT_ALLOWED_BURNER + ); + } + + inline fun borrow_token_metadata_refs(state: &TokenState): &TokenMetadataRefs { + &TokenMetadataRefs[token_metadata_internal(state)] + } + + // ================================================================ + // | Ownable State | + // ================================================================ + + #[view] + public fun owner(): address acquires TokenState { + owner_internal(&TokenState[token_state_address_internal()]) + } + + #[view] + public fun has_pending_transfer(): bool acquires TokenState { + ownable::has_pending_transfer( + &TokenState[token_state_address_internal()].ownable_state + ) + } + + #[view] + public fun pending_transfer_from(): Option
acquires TokenState { + ownable::pending_transfer_from( + &TokenState[token_state_address_internal()].ownable_state + ) + } + + #[view] + public fun pending_transfer_to(): Option
acquires TokenState { + ownable::pending_transfer_to( + &TokenState[token_state_address_internal()].ownable_state + ) + } + + #[view] + public fun pending_transfer_accepted(): Option acquires TokenState { + ownable::pending_transfer_accepted( + &TokenState[token_state_address_internal()].ownable_state + ) + } + + inline fun owner_internal(state: &TokenState): address { + ownable::owner(&state.ownable_state) + } + + fun assert_only_owner(caller: address, ownable_state: &OwnableState) { + ownable::assert_only_owner(caller, ownable_state) + } + + /// ownable::transfer_ownership checks if the caller is the owner + /// So we only extract the ownable state from the token state + public entry fun transfer_ownership(caller: &signer, to: address) acquires TokenState { + ownable::transfer_ownership( + caller, + &mut TokenState[token_state_address_internal()].ownable_state, + to + ) + } + + /// Anyone can call this as \`ownable::accept_ownership\` verifies + /// that the caller is the pending owner + public entry fun accept_ownership(caller: &signer) acquires TokenState { + ownable::accept_ownership( + caller, + &mut TokenState[token_state_address_internal()].ownable_state + ) + } + + /// ownable::execute_ownership_transfer checks if the caller is the owner + /// So we only extract the ownable state from the token state + public entry fun execute_ownership_transfer( + caller: &signer, to: address + ) acquires TokenState { + ownable::execute_ownership_transfer( + caller, + &mut TokenState[token_state_address_internal()].ownable_state, + to + ) + } + + #[test_only] + public fun init_module_for_testing(publisher: &signer) { + init_module(publisher); + } +} +` diff --git a/ccip-sdk/src/token-admin/aptos/bytecodes/managed_token_pool.ts b/ccip-sdk/src/token-admin/aptos/bytecodes/managed_token_pool.ts new file mode 100644 index 00000000..e8b7d1ba --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/bytecodes/managed_token_pool.ts @@ -0,0 +1,1976 @@ +/** + * ManagedTokenPool Move package source files. + * + * Source: chainlink-aptos contracts/ccip/ccip_token_pools/managed_token_pool + * AptosFramework rev: 16beac69835f3a71564c96164a606a23f259099a + * ChainlinkCCIP + MCMS: embedded as local dependencies + * + * Vendored as source (not compiled bytecodes) because Aptos Move modules + * must be compiled with the deployer's address at deploy time. + * + * Lazy-loaded via dynamic import() — same pattern as EVM BurnMintERC20 bytecode. + */ + +/** Move.toml for the ManagedTokenPool package. */ +export const POOL_MOVE_TOML = `[package] +name = "ManagedTokenPool" +version = "1.0.0" +authors = [] + +[addresses] +ccip = "_" +ccip_token_pool = "_" +managed_token_pool = "_" +mcms = "_" +mcms_register_entrypoints = "_" +managed_token = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +ChainlinkCCIP = { local = "../ccip" } +CCIPTokenPool = { local = "../token_pool" } +ManagedToken = { local = "../managed_token" } +` + +/** sources/managed_token_pool.move */ +export const MANAGED_TOKEN_POOL_MOVE = `module managed_token_pool::managed_token_pool { + use std::account::{Self, SignerCapability}; + use std::error; + use std::fungible_asset::{Self, FungibleAsset, Metadata, TransferRef}; + use std::primary_fungible_store; + use std::object::{Self, Object}; + use std::option::{Self, Option}; + use std::signer; + use std::string::{Self, String}; + + use managed_token::managed_token; + + use ccip::token_admin_registry::{Self, LockOrBurnInputV1, ReleaseOrMintInputV1}; + use ccip_token_pool::ownable; + use ccip_token_pool::rate_limiter; + use ccip_token_pool::token_pool; + + use mcms::mcms_registry; + use mcms::bcs_stream; + + const STORE_OBJECT_SEED: vector = b"CcipManagedTokenPool"; + + struct ManagedTokenPoolState has key, store { + store_signer_cap: SignerCapability, + ownable_state: ownable::OwnableState, + token_pool_state: token_pool::TokenPoolState, + store_signer_address: address + } + + const E_INVALID_ARGUMENTS: u64 = 1; + const E_UNKNOWN_FUNCTION: u64 = 2; + const E_NOT_PUBLISHER: u64 = 3; + + // ================================================================ + // | Init | + // ================================================================ + #[view] + public fun type_and_version(): String { + string::utf8(b"ManagedTokenPool 1.6.0") + } + + fun init_module(publisher: &signer) { + // register the pool on deployment, because in the case of object code deployment, + // this is the only time we have a signer ref to @ccip_managed_pool. + + // create an Account on the object for event handles. + account::create_account_if_does_not_exist(@managed_token_pool); + + // the name of this module. if incorrect, callbacks will fail to be registered and + // register_pool will revert. + let token_pool_module_name = b"managed_token_pool"; + + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher, token_pool_module_name); + }; + + // Register V2 pool with closure-based callbacks + register_v2_callbacks(publisher); + + // create a resource account to be the owner of the primary FungibleStore we will use. + let (store_signer, store_signer_cap) = + account::create_resource_account(publisher, STORE_OBJECT_SEED); + + let managed_token_address = managed_token::token_metadata(); + let metadata = object::address_to_object(managed_token_address); + + // make sure this is a valid fungible asset that is primary fungible store enabled, + // ie. created with primary_fungible_store::create_primary_store_enabled_fungible_asset + primary_fungible_store::ensure_primary_store_exists( + signer::address_of(&store_signer), metadata + ); + + let store_signer = account::create_signer_with_capability(&store_signer_cap); + + let pool = ManagedTokenPoolState { + ownable_state: ownable::new(&store_signer, @managed_token_pool), + store_signer_address: signer::address_of(&store_signer), + store_signer_cap, + token_pool_state: token_pool::initialize( + &store_signer, managed_token_address, vector[] + ) + }; + + move_to(&store_signer, pool); + } + + public fun register_v2_callbacks(publisher: &signer) { + assert!( + signer::address_of(publisher) == @managed_token_pool, + error::permission_denied(E_NOT_PUBLISHER) + ); + let managed_token_address = managed_token::token_metadata(); + token_admin_registry::register_pool_v2( + publisher, + managed_token_address, + lock_or_burn_v2, + release_or_mint_v2 + ); + } + + // ================================================================ + // | Exposing token_pool functions | + // ================================================================ + #[view] + public fun get_token(): address acquires ManagedTokenPoolState { + token_pool::get_token(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_router(): address { + token_pool::get_router() + } + + #[view] + public fun get_token_decimals(): u8 acquires ManagedTokenPoolState { + token_pool::get_token_decimals(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_remote_pools( + remote_chain_selector: u64 + ): vector> acquires ManagedTokenPoolState { + token_pool::get_remote_pools( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun is_remote_pool( + remote_chain_selector: u64, remote_pool_address: vector + ): bool acquires ManagedTokenPoolState { + token_pool::is_remote_pool( + &borrow_pool().token_pool_state, + remote_chain_selector, + remote_pool_address + ) + } + + #[view] + public fun get_remote_token( + remote_chain_selector: u64 + ): vector acquires ManagedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_remote_token(&pool.token_pool_state, remote_chain_selector) + } + + public entry fun add_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::add_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + public entry fun remove_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::remove_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + #[view] + public fun is_supported_chain(remote_chain_selector: u64): bool acquires ManagedTokenPoolState { + let pool = borrow_pool(); + token_pool::is_supported_chain(&pool.token_pool_state, remote_chain_selector) + } + + #[view] + public fun get_supported_chains(): vector acquires ManagedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_supported_chains(&pool.token_pool_state) + } + + public entry fun apply_chain_updates( + caller: &signer, + remote_chain_selectors_to_remove: vector, + remote_chain_selectors_to_add: vector, + remote_pool_addresses_to_add: vector>>, + remote_token_addresses_to_add: vector> + ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::apply_chain_updates( + &mut pool.token_pool_state, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } + + #[view] + public fun get_allowlist_enabled(): bool acquires ManagedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist_enabled(&pool.token_pool_state) + } + + public entry fun set_allowlist_enabled( + caller: &signer, enabled: bool + ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::set_allowlist_enabled(&mut pool.token_pool_state, enabled); + } + + #[view] + public fun get_allowlist(): vector
acquires ManagedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist(&pool.token_pool_state) + } + + public entry fun apply_allowlist_updates( + caller: &signer, removes: vector
, adds: vector
+ ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::apply_allowlist_updates(&mut pool.token_pool_state, removes, adds); + } + + // ================================================================ + // | Burn/Mint | + // ================================================================ + + // the callback proof type used as authentication to retrieve and set input and output arguments. + struct CallbackProof has drop {} + + public fun lock_or_burn( + _store: Object, fa: FungibleAsset, _transfer_ref: &TransferRef + ) acquires ManagedTokenPoolState { + // retrieve the input for this lock or burn operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_lock_or_burn_input_v1( + @managed_token_pool, CallbackProof {} + ); + + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + // This method validates various aspects of the lock or burn operation. If any of the + // validations fail, the transaction will abort. + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + // Construct lock_or_burn output before we lose access to fa + let dest_pool_data = token_pool::encode_local_decimals(&pool.token_pool_state); + + // Burn the funds + let store = + primary_fungible_store::ensure_primary_store_exists( + pool.store_signer_address, fungible_asset::asset_metadata(&fa) + ); + let signer = &account::create_signer_with_capability(&pool.store_signer_cap); + fungible_asset::deposit(store, fa); + managed_token::burn(signer, pool.store_signer_address, fa_amount); + + // set the output for this lock or burn operation. + token_admin_registry::set_lock_or_burn_output_v1( + @managed_token_pool, + CallbackProof {}, + dest_token_address, + dest_pool_data + ); + + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + } + + public fun release_or_mint( + _store: Object, _amount: u64, _transfer_ref: &TransferRef + ): FungibleAsset acquires ManagedTokenPoolState { + // retrieve the input for this release or mint operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_release_or_mint_input_v1( + @managed_token_pool, CallbackProof {} + ); + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + // Mint the amount for release. + let local_token = token_admin_registry::get_release_or_mint_local_token(&input); + let metadata = object::address_to_object(local_token); + let store = + primary_fungible_store::ensure_primary_store_exists( + pool.store_signer_address, metadata + ); + let signer = &account::create_signer_with_capability(&pool.store_signer_cap); + managed_token::mint(signer, pool.store_signer_address, local_amount); + let fa = fungible_asset::withdraw(signer, store, local_amount); + + // set the output for this release or mint operation. + token_admin_registry::set_release_or_mint_output_v1( + @managed_token_pool, CallbackProof {}, local_amount + ); + + let recipient = token_admin_registry::get_release_or_mint_receiver(&input); + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + recipient, + local_amount, + remote_chain_selector + ); + + // return the withdrawn fungible asset. + fa + } + + #[persistent] + fun lock_or_burn_v2(fa: FungibleAsset, input: LockOrBurnInputV1) + : (vector, vector) { + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + // This method validates various aspects of the lock or burn operation. If any of the + // validations fail, the transaction will abort. + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + // Burn the funds + let store = + primary_fungible_store::ensure_primary_store_exists( + pool.store_signer_address, fungible_asset::asset_metadata(&fa) + ); + let signer = &account::create_signer_with_capability(&pool.store_signer_cap); + fungible_asset::deposit(store, fa); + managed_token::burn(signer, pool.store_signer_address, fa_amount); + + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + + (dest_token_address, token_pool::encode_local_decimals(&pool.token_pool_state)) + } + + #[persistent] + fun release_or_mint_v2(input: ReleaseOrMintInputV1): (FungibleAsset, u64) { + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + // Mint the amount for release. + let local_token = token_admin_registry::get_release_or_mint_local_token(&input); + let metadata = object::address_to_object(local_token); + let store = + primary_fungible_store::ensure_primary_store_exists( + pool.store_signer_address, metadata + ); + let signer = &account::create_signer_with_capability(&pool.store_signer_cap); + managed_token::mint(signer, pool.store_signer_address, local_amount); + + // Calling into \`fungible_asset::withdraw\` works as managed token is not dispatchable + let fa = fungible_asset::withdraw(signer, store, local_amount); + let recipient = token_admin_registry::get_release_or_mint_receiver(&input); + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + recipient, + local_amount, + remote_chain_selector + ); + + (fa, local_amount) + } + + // ================================================================ + // | Rate limit config | + // ================================================================ + public entry fun set_chain_rate_limiter_configs( + caller: &signer, + remote_chain_selectors: vector, + outbound_is_enableds: vector, + outbound_capacities: vector, + outbound_rates: vector, + inbound_is_enableds: vector, + inbound_capacities: vector, + inbound_rates: vector + ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + let number_of_chains = remote_chain_selectors.length(); + + assert!( + number_of_chains == outbound_is_enableds.length() + && number_of_chains == outbound_capacities.length() + && number_of_chains == outbound_rates.length() + && number_of_chains == inbound_is_enableds.length() + && number_of_chains == inbound_capacities.length() + && number_of_chains == inbound_rates.length(), + error::invalid_argument(E_INVALID_ARGUMENTS) + ); + + for (i in 0..number_of_chains) { + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selectors[i], + outbound_is_enableds[i], + outbound_capacities[i], + outbound_rates[i], + inbound_is_enableds[i], + inbound_capacities[i], + inbound_rates[i] + ); + }; + } + + public entry fun set_chain_rate_limiter_config( + caller: &signer, + remote_chain_selector: u64, + outbound_is_enabled: bool, + outbound_capacity: u64, + outbound_rate: u64, + inbound_is_enabled: bool, + inbound_capacity: u64, + inbound_rate: u64 + ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } + + #[view] + public fun get_current_inbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires ManagedTokenPoolState { + token_pool::get_current_inbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun get_current_outbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires ManagedTokenPoolState { + token_pool::get_current_outbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + // ================================================================ + // | Storage helpers | + // ================================================================ + #[view] + public fun get_store_address(): address { + store_address() + } + + inline fun store_address(): address { + account::create_resource_address(&@managed_token_pool, STORE_OBJECT_SEED) + } + + inline fun borrow_pool(): &ManagedTokenPoolState { + borrow_global(store_address()) + } + + inline fun borrow_pool_mut(): &mut ManagedTokenPoolState { + borrow_global_mut(store_address()) + } + + // ================================================================ + // | Expose ownable | + // ================================================================ + #[view] + public fun owner(): address acquires ManagedTokenPoolState { + ownable::owner(&borrow_pool().ownable_state) + } + + #[view] + public fun has_pending_transfer(): bool acquires ManagedTokenPoolState { + ownable::has_pending_transfer(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_from(): Option
acquires ManagedTokenPoolState { + ownable::pending_transfer_from(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_to(): Option
acquires ManagedTokenPoolState { + ownable::pending_transfer_to(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_accepted(): Option acquires ManagedTokenPoolState { + ownable::pending_transfer_accepted(&borrow_pool().ownable_state) + } + + public entry fun transfer_ownership(caller: &signer, to: address) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::transfer_ownership(caller, &mut pool.ownable_state, to) + } + + public entry fun accept_ownership(caller: &signer) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::accept_ownership(caller, &mut pool.ownable_state) + } + + public entry fun execute_ownership_transfer( + caller: &signer, to: address + ) acquires ManagedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::execute_ownership_transfer(caller, &mut pool.ownable_state, to) + } + + // ================================================================ + // | MCMS entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: object::Object + ): option::Option acquires ManagedTokenPoolState { + let (caller, function, data) = + mcms_registry::get_callback_params(@managed_token_pool, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"add_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + add_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"remove_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + remove_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"apply_chain_updates") { + let remote_chain_selectors_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_chain_selectors_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_pool_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, + |stream| bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ) + ); + let remote_token_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_chain_updates( + &caller, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } else if (function_bytes == b"set_allowlist_enabled") { + let enabled = bcs_stream::deserialize_bool(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_allowlist_enabled(&caller, enabled); + } else if (function_bytes == b"apply_allowlist_updates") { + let removes = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let adds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_allowlist_updates(&caller, removes, adds); + } else if (function_bytes == b"set_chain_rate_limiter_configs") { + let remote_chain_selectors = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let outbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let inbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_configs( + &caller, + remote_chain_selectors, + outbound_is_enableds, + outbound_capacities, + outbound_rates, + inbound_is_enableds, + inbound_capacities, + inbound_rates + ); + } else if (function_bytes == b"set_chain_rate_limiter_config") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let outbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let outbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let outbound_rate = bcs_stream::deserialize_u64(&mut stream); + let inbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let inbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let inbound_rate = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_config( + &caller, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } else if (function_bytes == b"transfer_ownership") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + transfer_ownership(&caller, to); + } else if (function_bytes == b"accept_ownership") { + bcs_stream::assert_is_consumed(&stream); + accept_ownership(&caller); + } else if (function_bytes == b"execute_ownership_transfer") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + execute_ownership_transfer(&caller, to) + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint( + publisher: &signer, module_name: vector + ) { + mcms_registry::register_entrypoint( + publisher, string::utf8(module_name), McmsCallback {} + ); + } + + // ================================================================ + // | Test functions | + // ================================================================ + #[test_only] + public entry fun test_init_module(owner: &signer) { + init_module(owner); + } + + #[test_only] + /// Used for registering the pool with V2 closure-based callbacks. + public fun create_callback_proof(): CallbackProof { + CallbackProof {} + } +} +` + +/** Move.toml for the token_pool dependency package. */ +export const TOKEN_POOL_MOVE_TOML = `[package] +name = "CCIPTokenPool" +version = "1.0.0" +authors = [] + +[addresses] +ccip = "_" +ccip_token_pool = "_" +mcms = "_" +mcms_register_entrypoints = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +ChainlinkCCIP = { local = "../ccip" } +` + +/** token_pool/sources/token_pool.move */ +export const TOKEN_POOL_MOVE = `module ccip_token_pool::token_pool { + use std::account::{Self}; + use std::error; + use std::event::{Self, EventHandle}; + use std::fungible_asset::{Self, FungibleAsset, Metadata}; + use std::object::{Self, Object}; + use std::smart_table::{Self, SmartTable}; + + use ccip::address; + use ccip::eth_abi; + use ccip::token_admin_registry; + use ccip::rmn_remote; + use ccip::allowlist; + + use ccip_token_pool::rate_limiter; + use ccip_token_pool::token_pool_rate_limiter; + + const MAX_U256: u256 = + 115792089237316195423570985008687907853269984665640564039457584007913129639935; + const MAX_U64: u256 = 18446744073709551615; + + struct TokenPoolState has key, store { + allowlist_state: allowlist::AllowlistState, + fa_metadata: Object, + remote_chain_configs: SmartTable, + rate_limiter_config: token_pool_rate_limiter::RateLimitState, + locked_events: EventHandle, + released_events: EventHandle, + remote_pool_added_events: EventHandle, + remote_pool_removed_events: EventHandle, + chain_added_events: EventHandle, + chain_removed_events: EventHandle, + liquidity_added_events: EventHandle, + liquidity_removed_events: EventHandle, + rebalancer_set_events: EventHandle + } + + struct RemoteChainConfig has store, drop, copy { + remote_token_address: vector, + remote_pools: vector> + } + + #[event] + struct LockedOrBurned has store, drop { + remote_chain_selector: u64, + local_token: address, + amount: u64 + } + + #[event] + struct ReleasedOrMinted has store, drop { + remote_chain_selector: u64, + local_token: address, + recipient: address, + amount: u64 + } + + #[event] + struct AllowlistRemove has store, drop { + sender: address + } + + #[event] + struct AllowlistAdd has store, drop { + sender: address + } + + #[event] + struct RemotePoolAdded has store, drop { + remote_chain_selector: u64, + remote_pool_address: vector + } + + #[event] + struct RemotePoolRemoved has store, drop { + remote_chain_selector: u64, + remote_pool_address: vector + } + + #[event] + struct ChainAdded has store, drop { + remote_chain_selector: u64, + remote_token_address: vector + } + + #[event] + struct ChainRemoved has store, drop { + remote_chain_selector: u64 + } + + #[event] + struct LiquidityAdded has store, drop { + local_token: address, + provider: address, + amount: u64 + } + + #[event] + struct LiquidityRemoved has store, drop { + local_token: address, + provider: address, + amount: u64 + } + + #[event] + struct RebalancerSet has store, drop { + old_rebalancer: address, + new_rebalancer: address + } + + const E_NOT_ALLOWED_CALLER: u64 = 1; + const E_UNKNOWN_FUNGIBLE_ASSET: u64 = 2; + const E_UNKNOWN_REMOTE_CHAIN_SELECTOR: u64 = 3; + const E_ZERO_ADDRESS_NOT_ALLOWED: u64 = 4; + const E_REMOTE_POOL_ALREADY_ADDED: u64 = 5; + const E_UNKNOWN_REMOTE_POOL: u64 = 6; + const E_REMOTE_CHAIN_TO_ADD_MISMATCH: u64 = 7; + const E_REMOTE_CHAIN_ALREADY_EXISTS: u64 = 8; + const E_INVALID_REMOTE_CHAIN_DECIMALS: u64 = 9; + const E_INVALID_ENCODED_AMOUNT: u64 = 10; + const E_DECIMAL_OVERFLOW: u64 = 11; + const E_CURSED_CHAIN: u64 = 12; + + // ================================================================ + // | Initialize and state | + // ================================================================ + + /// This function should be called from the init_module function to ensure the events + /// are created on the correct object. + public fun initialize( + event_account: &signer, local_token: address, allowlist: vector
+ ): TokenPoolState { + let fa_metadata = object::address_to_object(local_token); + + TokenPoolState { + allowlist_state: allowlist::new(event_account, allowlist), + fa_metadata, + remote_chain_configs: smart_table::new(), + rate_limiter_config: token_pool_rate_limiter::new(event_account), + locked_events: account::new_event_handle(event_account), + released_events: account::new_event_handle(event_account), + remote_pool_added_events: account::new_event_handle(event_account), + remote_pool_removed_events: account::new_event_handle(event_account), + chain_added_events: account::new_event_handle(event_account), + chain_removed_events: account::new_event_handle(event_account), + liquidity_added_events: account::new_event_handle(event_account), + liquidity_removed_events: account::new_event_handle(event_account), + rebalancer_set_events: account::new_event_handle(event_account) + } + } + + #[view] + public fun get_router(): address { + @ccip + } + + public fun get_token(state: &TokenPoolState): address { + object::object_address(&state.fa_metadata) + } + + public fun get_token_decimals(state: &TokenPoolState): u8 { + fungible_asset::decimals(state.fa_metadata) + } + + public fun get_fa_metadata(state: &TokenPoolState): Object { + state.fa_metadata + } + + // ================================================================ + // | Remote Chains | + // ================================================================ + public fun get_supported_chains(state: &TokenPoolState): vector { + state.remote_chain_configs.keys() + } + + public fun is_supported_chain( + state: &TokenPoolState, remote_chain_selector: u64 + ): bool { + state.remote_chain_configs.contains(remote_chain_selector) + } + + public fun apply_chain_updates( + state: &mut TokenPoolState, + remote_chain_selectors_to_remove: vector, + remote_chain_selectors_to_add: vector, + remote_pool_addresses_to_add: vector>>, + remote_token_addresses_to_add: vector> + ) { + remote_chain_selectors_to_remove.for_each_ref( + |remote_chain_selector| { + let remote_chain_selector: u64 = *remote_chain_selector; + assert!( + state.remote_chain_configs.contains(remote_chain_selector), + error::invalid_argument(E_UNKNOWN_REMOTE_CHAIN_SELECTOR) + ); + state.remote_chain_configs.remove(remote_chain_selector); + + event::emit_event( + &mut state.chain_removed_events, + ChainRemoved { remote_chain_selector } + ); + } + ); + + let add_len = remote_chain_selectors_to_add.length(); + assert!( + add_len == remote_pool_addresses_to_add.length(), + error::invalid_argument(E_REMOTE_CHAIN_TO_ADD_MISMATCH) + ); + assert!( + add_len == remote_token_addresses_to_add.length(), + error::invalid_argument(E_REMOTE_CHAIN_TO_ADD_MISMATCH) + ); + + for (i in 0..add_len) { + let remote_chain_selector = remote_chain_selectors_to_add[i]; + assert!( + !state.remote_chain_configs.contains(remote_chain_selector), + error::invalid_argument(E_REMOTE_CHAIN_ALREADY_EXISTS) + ); + let remote_pool_addresses = remote_pool_addresses_to_add[i]; + let remote_token_address = remote_token_addresses_to_add[i]; + address::assert_non_zero_address_vector(&remote_token_address); + + let remote_chain_config = RemoteChainConfig { + remote_token_address, + remote_pools: vector[] + }; + + remote_pool_addresses.for_each( + |remote_pool_address| { + let remote_pool_address: vector = remote_pool_address; + address::assert_non_zero_address_vector(&remote_pool_address); + + let (found, _) = + remote_chain_config.remote_pools.index_of(&remote_pool_address); + assert!( + !found, error::invalid_argument(E_REMOTE_POOL_ALREADY_ADDED) + ); + + remote_chain_config.remote_pools.push_back(remote_pool_address); + + event::emit_event( + &mut state.remote_pool_added_events, + RemotePoolAdded { remote_chain_selector, remote_pool_address } + ); + } + ); + + state.remote_chain_configs.add(remote_chain_selector, remote_chain_config); + + event::emit_event( + &mut state.chain_added_events, + ChainAdded { remote_chain_selector, remote_token_address } + ); + }; + } + + // ================================================================ + // | Remote Pools | + // ================================================================ + public fun get_remote_pools( + state: &TokenPoolState, remote_chain_selector: u64 + ): vector> { + assert!( + state.remote_chain_configs.contains(remote_chain_selector), + error::invalid_argument(E_UNKNOWN_REMOTE_CHAIN_SELECTOR) + ); + let remote_chain_config = + state.remote_chain_configs.borrow(remote_chain_selector); + remote_chain_config.remote_pools + } + + public fun is_remote_pool( + state: &TokenPoolState, remote_chain_selector: u64, remote_pool_address: vector + ): bool { + let remote_pools = get_remote_pools(state, remote_chain_selector); + let (found, _) = remote_pools.index_of(&remote_pool_address); + found + } + + public fun get_remote_token( + state: &TokenPoolState, remote_chain_selector: u64 + ): vector { + assert!( + state.remote_chain_configs.contains(remote_chain_selector), + error::invalid_argument(E_UNKNOWN_REMOTE_CHAIN_SELECTOR) + ); + let remote_chain_config = + state.remote_chain_configs.borrow(remote_chain_selector); + remote_chain_config.remote_token_address + } + + public fun add_remote_pool( + state: &mut TokenPoolState, + remote_chain_selector: u64, + remote_pool_address: vector + ) { + address::assert_non_zero_address_vector(&remote_pool_address); + + assert!( + state.remote_chain_configs.contains(remote_chain_selector), + error::invalid_argument(E_UNKNOWN_REMOTE_CHAIN_SELECTOR) + ); + let remote_chain_config = + state.remote_chain_configs.borrow_mut(remote_chain_selector); + + let (found, _) = remote_chain_config.remote_pools.index_of(&remote_pool_address); + assert!(!found, error::invalid_argument(E_REMOTE_POOL_ALREADY_ADDED)); + + remote_chain_config.remote_pools.push_back(remote_pool_address); + + event::emit_event( + &mut state.remote_pool_added_events, + RemotePoolAdded { remote_chain_selector, remote_pool_address } + ); + } + + public fun remove_remote_pool( + state: &mut TokenPoolState, + remote_chain_selector: u64, + remote_pool_address: vector + ) { + assert!( + state.remote_chain_configs.contains(remote_chain_selector), + error::invalid_argument(E_UNKNOWN_REMOTE_CHAIN_SELECTOR) + ); + let remote_chain_config = + state.remote_chain_configs.borrow_mut(remote_chain_selector); + + let (found, i) = remote_chain_config.remote_pools.index_of(&remote_pool_address); + assert!(found, error::invalid_argument(E_UNKNOWN_REMOTE_POOL)); + + // remove instead of swap_remove for readability, so the newest added pool is always at the end. + remote_chain_config.remote_pools.remove(i); + + event::emit_event( + &mut state.remote_pool_removed_events, + RemotePoolRemoved { remote_chain_selector, remote_pool_address } + ); + } + + // ================================================================ + // | Validation | + // ================================================================ + + // Returns the remote token as bytes + public fun validate_lock_or_burn( + state: &mut TokenPoolState, + fa: &FungibleAsset, + input: &token_admin_registry::LockOrBurnInputV1, + local_amount: u64 + ): vector { + // Validate the fungible asset + let fa_metadata = fungible_asset::metadata_from_asset(fa); + let configured_token = get_token(state); + + // make sure the caller is requesting this pool's fungible asset. + assert!( + configured_token == object::object_address(&fa_metadata), + error::invalid_argument(E_UNKNOWN_FUNGIBLE_ASSET) + ); + + // Check RMN curse status + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(input); + assert!( + !rmn_remote::is_cursed_u128((remote_chain_selector as u128)), + error::invalid_state(E_CURSED_CHAIN) + ); + + let sender = token_admin_registry::get_lock_or_burn_sender(input); + // Allowlist check + assert!( + allowlist::is_allowed(&state.allowlist_state, sender), + error::permission_denied(E_NOT_ALLOWED_CALLER) + ); + + if (!is_supported_chain(state, remote_chain_selector)) { + abort error::invalid_argument(E_UNKNOWN_REMOTE_CHAIN_SELECTOR) + }; + + token_pool_rate_limiter::consume_outbound( + &mut state.rate_limiter_config, + remote_chain_selector, + local_amount + ); + + get_remote_token(state, remote_chain_selector) + } + + public fun validate_release_or_mint( + state: &mut TokenPoolState, + input: &token_admin_registry::ReleaseOrMintInputV1, + local_amount: u64 + ) { + // Validate the fungible asset + let local_token = token_admin_registry::get_release_or_mint_local_token(input); + let configured_token = get_token(state); + + // make sure the caller is requesting this pool's fungible asset. + assert!( + configured_token == local_token, + error::invalid_argument(E_UNKNOWN_FUNGIBLE_ASSET) + ); + + // Check RMN curse status + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(input); + assert!( + !rmn_remote::is_cursed_u128((remote_chain_selector as u128)), + error::invalid_state(E_CURSED_CHAIN) + ); + + let source_pool_address = + token_admin_registry::get_release_or_mint_source_pool_address(input); + + // This checks if the remote chain selector and the source pool are valid. + assert!( + is_remote_pool(state, remote_chain_selector, source_pool_address), + error::invalid_argument(E_UNKNOWN_REMOTE_POOL) + ); + + token_pool_rate_limiter::consume_inbound( + &mut state.rate_limiter_config, + remote_chain_selector, + local_amount + ); + } + + // ================================================================ + // | Events | + // ================================================================ + public fun emit_released_or_minted( + state: &mut TokenPoolState, + recipient: address, + amount: u64, + remote_chain_selector: u64 + ) { + let local_token = object::object_address(&state.fa_metadata); + + event::emit_event( + &mut state.released_events, + ReleasedOrMinted { + remote_chain_selector, + local_token, + recipient, + amount + } + ); + } + + public fun emit_locked_or_burned( + state: &mut TokenPoolState, amount: u64, remote_chain_selector: u64 + ) { + let local_token = object::object_address(&state.fa_metadata); + + event::emit_event( + &mut state.locked_events, + LockedOrBurned { remote_chain_selector, local_token, amount } + ); + } + + public fun emit_liquidity_added( + state: &mut TokenPoolState, provider: address, amount: u64 + ) { + let local_token = object::object_address(&state.fa_metadata); + + event::emit_event( + &mut state.liquidity_added_events, + LiquidityAdded { local_token, provider, amount } + ); + } + + public fun emit_liquidity_removed( + state: &mut TokenPoolState, provider: address, amount: u64 + ) { + let local_token = object::object_address(&state.fa_metadata); + + event::emit_event( + &mut state.liquidity_removed_events, + LiquidityRemoved { local_token, provider, amount } + ); + } + + public fun emit_rebalancer_set( + state: &mut TokenPoolState, old_rebalancer: address, new_rebalancer: address + ) { + event::emit_event( + &mut state.rebalancer_set_events, + RebalancerSet { old_rebalancer, new_rebalancer } + ); + } + + // ================================================================ + // | Decimals | + // ================================================================ + public fun encode_local_decimals(state: &TokenPoolState): vector { + let fa_decimals = fungible_asset::decimals(state.fa_metadata); + let ret = vector[]; + eth_abi::encode_u8(&mut ret, fa_decimals); + ret + } + + #[view] + public fun parse_remote_decimals( + source_pool_data: vector, local_decimals: u8 + ): u8 { + let data_len = source_pool_data.length(); + if (data_len == 0) { + // Fallback to the local value. + return local_decimals + }; + + assert!(data_len == 32, error::invalid_state(E_INVALID_REMOTE_CHAIN_DECIMALS)); + + let remote_decimals = eth_abi::decode_u256_value(source_pool_data); + assert!( + remote_decimals <= 255, + error::invalid_state(E_INVALID_REMOTE_CHAIN_DECIMALS) + ); + + remote_decimals as u8 + } + + #[view] + public fun calculate_local_amount( + remote_amount: u256, remote_decimals: u8, local_decimals: u8 + ): u64 { + let local_amount = + calculate_local_amount_internal( + remote_amount, remote_decimals, local_decimals + ); + assert!(local_amount <= MAX_U64, error::invalid_state(E_INVALID_ENCODED_AMOUNT)); + local_amount as u64 + } + + #[view] + fun calculate_local_amount_internal( + remote_amount: u256, remote_decimals: u8, local_decimals: u8 + ): u256 { + if (remote_decimals == local_decimals) { + return remote_amount + } else if (remote_decimals > local_decimals) { + let decimals_diff = remote_decimals - local_decimals; + let current_amount = remote_amount; + for (i in 0..decimals_diff) { + current_amount /= 10; + }; + return current_amount + } else { + let decimals_diff = local_decimals - remote_decimals; + // This is a safety check to prevent overflow in the next calculation. + // More than 77 would never fit in a uint256 and would cause an overflow. We also check if the resulting amount + // would overflow. + assert!(decimals_diff <= 77, error::invalid_state(E_DECIMAL_OVERFLOW)); + + let multiplier: u256 = 1; + let base: u256 = 10; + for (i in 0..decimals_diff) { + multiplier = multiplier * base; + }; + + assert!( + remote_amount <= (MAX_U256 / multiplier), + error::invalid_state(E_DECIMAL_OVERFLOW) + ); + + return remote_amount * multiplier + } + } + + public fun calculate_release_or_mint_amount( + state: &TokenPoolState, input: &token_admin_registry::ReleaseOrMintInputV1 + ): u64 { + let local_decimals = get_token_decimals(state); + let source_amount = + token_admin_registry::get_release_or_mint_source_amount(input); + let source_pool_data = + token_admin_registry::get_release_or_mint_source_pool_data(input); + let remote_decimals = parse_remote_decimals(source_pool_data, local_decimals); + let local_amount = + calculate_local_amount(source_amount, remote_decimals, local_decimals); + local_amount + } + + // ================================================================ + // | Rate limit config | + // ================================================================ + public fun set_chain_rate_limiter_config( + state: &mut TokenPoolState, + remote_chain_selector: u64, + outbound_is_enabled: bool, + outbound_capacity: u64, + outbound_rate: u64, + inbound_is_enabled: bool, + inbound_capacity: u64, + inbound_rate: u64 + ) { + token_pool_rate_limiter::set_chain_rate_limiter_config( + &mut state.rate_limiter_config, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } + + public fun get_current_inbound_rate_limiter_state( + state: &TokenPoolState, remote_chain_selector: u64 + ): rate_limiter::TokenBucket { + token_pool_rate_limiter::get_current_inbound_rate_limiter_state( + &state.rate_limiter_config, remote_chain_selector + ) + } + + public fun get_current_outbound_rate_limiter_state( + state: &TokenPoolState, remote_chain_selector: u64 + ): rate_limiter::TokenBucket { + token_pool_rate_limiter::get_current_outbound_rate_limiter_state( + &state.rate_limiter_config, remote_chain_selector + ) + } + + // ================================================================ + // | Allowlist | + // ================================================================ + public fun get_allowlist_enabled(state: &TokenPoolState): bool { + allowlist::get_allowlist_enabled(&state.allowlist_state) + } + + public fun set_allowlist_enabled( + state: &mut TokenPoolState, enabled: bool + ) { + allowlist::set_allowlist_enabled(&mut state.allowlist_state, enabled); + } + + public fun get_allowlist(state: &TokenPoolState): vector
{ + allowlist::get_allowlist(&state.allowlist_state) + } + + public fun apply_allowlist_updates( + state: &mut TokenPoolState, removes: vector
, adds: vector
+ ) { + allowlist::apply_allowlist_updates(&mut state.allowlist_state, removes, adds); + } + + // ================================================================ + // | Test functions | + // ================================================================ + #[test_only] + public fun destroy_token_pool(state: TokenPoolState) { + let TokenPoolState { + allowlist_state, + fa_metadata: _fa_metadata, + remote_chain_configs, + rate_limiter_config, + locked_events, + released_events, + remote_pool_added_events, + remote_pool_removed_events, + chain_added_events, + chain_removed_events, + liquidity_added_events, + liquidity_removed_events, + rebalancer_set_events + } = state; + + allowlist::destroy_allowlist(allowlist_state); + remote_chain_configs.destroy(); + event::destroy_handle(locked_events); + event::destroy_handle(released_events); + event::destroy_handle(remote_pool_added_events); + event::destroy_handle(remote_pool_removed_events); + event::destroy_handle(chain_added_events); + event::destroy_handle(chain_removed_events); + event::destroy_handle(liquidity_added_events); + event::destroy_handle(liquidity_removed_events); + event::destroy_handle(rebalancer_set_events); + + token_pool_rate_limiter::destroy_rate_limiter(rate_limiter_config); + } + + #[test_only] + public fun get_locked_or_burned_events(state: &TokenPoolState): vector { + event::emitted_events_by_handle(&state.locked_events) + } + + #[test_only] + public fun get_released_or_minted_events(state: &TokenPoolState) + : vector { + event::emitted_events_by_handle(&state.released_events) + } +} +` + +/** token_pool/sources/ownable.move */ +export const TOKEN_POOL_OWNABLE_MOVE = `/// This module implements an Ownable component similar to Ownable2Step.sol for managing +/// object ownership. +/// +/// Due to Aptos's security model requiring the original owner's signer for 0x1::object::transfer, +/// this implementation uses a 3-step ownership transfer flow: +/// +/// 1. Initial owner calls transfer_ownership with the new owner's address +/// 2. Pending owner calls accept_ownership to confirm the transfer +/// 3. Initial owner calls execute_ownership_transfer to complete the transfer +/// +/// The execute_ownership_transfer function requires a signer in order to perform the +/// object transfer, while other operations only require the caller address to maintain the +/// principle of least privilege. +/// +/// Note that direct ownership transfers via 0x1::object::transfer are still possible. +/// This module handles such cases gracefully by reading the current owner directly +/// from the object. +module ccip_token_pool::ownable { + use std::account; + use std::error; + use std::event::{Self, EventHandle}; + use std::object::{Self, Object, ObjectCore}; + use std::option::{Self, Option}; + use std::signer; + + struct OwnableState has store { + target_object: Object, + pending_transfer: Option, + ownership_transfer_requested_events: EventHandle, + ownership_transfer_accepted_events: EventHandle, + ownership_transferred_events: EventHandle + } + + struct PendingTransfer has store, drop { + from: address, + to: address, + accepted: bool + } + + const E_MUST_BE_PROPOSED_OWNER: u64 = 1; + const E_CANNOT_TRANSFER_TO_SELF: u64 = 2; + const E_ONLY_CALLABLE_BY_OWNER: u64 = 3; + const E_PROPOSED_OWNER_MISMATCH: u64 = 4; + const E_OWNER_CHANGED: u64 = 5; + const E_NO_PENDING_TRANSFER: u64 = 6; + const E_TRANSFER_NOT_ACCEPTED: u64 = 7; + const E_TRANSFER_ALREADY_ACCEPTED: u64 = 8; + + #[event] + struct OwnershipTransferRequested has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferAccepted has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferred has store, drop { + from: address, + to: address + } + + public fun new(event_account: &signer, object_address: address): OwnableState { + let new_state = OwnableState { + target_object: object::address_to_object(object_address), + pending_transfer: option::none(), + ownership_transfer_requested_events: account::new_event_handle(event_account), + ownership_transfer_accepted_events: account::new_event_handle(event_account), + ownership_transferred_events: account::new_event_handle(event_account) + }; + + new_state + } + + public fun owner(state: &OwnableState): address { + owner_internal(state) + } + + public fun has_pending_transfer(state: &OwnableState): bool { + state.pending_transfer.is_some() + } + + public fun pending_transfer_from(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.from) + } + + public fun pending_transfer_to(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.to) + } + + public fun pending_transfer_accepted(state: &OwnableState): Option { + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.accepted) + } + + inline fun owner_internal(state: &OwnableState): address { + object::owner(state.target_object) + } + + public fun transfer_ownership( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + assert!(caller_address != to, error::invalid_argument(E_CANNOT_TRANSFER_TO_SELF)); + + state.pending_transfer = option::some( + PendingTransfer { from: caller_address, to, accepted: false } + ); + + event::emit_event( + &mut state.ownership_transfer_requested_events, + OwnershipTransferRequested { from: caller_address, to } + ); + } + + public fun accept_ownership(caller: &signer, state: &mut OwnableState) { + let caller_address = signer::address_of(caller); + assert!( + state.pending_transfer.is_some(), + error::permission_denied(E_NO_PENDING_TRANSFER) + ); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.borrow_mut(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == caller_address, + error::permission_denied(E_MUST_BE_PROPOSED_OWNER) + ); + assert!( + !pending_transfer.accepted, + error::invalid_state(E_TRANSFER_ALREADY_ACCEPTED) + ); + + pending_transfer.accepted = true; + + event::emit_event( + &mut state.ownership_transfer_accepted_events, + OwnershipTransferAccepted { from: pending_transfer.from, to: caller_address } + ); + } + + public fun execute_ownership_transfer( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.extract(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == to, + error::permission_denied(E_PROPOSED_OWNER_MISMATCH) + ); + assert!( + pending_transfer.accepted, + error::invalid_state(E_TRANSFER_NOT_ACCEPTED) + ); + + object::transfer(caller, state.target_object, pending_transfer.to); + state.pending_transfer = option::none(); + + event::emit_event( + &mut state.ownership_transferred_events, + OwnershipTransferred { from: caller_address, to } + ); + } + + public fun assert_only_owner(caller: address, state: &OwnableState) { + assert_only_owner_internal(caller, state) + } + + inline fun assert_only_owner_internal( + caller: address, state: &OwnableState + ) { + assert!( + caller == owner_internal(state), + error::permission_denied(E_ONLY_CALLABLE_BY_OWNER) + ); + } + + public fun destroy(state: OwnableState) { + let OwnableState { + target_object: _, + pending_transfer: _, + ownership_transfer_requested_events, + ownership_transfer_accepted_events, + ownership_transferred_events + } = state; + + event::destroy_handle(ownership_transfer_requested_events); + event::destroy_handle(ownership_transfer_accepted_events); + event::destroy_handle(ownership_transferred_events); + } + + #[test_only] + public fun get_ownership_transfer_requested_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transfer_requested_events + } + + #[test_only] + public fun get_ownership_transfer_accepted_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transfer_accepted_events + } + + #[test_only] + public fun get_ownership_transferred_events( + state: &OwnableState + ): &EventHandle { + &state.ownership_transferred_events + } +} +` + +/** token_pool/sources/rate_limiter.move */ +export const RATE_LIMITER_MOVE = `module ccip_token_pool::rate_limiter { + use std::error; + use std::timestamp; + + struct TokenBucket has store, drop { + tokens: u64, + last_updated: u64, + is_enabled: bool, + capacity: u64, + rate: u64 + } + + const E_TOKEN_MAX_CAPACITY_EXCEEDED: u64 = 1; + const E_TOKEN_RATE_LIMIT_REACHED: u64 = 2; + + public fun new(is_enabled: bool, capacity: u64, rate: u64): TokenBucket { + TokenBucket { + tokens: 0, + last_updated: timestamp::now_seconds(), + is_enabled, + capacity, + rate + } + } + + public fun get_current_token_bucket_state(state: &TokenBucket): TokenBucket { + TokenBucket { + tokens: calculate_refill( + state, timestamp::now_seconds() - state.last_updated + ), + last_updated: timestamp::now_seconds(), + is_enabled: state.is_enabled, + capacity: state.capacity, + rate: state.rate + } + } + + public fun consume(bucket: &mut TokenBucket, requested_tokens: u64) { + if (!bucket.is_enabled || requested_tokens == 0) { return }; + + update_bucket(bucket); + + assert!( + requested_tokens <= bucket.capacity, + error::invalid_argument(E_TOKEN_MAX_CAPACITY_EXCEEDED) + ); + + assert!( + requested_tokens <= bucket.tokens, + error::invalid_argument(E_TOKEN_RATE_LIMIT_REACHED) + ); + + bucket.tokens -= requested_tokens; + } + + /// We allow 0 rate and/or 0 capacity rate limits to effectively disable value transfer. + public fun set_token_bucket_config( + bucket: &mut TokenBucket, is_enabled: bool, capacity: u64, rate: u64 + ) { + update_bucket(bucket); + + bucket.tokens = min(bucket.tokens, capacity); + bucket.capacity = capacity; + bucket.rate = rate; + bucket.is_enabled = is_enabled; + } + + inline fun update_bucket(bucket: &mut TokenBucket) { + let time_now_seconds = timestamp::now_seconds(); + let time_diff = time_now_seconds - bucket.last_updated; + + if (time_diff > 0) { + bucket.tokens = calculate_refill(bucket, time_diff); + bucket.last_updated = time_now_seconds; + }; + } + + inline fun calculate_refill(bucket: &TokenBucket, time_diff: u64): u64 { + min( + bucket.capacity, bucket.tokens + time_diff * bucket.rate + ) + } + + inline fun min(a: u64, b: u64): u64 { + if (a > b) b else a + } +} +` + +/** token_pool/sources/token_pool_rate_limiter.move */ +export const TOKEN_POOL_RATE_LIMITER_MOVE = `module ccip_token_pool::token_pool_rate_limiter { + use std::smart_table; + use std::smart_table::SmartTable; + use std::account; + use std::error; + use std::event; + use std::event::EventHandle; + + use ccip_token_pool::rate_limiter; + + struct RateLimitState has store { + outbound_rate_limiter_config: SmartTable, + inbound_rate_limiter_config: SmartTable, + tokens_consumed_events: EventHandle, + config_changed_events: EventHandle + } + + #[event] + struct TokensConsumed has store, drop { + remote_chain_selector: u64, + tokens: u64 + } + + #[event] + struct ConfigChanged has store, drop { + remote_chain_selector: u64, + outbound_is_enabled: bool, + outbound_capacity: u64, + outbound_rate: u64, + inbound_is_enabled: bool, + inbound_capacity: u64, + inbound_rate: u64 + } + + const E_BUCKET_NOT_FOUND: u64 = 1; + + public fun new(event_account: &signer): RateLimitState { + RateLimitState { + outbound_rate_limiter_config: smart_table::new(), + inbound_rate_limiter_config: smart_table::new(), + tokens_consumed_events: account::new_event_handle(event_account), + config_changed_events: account::new_event_handle(event_account) + } + } + + public fun consume_inbound( + state: &mut RateLimitState, dest_chain_selector: u64, requested_tokens: u64 + ) { + consume_from_bucket( + &mut state.tokens_consumed_events, + &mut state.inbound_rate_limiter_config, + dest_chain_selector, + requested_tokens + ); + } + + public fun consume_outbound( + state: &mut RateLimitState, dest_chain_selector: u64, requested_tokens: u64 + ) { + consume_from_bucket( + &mut state.tokens_consumed_events, + &mut state.outbound_rate_limiter_config, + dest_chain_selector, + requested_tokens + ); + } + + inline fun consume_from_bucket( + tokens_consumed_events: &mut EventHandle, + rate_limiter: &mut SmartTable, + dest_chain_selector: u64, + requested_tokens: u64 + ) { + assert!( + rate_limiter.contains(dest_chain_selector), + error::invalid_argument(E_BUCKET_NOT_FOUND) + ); + + let bucket = rate_limiter.borrow_mut(dest_chain_selector); + rate_limiter::consume(bucket, requested_tokens); + + event::emit_event( + tokens_consumed_events, + TokensConsumed { + remote_chain_selector: dest_chain_selector, + tokens: requested_tokens + } + ); + } + + public fun set_chain_rate_limiter_config( + state: &mut RateLimitState, + remote_chain_selector: u64, + outbound_is_enabled: bool, + outbound_capacity: u64, + outbound_rate: u64, + inbound_is_enabled: bool, + inbound_capacity: u64, + inbound_rate: u64 + ) { + let outbound_config = + state.outbound_rate_limiter_config.borrow_mut_with_default( + remote_chain_selector, + rate_limiter::new(false, 0, 0) + ); + rate_limiter::set_token_bucket_config( + outbound_config, + outbound_is_enabled, + outbound_capacity, + outbound_rate + ); + + let inbound_config = + state.inbound_rate_limiter_config.borrow_mut_with_default( + remote_chain_selector, + rate_limiter::new(false, 0, 0) + ); + rate_limiter::set_token_bucket_config( + inbound_config, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + + event::emit_event( + &mut state.config_changed_events, + ConfigChanged { + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + } + ); + } + + public fun get_current_inbound_rate_limiter_state( + state: &RateLimitState, remote_chain_selector: u64 + ): rate_limiter::TokenBucket { + rate_limiter::get_current_token_bucket_state( + state.inbound_rate_limiter_config.borrow(remote_chain_selector) + ) + } + + public fun get_current_outbound_rate_limiter_state( + state: &RateLimitState, remote_chain_selector: u64 + ): rate_limiter::TokenBucket { + rate_limiter::get_current_token_bucket_state( + state.outbound_rate_limiter_config.borrow(remote_chain_selector) + ) + } + + public fun destroy_rate_limiter(state: RateLimitState) { + let RateLimitState { + outbound_rate_limiter_config, + inbound_rate_limiter_config, + tokens_consumed_events, + config_changed_events + } = state; + + outbound_rate_limiter_config.destroy(); + inbound_rate_limiter_config.destroy(); + event::destroy_handle(tokens_consumed_events); + event::destroy_handle(config_changed_events); + } +} +` diff --git a/ccip-sdk/src/token-admin/aptos/bytecodes/mcms.ts b/ccip-sdk/src/token-admin/aptos/bytecodes/mcms.ts new file mode 100644 index 00000000..e484cbcf --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/bytecodes/mcms.ts @@ -0,0 +1,3362 @@ +/** + * ChainlinkManyChainMultisig (MCMS) Move sources — embedded from chainlink-aptos. + * + * These sources are compiled locally alongside pool packages so that + * the compiled bytecode matches the on-chain modules exactly. + * + * @packageDocumentation + */ + +/** Move.toml for ChainlinkManyChainMultisig. */ +export const MCMS_MOVE_TOML = `[package] +name = "ChainlinkManyChainMultisig" +version = "1.0.0" +upgrade_policy = "compatible" + +[addresses] +mcms = "_" +mcms_owner = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +` + +/** sources/mcms_account.move */ +export const MCMS_MCMS_ACCOUNT_MOVE = `/// This module manages the ownership of the MCMS package. +module mcms::mcms_account { + use std::account::{Self, SignerCapability}; + use std::error; + use std::event; + use std::resource_account; + use std::signer; + + friend mcms::mcms; + friend mcms::mcms_deployer; + friend mcms::mcms_registry; + + struct AccountState has key, store { + signer_cap: SignerCapability, + owner: address, + pending_owner: address + } + + #[event] + struct OwnershipTransferRequested has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferred has store, drop { + from: address, + to: address + } + + const E_CANNOT_TRANSFER_TO_SELF: u64 = 1; + const E_MUST_BE_PROPOSED_OWNER: u64 = 2; + const E_UNAUTHORIZED: u64 = 3; + + fun init_module(publisher: &signer) { + let signer_cap = + resource_account::retrieve_resource_account_cap(publisher, @mcms_owner); + init_module_internal(publisher, signer_cap); + } + + inline fun init_module_internal( + publisher: &signer, signer_cap: SignerCapability + ) { + move_to( + publisher, + AccountState { + signer_cap, + owner: @mcms_owner, + pending_owner: @0x0 + } + ); + } + + /// Transfers ownership to the specified address. + public entry fun transfer_ownership(caller: &signer, to: address) acquires AccountState { + let state = borrow_state_mut(); + + assert_is_owner_internal(state, caller); + + assert!( + signer::address_of(caller) != to, + error::invalid_argument(E_CANNOT_TRANSFER_TO_SELF) + ); + + state.pending_owner = to; + + event::emit(OwnershipTransferRequested { from: state.owner, to }); + } + + /// Transfers ownership back to the \`@mcms\` address. + public entry fun transfer_ownership_to_self(caller: &signer) acquires AccountState { + transfer_ownership(caller, @mcms); + } + + /// Accepts ownership transfer. Can only be called by the pending owner. + public entry fun accept_ownership(caller: &signer) acquires AccountState { + let state = borrow_state_mut(); + + let caller_address = signer::address_of(caller); + assert!( + caller_address == state.pending_owner, + error::permission_denied(E_MUST_BE_PROPOSED_OWNER) + ); + + let previous_owner = state.owner; + state.owner = caller_address; + state.pending_owner = @0x0; + + event::emit(OwnershipTransferred { from: previous_owner, to: state.owner }); + } + + #[view] + /// Returns the current owner. + public fun owner(): address acquires AccountState { + borrow_state().owner + } + + #[view] + /// Returns \`true\` if the module is self-owned (owned by \`@mcms\`). + public fun is_self_owned(): bool acquires AccountState { + owner() == @mcms + } + + public(friend) fun get_signer(): signer acquires AccountState { + account::create_signer_with_capability(&borrow_state().signer_cap) + } + + public(friend) fun assert_is_owner(caller: &signer) acquires AccountState { + assert_is_owner_internal(borrow_state(), caller); + } + + inline fun assert_is_owner_internal( + state: &AccountState, caller: &signer + ) { + assert!( + state.owner == signer::address_of(caller), + error::permission_denied(E_UNAUTHORIZED) + ); + } + + inline fun borrow_state(): &AccountState { + borrow_global(@mcms) + } + + inline fun borrow_state_mut(): &mut AccountState { + borrow_global_mut(@mcms) + } + + #[test_only] + public fun init_module_for_testing(publisher: &signer) { + let test_signer_cap = account::create_test_signer_cap(@mcms); + init_module_internal(publisher, test_signer_cap); + } +} +` + +/** sources/mcms_deployer.move */ +export const MCMS_MCMS_DEPLOYER_MOVE = `/// This module is a modified version of Aptos' large_packages package, providing functions for publishing and upgrading +/// MCMS-owned modules of arbitrary sizes via object code deployment. +module mcms::mcms_deployer { + use std::code::PackageRegistry; + use std::error; + use std::smart_table::{Self, SmartTable}; + use std::object; + use std::object_code_deployment; + + use mcms::mcms_account; + use mcms::mcms_registry; + + const E_CODE_MISMATCH: u64 = 1; + + struct StagingArea has key { + metadata_serialized: vector, + code: SmartTable>, + last_module_idx: u64 + } + + /// Stages a chunk of code in the StagingArea. + /// This function allows for incremental building of a large package. + public entry fun stage_code_chunk( + caller: &signer, + metadata_chunk: vector, + code_indices: vector, + code_chunks: vector> + ) acquires StagingArea { + mcms_account::assert_is_owner(caller); + + stage_code_chunk_internal(metadata_chunk, code_indices, code_chunks); + } + + /// Stages a code chunk and immediately publishes it to a new object. + public entry fun stage_code_chunk_and_publish_to_object( + caller: &signer, + metadata_chunk: vector, + code_indices: vector, + code_chunks: vector>, + new_owner_seed: vector + ) acquires StagingArea { + mcms_account::assert_is_owner(caller); + + let staging_area = + stage_code_chunk_internal(metadata_chunk, code_indices, code_chunks); + let code = assemble_module_code(staging_area); + + let owner_signer = + &mcms_registry::create_owner_for_new_code_object(new_owner_seed); + + object_code_deployment::publish( + owner_signer, staging_area.metadata_serialized, code + ); + + cleanup_staging_area_internal(); + } + + /// Stages a code chunk and immediately upgrades an existing code object. + public entry fun stage_code_chunk_and_upgrade_object_code( + caller: &signer, + metadata_chunk: vector, + code_indices: vector, + code_chunks: vector>, + code_object_address: address + ) acquires StagingArea { + mcms_account::assert_is_owner(caller); + + let staging_area = + stage_code_chunk_internal(metadata_chunk, code_indices, code_chunks); + let code = assemble_module_code(staging_area); + + let owner_signer = + &mcms_registry::get_signer_for_code_object_upgrade(code_object_address); + + object_code_deployment::upgrade( + owner_signer, + staging_area.metadata_serialized, + code, + object::address_to_object(code_object_address) + ); + + cleanup_staging_area_internal(); + } + + /// Cleans up the staging area, removing any staged code chunks. + /// This function can be called to reset the staging area without publishing or upgrading. + public entry fun cleanup_staging_area(caller: &signer) acquires StagingArea { + mcms_account::assert_is_owner(caller); + + cleanup_staging_area_internal(); + } + + inline fun stage_code_chunk_internal( + metadata_chunk: vector, + code_indices: vector, + code_chunks: vector> + ): &mut StagingArea { + assert!( + code_indices.length() == code_chunks.length(), + error::invalid_argument(E_CODE_MISMATCH) + ); + + if (!exists(@mcms)) { + move_to( + &mcms_account::get_signer(), + StagingArea { + metadata_serialized: vector[], + code: smart_table::new(), + last_module_idx: 0 + } + ); + }; + + let staging_area = borrow_global_mut(@mcms); + + if (!metadata_chunk.is_empty()) { + staging_area.metadata_serialized.append(metadata_chunk); + }; + + for (i in 0..code_chunks.length()) { + let inner_code = code_chunks[i]; + let idx = (code_indices[i] as u64); + + if (staging_area.code.contains(idx)) { + staging_area.code.borrow_mut(idx).append(inner_code); + } else { + staging_area.code.add(idx, inner_code); + if (idx > staging_area.last_module_idx) { + staging_area.last_module_idx = idx; + } + }; + }; + + staging_area + } + + inline fun assemble_module_code(staging_area: &mut StagingArea): vector> { + let last_module_idx = staging_area.last_module_idx; + let code = vector[]; + for (i in 0..(last_module_idx + 1)) { + code.push_back(*staging_area.code.borrow(i)); + }; + code + } + + inline fun cleanup_staging_area_internal() { + let StagingArea { metadata_serialized: _, code, last_module_idx: _ } = + move_from(@mcms); + code.destroy(); + } +} +` + +/** sources/mcms_executor.move */ +export const MCMS_MCMS_EXECUTOR_MOVE = `/// This module helps to stage large mcms::execute invocations, that cannot be done in a single +/// transaction due to the transaction size limit. +module mcms::mcms_executor { + use std::signer; + use std::string::String; + + use mcms::mcms; + + struct PendingExecute has key, store { + data: vector, + proofs: vector> + } + + public entry fun stage_data( + caller: &signer, data_chunk: vector, partial_proofs: vector> + ) acquires PendingExecute { + let caller_address = signer::address_of(caller); + if (!exists(caller_address)) { + move_to( + caller, + PendingExecute { data: vector[], proofs: vector[] } + ); + }; + let pending_execute = borrow_global_mut(caller_address); + if (!data_chunk.is_empty()) { + pending_execute.data.append(data_chunk); + }; + if (!partial_proofs.is_empty()) { + pending_execute.proofs.append(partial_proofs); + }; + } + + public entry fun stage_data_and_execute( + caller: &signer, + role: u8, + chain_id: u256, + multisig: address, + nonce: u64, + to: address, + module_name: String, + function: String, + data_chunk: vector, + partial_proofs: vector> + ) acquires PendingExecute { + if (!exists(signer::address_of(caller))) { + move_to( + caller, + PendingExecute { data: vector[], proofs: vector[] } + ); + }; + let PendingExecute { data, proofs } = + move_from(signer::address_of(caller)); + if (!data_chunk.is_empty()) { + data.append(data_chunk); + }; + if (!partial_proofs.is_empty()) { + proofs.append(partial_proofs); + }; + mcms::execute( + role, + chain_id, + multisig, + nonce, + to, + module_name, + function, + data, + proofs + ); + } + + public entry fun clear_staged_data(caller: &signer) acquires PendingExecute { + let PendingExecute { data: _, proofs: _ } = + move_from(signer::address_of(caller)); + } +} +` + +/** sources/mcms_registry.move */ +export const MCMS_MCMS_REGISTRY_MOVE = `/// This module handles registration and management of code object owners and callbacks. +module mcms::mcms_registry { + use std::account::{Self, SignerCapability}; + use std::bcs; + use std::code::PackageRegistry; + use std::dispatchable_fungible_asset; + use std::error; + use std::event; + use std::fungible_asset::{Self, Metadata}; + use std::function_info::{Self, FunctionInfo}; + use std::object::{Self, ExtendRef, Object}; + use std::option; + use std::signer; + use std::big_ordered_map::{Self, BigOrderedMap}; + use std::string::{Self, String}; + use std::type_info::{Self, TypeInfo}; + + use mcms::mcms_account; + + friend mcms::mcms; + friend mcms::mcms_deployer; + + const EXISTING_OBJECT_REGISTRATION_SEED: vector = b"CHAINLINK_MCMS_EXISTING_OBJECT_REGISTRATION"; + const NEW_OBJECT_REGISTRATION_SEED: vector = b"CHAINLINK_MCMS_NEW_OBJECT_REGISTRATION"; + const DISPATCH_OBJECT_SEED: vector = b"CHAINLINK_MCMS_DISPATCH_OBJECT"; + + // https://github.com/aptos-labs/aptos-core/blob/7fc73792e9db11462c9a42038c4a9eb41cc00192/aptos-move/framework/aptos-framework/sources/object_code_deployment.move#L53 + const OBJECT_CODE_DEPLOYMENT_DOMAIN_SEPARATOR: vector = b"aptos_framework::object_code_deployment"; + + struct RegistryState has key { + // preregistered code object and/or registered callback address -> owner/signer address + registered_addresses: BigOrderedMap + } + + struct OwnerRegistration has key { + owner_seed: vector, + owner_cap: SignerCapability, + is_preregistered: bool, + + // module name -> registered module + callback_modules: BigOrderedMap, RegisteredModule> + } + + struct OwnerTransfers has key { + // object address -> pending transfer + pending_transfers: BigOrderedMap + } + + struct RegisteredModule has store, drop { + callback_function_info: FunctionInfo, + proof_type_info: TypeInfo, + dispatch_metadata: Object, + dispatch_extend_ref: ExtendRef + } + + struct PendingCodeObjectTransfer has store, drop { + to: address, + accepted: bool + } + + struct ExecutingCallbackParams has key { + expected_type_info: TypeInfo, + function: String, + data: vector + } + + #[event] + struct EntrypointRegistered has store, drop { + owner_address: address, + account_address: address, + module_name: String + } + + #[event] + struct CodeObjectTransferRequested has store, drop { + object_address: address, + mcms_owner_address: address, + new_owner_address: address + } + + #[event] + struct CodeObjectTransferAccepted has store, drop { + object_address: address, + mcms_owner_address: address, + new_owner_address: address + } + + #[event] + struct CodeObjectTransferred has store, drop { + object_address: address, + mcms_owner_address: address, + new_owner_address: address + } + + #[event] + struct OwnerCreatedForPreexistingObject has store, drop { + owner_address: address, + object_address: address + } + + #[event] + struct OwnerCreatedForNewObject has store, drop { + owner_address: address, + expected_object_address: address + } + + #[event] + struct OwnerCreatedForEntrypoint has store, drop { + owner_address: address, + account_or_object_address: address + } + + const E_CALLBACK_PARAMS_ALREADY_EXISTS: u64 = 1; + const E_MISSING_CALLBACK_PARAMS: u64 = 2; + const E_WRONG_PROOF_TYPE: u64 = 3; + const E_CALLBACK_PARAMS_NOT_CONSUMED: u64 = 4; + const E_PROOF_NOT_AT_ACCOUNT_ADDRESS: u64 = 5; + const E_PROOF_NOT_IN_MODULE: u64 = 6; + const E_MODULE_ALREADY_REGISTERED: u64 = 7; + const E_EMPTY_MODULE_NAME: u64 = 8; + const E_MODULE_NAME_TOO_LONG: u64 = 9; + const E_ADDRESS_NOT_REGISTERED: u64 = 10; + const E_INVALID_CODE_OBJECT: u64 = 11; + const E_OWNER_ALREADY_REGISTERED: u64 = 12; + const E_NOT_CODE_OBJECT_OWNER: u64 = 13; + const E_UNGATED_TRANSFER_DISABLED: u64 = 14; + const E_NO_PENDING_TRANSFER: u64 = 15; + const E_TRANSFER_ALREADY_ACCEPTED: u64 = 16; + const E_NEW_OWNER_MISMATCH: u64 = 17; + const E_TRANSFER_NOT_ACCEPTED: u64 = 18; + const E_NOT_PROPOSED_OWNER: u64 = 19; + const E_MODULE_NOT_REGISTERED: u64 = 20; + + fun init_module(publisher: &signer) { + move_to( + publisher, + RegistryState { + registered_addresses: big_ordered_map::new_with_config(0, 0, false) + } + ); + } + + #[view] + /// Returns the resource address for a new code object owner using the provided seed. + public fun get_new_code_object_owner_address( + new_owner_seed: vector + ): address { + let owner_seed = NEW_OBJECT_REGISTRATION_SEED; + owner_seed.append(new_owner_seed); + account::create_resource_address(&@mcms, owner_seed) + } + + #[view] + /// Computes and returns the new code object's address using the new_owner_seed. + public fun get_new_code_object_address(new_owner_seed: vector): address { + let object_owner_address = get_new_code_object_owner_address(new_owner_seed); + let object_code_deployment_seed = + bcs::to_bytes(&OBJECT_CODE_DEPLOYMENT_DOMAIN_SEPARATOR); + object_code_deployment_seed.append(bcs::to_bytes(&1u64)); + object::create_object_address( + &object_owner_address, object_code_deployment_seed + ) + } + + #[view] + /// Derives the resource address for an preexisting code object's owner using the given object_address. + public fun get_preexisting_code_object_owner_address( + object_address: address + ): address { + let owner_seed = EXISTING_OBJECT_REGISTRATION_SEED; + owner_seed.append(bcs::to_bytes(&object_address)); + account::create_resource_address(&@mcms, owner_seed) + } + + #[view] + /// Returns the registered owner address for a given account address. The account address + /// can be either a code object address or a callback address. + /// Aborts if the address is not registered. + public fun get_registered_owner_address( + account_address: address + ): address acquires RegistryState { + let state = borrow_state(); + assert!( + state.registered_addresses.contains(&account_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + *state.registered_addresses.borrow(&account_address) + } + + #[view] + /// Returns true if the given address is a code object and is owned by MCMS. + /// Aborts if the address is not a valid code object. + public fun is_owned_code_object(object_address: address): bool acquires RegistryState { + assert!( + object::object_exists(object_address), + error::invalid_argument(E_INVALID_CODE_OBJECT) + ); + let code_object = object::address_to_object(object_address); + + let owner_address = get_registered_owner_address(object_address); + object::owner(code_object) == owner_address + } + + /// Imports a code object (ie. managed by 0x1::code_object_deployment) that was not deployed + /// using mcms_deployer, and has not registered for a callback, to be owned by MCMS. + /// If either of these conditions has already occurred, then an object owner was already + /// created and there is no need to call this function - however, the below flow can still + /// be followed to transfer ownership to MCMS, omitting the final step. + /// + /// Ownership transfer flow: + /// - if it was deployed using mcms_deployer, call get_new_code_object_owner_address() with + /// the same new_owner_seed used when publishing to get the MCMS object owner address. + /// - otherwise, call get_preexisting_code_object_owner_address() to get the MCMS object owner + /// address. + /// - call 0x1::object::transfer, transfering ownership to the MCMS object owner address. + /// - call create_owner_for_preexisting_code_object() with the object address. + /// + /// After these steps, MCMS will be the code object owner, and will be able to deploy and upgrade + /// the code object using proposals with mcms_deployer ops. + public entry fun create_owner_for_preexisting_code_object( + caller: &signer, object_address: address + ) acquires RegistryState { + mcms_account::assert_is_owner(caller); + assert!( + object::object_exists(object_address), + error::invalid_argument(E_INVALID_CODE_OBJECT) + ); + + let state = borrow_state_mut(); + let owner_signer = + &create_owner_for_preexisting_code_object_internal(state, object_address); + + event::emit( + OwnerCreatedForPreexistingObject { + owner_address: signer::address_of(owner_signer), + object_address + } + ); + } + + /// Transfers ownership of a code object to a new owner. Note that this does not unregister + /// the entrypoint or remove the previous owner from the registry. + /// + /// Due to Aptos's security model requiring the original owner's signer for 0x1::object::transfer, + /// we use the same 3-step ownership transfer flow as our ownable.move implementation: + /// + /// 1. MCMS owner calls transfer_code_object with the new owner's address + /// 2. Pending owner calls accept_code_object to confirm the transfer + /// 3. MCMS owner calls execute_code_object_transfer to complete the transfer + public entry fun transfer_code_object( + caller: &signer, object_address: address, new_owner_address: address + ) acquires RegistryState, OwnerRegistration, OwnerTransfers { + mcms_account::assert_is_owner(caller); + + assert!( + object::object_exists(object_address), + error::invalid_argument(E_INVALID_CODE_OBJECT) + ); + + let code_object = object::address_to_object(object_address); + + // this could occur if the code object was pre-existing and the original creator kept the TransferRef, + // transferred the object to MCMS by generating a LinearTransferRef. + assert!( + object::ungated_transfer_allowed(code_object), + error::permission_denied(E_UNGATED_TRANSFER_DISABLED) + ); + + let state = borrow_state(); + assert!( + state.registered_addresses.contains(&object_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + + let owner_address = *state.registered_addresses.borrow(&object_address); + // this could occur if the code object has already been transferred away either through this process + // or through a TransferRef if the object was pre-existing. + assert!( + object::owner(code_object) == owner_address, + error::invalid_state(E_NOT_CODE_OBJECT_OWNER) + ); + + if (!exists(owner_address)) { + let owner_registration = borrow_owner_registration(owner_address); + let owner_signer = + &account::create_signer_with_capability(&owner_registration.owner_cap); + move_to( + owner_signer, + OwnerTransfers { + pending_transfers: big_ordered_map::new_with_config(0, 0, false) + } + ); + }; + + let pending_transfers = borrow_global_mut(owner_address); + + // override any pending transfers if a new transfer has been requested. + pending_transfers.pending_transfers.upsert( + object_address, + PendingCodeObjectTransfer { to: new_owner_address, accepted: false } + ); + + event::emit( + CodeObjectTransferRequested { + object_address, + mcms_owner_address: owner_address, + new_owner_address + } + ); + } + + public entry fun accept_code_object( + caller: &signer, object_address: address + ) acquires RegistryState, OwnerTransfers { + assert!( + object::object_exists(object_address), + error::invalid_argument(E_INVALID_CODE_OBJECT) + ); + + let code_object = object::address_to_object(object_address); + + let state = borrow_state(); + assert!( + state.registered_addresses.contains(&object_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + + let owner_address = *state.registered_addresses.borrow(&object_address); + // these conditions could occur if the code object was pre-existing and the owner transferred object ownership or disabled + // ungated transfers using the TransferRef after this transfer process was initiated. + assert!( + object::owner(code_object) == owner_address, + error::invalid_state(E_NOT_CODE_OBJECT_OWNER) + ); + assert!( + object::ungated_transfer_allowed(code_object), + error::permission_denied(E_UNGATED_TRANSFER_DISABLED) + ); + + assert!( + exists(owner_address), + error::invalid_state(E_NO_PENDING_TRANSFER) + ); + let pending_transfers = borrow_global_mut(owner_address); + + assert!( + pending_transfers.pending_transfers.contains(&object_address), + error::invalid_state(E_NO_PENDING_TRANSFER) + ); + + let pending_transfer = + pending_transfers.pending_transfers.borrow_mut(&object_address); + assert!( + pending_transfer.to == signer::address_of(caller), + error::permission_denied(E_NOT_PROPOSED_OWNER) + ); + assert!( + !pending_transfer.accepted, + error::invalid_state(E_TRANSFER_ALREADY_ACCEPTED) + ); + + pending_transfer.accepted = true; + + event::emit( + CodeObjectTransferAccepted { + object_address, + mcms_owner_address: owner_address, + new_owner_address: pending_transfer.to + } + ); + } + + public entry fun execute_code_object_transfer( + caller: &signer, object_address: address, new_owner_address: address + ) acquires RegistryState, OwnerRegistration, OwnerTransfers { + mcms_account::assert_is_owner(caller); + + assert!( + object::object_exists(object_address), + error::invalid_argument(E_INVALID_CODE_OBJECT) + ); + + let code_object = object::address_to_object(object_address); + + let state = borrow_state(); + assert!( + state.registered_addresses.contains(&object_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + + let owner_address = *state.registered_addresses.borrow(&object_address); + // these conditions could occur if the code object was pre-existing and the owner transferred object ownership or disabled + // ungated transfers using the TransferRef after this transfer process was initiated. + assert!( + object::owner(code_object) == owner_address, + error::invalid_state(E_NOT_CODE_OBJECT_OWNER) + ); + assert!( + object::ungated_transfer_allowed(code_object), + error::permission_denied(E_UNGATED_TRANSFER_DISABLED) + ); + + assert!( + exists(owner_address), + error::invalid_state(E_NO_PENDING_TRANSFER) + ); + let pending_transfers = borrow_global_mut(owner_address); + + assert!( + pending_transfers.pending_transfers.contains(&object_address), + error::invalid_state(E_NO_PENDING_TRANSFER) + ); + let pending_transfer = + pending_transfers.pending_transfers.borrow_mut(&object_address); + assert!( + pending_transfer.to == new_owner_address, + error::invalid_state(E_NEW_OWNER_MISMATCH) + ); + assert!( + pending_transfer.accepted, + error::invalid_state(E_TRANSFER_NOT_ACCEPTED) + ); + + let owner_registration = borrow_owner_registration(owner_address); + let owner_signer = + &account::create_signer_with_capability(&owner_registration.owner_cap); + + object::transfer(owner_signer, code_object, new_owner_address); + + event::emit( + CodeObjectTransferred { + object_address, + mcms_owner_address: owner_address, + new_owner_address + } + ); + + pending_transfers.pending_transfers.remove(&object_address); + if (pending_transfers.pending_transfers.is_empty()) { + let OwnerTransfers { pending_transfers } = + move_from(owner_address); + pending_transfers.destroy_empty(); + } + } + + public(friend) fun create_owner_for_new_code_object( + new_owner_seed: vector + ): signer acquires RegistryState { + let owner_seed = NEW_OBJECT_REGISTRATION_SEED; + owner_seed.append(new_owner_seed); + let new_code_object_address = get_new_code_object_address(new_owner_seed); + let owner_signer = + create_owner_internal( + borrow_state_mut(), + owner_seed, + new_code_object_address, + true + ); + + event::emit( + OwnerCreatedForNewObject { + owner_address: signer::address_of(&owner_signer), + expected_object_address: new_code_object_address + } + ); + + owner_signer + } + + public(friend) fun get_signer_for_code_object_upgrade( + object_address: address + ): signer acquires RegistryState, OwnerRegistration { + assert!( + object::object_exists(object_address), + error::invalid_argument(E_INVALID_CODE_OBJECT) + ); + + let state = borrow_state(); + assert!( + state.registered_addresses.contains(&object_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + let owner_address = *state.registered_addresses.borrow(&object_address); + + let owner_registration = borrow_owner_registration(owner_address); + account::create_signer_with_capability(&owner_registration.owner_cap) + } + + inline fun create_owner_for_preexisting_code_object_internal( + state: &mut RegistryState, object_address: address + ): signer { + let owner_seed = EXISTING_OBJECT_REGISTRATION_SEED; + owner_seed.append(bcs::to_bytes(&object_address)); + create_owner_internal(state, owner_seed, object_address, false) + } + + inline fun create_owner_internal( + state: &mut RegistryState, + owner_seed: vector, + code_object_address: address, + is_preregistered: bool + ): signer { + let mcms_signer = &mcms_account::get_signer(); + + let owner_address = account::create_resource_address(&@mcms, owner_seed); + assert!( + !exists(owner_address), + error::invalid_state(E_OWNER_ALREADY_REGISTERED) + ); + + let (owner_signer, owner_cap) = + account::create_resource_account(mcms_signer, owner_seed); + move_to( + &owner_signer, + OwnerRegistration { + owner_seed, + owner_cap, + is_preregistered, + callback_modules: big_ordered_map::new_with_config(0, 0, false) + } + ); + + state.registered_addresses.add( + code_object_address, signer::address_of(&owner_signer) + ); + owner_signer + } + + /// Registers a callback to mcms_entrypoint to enable dynamic dispatch. + public fun register_entrypoint( + account: &signer, module_name: String, _proof: T + ): address acquires RegistryState, OwnerRegistration { + let account_address = signer::address_of(account); + let account_address_bytes = bcs::to_bytes(&account_address); + + let module_name_bytes = *module_name.bytes(); + let module_name_len = module_name_bytes.length(); + assert!(module_name_len > 0, error::invalid_argument(E_EMPTY_MODULE_NAME)); + assert!(module_name_len <= 64, error::invalid_argument(E_MODULE_NAME_TOO_LONG)); + + let state = borrow_state_mut(); + + let owner_address = + if (!state.registered_addresses.contains(&account_address)) { + let owner_signer = + create_owner_for_preexisting_code_object_internal( + state, account_address + ); + + let owner_address = signer::address_of(&owner_signer); + + event::emit( + OwnerCreatedForEntrypoint { + owner_address, + account_or_object_address: account_address + } + ); + + owner_address + } else { + *state.registered_addresses.borrow(&account_address) + }; + + let registration = borrow_owner_registration_mut(owner_address); + + assert!( + !registration.callback_modules.contains(&module_name_bytes), + error::invalid_argument(E_MODULE_ALREADY_REGISTERED) + ); + + let proof_type_info = type_info::type_of(); + + assert!( + proof_type_info.account_address() == account_address, + error::invalid_argument(E_PROOF_NOT_AT_ACCOUNT_ADDRESS) + ); + + let owner_signer = + account::create_signer_with_capability(®istration.owner_cap); + + let object_seed = DISPATCH_OBJECT_SEED; + object_seed.append(account_address_bytes); + object_seed.append(module_name_bytes); + + let dispatch_constructor_ref = + object::create_named_object(&owner_signer, object_seed); + let dispatch_extend_ref = object::generate_extend_ref(&dispatch_constructor_ref); + let dispatch_metadata = + fungible_asset::add_fungibility( + &dispatch_constructor_ref, + option::none(), + string::utf8(b"mcms"), + string::utf8(b"mcms"), + 0, + string::utf8(b""), + string::utf8(b"") + ); + + let callback_function_info = + function_info::new_function_info( + account, + string::utf8(proof_type_info.module_name()), + string::utf8(b"mcms_entrypoint") + ); + + dispatchable_fungible_asset::register_derive_supply_dispatch_function( + &dispatch_constructor_ref, option::some(callback_function_info) + ); + + let registered_module = RegisteredModule { + callback_function_info, + proof_type_info, + dispatch_metadata, + dispatch_extend_ref + }; + + registration.callback_modules.add(module_name_bytes, registered_module); + + event::emit(EntrypointRegistered { owner_address, account_address, module_name }); + + owner_address + } + + public(friend) fun start_dispatch( + callback_address: address, + callback_module_name: String, + callback_function: String, + data: vector + ): Object acquires RegistryState, OwnerRegistration { + let state = borrow_state(); + + assert!( + state.registered_addresses.contains(&callback_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + + let owner_address = *state.registered_addresses.borrow(&callback_address); + assert!( + !exists(owner_address), + error::invalid_state(E_CALLBACK_PARAMS_ALREADY_EXISTS) + ); + + let registration = borrow_owner_registration(owner_address); + + let callback_module_name_bytes = *callback_module_name.bytes(); + assert!( + registration.callback_modules.contains(&callback_module_name_bytes), + error::invalid_state(E_MODULE_NOT_REGISTERED) + ); + + let registered_module = + registration.callback_modules.borrow(&callback_module_name_bytes); + + let owner_signer = + account::create_signer_with_capability(®istration.owner_cap); + + move_to( + &owner_signer, + ExecutingCallbackParams { + expected_type_info: registered_module.proof_type_info, + function: callback_function, + data + } + ); + + registered_module.dispatch_metadata + } + + public(friend) fun finish_dispatch(callback_address: address) acquires RegistryState { + let state = borrow_state(); + + assert!( + state.registered_addresses.contains(&callback_address), + error::invalid_state(E_ADDRESS_NOT_REGISTERED) + ); + + let owner_address = *state.registered_addresses.borrow(&callback_address); + assert!( + !exists(owner_address), + error::invalid_argument(E_CALLBACK_PARAMS_NOT_CONSUMED) + ); + } + + public fun get_callback_params( + callback_address: address, _proof: T + ): (signer, String, vector) acquires RegistryState, OwnerRegistration, ExecutingCallbackParams { + let state = borrow_state(); + + assert!( + state.registered_addresses.contains(&callback_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + + let owner_address = *state.registered_addresses.borrow(&callback_address); + assert!( + exists(owner_address), + error::invalid_state(E_MISSING_CALLBACK_PARAMS) + ); + + let ExecutingCallbackParams { expected_type_info, function, data } = + move_from(owner_address); + + let proof_type_info = type_info::type_of(); + assert!( + expected_type_info == proof_type_info, + error::invalid_argument(E_WRONG_PROOF_TYPE) + ); + + let registration = borrow_owner_registration(owner_address); + let owner_signer = + account::create_signer_with_capability(®istration.owner_cap); + + (owner_signer, function, data) + } + + inline fun borrow_state(): &RegistryState { + borrow_global(@mcms) + } + + inline fun borrow_state_mut(): &mut RegistryState { + borrow_global_mut(@mcms) + } + + inline fun borrow_owner_registration(account_address: address): &OwnerRegistration { + assert!( + exists(account_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + borrow_global(account_address) + } + + inline fun borrow_owner_registration_mut(account_address: address) + : &mut OwnerRegistration { + assert!( + exists(account_address), + error::invalid_argument(E_ADDRESS_NOT_REGISTERED) + ); + borrow_global_mut(account_address) + } + + #[test_only] + public fun init_module_for_testing(publisher: &signer) { + init_module(publisher); + } + + #[test_only] + public fun test_start_dispatch( + callback_address: address, + callback_module_name: String, + callback_function: String, + data: vector + ): Object acquires RegistryState, OwnerRegistration { + start_dispatch( + callback_address, + callback_module_name, + callback_function, + data + ) + } + + #[test_only] + public fun test_finish_dispatch(callback_address: address) acquires RegistryState { + finish_dispatch(callback_address) + } + + #[test_only] + public fun move_from_owner_transfers(owner_address: address) acquires OwnerTransfers { + let OwnerTransfers { pending_transfers } = + move_from(owner_address); + pending_transfers.destroy({ |_dv| {} }); + } +} +` + +/** sources/mcms.move */ +export const MCMS_MCMS_MOVE = `/// This module is the Aptos implementation of Chainlink's MultiChainMultiSig contract. +module mcms::mcms { + use std::aptos_hash::keccak256; + use std::bcs; + use std::event; + use std::signer; + use std::simple_map::{Self, SimpleMap}; + use std::string::{String}; + use aptos_std::smart_table::{Self, SmartTable}; + use aptos_std::smart_vector::{Self, SmartVector}; + use aptos_framework::chain_id; + use aptos_framework::object::{Self, ExtendRef, Object}; + use aptos_framework::timestamp; + use aptos_std::secp256k1; + use mcms::bcs_stream::{Self, BCSStream}; + use mcms::mcms_account; + use mcms::mcms_deployer; + use mcms::mcms_registry; + use mcms::params::{Self}; + + const BYPASSER_ROLE: u8 = 0; + const CANCELLER_ROLE: u8 = 1; + const PROPOSER_ROLE: u8 = 2; + const TIMELOCK_ROLE: u8 = 3; + const MAX_ROLE: u8 = 4; + + const NUM_GROUPS: u64 = 32; + const MAX_NUM_SIGNERS: u64 = 200; + + // equivalent to initializing empty uint8[NUM_GROUPS] in Solidity + const VEC_NUM_GROUPS: vector = vector[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + + // keccak256("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA_APTOS") + const MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA: vector = x"a71d47b6c00b64ee21af96a1d424cb2dcbbed12becdcd3b4e6c7fc4c2f80a697"; + + // keccak256("MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP_APTOS") + const MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP: vector = x"e5a6d1256b00d7ec22512b6b60a3f4d75c559745d2dbf309f77b8b756caabe14"; + + /// Special timestamp value indicating an operation is done + const DONE_TIMESTAMP: u64 = 1; + + const ZERO_HASH: vector = vector[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct MultisigState has key { + bypasser: Object, + canceller: Object, + proposer: Object + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct Multisig has key { + extend_ref: ExtendRef, + + /// signers is used to easily validate the existence of the signer by its address. We still + /// have signers stored in config in order to easily deactivate them when a new config is set. + signers: SimpleMap, Signer>, + config: Config, + + /// Remember signed hashes that this contract has seen. Each signed hash can only be set once. + seen_signed_hashes: SimpleMap, bool>, + expiring_root_and_op_count: ExpiringRootAndOpCount, + root_metadata: RootMetadata + } + + struct Op has copy, drop { + role: u8, + chain_id: u256, + multisig: address, + nonce: u64, + to: address, + module_name: String, + function_name: String, + data: vector + } + + struct RootMetadata has copy, drop, store { + role: u8, + chain_id: u256, + multisig: address, + pre_op_count: u64, + post_op_count: u64, + override_previous_root: bool + } + + struct Signer has store, copy, drop { + addr: vector, + index: u8, // index of signer in config.signers + group: u8 // 0 <= group < NUM_GROUPS. Each signer can only be in one group. + } + + struct Config has store, copy, drop { + signers: vector, + + // group_quorums[i] stores the quorum for the i-th signer group. Any group with + // group_quorums[i] = 0 is considered disabled. The i-th group is successful if + // it is enabled and at least group_quorums[i] of its children are successful. + group_quorums: vector, + + // group_parents[i] stores the parent group of the i-th signer group. We ensure that the + // groups form a tree structure (where the root/0-th signer group points to itself as + // parent) by enforcing + // - (i != 0) implies (group_parents[i] < i) + // - group_parents[0] == 0 + group_parents: vector + } + + struct ExpiringRootAndOpCount has store, drop { + root: vector, + valid_until: u64, + op_count: u64 + } + + #[event] + struct MultisigStateInitialized has drop, store { + bypasser: Object, + canceller: Object, + proposer: Object + } + + #[event] + struct ConfigSet has drop, store { + role: u8, + config: Config, + is_root_cleared: bool + } + + #[event] + struct NewRoot has drop, store { + role: u8, + root: vector, + valid_until: u64, + metadata: RootMetadata + } + + #[event] + struct OpExecuted has drop, store { + role: u8, + chain_id: u256, + multisig: address, + nonce: u64, + to: address, + module_name: String, + function_name: String, + data: vector + } + + const E_ALREADY_SEEN_HASH: u64 = 1; + const E_POST_OP_COUNT_REACHED: u64 = 2; + const E_WRONG_CHAIN_ID: u64 = 3; + const E_WRONG_MULTISIG: u64 = 4; + const E_ROOT_EXPIRED: u64 = 5; + const E_WRONG_NONCE: u64 = 6; + const E_VALID_UNTIL_EXPIRED: u64 = 7; + const E_INVALID_SIGNER: u64 = 8; + const E_MISSING_CONFIG: u64 = 9; + const E_INSUFFICIENT_SIGNERS: u64 = 10; + const E_PROOF_CANNOT_BE_VERIFIED: u64 = 11; + const E_PENDING_OPS: u64 = 12; + const E_WRONG_PRE_OP_COUNT: u64 = 13; + const E_WRONG_POST_OP_COUNT: u64 = 14; + const E_INVALID_NUM_SIGNERS: u64 = 15; + const E_SIGNER_GROUPS_LEN_MISMATCH: u64 = 16; + const E_INVALID_GROUP_QUORUM_LEN: u64 = 17; + const E_INVALID_GROUP_PARENTS_LEN: u64 = 18; + const E_OUT_OF_BOUNDS_GROUP: u64 = 19; + const E_GROUP_TREE_NOT_WELL_FORMED: u64 = 20; + const E_SIGNER_IN_DISABLED_GROUP: u64 = 21; + const E_OUT_OF_BOUNDS_GROUP_QUORUM: u64 = 22; + const E_SIGNER_ADDR_MUST_BE_INCREASING: u64 = 23; + const E_INVALID_SIGNER_ADDR_LEN: u64 = 24; + const E_UNKNOWN_MCMS_MODULE_FUNCTION: u64 = 25; + const E_UNKNOWN_FRAMEWORK_MODULE_FUNCTION: u64 = 26; + const E_UNKNOWN_FRAMEWORK_MODULE: u64 = 27; + const E_SELF_CALL_ROLE_MISMATCH: u64 = 28; + const E_NOT_BYPASSER_ROLE: u64 = 29; + const E_INVALID_ROLE: u64 = 30; + const E_NOT_AUTHORIZED_ROLE: u64 = 31; + const E_NOT_AUTHORIZED: u64 = 32; + const E_OPERATION_ALREADY_SCHEDULED: u64 = 33; + const E_INSUFFICIENT_DELAY: u64 = 34; + const E_OPERATION_NOT_READY: u64 = 35; + const E_MISSING_DEPENDENCY: u64 = 36; + const E_OPERATION_CANNOT_BE_CANCELLED: u64 = 37; + const E_FUNCTION_BLOCKED: u64 = 38; + const E_INVALID_INDEX: u64 = 39; + const E_UNKNOWN_MCMS_ACCOUNT_MODULE_FUNCTION: u64 = 40; + const E_UNKNOWN_MCMS_DEPLOYER_MODULE_FUNCTION: u64 = 41; + const E_UNKNOWN_MCMS_REGISTRY_MODULE_FUNCTION: u64 = 42; + const E_INVALID_PARAMETERS: u64 = 43; + const E_INVALID_SIGNATURE_LEN: u64 = 44; + const E_INVALID_V_SIGNATURE: u64 = 45; + const E_FAILED_ECDSA_RECOVER: u64 = 46; + const E_INVALID_MODULE_NAME: u64 = 47; + const E_UNKNOWN_MCMS_TIMELOCK_FUNCTION: u64 = 48; + const E_INVALID_ROOT_LEN: u64 = 49; + const E_NOT_CANCELLER_ROLE: u64 = 50; + const E_NOT_TIMELOCK_ROLE: u64 = 51; + const E_UNKNOWN_MCMS_MODULE: u64 = 52; + + fun init_module(publisher: &signer) { + let bypasser = create_multisig(publisher, BYPASSER_ROLE); + let canceller = create_multisig(publisher, CANCELLER_ROLE); + let proposer = create_multisig(publisher, PROPOSER_ROLE); + + move_to( + publisher, + MultisigState { bypasser, canceller, proposer } + ); + + event::emit(MultisigStateInitialized { bypasser, canceller, proposer }); + + move_to( + publisher, + Timelock { + min_delay: 0, + timestamps: smart_table::new(), + blocked_functions: smart_vector::new() + } + ); + + event::emit(TimelockInitialized { min_delay: 0 }); + } + + inline fun create_multisig(publisher: &signer, role: u8): Object { + let constructor_ref = &object::create_object(signer::address_of(publisher)); + let object_signer = object::generate_signer(constructor_ref); + let extend_ref = object::generate_extend_ref(constructor_ref); + + move_to( + &object_signer, + Multisig { + extend_ref, + signers: simple_map::new(), + config: Config { + signers: vector[], + group_quorums: VEC_NUM_GROUPS, + group_parents: VEC_NUM_GROUPS + }, + seen_signed_hashes: simple_map::new(), + expiring_root_and_op_count: ExpiringRootAndOpCount { + root: vector[], + valid_until: 0, + op_count: 0 + }, + root_metadata: RootMetadata { + role, + chain_id: 0, + multisig: signer::address_of(&object_signer), + pre_op_count: 0, + post_op_count: 0, + override_previous_root: false + } + } + ); + + object::object_from_constructor_ref(constructor_ref) + } + + /// @notice set_root Sets a new expiring root. + /// + /// @param root is the new expiring root. + /// @param valid_until is the time by which root is valid + /// @param chain_id is the chain id of the chain on which the root is valid + /// @param multisig is the address of the multisig to set the root for + /// @param pre_op_count is the number of operations that have been executed before this root was set + /// @param post_op_count is the number of operations that have been executed after this root was set + /// @param override_previous_root is a boolean that indicates whether to override the previous root + /// @param metadata_proof is the MerkleProof of inclusion of the metadata in the Merkle tree. + /// @param signatures the ECDSA signatures on (root, valid_until). + /// + /// @dev the message (root, valid_until) should be signed by a sufficient set of signers. + /// This signature authenticates also the metadata. + /// + /// @dev this method can be executed by anyone who has the root and valid signatures. + /// as we validate the correctness of signatures, this imposes no risk. + public entry fun set_root( + role: u8, + root: vector, + valid_until: u64, + chain_id: u256, + multisig_addr: address, + pre_op_count: u64, + post_op_count: u64, + override_previous_root: bool, + metadata_proof: vector>, + signatures: vector> + ) acquires Multisig, MultisigState { + assert!(is_valid_role(role), E_INVALID_ROLE); + + let metadata = RootMetadata { + role, + chain_id, + multisig: multisig_addr, + pre_op_count, + post_op_count, + override_previous_root + }; + + let signed_hash = compute_eth_message_hash(root, valid_until); + + // Validate that \`multisig\` is a registered multisig for \`role\`. + let multisig = borrow_multisig_mut(multisig_object(role)); + + assert!( + !multisig.seen_signed_hashes.contains_key(&signed_hash), + E_ALREADY_SEEN_HASH + ); + assert!(timestamp::now_seconds() <= valid_until, E_VALID_UNTIL_EXPIRED); + assert!(metadata.chain_id == (chain_id::get() as u256), E_WRONG_CHAIN_ID); + assert!(metadata.multisig == @mcms, E_WRONG_MULTISIG); + + let op_count = multisig.expiring_root_and_op_count.op_count; + assert!( + override_previous_root || op_count == multisig.root_metadata.post_op_count, + E_PENDING_OPS + ); + + assert!(op_count == metadata.pre_op_count, E_WRONG_PRE_OP_COUNT); + assert!(metadata.pre_op_count <= metadata.post_op_count, E_WRONG_POST_OP_COUNT); + + let metadata_leaf_hash = hash_metadata_leaf(metadata); + assert!( + verify_merkle_proof(metadata_proof, root, metadata_leaf_hash), + E_PROOF_CANNOT_BE_VERIFIED + ); + + let prev_address = vector[]; + let group_vote_counts: vector = vector[]; + params::right_pad_vec(&mut group_vote_counts, NUM_GROUPS); + + let signatures_len = signatures.length(); + for (i in 0..signatures_len) { + let signature = signatures[i]; + let signer_addr = ecdsa_recover_evm_addr(signed_hash, signature); + // the off-chain system is required to sort the signatures by the + // signer address in an increasing order + if (i > 0) { + assert!( + params::vector_u8_gt(&signer_addr, &prev_address), + E_SIGNER_ADDR_MUST_BE_INCREASING + ); + }; + prev_address = signer_addr; + + assert!(multisig.signers.contains_key(&signer_addr), E_INVALID_SIGNER); + let signer = *multisig.signers.borrow(&signer_addr); + + // check group quorums + let group: u8 = signer.group; + while (true) { + let group_vote_count = group_vote_counts.borrow_mut((group as u64)); + *group_vote_count += 1; + + let quorum = multisig.config.group_quorums.borrow((group as u64)); + if (*group_vote_count != *quorum) { + // bail out unless we just hit the quorum. we only hit each quorum once, + // so we never move on to the parent of a group more than once. + break + }; + + if (group == 0) { + // root group reached + break + }; + + // group quorum reached, restart loop and check parent group + group = multisig.config.group_parents[(group as u64)]; + }; + }; + + // the group at the root of the tree (with index 0) determines whether the vote passed, + // we cannot proceed if it isn't configured with a valid (non-zero) quorum + let root_group_quorum = multisig.config.group_quorums[0]; + assert!(root_group_quorum != 0, E_MISSING_CONFIG); + + // check root group reached quorum + let root_group_vote_count = group_vote_counts[0]; + assert!(root_group_vote_count >= root_group_quorum, E_INSUFFICIENT_SIGNERS); + + multisig.seen_signed_hashes.add(signed_hash, true); + multisig.expiring_root_and_op_count = ExpiringRootAndOpCount { + root, + valid_until, + op_count: metadata.pre_op_count + }; + multisig.root_metadata = metadata; + + event::emit( + NewRoot { + role, + root, + valid_until, + metadata: RootMetadata { + role, + chain_id, + multisig: multisig_addr, + pre_op_count: metadata.pre_op_count, + post_op_count: metadata.post_op_count, + override_previous_root: metadata.override_previous_root + } + } + ); + } + + inline fun ecdsa_recover_evm_addr( + eth_signed_message_hash: vector, signature: vector + ): vector { + // ensure signature has correct length - (r,s,v) concatenated = 65 bytes + assert!(signature.length() == 65, E_INVALID_SIGNATURE_LEN); + // extract v from signature + let v = signature.pop_back(); + // convert 64 byte signature into ECDSASignature struct + let sig = secp256k1::ecdsa_signature_from_bytes(signature); + // Aptos uses the rust libsecp256k1 parse() under the hood which has a different numbering scheme + // see: https://docs.rs/libsecp256k1/latest/libsecp256k1/struct.RecoveryId.html#method.parse_rpc + assert!(v >= 27 && v < 27 + 4, E_INVALID_V_SIGNATURE); + let v = v - 27; + + // retrieve signer public key + let public_key = secp256k1::ecdsa_recover(eth_signed_message_hash, v, &sig); + assert!(public_key.is_some(), E_FAILED_ECDSA_RECOVER); + + // return last 20 bytes of hashed public key as the recovered ethereum address + let public_key_bytes = + secp256k1::ecdsa_raw_public_key_to_bytes(&public_key.extract()); + keccak256(public_key_bytes).trim(12) // trims publicKeyBytes to 12 bytes, returns trimmed last 20 bytes + } + + /// Execute an operation after verifying its inclusion in the merkle tree + public entry fun execute( + role: u8, + chain_id: u256, + multisig_addr: address, + nonce: u64, + to: address, + module_name: String, + function_name: String, + data: vector, + proof: vector> + ) acquires Multisig, MultisigState, Timelock { + assert!(is_valid_role(role), E_INVALID_ROLE); + + let op = Op { + role, + chain_id, + multisig: multisig_addr, + nonce, + to, + module_name, + function_name, + data + }; + let multisig = borrow_multisig_mut(multisig_object(role)); + + assert!( + multisig.root_metadata.post_op_count + > multisig.expiring_root_and_op_count.op_count, + E_POST_OP_COUNT_REACHED + ); + assert!(chain_id == (chain_id::get() as u256), E_WRONG_CHAIN_ID); + assert!( + timestamp::now_seconds() <= multisig.expiring_root_and_op_count.valid_until, + E_ROOT_EXPIRED + ); + assert!(op.multisig == @mcms, E_WRONG_MULTISIG); + assert!(nonce == multisig.expiring_root_and_op_count.op_count, E_WRONG_NONCE); + + // computes keccak256(abi.encode(MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP, op)) + let hashed_leaf = hash_op_leaf(MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_OP, op); + assert!( + verify_merkle_proof( + proof, multisig.expiring_root_and_op_count.root, hashed_leaf + ), + E_PROOF_CANNOT_BE_VERIFIED + ); + + multisig.expiring_root_and_op_count.op_count += 1; + + // Only allow dispatching to timelock functions + assert!( + op.to == @mcms && *op.module_name.bytes() == b"mcms", + E_INVALID_MODULE_NAME + ); + + dispatch_to_timelock(role, op.function_name, op.data); + + event::emit( + OpExecuted { + role, + chain_id, + multisig: multisig_addr, + nonce, + to, + module_name, + function_name, + data + } + ); + } + + /// Only callable from \`execute\`, the role that was validated is passed down to the timelock functions + inline fun dispatch_to_timelock( + role: u8, function_name: String, data: vector + ) { + let function_name_bytes = *function_name.bytes(); + let stream = bcs_stream::new(data); + + if (function_name_bytes == b"timelock_schedule_batch") { + dispatch_timelock_schedule_batch(role, &mut stream) + } else if (function_name_bytes == b"timelock_bypasser_execute_batch") { + dispatch_timelock_bypasser_execute_batch(role, &mut stream) + } else if (function_name_bytes == b"timelock_execute_batch") { + dispatch_timelock_execute_batch(&mut stream) + } else if (function_name_bytes == b"timelock_cancel") { + dispatch_timelock_cancel(role, &mut stream) + } else if (function_name_bytes == b"timelock_update_min_delay") { + dispatch_timelock_update_min_delay(role, &mut stream) + } else if (function_name_bytes == b"timelock_block_function") { + dispatch_timelock_block_function(role, &mut stream) + } else if (function_name_bytes == b"timelock_unblock_function") { + dispatch_timelock_unblock_function(role, &mut stream) + } else { + abort E_UNKNOWN_MCMS_TIMELOCK_FUNCTION + } + } + + /// \`dispatch_timelock_\` functions should only be called from dispatch functions + inline fun dispatch_timelock_schedule_batch( + role: u8, stream: &mut BCSStream + ) { + assert!( + role == PROPOSER_ROLE || role == TIMELOCK_ROLE, E_NOT_AUTHORIZED_ROLE + ); + + let targets = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_address(stream) + ); + let module_names = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_string(stream) + ); + let function_names = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_string(stream) + ); + let datas = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + let predecessor = bcs_stream::deserialize_vector_u8(stream); + let salt = bcs_stream::deserialize_vector_u8(stream); + let delay = bcs_stream::deserialize_u64(stream); + bcs_stream::assert_is_consumed(stream); + + timelock_schedule_batch( + targets, + module_names, + function_names, + datas, + predecessor, + salt, + delay + ) + } + + inline fun dispatch_timelock_bypasser_execute_batch( + role: u8, stream: &mut BCSStream + ) { + assert!( + role == BYPASSER_ROLE || role == TIMELOCK_ROLE, E_NOT_AUTHORIZED_ROLE + ); + + let targets = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_address(stream) + ); + let module_names = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_string(stream) + ); + let function_names = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_string(stream) + ); + let datas = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(stream); + + timelock_bypasser_execute_batch(targets, module_names, function_names, datas) + } + + inline fun dispatch_timelock_execute_batch(stream: &mut BCSStream) { + let targets = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_address(stream) + ); + let module_names = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_string(stream) + ); + let function_names = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_string(stream) + ); + let datas = + bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + let predecessor = bcs_stream::deserialize_vector_u8(stream); + let salt = bcs_stream::deserialize_vector_u8(stream); + bcs_stream::assert_is_consumed(stream); + + timelock_execute_batch( + targets, + module_names, + function_names, + datas, + predecessor, + salt + ) + } + + inline fun dispatch_timelock_cancel(role: u8, stream: &mut BCSStream) { + assert!( + role == CANCELLER_ROLE || role == TIMELOCK_ROLE, E_NOT_AUTHORIZED_ROLE + ); + + let id = bcs_stream::deserialize_vector_u8(stream); + bcs_stream::assert_is_consumed(stream); + + timelock_cancel(id) + } + + inline fun dispatch_timelock_update_min_delay( + role: u8, stream: &mut BCSStream + ) { + assert!(role == TIMELOCK_ROLE, E_NOT_TIMELOCK_ROLE); + + let new_min_delay = bcs_stream::deserialize_u64(stream); + bcs_stream::assert_is_consumed(stream); + + timelock_update_min_delay(new_min_delay) + } + + inline fun dispatch_timelock_block_function( + role: u8, stream: &mut BCSStream + ) { + assert!(role == TIMELOCK_ROLE, E_NOT_TIMELOCK_ROLE); + + let target = bcs_stream::deserialize_address(stream); + let module_name = bcs_stream::deserialize_string(stream); + let function_name = bcs_stream::deserialize_string(stream); + bcs_stream::assert_is_consumed(stream); + + timelock_block_function(target, module_name, function_name) + } + + inline fun dispatch_timelock_unblock_function( + role: u8, stream: &mut BCSStream + ) { + assert!(role == TIMELOCK_ROLE, E_NOT_TIMELOCK_ROLE); + + let target = bcs_stream::deserialize_address(stream); + let module_name = bcs_stream::deserialize_string(stream); + let function_name = bcs_stream::deserialize_string(stream); + bcs_stream::assert_is_consumed(stream); + + timelock_unblock_function(target, module_name, function_name) + } + + /// Updates the multisig configuration, including signer addresses and group settings. + public entry fun set_config( + caller: &signer, + role: u8, + signer_addresses: vector>, + signer_groups: vector, + group_quorums: vector, + group_parents: vector, + clear_root: bool + ) acquires Multisig, MultisigState { + mcms_account::assert_is_owner(caller); + + assert!( + signer_addresses.length() != 0 + && signer_addresses.length() <= MAX_NUM_SIGNERS, + E_INVALID_NUM_SIGNERS + ); + assert!( + signer_addresses.length() == signer_groups.length(), + E_SIGNER_GROUPS_LEN_MISMATCH + ); + assert!(group_quorums.length() == NUM_GROUPS, E_INVALID_GROUP_QUORUM_LEN); + assert!(group_parents.length() == NUM_GROUPS, E_INVALID_GROUP_PARENTS_LEN); + + // validate group structure + // counts number of children of each group + let group_children_counts = vector[]; + params::right_pad_vec(&mut group_children_counts, NUM_GROUPS); + // first, we count the signers as children + signer_groups.for_each_ref( + |group| { + let group: u64 = *group as u64; + assert!(group < NUM_GROUPS, E_OUT_OF_BOUNDS_GROUP); + let count = group_children_counts.borrow_mut(group); + *count += 1; + } + ); + + // second, we iterate backwards so as to check each group and propagate counts from + // child group to parent groups up the tree to the root + for (j in 0..NUM_GROUPS) { + let i = NUM_GROUPS - j - 1; + // ensure we have a well-formed group tree: + // - the root should have itself as parent + // - all other groups should have a parent group with a lower index + let group_parent = group_parents[i] as u64; + assert!( + i == 0 || group_parent < i, E_GROUP_TREE_NOT_WELL_FORMED + ); + assert!( + i != 0 || group_parent == 0, E_GROUP_TREE_NOT_WELL_FORMED + ); + + let group_quorum = group_quorums[i]; + let disabled = group_quorum == 0; + let group_children_count = group_children_counts[i]; + if (disabled) { + // if group is disabled, ensure it has no children + assert!(group_children_count == 0, E_SIGNER_IN_DISABLED_GROUP); + } else { + // if group is enabled, ensure group quorum can be met + assert!( + group_children_count >= group_quorum, E_OUT_OF_BOUNDS_GROUP_QUORUM + ); + + // propagate children counts to parent group + let count = group_children_counts.borrow_mut(group_parent); + *count += 1; + }; + }; + + let multisig = borrow_multisig_mut(multisig_object(role)); + + // remove old signer addresses + multisig.signers = simple_map::new(); + multisig.config.signers = vector[]; + + // save group quorums and parents to timelock + multisig.config.group_quorums = group_quorums; + multisig.config.group_parents = group_parents; + + // check signer addresses are in increasing order and save signers to timelock + // evm zero address (20 bytes of 0) is the smallest address possible + let prev_signer_addr = vector[]; + for (i in 0..signer_addresses.length()) { + let signer_addr = signer_addresses[i]; + assert!(signer_addr.length() == 20, E_INVALID_SIGNER_ADDR_LEN); + + if (i > 0) { + assert!( + params::vector_u8_gt(&signer_addr, &prev_signer_addr), + E_SIGNER_ADDR_MUST_BE_INCREASING + ); + }; + + let signer = Signer { + addr: signer_addr, + index: (i as u8), + group: signer_groups[i] + }; + multisig.signers.add(signer_addr, signer); + multisig.config.signers.push_back(signer); + prev_signer_addr = signer_addr; + }; + + if (clear_root) { + // clearRoot is equivalent to overriding with a completely empty root + let op_count = multisig.expiring_root_and_op_count.op_count; + multisig.expiring_root_and_op_count = ExpiringRootAndOpCount { + root: vector[], + valid_until: 0, + op_count + }; + multisig.root_metadata = RootMetadata { + role, + chain_id: (chain_id::get() as u256), + multisig: @mcms, + pre_op_count: op_count, + post_op_count: op_count, + override_previous_root: true + }; + }; + + event::emit(ConfigSet { + role, + config: multisig.config, + is_root_cleared: clear_root + }); + } + + public fun verify_merkle_proof( + proof: vector>, root: vector, leaf: vector + ): bool { + let computed_hash = leaf; + proof.for_each_ref( + |proof_element| { + let (left, right) = + if (params::vector_u8_gt(&computed_hash, proof_element)) { + (*proof_element, computed_hash) + } else { + (computed_hash, *proof_element) + }; + let hash_input: vector = left; + hash_input.append(right); + computed_hash = keccak256(hash_input); + } + ); + computed_hash == root + } + + public fun compute_eth_message_hash( + root: vector, valid_until: u64 + ): vector { + // abi.encode(root (bytes32), valid_until) + let valid_until_bytes = params::encode_uint(valid_until, 32); + assert!(root.length() == 32, E_INVALID_ROOT_LEN); // root should be 32 bytes + let abi_encoded_params = &mut root; + abi_encoded_params.append(valid_until_bytes); + + // keccak256(abi_encoded_params) + let hashed_encoded_params = keccak256(*abi_encoded_params); + + // ECDSA.toEthSignedMessageHash() + let eth_msg_prefix = b"\\x19Ethereum Signed Message:\\n32"; + let hash = &mut eth_msg_prefix; + hash.append(hashed_encoded_params); + keccak256(*hash) + } + + public fun hash_op_leaf(domain_separator: vector, op: Op): vector { + let packed = vector[]; + packed.append(domain_separator); + packed.append(bcs::to_bytes(&op.role)); + packed.append(bcs::to_bytes(&op.chain_id)); + packed.append(bcs::to_bytes(&op.multisig)); + packed.append(bcs::to_bytes(&op.nonce)); + packed.append(bcs::to_bytes(&op.to)); + packed.append(bcs::to_bytes(&op.module_name)); + packed.append(bcs::to_bytes(&op.function_name)); + packed.append(bcs::to_bytes(&op.data)); + keccak256(packed) + } + + #[view] + public fun seen_signed_hashes( + multisig: Object + ): SimpleMap, bool> acquires Multisig { + borrow_multisig(multisig).seen_signed_hashes + } + + #[view] + /// Returns the current Merkle root along with its expiration timestamp and op count. + public fun expiring_root_and_op_count( + multisig: Object + ): (vector, u64, u64) acquires Multisig { + let multisig = borrow_multisig(multisig); + ( + multisig.expiring_root_and_op_count.root, + multisig.expiring_root_and_op_count.valid_until, + multisig.expiring_root_and_op_count.op_count + ) + } + + #[view] + public fun root_metadata(multisig: Object): RootMetadata acquires Multisig { + borrow_multisig(multisig).root_metadata + } + + #[view] + public fun get_root_metadata(role: u8): RootMetadata acquires MultisigState, Multisig { + let multisig = multisig_object(role); + borrow_multisig(multisig).root_metadata + } + + #[view] + public fun get_op_count(role: u8): u64 acquires MultisigState, Multisig { + let multisig = multisig_object(role); + borrow_multisig(multisig).expiring_root_and_op_count.op_count + } + + #[view] + public fun get_root(role: u8): (vector, u64) acquires MultisigState, Multisig { + let multisig = borrow_multisig(multisig_object(role)); + ( + multisig.expiring_root_and_op_count.root, + multisig.expiring_root_and_op_count.valid_until + ) + } + + #[view] + public fun get_config(role: u8): Config acquires MultisigState, Multisig { + let multisig = multisig_object(role); + borrow_multisig(multisig).config + } + + #[view] + public fun signers(multisig: Object): SimpleMap, Signer> acquires Multisig { + borrow_multisig(multisig).signers + } + + #[view] + /// Returns the registered multisig objects for the given role. + public fun multisig_object(role: u8): Object acquires MultisigState { + let state = borrow(); + if (role == BYPASSER_ROLE) { + state.bypasser + } else if (role == CANCELLER_ROLE) { + state.canceller + } else if (role == PROPOSER_ROLE) { + state.proposer + } else { + abort E_INVALID_ROLE + } + } + + #[view] + public fun num_groups(): u64 { + NUM_GROUPS + } + + #[view] + public fun max_num_signers(): u64 { + MAX_NUM_SIGNERS + } + + #[view] + public fun bypasser_role(): u8 { + BYPASSER_ROLE + } + + #[view] + public fun canceller_role(): u8 { + CANCELLER_ROLE + } + + #[view] + public fun proposer_role(): u8 { + PROPOSER_ROLE + } + + #[view] + public fun timelock_role(): u8 { + TIMELOCK_ROLE + } + + #[view] + public fun is_valid_role(role: u8): bool { + role < MAX_ROLE + } + + #[view] + public fun zero_hash(): vector { + ZERO_HASH + } + + fun hash_metadata_leaf(metadata: RootMetadata): vector { + let packed = vector[]; + packed.append(MANY_CHAIN_MULTI_SIG_DOMAIN_SEPARATOR_METADATA); + packed.append(bcs::to_bytes(&metadata.role)); + packed.append(bcs::to_bytes(&metadata.chain_id)); + packed.append(bcs::to_bytes(&metadata.multisig)); + packed.append(bcs::to_bytes(&metadata.pre_op_count)); + packed.append(bcs::to_bytes(&metadata.post_op_count)); + packed.append(bcs::to_bytes(&metadata.override_previous_root)); + keccak256(packed) + } + + inline fun borrow_multisig(obj: Object): &Multisig acquires Multisig { + borrow_global(object::object_address(&obj)) + } + + inline fun borrow_multisig_mut(multisig: Object): &mut Multisig acquires Multisig { + borrow_global_mut(object::object_address(&multisig)) + } + + inline fun borrow(): &MultisigState acquires MultisigState { + borrow_global(@mcms) + } + + inline fun borrow_mut(): &mut MultisigState acquires MultisigState { + borrow_global_mut(@mcms) + } + + public fun role(root_metadata: RootMetadata): u8 { + root_metadata.role + } + + public fun chain_id(root_metadata: RootMetadata): u256 { + root_metadata.chain_id + } + + public fun root_metadata_multisig(root_metadata: RootMetadata): address { + root_metadata.multisig + } + + public fun pre_op_count(root_metadata: RootMetadata): u64 { + root_metadata.pre_op_count + } + + public fun post_op_count(root_metadata: RootMetadata): u64 { + root_metadata.post_op_count + } + + public fun override_previous_root(root_metadata: RootMetadata): bool { + root_metadata.override_previous_root + } + + public fun config_signers(config: &Config): vector { + config.signers + } + + public fun config_group_quorums(config: &Config): vector { + config.group_quorums + } + + public fun config_group_parents(config: &Config): vector { + config.group_parents + } + + // ======================================================================================= + // | Timelock Implementation | + // ======================================================================================= + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct Timelock has key { + min_delay: u64, + /// hashed batch of hashed calls -> timestamp + timestamps: SmartTable, u64>, + /// blocked functions + blocked_functions: SmartVector + } + + struct Call has copy, drop, store { + function: Function, + data: vector + } + + struct Function has copy, drop, store { + target: address, + module_name: String, + function_name: String + } + + #[event] + struct TimelockInitialized has drop, store { + min_delay: u64 + } + + #[event] + struct BypasserCallExecuted has drop, store { + index: u64, + target: address, + module_name: String, + function_name: String, + data: vector + } + + #[event] + struct Cancelled has drop, store { + id: vector + } + + #[event] + struct CallScheduled has drop, store { + id: vector, + index: u64, + target: address, + module_name: String, + function_name: String, + data: vector, + predecessor: vector, + salt: vector, + delay: u64 + } + + #[event] + struct CallExecuted has drop, store { + id: vector, + index: u64, + target: address, + module_name: String, + function_name: String, + data: vector + } + + #[event] + struct UpdateMinDelay has drop, store { + old_min_delay: u64, + new_min_delay: u64 + } + + #[event] + struct FunctionBlocked has drop, store { + target: address, + module_name: String, + function_name: String + } + + #[event] + struct FunctionUnblocked has drop, store { + target: address, + module_name: String, + function_name: String + } + + /// Schedule a batch of calls to be executed after a delay. + /// This function can only be called by PROPOSER or ADMIN role. + inline fun timelock_schedule_batch( + targets: vector
, + module_names: vector, + function_names: vector, + datas: vector>, + predecessor: vector, + salt: vector, + delay: u64 + ) { + let calls = create_calls(targets, module_names, function_names, datas); + let id = hash_operation_batch(calls, predecessor, salt); + let timelock = borrow_mut_timelock(); + + timelock_schedule(timelock, id, delay); + + for (i in 0..calls.length()) { + assert_not_blocked(timelock, &calls[i].function); + event::emit( + CallScheduled { + id, + index: i, + target: calls[i].function.target, + module_name: calls[i].function.module_name, + function_name: calls[i].function.function_name, + data: calls[i].data, + predecessor, + salt, + delay + } + ); + }; + } + + inline fun timelock_schedule( + timelock: &mut Timelock, id: vector, delay: u64 + ) { + assert!( + !timelock_is_operation_internal(timelock, id), + E_OPERATION_ALREADY_SCHEDULED + ); + assert!(delay >= timelock.min_delay, E_INSUFFICIENT_DELAY); + + let timestamp = timestamp::now_seconds() + delay; + timelock.timestamps.add(id, timestamp); + + } + + inline fun timelock_before_call( + id: vector, predecessor: vector + ) { + assert!(timelock_is_operation_ready(id), E_OPERATION_NOT_READY); + assert!( + predecessor == ZERO_HASH || timelock_is_operation_done(predecessor), + E_MISSING_DEPENDENCY + ); + } + + inline fun timelock_after_call(id: vector) { + assert!(timelock_is_operation_ready(id), E_OPERATION_NOT_READY); + *borrow_mut_timelock().timestamps.borrow_mut(id) = DONE_TIMESTAMP; + } + + /// Anyone can call this as it checks if the operation was scheduled by a bypasser or proposer. + public entry fun timelock_execute_batch( + targets: vector
, + module_names: vector, + function_names: vector, + datas: vector>, + predecessor: vector, + salt: vector + ) acquires Multisig, MultisigState, Timelock { + let calls = create_calls(targets, module_names, function_names, datas); + let id = hash_operation_batch(calls, predecessor, salt); + + timelock_before_call(id, predecessor); + + for (i in 0..calls.length()) { + let function = calls[i].function; + let target = function.target; + let module_name = function.module_name; + let function_name = function.function_name; + let data = calls[i].data; + + timelock_dispatch(target, module_name, function_name, data); + + event::emit( + CallExecuted { + id, + index: i, + target, + module_name, + function_name, + data + } + ); + }; + + timelock_after_call(id); + } + + fun timelock_bypasser_execute_batch( + targets: vector
, + module_names: vector, + function_names: vector, + datas: vector> + ) acquires Multisig, MultisigState, Timelock { + let len = targets.length(); + assert!( + len == module_names.length() + && len == function_names.length() + && len == datas.length(), + E_INVALID_PARAMETERS + ); + + for (i in 0..len) { + let target = targets[i]; + let module_name = module_names[i]; + let function_name = function_names[i]; + let data = datas[i]; + + timelock_dispatch(target, module_name, function_name, data); + + event::emit( + BypasserCallExecuted { index: i, target, module_name, function_name, data } + ); + }; + } + + /// If we reach here, we know that the call was scheduled and is ready to be executed. + /// Only callable from \`timelock_execute_batch\` or \`timelock_bypasser_execute_batch\` + inline fun timelock_dispatch( + target: address, + module_name: String, + function_name: String, + data: vector + ) { + let module_name_bytes = *module_name.bytes(); + let function_name_bytes = *function_name.bytes(); + + if (target == @mcms) { + if (module_name_bytes == b"mcms") { + // dispatch to the mcms module's functions for setting config, scheduling, executing, and canceling operations. + timelock_dispatch_to_self(function_name, data); + } else if (module_name_bytes == b"mcms_account") { + // dispatch to the account module's functions for ownership transfers. + timelock_dispatch_to_account(function_name_bytes, data); + } else if (module_name_bytes == b"mcms_deployer") { + // dispatch to the deployer module's functions for deploying and upgrading contracts. + timelock_dispatch_to_deployer(function_name_bytes, data); + } else if (module_name_bytes == b"mcms_registry") { + // dispatch to the registry module's functions for code object management. + timelock_dispatch_to_registry(function_name_bytes, data); + } else { + abort E_UNKNOWN_MCMS_MODULE; + } + } else { + // If role is present, it must be a bypasser (calling from \`execute\`). + let object_meta = + mcms_registry::start_dispatch(target, module_name, function_name, data); + aptos_framework::dispatchable_fungible_asset::derived_supply(object_meta); + mcms_registry::finish_dispatch(target); + } + } + + inline fun timelock_dispatch_to_self( + function_name: String, data: vector + ) { + let stream = bcs_stream::new(data); + let fn_bytes = *function_name.bytes(); + let prefix = b"timelock"; + + if (fn_bytes.length() >= prefix.length() + && fn_bytes.slice(0, prefix.length()) == prefix) { + // Pass \`TIMELOCK_ROLE\` as the function call has already been validated + dispatch_to_timelock(TIMELOCK_ROLE, function_name, data); + } else if (fn_bytes == b"set_config") { + let role_param = bcs_stream::deserialize_u8(&mut stream); + let signer_addresses = + bcs_stream::deserialize_vector( + &mut stream, + |stream| { bcs_stream::deserialize_vector_u8(stream) } + ); + let signer_groups = bcs_stream::deserialize_vector_u8(&mut stream); + let group_quorums = bcs_stream::deserialize_vector_u8(&mut stream); + let group_parents = bcs_stream::deserialize_vector_u8(&mut stream); + let clear_root = bcs_stream::deserialize_bool(&mut stream); + bcs_stream::assert_is_consumed(&stream); + + set_config( + &mcms_account::get_signer(), // Must get MCMS signer for \`set_config\` + role_param, + signer_addresses, + signer_groups, + group_quorums, + group_parents, + clear_root + ); + } else { + abort E_UNKNOWN_MCMS_MODULE_FUNCTION + } + } + + inline fun timelock_dispatch_to_account( + function_name_bytes: vector, data: vector + ) { + let stream = bcs_stream::new(data); + let self_signer = &mcms_account::get_signer(); + + if (function_name_bytes == b"transfer_ownership") { + let target = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + mcms_account::transfer_ownership(self_signer, target); + } else if (function_name_bytes == b"accept_ownership") { + bcs_stream::assert_is_consumed(&stream); + mcms_account::accept_ownership(self_signer); + } else { + abort E_UNKNOWN_MCMS_ACCOUNT_MODULE_FUNCTION; + } + } + + inline fun timelock_dispatch_to_deployer( + function_name_bytes: vector, data: vector + ) { + let self_signer = &mcms_account::get_signer(); + let stream = bcs_stream::new(data); + + if (function_name_bytes == b"stage_code_chunk") { + let metadata_chunk = bcs_stream::deserialize_vector_u8(&mut stream); + let code_indices = + bcs_stream::deserialize_vector( + &mut stream, + |stream| { bcs_stream::deserialize_u16(stream) } + ); + let code_chunks = + bcs_stream::deserialize_vector( + &mut stream, + |stream| { bcs_stream::deserialize_vector_u8(stream) } + ); + bcs_stream::assert_is_consumed(&stream); + + mcms_deployer::stage_code_chunk( + self_signer, + metadata_chunk, + code_indices, + code_chunks + ); + } else if (function_name_bytes == b"stage_code_chunk_and_publish_to_object") { + let metadata_chunk = bcs_stream::deserialize_vector_u8(&mut stream); + let code_indices = + bcs_stream::deserialize_vector( + &mut stream, + |stream| { bcs_stream::deserialize_u16(stream) } + ); + let code_chunks = + bcs_stream::deserialize_vector( + &mut stream, + |stream| { bcs_stream::deserialize_vector_u8(stream) } + ); + let new_owner_seed = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + + mcms_deployer::stage_code_chunk_and_publish_to_object( + self_signer, + metadata_chunk, + code_indices, + code_chunks, + new_owner_seed + ); + } else if (function_name_bytes == b"stage_code_chunk_and_upgrade_object_code") { + let metadata_chunk = bcs_stream::deserialize_vector_u8(&mut stream); + let code_indices = + bcs_stream::deserialize_vector( + &mut stream, + |stream| { bcs_stream::deserialize_u16(stream) } + ); + let code_chunks = + bcs_stream::deserialize_vector( + &mut stream, + |stream| { bcs_stream::deserialize_vector_u8(stream) } + ); + let code_object_address = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + + mcms_deployer::stage_code_chunk_and_upgrade_object_code( + self_signer, + metadata_chunk, + code_indices, + code_chunks, + code_object_address + ); + } else if (function_name_bytes == b"cleanup_staging_area") { + bcs_stream::assert_is_consumed(&stream); + mcms_deployer::cleanup_staging_area(self_signer); + } else { + abort E_UNKNOWN_MCMS_DEPLOYER_MODULE_FUNCTION; + } + } + + inline fun timelock_dispatch_to_registry( + function_name_bytes: vector, data: vector + ) { + let stream = bcs_stream::new(data); + let self_signer = &mcms_account::get_signer(); + + if (function_name_bytes == b"create_owner_for_preexisting_code_object") { + let object_address = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + mcms_registry::create_owner_for_preexisting_code_object( + self_signer, object_address + ); + } else if (function_name_bytes == b"transfer_code_object") { + let object_address = bcs_stream::deserialize_address(&mut stream); + let new_owner_address = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + mcms_registry::transfer_code_object( + self_signer, object_address, new_owner_address + ); + } else if (function_name_bytes == b"execute_code_object_transfer") { + let object_address = bcs_stream::deserialize_address(&mut stream); + let new_owner_address = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + mcms_registry::execute_code_object_transfer( + self_signer, object_address, new_owner_address + ); + } else { + abort E_UNKNOWN_MCMS_REGISTRY_MODULE_FUNCTION; + } + } + + inline fun timelock_cancel(id: vector) { + assert!(timelock_is_operation_pending(id), E_OPERATION_CANNOT_BE_CANCELLED); + + borrow_mut_timelock().timestamps.remove(id); + event::emit(Cancelled { id }); + } + + inline fun timelock_update_min_delay(new_min_delay: u64) { + let timelock = borrow_mut_timelock(); + let old_min_delay = timelock.min_delay; + timelock.min_delay = new_min_delay; + + event::emit(UpdateMinDelay { old_min_delay, new_min_delay }); + } + + inline fun timelock_block_function( + target: address, module_name: String, function_name: String + ) { + let already_blocked = false; + let new_function = Function { target, module_name, function_name }; + let timelock = borrow_mut_timelock(); + + for (i in 0..timelock.blocked_functions.length()) { + let blocked_function = timelock.blocked_functions.borrow(i); + if (equals(&new_function, blocked_function)) { + already_blocked = true; + break + }; + }; + + if (!already_blocked) { + timelock.blocked_functions.push_back(new_function); + event::emit(FunctionBlocked { target, module_name, function_name }); + }; + } + + inline fun timelock_unblock_function( + target: address, module_name: String, function_name: String + ) { + let function_to_unblock = Function { target, module_name, function_name }; + let timelock = borrow_mut_timelock(); + + for (i in 0..timelock.blocked_functions.length()) { + let blocked_function = timelock.blocked_functions.borrow(i); + if (equals(&function_to_unblock, blocked_function)) { + timelock.blocked_functions.swap_remove(i); + event::emit(FunctionUnblocked { target, module_name, function_name }); + break + }; + }; + } + + inline fun assert_not_blocked( + timelock: &Timelock, function: &Function + ) { + for (i in 0..timelock.blocked_functions.length()) { + let blocked_function = timelock.blocked_functions.borrow(i); + if (equals(function, blocked_function)) { + abort E_FUNCTION_BLOCKED; + }; + }; + } + + #[view] + public fun timelock_get_blocked_function(index: u64): Function acquires Timelock { + let timelock = borrow_timelock(); + assert!(index < timelock.blocked_functions.length(), E_INVALID_INDEX); + *timelock.blocked_functions.borrow(index) + } + + #[view] + public fun timelock_is_operation(id: vector): bool acquires Timelock { + timelock_is_operation_internal(borrow_timelock(), id) + } + + inline fun timelock_is_operation_internal( + timelock: &Timelock, id: vector + ): bool { + timelock.timestamps.contains(id) && *timelock.timestamps.borrow(id) > 0 + } + + #[view] + public fun timelock_is_operation_pending(id: vector): bool acquires Timelock { + let timelock = borrow_timelock(); + timelock.timestamps.contains(id) + && *timelock.timestamps.borrow(id) > DONE_TIMESTAMP + } + + #[view] + public fun timelock_is_operation_ready(id: vector): bool acquires Timelock { + let timelock = borrow_timelock(); + if (!timelock.timestamps.contains(id)) { + return false + }; + + let timestamp_value = *timelock.timestamps.borrow(id); + timestamp_value > DONE_TIMESTAMP && timestamp_value <= timestamp::now_seconds() + } + + #[view] + public fun timelock_is_operation_done(id: vector): bool acquires Timelock { + let timelock = borrow_timelock(); + timelock.timestamps.contains(id) + && *timelock.timestamps.borrow(id) == DONE_TIMESTAMP + } + + #[view] + public fun timelock_get_timestamp(id: vector): u64 acquires Timelock { + let timelock = borrow_timelock(); + if (timelock.timestamps.contains(id)) { + *timelock.timestamps.borrow(id) + } else { 0 } + } + + #[view] + public fun timelock_min_delay(): u64 acquires Timelock { + borrow_timelock().min_delay + } + + #[view] + public fun timelock_get_blocked_functions(): vector acquires Timelock { + let timelock = borrow_timelock(); + let blocked_functions = vector[]; + for (i in 0..timelock.blocked_functions.length()) { + blocked_functions.push_back(*timelock.blocked_functions.borrow(i)); + }; + blocked_functions + } + + #[view] + public fun timelock_get_blocked_functions_count(): u64 acquires Timelock { + borrow_timelock().blocked_functions.length() + } + + public fun create_calls( + targets: vector
, + module_names: vector, + function_names: vector, + datas: vector> + ): vector { + let len = targets.length(); + assert!( + len == module_names.length() + && len == function_names.length() + && len == datas.length(), + E_INVALID_PARAMETERS + ); + + let calls = vector[]; + for (i in 0..len) { + let target = targets[i]; + let module_name = module_names[i]; + let function_name = function_names[i]; + let data = datas[i]; + let function = Function { target, module_name, function_name }; + let call = Call { function, data }; + calls.push_back(call); + }; + + calls + } + + public fun hash_operation_batch( + calls: vector, predecessor: vector, salt: vector + ): vector { + let packed = vector[]; + packed.append(bcs::to_bytes(&calls)); + packed.append(predecessor); + packed.append(salt); + keccak256(packed) + } + + fun equals(fn1: &Function, fn2: &Function): bool { + fn1.target == fn2.target + && fn1.module_name.bytes() == fn2.module_name.bytes() + && fn1.function_name.bytes() == fn2.function_name.bytes() + } + + inline fun borrow_timelock(): &Timelock acquires Timelock { + borrow_global(@mcms) + } + + inline fun borrow_mut_timelock(): &mut Timelock acquires Timelock { + borrow_global_mut(@mcms) + } + + public fun signer_view(signer_: &Signer): (vector, u8, u8) { + (signer_.addr, signer_.index, signer_.group) + } + + public fun function_name(function: Function): String { + function.function_name + } + + public fun module_name(function: Function): String { + function.module_name + } + + public fun target(function: Function): address { + function.target + } + + public fun data(call: Call): vector { + call.data + } + + // ======================= TEST ONLY FUNCTIONS ======================= // + #[test_only] + public fun init_module_for_testing(publisher: &signer) { + init_module(publisher); + } + + #[test_only] + public fun test_hash_metadata_leaf( + role: u8, + chain_id: u256, + multisig: address, + pre_op_count: u64, + post_op_count: u64, + override_previous_root: bool + ): vector { + let metadata = RootMetadata { + role, + chain_id, + multisig, + pre_op_count, + post_op_count, + override_previous_root + }; + hash_metadata_leaf(metadata) + } + + #[test_only] + public fun test_set_expiring_root_and_op_count( + multisig: Object, + root: vector, + valid_until: u64, + op_count: u64 + ) acquires Multisig { + let multisig = borrow_multisig_mut(multisig); + multisig.expiring_root_and_op_count.root = root; + multisig.expiring_root_and_op_count.valid_until = valid_until; + multisig.expiring_root_and_op_count.op_count = op_count; + } + + #[test_only] + public fun test_set_root_metadata( + multisig: Object, + role: u8, + chain_id: u256, + multisig_addr: address, + pre_op_count: u64, + post_op_count: u64, + override_previous_root: bool + ) acquires Multisig { + let multisig = borrow_multisig_mut(multisig); + multisig.root_metadata.role = role; + multisig.root_metadata.chain_id = chain_id; + multisig.root_metadata.multisig = multisig_addr; + multisig.root_metadata.pre_op_count = pre_op_count; + multisig.root_metadata.post_op_count = post_op_count; + multisig.root_metadata.override_previous_root = override_previous_root; + } + + #[test_only] + public fun test_ecdsa_recover_evm_addr( + eth_signed_message_hash: vector, signature: vector + ): vector { + ecdsa_recover_evm_addr(eth_signed_message_hash, signature) + } + + #[test_only] + public fun test_timelock_schedule_batch( + targets: vector
, + module_names: vector, + function_names: vector, + datas: vector>, + predecessor: vector, + salt: vector, + delay: u64 + ) acquires Timelock { + timelock_schedule_batch( + targets, + module_names, + function_names, + datas, + predecessor, + salt, + delay + ); + } + + #[test_only] + public fun test_timelock_update_min_delay(delay: u64) acquires Timelock { + timelock_update_min_delay(delay); + } + + #[test_only] + public fun test_timelock_cancel(id: vector) acquires Timelock { + timelock_cancel(id); + } + + #[test_only] + public fun test_timelock_bypasser_execute_batch( + targets: vector
, + module_names: vector, + function_names: vector, + datas: vector> + ) acquires Multisig, MultisigState, Timelock { + timelock_bypasser_execute_batch(targets, module_names, function_names, datas); + } + + #[test_only] + public fun test_timelock_block_function( + target: address, module_name: String, function_name: String + ) acquires Timelock { + timelock_block_function(target, module_name, function_name); + } + + #[test_only] + public fun test_timelock_unblock_function( + target: address, module_name: String, function_name: String + ) acquires Timelock { + timelock_unblock_function(target, module_name, function_name); + } + + #[test_only] + public fun create_op( + role: u8, + chain_id: u256, + multisig: address, + nonce: u64, + to: address, + module_name: String, + function_name: String, + data: vector + ): Op { + Op { + role, + chain_id, + multisig, + nonce, + to, + module_name, + function_name, + data + } + } + + #[test_only] + public fun test_timelock_dispatch( + target: address, + module_name: String, + function_name: String, + data: vector + ) acquires Multisig, MultisigState, Timelock { + timelock_dispatch(target, module_name, function_name, data) + } +} +` + +/** sources/utils/bcs_stream.move */ +export const MCMS_UTILS_BCS_STREAM_MOVE = `/// Copied and modified from: https://github.com/aptos-labs/aptos-core/blob/9baf39b6fba7812f09238c91973f61fd0955057c/aptos-move/move-examples/bcs-stream/sources/stream.move +/// +/// This module enables the deserialization of BCS-formatted byte arrays into Move primitive types. +/// Deserialization Strategies: +/// - Per-Byte Deserialization: Employed for most types to ensure lower gas consumption, this method processes each byte +/// individually to match the length and type requirements of target Move types. +/// - Exception: For the \`deserialize_address\` function, the function-based approach from \`aptos_std::from_bcs\` is used +/// due to type constraints, even though it is generally more gas-intensive. +/// - This can be optimized further by introducing native vector slices. +/// Application: +/// - This deserializer is particularly valuable for processing BCS serialized data within Move modules, +/// especially useful for systems requiring cross-chain message interpretation or off-chain data verification. +module mcms::bcs_stream { + use std::error; + use std::vector; + use std::option::{Self, Option}; + use std::string::{Self, String}; + + use aptos_std::from_bcs; + + /// The data does not fit the expected format. + const E_MALFORMED_DATA: u64 = 1; + /// There are not enough bytes to deserialize for the given type. + const E_OUT_OF_BYTES: u64 = 2; + /// The stream has not been consumed. + const E_NOT_CONSUMED: u64 = 3; + + struct BCSStream has drop { + /// Byte buffer containing the serialized data. + data: vector, + /// Cursor indicating the current position in the byte buffer. + cur: u64 + } + + /// Constructs a new BCSStream instance from the provided byte array. + public fun new(data: vector): BCSStream { + BCSStream { data, cur: 0 } + } + + /// Asserts that the stream has been fully consumed. + public fun assert_is_consumed(stream: &BCSStream) { + assert!(stream.cur == stream.data.length(), error::invalid_state(E_NOT_CONSUMED)); + } + + /// Deserializes a ULEB128-encoded integer from the stream. + /// In the BCS format, lengths of vectors are represented using ULEB128 encoding. + public fun deserialize_uleb128(stream: &mut BCSStream): u64 { + let res = 0; + let shift = 0; + + while (stream.cur < stream.data.length()) { + let byte = stream.data[stream.cur]; + stream.cur += 1; + + let val = ((byte & 0x7f) as u64); + if (((val << shift) >> shift) != val) { + abort error::invalid_argument(E_MALFORMED_DATA) + }; + res |=(val << shift); + + if ((byte & 0x80) == 0) { + if (shift > 0 && val == 0) { + abort error::invalid_argument(E_MALFORMED_DATA) + }; + return res + }; + + shift += 7; + if (shift > 64) { + abort error::invalid_argument(E_MALFORMED_DATA) + }; + }; + + abort error::out_of_range(E_OUT_OF_BYTES) + } + + /// Deserializes a \`bool\` value from the stream. + public fun deserialize_bool(stream: &mut BCSStream): bool { + assert!(stream.cur < stream.data.length(), error::out_of_range(E_OUT_OF_BYTES)); + let byte = stream.data[stream.cur]; + stream.cur += 1; + if (byte == 0) { false } + else if (byte == 1) { true } + else { + abort error::invalid_argument(E_MALFORMED_DATA) + } + } + + /// Deserializes an \`address\` value from the stream. + /// 32-byte \`address\` values are serialized using little-endian byte order. + /// This function utilizes the \`to_address\` function from the \`aptos_std::from_bcs\` module, + /// because the Move type system does not permit per-byte referencing of addresses. + public fun deserialize_address(stream: &mut BCSStream): address { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 32 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + let res = from_bcs::to_address(data.slice(cur, cur + 32)); + + stream.cur = cur + 32; + res + } + + /// Deserializes a \`u8\` value from the stream. + /// 1-byte \`u8\` values are serialized using little-endian byte order. + public fun deserialize_u8(stream: &mut BCSStream): u8 { + let data = &stream.data; + let cur = stream.cur; + + assert!(cur < data.length(), error::out_of_range(E_OUT_OF_BYTES)); + + let res = data[cur]; + + stream.cur = cur + 1; + res + } + + /// Deserializes a \`u16\` value from the stream. + /// 2-byte \`u16\` values are serialized using little-endian byte order. + public fun deserialize_u16(stream: &mut BCSStream): u16 { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 2 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + let res = (data[cur] as u16) | ((data[cur + 1] as u16) << 8); + + stream.cur += 2; + res + } + + /// Deserializes a \`u32\` value from the stream. + /// 4-byte \`u32\` values are serialized using little-endian byte order. + public fun deserialize_u32(stream: &mut BCSStream): u32 { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 4 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + let res = + (data[cur] as u32) | ((data[cur + 1] as u32) << 8) | ((data[cur + 2] as u32) + << 16) | ((data[cur + 3] as u32) << 24); + + stream.cur += 4; + res + } + + /// Deserializes a \`u64\` value from the stream. + /// 8-byte \`u64\` values are serialized using little-endian byte order. + public fun deserialize_u64(stream: &mut BCSStream): u64 { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 8 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + let res = + (data[cur] as u64) | ((data[cur + 1] as u64) << 8) | ((data[cur + 2] as u64) + << 16) | ((data[cur + 3] as u64) << 24) | ((data[cur + 4] as u64) << 32) + | ((data[cur + 5] as u64) << 40) | ((data[cur + 6] as u64) << 48) + | ((data[cur + 7] as u64) << 56); + + stream.cur += 8; + res + } + + /// Deserializes a \`u128\` value from the stream. + /// 16-byte \`u128\` values are serialized using little-endian byte order. + public fun deserialize_u128(stream: &mut BCSStream): u128 { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 16 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + let res = + (data[cur] as u128) | ((data[cur + 1] as u128) << 8) + | ((data[cur + 2] as u128) << 16) | ((data[cur + 3] as u128) << 24) + | ((data[cur + 4] as u128) << 32) | ((data[cur + 5] as u128) << 40) + | ((data[cur + 6] as u128) << 48) | ((data[cur + 7] as u128) << 56) + | ((data[cur + 8] as u128) << 64) | ((data[cur + 9] as u128) << 72) + | ((data[cur + 10] as u128) << 80) | ((data[cur + 11] as u128) << 88) + | ((data[cur + 12] as u128) << 96) | ((data[cur + 13] as u128) << 104) + | ((data[cur + 14] as u128) << 112) | ((data[cur + 15] as u128) << 120); + + stream.cur += 16; + res + } + + /// Deserializes a \`u256\` value from the stream. + /// 32-byte \`u256\` values are serialized using little-endian byte order. + public fun deserialize_u256(stream: &mut BCSStream): u256 { + let data = &stream.data; + let cur = stream.cur; + + assert!( + cur + 32 <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + let res = + (data[cur] as u256) | ((data[cur + 1] as u256) << 8) + | ((data[cur + 2] as u256) << 16) | ((data[cur + 3] as u256) << 24) + | ((data[cur + 4] as u256) << 32) | ((data[cur + 5] as u256) << 40) + | ((data[cur + 6] as u256) << 48) | ((data[cur + 7] as u256) << 56) + | ((data[cur + 8] as u256) << 64) | ((data[cur + 9] as u256) << 72) + | ((data[cur + 10] as u256) << 80) | ((data[cur + 11] as u256) << 88) + | ((data[cur + 12] as u256) << 96) | ((data[cur + 13] as u256) << 104) + | ((data[cur + 14] as u256) << 112) | ((data[cur + 15] as u256) << 120) + | ((data[cur + 16] as u256) << 128) | ((data[cur + 17] as u256) << 136) + | ((data[cur + 18] as u256) << 144) | ((data[cur + 19] as u256) << 152) + | ((data[cur + 20] as u256) << 160) | ((data[cur + 21] as u256) << 168) + | ((data[cur + 22] as u256) << 176) | ((data[cur + 23] as u256) << 184) + | ((data[cur + 24] as u256) << 192) | ((data[cur + 25] as u256) << 200) + | ((data[cur + 26] as u256) << 208) | ((data[cur + 27] as u256) << 216) + | ((data[cur + 28] as u256) << 224) | ((data[cur + 29] as u256) << 232) + | ((data[cur + 30] as u256) << 240) | ((data[cur + 31] as u256) << 248); + + stream.cur += 32; + res + } + + /// Deserializes a \`u256\` value from the stream. + public entry fun deserialize_u256_entry(data: vector, cursor: u64) { + let stream = BCSStream { data, cur: cursor }; + deserialize_u256(&mut stream); + } + + /// Deserializes an array of BCS deserializable elements from the stream. + /// First, reads the length of the vector, which is in uleb128 format. + /// After determining the length, it then reads the contents of the vector. + /// The \`elem_deserializer\` lambda expression is used sequentially to deserialize each element of the vector. + public inline fun deserialize_vector( + stream: &mut BCSStream, elem_deserializer: |&mut BCSStream| E + ): vector { + let len = deserialize_uleb128(stream); + let v = vector::empty(); + + for (i in 0..len) { + v.push_back(elem_deserializer(stream)); + }; + + v + } + + public fun deserialize_vector_u8(stream: &mut BCSStream): vector { + let len = deserialize_uleb128(stream); + let data = &mut stream.data; + let cur = stream.cur; + + assert!( + cur + len <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + + // AIP-105 introduces vector::move_range to efficiently move a range of elements from one vector to another. + let res = data.trim(cur); + stream.data = res.trim(len); + stream.cur = 0; + + res + } + + public fun deserialize_fixed_vector_u8( + stream: &mut BCSStream, len: u64 + ): vector { + let data = &mut stream.data; + let cur = stream.cur; + + assert!( + cur + len <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + + // AIP-105 introduces vector::move_range to efficiently move a range of elements from one vector to another. + let res = data.trim(cur); + stream.data = res.trim(len); + stream.cur = 0; + + res + } + + /// Deserializes utf-8 \`String\` from the stream. + /// First, reads the length of the String, which is in uleb128 format. + /// After determining the length, it then reads the contents of the String. + public fun deserialize_string(stream: &mut BCSStream): String { + let len = deserialize_uleb128(stream); + let data = &mut stream.data; + let cur = stream.cur; + + assert!( + cur + len <= data.length(), error::out_of_range(E_OUT_OF_BYTES) + ); + + // AIP-105 introduces vector::move_range to efficiently move a range of elements from one vector to another. + let res = data.trim(cur); + stream.data = res.trim(len); + stream.cur = 0; + + string::utf8(res) + } + + /// Deserializes \`Option\` from the stream. + /// First, reads a single byte representing the presence (0x01) or absence (0x00) of data. + /// After determining the presence of data, it then reads the actual data if present. + /// The \`elem_deserializer\` lambda expression is used to deserialize the element contained within the \`Option\`. + public inline fun deserialize_option( + stream: &mut BCSStream, elem_deserializer: |&mut BCSStream| E + ): Option { + let is_data = deserialize_bool(stream); + if (is_data) { + option::some(elem_deserializer(stream)) + } else { + option::none() + } + } +} +` + +/** sources/utils/params.move */ +export const MCMS_UTILS_PARAMS_MOVE = `module mcms::params { + use std::bcs; + + const E_CMP_VECTORS_DIFF_LEN: u64 = 1; + const E_INPUT_TOO_LARGE_FOR_NUM_BYTES: u64 = 2; + + public inline fun encode_uint(input: T, num_bytes: u64): vector { + let bcs_bytes = bcs::to_bytes(&input); + + let len = bcs_bytes.length(); + assert!(len <= num_bytes, E_INPUT_TOO_LARGE_FOR_NUM_BYTES); + + if (len < num_bytes) { + let bytes_to_pad = num_bytes - len; + for (i in 0..bytes_to_pad) { + bcs_bytes.push_back(0); + }; + }; + + // little endian to big endian + bcs_bytes.reverse(); + + bcs_bytes + } + + public inline fun right_pad_vec(v: &mut vector, num_bytes: u64) { + let len = v.length(); + if (len < num_bytes) { + let bytes_to_pad = num_bytes - len; + for (i in 0..bytes_to_pad) { + v.push_back(0); + }; + }; + } + + /// compares two vectors of equal length, returns true if a > b, false otherwise. + public fun vector_u8_gt(a: &vector, b: &vector): bool { + let len = a.length(); + assert!(len == b.length(), E_CMP_VECTORS_DIFF_LEN); + + if (len == 0) { + return false + }; + + // compare each byte until not equal + for (i in 0..len) { + let byte_a = a[i]; + let byte_b = b[i]; + if (byte_a > byte_b) { + return true + } else if (byte_a < byte_b) { + return false + }; + }; + + // vectors are equal, a == b + false + } +} +` diff --git a/ccip-sdk/src/token-admin/aptos/bytecodes/regulated_token_pool.ts b/ccip-sdk/src/token-admin/aptos/bytecodes/regulated_token_pool.ts new file mode 100644 index 00000000..706f9088 --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/bytecodes/regulated_token_pool.ts @@ -0,0 +1,1252 @@ +/** + * RegulatedTokenPool Move package source files. + * + * Source: chainlink-aptos contracts/ccip/ccip_token_pools/regulated_token_pool + * + contracts/regulated_token + * AptosFramework rev: 16beac69835f3a71564c96164a606a23f259099a + * ChainlinkCCIP + MCMS: embedded as local dependencies + * + * For regulated tokens with pause/freeze/role-based access control. + * The regulated_token package provides dynamic dispatch deposit/withdraw + * functions that enforce compliance controls. + * + * Vendored as source (not compiled bytecodes) because Aptos Move modules + * must be compiled with the deployer's address at deploy time. + * + * Lazy-loaded via dynamic import() — same pattern as EVM BurnMintERC20 bytecode. + */ + +export const REGULATED_POOL_MOVE_TOML = `[package] +name = "RegulatedTokenPool" +version = "1.0.0" +authors = [] + +[addresses] +ccip = "_" +ccip_token_pool = "_" +regulated_token_pool = "_" +mcms = "_" +mcms_register_entrypoints = "_" +regulated_token = "_" +admin = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +ChainlinkCCIP = { local = "../ccip" } +CCIPTokenPool = { local = "../token_pool" } +RegulatedToken = { local = "../regulated_token" } +` + +export const REGULATED_TOKEN_POOL_MOVE = `module regulated_token_pool::regulated_token_pool { + use std::account::{Self, SignerCapability}; + use std::error; + use std::fungible_asset::{Self, FungibleAsset, Metadata, TransferRef}; + use std::primary_fungible_store; + use std::object::{Self, Object}; + use std::option::{Self, Option}; + use std::signer; + use std::string::{Self, String}; + + use regulated_token::regulated_token::{Self}; + + use ccip::token_admin_registry::{Self, LockOrBurnInputV1, ReleaseOrMintInputV1}; + use ccip_token_pool::ownable; + use ccip_token_pool::rate_limiter; + use ccip_token_pool::token_pool; + + use mcms::mcms_registry; + use mcms::bcs_stream; + + const STORE_OBJECT_SEED: vector = b"CcipRegulatedTokenPool"; + + struct RegulatedTokenPoolState has key, store { + store_signer_cap: SignerCapability, + ownable_state: ownable::OwnableState, + token_pool_state: token_pool::TokenPoolState, + store_signer_address: address + } + + const E_INVALID_ARGUMENTS: u64 = 1; + const E_UNKNOWN_FUNCTION: u64 = 2; + const E_NOT_PUBLISHER: u64 = 3; + + // ================================================================ + // | Init | + // ================================================================ + #[view] + public fun type_and_version(): String { + string::utf8(b"RegulatedTokenPool 1.6.0") + } + + fun init_module(publisher: &signer) { + // register the pool on deployment, because in the case of object code deployment, + // this is the only time we have a signer ref to @regulated_token_pool. + + // create an Account on the object for event handles. + account::create_account_if_does_not_exist(@regulated_token_pool); + + // the name of this module. if incorrect, callbacks will fail to be registered and + // register_pool will revert. + let token_pool_module_name = b"regulated_token_pool"; + + // Register the entrypoint with mcms + if (@mcms_register_entrypoints == @0x1) { + register_mcms_entrypoint(publisher, token_pool_module_name); + }; + + // Register V2 pool with closure-based callbacks + register_v2_callbacks(publisher); + + // create a resource account to be the owner of the primary FungibleStore we will use. + let (store_signer, store_signer_cap) = + account::create_resource_account(publisher, STORE_OBJECT_SEED); + + let regulated_token_address = regulated_token::token_address(); + let metadata = object::address_to_object(regulated_token_address); + + // make sure this is a valid fungible asset that is primary fungible store enabled, + // ie. created with primary_fungible_store::create_primary_store_enabled_fungible_asset + primary_fungible_store::ensure_primary_store_exists( + signer::address_of(&store_signer), metadata + ); + + let pool = RegulatedTokenPoolState { + ownable_state: ownable::new(&store_signer, @regulated_token_pool), + store_signer_address: signer::address_of(&store_signer), + store_signer_cap, + token_pool_state: token_pool::initialize( + &store_signer, regulated_token_address, vector[] + ) + }; + + move_to(&store_signer, pool); + } + + public fun register_v2_callbacks(publisher: &signer) { + assert!( + signer::address_of(publisher) == @regulated_token_pool, + error::permission_denied(E_NOT_PUBLISHER) + ); + let regulated_token_address = regulated_token::token_address(); + token_admin_registry::register_pool_v2( + publisher, + regulated_token_address, + lock_or_burn_v2, + release_or_mint_v2 + ); + } + + // ================================================================ + // | Exposing token_pool functions | + // ================================================================ + #[view] + public fun get_token(): address acquires RegulatedTokenPoolState { + token_pool::get_token(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_router(): address { + token_pool::get_router() + } + + #[view] + public fun get_token_decimals(): u8 acquires RegulatedTokenPoolState { + token_pool::get_token_decimals(&borrow_pool().token_pool_state) + } + + #[view] + public fun get_remote_pools( + remote_chain_selector: u64 + ): vector> acquires RegulatedTokenPoolState { + token_pool::get_remote_pools( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun is_remote_pool( + remote_chain_selector: u64, remote_pool_address: vector + ): bool acquires RegulatedTokenPoolState { + token_pool::is_remote_pool( + &borrow_pool().token_pool_state, + remote_chain_selector, + remote_pool_address + ) + } + + #[view] + public fun get_remote_token( + remote_chain_selector: u64 + ): vector acquires RegulatedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_remote_token(&pool.token_pool_state, remote_chain_selector) + } + + public entry fun add_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::add_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + public entry fun remove_remote_pool( + caller: &signer, remote_chain_selector: u64, remote_pool_address: vector + ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::remove_remote_pool( + &mut pool.token_pool_state, + remote_chain_selector, + remote_pool_address + ); + } + + #[view] + public fun is_supported_chain(remote_chain_selector: u64): bool acquires RegulatedTokenPoolState { + let pool = borrow_pool(); + token_pool::is_supported_chain(&pool.token_pool_state, remote_chain_selector) + } + + #[view] + public fun get_supported_chains(): vector acquires RegulatedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_supported_chains(&pool.token_pool_state) + } + + public entry fun apply_chain_updates( + caller: &signer, + remote_chain_selectors_to_remove: vector, + remote_chain_selectors_to_add: vector, + remote_pool_addresses_to_add: vector>>, + remote_token_addresses_to_add: vector> + ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::apply_chain_updates( + &mut pool.token_pool_state, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } + + #[view] + public fun get_allowlist_enabled(): bool acquires RegulatedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist_enabled(&pool.token_pool_state) + } + + public entry fun set_allowlist_enabled( + caller: &signer, enabled: bool + ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::set_allowlist_enabled(&mut pool.token_pool_state, enabled); + } + + #[view] + public fun get_allowlist(): vector
acquires RegulatedTokenPoolState { + let pool = borrow_pool(); + token_pool::get_allowlist(&pool.token_pool_state) + } + + public entry fun apply_allowlist_updates( + caller: &signer, removes: vector
, adds: vector
+ ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + token_pool::apply_allowlist_updates(&mut pool.token_pool_state, removes, adds); + } + + // ================================================================ + // | Burn/Mint | + // ================================================================ + + // the callback proof type used as authentication to retrieve and set input and output arguments. + struct CallbackProof has drop {} + + public fun lock_or_burn( + _store: Object, fa: FungibleAsset, _transfer_ref: &TransferRef + ) acquires RegulatedTokenPoolState { + // retrieve the input for this lock or burn operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_lock_or_burn_input_v1( + @regulated_token_pool, CallbackProof {} + ); + + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + // This method validates various aspects of the lock or burn operation. If any of the + // validations fail, the transaction will abort. + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + // Construct lock_or_burn output before we lose access to fa + let dest_pool_data = token_pool::encode_local_decimals(&pool.token_pool_state); + + // Burn the funds using regulated token's bridge burn function + // The pool store signer must have BRIDGE_MINTER_OR_BURNER role + let pool_signer = &account::create_signer_with_capability(&pool.store_signer_cap); + let sender = token_admin_registry::get_lock_or_burn_sender(&input); + regulated_token::bridge_burn(pool_signer, sender, fa); + + // set the output for this lock or burn operation. + token_admin_registry::set_lock_or_burn_output_v1( + @regulated_token_pool, + CallbackProof {}, + dest_token_address, + dest_pool_data + ); + + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + } + + public fun release_or_mint( + _store: Object, _amount: u64, _transfer_ref: &TransferRef + ): FungibleAsset acquires RegulatedTokenPoolState { + // retrieve the input for this release or mint operation. if this function is invoked + // outside of ccip::token_admin_registry, the transaction will abort. + let input = + token_admin_registry::get_release_or_mint_input_v1( + @regulated_token_pool, CallbackProof {} + ); + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + // Mint the amount for release using regulated token's bridge mint function + // The pool store signer must have BRIDGE_MINTER_OR_BURNER role + let pool_signer = &account::create_signer_with_capability(&pool.store_signer_cap); + let receiver = token_admin_registry::get_release_or_mint_receiver(&input); + let fa = regulated_token::bridge_mint(pool_signer, receiver, local_amount); + + // set the output for this release or mint operation. + token_admin_registry::set_release_or_mint_output_v1( + @regulated_token_pool, CallbackProof {}, local_amount + ); + + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + receiver, + local_amount, + remote_chain_selector + ); + + // return the withdrawn fungible asset. + fa + } + + #[persistent] + fun lock_or_burn_v2( + fa: FungibleAsset, input: LockOrBurnInputV1 + ): (vector, vector) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + let fa_amount = fungible_asset::amount(&fa); + + let dest_token_address = + token_pool::validate_lock_or_burn( + &mut pool.token_pool_state, + &fa, + &input, + fa_amount + ); + + let pool_signer = &account::create_signer_with_capability(&pool.store_signer_cap); + let sender = token_admin_registry::get_lock_or_burn_sender(&input); + regulated_token::bridge_burn(pool_signer, sender, fa); + + let remote_chain_selector = + token_admin_registry::get_lock_or_burn_remote_chain_selector(&input); + + token_pool::emit_locked_or_burned( + &mut pool.token_pool_state, fa_amount, remote_chain_selector + ); + + (dest_token_address, token_pool::encode_local_decimals(&pool.token_pool_state)) + } + + #[persistent] + fun release_or_mint_v2( + input: ReleaseOrMintInputV1 + ): (FungibleAsset, u64) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + let local_amount = + token_pool::calculate_release_or_mint_amount(&pool.token_pool_state, &input); + + token_pool::validate_release_or_mint( + &mut pool.token_pool_state, &input, local_amount + ); + + // Mint the amount for release using regulated token's bridge mint function + let pool_signer = &account::create_signer_with_capability(&pool.store_signer_cap); + let receiver = token_admin_registry::get_release_or_mint_receiver(&input); + let fa = regulated_token::bridge_mint(pool_signer, receiver, local_amount); + + let remote_chain_selector = + token_admin_registry::get_release_or_mint_remote_chain_selector(&input); + + token_pool::emit_released_or_minted( + &mut pool.token_pool_state, + receiver, + local_amount, + remote_chain_selector + ); + + (fa, local_amount) + } + + // ================================================================ + // | Rate limit config | + // ================================================================ + public entry fun set_chain_rate_limiter_configs( + caller: &signer, + remote_chain_selectors: vector, + outbound_is_enableds: vector, + outbound_capacities: vector, + outbound_rates: vector, + inbound_is_enableds: vector, + inbound_capacities: vector, + inbound_rates: vector + ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + let number_of_chains = remote_chain_selectors.length(); + + assert!( + number_of_chains == outbound_is_enableds.length() + && number_of_chains == outbound_capacities.length() + && number_of_chains == outbound_rates.length() + && number_of_chains == inbound_is_enableds.length() + && number_of_chains == inbound_capacities.length() + && number_of_chains == inbound_rates.length(), + error::invalid_argument(E_INVALID_ARGUMENTS) + ); + + for (i in 0..number_of_chains) { + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selectors[i], + outbound_is_enableds[i], + outbound_capacities[i], + outbound_rates[i], + inbound_is_enableds[i], + inbound_capacities[i], + inbound_rates[i] + ); + }; + } + + public entry fun set_chain_rate_limiter_config( + caller: &signer, + remote_chain_selector: u64, + outbound_is_enabled: bool, + outbound_capacity: u64, + outbound_rate: u64, + inbound_is_enabled: bool, + inbound_capacity: u64, + inbound_rate: u64 + ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::assert_only_owner(signer::address_of(caller), &pool.ownable_state); + + token_pool::set_chain_rate_limiter_config( + &mut pool.token_pool_state, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } + + #[view] + public fun get_current_inbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires RegulatedTokenPoolState { + token_pool::get_current_inbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + #[view] + public fun get_current_outbound_rate_limiter_state( + remote_chain_selector: u64 + ): rate_limiter::TokenBucket acquires RegulatedTokenPoolState { + token_pool::get_current_outbound_rate_limiter_state( + &borrow_pool().token_pool_state, remote_chain_selector + ) + } + + // ================================================================ + // | Storage helpers | + // ================================================================ + #[view] + public fun get_store_address(): address { + store_address() + } + + inline fun store_address(): address { + account::create_resource_address(&@regulated_token_pool, STORE_OBJECT_SEED) + } + + inline fun borrow_pool(): &RegulatedTokenPoolState { + borrow_global(store_address()) + } + + inline fun borrow_pool_mut(): &mut RegulatedTokenPoolState { + borrow_global_mut(store_address()) + } + + // ================================================================ + // | Expose ownable | + // ================================================================ + #[view] + public fun owner(): address acquires RegulatedTokenPoolState { + ownable::owner(&borrow_pool().ownable_state) + } + + #[view] + public fun has_pending_transfer(): bool acquires RegulatedTokenPoolState { + ownable::has_pending_transfer(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_from(): Option
acquires RegulatedTokenPoolState { + ownable::pending_transfer_from(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_to(): Option
acquires RegulatedTokenPoolState { + ownable::pending_transfer_to(&borrow_pool().ownable_state) + } + + #[view] + public fun pending_transfer_accepted(): Option acquires RegulatedTokenPoolState { + ownable::pending_transfer_accepted(&borrow_pool().ownable_state) + } + + public entry fun transfer_ownership(caller: &signer, to: address) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::transfer_ownership(caller, &mut pool.ownable_state, to) + } + + public entry fun accept_ownership(caller: &signer) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::accept_ownership(caller, &mut pool.ownable_state) + } + + public entry fun execute_ownership_transfer( + caller: &signer, to: address + ) acquires RegulatedTokenPoolState { + let pool = borrow_pool_mut(); + ownable::execute_ownership_transfer(caller, &mut pool.ownable_state, to) + } + + // ================================================================ + // | MCMS entrypoint | + // ================================================================ + struct McmsCallback has drop {} + + public fun mcms_entrypoint( + _metadata: object::Object + ): option::Option acquires RegulatedTokenPoolState { + let (caller, function, data) = + mcms_registry::get_callback_params(@regulated_token_pool, McmsCallback {}); + + let function_bytes = *function.bytes(); + let stream = bcs_stream::new(data); + + if (function_bytes == b"add_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + add_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"remove_remote_pool") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let remote_pool_address = bcs_stream::deserialize_vector_u8(&mut stream); + bcs_stream::assert_is_consumed(&stream); + remove_remote_pool(&caller, remote_chain_selector, remote_pool_address); + } else if (function_bytes == b"apply_chain_updates") { + let remote_chain_selectors_to_remove = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_chain_selectors_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let remote_pool_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, + |stream| bcs_stream::deserialize_vector( + stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ) + ); + let remote_token_addresses_to_add = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_vector_u8(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_chain_updates( + &caller, + remote_chain_selectors_to_remove, + remote_chain_selectors_to_add, + remote_pool_addresses_to_add, + remote_token_addresses_to_add + ); + } else if (function_bytes == b"set_allowlist_enabled") { + let enabled = bcs_stream::deserialize_bool(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_allowlist_enabled(&caller, enabled); + } else if (function_bytes == b"apply_allowlist_updates") { + let removes = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + let adds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_address(stream) + ); + bcs_stream::assert_is_consumed(&stream); + apply_allowlist_updates(&caller, removes, adds); + } else if (function_bytes == b"set_chain_rate_limiter_configs") { + let remote_chain_selectors = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let outbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let outbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_is_enableds = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_bool(stream) + ); + let inbound_capacities = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + let inbound_rates = + bcs_stream::deserialize_vector( + &mut stream, |stream| bcs_stream::deserialize_u64(stream) + ); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_configs( + &caller, + remote_chain_selectors, + outbound_is_enableds, + outbound_capacities, + outbound_rates, + inbound_is_enableds, + inbound_capacities, + inbound_rates + ); + } else if (function_bytes == b"set_chain_rate_limiter_config") { + let remote_chain_selector = bcs_stream::deserialize_u64(&mut stream); + let outbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let outbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let outbound_rate = bcs_stream::deserialize_u64(&mut stream); + let inbound_is_enabled = bcs_stream::deserialize_bool(&mut stream); + let inbound_capacity = bcs_stream::deserialize_u64(&mut stream); + let inbound_rate = bcs_stream::deserialize_u64(&mut stream); + bcs_stream::assert_is_consumed(&stream); + set_chain_rate_limiter_config( + &caller, + remote_chain_selector, + outbound_is_enabled, + outbound_capacity, + outbound_rate, + inbound_is_enabled, + inbound_capacity, + inbound_rate + ); + } else if (function_bytes == b"transfer_ownership") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + transfer_ownership(&caller, to); + } else if (function_bytes == b"accept_ownership") { + bcs_stream::assert_is_consumed(&stream); + accept_ownership(&caller); + } else if (function_bytes == b"execute_ownership_transfer") { + let to = bcs_stream::deserialize_address(&mut stream); + bcs_stream::assert_is_consumed(&stream); + execute_ownership_transfer(&caller, to) + } else { + abort error::invalid_argument(E_UNKNOWN_FUNCTION) + }; + + option::none() + } + + /// Callable during upgrades + public(friend) fun register_mcms_entrypoint( + publisher: &signer, module_name: vector + ) { + mcms_registry::register_entrypoint( + publisher, string::utf8(module_name), McmsCallback {} + ); + } +} +` + +export const REGULATED_TOKEN_MOVE_TOML = `[package] +name = "RegulatedToken" +version = "1.0.0" +authors = [] + +[addresses] +regulated_token = "_" +admin = "_" + +[dependencies] +AptosFramework = { git = "https://github.com/aptos-labs/aptos-core.git", rev = "16beac69835f3a71564c96164a606a23f259099a", subdir = "aptos-move/framework/aptos-framework" } +` + +// prettier-ignore +export const REGULATED_TOKEN_MOVE = "module regulated_token::regulated_token {\n use std::event;\n use std::fungible_asset::{\n Self,\n BurnRef,\n FungibleAsset,\n Metadata,\n MintRef,\n TransferRef,\n RawBalanceRef,\n RawSupplyRef,\n MutateMetadataRef\n };\n use std::object::{\n Self,\n ExtendRef,\n Object,\n TransferRef as ObjectTransferRef\n };\n use std::option::{Self, Option};\n use std::primary_fungible_store;\n use std::account;\n use std::signer;\n use std::string::{Self, String};\n use std::dispatchable_fungible_asset;\n use std::function_info;\n use std::big_ordered_map::{Self, BigOrderedMap};\n\n use regulated_token::access_control::{Self};\n use regulated_token::ownable::{Self, OwnableState};\n\n const TOKEN_STATE_SEED: vector = b\"regulated_token::regulated_token::token_state\";\n\n const PAUSER_ROLE: u8 = 0;\n const UNPAUSER_ROLE: u8 = 1;\n const FREEZER_ROLE: u8 = 2;\n const UNFREEZER_ROLE: u8 = 3;\n const MINTER_ROLE: u8 = 4;\n const BURNER_ROLE: u8 = 5;\n const BRIDGE_MINTER_OR_BURNER_ROLE: u8 = 6;\n const RECOVERY_ROLE: u8 = 7;\n\n enum Role has copy, drop, store {\n PAUSER_ROLE,\n UNPAUSER_ROLE,\n FREEZER_ROLE,\n UNFREEZER_ROLE,\n MINTER_ROLE,\n BURNER_ROLE,\n BRIDGE_MINTER_OR_BURNER_ROLE,\n RECOVERY_ROLE\n }\n\n #[resource_group_member(group = aptos_framework::object::ObjectGroup)]\n struct TokenStateDeployment has key {\n extend_ref: ExtendRef,\n transfer_ref: ObjectTransferRef,\n paused: bool,\n frozen_accounts: BigOrderedMap,\n ownable_state: OwnableState\n }\n\n #[resource_group_member(group = aptos_framework::object::ObjectGroup)]\n struct TokenState has key {\n extend_ref: ExtendRef,\n transfer_ref: ObjectTransferRef,\n paused: bool,\n frozen_accounts: BigOrderedMap,\n ownable_state: OwnableState,\n token: Object\n }\n\n #[resource_group_member(group = aptos_framework::object::ObjectGroup)]\n struct TokenMetadataRefs has key {\n extend_ref: ExtendRef,\n mint_ref: MintRef,\n burn_ref: BurnRef,\n transfer_ref: TransferRef,\n raw_balance_ref: RawBalanceRef,\n raw_supply_ref: RawSupplyRef,\n mutate_metadata_ref: MutateMetadataRef\n }\n\n #[event]\n struct InitializeToken has drop, store {\n publisher: address,\n token: Object,\n max_supply: Option,\n decimals: u8,\n icon: String,\n project: String\n }\n\n #[event]\n struct NativeMint has drop, store {\n minter: address,\n to: address,\n amount: u64\n }\n\n #[event]\n struct BridgeMint has drop, store {\n minter: address,\n to: address,\n amount: u64\n }\n\n #[event]\n struct NativeBurn has drop, store {\n burner: address,\n from: address,\n amount: u64\n }\n\n #[event]\n struct BridgeBurn has drop, store {\n burner: address,\n from: address,\n amount: u64\n }\n\n #[event]\n struct MinterAdded has drop, store {\n admin: address,\n minter: address,\n role: R,\n operation_type: u8\n }\n\n #[event]\n struct Paused has drop, store {\n pauser: address\n }\n\n #[event]\n struct Unpaused has drop, store {\n unpauser: address\n }\n\n #[event]\n struct AccountFrozen has drop, store {\n freezer: address,\n account: address\n }\n\n #[event]\n struct AccountUnfrozen has drop, store {\n unfreezer: address,\n account: address\n }\n\n #[event]\n struct TokensRecovered has drop, store {\n caller: address,\n token_metadata: Object,\n from: address,\n to: address,\n amount: u64\n }\n\n /// The caller is not the signer of this contract\n const E_NOT_PUBLISHER: u64 = 1;\n /// TokenState has not been initialized yet\n const E_TOKEN_NOT_INITIALIZED: u64 = 2;\n /// Caller must have either BURNER_ROLE or BRIDGE_MINTER_OR_BURNER_ROLE\n const E_ONLY_BURNER_OR_BRIDGE: u64 = 3;\n /// Caller must have either MINTER_ROLE or BRIDGE_MINTER_OR_BURNER_ROLE\n const E_ONLY_MINTER_OR_BRIDGE: u64 = 4;\n /// Invalid fungible asset for transfer ref\n const E_INVALID_ASSET: u64 = 5;\n /// Zero address (0x0) is not allowed\n const E_ZERO_ADDRESS_NOT_ALLOWED: u64 = 6;\n /// Cannot transfer tokens to the regulated token contract address\n const E_CANNOT_TRANSFER_TO_REGULATED_TOKEN: u64 = 7;\n /// Contract is paused\n const E_PAUSED: u64 = 8;\n /// Account is frozen and cannot perform token operations\n const E_ACCOUNT_FROZEN: u64 = 9;\n /// Contract is already paused\n const E_ALREADY_PAUSED: u64 = 14;\n /// Contract is not paused\n const E_NOT_PAUSED: u64 = 15;\n /// Invalid role number provided\n const E_INVALID_ROLE_NUMBER: u64 = 10;\n /// Invalid fungible store provided for token metadata\n const E_INVALID_STORE: u64 = 11;\n /// Fungible store does not exist for this account\n const E_STORE_DOES_NOT_EXIST: u64 = 12;\n /// TokenState deployment has already been initialized\n const E_TOKEN_STATE_DEPLOYMENT_ALREADY_INITIALIZED: u64 = 13;\n /// Account msut be frozen for recovery\n const E_ACCOUNT_MUST_BE_FROZEN_FOR_RECOVERY: u64 = 14;\n\n #[view]\n public fun type_and_version(): String {\n string::utf8(b\"RegulatedToken 1.0.0\")\n }\n\n #[view]\n public fun token_state_address(): address {\n token_state_address_internal()\n }\n\n #[view]\n public fun token_state_object(): Object {\n token_state_object_internal()\n }\n\n #[view]\n public fun admin(): address {\n access_control::admin(token_state_object_internal())\n }\n\n #[view]\n public fun pending_admin(): address {\n access_control::pending_admin(token_state_object_internal())\n }\n\n inline fun token_state_object_internal(): Object {\n let token_state_address = token_state_address_internal();\n assert!(exists(token_state_address), E_TOKEN_NOT_INITIALIZED);\n object::address_to_object(token_state_address)\n }\n\n inline fun token_state_address_internal(): address {\n object::create_object_address(&@regulated_token, TOKEN_STATE_SEED)\n }\n\n #[view]\n public fun token_address(): address acquires TokenState {\n object::object_address(&token_metadata_internal())\n }\n\n #[view]\n public fun token_metadata(): Object acquires TokenState {\n token_metadata_internal()\n }\n\n inline fun token_metadata_from_state_obj(\n state_obj: Object\n ): Object {\n TokenState[object::object_address(&state_obj)].token\n }\n\n inline fun token_metadata_internal(): Object {\n let state_address = token_state_address_internal();\n assert!(exists(state_address), E_TOKEN_NOT_INITIALIZED);\n TokenState[state_address].token\n }\n\n #[view]\n public fun is_paused(): bool acquires TokenState {\n TokenState[token_state_address_internal()].paused\n }\n\n #[view]\n public fun get_role_members(role_number: u8): vector
{\n let role = get_role(role_number);\n access_control::get_role_members(token_state_object_internal(), role)\n }\n\n #[view]\n public fun get_role_member_count(role_number: u8): u64 {\n let role = get_role(role_number);\n access_control::get_role_member_count(token_state_object_internal(), role)\n }\n\n #[view]\n public fun get_role_member(role_number: u8, index: u64): address {\n let role = get_role(role_number);\n access_control::get_role_member(token_state_object_internal(), role, index)\n }\n\n #[view]\n public fun get_admin(): address {\n access_control::admin(token_state_object_internal())\n }\n\n #[view]\n public fun get_minters(): vector
{\n access_control::get_role_members(token_state_object_internal(), minter_role())\n }\n\n #[view]\n public fun get_bridge_minters_or_burners(): vector
{\n access_control::get_role_members(\n token_state_object_internal(), bridge_minter_or_burner_role()\n )\n }\n\n #[view]\n public fun get_burners(): vector
{\n access_control::get_role_members(token_state_object_internal(), burner_role())\n }\n\n #[view]\n public fun get_freezers(): vector
{\n access_control::get_role_members(token_state_object_internal(), freezer_role())\n }\n\n #[view]\n public fun get_unfreezers(): vector
{\n access_control::get_role_members(\n token_state_object_internal(), unfreezer_role()\n )\n }\n\n #[view]\n public fun get_pausers(): vector
{\n access_control::get_role_members(token_state_object_internal(), pauser_role())\n }\n\n #[view]\n public fun get_unpausers(): vector
{\n access_control::get_role_members(\n token_state_object_internal(), unpauser_role()\n )\n }\n\n #[view]\n public fun get_recovery_managers(): vector
{\n access_control::get_role_members(\n token_state_object_internal(), recovery_role()\n )\n }\n\n #[view]\n public fun get_pending_admin(): address {\n access_control::pending_admin(token_state_object_internal())\n }\n\n #[view]\n public fun is_frozen(account: address): bool acquires TokenState {\n TokenState[token_state_address_internal()].frozen_accounts.contains(&account)\n }\n\n #[view]\n /// Get frozen accounts paginated using a start key and limit.\n /// Caller should call this on a certain block to ensure you the same state for every call.\n ///\n /// This function retrieves a batch of frozen account addresses from the registry, starting from\n /// the account address that comes after the provided start_key.\n ///\n /// @param start_key - Address to start pagination from (returns accounts AFTER this address)\n /// @param max_count - Maximum number of accounts to return\n ///\n /// @return:\n /// - vector
: List of frozen account addresses (up to max_count)\n /// - address: Next key to use for pagination (pass this as start_key in next call)\n /// - bool: Whether there are more accounts after this batch\n public fun get_all_frozen_accounts(\n start_key: address, max_count: u64\n ): (vector
, address, bool) acquires TokenState {\n let frozen_accounts = &TokenState[token_state_address_internal()].frozen_accounts;\n let result = vector[];\n\n let current_key_opt = frozen_accounts.next_key(&start_key);\n if (max_count == 0 || current_key_opt.is_none()) {\n return (result, start_key, current_key_opt.is_some())\n };\n\n let current_key = *current_key_opt.borrow();\n\n result.push_back(current_key);\n\n for (_i in 1..max_count) {\n let next_key_opt = frozen_accounts.next_key(¤t_key);\n if (next_key_opt.is_none()) {\n return (result, current_key, false)\n };\n\n current_key = *next_key_opt.borrow();\n result.push_back(current_key);\n };\n\n // Check if there are more accounts after the last key\n let has_more = frozen_accounts.next_key(¤t_key).is_some();\n (result, current_key, has_more)\n }\n\n #[view]\n public fun has_role(account: address, role: u8): bool {\n access_control::has_role(token_state_object_internal(), account, get_role(role))\n }\n\n public fun deposit(\n store: Object, fa: FungibleAsset, transfer_ref: &TransferRef\n ) acquires TokenState {\n let state_obj = token_state_object_internal();\n let token_metadata = token_metadata_from_state_obj(state_obj);\n let token_state = &TokenState[object::object_address(&state_obj)];\n\n assert_not_paused(token_state);\n assert_not_frozen(object::owner(store), token_state);\n assert_correct_asset(transfer_ref, token_metadata, store);\n\n fungible_asset::deposit_with_ref(transfer_ref, store, fa);\n }\n\n public fun withdraw(\n store: Object, amount: u64, transfer_ref: &TransferRef\n ): FungibleAsset acquires TokenState {\n let state_obj = token_state_object_internal();\n let token_metadata = token_metadata_from_state_obj(state_obj);\n let token_state = &TokenState[object::object_address(&state_obj)];\n\n assert_not_paused(token_state);\n assert_not_frozen(object::owner(store), token_state);\n assert_correct_asset(transfer_ref, token_metadata, store);\n\n fungible_asset::withdraw_with_ref(transfer_ref, store, amount)\n }\n\n /// `publisher` is the code object, deployed through object_code_deployment\n fun init_module(publisher: &signer) {\n assert!(object::is_object(@regulated_token), E_NOT_PUBLISHER);\n\n // Create object owned by code object\n let constructor_ref = &object::create_named_object(publisher, TOKEN_STATE_SEED);\n let token_state_signer = &object::generate_signer(constructor_ref);\n\n // Create an Account on the object for event handles.\n account::create_account_if_does_not_exist(signer::address_of(token_state_signer));\n\n move_to(\n token_state_signer,\n TokenStateDeployment {\n extend_ref: object::generate_extend_ref(constructor_ref),\n transfer_ref: object::generate_transfer_ref(constructor_ref),\n paused: false,\n frozen_accounts: big_ordered_map::new_with_config(0, 0, false),\n ownable_state: ownable::new(token_state_signer, @regulated_token)\n }\n );\n\n // Initialize the access control module with `@admin` as the admin\n access_control::init(constructor_ref, @admin);\n }\n\n /// Only owner of this code object can initialize a token once\n public entry fun initialize(\n publisher: &signer,\n max_supply: Option,\n name: String,\n symbol: String,\n decimals: u8,\n icon: String,\n project: String\n ) acquires TokenStateDeployment {\n let publisher_addr = signer::address_of(publisher);\n let token_state_address = token_state_address_internal();\n\n assert!(\n exists(token_state_address),\n E_TOKEN_STATE_DEPLOYMENT_ALREADY_INITIALIZED\n );\n\n let TokenStateDeployment {\n extend_ref,\n transfer_ref,\n paused,\n frozen_accounts,\n ownable_state\n } = move_from(token_state_address);\n\n ownable::assert_only_owner(publisher_addr, &ownable_state);\n\n let token_state_signer = &object::generate_signer_for_extending(&extend_ref);\n\n // Code object owns token state, which owns the fungible asset\n // Code object => token state => fungible asset\n let constructor_ref =\n &object::create_named_object(token_state_signer, *symbol.bytes());\n primary_fungible_store::create_primary_store_enabled_fungible_asset(\n constructor_ref,\n max_supply,\n name,\n symbol,\n decimals,\n icon,\n project\n );\n\n fungible_asset::set_untransferable(constructor_ref);\n\n move_to(\n &object::generate_signer(constructor_ref),\n TokenMetadataRefs {\n extend_ref: object::generate_extend_ref(constructor_ref),\n mint_ref: fungible_asset::generate_mint_ref(constructor_ref),\n burn_ref: fungible_asset::generate_burn_ref(constructor_ref),\n transfer_ref: fungible_asset::generate_transfer_ref(constructor_ref),\n raw_balance_ref: fungible_asset::generate_raw_balance_ref(constructor_ref),\n raw_supply_ref: fungible_asset::generate_raw_supply_ref(constructor_ref),\n mutate_metadata_ref: fungible_asset::generate_mutate_metadata_ref(\n constructor_ref\n )\n }\n );\n\n // Set up dynamic dispatch functions\n let deposit =\n function_info::new_function_info_from_address(\n @regulated_token,\n string::utf8(b\"regulated_token\"),\n string::utf8(b\"deposit\")\n );\n let withdraw =\n function_info::new_function_info_from_address(\n @regulated_token,\n string::utf8(b\"regulated_token\"),\n string::utf8(b\"withdraw\")\n );\n dispatchable_fungible_asset::register_dispatch_functions(\n constructor_ref,\n option::some(withdraw),\n option::some(deposit),\n option::none()\n );\n\n let token = object::object_from_constructor_ref(constructor_ref);\n event::emit(\n InitializeToken {\n publisher: publisher_addr,\n token,\n max_supply,\n decimals,\n icon,\n project\n }\n );\n\n move_to(\n token_state_signer,\n TokenState {\n extend_ref,\n transfer_ref,\n paused,\n frozen_accounts,\n ownable_state,\n token\n }\n );\n }\n\n public entry fun mint(\n caller: &signer, to: address, amount: u64\n ) acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n let token_state = &TokenState[object::object_address(&state_obj)];\n\n assert_not_paused(token_state);\n assert_not_frozen(to, token_state);\n\n let minter = signer::address_of(caller);\n let is_bridge_minter =\n access_control::has_role(state_obj, minter, bridge_minter_or_burner_role());\n let is_native_minter = access_control::has_role(state_obj, minter, minter_role());\n\n assert!(is_bridge_minter || is_native_minter, E_ONLY_MINTER_OR_BRIDGE);\n\n primary_fungible_store::mint(&borrow_token_metadata_refs().mint_ref, to, amount);\n\n if (is_bridge_minter) {\n event::emit(BridgeMint { minter, to, amount });\n } else {\n event::emit(NativeMint { minter, to, amount });\n };\n }\n\n public entry fun burn(\n caller: &signer, from: address, amount: u64\n ) acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n let token_state = &TokenState[object::object_address(&state_obj)];\n\n assert_not_paused(token_state);\n assert_not_frozen(from, token_state);\n\n let burner = signer::address_of(caller);\n let (is_bridge_burner, _) = assert_burner_and_get_type(burner, state_obj);\n\n primary_fungible_store::burn(\n &borrow_token_metadata_refs().burn_ref, from, amount\n );\n\n if (is_bridge_burner) {\n event::emit(BridgeBurn { burner, from, amount });\n } else {\n event::emit(NativeBurn { burner, from, amount });\n }\n }\n\n /// Bridge-specific function to mint tokens directly as `FungibleAsset`.\n /// Required because this token has dynamic dispatch enabled\n /// as minting to pool and calling `fungible_asset::withdraw()` reverts.\n /// Only callable by accounts with BRIDGE_MINTER_OR_BURNER_ROLE.\n public fun bridge_mint(\n caller: &signer, to: address, amount: u64\n ): FungibleAsset acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n let token_state = &TokenState[object::object_address(&state_obj)];\n\n assert_not_paused(token_state);\n assert_bridge_minter_or_burner(caller, state_obj);\n assert_not_frozen(to, token_state);\n\n let fa = fungible_asset::mint(&borrow_token_metadata_refs().mint_ref, amount);\n\n event::emit(BridgeMint { minter: signer::address_of(caller), to, amount });\n\n fa\n }\n\n /// Bridge-specific function to burn `FungibleAsset` directly.\n /// Required because this token has dynamic dispatch enabled\n /// as depositing to pool and calling `fungible_asset::deposit()` reverts.\n /// Only callable by accounts with BRIDGE_MINTER_OR_BURNER_ROLE.\n public fun bridge_burn(\n caller: &signer, from: address, fa: FungibleAsset\n ) acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n let token_state = &TokenState[object::object_address(&state_obj)];\n\n assert_not_paused(token_state);\n assert_bridge_minter_or_burner(caller, state_obj);\n assert_not_frozen(from, token_state);\n\n let amount = fungible_asset::amount(&fa);\n fungible_asset::burn(&borrow_token_metadata_refs().burn_ref, fa);\n\n event::emit(BridgeBurn { burner: signer::address_of(caller), from, amount });\n }\n\n fun freeze_account_internal(\n caller_addr: address,\n account: address,\n transfer_ref: &TransferRef,\n token_state: &mut TokenState\n ) {\n // Ensure the account is frozen at the primary store level\n primary_fungible_store::set_frozen_flag(transfer_ref, account, true);\n\n if (!token_state.frozen_accounts.contains(&account)) {\n token_state.frozen_accounts.add(account, true);\n };\n\n event::emit(AccountFrozen { freezer: caller_addr, account });\n }\n\n fun unfreeze_account_internal(\n caller_addr: address,\n account: address,\n transfer_ref: &TransferRef,\n token_state: &mut TokenState\n ) {\n // Ensure the account is unfrozen at the primary store level\n primary_fungible_store::set_frozen_flag(transfer_ref, account, false);\n\n if (token_state.frozen_accounts.contains(&account)) {\n token_state.frozen_accounts.remove(&account);\n };\n\n event::emit(AccountUnfrozen { unfreezer: caller_addr, account });\n }\n\n fun burn_frozen_funds_internal(\n burner: address,\n account: address,\n burn_ref: &BurnRef,\n token_metadata: Object,\n is_frozen: bool,\n is_bridge_burner: bool\n ) {\n if (is_frozen) {\n let balance = primary_fungible_store::balance(account, token_metadata);\n if (balance > 0) {\n primary_fungible_store::burn(burn_ref, account, balance);\n if (is_bridge_burner) {\n event::emit(BridgeBurn { burner, from: account, amount: balance });\n } else {\n event::emit(NativeBurn { burner, from: account, amount: balance });\n };\n };\n };\n }\n\n fun recover_frozen_funds_internal(\n caller: address,\n from: address,\n to: address,\n transfer_ref: &TransferRef,\n token_state: &TokenState\n ) {\n assert!(\n token_state.frozen_accounts.contains(&from),\n E_ACCOUNT_MUST_BE_FROZEN_FOR_RECOVERY\n );\n\n let balance = primary_fungible_store::balance(from, token_state.token);\n if (balance > 0) {\n primary_fungible_store::transfer_with_ref(transfer_ref, from, to, balance);\n event::emit(\n TokensRecovered {\n caller,\n token_metadata: token_state.token,\n from,\n to,\n amount: balance\n }\n );\n };\n }\n\n /// Periphery function to apply roles to accounts\n public entry fun grant_role(\n caller: &signer, role_number: u8, account: address\n ) {\n let role = get_role(role_number);\n\n access_control::grant_role(\n caller,\n token_state_object_internal(),\n role,\n account\n );\n\n if (role == minter_role() || role == bridge_minter_or_burner_role()) {\n event::emit(\n MinterAdded {\n admin: signer::address_of(caller),\n minter: account,\n role,\n operation_type: role_number\n }\n );\n }\n }\n\n public entry fun revoke_role(\n caller: &signer, role_number: u8, account: address\n ) {\n let role = get_role(role_number);\n access_control::revoke_role(\n caller,\n token_state_object_internal(),\n role,\n account\n );\n }\n\n public entry fun freeze_accounts(\n caller: &signer, accounts: vector
\n ) acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n assert_freezer(caller, state_obj);\n\n let caller_addr = signer::address_of(caller);\n let transfer_ref = &borrow_token_metadata_refs().transfer_ref;\n for (i in 0..accounts.length()) {\n freeze_account_internal(\n caller_addr,\n accounts[i],\n transfer_ref,\n &mut TokenState[object::object_address(&state_obj)]\n );\n };\n }\n\n public entry fun freeze_account(\n caller: &signer, account: address\n ) acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n assert_freezer(caller, state_obj);\n\n let transfer_ref = &borrow_token_metadata_refs().transfer_ref;\n freeze_account_internal(\n signer::address_of(caller),\n account,\n transfer_ref,\n &mut TokenState[object::object_address(&state_obj)]\n );\n }\n\n public entry fun unfreeze_accounts(\n caller: &signer, accounts: vector
\n ) acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n assert_unfreezer(caller, state_obj);\n\n let caller_addr = signer::address_of(caller);\n let transfer_ref = &borrow_token_metadata_refs().transfer_ref;\n for (i in 0..accounts.length()) {\n unfreeze_account_internal(\n caller_addr,\n accounts[i],\n transfer_ref,\n &mut TokenState[object::object_address(&state_obj)]\n );\n };\n }\n\n public entry fun unfreeze_account(\n caller: &signer, account: address\n ) acquires TokenMetadataRefs, TokenState {\n let state_obj = token_state_object_internal();\n assert_unfreezer(caller, state_obj);\n\n let transfer_ref = &borrow_token_metadata_refs().transfer_ref;\n unfreeze_account_internal(\n signer::address_of(caller),\n account,\n transfer_ref,\n &mut TokenState[object::object_address(&state_obj)]\n );\n }\n\n /// Batch revoke and grant roles by role number\n /// `batch_revoke_role` and `batch_grant_role` assert that the caller is the admin\n public entry fun apply_role_updates(\n caller: &signer,\n role_number: u8,\n addresses_to_remove: vector
,\n addresses_to_add: vector
\n ) {\n let role = get_role(role_number);\n let state_obj = token_state_object_internal();\n\n if (addresses_to_remove.length() > 0) {\n access_control::batch_revoke_role(\n caller,\n state_obj,\n role,\n addresses_to_remove\n );\n };\n\n if (addresses_to_add.length() > 0) {\n access_control::batch_grant_role(caller, state_obj, role, addresses_to_add);\n };\n }\n\n public entry fun pause(caller: &signer) acquires TokenState {\n let state_obj = token_state_object_internal();\n assert_pauser(caller, state_obj);\n\n let state = &mut TokenState[object::object_address(&state_obj)];\n assert!(!state.paused, E_ALREADY_PAUSED);\n\n state.paused = true;\n event::emit(Paused { pauser: signer::address_of(caller) });\n }\n\n public entry fun unpause(caller: &signer) acquires TokenState {\n let state_obj = token_state_object_internal();\n assert_unpauser(caller, state_obj);\n\n let state = &mut TokenState[object::object_address(&state_obj)];\n assert!(state.paused, E_NOT_PAUSED);\n\n state.paused = false;\n event::emit(Unpaused { unpauser: signer::address_of(caller) });\n }\n\n /// Validates and sets up burn frozen funds operation.\n inline fun validate_burn_frozen_funds(\n caller: &signer\n ): (\n address, &BurnRef, Object, &TokenState, bool\n ) {\n let state_obj = token_state_object_internal();\n let token_state = &TokenState[object::object_address(&state_obj)];\n assert_not_paused(token_state);\n\n let burner = signer::address_of(caller);\n let (is_bridge_burner, _) = assert_burner_and_get_type(burner, state_obj);\n let token_metadata = token_metadata_from_state_obj(state_obj);\n let burn_ref = &borrow_token_metadata_refs().burn_ref;\n\n (\n burner, burn_ref, token_metadata, token_state, is_bridge_burner\n )\n }\n\n public entry fun batch_burn_frozen_funds(\n caller: &signer, accounts: vector
\n ) acquires TokenMetadataRefs, TokenState {\n let (\n burner, burn_ref, token_metadata, token_state, is_bridge_burner\n ) = validate_burn_frozen_funds(caller);\n\n for (i in 0..accounts.length()) {\n burn_frozen_funds_internal(\n burner,\n accounts[i],\n burn_ref,\n token_metadata,\n token_state.frozen_accounts.contains(&accounts[i]),\n is_bridge_burner\n );\n };\n }\n\n public entry fun burn_frozen_funds(\n caller: &signer, from: address\n ) acquires TokenMetadataRefs, TokenState {\n let (\n burner, burn_ref, token_metadata, token_state, is_bridge_burner\n ) = validate_burn_frozen_funds(caller);\n\n burn_frozen_funds_internal(\n burner,\n from,\n burn_ref,\n token_metadata,\n token_state.frozen_accounts.contains(&from),\n is_bridge_burner\n );\n }\n\n /// Recovers funds from frozen accounts by transferring them to a specified account.\n /// Only callable by accounts with RECOVERY_ROLE.\n public entry fun recover_frozen_funds(\n caller: &signer, from: address, to: address\n ) acquires TokenMetadataRefs, TokenState {\n let (transfer_ref, token_state) = validate_recovery_procedure(caller, to);\n recover_frozen_funds_internal(\n signer::address_of(caller),\n from,\n to,\n transfer_ref,\n token_state\n );\n }\n\n /// Batch version of recover_frozen_funds for processing multiple frozen accounts.\n /// Only callable by accounts with RECOVERY_ROLE.\n public entry fun batch_recover_frozen_funds(\n caller: &signer, accounts: vector
, to: address\n ) acquires TokenMetadataRefs, TokenState {\n let caller_addr = signer::address_of(caller);\n let (transfer_ref, token_state) = validate_recovery_procedure(caller, to);\n\n for (i in 0..accounts.length()) {\n recover_frozen_funds_internal(\n caller_addr,\n accounts[i],\n to,\n transfer_ref,\n token_state\n );\n };\n }\n\n inline fun assert_valid_recovery_recipient(\n to: address, token_state: &TokenState\n ) {\n assert!(to != @0x0, E_ZERO_ADDRESS_NOT_ALLOWED);\n assert!(\n to != @regulated_token && to != token_state_address_internal(),\n E_CANNOT_TRANSFER_TO_REGULATED_TOKEN\n );\n assert_not_frozen(to, token_state);\n }\n\n inline fun validate_recovery_procedure(caller: &signer, to: address)\n : (&TransferRef, &TokenState) {\n let state_obj = token_state_object_internal();\n let token_state = &TokenState[object::object_address(&state_obj)];\n\n assert_not_paused(token_state);\n assert_recovery_role(caller, state_obj);\n assert_valid_recovery_recipient(to, token_state);\n\n (&borrow_token_metadata_refs().transfer_ref, token_state)\n }\n\n public entry fun transfer_admin(caller: &signer, new_admin: address) {\n access_control::transfer_admin(\n caller, token_state_object_internal(), new_admin\n );\n }\n\n public entry fun accept_admin(caller: &signer) {\n access_control::accept_admin(\n caller, token_state_object_internal()\n );\n }\n\n /// Helper function to recover tokens from a specific address\n fun recover_tokens_from_address(\n caller_addr: address,\n from: address,\n to: address,\n transfer_ref: &TransferRef\n ) {\n let token_metadata = fungible_asset::transfer_ref_metadata(transfer_ref);\n let balance = primary_fungible_store::balance(from, token_metadata);\n if (balance > 0) {\n primary_fungible_store::transfer_with_ref(transfer_ref, from, to, balance);\n event::emit(\n TokensRecovered {\n caller: caller_addr,\n token_metadata,\n from,\n to,\n amount: balance\n }\n );\n }\n }\n\n /// In case regulated tokens get stuck in the contract or token state, this function can be used to recover them\n /// This function can only be called by the recovery role\n public entry fun recover_tokens(\n caller: &signer, to: address\n ) acquires TokenMetadataRefs, TokenState {\n let (transfer_ref, _token_state) = validate_recovery_procedure(caller, to);\n let caller_addr = signer::address_of(caller);\n\n // Recover regulated tokens sent to contract\n recover_tokens_from_address(\n caller_addr,\n @regulated_token,\n to,\n transfer_ref\n );\n\n // Recover regulated tokens sent to token state address\n recover_tokens_from_address(\n caller_addr,\n token_state_address_internal(),\n to,\n transfer_ref\n );\n }\n\n fun assert_not_paused(token_state: &TokenState) {\n assert!(!token_state.paused, E_PAUSED);\n }\n\n inline fun assert_pauser(\n caller: &signer, state_obj: Object\n ) {\n access_control::assert_role(\n state_obj, signer::address_of(caller), pauser_role()\n );\n }\n\n inline fun assert_unpauser(\n caller: &signer, state_obj: Object\n ) {\n access_control::assert_role(\n state_obj, signer::address_of(caller), unpauser_role()\n );\n }\n\n inline fun assert_freezer(\n caller: &signer, state_obj: Object\n ) {\n access_control::assert_role(\n state_obj, signer::address_of(caller), freezer_role()\n );\n }\n\n inline fun assert_unfreezer(\n caller: &signer, state_obj: Object\n ) {\n access_control::assert_role(\n state_obj, signer::address_of(caller), unfreezer_role()\n );\n }\n\n inline fun assert_recovery_role(\n caller: &signer, state_obj: Object\n ) {\n access_control::assert_role(\n state_obj, signer::address_of(caller), recovery_role()\n );\n }\n\n fun assert_bridge_minter_or_burner(\n caller: &signer, state_obj: Object\n ) {\n access_control::assert_role(\n state_obj,\n signer::address_of(caller),\n bridge_minter_or_burner_role()\n );\n }\n\n inline fun assert_burner_and_get_type(\n burner: address, state_obj: Object\n ): (bool, bool) {\n let is_bridge_burner =\n access_control::has_role(state_obj, burner, bridge_minter_or_burner_role());\n let is_native_burner = access_control::has_role(state_obj, burner, burner_role());\n\n assert!(is_bridge_burner || is_native_burner, E_ONLY_BURNER_OR_BRIDGE);\n\n (is_bridge_burner, is_native_burner)\n }\n\n fun assert_not_frozen(account: address, token_state: &TokenState) {\n assert!(!token_state.frozen_accounts.contains(&account), E_ACCOUNT_FROZEN);\n }\n\n fun assert_correct_asset(\n transfer_ref: &TransferRef, token_metadata: Object, store: Object\n ) {\n assert!(\n fungible_asset::transfer_ref_metadata(transfer_ref) == token_metadata,\n E_INVALID_ASSET\n );\n assert!(fungible_asset::store_metadata(store) == token_metadata, E_INVALID_STORE);\n }\n\n fun get_role(role_number: u8): Role {\n if (role_number == PAUSER_ROLE) {\n pauser_role()\n } else if (role_number == UNPAUSER_ROLE) {\n unpauser_role()\n } else if (role_number == FREEZER_ROLE) {\n freezer_role()\n } else if (role_number == UNFREEZER_ROLE) {\n unfreezer_role()\n } else if (role_number == MINTER_ROLE) {\n minter_role()\n } else if (role_number == BURNER_ROLE) {\n burner_role()\n } else if (role_number == BRIDGE_MINTER_OR_BURNER_ROLE) {\n bridge_minter_or_burner_role()\n } else if (role_number == RECOVERY_ROLE) {\n recovery_role()\n } else {\n abort E_INVALID_ROLE_NUMBER\n }\n }\n\n inline fun borrow_token_metadata_refs(): &TokenMetadataRefs {\n let token_metadata = token_metadata_internal();\n &TokenMetadataRefs[object::object_address(&token_metadata)]\n }\n\n public fun pauser_role(): Role {\n Role::PAUSER_ROLE\n }\n\n public fun unpauser_role(): Role {\n Role::UNPAUSER_ROLE\n }\n\n public fun freezer_role(): Role {\n Role::FREEZER_ROLE\n }\n\n public fun unfreezer_role(): Role {\n Role::UNFREEZER_ROLE\n }\n\n public fun minter_role(): Role {\n Role::MINTER_ROLE\n }\n\n public fun burner_role(): Role {\n Role::BURNER_ROLE\n }\n\n public fun bridge_minter_or_burner_role(): Role {\n Role::BRIDGE_MINTER_OR_BURNER_ROLE\n }\n\n public fun recovery_role(): Role {\n Role::RECOVERY_ROLE\n }\n\n // ====================== Ownable Functions ======================\n #[view]\n public fun owner(): address acquires TokenState {\n ownable::owner(&TokenState[token_state_address_internal()].ownable_state)\n }\n\n #[view]\n public fun has_pending_transfer(): bool acquires TokenState {\n ownable::has_pending_transfer(\n &TokenState[token_state_address_internal()].ownable_state\n )\n }\n\n #[view]\n public fun pending_transfer_from(): Option
acquires TokenState {\n ownable::pending_transfer_from(\n &TokenState[token_state_address_internal()].ownable_state\n )\n }\n\n #[view]\n public fun pending_transfer_to(): Option
acquires TokenState {\n ownable::pending_transfer_to(\n &TokenState[token_state_address_internal()].ownable_state\n )\n }\n\n #[view]\n public fun pending_transfer_accepted(): Option acquires TokenState {\n ownable::pending_transfer_accepted(\n &TokenState[token_state_address_internal()].ownable_state\n )\n }\n\n public entry fun transfer_ownership(caller: &signer, to: address) acquires TokenState {\n let state = &mut TokenState[token_state_address_internal()];\n ownable::transfer_ownership(caller, &mut state.ownable_state, to)\n }\n\n public entry fun accept_ownership(caller: &signer) acquires TokenState {\n let state = &mut TokenState[token_state_address_internal()];\n ownable::accept_ownership(caller, &mut state.ownable_state)\n }\n\n public entry fun execute_ownership_transfer(\n caller: &signer, to: address\n ) acquires TokenState {\n let state = &mut TokenState[token_state_address_internal()];\n ownable::execute_ownership_transfer(caller, &mut state.ownable_state, to)\n }\n}\n"; + +export const REGULATED_ACCESS_CONTROL_MOVE = `module regulated_token::access_control { + use std::event; + use std::ordered_map::{Self, OrderedMap}; + use std::object::{Self, Object}; + use std::signer; + use std::object::ConstructorRef; + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct AccessControlState has key, store { + /// Mapping from role to list of addresses that have the role + roles: OrderedMap>, + /// The admin address who can manage all roles + admin: address, + /// Pending admin for two-step admin transfer + pending_admin: address + } + + #[event] + struct RoleGranted has drop, store { + role: Role, + account: address, + sender: address + } + + #[event] + struct RoleRevoked has drop, store { + role: Role, + account: address, + sender: address + } + + #[event] + struct TransferAdmin has drop, store { + admin: address, + pending_admin: address + } + + #[event] + struct AcceptAdmin has drop, store { + old_admin: address, + new_admin: address + } + + /// Role state not initialized + const E_ROLE_STATE_NOT_INITIALIZED: u64 = 1; + /// Caller does not have the required role + const E_MISSING_ROLE: u64 = 2; + /// Caller is not the admin + const E_NOT_ADMIN: u64 = 3; + /// Cannot transfer admin to same address + const E_SAME_ADMIN: u64 = 4; + /// Index out of bounds + const E_INDEX_OUT_OF_BOUNDS: u64 = 5; + + public fun init( + constructor_ref: &ConstructorRef, admin: address + ) { + let obj_signer = object::generate_signer(constructor_ref); + move_to( + &obj_signer, + AccessControlState { + admin, + pending_admin: @0x0, + roles: ordered_map::new() + } + ); + } + + #[view] + public fun has_role( + state_obj: Object, account: address, role: Role + ): bool acquires AccessControlState { + let roles = &borrow(state_obj).roles; + roles.contains(&role) && roles.borrow(&role).contains(&account) + } + + #[view] + public fun get_role_members( + state_obj: Object, role: Role + ): vector
acquires AccessControlState { + let state = borrow(state_obj); + if (state.roles.contains(&role)) { + *state.roles.borrow(&role) + } else { + vector[] + } + } + + #[view] + public fun get_role_member_count( + state_obj: Object, role: Role + ): u64 acquires AccessControlState { + let roles = &borrow(state_obj).roles; + if (roles.contains(&role)) { + roles.borrow(&role).length() + } else { 0 } + } + + #[view] + public fun get_role_member( + state_obj: Object, role: Role, index: u64 + ): address acquires AccessControlState { + let roles = &borrow(state_obj).roles; + assert!(roles.contains(&role), E_MISSING_ROLE); + + let addresses = roles.borrow(&role); + assert!(index < addresses.length(), E_INDEX_OUT_OF_BOUNDS); + addresses[index] + } + + #[view] + public fun admin( + state_obj: Object + ): address acquires AccessControlState { + borrow(state_obj).admin + } + + #[view] + public fun pending_admin( + state_obj: Object + ): address acquires AccessControlState { + borrow(state_obj).pending_admin + } + + public entry fun batch_grant_role( + caller: &signer, + state_obj: Object, + role: Role, + accounts: vector
+ ) acquires AccessControlState { + if (accounts.length() == 0) return; + + let state = authorized_borrow_mut(caller, state_obj); + let sender = signer::address_of(caller); + + for (i in 0..accounts.length()) { + grant_role_internal(state, role, accounts[i], sender); + }; + } + + public entry fun grant_role( + caller: &signer, state_obj: Object, role: Role, account: address + ) acquires AccessControlState { + let state = authorized_borrow_mut(caller, state_obj); + let sender = signer::address_of(caller); + + grant_role_internal(state, role, account, sender); + } + + fun grant_role_internal( + state: &mut AccessControlState, + role: Role, + account: address, + sender: address + ) { + if (state.roles.contains(&role)) { + let addresses = state.roles.borrow_mut(&role); + if (!addresses.contains(&account)) { + addresses.push_back(account); + event::emit(RoleGranted { role, account, sender }); + } + } else { + state.roles.add(role, vector[account]); + event::emit(RoleGranted { role, account, sender }); + } + } + + public entry fun batch_revoke_role( + caller: &signer, + state_obj: Object, + role: Role, + accounts: vector
+ ) acquires AccessControlState { + if (accounts.length() == 0) return; + + let state = authorized_borrow_mut(caller, state_obj); + let sender = signer::address_of(caller); + + for (i in 0..accounts.length()) { + revoke_role_internal(state, role, accounts[i], sender); + }; + } + + public entry fun revoke_role( + caller: &signer, state_obj: Object, role: Role, account: address + ) acquires AccessControlState { + let state = authorized_borrow_mut(caller, state_obj); + let sender = signer::address_of(caller); + + revoke_role_internal(state, role, account, sender); + } + + fun revoke_role_internal( + state: &mut AccessControlState, + role: Role, + account: address, + sender: address + ) { + if (state.roles.contains(&role)) { + let addresses = state.roles.borrow_mut(&role); + let (found, index) = addresses.index_of(&account); + if (found) { + addresses.remove(index); + event::emit(RoleRevoked { role, account, sender }); + } + } + } + + public entry fun renounce_role( + caller: &signer, state_obj: Object, role: Role + ) acquires AccessControlState { + let state = borrow_mut(state_obj); + let caller_addr = signer::address_of(caller); + + if (state.roles.contains(&role)) { + let addresses = state.roles.borrow_mut(&role); + let (found, index) = addresses.index_of(&caller_addr); + if (found) { + addresses.remove(index); + event::emit(RoleRevoked { role, account: caller_addr, sender: caller_addr }); + }; + }; + } + + public fun assert_role( + state_obj: Object, caller: address, role: Role + ) acquires AccessControlState { + assert!( + has_role(state_obj, caller, role), + E_MISSING_ROLE + ); + } + + public entry fun transfer_admin( + admin: &signer, state_obj: Object, new_admin: address + ) acquires AccessControlState { + let state = authorized_borrow_mut(admin, state_obj); + assert!(signer::address_of(admin) != new_admin, E_SAME_ADMIN); + + state.pending_admin = new_admin; + + event::emit(TransferAdmin { admin: state.admin, pending_admin: new_admin }); + } + + public entry fun accept_admin( + pending_admin: &signer, state_obj: Object + ) acquires AccessControlState { + let state = borrow_mut(state_obj); + let pending_admin_addr = signer::address_of(pending_admin); + + assert!(pending_admin_addr == state.pending_admin, E_NOT_ADMIN); + + let old_admin = state.admin; + state.admin = state.pending_admin; + state.pending_admin = @0x0; + + event::emit(AcceptAdmin { old_admin, new_admin: state.admin }); + } + + inline fun authorized_borrow_mut( + caller: &signer, state_obj: Object + ): &mut AccessControlState { + let state = borrow_mut(state_obj); + assert!(state.admin == signer::address_of(caller), E_NOT_ADMIN); + state + } + + inline fun borrow_mut( + state_obj: Object + ): &mut AccessControlState { + let obj_addr = assert_exists(state_obj); + &mut AccessControlState[obj_addr] + } + + inline fun borrow(state_obj: Object) + : &AccessControlState { + let obj_addr = assert_exists(state_obj); + &AccessControlState[obj_addr] + } + + inline fun assert_exists( + state_obj: Object + ): address { + let obj_addr = object::object_address(&state_obj); + assert!( + exists>(obj_addr), + E_ROLE_STATE_NOT_INITIALIZED + ); + obj_addr + } +} +` + +export const REGULATED_OWNABLE_MOVE = `/// This module implements an Ownable component similar to Ownable2Step.sol for managing +/// object ownership. +/// +/// Due to Aptos's security model requiring the original owner's signer for 0x1::object::transfer, +/// this implementation uses a 3-step ownership transfer flow: +/// +/// 1. Initial owner calls transfer_ownership with the new owner's address +/// 2. Pending owner calls accept_ownership to confirm the transfer +/// 3. Initial owner calls execute_ownership_transfer to complete the transfer +/// +/// The execute_ownership_transfer function requires a signer in order to perform the +/// object transfer, while other operations only require the caller address to maintain the +/// principle of least privilege. +/// +/// Note that direct ownership transfers via 0x1::object::transfer are still possible. +/// This module handles such cases gracefully by reading the current owner directly +/// from the object. +module regulated_token::ownable { + use std::account; + use std::error; + use std::event::{Self, EventHandle}; + use std::object::{Self, Object, ObjectCore}; + use std::option::{Self, Option}; + use std::signer; + + struct OwnableState has store { + target_object: Object, + pending_transfer: Option, + ownership_transfer_requested_events: EventHandle, + ownership_transfer_accepted_events: EventHandle, + ownership_transferred_events: EventHandle + } + + struct PendingTransfer has store, drop { + from: address, + to: address, + accepted: bool + } + + const E_MUST_BE_PROPOSED_OWNER: u64 = 1; + const E_CANNOT_TRANSFER_TO_SELF: u64 = 2; + const E_ONLY_CALLABLE_BY_OWNER: u64 = 3; + const E_PROPOSED_OWNER_MISMATCH: u64 = 4; + const E_OWNER_CHANGED: u64 = 5; + const E_NO_PENDING_TRANSFER: u64 = 6; + const E_TRANSFER_NOT_ACCEPTED: u64 = 7; + const E_TRANSFER_ALREADY_ACCEPTED: u64 = 8; + + #[event] + struct OwnershipTransferRequested has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferAccepted has store, drop { + from: address, + to: address + } + + #[event] + struct OwnershipTransferred has store, drop { + from: address, + to: address + } + + public fun new(event_account: &signer, object_address: address): OwnableState { + let new_state = OwnableState { + target_object: object::address_to_object(object_address), + pending_transfer: option::none(), + ownership_transfer_requested_events: account::new_event_handle(event_account), + ownership_transfer_accepted_events: account::new_event_handle(event_account), + ownership_transferred_events: account::new_event_handle(event_account) + }; + + new_state + } + + public fun owner(state: &OwnableState): address { + owner_internal(state) + } + + public fun has_pending_transfer(state: &OwnableState): bool { + state.pending_transfer.is_some() + } + + public fun pending_transfer_from(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.from) + } + + public fun pending_transfer_to(state: &OwnableState): Option
{ + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.to) + } + + public fun pending_transfer_accepted(state: &OwnableState): Option { + state.pending_transfer.map_ref(|pending_transfer| pending_transfer.accepted) + } + + inline fun owner_internal(state: &OwnableState): address { + object::owner(state.target_object) + } + + public fun transfer_ownership( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + assert!(caller_address != to, error::invalid_argument(E_CANNOT_TRANSFER_TO_SELF)); + + state.pending_transfer = option::some( + PendingTransfer { from: caller_address, to, accepted: false } + ); + + event::emit_event( + &mut state.ownership_transfer_requested_events, + OwnershipTransferRequested { from: caller_address, to } + ); + } + + public fun accept_ownership(caller: &signer, state: &mut OwnableState) { + let caller_address = signer::address_of(caller); + assert!( + state.pending_transfer.is_some(), + error::permission_denied(E_NO_PENDING_TRANSFER) + ); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.borrow_mut(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == caller_address, + error::permission_denied(E_MUST_BE_PROPOSED_OWNER) + ); + assert!( + !pending_transfer.accepted, + error::invalid_state(E_TRANSFER_ALREADY_ACCEPTED) + ); + + pending_transfer.accepted = true; + + event::emit_event( + &mut state.ownership_transfer_accepted_events, + OwnershipTransferAccepted { from: pending_transfer.from, to: caller_address } + ); + } + + public fun execute_ownership_transfer( + caller: &signer, state: &mut OwnableState, to: address + ) { + let caller_address = signer::address_of(caller); + assert_only_owner_internal(caller_address, state); + + let current_owner = owner_internal(state); + let pending_transfer = state.pending_transfer.extract(); + + // check that the owner has not changed from a direct call to 0x1::object::transfer, + // in which case the transfer flow should be restarted. + assert!( + pending_transfer.from == current_owner, + error::permission_denied(E_OWNER_CHANGED) + ); + assert!( + pending_transfer.to == to, + error::permission_denied(E_PROPOSED_OWNER_MISMATCH) + ); + assert!( + pending_transfer.accepted, + error::invalid_state(E_TRANSFER_NOT_ACCEPTED) + ); + + object::transfer(caller, state.target_object, pending_transfer.to); + state.pending_transfer = option::none(); + + event::emit_event( + &mut state.ownership_transferred_events, + OwnershipTransferred { from: caller_address, to } + ); + } + + public fun assert_only_owner(caller: address, state: &OwnableState) { + assert_only_owner_internal(caller, state) + } + + inline fun assert_only_owner_internal( + caller: address, state: &OwnableState + ) { + assert!( + caller == owner_internal(state), + error::permission_denied(E_ONLY_CALLABLE_BY_OWNER) + ); + } + + public fun destroy(state: OwnableState) { + let OwnableState { + target_object: _, + pending_transfer: _, + ownership_transfer_requested_events, + ownership_transfer_accepted_events, + ownership_transferred_events + } = state; + + event::destroy_handle(ownership_transfer_requested_events); + event::destroy_handle(ownership_transfer_accepted_events); + event::destroy_handle(ownership_transferred_events); + } +} +` diff --git a/ccip-sdk/src/token-admin/aptos/index.ts b/ccip-sdk/src/token-admin/aptos/index.ts new file mode 100644 index 00000000..ee08f06f --- /dev/null +++ b/ccip-sdk/src/token-admin/aptos/index.ts @@ -0,0 +1,3280 @@ +/** + * Aptos token admin — deploy ManagedToken FA modules on Aptos chains. + * + * Requires the `aptos` CLI to be installed for Move compilation at deploy time. + * This is a **Node.js/CLI-only** operation — browser environments are not supported + * for the publish step (compilation requires filesystem + child_process). + * + * ## Why this cannot run in a browser + * + * The ManagedToken Move bytecode embeds the **object address** as a named address + * (`managed_token=`). This address is derived deterministically from the + * sender's account address and current sequence number, so it changes for every + * deploy. The Move source must be recompiled each time using the `aptos` CLI, + * which requires Node.js (`child_process`, `fs`, `os`, `path`). + * + * ## Frontend integration + * + * To use this from a frontend (browser/React/Next.js), set up a **backend relay**: + * + * 1. Frontend sends `{ sender, params }` to your backend API + * 2. Backend calls `admin.generateUnsignedDeployToken(sender, params)` (Node.js) + * 3. Backend returns the serialized unsigned transactions to the frontend + * 4. Frontend deserializes, signs with the user's wallet (e.g. Petra/Pontem), + * and submits each transaction sequentially + * + * ```typescript + * // ── Backend (Node.js / Express / serverless) ── + * app.post('/api/aptos/deploy-token', async (req, res) => { + * const { sender, params } = req.body + * const chain = await AptosChain.fromUrl(APTOS_RPC) + * const admin = AptosTokenAdmin.fromChain(chain) + * const unsignedTxs = await admin.generateUnsignedDeployToken(sender, params) + * // Each tx is already BCS-serialized (Uint8Array), encode for transport + * const txsHex = unsignedTxs.map(tx => ({ + * family: tx.family, + * transactions: tx.transactions.map(t => Buffer.from(t).toString('hex')), + * })) + * res.json({ txs: txsHex }) + * }) + * + * // ── Frontend (browser) ── + * const { txs } = await fetch('/api/aptos/deploy-token', { + * method: 'POST', + * body: JSON.stringify({ sender: account.address, params }), + * }).then(r => r.json()) + * + * for (const tx of txs) { + * const bytes = Uint8Array.from(Buffer.from(tx.transactions[0], 'hex')) + * const unsignedTx = SimpleTransaction.deserialize(new Deserializer(bytes)) + * const signed = await walletAdapter.signTransaction(unsignedTx) + * await aptosClient.transaction.submit.simple({ + * transaction: unsignedTx, + * senderAuthenticator: signed, + * }) + * } + * ``` + * + * @example Using AptosTokenAdmin with a wallet (signed deploy — Node.js only) + * ```typescript + * import { AptosChain } from '@chainlink/ccip-sdk' + * import { AptosTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/aptos/index.ts' + * + * const chain = await AptosChain.fromUrl('https://fullnode.testnet.aptoslabs.com/v1') + * const admin = AptosTokenAdmin.fromChain(chain) + * const { tokenAddress, txHash } = await admin.deployToken(wallet, { + * name: 'My Token', symbol: 'MTK', decimals: 8, + * }) + * ``` + * + * @packageDocumentation + */ + +/* eslint-disable import-x/no-nodejs-modules -- Node.js-only module: requires CLI compilation */ +import { execSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +/* eslint-enable import-x/no-nodejs-modules */ + +import { + type Aptos, + AccountAddress, + Deserializer, + SimpleTransaction, + buildTransaction, + createObjectAddress, + generateTransactionPayloadWithABI, + parseTypeTag, +} from '@aptos-labs/ts-sdk' +import { hexlify, zeroPadValue } from 'ethers' + +import { AptosChain } from '../../aptos/index.ts' +import { type UnsignedAptosTx, isAptosAccount } from '../../aptos/types.ts' +import type { ChainContext } from '../../chain.ts' +import { + CCIPAcceptAdminRoleFailedError, + CCIPAcceptAdminRoleParamsInvalidError, + CCIPAcceptOwnershipFailedError, + CCIPAcceptOwnershipParamsInvalidError, + CCIPAppendRemotePoolAddressesFailedError, + CCIPAppendRemotePoolAddressesParamsInvalidError, + CCIPApplyChainUpdatesFailedError, + CCIPApplyChainUpdatesParamsInvalidError, + CCIPDeleteChainConfigFailedError, + CCIPDeleteChainConfigParamsInvalidError, + CCIPExecuteOwnershipTransferFailedError, + CCIPExecuteOwnershipTransferParamsInvalidError, + CCIPGrantMintBurnAccessFailedError, + CCIPGrantMintBurnAccessParamsInvalidError, + CCIPMethodUnsupportedError, + CCIPPoolDeployFailedError, + CCIPPoolDeployParamsInvalidError, + CCIPPoolNotInitializedError, + CCIPProposeAdminRoleFailedError, + CCIPProposeAdminRoleParamsInvalidError, + CCIPRemoveRemotePoolAddressesFailedError, + CCIPRemoveRemotePoolAddressesParamsInvalidError, + CCIPRevokeMintBurnAccessFailedError, + CCIPRevokeMintBurnAccessParamsInvalidError, + CCIPSetPoolFailedError, + CCIPSetPoolParamsInvalidError, + CCIPSetRateLimiterConfigFailedError, + CCIPSetRateLimiterConfigParamsInvalidError, + CCIPTokenDeployFailedError, + CCIPTokenDeployParamsInvalidError, + CCIPTokenPoolInfoNotFoundError, + CCIPTransferAdminRoleFailedError, + CCIPTransferAdminRoleParamsInvalidError, + CCIPTransferOwnershipFailedError, + CCIPTransferOwnershipParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type Logger, type NetworkInfo, ChainFamily } from '../../types.ts' +import { getAddressBytes } from '../../utils.ts' +import { + validateAppendRemotePoolAddressesParams, + validateApplyChainUpdatesParams, + validateDeleteChainConfigParams, + validateRemoveRemotePoolAddressesParams, +} from '../apply-chain-updates-utils.ts' +import { validateSetChainRateLimiterConfigParams } from '../set-rate-limiter-config-utils.ts' +import type { + AcceptAdminRoleParams, + AcceptAdminRoleResult, + AcceptOwnershipParams, + AppendRemotePoolAddressesParams, + AppendRemotePoolAddressesResult, + ApplyChainUpdatesParams, + ApplyChainUpdatesResult, + AptosDeployPoolParams, + AptosDeployTokenParams, + AptosMintBurnRolesResult, + AptosProposeAdminRoleParams, + AptosTokenModule, + DeleteChainConfigParams, + DeleteChainConfigResult, + DeployPoolResult, + DeployTokenResult, + ExecuteOwnershipTransferParams, + GrantMintBurnAccessParams, + GrantMintBurnAccessResult, + OwnershipResult, + ProposeAdminRoleResult, + RemoveRemotePoolAddressesParams, + RemoveRemotePoolAddressesResult, + RevokeMintBurnAccessParams, + RevokeMintBurnAccessResult, + SetChainRateLimiterConfigParams, + SetChainRateLimiterConfigResult, + SetPoolParams, + SetPoolResult, + TransferAdminRoleParams, + TransferAdminRoleResult, + TransferOwnershipParams, +} from '../types.ts' + +/** Domain separator used by object_code_deployment::publish to derive object addresses. */ +const OBJECT_CODE_DEPLOYMENT_DOMAIN = 'aptos_framework::object_code_deployment' + +/** Seed used by init_module to create the token state named object. */ +const TOKEN_STATE_SEED = 'managed_token::managed_token::token_state' + +/** + * Computes the deterministic object address that `object_code_deployment::publish` + * will create for a given sender and their current sequence number. + * + * Uses the Aptos SDK's `createObjectAddress` with the same seed derivation as + * `object_code_deployment::object_seed`: `bcs(domain_separator) || bcs(seq + 1)`. + */ +async function computeObjectAddress( + provider: Aptos, + sender: string, +): Promise<{ objectAddress: string; sequenceNumber: bigint }> { + const { sequence_number } = await provider.getAccountInfo({ accountAddress: sender }) + const sequenceNumber = BigInt(sequence_number) + + const domainBytes = Buffer.from(OBJECT_CODE_DEPLOYMENT_DOMAIN, 'utf8') + // BCS vector: ULEB128(length) + bytes + const uleb = Buffer.from([domainBytes.length]) + // BCS u64: 8 bytes little-endian; object_seed uses sequence_number + 1 + const seqBuf = Buffer.alloc(8) + seqBuf.writeBigUInt64LE(sequenceNumber + 1n) + + const seed = new Uint8Array(Buffer.concat([uleb, domainBytes, seqBuf])) + const objectAddress = createObjectAddress(AccountAddress.from(sender), seed).toString() + + return { objectAddress, sequenceNumber } +} + +/** + * Derives the fungible asset metadata address from the code object address and token symbol. + * + * Object hierarchy: code object → token state (TOKEN_STATE_SEED) → FA (symbol bytes). + */ +function deriveFungibleAssetAddress(objectAddress: string, symbol: string): string { + // token state = createObjectAddress(code_object, TOKEN_STATE_SEED) + const tokenStateAddress = createObjectAddress( + AccountAddress.from(objectAddress), + new Uint8Array(Buffer.from(TOKEN_STATE_SEED, 'utf8')), + ) + + // FA metadata = createObjectAddress(token_state, symbol_bytes) + const faAddress = createObjectAddress( + tokenStateAddress, + new Uint8Array(Buffer.from(symbol, 'utf8')), + ) + + return faAddress.toString() +} + +/** + * Resolves the code object address for a managed or regulated token by walking + * the Aptos object ownership chain: FA metadata → owner (TokenState) → owner (code object). + * + * Uses the generic `0x1::object::ObjectCore` resource which stores the `owner` field + * for every Aptos object — no dependency on specific module view functions. + * + * @param provider - Aptos provider instance + * @param faMetadataAddress - Fungible asset metadata address (the user-facing token address) + * @returns Code object address (grandparent of the FA metadata) + * @throws {@link CCIPPoolDeployParamsInvalidError} if ownership chain cannot be resolved + */ +async function resolveCodeObjectAddress( + provider: Aptos, + faMetadataAddress: string, +): Promise { + const resourceType = '0x1::object::ObjectCore' + + // Step 1: FA metadata → owner (TokenState) + let tokenStateOwner: string + try { + const faResource = await provider.getAccountResource<{ owner: string }>({ + accountAddress: faMetadataAddress, + resourceType, + }) + tokenStateOwner = faResource.owner + } catch { + throw new CCIPPoolDeployParamsInvalidError( + 'tokenAddress', + `cannot resolve object owner for FA metadata at ${faMetadataAddress} — is this a valid Aptos fungible asset?`, + ) + } + + // Step 2: TokenState → owner (code object) + let codeObjectAddress: string + try { + const stateResource = await provider.getAccountResource<{ owner: string }>({ + accountAddress: tokenStateOwner, + resourceType, + }) + codeObjectAddress = stateResource.owner + } catch { + throw new CCIPPoolDeployParamsInvalidError( + 'tokenAddress', + `cannot resolve code object from token state at ${tokenStateOwner} — unexpected object hierarchy`, + ) + } + + // Normalize to full 0x-prefixed 64-char hex (API may return short form) + return AccountAddress.from(codeObjectAddress).toString() +} + +/** + * Validates deploy parameters for Aptos ManagedToken. + * @throws {@link CCIPTokenDeployParamsInvalidError} on invalid params + */ +function validateParams(params: AptosDeployTokenParams): void { + if (!params.name || params.name.trim().length === 0) { + throw new CCIPTokenDeployParamsInvalidError('name', 'must be non-empty') + } + if (!params.symbol || params.symbol.trim().length === 0) { + throw new CCIPTokenDeployParamsInvalidError('symbol', 'must be non-empty') + } + if (params.maxSupply !== undefined && params.maxSupply < 0n) { + throw new CCIPTokenDeployParamsInvalidError('maxSupply', 'must be non-negative') + } + if (params.initialSupply !== undefined && params.initialSupply < 0n) { + throw new CCIPTokenDeployParamsInvalidError('initialSupply', 'must be non-negative') + } + if ( + params.maxSupply !== undefined && + params.maxSupply > 0n && + params.initialSupply !== undefined && + params.initialSupply > params.maxSupply + ) { + throw new CCIPTokenDeployParamsInvalidError('initialSupply', 'exceeds maxSupply') + } +} + +/** + * Checks that the `aptos` CLI is available. + * @throws {@link CCIPTokenDeployFailedError} if not installed + */ +function ensureAptosCli(): void { + try { + execSync('aptos --version', { stdio: 'ignore' }) + } catch { + throw new CCIPTokenDeployFailedError( + 'aptos CLI is not installed. Install from https://aptos.dev/tools/aptos-cli/', + ) + } +} + +/** + * Writes Move source files to a temp directory and compiles them + * with the object address as the named address. + * + * @param objectAddress - The deterministic object address where the module will be published + * @returns metadataBytes and byteCode extracted from the compiled JSON payload + */ +async function compilePackage( + objectAddress: string, + logger: Logger, +): Promise<{ metadataBytes: string; byteCode: string[] }> { + const { MOVE_TOML, ALLOWLIST_MOVE, OWNABLE_MOVE, MANAGED_TOKEN_MOVE } = + await import('./bytecodes/managed_token.ts') + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'managed-token-')) + const sourcesDir = path.join(tmpDir, 'sources') + fs.mkdirSync(sourcesDir, { recursive: true }) + + try { + // Write Move source files + fs.writeFileSync(path.join(tmpDir, 'Move.toml'), MOVE_TOML) + fs.writeFileSync(path.join(sourcesDir, 'allowlist.move'), ALLOWLIST_MOVE) + fs.writeFileSync(path.join(sourcesDir, 'ownable.move'), OWNABLE_MOVE) + fs.writeFileSync(path.join(sourcesDir, 'managed_token.move'), MANAGED_TOKEN_MOVE) + + const outputFile = path.join(tmpDir, 'compiled.json') + + const cmd = [ + 'aptos move build-publish-payload', + `--json-output-file ${outputFile}`, + `--package-dir ${tmpDir}`, + `--named-addresses managed_token=${objectAddress}`, + '--skip-fetch-latest-git-deps', + '--assume-yes', + ].join(' ') + + logger.debug('compilePackage: compiling ManagedToken Move package...') + execSync(cmd, { stdio: 'pipe' }) + + const compiled = JSON.parse(fs.readFileSync(outputFile, 'utf8')) as { + args: [{ value: string }, { value: string[] }] + } + const metadataBytes = compiled.args[0].value + const byteCode = compiled.args[1].value + + logger.debug('compilePackage: compiled', byteCode.length, 'modules') + return { metadataBytes, byteCode } + } finally { + // Clean up temp dir + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + +/** + * Validates deploy parameters for Aptos pool. + * @throws {@link CCIPPoolDeployParamsInvalidError} on invalid params + */ +function validatePoolParams(params: AptosDeployPoolParams): AptosTokenModule { + const poolType: string = params.poolType + if (poolType !== 'burn-mint' && poolType !== 'lock-release') { + throw new CCIPPoolDeployParamsInvalidError('poolType', "must be 'burn-mint' or 'lock-release'") + } + + const tokenModule: AptosTokenModule = params.tokenModule ?? 'managed' + const tokenModuleStr: string = tokenModule + if ( + tokenModuleStr !== 'managed' && + tokenModuleStr !== 'generic' && + tokenModuleStr !== 'regulated' + ) { + throw new CCIPPoolDeployParamsInvalidError( + 'tokenModule', + "must be 'managed', 'generic', or 'regulated'", + ) + } + + // managed and regulated only support burn-mint + if (tokenModule === 'managed' && poolType !== 'burn-mint') { + throw new CCIPPoolDeployParamsInvalidError( + 'poolType', + "managed tokens only support 'burn-mint' pools (managed_token_pool is inherently burn-mint)", + ) + } + if (tokenModule === 'regulated' && poolType !== 'burn-mint') { + throw new CCIPPoolDeployParamsInvalidError( + 'poolType', + "regulated tokens only support 'burn-mint' pools (regulated_token_pool is inherently burn-mint)", + ) + } + + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPPoolDeployParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPPoolDeployParamsInvalidError('routerAddress', 'must be non-empty') + } + if (!params.mcmsAddress || params.mcmsAddress.trim().length === 0) { + throw new CCIPPoolDeployParamsInvalidError('mcmsAddress', 'must be non-empty') + } + + // regulated requires adminAddress + if ( + tokenModule === 'regulated' && + (!params.adminAddress || params.adminAddress.trim().length === 0) + ) { + throw new CCIPPoolDeployParamsInvalidError( + 'adminAddress', + "must be non-empty when tokenModule is 'regulated'", + ) + } + + return tokenModule +} + +/** + * Writes the ChainlinkCCIP dependency sources to the given directory. + * All pool types transitively depend on this package. + */ +async function writeCcipDep(tmpDir: string): Promise { + const ccip = await import('./bytecodes/ccip.ts') + + const ccipDir = path.join(tmpDir, 'ccip') + const ccipSrc = path.join(ccipDir, 'sources') + const ccipUtilSrc = path.join(ccipSrc, 'util') + fs.mkdirSync(ccipUtilSrc, { recursive: true }) + + fs.writeFileSync(path.join(ccipDir, 'Move.toml'), ccip.CCIP_MOVE_TOML) + fs.writeFileSync(path.join(ccipSrc, 'allowlist.move'), ccip.CCIP_ALLOWLIST_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'auth.move'), ccip.CCIP_AUTH_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'client.move'), ccip.CCIP_CLIENT_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'eth_abi.move'), ccip.CCIP_ETH_ABI_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'fee_quoter.move'), ccip.CCIP_FEE_QUOTER_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'merkle_proof.move'), ccip.CCIP_MERKLE_PROOF_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'nonce_manager.move'), ccip.CCIP_NONCE_MANAGER_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'ownable.move'), ccip.CCIP_OWNABLE_MOVE) + fs.writeFileSync( + path.join(ccipSrc, 'receiver_dispatcher.move'), + ccip.CCIP_RECEIVER_DISPATCHER_MOVE, + ) + fs.writeFileSync(path.join(ccipSrc, 'receiver_registry.move'), ccip.CCIP_RECEIVER_REGISTRY_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'rmn_remote.move'), ccip.CCIP_RMN_REMOTE_MOVE) + fs.writeFileSync(path.join(ccipSrc, 'state_object.move'), ccip.CCIP_STATE_OBJECT_MOVE) + fs.writeFileSync( + path.join(ccipSrc, 'token_admin_dispatcher.move'), + ccip.CCIP_TOKEN_ADMIN_DISPATCHER_MOVE, + ) + fs.writeFileSync( + path.join(ccipSrc, 'token_admin_registry.move'), + ccip.CCIP_TOKEN_ADMIN_REGISTRY_MOVE, + ) + fs.writeFileSync(path.join(ccipUtilSrc, 'address.move'), ccip.CCIP_UTIL_ADDRESS_MOVE) +} + +/** + * Writes the ChainlinkManyChainMultisig (MCMS) dependency sources to the given directory. + * CCIP depends on this package, and all pool types transitively depend on CCIP. + */ +async function writeMcmsDep(tmpDir: string): Promise { + const mcms = await import('./bytecodes/mcms.ts') + + const mcmsDir = path.join(tmpDir, 'mcms') + const mcmsSrc = path.join(mcmsDir, 'sources') + const mcmsUtilsSrc = path.join(mcmsSrc, 'utils') + fs.mkdirSync(mcmsUtilsSrc, { recursive: true }) + + fs.writeFileSync(path.join(mcmsDir, 'Move.toml'), mcms.MCMS_MOVE_TOML) + fs.writeFileSync(path.join(mcmsSrc, 'mcms.move'), mcms.MCMS_MCMS_MOVE) + fs.writeFileSync(path.join(mcmsSrc, 'mcms_registry.move'), mcms.MCMS_MCMS_REGISTRY_MOVE) + fs.writeFileSync(path.join(mcmsSrc, 'mcms_executor.move'), mcms.MCMS_MCMS_EXECUTOR_MOVE) + fs.writeFileSync(path.join(mcmsSrc, 'mcms_deployer.move'), mcms.MCMS_MCMS_DEPLOYER_MOVE) + fs.writeFileSync(path.join(mcmsSrc, 'mcms_account.move'), mcms.MCMS_MCMS_ACCOUNT_MOVE) + fs.writeFileSync(path.join(mcmsUtilsSrc, 'bcs_stream.move'), mcms.MCMS_UTILS_BCS_STREAM_MOVE) + fs.writeFileSync(path.join(mcmsUtilsSrc, 'params.move'), mcms.MCMS_UTILS_PARAMS_MOVE) +} + +/** + * Writes the token_pool shared dependency to the given directory. + * All pool types depend on this package. + */ +async function writeTokenPoolDep(tmpDir: string): Promise { + const { + TOKEN_POOL_MOVE_TOML, + TOKEN_POOL_MOVE, + TOKEN_POOL_OWNABLE_MOVE, + RATE_LIMITER_MOVE, + TOKEN_POOL_RATE_LIMITER_MOVE, + } = await import('./bytecodes/managed_token_pool.ts') + + const tokenPoolDir = path.join(tmpDir, 'token_pool') + const tokenPoolSourcesDir = path.join(tokenPoolDir, 'sources') + fs.mkdirSync(tokenPoolSourcesDir, { recursive: true }) + + fs.writeFileSync(path.join(tokenPoolDir, 'Move.toml'), TOKEN_POOL_MOVE_TOML) + fs.writeFileSync(path.join(tokenPoolSourcesDir, 'token_pool.move'), TOKEN_POOL_MOVE) + fs.writeFileSync(path.join(tokenPoolSourcesDir, 'ownable.move'), TOKEN_POOL_OWNABLE_MOVE) + fs.writeFileSync(path.join(tokenPoolSourcesDir, 'rate_limiter.move'), RATE_LIMITER_MOVE) + fs.writeFileSync( + path.join(tokenPoolSourcesDir, 'token_pool_rate_limiter.move'), + TOKEN_POOL_RATE_LIMITER_MOVE, + ) +} + +/** + * Writes Move source files for the specified pool type to the temp directory. + * + * @returns The path to the pool package directory (to pass to `aptos move build-publish-payload`) + */ +async function writePoolSources( + tmpDir: string, + tokenModule: AptosTokenModule, + poolType: string, +): Promise { + if (tokenModule === 'managed') { + const { POOL_MOVE_TOML, MANAGED_TOKEN_POOL_MOVE } = + await import('./bytecodes/managed_token_pool.ts') + const { MOVE_TOML, ALLOWLIST_MOVE, OWNABLE_MOVE, MANAGED_TOKEN_MOVE } = + await import('./bytecodes/managed_token.ts') + + // managed_token_pool package + const poolDir = path.join(tmpDir, 'managed_token_pool') + const poolSrc = path.join(poolDir, 'sources') + fs.mkdirSync(poolSrc, { recursive: true }) + fs.writeFileSync(path.join(poolDir, 'Move.toml'), POOL_MOVE_TOML) + fs.writeFileSync(path.join(poolSrc, 'managed_token_pool.move'), MANAGED_TOKEN_POOL_MOVE) + + // managed_token dependency + const mtDir = path.join(tmpDir, 'managed_token') + const mtSrc = path.join(mtDir, 'sources') + fs.mkdirSync(mtSrc, { recursive: true }) + fs.writeFileSync(path.join(mtDir, 'Move.toml'), MOVE_TOML) + fs.writeFileSync(path.join(mtSrc, 'allowlist.move'), ALLOWLIST_MOVE) + fs.writeFileSync(path.join(mtSrc, 'ownable.move'), OWNABLE_MOVE) + fs.writeFileSync(path.join(mtSrc, 'managed_token.move'), MANAGED_TOKEN_MOVE) + + return poolDir + } + + if (tokenModule === 'generic') { + if (poolType === 'burn-mint') { + const { BURN_MINT_POOL_MOVE_TOML, BURN_MINT_TOKEN_POOL_MOVE } = + await import('./bytecodes/burn_mint_token_pool.ts') + + const poolDir = path.join(tmpDir, 'burn_mint_token_pool') + const poolSrc = path.join(poolDir, 'sources') + fs.mkdirSync(poolSrc, { recursive: true }) + fs.writeFileSync(path.join(poolDir, 'Move.toml'), BURN_MINT_POOL_MOVE_TOML) + fs.writeFileSync(path.join(poolSrc, 'burn_mint_token_pool.move'), BURN_MINT_TOKEN_POOL_MOVE) + + return poolDir + } + + // lock-release + const { LOCK_RELEASE_POOL_MOVE_TOML, LOCK_RELEASE_TOKEN_POOL_MOVE } = + await import('./bytecodes/lock_release_token_pool.ts') + + const poolDir = path.join(tmpDir, 'lock_release_token_pool') + const poolSrc = path.join(poolDir, 'sources') + fs.mkdirSync(poolSrc, { recursive: true }) + fs.writeFileSync(path.join(poolDir, 'Move.toml'), LOCK_RELEASE_POOL_MOVE_TOML) + fs.writeFileSync( + path.join(poolSrc, 'lock_release_token_pool.move'), + LOCK_RELEASE_TOKEN_POOL_MOVE, + ) + + return poolDir + } + + // regulated + const { REGULATED_POOL_MOVE_TOML, REGULATED_TOKEN_POOL_MOVE } = + await import('./bytecodes/regulated_token_pool.ts') + const { + REGULATED_TOKEN_MOVE_TOML, + REGULATED_TOKEN_MOVE, + REGULATED_ACCESS_CONTROL_MOVE, + REGULATED_OWNABLE_MOVE, + } = await import('./bytecodes/regulated_token_pool.ts') + + // regulated_token_pool package + const poolDir = path.join(tmpDir, 'regulated_token_pool') + const poolSrc = path.join(poolDir, 'sources') + fs.mkdirSync(poolSrc, { recursive: true }) + fs.writeFileSync(path.join(poolDir, 'Move.toml'), REGULATED_POOL_MOVE_TOML) + fs.writeFileSync(path.join(poolSrc, 'regulated_token_pool.move'), REGULATED_TOKEN_POOL_MOVE) + + // regulated_token dependency + const rtDir = path.join(tmpDir, 'regulated_token') + const rtSrc = path.join(rtDir, 'sources') + fs.mkdirSync(rtSrc, { recursive: true }) + fs.writeFileSync(path.join(rtDir, 'Move.toml'), REGULATED_TOKEN_MOVE_TOML) + fs.writeFileSync(path.join(rtSrc, 'regulated_token.move'), REGULATED_TOKEN_MOVE) + fs.writeFileSync(path.join(rtSrc, 'access_control.move'), REGULATED_ACCESS_CONTROL_MOVE) + fs.writeFileSync(path.join(rtSrc, 'ownable.move'), REGULATED_OWNABLE_MOVE) + + return poolDir +} + +/** + * Resolves the named addresses for Move compilation based on the pool type. + * + * For managed/regulated pools, `tokenCodeObjectAddress` is the code object resolved + * from the FA metadata via on-chain ownership traversal. For generic pools it is unused + * — `params.tokenAddress` (the FA metadata) is passed directly as the local token address. + */ +/** + * Resolves the named addresses for Move compilation. + * + * CCIPTokenPool is published to `tokenPoolObjectAddress` (separate object). + * The pool itself is published to `poolObjectAddress`. + * + * For managed/regulated pools, `tokenCodeObjectAddress` is the code object resolved + * from the FA metadata via on-chain ownership traversal. For generic pools it is unused + * — `params.tokenAddress` (the FA metadata) is passed directly as the local token address. + */ +function resolveNamedAddresses( + tokenPoolObjectAddress: string, + poolObjectAddress: string, + tokenModule: AptosTokenModule, + poolType: string, + params: AptosDeployPoolParams, + tokenCodeObjectAddress?: string, +): Record { + const base: Record = { + ccip: params.routerAddress, + ccip_token_pool: tokenPoolObjectAddress, + mcms: params.mcmsAddress, + // mcms_owner is the account that created the MCMS resource account. + // Set to 0x0 — only needed at MCMS package init time, not for pool deploys. + mcms_owner: '0x0', + // mcms_register_entrypoints is a compile-time feature flag (0x0 = disabled, 0x1 = enabled). + // When enabled, init_module registers MCMS entrypoints for multisig control. + // MCMS is internal Chainlink infrastructure — external users always disable it. + mcms_register_entrypoints: '0x0', + } + + if (tokenModule === 'managed') { + return { + ...base, + managed_token_pool: poolObjectAddress, + managed_token: tokenCodeObjectAddress!, + } + } + + if (tokenModule === 'generic') { + if (poolType === 'burn-mint') { + return { + ...base, + burn_mint_token_pool: poolObjectAddress, + burn_mint_local_token: params.tokenAddress, + } + } + // lock-release + return { + ...base, + lock_release_token_pool: poolObjectAddress, + lock_release_local_token: params.tokenAddress, + } + } + + // regulated + return { + ...base, + regulated_token_pool: poolObjectAddress, + regulated_token: tokenCodeObjectAddress!, + admin: params.adminAddress!, + } +} + +/** Human-readable pool type label for log messages. */ +function poolLabel(tokenModule: AptosTokenModule, poolType: string): string { + if (tokenModule === 'managed') return 'ManagedTokenPool' + if (tokenModule === 'regulated') return 'RegulatedTokenPool' + return poolType === 'burn-mint' ? 'BurnMintTokenPool' : 'LockReleaseTokenPool' +} + +/** + * Compiles a Move package and returns the metadata + bytecode from the publish payload. + * + * Uses `aptos move build-publish-payload` with `--skip-fetch-latest-git-deps` + * to ensure compiled bytecode matches what's deployed on-chain. + * + * @param packageDir - Path to the package to compile + * @param namedAddresses - All named addresses for compilation + * @param label - Human-readable label for log messages + * @param logger - Logger instance + * @returns metadataBytes and byteCode from the compiled payload + */ +function compileMovePackage( + packageDir: string, + namedAddresses: Record, + label: string, + logger: Logger, +): { metadataBytes: string; byteCode: string[] } { + const outputFile = path.join(path.dirname(packageDir), `${label}-compiled.json`) + + const namedAddressesStr = Object.entries(namedAddresses) + .map(([k, v]) => `${k}=${v}`) + .join(',') + + const cmd = [ + 'aptos move build-publish-payload', + `--json-output-file ${outputFile}`, + `--package-dir ${packageDir}`, + `--named-addresses ${namedAddressesStr}`, + '--skip-fetch-latest-git-deps', + '--assume-yes', + ].join(' ') + + logger.debug(`compileMovePackage: compiling ${label}...`) + logger.debug(`compileMovePackage: cmd = ${cmd}`) + const result = execSync(cmd, { stdio: 'pipe' }) + const output = result.toString().trim() + // aptos CLI may exit 0 but return an error in JSON — check for it + if (output.includes('"Error"')) { + throw new CCIPPoolDeployFailedError(`Move compilation failed for ${label}:\n${output}`) + } + + const compiled = JSON.parse(fs.readFileSync(outputFile, 'utf8')) as { + args: [{ value: string }, { value: string[] }] + } + + logger.debug(`compileMovePackage: ${label} compiled`, compiled.args[1].value.length, 'modules') + return { metadataBytes: compiled.args[0].value, byteCode: compiled.args[1].value } +} + +/** + * Writes all shared dependencies and compiles both the CCIPTokenPool package and the + * pool-specific package. Returns publish payloads for both. + * + * Aptos Move `build-publish-payload` only includes modules from the TOP-LEVEL package + * in the output — local dependency modules are NOT included. Since CCIPTokenPool is a + * local dependency of every pool type, it must be compiled and published as a SEPARATE + * object before the pool itself. + * + * Deploy flow (2 publish transactions): + * 1. Publish CCIPTokenPool (4 modules: token_pool, ownable, rate_limiter, token_pool_rate_limiter) + * 2. Publish the pool (1 module), referencing the CCIPTokenPool object from step 1 + * + * @returns Two compiled payloads: tokenPool and pool + */ +async function compilePoolPackages( + tokenPoolObjectAddress: string, + poolObjectAddress: string, + tokenModule: AptosTokenModule, + poolType: string, + namedAddresses: Record, + logger: Logger, +): Promise<{ + tokenPool: { metadataBytes: string; byteCode: string[] } + pool: { metadataBytes: string; byteCode: string[] } +}> { + const label = poolLabel(tokenModule, poolType) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `aptos-pool-${tokenModule}-`)) + + try { + // Write all transitive dependencies as local packages. + // Order: mcms (leaf) → ccip (depends on mcms) → token_pool (depends on ccip) + await writeMcmsDep(tmpDir) + await writeCcipDep(tmpDir) + await writeTokenPoolDep(tmpDir) + + // Write pool-specific sources and get the pool package directory + const poolDir = await writePoolSources(tmpDir, tokenModule, poolType) + + // Step 1: Compile CCIPTokenPool (4 modules) + const tokenPoolDir = path.join(tmpDir, 'token_pool') + const tokenPool = compileMovePackage(tokenPoolDir, namedAddresses, 'CCIPTokenPool', logger) + + // Step 2: Compile pool package (1 module) — references the already-compiled token_pool + const pool = compileMovePackage(poolDir, namedAddresses, label, logger) + + return { tokenPool, pool } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + +/** + * Aptos token admin for deploying CCIP-compatible ManagedToken FA modules. + * + * Extends {@link AptosChain} — inherits provider, logger, and chain discovery + * methods like `getTokenAdminRegistryFor`. + * + * **Node.js/CLI only** — Move compilation requires the `aptos` CLI and filesystem access. + * + * @example Direct construction + * ```typescript + * const admin = new AptosTokenAdmin(provider, network, { logger }) + * ``` + */ +export class AptosTokenAdmin extends AptosChain { + /** Creates a new AptosTokenAdmin instance. */ + constructor(provider: Aptos, network: NetworkInfo, ctx?: ChainContext) { + super(provider, network, ctx) + } + + /** + * Builds unsigned transactions for deploying a ManagedToken FA module. + * + * **Requires `aptos` CLI** — compiles the Move source with the sender's address. + * + * The returned transactions must be signed and submitted **sequentially**: + * 1. Publish ManagedToken package + * 2. Initialize token (name, symbol, decimals, etc.) + * 3. Mint initial supply (only if initialSupply \> 0) + * + * @param sender - Deployer's account address (hex string) + * @param params - Token deployment parameters + * @returns Unsigned transactions, code object address, and FA token address + * @throws {@link CCIPTokenDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenDeployFailedError} if compilation fails + * + * @example + * ```typescript + * const txs = await admin.generateUnsignedDeployToken( + * account.accountAddress.toString(), + * { name: 'My Token', symbol: 'MTK', decimals: 8 }, + * ) + * ``` + */ + async generateUnsignedDeployToken( + sender: string, + params: AptosDeployTokenParams, + ): Promise<{ transactions: UnsignedAptosTx[]; codeObjectAddress: string; tokenAddress: string }> { + validateParams(params) + ensureAptosCli() + + // Step 1: Compute the deterministic object address from sender + sequence number + const { objectAddress, sequenceNumber } = await computeObjectAddress(this.provider, sender) + let nextSeq = sequenceNumber + + this.logger.debug('generateUnsignedDeployToken: object address =', objectAddress) + + // Step 2: Compile Move package with the object address as named address + const { metadataBytes, byteCode } = await compilePackage(objectAddress, this.logger) + + // Step 3: Build publish transaction via object_code_deployment::publish + const publishPayload = generateTransactionPayloadWithABI({ + function: '0x1::object_code_deployment::publish' as `${string}::${string}::${string}`, + functionArguments: [ + Buffer.from(metadataBytes.replace(/^0x/, ''), 'hex'), + byteCode.map((b) => Buffer.from(b.replace(/^0x/, ''), 'hex')), + ], + abi: { + typeParameters: [], + parameters: [parseTypeTag('vector'), parseTypeTag('vector>')], + }, + }) + const publishTx = await buildTransaction({ + aptosConfig: this.provider.config, + sender, + payload: publishPayload, + options: { accountSequenceNumber: nextSeq++ }, + }) + + const transactions: UnsignedAptosTx[] = [ + { family: ChainFamily.Aptos, transactions: [publishTx.bcsToBytes()] }, + ] + + // Step 4: Build initialize transaction using local ABI (module not yet on-chain) + // Entry function lives at the object address, not sender + // initialize(max_supply: Option, name, symbol, decimals, icon, project) + // The Aptos SDK auto-converts null → MoveOption(None), bigint → MoveOption(Some(v)) + const maxSupply = + params.maxSupply !== undefined && params.maxSupply > 0n ? params.maxSupply : null + + const initPayload = generateTransactionPayloadWithABI({ + function: `${objectAddress}::managed_token::initialize` as `${string}::${string}::${string}`, + functionArguments: [ + maxSupply, + params.name, + params.symbol, + params.decimals, + params.icon ?? '', + params.project ?? '', + ], + abi: { + typeParameters: [], + parameters: [ + parseTypeTag('0x1::option::Option'), + parseTypeTag('0x1::string::String'), + parseTypeTag('0x1::string::String'), + parseTypeTag('u8'), + parseTypeTag('0x1::string::String'), + parseTypeTag('0x1::string::String'), + ], + }, + }) + const initTx = await buildTransaction({ + aptosConfig: this.provider.config, + sender, + payload: initPayload, + options: { accountSequenceNumber: nextSeq++ }, + }) + transactions.push({ family: ChainFamily.Aptos, transactions: [initTx.bcsToBytes()] }) + + // Step 5: Build mint transaction (if initialSupply > 0) + const initialSupply = params.initialSupply ?? 0n + if (initialSupply > 0n) { + const recipient = params.recipient ?? sender + const mintPayload = generateTransactionPayloadWithABI({ + function: `${objectAddress}::managed_token::mint` as `${string}::${string}::${string}`, + functionArguments: [recipient, initialSupply.toString()], + abi: { + typeParameters: [], + parameters: [parseTypeTag('address'), parseTypeTag('u64')], + }, + }) + const mintTx = await buildTransaction({ + aptosConfig: this.provider.config, + sender, + payload: mintPayload, + options: { accountSequenceNumber: nextSeq }, + }) + transactions.push({ family: ChainFamily.Aptos, transactions: [mintTx.bcsToBytes()] }) + } + + const faAddress = deriveFungibleAssetAddress(objectAddress, params.symbol) + + this.logger.debug( + 'generateUnsignedDeployToken: sender =', + sender, + 'object =', + objectAddress, + 'FA =', + faAddress, + 'transactions =', + transactions.length, + ) + + return { transactions, codeObjectAddress: objectAddress, tokenAddress: faAddress } + } + + /** + * Builds unsigned publish transactions for an Aptos CCIP token pool. + * + * **Requires `aptos` CLI** — compiles the Move source at deploy time. + * + * Produces **2 sequential transactions**: + * 1. Publish CCIPTokenPool (4 modules: token_pool, ownable, rate_limiter, token_pool_rate_limiter) + * 2. Publish the pool (1 module) — `init_module` runs automatically and + * registers the pool, creates state, and sets up callbacks + * + * CCIPTokenPool must be a separate object because `build-publish-payload` only + * includes the top-level package's modules — local dependency modules are expected + * to already exist on-chain at their named address. + * + * The `tokenModule` param (default: `'managed'`) selects which pool to deploy: + * - `'managed'` → `managed_token_pool` (for tokens from `deployToken()`) + * - `'generic'` → `burn_mint_token_pool` or `lock_release_token_pool` + * - `'regulated'` → `regulated_token_pool` + * + * @param sender - Deployer's account address (hex string) + * @param params - Pool deployment parameters + * @returns Unsigned publish transactions and pool object address + * @throws {@link CCIPPoolDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPPoolDeployFailedError} if compilation fails + */ + async generateUnsignedDeployPool( + sender: string, + params: AptosDeployPoolParams, + ): Promise<{ transactions: UnsignedAptosTx[]; poolAddress: string }> { + const tokenModule = validatePoolParams(params) + ensureAptosCli() + + // We need 2 sequential object addresses: + // seq+1 → CCIPTokenPool object + // seq+2 → pool object + const { sequence_number } = await this.provider.getAccountInfo({ + accountAddress: sender, + }) + const sequenceNumber = BigInt(sequence_number) + + const domainBytes = Buffer.from(OBJECT_CODE_DEPLOYMENT_DOMAIN, 'utf8') + const uleb = Buffer.from([domainBytes.length]) + + // Object address for CCIPTokenPool (seq + 1) + const seqBuf1 = Buffer.alloc(8) + seqBuf1.writeBigUInt64LE(sequenceNumber + 1n) + const tokenPoolObjectAddress = createObjectAddress( + AccountAddress.from(sender), + new Uint8Array(Buffer.concat([uleb, domainBytes, seqBuf1])), + ).toString() + + // Object address for the pool (seq + 2) + const seqBuf2 = Buffer.alloc(8) + seqBuf2.writeBigUInt64LE(sequenceNumber + 2n) + const poolObjectAddress = createObjectAddress( + AccountAddress.from(sender), + new Uint8Array(Buffer.concat([uleb, domainBytes, seqBuf2])), + ).toString() + + const label = poolLabel(tokenModule, params.poolType) + + this.logger.debug( + `generateUnsignedDeployPool: ${label} tokenPool =`, + tokenPoolObjectAddress, + 'pool =', + poolObjectAddress, + ) + + // For managed/regulated pools, resolve the code object address from the FA metadata + // by walking the Aptos object ownership chain on-chain. + // Generic pools use tokenAddress (FA metadata) directly as a named address. + let tokenCodeObjectAddress: string | undefined + if (tokenModule === 'managed' || tokenModule === 'regulated') { + tokenCodeObjectAddress = await resolveCodeObjectAddress(this.provider, params.tokenAddress) + this.logger.debug( + `generateUnsignedDeployPool: resolved code object =`, + tokenCodeObjectAddress, + 'from FA metadata =', + params.tokenAddress, + ) + } + + const namedAddresses = resolveNamedAddresses( + tokenPoolObjectAddress, + poolObjectAddress, + tokenModule, + params.poolType, + params, + tokenCodeObjectAddress, + ) + + const { tokenPool, pool } = await compilePoolPackages( + tokenPoolObjectAddress, + poolObjectAddress, + tokenModule, + params.poolType, + namedAddresses, + this.logger, + ) + + // Build 2 publish transactions (sequential: token_pool first, then pool) + const buildPublishTx = async ( + compiled: { metadataBytes: string; byteCode: string[] }, + seq: bigint, + ) => { + const payload = generateTransactionPayloadWithABI({ + function: '0x1::object_code_deployment::publish' as `${string}::${string}::${string}`, + functionArguments: [ + Buffer.from(compiled.metadataBytes.replace(/^0x/, ''), 'hex'), + compiled.byteCode.map((b) => Buffer.from(b.replace(/^0x/, ''), 'hex')), + ], + abi: { + typeParameters: [], + parameters: [parseTypeTag('vector'), parseTypeTag('vector>')], + }, + }) + return buildTransaction({ + aptosConfig: this.provider.config, + sender, + payload, + options: { accountSequenceNumber: seq }, + }) + } + + const tokenPoolTx = await buildPublishTx(tokenPool, sequenceNumber) + const poolTx = await buildPublishTx(pool, sequenceNumber + 1n) + + this.logger.debug( + `generateUnsignedDeployPool: ${label} sender =`, + sender, + 'tokenPool =', + tokenPoolObjectAddress, + 'pool =', + poolObjectAddress, + 'transactions = 2', + ) + + return { + transactions: [ + { family: ChainFamily.Aptos, transactions: [tokenPoolTx.bcsToBytes()] }, + { family: ChainFamily.Aptos, transactions: [poolTx.bcsToBytes()] }, + ], + poolAddress: poolObjectAddress, + } + } + + /** + * Deploys an Aptos CCIP token pool, signing and submitting with the provided wallet. + * + * **Requires `aptos` CLI** — compiles the Move source at deploy time. + * + * @param wallet - Aptos account with signing capability + * @param params - Pool deployment parameters (see {@link AptosDeployPoolParams}) + * @returns Deploy result with `poolAddress` and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPPoolDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPPoolDeployFailedError} if the deploy transaction fails + */ + async deployPool(wallet: unknown, params: AptosDeployPoolParams): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs, poolAddress } = await this.generateUnsignedDeployPool( + sender, + params, + ) + + const tokenModule = params.tokenModule ?? 'managed' + const label = poolLabel(tokenModule, params.poolType) + this.logger.debug(`deployPool: deploying ${label}...`, unsignedTxs.length, 'transactions') + + let lastTxHash = '' + + try { + for (let i = 0; i < unsignedTxs.length; i++) { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[i]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + lastTxHash = hash + this.logger.debug(`deployPool: tx ${i + 1}/${unsignedTxs.length} confirmed:`, hash) + } + + const initialized = tokenModule !== 'generic' + + if (!initialized) { + const poolModule = + params.poolType === 'burn-mint' ? 'burn_mint_token_pool' : 'lock_release_token_pool' + this.logger.warn( + `deployPool: Generic pool deployed but NOT initialized. ` + + `The token creator module must call ${poolModule}::initialize() ` + + `with the stored capability refs (BurnRef/MintRef/TransferRef) ` + + `before the pool can be used for CCIP operations.`, + ) + } + + this.logger.info('deployPool: pool at', poolAddress, 'tx =', lastTxHash) + + return { poolAddress, txHash: lastTxHash, initialized } + } catch (error) { + if (error instanceof CCIPPoolDeployFailedError) throw error + throw new CCIPPoolDeployFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Token Admin Registry Discovery ───────────────────────────────────── + + // getTokenAdminRegistryFor is inherited from AptosChain. + + // ── Propose Admin Role ──────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for proposing an administrator in the + * TokenAdminRegistry on Aptos. + * + * On Aptos, the TokenAdminRegistry is a module within the CCIP router package + * (`routerAddress::token_admin_registry::propose_administrator`). + * + * @param sender - Aptos account address (hex string) + * @param params - Propose admin role parameters + * @returns Unsigned Aptos transactions (single tx) + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} if params are invalid + */ + async generateUnsignedProposeAdminRole( + sender: string, + params: AptosProposeAdminRoleParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.administrator || params.administrator.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('administrator', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } + + const payload = generateTransactionPayloadWithABI({ + function: + `${params.routerAddress}::token_admin_registry::propose_administrator` as `${string}::${string}::${string}`, + functionArguments: [params.tokenAddress, params.administrator], + abi: { + typeParameters: [], + parameters: [parseTypeTag('address'), parseTypeTag('address')], + }, + }) + const tx = await buildTransaction({ + aptosConfig: this.provider.config, + sender, + payload, + }) + + this.logger.debug( + 'generateUnsignedProposeAdminRole: router =', + params.routerAddress, + 'token =', + params.tokenAddress, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [tx.bcsToBytes()], + }, + ], + } + } + + /** + * Proposes an administrator for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability + * @param params - Propose admin role parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} if params are invalid + * @throws {@link CCIPProposeAdminRoleFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.proposeAdminRole(wallet, { + * tokenAddress: '0x89fd6b...', + * administrator: '0x1234...', + * routerAddress: '0xabc...', + * }) + * console.log(`Proposed admin, tx: ${txHash}`) + * ``` + */ + async proposeAdminRole( + wallet: unknown, + params: AptosProposeAdminRoleParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedProposeAdminRole( + sender, + params, + ) + + this.logger.debug('proposeAdminRole: proposing administrator...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('proposeAdminRole: proposed admin, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPProposeAdminRoleFailedError) throw error + if (error instanceof CCIPProposeAdminRoleParamsInvalidError) throw error + throw new CCIPProposeAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Accept Admin Role ───────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for accepting an administrator role in the + * TokenAdminRegistry on Aptos. + * + * @param sender - Aptos account address (hex string) of the pending administrator + * @param params - Accept admin role parameters + * @returns Unsigned Aptos transactions (single tx) + * @throws {@link CCIPAcceptAdminRoleParamsInvalidError} if params are invalid + */ + async generateUnsignedAcceptAdminRole( + sender: string, + params: AcceptAdminRoleParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPAcceptAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPAcceptAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } + + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${params.routerAddress}::token_admin_registry::accept_admin_role` as `${string}::${string}::${string}`, + functionArguments: [params.tokenAddress], + }, + }) + + this.logger.debug( + 'generateUnsignedAcceptAdminRole: router =', + params.routerAddress, + 'token =', + params.tokenAddress, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [tx.bcsToBytes()], + }, + ], + } + } + + /** + * Accepts an administrator role for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be the pending administrator) + * @param params - Accept admin role parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPAcceptAdminRoleParamsInvalidError} if params are invalid + * @throws {@link CCIPAcceptAdminRoleFailedError} if the transaction fails + */ + async acceptAdminRole( + wallet: unknown, + params: AcceptAdminRoleParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedAcceptAdminRole(sender, params) + + this.logger.debug('acceptAdminRole: accepting administrator role...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('acceptAdminRole: accepted admin, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPAcceptAdminRoleFailedError) throw error + if (error instanceof CCIPAcceptAdminRoleParamsInvalidError) throw error + throw new CCIPAcceptAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Transfer Admin Role ───────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for transferring the administrator role + * in the TokenAdminRegistry on Aptos. + * + * Calls `${routerAddress}::token_admin_registry::transfer_admin_role`. + * Pass `@0x0` as newAdmin to cancel a pending transfer. + * + * @param sender - Aptos account address (hex string) of the current administrator + * @param params - Transfer admin role parameters + * @returns Unsigned Aptos transactions (single tx) + * @throws {@link CCIPTransferAdminRoleParamsInvalidError} if params are invalid + */ + async generateUnsignedTransferAdminRole( + sender: string, + params: TransferAdminRoleParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.newAdmin || params.newAdmin.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('newAdmin', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } + + const payload = generateTransactionPayloadWithABI({ + function: + `${params.routerAddress}::token_admin_registry::transfer_admin_role` as `${string}::${string}::${string}`, + functionArguments: [params.tokenAddress, params.newAdmin], + abi: { + typeParameters: [], + parameters: [parseTypeTag('address'), parseTypeTag('address')], + }, + }) + const tx = await buildTransaction({ + aptosConfig: this.provider.config, + sender, + payload, + }) + + this.logger.debug( + 'generateUnsignedTransferAdminRole: router =', + params.routerAddress, + 'token =', + params.tokenAddress, + 'newAdmin =', + params.newAdmin, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [tx.bcsToBytes()], + }, + ], + } + } + + /** + * Transfers the administrator role for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be the current administrator) + * @param params - Transfer admin role parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPTransferAdminRoleParamsInvalidError} if params are invalid + * @throws {@link CCIPTransferAdminRoleFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.transferAdminRole(wallet, { + * tokenAddress: '0x89fd6b...', + * newAdmin: '0x1234...', + * routerAddress: '0xabc...', + * }) + * console.log(`Transferred admin, tx: ${txHash}`) + * ``` + */ + async transferAdminRole( + wallet: unknown, + params: TransferAdminRoleParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedTransferAdminRole( + sender, + params, + ) + + this.logger.debug('transferAdminRole: transferring administrator role...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('transferAdminRole: transferred admin, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPTransferAdminRoleFailedError) throw error + if (error instanceof CCIPTransferAdminRoleParamsInvalidError) throw error + throw new CCIPTransferAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Set Pool ───────────────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for registering a pool in the TokenAdminRegistry + * on Aptos. + * + * @param sender - Aptos account address (hex string) of the token administrator + * @param params - Set pool parameters + * @returns Unsigned Aptos transactions (single tx) + * @throws {@link CCIPSetPoolParamsInvalidError} if params are invalid + */ + async generateUnsignedSetPool( + sender: string, + params: SetPoolParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('routerAddress', 'must be non-empty') + } + + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${params.routerAddress}::token_admin_registry::set_pool` as `${string}::${string}::${string}`, + functionArguments: [params.tokenAddress, params.poolAddress], + }, + }) + + this.logger.debug( + 'generateUnsignedSetPool: router =', + params.routerAddress, + 'token =', + params.tokenAddress, + 'pool =', + params.poolAddress, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [tx.bcsToBytes()], + }, + ], + } + } + + /** + * Registers a pool in the TokenAdminRegistry, signing and submitting + * with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be the token administrator) + * @param params - Set pool parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPSetPoolParamsInvalidError} if params are invalid + * @throws {@link CCIPSetPoolFailedError} if the transaction fails + */ + async setPool(wallet: unknown, params: SetPoolParams): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedSetPool(sender, params) + + this.logger.debug('setPool: registering pool...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('setPool: pool registered, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPSetPoolFailedError) throw error + if (error instanceof CCIPSetPoolParamsInvalidError) throw error + throw new CCIPSetPoolFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Apply Chain Updates ────────────────────────────────────────────────── + + /** + * Auto-discovers the pool module name from a pool address by querying + * account modules and filtering for `*token_pool`. + * + * @param poolAddress - Pool object address (hex string) + * @returns The pool module name (e.g., 'managed_token_pool') + * @throws {@link CCIPTokenPoolInfoNotFoundError} if no pool module found + */ + private async discoverPoolModule(poolAddress: string): Promise { + const modulesNames = (await this._getAccountModulesNames(poolAddress)) + .reverse() + .filter((name) => name.endsWith('token_pool')) + + if (modulesNames.length === 0) { + throw new CCIPTokenPoolInfoNotFoundError(poolAddress) + } + + // Try each module until one responds to get_token view + for (const name of modulesNames) { + try { + await this.provider.view<[string]>({ + payload: { + function: `${poolAddress}::${name}::get_token`, + }, + }) + return name + } catch { + continue + } + } + + // If none respond, use the first one (best effort) + return modulesNames[0]! + } + + /** + * Checks whether an Aptos pool is initialized by attempting to call `get_token`. + * + * Generic pools (`burn_mint_token_pool`, `lock_release_token_pool`) have a + * two-phase lifecycle: `init_module()` creates a `*Deployment` struct, but the + * pool is not usable until `initialize()` creates the `*State` with ownership + * and pool functionality. The `get_token` view function only succeeds when + * the `*State` resource exists. + * + * Managed and regulated pools initialize fully in `init_module()`, so this + * always returns `true` for them. + * + * @param poolAddress - Pool object address (hex string) + * @param poolModule - Pool module name (from `discoverPoolModule`) + * @returns `true` if pool state is initialized, `false` otherwise + */ + private async isPoolInitialized(poolAddress: string, poolModule: string): Promise { + try { + await this.provider.view<[string]>({ + payload: { + function: `${poolAddress}::${poolModule}::get_token`, + }, + }) + return true + } catch { + return false + } + } + + /** + * Guards pool operations by checking initialization status. + * + * Throws {@link CCIPPoolNotInitializedError} if the pool is not initialized, + * providing a clear error message instead of a cryptic `MutBorrowGlobal` failure. + * + * @param poolAddress - Pool object address (hex string) + * @param poolModule - Pool module name + * @throws {@link CCIPPoolNotInitializedError} if pool is not initialized + */ + private async ensurePoolInitialized(poolAddress: string, poolModule: string): Promise { + const initialized = await this.isPoolInitialized(poolAddress, poolModule) + if (!initialized) { + throw new CCIPPoolNotInitializedError(poolAddress) + } + } + + /** + * Builds an unsigned transaction for configuring remote chains on a token pool. + * + * Auto-discovers the pool module name from the pool address. + * Calls `apply_chain_updates` on the pool module. + * + * Pool addresses are passed as raw bytes (not padded). + * Token addresses are 32-byte left-padded. + * + * @param sender - Aptos account address (hex string) of the pool owner + * @param params - Apply chain updates parameters + * @returns Unsigned Aptos transactions + * @throws {@link CCIPApplyChainUpdatesParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool module not found + * @throws {@link CCIPPoolNotInitializedError} if pool is not initialized (generic pools) + */ + async generateUnsignedApplyChainUpdates( + sender: string, + params: ApplyChainUpdatesParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + validateApplyChainUpdatesParams(params) + + // Auto-discover pool module name + const poolModule = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, poolModule) + + // Encode arguments + const remoteChainSelectorsToRemove = params.remoteChainSelectorsToRemove + const remoteChainSelectorsToAdd = params.chainsToAdd.map((c) => c.remoteChainSelector) + + // Pool addresses: raw bytes (not padded) — matches chainlink-deployments + // vector>>: one entry per chain, each has a list of pool address byte arrays + const remotePoolAddressesToAdd = params.chainsToAdd.map((chain) => + chain.remotePoolAddresses.map((addr) => Array.from(getAddressBytes(addr))), + ) + + // Token addresses: 32-byte left-padded — matches chainlink-deployments + // vector>: one entry per chain, each is a 32-byte byte array + const remoteTokenAddressesToAdd = params.chainsToAdd.map((chain) => { + const bytes = getAddressBytes(chain.remoteTokenAddress) + const padded = zeroPadValue(hexlify(bytes), 32) + return Array.from(Buffer.from(padded.slice(2), 'hex')) + }) + + const senderAddr = AccountAddress.from(sender) + + // Fetch current sequence number so multi-tx batches get consecutive nonces + const { sequence_number } = await this.provider.getAccountInfo({ + accountAddress: senderAddr, + }) + let nextSeq = BigInt(sequence_number) + + // Transaction 1: apply_chain_updates — adds/removes remote chains + const applyTx = await this.provider.transaction.build.simple({ + sender: senderAddr, + data: { + function: + `${params.poolAddress}::${poolModule}::apply_chain_updates` as `${string}::${string}::${string}`, + functionArguments: [ + remoteChainSelectorsToRemove, + remoteChainSelectorsToAdd, + remotePoolAddressesToAdd, + remoteTokenAddressesToAdd, + ], + }, + options: { accountSequenceNumber: nextSeq++ }, + }) + + const transactions: [Uint8Array, ...Uint8Array[]] = [applyTx.bcsToBytes()] + + // Transaction 2: set_chain_rate_limiter_configs — configures rate limiters + // Aptos apply_chain_updates does NOT include rate limiter args; they must be set separately. + // Only build this transaction if there are chains to add (rate limiters apply to added chains). + if (params.chainsToAdd.length > 0) { + const rateLimiterTx = await this.provider.transaction.build.simple({ + sender: senderAddr, + data: { + function: + `${params.poolAddress}::${poolModule}::set_chain_rate_limiter_configs` as `${string}::${string}::${string}`, + functionArguments: [ + remoteChainSelectorsToAdd, + params.chainsToAdd.map((c) => c.outboundRateLimiterConfig.isEnabled), + params.chainsToAdd.map((c) => BigInt(c.outboundRateLimiterConfig.capacity)), + params.chainsToAdd.map((c) => BigInt(c.outboundRateLimiterConfig.rate)), + params.chainsToAdd.map((c) => c.inboundRateLimiterConfig.isEnabled), + params.chainsToAdd.map((c) => BigInt(c.inboundRateLimiterConfig.capacity)), + params.chainsToAdd.map((c) => BigInt(c.inboundRateLimiterConfig.rate)), + ], + }, + options: { accountSequenceNumber: nextSeq }, + }) + + transactions.push(rateLimiterTx.bcsToBytes()) + } + + this.logger.debug( + 'generateUnsignedApplyChainUpdates: pool =', + params.poolAddress, + 'module =', + poolModule, + 'adds =', + params.chainsToAdd.length, + 'removes =', + params.remoteChainSelectorsToRemove.length, + 'txs =', + transactions.length, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions, + }, + ], + } + } + + /** + * Configures remote chains on a token pool, signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be pool owner) + * @param params - Apply chain updates parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPApplyChainUpdatesParamsInvalidError} if params are invalid + * @throws {@link CCIPApplyChainUpdatesFailedError} if the transaction fails + */ + async applyChainUpdates( + wallet: unknown, + params: ApplyChainUpdatesParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedApplyChainUpdates( + sender, + params, + ) + + this.logger.debug('applyChainUpdates: applying chain updates...') + + try { + // Submit transactions sequentially — tx2 (rate limiters) depends on tx1 (chain config) + let lastHash = '' + for (const unsignedTx of unsignedTxs) { + for (const txBytes of unsignedTx.transactions) { + const unsigned = SimpleTransaction.deserialize(new Deserializer(txBytes)) + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + this.logger.debug('applyChainUpdates: submitted tx =', hash) + lastHash = hash + } + } + + this.logger.info('applyChainUpdates: applied chain updates, tx =', lastHash) + + return { txHash: lastHash } + } catch (error) { + if (error instanceof CCIPApplyChainUpdatesFailedError) throw error + if (error instanceof CCIPApplyChainUpdatesParamsInvalidError) throw error + throw new CCIPApplyChainUpdatesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Append Remote Pool Addresses ──────────────────────────────────────── + + /** + * Builds unsigned transactions for appending remote pool addresses to an existing chain config. + * + * Auto-discovers the pool module name from the pool address. + * Calls `add_remote_pool` on the pool module — one transaction per address. + * + * @param sender - Aptos account address (hex string) of the pool owner + * @param params - Append remote pool addresses parameters + * @returns Unsigned Aptos transactions (one per address) + * @throws {@link CCIPAppendRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool module not found + * @throws {@link CCIPPoolNotInitializedError} if pool is not initialized + */ + async generateUnsignedAppendRemotePoolAddresses( + sender: string, + params: AppendRemotePoolAddressesParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + validateAppendRemotePoolAddressesParams(params) + + const poolModule = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, poolModule) + + const senderAddr = AccountAddress.from(sender) + const transactions: Uint8Array[] = [] + + // Fetch current sequence number so multi-tx batches get consecutive nonces + const { sequence_number } = await this.provider.getAccountInfo({ + accountAddress: senderAddr, + }) + let nextSeq = BigInt(sequence_number) + + for (const remotePoolAddress of params.remotePoolAddresses) { + const encodedAddress = Array.from(getAddressBytes(remotePoolAddress)) + const tx = await this.provider.transaction.build.simple({ + sender: senderAddr, + data: { + function: + `${params.poolAddress}::${poolModule}::add_remote_pool` as `${string}::${string}::${string}`, + functionArguments: [params.remoteChainSelector, encodedAddress], + }, + options: { accountSequenceNumber: nextSeq++ }, + }) + transactions.push(tx.bcsToBytes()) + } + + this.logger.debug( + 'generateUnsignedAppendRemotePoolAddresses: pool =', + params.poolAddress, + 'module =', + poolModule, + 'addresses =', + params.remotePoolAddresses.length, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: transactions as [Uint8Array, ...Uint8Array[]], + }, + ], + } + } + + /** + * Appends remote pool addresses to an existing chain config, signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be pool owner) + * @param params - Append remote pool addresses parameters + * @returns Result with `txHash` of the last transaction + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPAppendRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPAppendRemotePoolAddressesFailedError} if the transaction fails + */ + async appendRemotePoolAddresses( + wallet: unknown, + params: AppendRemotePoolAddressesParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedAppendRemotePoolAddresses( + sender, + params, + ) + + this.logger.debug('appendRemotePoolAddresses: appending remote pool addresses...') + + try { + let lastHash = '' + for (const unsignedTx of unsignedTxs) { + for (const txBytes of unsignedTx.transactions) { + const unsigned = SimpleTransaction.deserialize(new Deserializer(txBytes)) + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + this.logger.debug('appendRemotePoolAddresses: submitted tx =', hash) + lastHash = hash + } + } + + this.logger.info('appendRemotePoolAddresses: appended remote pool addresses, tx =', lastHash) + + return { txHash: lastHash } + } catch (error) { + if (error instanceof CCIPAppendRemotePoolAddressesFailedError) throw error + if (error instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) throw error + throw new CCIPAppendRemotePoolAddressesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Delete Chain Config ────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for removing a remote chain configuration from a token pool. + * + * Calls `apply_chain_updates` on the pool module with only the removal selector + * and empty add arrays. + * + * @param sender - Aptos account address (hex string) of the pool owner + * @param params - Delete chain config parameters + * @returns Unsigned Aptos transaction + * @throws {@link CCIPDeleteChainConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool module not found + * @throws {@link CCIPPoolNotInitializedError} if pool is not initialized + */ + async generateUnsignedDeleteChainConfig( + sender: string, + params: DeleteChainConfigParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + validateDeleteChainConfigParams(params) + + const poolModule = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, poolModule) + + const senderAddr = AccountAddress.from(sender) + + const applyTx = await this.provider.transaction.build.simple({ + sender: senderAddr, + data: { + function: + `${params.poolAddress}::${poolModule}::apply_chain_updates` as `${string}::${string}::${string}`, + functionArguments: [ + [params.remoteChainSelector], // remoteChainSelectorsToRemove + [], // remoteChainSelectorsToAdd + [], // remotePoolAddressesToAdd + [], // remoteTokenAddressesToAdd + ], + }, + }) + + this.logger.debug( + 'generateUnsignedDeleteChainConfig: pool =', + params.poolAddress, + 'module =', + poolModule, + 'remoteChainSelector =', + params.remoteChainSelector, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [applyTx.bcsToBytes()], + }, + ], + } + } + + /** + * Removes a remote chain configuration from a token pool, signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be pool owner) + * @param params - Delete chain config parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPDeleteChainConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPDeleteChainConfigFailedError} if the transaction fails + */ + async deleteChainConfig( + wallet: unknown, + params: DeleteChainConfigParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedDeleteChainConfig( + sender, + params, + ) + + this.logger.debug('deleteChainConfig: deleting chain config...') + + try { + const unsignedTx = unsignedTxs[0]! + const txBytes = unsignedTx.transactions[0] + const unsigned = SimpleTransaction.deserialize(new Deserializer(txBytes)) + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('deleteChainConfig: deleted chain config, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPDeleteChainConfigFailedError) throw error + if (error instanceof CCIPDeleteChainConfigParamsInvalidError) throw error + throw new CCIPDeleteChainConfigFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Remove Remote Pool Addresses ──────────────────────────────────────── + + /** + * Builds unsigned transactions for removing specific remote pool addresses from an existing chain config. + * + * Calls `remove_remote_pool(signer, u64, vector)` on the pool module. + * One transaction per address. + * + * @param sender - Aptos account address (hex string) of the pool owner + * @param params - Remove remote pool addresses parameters + * @returns Unsigned Aptos transactions (one per address) + * @throws {@link CCIPRemoveRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool module not found + * @throws {@link CCIPPoolNotInitializedError} if pool is not initialized + */ + async generateUnsignedRemoveRemotePoolAddresses( + sender: string, + params: RemoveRemotePoolAddressesParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + validateRemoveRemotePoolAddressesParams(params) + + const poolModule = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, poolModule) + + const senderAddr = AccountAddress.from(sender) + const transactions: Uint8Array[] = [] + + // Fetch current sequence number so multi-tx batches get consecutive nonces + const { sequence_number } = await this.provider.getAccountInfo({ + accountAddress: senderAddr, + }) + let nextSeq = BigInt(sequence_number) + + for (const remotePoolAddress of params.remotePoolAddresses) { + const encodedAddress = Array.from(getAddressBytes(remotePoolAddress)) + const tx = await this.provider.transaction.build.simple({ + sender: senderAddr, + data: { + function: + `${params.poolAddress}::${poolModule}::remove_remote_pool` as `${string}::${string}::${string}`, + functionArguments: [params.remoteChainSelector, encodedAddress], + }, + options: { accountSequenceNumber: nextSeq++ }, + }) + transactions.push(tx.bcsToBytes()) + } + + this.logger.debug( + 'generateUnsignedRemoveRemotePoolAddresses: pool =', + params.poolAddress, + 'module =', + poolModule, + 'addresses =', + params.remotePoolAddresses.length, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: transactions as [Uint8Array, ...Uint8Array[]], + }, + ], + } + } + + /** + * Removes specific remote pool addresses from an existing chain config, signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be pool owner) + * @param params - Remove remote pool addresses parameters + * @returns Result with `txHash` of the last transaction + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPRemoveRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPRemoveRemotePoolAddressesFailedError} if the transaction fails + */ + async removeRemotePoolAddresses( + wallet: unknown, + params: RemoveRemotePoolAddressesParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedRemoveRemotePoolAddresses( + sender, + params, + ) + + this.logger.debug('removeRemotePoolAddresses: removing remote pool addresses...') + + try { + let lastHash = '' + for (const unsignedTx of unsignedTxs) { + for (const txBytes of unsignedTx.transactions) { + const unsigned = SimpleTransaction.deserialize(new Deserializer(txBytes)) + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + this.logger.debug('removeRemotePoolAddresses: submitted tx =', hash) + lastHash = hash + } + } + + this.logger.info('removeRemotePoolAddresses: removed remote pool addresses, tx =', lastHash) + + return { txHash: lastHash } + } catch (error) { + if (error instanceof CCIPRemoveRemotePoolAddressesFailedError) throw error + if (error instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) throw error + throw new CCIPRemoveRemotePoolAddressesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Set Chain Rate Limiter Config ──────────────────────────────────────── + + /** + * Builds unsigned transactions for updating rate limiter configurations on a token pool. + * + * Auto-discovers the pool module name from the pool address. + * Encodes a single `set_chain_rate_limiter_configs` Move call with all chain configs. + * + * @param sender - Aptos account address (hex) + * @param params - Set chain rate limiter config parameters + * @returns Unsigned Aptos transactions + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPPoolNotInitializedError} if pool is not initialized (generic pools) + */ + async generateUnsignedSetChainRateLimiterConfig( + sender: string, + params: SetChainRateLimiterConfigParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + validateSetChainRateLimiterConfigParams(params) + + // Auto-discover pool module name + const poolModule = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, poolModule) + + const senderAddr = AccountAddress.from(sender) + + const rateLimiterTx = await this.provider.transaction.build.simple({ + sender: senderAddr, + data: { + function: + `${params.poolAddress}::${poolModule}::set_chain_rate_limiter_configs` as `${string}::${string}::${string}`, + functionArguments: [ + params.chainConfigs.map((c) => c.remoteChainSelector), + params.chainConfigs.map((c) => c.outboundRateLimiterConfig.isEnabled), + params.chainConfigs.map((c) => BigInt(c.outboundRateLimiterConfig.capacity)), + params.chainConfigs.map((c) => BigInt(c.outboundRateLimiterConfig.rate)), + params.chainConfigs.map((c) => c.inboundRateLimiterConfig.isEnabled), + params.chainConfigs.map((c) => BigInt(c.inboundRateLimiterConfig.capacity)), + params.chainConfigs.map((c) => BigInt(c.inboundRateLimiterConfig.rate)), + ], + }, + }) + + this.logger.debug( + 'generateUnsignedSetChainRateLimiterConfig: pool =', + params.poolAddress, + 'module =', + poolModule, + 'configs =', + params.chainConfigs.length, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [rateLimiterTx.bcsToBytes()], + }, + ], + } + } + + /** + * Updates rate limiter configurations on a token pool, signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be pool owner or rate limit admin) + * @param params - Set chain rate limiter config parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPSetRateLimiterConfigFailedError} if the transaction fails + */ + async setChainRateLimiterConfig( + wallet: unknown, + params: SetChainRateLimiterConfigParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedSetChainRateLimiterConfig( + sender, + params, + ) + + this.logger.debug('setChainRateLimiterConfig: updating rate limits...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('setChainRateLimiterConfig: updated rate limits, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPSetRateLimiterConfigFailedError) throw error + if (error instanceof CCIPSetRateLimiterConfigParamsInvalidError) throw error + throw new CCIPSetRateLimiterConfigFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // setRateLimitAdmin — NOT SUPPORTED on Aptos + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Not supported on Aptos — rate limiting is managed directly by the pool owner. + * + * @throws {@link CCIPMethodUnsupportedError} always + */ + generateUnsignedSetRateLimitAdmin( + _sender: string, + _params: { poolAddress: string; rateLimitAdmin: string }, + ): never { + throw new CCIPMethodUnsupportedError('AptosTokenAdmin', 'setRateLimitAdmin') + } + + /** + * Not supported on Aptos — rate limiting is managed directly by the pool owner. + * + * @throws {@link CCIPMethodUnsupportedError} always + */ + setRateLimitAdmin( + _wallet: unknown, + _params: { poolAddress: string; rateLimitAdmin: string }, + ): never { + throw new CCIPMethodUnsupportedError('AptosTokenAdmin', 'setRateLimitAdmin') + } + + // ── Grant Mint/Burn Access ───────────────────────────────────────────── + + /** + * Detects the pool type from a pool address by discovering the pool module + * name and mapping it to a known type. + * + * @param poolAddress - Pool object address (hex string) + * @returns Pool type and module name + * @throws {@link CCIPGrantMintBurnAccessParamsInvalidError} if pool type cannot be determined + */ + private async detectPoolType(poolAddress: string): Promise<{ + type: 'managed' | 'burn_mint' | 'regulated' | 'lock_release' + module: string + }> { + const poolModule = await this.discoverPoolModule(poolAddress) + + if (poolModule === 'managed_token_pool') return { type: 'managed', module: poolModule } + if (poolModule === 'burn_mint_token_pool') return { type: 'burn_mint', module: poolModule } + if (poolModule === 'regulated_token_pool') return { type: 'regulated', module: poolModule } + if (poolModule === 'lock_release_token_pool') + return { type: 'lock_release', module: poolModule } + + // Fallback: try type_and_version view function + try { + const [typeName] = await this.provider.view<[string]>({ + payload: { + function: + `${poolAddress}::${poolModule}::type_and_version` as `${string}::${string}::${string}`, + }, + }) + if (typeName.includes('ManagedTokenPool')) return { type: 'managed', module: poolModule } + if (typeName.includes('BurnMintTokenPool')) return { type: 'burn_mint', module: poolModule } + if (typeName.includes('RegulatedTokenPool')) return { type: 'regulated', module: poolModule } + if (typeName.includes('LockReleaseTokenPool')) + return { type: 'lock_release', module: poolModule } + } catch { + // type_and_version not available, fall through + } + + throw new CCIPGrantMintBurnAccessParamsInvalidError( + 'authority', + `unknown pool type at ${poolAddress}: module=${poolModule}`, + ) + } + + /** + * Resolves the token code object address from a Fungible Asset metadata address. + * + * Object hierarchy: code object → token state → FA metadata. + * The FA metadata object's owner is the token state object, whose owner is + * the code object. + * + * @param tokenAddress - FA metadata address (hex string) + * @returns Code object address + */ + private async resolveTokenCodeObject(tokenAddress: string): Promise { + // FA metadata → owned by token state → owned by code object + // First get the owner of FA metadata (token state object) + const [tokenStateOwner] = await this.provider.view<[string]>({ + payload: { + function: '0x1::object::owner' as `${string}::${string}::${string}`, + typeArguments: ['0x1::fungible_asset::Metadata'], + functionArguments: [tokenAddress], + }, + }) + + // Then get the owner of the token state (code object) + const [codeObject] = await this.provider.view<[string]>({ + payload: { + function: '0x1::object::owner' as `${string}::${string}::${string}`, + typeArguments: ['0x1::object::ObjectCore'], + functionArguments: [tokenStateOwner], + }, + }) + + return codeObject + } + + /** + * Builds unsigned transactions for granting mint/burn access on an Aptos + * token to the specified pool address. + * + * Auto-detects the pool type via the pool's module name: + * - **ManagedTokenPool**: calls `apply_allowed_minter_updates` + `apply_allowed_burner_updates` + * on the managed_token module (2 transactions) + * - **RegulatedTokenPool**: calls `regulated_token::grant_role` with + * `BRIDGE_MINTER_OR_BURNER_ROLE` (1 transaction) + * - **BurnMintTokenPool**: not supported — requires `initialize()` with Move BurnRef/MintRef + * - **LockReleaseTokenPool**: not applicable — does not mint/burn + * + * @param sender - Token owner address (hex string) + * @param params - Grant mint/burn access parameters + * @returns Unsigned Aptos transactions + * @throws {@link CCIPGrantMintBurnAccessParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool module not found + */ + async generateUnsignedGrantMintBurnAccess( + sender: string, + params: GrantMintBurnAccessParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPGrantMintBurnAccessParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.authority || params.authority.trim().length === 0) { + throw new CCIPGrantMintBurnAccessParamsInvalidError('authority', 'must be non-empty') + } + + const poolInfo = await this.detectPoolType(params.authority) + + if (poolInfo.type === 'lock_release') { + throw new CCIPGrantMintBurnAccessParamsInvalidError( + 'authority', + 'lock-release pools do not mint or burn tokens — no access to grant', + ) + } + + if (poolInfo.type === 'burn_mint') { + throw new CCIPGrantMintBurnAccessParamsInvalidError( + 'authority', + 'burn_mint_token_pool requires initialization by the token creator module. ' + + 'The token creator must call burn_mint_token_pool::initialize() with stored BurnRef/MintRef. ' + + 'This cannot be done via SDK because the capability refs are only available to the token creator.', + ) + } + + await this.ensurePoolInitialized(params.authority, poolInfo.module) + + // Get pool resource signer address (the address that calls mint/burn) + const [poolResourceSigner] = await this.provider.view<[string]>({ + payload: { + function: + `${params.authority}::${poolInfo.module}::get_store_address` as `${string}::${string}::${string}`, + }, + }) + + // Resolve token code object from FA metadata + const tokenCodeObject = await this.resolveTokenCodeObject(params.tokenAddress) + + const txs: UnsignedAptosTx[] = [] + + // Fetch current sequence number so multi-tx batches get consecutive nonces + const { sequence_number } = await this.provider.getAccountInfo({ + accountAddress: AccountAddress.from(sender), + }) + let nextSeq = BigInt(sequence_number) + + const role = params.role ?? 'mintAndBurn' + + if (poolInfo.type === 'managed') { + // managed_token: add pool resource signer to allowed minters and/or burners + if (role === 'mint' || role === 'mintAndBurn') { + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${tokenCodeObject}::managed_token::apply_allowed_minter_updates` as `${string}::${string}::${string}`, + functionArguments: [[], [poolResourceSigner]], + }, + options: { accountSequenceNumber: nextSeq++ }, + }) + txs.push({ family: ChainFamily.Aptos, transactions: [tx.bcsToBytes()] }) + } + if (role === 'burn' || role === 'mintAndBurn') { + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${tokenCodeObject}::managed_token::apply_allowed_burner_updates` as `${string}::${string}::${string}`, + functionArguments: [[], [poolResourceSigner]], + }, + options: { accountSequenceNumber: nextSeq }, + }) + txs.push({ family: ChainFamily.Aptos, transactions: [tx.bcsToBytes()] }) + } + } else { + // regulated_token: MINTER_ROLE=4, BURNER_ROLE=5, BRIDGE_MINTER_OR_BURNER_ROLE=6 + const roleNumber = role === 'mint' ? 4 : role === 'burn' ? 5 : 6 + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${tokenCodeObject}::regulated_token::grant_role` as `${string}::${string}::${string}`, + functionArguments: [roleNumber, poolResourceSigner], + }, + }) + txs.push({ family: ChainFamily.Aptos, transactions: [tx.bcsToBytes()] }) + } + + this.logger.debug( + 'generateUnsignedGrantMintBurnAccess: pool type =', + poolInfo.type, + 'poolResourceSigner =', + poolResourceSigner, + 'txs =', + txs.length, + ) + + return { transactions: txs } + } + + /** + * Grants mint/burn access on an Aptos token, signing and submitting with + * the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be the token owner) + * @param params - Grant mint/burn access parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPGrantMintBurnAccessParamsInvalidError} if params are invalid + * @throws {@link CCIPGrantMintBurnAccessFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.grantMintBurnAccess(wallet, { + * tokenAddress: '0x89fd6b...', + * authority: '0x1234...', + * }) + * ``` + */ + async grantMintBurnAccess( + wallet: unknown, + params: GrantMintBurnAccessParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedGrantMintBurnAccess( + sender, + params, + ) + + this.logger.debug('grantMintBurnAccess: granting mint/burn access...') + + try { + let lastHash = '' + for (const unsignedTx of unsignedTxs) { + const unsigned = SimpleTransaction.deserialize(new Deserializer(unsignedTx.transactions[0])) + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + lastHash = hash + } + + this.logger.info('grantMintBurnAccess: granted mint/burn access, tx =', lastHash) + + return { txHash: lastHash } + } catch (error) { + if (error instanceof CCIPGrantMintBurnAccessFailedError) throw error + if (error instanceof CCIPGrantMintBurnAccessParamsInvalidError) throw error + throw new CCIPGrantMintBurnAccessFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Revoke Mint/Burn Access ─────────────────────────────────────────────── + + /** + * Builds unsigned transaction(s) to revoke mint or burn access from an + * address on an Aptos token. + * + * - **Managed token:** calls `apply_allowed_minter_updates([authority], [])` or + * `apply_allowed_burner_updates([authority], [])`. + * - **Regulated token:** calls `revoke_role(MINTER_ROLE=4, authority)` or + * `revoke_role(BURNER_ROLE=5, authority)`. + * + * @param sender - Sender account address + * @param params - Revoke mint/burn access parameters + * @returns Unsigned Aptos transaction(s) + * @throws {@link CCIPRevokeMintBurnAccessParamsInvalidError} if params are invalid + */ + async generateUnsignedRevokeMintBurnAccess( + sender: string, + params: RevokeMintBurnAccessParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPRevokeMintBurnAccessParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.authority || params.authority.trim().length === 0) { + throw new CCIPRevokeMintBurnAccessParamsInvalidError('authority', 'must be non-empty') + } + if ((params.role as string) !== 'mint' && (params.role as string) !== 'burn') { + throw new CCIPRevokeMintBurnAccessParamsInvalidError('role', "must be 'mint' or 'burn'") + } + + const poolInfo = await this.detectPoolType(params.authority) + + if (poolInfo.type === 'lock_release') { + throw new CCIPRevokeMintBurnAccessParamsInvalidError( + 'authority', + 'lock-release pools do not mint or burn tokens — no access to revoke', + ) + } + + if (poolInfo.type === 'burn_mint') { + throw new CCIPRevokeMintBurnAccessParamsInvalidError( + 'authority', + 'burn_mint_token_pool requires initialization by the token creator module. Revoke is not supported via SDK.', + ) + } + + await this.ensurePoolInitialized(params.authority, poolInfo.module) + + const [poolResourceSigner] = await this.provider.view<[string]>({ + payload: { + function: + `${params.authority}::${poolInfo.module}::get_store_address` as `${string}::${string}::${string}`, + }, + }) + + const tokenCodeObject = await this.resolveTokenCodeObject(params.tokenAddress) + + const txs: UnsignedAptosTx[] = [] + + if (poolInfo.type === 'managed') { + // managed_token: remove pool resource signer from minters or burners + const fnName = + params.role === 'mint' ? 'apply_allowed_minter_updates' : 'apply_allowed_burner_updates' + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${tokenCodeObject}::managed_token::${fnName}` as `${string}::${string}::${string}`, + functionArguments: [[poolResourceSigner], []], // remove=[signer], add=[] + }, + }) + txs.push({ family: ChainFamily.Aptos, transactions: [tx.bcsToBytes()] }) + } else { + // regulated_token: MINTER_ROLE=4, BURNER_ROLE=5 + const roleNumber = params.role === 'mint' ? 4 : 5 + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${tokenCodeObject}::regulated_token::revoke_role` as `${string}::${string}::${string}`, + functionArguments: [roleNumber, poolResourceSigner], + }, + }) + txs.push({ family: ChainFamily.Aptos, transactions: [tx.bcsToBytes()] }) + } + + this.logger.debug( + 'generateUnsignedRevokeMintBurnAccess: pool type =', + poolInfo.type, + 'role =', + params.role, + 'poolResourceSigner =', + poolResourceSigner, + ) + + return { transactions: txs } + } + + /** + * Revokes mint or burn access from an address on an Aptos token, + * signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability + * @param params - Revoke mint/burn access parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPRevokeMintBurnAccessParamsInvalidError} if params are invalid + * @throws {@link CCIPRevokeMintBurnAccessFailedError} if the transaction fails + */ + async revokeMintBurnAccess( + wallet: unknown, + params: RevokeMintBurnAccessParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedRevokeMintBurnAccess( + sender, + params, + ) + + this.logger.debug('revokeMintBurnAccess: revoking', params.role, 'access...') + + try { + let lastHash = '' + for (const unsignedTx of unsignedTxs) { + const unsigned = SimpleTransaction.deserialize(new Deserializer(unsignedTx.transactions[0])) + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + lastHash = hash + } + + this.logger.info('revokeMintBurnAccess: revoked', params.role, 'access, tx =', lastHash) + + return { txHash: lastHash } + } catch (error) { + if (error instanceof CCIPRevokeMintBurnAccessFailedError) throw error + if (error instanceof CCIPRevokeMintBurnAccessParamsInvalidError) throw error + throw new CCIPRevokeMintBurnAccessFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + /** + * Deploys a ManagedToken FA module, signing and submitting with the provided wallet. + * + * **Requires `aptos` CLI** — compiles the Move source at deploy time. + * + * @param wallet - Aptos account with signing capability + * @param params - Token deployment parameters + * @returns Unified deploy result with `tokenAddress` and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPTokenDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenDeployFailedError} if the deploy transaction fails + * + * @example + * ```typescript + * const { tokenAddress, txHash } = await admin.deployToken(wallet, { + * name: 'My Token', + * symbol: 'MTK', + * decimals: 8, + * initialSupply: 1_000_000_000n, + * }) + * console.log(\`Deployed at \${tokenAddress}, tx: \${txHash}\`) + * ``` + */ + async deployToken(wallet: unknown, params: AptosDeployTokenParams): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + + const { + transactions: unsignedTxs, + codeObjectAddress, + tokenAddress: faAddress, + } = await this.generateUnsignedDeployToken(sender, params) + + this.logger.debug('deployToken: deploying ManagedToken...', unsignedTxs.length, 'transactions') + + let firstTxHash = '' + + try { + for (let i = 0; i < unsignedTxs.length; i++) { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[i]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + if (i === 0) firstTxHash = hash + this.logger.debug('deployToken: tx', i, 'confirmed:', hash) + } + + this.logger.info( + 'deployToken: FA at', + faAddress, + 'code object at', + codeObjectAddress, + 'tx =', + firstTxHash, + ) + + return { tokenAddress: faAddress, txHash: firstTxHash, codeObjectAddress } + } catch (error) { + if (error instanceof CCIPTokenDeployFailedError) throw error + throw new CCIPTokenDeployFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Get Mint/Burn Roles (read-only) ────────────────────────────────────── + + /** + * Queries mint and burn roles on an Aptos managed or regulated token. + * + * - **managed**: calls `get_allowed_minters()` and `get_allowed_burners()` + * - **regulated**: calls `get_minters()`, `get_burners()`, + * and `get_bridge_minters_or_burners()` + * + * Resolves the code object address from the FA metadata automatically. + * + * @param tokenAddress - Fungible asset metadata address (hex string) + * @returns Role info including detected token module type + * + * @example + * ```typescript + * const roles = await admin.getMintBurnRoles('0x89fd6b...') + * ``` + */ + async getMintBurnRoles(tokenAddress: string): Promise { + const codeObject = await this.resolveTokenCodeObject(tokenAddress) + + // Resolve the owner of the code object (wallet that deployed the token) + let owner: string | undefined + try { + const [codeObjectOwner] = await this.provider.view<[string]>({ + payload: { + function: '0x1::object::owner' as `${string}::${string}::${string}`, + typeArguments: ['0x1::object::ObjectCore'], + functionArguments: [codeObject], + }, + }) + owner = codeObjectOwner + } catch { + this.logger.debug('getMintBurnRoles: failed to resolve code object owner') + } + + // Try managed_token first + try { + const [minters] = await this.provider.view<[string[]]>({ + payload: { + function: + `${codeObject}::managed_token::get_allowed_minters` as `${string}::${string}::${string}`, + }, + }) + const [burners] = await this.provider.view<[string[]]>({ + payload: { + function: + `${codeObject}::managed_token::get_allowed_burners` as `${string}::${string}::${string}`, + }, + }) + + this.logger.debug( + `getMintBurnRoles: managed token, minters=${minters.length}, burners=${burners.length}`, + ) + + return { + tokenModule: 'managed', + owner, + allowedMinters: minters, + allowedBurners: burners, + } + } catch { + // Not a managed token, try regulated + } + + // Try regulated_token + try { + const [minters] = await this.provider.view<[string[]]>({ + payload: { + function: + `${codeObject}::regulated_token::get_minters` as `${string}::${string}::${string}`, + }, + }) + const [burners] = await this.provider.view<[string[]]>({ + payload: { + function: + `${codeObject}::regulated_token::get_burners` as `${string}::${string}::${string}`, + }, + }) + const [bridgeMintersOrBurners] = await this.provider.view<[string[]]>({ + payload: { + function: + `${codeObject}::regulated_token::get_bridge_minters_or_burners` as `${string}::${string}::${string}`, + }, + }) + + this.logger.debug( + `getMintBurnRoles: regulated token, minters=${minters.length}, burners=${burners.length}, bridge=${bridgeMintersOrBurners.length}`, + ) + + return { + tokenModule: 'regulated', + owner, + allowedMinters: minters, + allowedBurners: burners, + bridgeMintersOrBurners, + } + } catch { + // Not a regulated token either + } + + this.logger.debug('getMintBurnRoles: unknown token module type') + + return { tokenModule: 'unknown', owner } + } + + // ── Transfer Ownership ─────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for proposing a new pool owner. + * + * Uses `poolAddress::moduleName::transfer_ownership(caller, to)`. + * + * @param sender - Sender address (hex) + * @param params - Transfer ownership parameters + * @returns Unsigned Aptos transaction + * @throws {@link CCIPTransferOwnershipParamsInvalidError} if params are invalid + */ + async generateUnsignedTransferOwnership( + sender: string, + params: TransferOwnershipParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPTransferOwnershipParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.newOwner || params.newOwner.trim().length === 0) { + throw new CCIPTransferOwnershipParamsInvalidError('newOwner', 'must be non-empty') + } + + const moduleName = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, moduleName) + + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${params.poolAddress}::${moduleName}::transfer_ownership` as `${string}::${string}::${string}`, + functionArguments: [params.newOwner], + }, + }) + + this.logger.debug( + 'generateUnsignedTransferOwnership: pool =', + params.poolAddress, + 'module =', + moduleName, + 'newOwner =', + params.newOwner, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [tx.bcsToBytes()], + }, + ], + } + } + + /** + * Proposes a new pool owner, signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be current pool owner) + * @param params - Transfer ownership parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPTransferOwnershipParamsInvalidError} if params are invalid + * @throws {@link CCIPTransferOwnershipFailedError} if the transaction fails + */ + async transferOwnership( + wallet: unknown, + params: TransferOwnershipParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedTransferOwnership( + sender, + params, + ) + + this.logger.debug('transferOwnership: proposing new owner...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('transferOwnership: ownership proposed, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPTransferOwnershipFailedError) throw error + if (error instanceof CCIPTransferOwnershipParamsInvalidError) throw error + throw new CCIPTransferOwnershipFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Accept Ownership ───────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for accepting pool ownership. + * + * Uses `poolAddress::moduleName::accept_ownership(caller)`. + * + * @param sender - Sender address (hex) + * @param params - Accept ownership parameters + * @returns Unsigned Aptos transaction + * @throws {@link CCIPAcceptOwnershipParamsInvalidError} if params are invalid + */ + async generateUnsignedAcceptOwnership( + sender: string, + params: AcceptOwnershipParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPAcceptOwnershipParamsInvalidError('poolAddress', 'must be non-empty') + } + + const moduleName = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, moduleName) + + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${params.poolAddress}::${moduleName}::accept_ownership` as `${string}::${string}::${string}`, + functionArguments: [], + }, + }) + + this.logger.debug( + 'generateUnsignedAcceptOwnership: pool =', + params.poolAddress, + 'module =', + moduleName, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [tx.bcsToBytes()], + }, + ], + } + } + + /** + * Accepts pool ownership, signing and submitting with the provided wallet. + * + * @param wallet - Aptos account with signing capability (must be proposed owner) + * @param params - Accept ownership parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPAcceptOwnershipParamsInvalidError} if params are invalid + * @throws {@link CCIPAcceptOwnershipFailedError} if the transaction fails + */ + async acceptOwnership(wallet: unknown, params: AcceptOwnershipParams): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedAcceptOwnership(sender, params) + + this.logger.debug('acceptOwnership: accepting ownership...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('acceptOwnership: ownership accepted, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPAcceptOwnershipFailedError) throw error + if (error instanceof CCIPAcceptOwnershipParamsInvalidError) throw error + throw new CCIPAcceptOwnershipFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Execute Ownership Transfer (Aptos 3rd step) ───────────────────────── + + /** + * Builds an unsigned transaction for executing (finalizing) pool ownership transfer. + * + * Aptos uses a 3-step ownership transfer: + * 1. `transfer_ownership(to)` — current owner proposes + * 2. `accept_ownership()` — proposed owner signals acceptance + * 3. `execute_ownership_transfer(to)` — **current owner** finalizes the AptosFramework object transfer + * + * Uses `poolAddress::moduleName::execute_ownership_transfer(caller, to)`. + * + * @param sender - Sender address (hex) — must be the **current** pool owner + * @param params - Execute ownership transfer parameters + * @returns Unsigned Aptos transaction + * @throws {@link CCIPExecuteOwnershipTransferParamsInvalidError} if params are invalid + */ + async generateUnsignedExecuteOwnershipTransfer( + sender: string, + params: ExecuteOwnershipTransferParams, + ): Promise<{ transactions: UnsignedAptosTx[] }> { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPExecuteOwnershipTransferParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.newOwner || params.newOwner.trim().length === 0) { + throw new CCIPExecuteOwnershipTransferParamsInvalidError('newOwner', 'must be non-empty') + } + + const moduleName = await this.discoverPoolModule(params.poolAddress) + await this.ensurePoolInitialized(params.poolAddress, moduleName) + + const tx = await this.provider.transaction.build.simple({ + sender: AccountAddress.from(sender), + data: { + function: + `${params.poolAddress}::${moduleName}::execute_ownership_transfer` as `${string}::${string}::${string}`, + functionArguments: [params.newOwner], + }, + }) + + this.logger.debug( + 'generateUnsignedExecuteOwnershipTransfer: pool =', + params.poolAddress, + 'module =', + moduleName, + 'newOwner =', + params.newOwner, + ) + + return { + transactions: [ + { + family: ChainFamily.Aptos, + transactions: [tx.bcsToBytes()], + }, + ], + } + } + + /** + * Executes (finalizes) pool ownership transfer, signing and submitting with the provided wallet. + * + * This is the Aptos-only 3rd step: the **current owner** calls this after the proposed + * owner has called `acceptOwnership`. It performs the AptosFramework `object::transfer`. + * + * @param wallet - Aptos account with signing capability (must be **current** pool owner) + * @param params - Execute ownership transfer parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Aptos account + * @throws {@link CCIPExecuteOwnershipTransferParamsInvalidError} if params are invalid + * @throws {@link CCIPExecuteOwnershipTransferFailedError} if the transaction fails + */ + async executeOwnershipTransfer( + wallet: unknown, + params: ExecuteOwnershipTransferParams, + ): Promise { + if (!isAptosAccount(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.accountAddress.toString() + const { transactions: unsignedTxs } = await this.generateUnsignedExecuteOwnershipTransfer( + sender, + params, + ) + + this.logger.debug('executeOwnershipTransfer: finalizing ownership transfer...') + + try { + const unsigned = SimpleTransaction.deserialize( + new Deserializer(unsignedTxs[0]!.transactions[0]), + ) + + const signed = await wallet.signTransactionWithAuthenticator(unsigned) + const pendingTxn = await this.provider.transaction.submit.simple({ + transaction: unsigned, + senderAuthenticator: signed, + }) + + const { hash } = await this.provider.waitForTransaction({ + transactionHash: pendingTxn.hash, + }) + + this.logger.info('executeOwnershipTransfer: ownership transfer executed, tx =', hash) + + return { txHash: hash } + } catch (error) { + if (error instanceof CCIPExecuteOwnershipTransferFailedError) throw error + if (error instanceof CCIPExecuteOwnershipTransferParamsInvalidError) throw error + throw new CCIPExecuteOwnershipTransferFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } +} diff --git a/ccip-sdk/src/token-admin/evm/abi/BurnMintERC20.ts b/ccip-sdk/src/token-admin/evm/abi/BurnMintERC20.ts new file mode 100644 index 00000000..7a5fedcc --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/abi/BurnMintERC20.ts @@ -0,0 +1,325 @@ +/** BurnMintERC20 ABI — auto-generated from chainlink-evm gethwrapper. */ +export default [ + // generate: + // fetch('https://raw.githubusercontent.com/smartcontractkit/chainlink-evm/contracts-solidity/1.5.1/gethwrappers/shared/generated/latest/burn_mint_erc20/burn_mint_erc20.go') + // .then((res) => res.text()) + // .then((body) => body.match(/^\s*ABI: "(.*?)",$/m)?.[1]) + // .then((abi) => JSON.parse(abi.replace(/\\"/g, '"'))) + // .then((obj) => require('util').inspect(obj, {depth:99}).split('\n').slice(1, -1)) + { + type: 'constructor', + inputs: [ + { name: 'name', type: 'string', internalType: 'string' }, + { name: 'symbol', type: 'string', internalType: 'string' }, + { name: 'decimals_', type: 'uint8', internalType: 'uint8' }, + { name: 'maxSupply_', type: 'uint256', internalType: 'uint256' }, + { name: 'preMint', type: 'uint256', internalType: 'uint256' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'BURNER_ROLE', + inputs: [], + outputs: [{ name: '', type: 'bytes32', internalType: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'DEFAULT_ADMIN_ROLE', + inputs: [], + outputs: [{ name: '', type: 'bytes32', internalType: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'MINTER_ROLE', + inputs: [], + outputs: [{ name: '', type: 'bytes32', internalType: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'allowance', + inputs: [ + { name: 'owner', type: 'address', internalType: 'address' }, + { name: 'spender', type: 'address', internalType: 'address' }, + ], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'approve', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'balanceOf', + inputs: [{ name: 'account', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'burn', + inputs: [{ name: 'amount', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'burn', + inputs: [ + { name: 'account', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'burnFrom', + inputs: [ + { name: 'account', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'decimals', + inputs: [], + outputs: [{ name: '', type: 'uint8', internalType: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'decreaseAllowance', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'subtractedValue', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'getCCIPAdmin', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getRoleAdmin', + inputs: [{ name: 'role', type: 'bytes32', internalType: 'bytes32' }], + outputs: [{ name: '', type: 'bytes32', internalType: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'grantMintAndBurnRoles', + inputs: [{ name: 'burnAndMinter', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'grantRole', + inputs: [ + { name: 'role', type: 'bytes32', internalType: 'bytes32' }, + { name: 'account', type: 'address', internalType: 'address' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'hasRole', + inputs: [ + { name: 'role', type: 'bytes32', internalType: 'bytes32' }, + { name: 'account', type: 'address', internalType: 'address' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'increaseAllowance', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'addedValue', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'maxSupply', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'mint', + inputs: [ + { name: 'account', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'name', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'renounceRole', + inputs: [ + { name: 'role', type: 'bytes32', internalType: 'bytes32' }, + { name: 'account', type: 'address', internalType: 'address' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'revokeRole', + inputs: [ + { name: 'role', type: 'bytes32', internalType: 'bytes32' }, + { name: 'account', type: 'address', internalType: 'address' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setCCIPAdmin', + inputs: [{ name: 'newAdmin', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [{ name: 'interfaceId', type: 'bytes4', internalType: 'bytes4' }], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'pure', + }, + { + type: 'function', + name: 'symbol', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'totalSupply', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'to', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'transferFrom', + inputs: [ + { name: 'from', type: 'address', internalType: 'address' }, + { name: 'to', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'event', + name: 'Approval', + inputs: [ + { name: 'owner', type: 'address', indexed: true, internalType: 'address' }, + { name: 'spender', type: 'address', indexed: true, internalType: 'address' }, + { name: 'value', type: 'uint256', indexed: false, internalType: 'uint256' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'CCIPAdminTransferred', + inputs: [ + { name: 'previousAdmin', type: 'address', indexed: true, internalType: 'address' }, + { name: 'newAdmin', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleAdminChanged', + inputs: [ + { name: 'role', type: 'bytes32', indexed: true, internalType: 'bytes32' }, + { name: 'previousAdminRole', type: 'bytes32', indexed: true, internalType: 'bytes32' }, + { name: 'newAdminRole', type: 'bytes32', indexed: true, internalType: 'bytes32' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleGranted', + inputs: [ + { name: 'role', type: 'bytes32', indexed: true, internalType: 'bytes32' }, + { name: 'account', type: 'address', indexed: true, internalType: 'address' }, + { name: 'sender', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'RoleRevoked', + inputs: [ + { name: 'role', type: 'bytes32', indexed: true, internalType: 'bytes32' }, + { name: 'account', type: 'address', indexed: true, internalType: 'address' }, + { name: 'sender', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Transfer', + inputs: [ + { name: 'from', type: 'address', indexed: true, internalType: 'address' }, + { name: 'to', type: 'address', indexed: true, internalType: 'address' }, + { name: 'value', type: 'uint256', indexed: false, internalType: 'uint256' }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'InvalidRecipient', + inputs: [{ name: 'recipient', type: 'address', internalType: 'address' }], + }, + { + type: 'error', + name: 'MaxSupplyExceeded', + inputs: [{ name: 'supplyAfterMint', type: 'uint256', internalType: 'uint256' }], + }, +] as const diff --git a/ccip-sdk/src/token-admin/evm/abi/FactoryBurnMintERC20.ts b/ccip-sdk/src/token-admin/evm/abi/FactoryBurnMintERC20.ts new file mode 100644 index 00000000..1e8fb0cd --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/abi/FactoryBurnMintERC20.ts @@ -0,0 +1,399 @@ +/** FactoryBurnMintERC20 ABI — auto-generated from chainlink-ccip gethwrapper. */ +export default [ + // generate: + // fetch('https://raw.githubusercontent.com/smartcontractkit/chainlink-ccip/release/contracts-ccip-1.6.2/chains/evm/gobindings/generated/v1_6_2/factory_burn_mint_erc20/factory_burn_mint_erc20.go') + // .then((res) => res.text()) + // .then((body) => body.match(/^\s*ABI: "(.*?)",$/m)?.[1]) + // .then((abi) => JSON.parse(abi.replace(/\\"/g, '"'))) + // .then((obj) => require('util').inspect(obj, {depth:99}).split('\n').slice(1, -1)) + { + type: 'constructor', + inputs: [ + { name: 'name', type: 'string', internalType: 'string' }, + { name: 'symbol', type: 'string', internalType: 'string' }, + { name: 'decimals_', type: 'uint8', internalType: 'uint8' }, + { name: 'maxSupply_', type: 'uint256', internalType: 'uint256' }, + { name: 'preMint', type: 'uint256', internalType: 'uint256' }, + { name: 'newOwner', type: 'address', internalType: 'address' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'acceptOwnership', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'allowance', + inputs: [ + { name: 'owner', type: 'address', internalType: 'address' }, + { name: 'spender', type: 'address', internalType: 'address' }, + ], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'approve', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'balanceOf', + inputs: [{ name: 'account', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'burn', + inputs: [{ name: 'amount', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'burn', + inputs: [ + { name: 'account', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'burnFrom', + inputs: [ + { name: 'account', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'decimals', + inputs: [], + outputs: [{ name: '', type: 'uint8', internalType: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'decreaseAllowance', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'subtractedValue', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'decreaseApproval', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'subtractedValue', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: 'success', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'getBurners', + inputs: [], + outputs: [{ name: '', type: 'address[]', internalType: 'address[]' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getCCIPAdmin', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'getMinters', + inputs: [], + outputs: [{ name: '', type: 'address[]', internalType: 'address[]' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'grantBurnRole', + inputs: [{ name: 'burner', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'grantMintAndBurnRoles', + inputs: [{ name: 'burnAndMinter', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'grantMintRole', + inputs: [{ name: 'minter', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'increaseAllowance', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'addedValue', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'increaseApproval', + inputs: [ + { name: 'spender', type: 'address', internalType: 'address' }, + { name: 'addedValue', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'isBurner', + inputs: [{ name: 'burner', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'isMinter', + inputs: [{ name: 'minter', type: 'address', internalType: 'address' }], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'maxSupply', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'mint', + inputs: [ + { name: 'account', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'name', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'owner', + inputs: [], + outputs: [{ name: '', type: 'address', internalType: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'revokeBurnRole', + inputs: [{ name: 'burner', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'revokeMintRole', + inputs: [{ name: 'minter', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'setCCIPAdmin', + inputs: [{ name: 'newAdmin', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'supportsInterface', + inputs: [{ name: 'interfaceId', type: 'bytes4', internalType: 'bytes4' }], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'pure', + }, + { + type: 'function', + name: 'symbol', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'totalSupply', + inputs: [], + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'to', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'transferFrom', + inputs: [ + { name: 'from', type: 'address', internalType: 'address' }, + { name: 'to', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'transferOwnership', + inputs: [{ name: 'to', type: 'address', internalType: 'address' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'typeAndVersion', + inputs: [], + outputs: [{ name: '', type: 'string', internalType: 'string' }], + stateMutability: 'pure', + }, + { + type: 'event', + name: 'Approval', + inputs: [ + { name: 'owner', type: 'address', indexed: true, internalType: 'address' }, + { name: 'spender', type: 'address', indexed: true, internalType: 'address' }, + { name: 'value', type: 'uint256', indexed: false, internalType: 'uint256' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'BurnAccessGranted', + inputs: [{ name: 'burner', type: 'address', indexed: true, internalType: 'address' }], + anonymous: false, + }, + { + type: 'event', + name: 'BurnAccessRevoked', + inputs: [{ name: 'burner', type: 'address', indexed: true, internalType: 'address' }], + anonymous: false, + }, + { + type: 'event', + name: 'CCIPAdminTransferred', + inputs: [ + { name: 'previousAdmin', type: 'address', indexed: true, internalType: 'address' }, + { name: 'newAdmin', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'MintAccessGranted', + inputs: [{ name: 'minter', type: 'address', indexed: true, internalType: 'address' }], + anonymous: false, + }, + { + type: 'event', + name: 'MintAccessRevoked', + inputs: [{ name: 'minter', type: 'address', indexed: true, internalType: 'address' }], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipTransferRequested', + inputs: [ + { name: 'from', type: 'address', indexed: true, internalType: 'address' }, + { name: 'to', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'OwnershipTransferred', + inputs: [ + { name: 'from', type: 'address', indexed: true, internalType: 'address' }, + { name: 'to', type: 'address', indexed: true, internalType: 'address' }, + ], + anonymous: false, + }, + { + type: 'event', + name: 'Transfer', + inputs: [ + { name: 'from', type: 'address', indexed: true, internalType: 'address' }, + { name: 'to', type: 'address', indexed: true, internalType: 'address' }, + { name: 'value', type: 'uint256', indexed: false, internalType: 'uint256' }, + ], + anonymous: false, + }, + { + type: 'error', + name: 'CannotTransferToSelf', + inputs: [], + }, + { + type: 'error', + name: 'MaxSupplyExceeded', + inputs: [{ name: 'supplyAfterMint', type: 'uint256', internalType: 'uint256' }], + }, + { + type: 'error', + name: 'MustBeProposedOwner', + inputs: [], + }, + { + type: 'error', + name: 'OnlyCallableByOwner', + inputs: [], + }, + { + type: 'error', + name: 'OwnerCannotBeZero', + inputs: [], + }, + { + type: 'error', + name: 'SenderNotBurner', + inputs: [{ name: 'sender', type: 'address', internalType: 'address' }], + }, + { + type: 'error', + name: 'SenderNotMinter', + inputs: [{ name: 'sender', type: 'address', internalType: 'address' }], + }, +] as const diff --git a/ccip-sdk/src/token-admin/evm/bytecodes/BurnMintERC20.ts b/ccip-sdk/src/token-admin/evm/bytecodes/BurnMintERC20.ts new file mode 100644 index 00000000..8018c106 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/bytecodes/BurnMintERC20.ts @@ -0,0 +1,3 @@ +/** BurnMintERC20 deployment bytecode. Lazy-loaded via dynamic import(). */ +export const BURN_MINT_ERC20_BYTECODE = + '0x60c060405234801562000010575f80fd5b5060405162001cbe38038062001cbe8339810160408190526200003391620002dd565b84846003620000438382620003f5565b506004620000528282620003f5565b50505060ff831660805260a0829052600680546001600160a01b03191633179055801562000086576200008633826200009d565b620000925f3362000162565b5050505050620004e1565b6001600160a01b038216620000f85760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f206164647265737300604482015260640160405180910390fd5b8060025f8282546200010b9190620004c1565b90915550506001600160a01b0382165f81815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35b5050565b6200016e8282620001f0565b6200015e575f8281526005602090815260408083206001600160a01b03851684529091529020805460ff19166001179055620001a73390565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b505050565b5f8281526005602090815260408083206001600160a01b038516845290915290205460ff165b92915050565b634e487b7160e01b5f52604160045260245ffd5b5f82601f83011262000240575f80fd5b81516001600160401b03808211156200025d576200025d6200021c565b604051601f8301601f19908116603f011681019082821181831017156200028857620002886200021c565b8160405283815260209250866020858801011115620002a5575f80fd5b5f91505b83821015620002c85785820183015181830184015290820190620002a9565b5f602085830101528094505050505092915050565b5f805f805f60a08688031215620002f2575f80fd5b85516001600160401b038082111562000309575f80fd5b6200031789838a0162000230565b965060208801519150808211156200032d575f80fd5b506200033c8882890162000230565b945050604086015160ff8116811462000353575f80fd5b6060870151608090970151959894975095949392505050565b600181811c908216806200038157607f821691505b602082108103620003a057634e487b7160e01b5f52602260045260245ffd5b50919050565b601f821115620001eb57805f5260205f20601f840160051c81016020851015620003cd5750805b601f840160051c820191505b81811015620003ee575f8155600101620003d9565b5050505050565b81516001600160401b038111156200041157620004116200021c565b62000429816200042284546200036c565b84620003a6565b602080601f8311600181146200045f575f8415620004475750858301515b5f19600386901b1c1916600185901b178555620004b9565b5f85815260208120601f198616915b828110156200048f578886015182559484019460019091019084016200046e565b5085821015620004ad57878501515f19600388901b60f8161c191681555b505060018460011b0185555b505050505050565b808201808211156200021657634e487b7160e01b5f52601160045260245ffd5b60805160a0516117ad620005115f395f8181610447015281816107c901526107f301525f61029901526117ad5ff3fe608060405234801561000f575f80fd5b50600436106101bb575f3560e01c806379cc6790116100f3578063a8fa343c11610093578063d53913931161006e578063d53913931461040b578063d547741f14610432578063d5abeb0114610445578063dd62ed3e1461046b575f80fd5b8063a8fa343c146103d2578063a9059cbb146103e5578063c630948d146103f8575f80fd5b806395d89b41116100ce57806395d89b411461039d5780639dc29fac146103a5578063a217fddf146103b8578063a457c2d7146103bf575f80fd5b806379cc6790146103375780638fd6a6ac1461034a57806391d1485414610365575f80fd5b80632f2ff15d1161015e578063395093511161013957806339509351146102d657806340c10f19146102e957806342966c68146102fc57806370a082311461030f575f80fd5b80632f2ff15d1461027d578063313ce5671461029257806336568abe146102c3575f80fd5b806318160ddd1161019957806318160ddd1461020f57806323b872dd14610221578063248a9ca314610234578063282c51f314610256575f80fd5b806301ffc9a7146101bf57806306fdde03146101e7578063095ea7b3146101fc575b5f80fd5b6101d26101cd3660046114cb565b6104a3565b60405190151581526020015b60405180910390f35b6101ef6105a7565b6040516101de9190611514565b6101d261020a366004611561565b610637565b6002545b6040519081526020016101de565b6101d261022f366004611589565b61064e565b6102136102423660046115c2565b5f9081526005602052604090206001015490565b6102137f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a84881565b61029061028b3660046115d9565b610671565b005b60405160ff7f00000000000000000000000000000000000000000000000000000000000000001681526020016101de565b6102906102d13660046115d9565b61069a565b6101d26102e4366004611561565b61072b565b6102906102f7366004611561565b610769565b61029061030a3660046115c2565b610880565b61021361031d366004611603565b6001600160a01b03165f9081526020819052604090205490565b610290610345366004611561565b6108b3565b6006546040516001600160a01b0390911681526020016101de565b6101d26103733660046115d9565b5f9182526005602090815260408084206001600160a01b0393909316845291905290205460ff1690565b6101ef6108e7565b6102906103b3366004611561565b6108f6565b6102135f81565b6101d26103cd366004611561565b610900565b6102906103e0366004611603565b6109a9565b6101d26103f3366004611561565b610a1d565b610290610406366004611603565b610a2a565b6102137f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a681565b6102906104403660046115d9565b610a81565b7f0000000000000000000000000000000000000000000000000000000000000000610213565b61021361047936600461161c565b6001600160a01b039182165f90815260016020908152604080832093909416825291909152205490565b5f6001600160e01b031982167f36372b0700000000000000000000000000000000000000000000000000000000148061050557506001600160e01b031982167fe6599b4d00000000000000000000000000000000000000000000000000000000145b8061053957506001600160e01b031982167f01ffc9a700000000000000000000000000000000000000000000000000000000145b8061056d57506001600160e01b031982167f7965db0b00000000000000000000000000000000000000000000000000000000145b806105a157506001600160e01b031982167f8fd6a6ac00000000000000000000000000000000000000000000000000000000145b92915050565b6060600380546105b690611644565b80601f01602080910402602001604051908101604052809291908181526020018280546105e290611644565b801561062d5780601f106106045761010080835404028352916020019161062d565b820191905f5260205f20905b81548152906001019060200180831161061057829003601f168201915b5050505050905090565b5f33610644818585610aa5565b5060019392505050565b5f3361065b858285610ae4565b610666858585610b74565b506001949350505050565b5f8281526005602052604090206001015461068b81610bb3565b6106958383610bbd565b505050565b6001600160a01b038116331461071d5760405162461bcd60e51b815260206004820152602f60248201527f416363657373436f6e74726f6c3a2063616e206f6e6c792072656e6f756e636560448201527f20726f6c657320666f722073656c66000000000000000000000000000000000060648201526084015b60405180910390fd5b6107278282610c5d565b5050565b335f8181526001602090815260408083206001600160a01b03871684529091528120549091906106449082908690610764908790611690565b610aa5565b7f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a661079381610bb3565b306001600160a01b038416036107c757604051630bc2c5df60e11b81526001600160a01b0384166004820152602401610714565b7f00000000000000000000000000000000000000000000000000000000000000001580159061082857507f00000000000000000000000000000000000000000000000000000000000000008261081c60025490565b6108269190611690565b115b15610876578161083760025490565b6108419190611690565b6040517fcbbf111300000000000000000000000000000000000000000000000000000000815260040161071491815260200190565b6106958383610cde565b7f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a8486108aa81610bb3565b61072782610d9b565b7f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a8486108dd81610bb3565b6106958383610da5565b6060600480546105b690611644565b61072782826108b3565b335f8181526001602090815260408083206001600160a01b03871684529091528120549091908381101561099c5760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f7760448201527f207a65726f0000000000000000000000000000000000000000000000000000006064820152608401610714565b6106668286868403610aa5565b5f6109b381610bb3565b600680546001600160a01b038481167fffffffffffffffffffffffff0000000000000000000000000000000000000000831681179093556040519116919082907f9524c9e4b0b61eb018dd58a1cd856e3e74009528328ab4a613b434fa631d7242905f90a3505050565b5f33610644818585610b74565b610a547f9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a682610671565b610a7e7f3c11d16cbaffd01df69ce1c404f6340ee057498f5f00246190ea54220576a84882610671565b50565b5f82815260056020526040902060010154610a9b81610bb3565b6106958383610c5d565b306001600160a01b03831603610ad957604051630bc2c5df60e11b81526001600160a01b0383166004820152602401610714565b610695838383610dba565b6001600160a01b038381165f908152600160209081526040808320938616835292905220545f198114610b6e5781811015610b615760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e63650000006044820152606401610714565b610b6e8484848403610aa5565b50505050565b306001600160a01b03831603610ba857604051630bc2c5df60e11b81526001600160a01b0383166004820152602401610714565b610695838383610f11565b610a7e81336110fc565b5f8281526005602090815260408083206001600160a01b038516845290915290205460ff16610727575f8281526005602090815260408083206001600160a01b03851684529091529020805460ff19166001179055610c193390565b6001600160a01b0316816001600160a01b0316837f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d60405160405180910390a45050565b5f8281526005602090815260408083206001600160a01b038516845290915290205460ff1615610727575f8281526005602090815260408083206001600160a01b0385168085529252808320805460ff1916905551339285917ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b9190a45050565b6001600160a01b038216610d345760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f2061646472657373006044820152606401610714565b8060025f828254610d459190611690565b90915550506001600160a01b0382165f81815260208181526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b610a7e3382611170565b610db0823383610ae4565b6107278282611170565b6001600160a01b038316610e355760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f2061646460448201527f72657373000000000000000000000000000000000000000000000000000000006064820152608401610714565b6001600160a01b038216610eb15760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f20616464726560448201527f73730000000000000000000000000000000000000000000000000000000000006064820152608401610714565b6001600160a01b038381165f8181526001602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925910160405180910390a3505050565b6001600160a01b038316610f8d5760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f20616460448201527f64726573730000000000000000000000000000000000000000000000000000006064820152608401610714565b6001600160a01b0382166110095760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201527f65737300000000000000000000000000000000000000000000000000000000006064820152608401610714565b6001600160a01b0383165f90815260208190526040902054818110156110975760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e742065786365656473206260448201527f616c616e636500000000000000000000000000000000000000000000000000006064820152608401610714565b6001600160a01b038481165f81815260208181526040808320878703905593871680835291849020805487019055925185815290927fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a3610b6e565b5f8281526005602090815260408083206001600160a01b038516845290915290205460ff166107275761112e816112d7565b6111398360206112e9565b60405160200161114a9291906116a3565b60408051601f198184030181529082905262461bcd60e51b825261071491600401611514565b6001600160a01b0382166111ec5760405162461bcd60e51b815260206004820152602160248201527f45524332303a206275726e2066726f6d20746865207a65726f2061646472657360448201527f73000000000000000000000000000000000000000000000000000000000000006064820152608401610714565b6001600160a01b0382165f908152602081905260409020548181101561127a5760405162461bcd60e51b815260206004820152602260248201527f45524332303a206275726e20616d6f756e7420657863656564732062616c616e60448201527f63650000000000000000000000000000000000000000000000000000000000006064820152608401610714565b6001600160a01b0383165f818152602081815260408083208686039055600280548790039055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a3505050565b60606105a16001600160a01b03831660145b60605f6112f7836002611723565b611302906002611690565b67ffffffffffffffff81111561131a5761131a61173a565b6040519080825280601f01601f191660200182016040528015611344576020820181803683370190505b5090507f3000000000000000000000000000000000000000000000000000000000000000815f8151811061137a5761137a61174e565b60200101906001600160f81b03191690815f1a9053507f7800000000000000000000000000000000000000000000000000000000000000816001815181106113c4576113c461174e565b60200101906001600160f81b03191690815f1a9053505f6113e6846002611723565b6113f1906001611690565b90505b6001811115611475577f303132333435363738396162636465660000000000000000000000000000000085600f16601081106114325761143261174e565b1a60f81b8282815181106114485761144861174e565b60200101906001600160f81b03191690815f1a90535060049490941c9361146e81611762565b90506113f4565b5083156114c45760405162461bcd60e51b815260206004820181905260248201527f537472696e67733a20686578206c656e67746820696e73756666696369656e746044820152606401610714565b9392505050565b5f602082840312156114db575f80fd5b81356001600160e01b0319811681146114c4575f80fd5b5f5b8381101561150c5781810151838201526020016114f4565b50505f910152565b602081525f82518060208401526115328160408501602087016114f2565b601f01601f19169190910160400192915050565b80356001600160a01b038116811461155c575f80fd5b919050565b5f8060408385031215611572575f80fd5b61157b83611546565b946020939093013593505050565b5f805f6060848603121561159b575f80fd5b6115a484611546565b92506115b260208501611546565b9150604084013590509250925092565b5f602082840312156115d2575f80fd5b5035919050565b5f80604083850312156115ea575f80fd5b823591506115fa60208401611546565b90509250929050565b5f60208284031215611613575f80fd5b6114c482611546565b5f806040838503121561162d575f80fd5b61163683611546565b91506115fa60208401611546565b600181811c9082168061165857607f821691505b60208210810361167657634e487b7160e01b5f52602260045260245ffd5b50919050565b634e487b7160e01b5f52601160045260245ffd5b808201808211156105a1576105a161167c565b7f416363657373436f6e74726f6c3a206163636f756e742000000000000000000081525f83516116da8160178501602088016114f2565b7f206973206d697373696e6720726f6c652000000000000000000000000000000060179184019182015283516117178160288401602088016114f2565b01602801949350505050565b80820281158282048414176105a1576105a161167c565b634e487b7160e01b5f52604160045260245ffd5b634e487b7160e01b5f52603260045260245ffd5b5f816117705761177061167c565b505f19019056fea26469706673582212203816aec2b27e70d8411644f3cee3620158f6231cf3932b3b1330c090a442598364736f6c63430008180033' diff --git a/ccip-sdk/src/token-admin/evm/bytecodes/BurnMintTokenPool.ts b/ccip-sdk/src/token-admin/evm/bytecodes/BurnMintTokenPool.ts new file mode 100644 index 00000000..a845577f --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/bytecodes/BurnMintTokenPool.ts @@ -0,0 +1,3 @@ +/** BurnMintTokenPool (v1.6.1) deployment bytecode. Lazy-loaded via dynamic import(). */ +export const BURN_MINT_TOKEN_POOL_BYTECODE = + '0x61010080604052346103635761497e803803809161001d82856103e2565b833981019060a0818303126103635780516001600160a01b038116908190036103635761004c60208301610405565b60408301519091906001600160401b0381116103635783019380601f86011215610363578451946001600160401b0386116103cc578560051b90602082019661009860405198896103e2565b875260208088019282010192831161036357602001905b8282106103b4575050506100d160806100ca60608601610413565b9401610413565b9233156103a357600180546001600160a01b0319163317905581158015610392575b8015610381575b610370578160209160049360805260c0526040519283809263313ce56760e01b82525afa6000918161032f575b50610304575b5060a052600480546001600160a01b0319166001600160a01b03929092169190911790558051151560e08190526101e6575b6040516143b690816105c882396080518181816115fc015281816117ec015281816122ed015281816124bd0152818161281a0152612892015260a051818181611907015281816127a10152818161325801526132db015260c051818181610bd5015281816116980152612389015260e051818181610b65015281816116db015261206c0152f35b60405160206101f581836103e2565b60008252600036813760e051156102f35760005b8251811015610270576001906001600160a01b036102278286610427565b51168361023382610469565b610240575b505001610209565b7f800671136ab6cfee9fbe5ed1fb7ca417811aca3cf864800d127b927adedf756691604051908152a13883610238565b50905060005b82518110156102ea576001906001600160a01b036102948286610427565b511680156102e457836102a682610567565b6102b4575b50505b01610276565b7f2640d4d76caf8bf478aabfa982fa4e1c4eb71a37f93cd15e80dbc657911546d891604051908152a138836102ab565b506102ae565b5050503861015f565b6335f4a7b360e01b60005260046000fd5b60ff1660ff8216818103610318575061012d565b6332ad3e0760e11b60005260045260245260446000fd5b9091506020813d602011610368575b8161034b602093836103e2565b810103126103635761035c90610405565b9038610127565b600080fd5b3d915061033e565b6342bcdf7f60e11b60005260046000fd5b506001600160a01b038116156100fa565b506001600160a01b038416156100f3565b639b15e16f60e01b60005260046000fd5b602080916103c184610413565b8152019101906100af565b634e487b7160e01b600052604160045260246000fd5b601f909101601f19168101906001600160401b038211908210176103cc57604052565b519060ff8216820361036357565b51906001600160a01b038216820361036357565b805182101561043b5760209160051b010190565b634e487b7160e01b600052603260045260246000fd5b805482101561043b5760005260206000200190600090565b600081815260036020526040902054801561056057600019810181811161054a5760025460001981019190821161054a578181036104f9575b50505060025480156104e357600019016104bd816002610451565b8154906000199060031b1b19169055600255600052600360205260006040812055600190565b634e487b7160e01b600052603160045260246000fd5b61053261050a61051b936002610451565b90549060031b1c9283926002610451565b819391549060031b91821b91600019901b19161790565b905560005260036020526040600020553880806104a2565b634e487b7160e01b600052601160045260246000fd5b5050600090565b806000526003602052604060002054156000146105c157600254680100000000000000008110156103cc576105a861051b8260018594016002556002610451565b9055600254906000526003602052604060002055600190565b5060009056fe608080604052600436101561001357600080fd5b600090813560e01c90816301ffc9a71461293557508063181f5a77146128b657806321df0da714612847578063240028e8146127c557806324f65ee714612769578063390775371461221a5780634c5ef0ed146121b557806354c8a4f31461203857806362ddd3c414611fb45780636d3d1a5814611f6257806379ba509714611e7d5780637d54534e14611dd05780638926f54f14611d6c5780638da5cb5b14611d1a578063962d402014611b765780639a4575b914611554578063a42a7b8b146113cf578063a7cd63b714611303578063acfecf91146111df578063af58d59f14611178578063b0f479a114611126578063b7946580146110cf578063c0d7865514610fd7578063c4bffe2b14610e8e578063c75eea9c14610dc8578063cf7401f314610bf9578063dc0bd97114610b8a578063e0351e1314610b2f578063e8a1da171461025a5763f2fde38b1461016b57600080fd5b346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575773ffffffffffffffffffffffffffffffffffffffff6101b7612b63565b6101bf6133fd565b1633811461022f57807fffffffffffffffffffffffff000000000000000000000000000000000000000083541617825573ffffffffffffffffffffffffffffffffffffffff600154167fed8889f560326eb138920d842192f0eb3dd22b4f139c87a2c57538e05bae12788380a380f35b6004827fdad89dca000000000000000000000000000000000000000000000000000000008152fd5b80fd5b50346102575761026936612c51565b939190926102756133fd565b82915b80831061099a575050508063ffffffff4216917ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee1843603015b85821015610996578160051b85013581811215610992578501906101208236031261099257604051956102e387612a8b565b823567ffffffffffffffff8116810361098d578752602083013567ffffffffffffffff81116109895783019536601f880112156109895786359661032688612ea2565b97610334604051998a612ac3565b8089526020808a019160051b830101903682116109855760208301905b828210610952575050505060208801968752604084013567ffffffffffffffff811161094e576103849036908601612c02565b9860408901998a526103ae61039c3660608801612d92565b9560608b0196875260c0369101612d92565b9660808a019788526103c08651613874565b6103ca8851613874565b8a515115610926576103e667ffffffffffffffff8b51166140b3565b156108ef5767ffffffffffffffff8a5116815260076020526040812061052687516fffffffffffffffffffffffffffffffff604082015116906104e16fffffffffffffffffffffffffffffffff6020830151169151151583608060405161044c81612a8b565b858152602081018c905260408101849052606081018690520152855474ff000000000000000000000000000000000000000091151560a01b919091167fffffffffffffffffffffff0000000000000000000000000000000000000000009091166fffffffffffffffffffffffffffffffff84161773ffffffff0000000000000000000000000000000060808b901b1617178555565b60809190911b7fffffffffffffffffffffffffffffffff00000000000000000000000000000000166fffffffffffffffffffffffffffffffff91909116176001830155565b61064c89516fffffffffffffffffffffffffffffffff604082015116906106076fffffffffffffffffffffffffffffffff6020830151169151151583608060405161057081612a8b565b858152602081018c9052604081018490526060810186905201526002860180547fffffffffffffffffffffff000000000000000000000000000000000000000000166fffffffffffffffffffffffffffffffff85161773ffffffff0000000000000000000000000000000060808c901b161791151560a01b74ff000000000000000000000000000000000000000016919091179055565b60809190911b7fffffffffffffffffffffffffffffffff00000000000000000000000000000000166fffffffffffffffffffffffffffffffff91909116176003830155565b60048c5191019080519067ffffffffffffffff82116108c25761066f8354612f85565b601f8111610887575b50602090601f83116001146107e8576106c692918591836107dd575b50507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8260011b9260031b1c19161790565b90555b805b8951805182101561070157906106fb6001926106f4838f67ffffffffffffffff90511692612f71565b5190613448565b016106cb565b5050975097987f8d340f17e19058004c20453540862a9c62778504476f6756755cb33bcd6c38c2929593966107cf67ffffffffffffffff600197949c511692519351915161079b61076660405196879687526101006020880152610100870190612b04565b9360408601906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b60a08401906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b0390a10190939492916102b1565b015190503880610694565b83855281852091907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08416865b81811061086f5750908460019594939210610838575b505050811b0190556106c9565b01517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60f88460031b161c1916905538808061082b565b92936020600181928786015181550195019301610815565b6108b29084865260208620601f850160051c810191602086106108b8575b601f0160051c019061318c565b38610678565b90915081906108a5565b6024847f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b60249067ffffffffffffffff8b51167f1d5ad3c5000000000000000000000000000000000000000000000000000000008252600452fd5b807f8579befe0000000000000000000000000000000000000000000000000000000060049252fd5b8680fd5b813567ffffffffffffffff8111610981576020916109768392833691890101612c02565b815201910190610351565b8a80fd5b8880fd5b8580fd5b600080fd5b8380fd5b8280f35b9092919367ffffffffffffffff6109ba6109b5878588612f22565b612e50565b16956109c587613de7565b15610b035786845260076020526109e160056040862001613bee565b94845b8651811015610a1a576001908987526007602052610a1360056040892001610a0c838b612f71565b5190613f12565b50016109e4565b5093945094909580855260076020526005604086208681558660018201558660028201558660038201558660048201610a538154612f85565b80610ac2575b5050500180549086815581610aa4575b5050907f5204aec90a3c794d8e90fded8b46ae9c7c552803e7e832e0c1d358396d8599166020600193604051908152a1019190949394610278565b865260208620908101905b81811015610a6957868155600101610aaf565b601f8111600114610ad85750555b863880610a59565b81835260208320610af391601f01861c81019060010161318c565b8082528160208120915555610ad0565b602484887f1e670e4b000000000000000000000000000000000000000000000000000000008252600452fd5b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575760206040517f000000000000000000000000000000000000000000000000000000000000000015158152f35b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757602060405173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b50346102575760e07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757610c31612b86565b9060607fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc36011261025757604051610c6881612aa7565b6024358015158103610dc45781526044356fffffffffffffffffffffffffffffffff81168103610dc45760208201526064356fffffffffffffffffffffffffffffffff81168103610dc457604082015260607fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c360112610dc05760405190610cef82612aa7565b608435801515810361099257825260a4356fffffffffffffffffffffffffffffffff8116810361099257602083015260c4356fffffffffffffffffffffffffffffffff8116810361099257604083015273ffffffffffffffffffffffffffffffffffffffff6009541633141580610d9e575b610d7257610d6f92936136b2565b80f35b6024837f8e4a23d600000000000000000000000000000000000000000000000000000000815233600452fd5b5073ffffffffffffffffffffffffffffffffffffffff60015416331415610d61565b5080fd5b8280fd5b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757610e31610e2c6040610e8a9367ffffffffffffffff610e15612b86565b610e1d6130d9565b50168152600760205220613104565b6137ef565b6040519182918291909160806fffffffffffffffffffffffffffffffff8160a084019582815116855263ffffffff6020820151166020860152604081015115156040860152826060820151166060860152015116910152565b0390f35b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757604051906005548083528260208101600584526020842092845b818110610fbe575050610eec92500383612ac3565b8151610f10610efa82612ea2565b91610f086040519384612ac3565b808352612ea2565b917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0602083019301368437805b8451811015610f6f578067ffffffffffffffff610f5c60019388612f71565b5116610f688286612f71565b5201610f3d565b50925090604051928392602084019060208552518091526040840192915b818110610f9b575050500390f35b825167ffffffffffffffff16845285945060209384019390920191600101610f8d565b8454835260019485019487945060209093019201610ed7565b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575773ffffffffffffffffffffffffffffffffffffffff611024612b63565b61102c6133fd565b1680156110a75760407f02dc5c233404867c793b749c6d644beb2277536d18a7e7974d3f238e4c6f16849160045490807fffffffffffffffffffffffff000000000000000000000000000000000000000083161760045573ffffffffffffffffffffffffffffffffffffffff8351921682526020820152a180f35b6004827f8579befe000000000000000000000000000000000000000000000000000000008152fd5b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757610e8a61111261110d612b86565b61316a565b604051918291602083526020830190612b04565b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757602073ffffffffffffffffffffffffffffffffffffffff60045416604051908152f35b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757610e31610e2c60026040610e8a9467ffffffffffffffff6111c7612b86565b6111cf6130d9565b5016815260076020522001613104565b50346102575767ffffffffffffffff6111f736612cc1565b9290916112026133fd565b169161121b836000526006602052604060002054151590565b156112d757828452600760205261124a6005604086200161123d368486612b9d565b6020815191012090613f12565b1561128f57907f52d00ee4d9bd51b40168f2afc5848837288ce258784ad914278791464b3f4d769161128960405192839260208452602084019161309a565b0390a280f35b826112d3836040519384937f74f23c7c000000000000000000000000000000000000000000000000000000008552600485015260406024850152604484019161309a565b0390fd5b602484847f1e670e4b000000000000000000000000000000000000000000000000000000008252600452fd5b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757604051600254808252602082018091600285526020852090855b8181106113b95750505082611362910383612ac3565b604051928392602084019060208552518091526040840192915b81811061138a575050500390f35b825173ffffffffffffffffffffffffffffffffffffffff1684528594506020938401939092019160010161137c565b825484526020909301926001928301920161134c565b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575767ffffffffffffffff611410612b86565b168152600760205261142760056040832001613bee565b80517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe061146c61145683612ea2565b926114646040519485612ac3565b808452612ea2565b01835b818110611543575050825b82518110156114c0578061149060019285612f71565b51855260086020526114a460408620612fd8565b6114ae8285612f71565b526114b98184612f71565b500161147a565b81846040519182916020830160208452825180915260408401602060408360051b870101940192905b8282106114f857505050500390f35b91936020611533827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc060019597998495030186528851612b04565b96019201920185949391926114e9565b80606060208093860101520161146f565b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575760043567ffffffffffffffff8111610dc05760a07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8236030112610dc057606060206040516115d281612a6f565b8281520152608481016115e481612e2f565b73ffffffffffffffffffffffffffffffffffffffff807f000000000000000000000000000000000000000000000000000000000000000016911603611b2c5750602481019077ffffffffffffffff0000000000000000000000000000000061164b83612e50565b60801b16604051907f2cbc26bb000000000000000000000000000000000000000000000000000000008252600482015260208160248173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000165afa908115611a4d578491611afd575b50611ad5576116d960448201612e2f565b7f0000000000000000000000000000000000000000000000000000000000000000611a83575b5067ffffffffffffffff61171283612e50565b1661172a816000526006602052604060002054151590565b15611a5857602073ffffffffffffffffffffffffffffffffffffffff60045416916024604051809481937fa8d87a3b00000000000000000000000000000000000000000000000000000000835260048301525afa8015611a4d5784906119ea575b73ffffffffffffffffffffffffffffffffffffffff91501633036119be57819260646117bf67ffffffffffffffff94612e50565b9201359283921680825260076020526118146040832073ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016948591614162565b6040805173ffffffffffffffffffffffffffffffffffffffff85168152602081018690527fff0133389f9bb82d5b9385826160eaf2328039f6fa950eeb8cf0836da81789449190a2813b15610257576040517f42966c68000000000000000000000000000000000000000000000000000000008152836004820152818160248183875af180156119b35761199e575b61196d6118fd61110d87877ff33bc26b4413b0e7f19f1ea739fdf99098c0061f1f87d954b11f5293fad9ae1060608967ffffffffffffffff6118e486612e50565b16936040519182523360208301526040820152a2612e50565b610e8a60405160ff7f00000000000000000000000000000000000000000000000000000000000000001660208201526020815261193b604082612ac3565b6040519261194884612a6f565b8352602083019081526040519384936020855251604060208601526060850190612b04565b90517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0848303016040850152612b04565b6119a9828092612ac3565b61025757806118a3565b6040513d84823e3d90fd5b6024837f728fe07b00000000000000000000000000000000000000000000000000000000815233600452fd5b506020813d602011611a45575b81611a0460209383612ac3565b81010312610992575173ffffffffffffffffffffffffffffffffffffffff811681036109925773ffffffffffffffffffffffffffffffffffffffff9061178b565b3d91506119f7565b6040513d86823e3d90fd5b7fa9902c7e000000000000000000000000000000000000000000000000000000008452600452602483fd5b73ffffffffffffffffffffffffffffffffffffffff16808452600360205260408420546116ff577fd0d25976000000000000000000000000000000000000000000000000000000008452600452602483fd5b6004837f53ad11d8000000000000000000000000000000000000000000000000000000008152fd5b611b1f915060203d602011611b25575b611b178183612ac3565b8101906133e5565b386116c8565b503d611b0d565b8273ffffffffffffffffffffffffffffffffffffffff611b4d602493612e2f565b7f961c9a4f00000000000000000000000000000000000000000000000000000000835216600452fd5b50346102575760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575760043567ffffffffffffffff8111610dc057611bc6903690600401612c20565b60243567ffffffffffffffff811161099257611be6903690600401612d44565b60449291923567ffffffffffffffff811161098957611c09903690600401612d44565b91909273ffffffffffffffffffffffffffffffffffffffff6009541633141580611cf8575b611ccc57818114801590611cc2575b611c9a57865b818110611c4e578780f35b80611c94611c626109b5600194868c612f22565b611c6d83878b612f61565b611c8e611c86611c7e868b8d612f61565b923690612d92565b913690612d92565b916136b2565b01611c43565b6004877f568efce2000000000000000000000000000000000000000000000000000000008152fd5b5082811415611c3d565b6024877f8e4a23d600000000000000000000000000000000000000000000000000000000815233600452fd5b5073ffffffffffffffffffffffffffffffffffffffff60015416331415611c2e565b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757602073ffffffffffffffffffffffffffffffffffffffff60015416604051908152f35b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610257576020611dc667ffffffffffffffff611db2612b86565b166000526006602052604060002054151590565b6040519015158152f35b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610257577f44676b5284b809a22248eba0da87391d79098be38bb03154be88a58bf4d09174602073ffffffffffffffffffffffffffffffffffffffff611e40612b63565b611e486133fd565b16807fffffffffffffffffffffffff00000000000000000000000000000000000000006009541617600955604051908152a180f35b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757805473ffffffffffffffffffffffffffffffffffffffff81163303611f3a577fffffffffffffffffffffffff000000000000000000000000000000000000000060015491338284161760015516825573ffffffffffffffffffffffffffffffffffffffff3391167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e08380a380f35b6004827f02b543c6000000000000000000000000000000000000000000000000000000008152fd5b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757602073ffffffffffffffffffffffffffffffffffffffff60095416604051908152f35b503461025757611fc336612cc1565b611fcf939291936133fd565b67ffffffffffffffff8216611ff1816000526006602052604060002054151590565b1561200d5750610d6f9293612007913691612b9d565b90613448565b7f1e670e4b000000000000000000000000000000000000000000000000000000008452600452602483fd5b5034610257576120629061206a61204e36612c51565b959161205b9391936133fd565b3691612eba565b933691612eba565b7f00000000000000000000000000000000000000000000000000000000000000001561218d57815b8351811015612105578073ffffffffffffffffffffffffffffffffffffffff6120bd60019387612f71565b51166120c881613c51565b6120d4575b5001612092565b60207f800671136ab6cfee9fbe5ed1fb7ca417811aca3cf864800d127b927adedf756691604051908152a1386120cd565b5090805b8251811015612189578073ffffffffffffffffffffffffffffffffffffffff61213460019386612f71565b511680156121835761214581614053565b612152575b505b01612109565b60207f2640d4d76caf8bf478aabfa982fa4e1c4eb71a37f93cd15e80dbc657911546d891604051908152a18461214a565b5061214c565b5080f35b6004827f35f4a7b3000000000000000000000000000000000000000000000000000000008152fd5b50346102575760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610257576121ed612b86565b906024359067ffffffffffffffff8211610257576020611dc6846122143660048701612c02565b90612e65565b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575760043567ffffffffffffffff8111610dc057806004016101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8336030112610dc4578260405161229a81612a24565b526122c76122bd6122b86122b160c4860185612dde565b3691612b9d565b6131e5565b60648401356132d8565b91608481016122d581612e2f565b73ffffffffffffffffffffffffffffffffffffffff807f0000000000000000000000000000000000000000000000000000000000000000169116036127485750602481019177ffffffffffffffff0000000000000000000000000000000061233c84612e50565b60801b16604051907f2cbc26bb000000000000000000000000000000000000000000000000000000008252600482015260208160248173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000165afa908115612639578691612729575b506127015767ffffffffffffffff6123d084612e50565b166123e8816000526006602052604060002054151590565b156126d657602073ffffffffffffffffffffffffffffffffffffffff60045416916044604051809481937f83826b2b00000000000000000000000000000000000000000000000000000000835260048301523360248301525afa9081156126395786916126b7575b501561268b5761245f83612e50565b9061247560a48401926122146122b18585612dde565b15612644575050906044839267ffffffffffffffff61249384612e50565b1680875260076020526124e56002604089200173ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016968791614162565b6040805173ffffffffffffffffffffffffffffffffffffffff87168152602081018890527f50f6fbee3ceedce6b7fd7eaef18244487867e6718aec7208187efb6b7908c14c9190a2019061253882612e2f565b85843b15610257576040517f40c10f1900000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff929092166004830152602482018690528160448183885af18015612639579273ffffffffffffffffffffffffffffffffffffffff6125f96125f360809560209a67ffffffffffffffff967ffc5e3a5bddc11d92c2dc20fae6f7d5eb989f056be35239f7de7e86150609abc099612629575b5050612e50565b92612e2f565b60405196875233898801521660408601528560608601521692a28060405161262081612a24565b52604051908152f35b8161263391612ac3565b386125ec565b6040513d88823e3d90fd5b61264e9250612dde565b6112d36040519283927f24eb47e500000000000000000000000000000000000000000000000000000000845260206004850152602484019161309a565b6024857f728fe07b00000000000000000000000000000000000000000000000000000000815233600452fd5b6126d0915060203d602011611b2557611b178183612ac3565b38612450565b7fa9902c7e000000000000000000000000000000000000000000000000000000008652600452602485fd5b6004857f53ad11d8000000000000000000000000000000000000000000000000000000008152fd5b612742915060203d602011611b2557611b178183612ac3565b386123b9565b8473ffffffffffffffffffffffffffffffffffffffff611b4d602493612e2f565b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757602060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b50346102575760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757602090612800612b63565b905073ffffffffffffffffffffffffffffffffffffffff807f0000000000000000000000000000000000000000000000000000000000000000169116146040519015158152f35b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261025757602060405173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b503461025757807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102575750610e8a6040516128f7604082612ac3565b601781527f4275726e4d696e74546f6b656e506f6f6c20312e362e310000000000000000006020820152604051918291602083526020830190612b04565b905034610dc05760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610dc0576004357fffffffff000000000000000000000000000000000000000000000000000000008116809103610dc457602092507faff2afbf0000000000000000000000000000000000000000000000000000000081149081156129fa575b81156129d0575b5015158152f35b7f01ffc9a700000000000000000000000000000000000000000000000000000000915014386129c9565b7f0e64dd2900000000000000000000000000000000000000000000000000000000811491506129c2565b6020810190811067ffffffffffffffff821117612a4057604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6040810190811067ffffffffffffffff821117612a4057604052565b60a0810190811067ffffffffffffffff821117612a4057604052565b6060810190811067ffffffffffffffff821117612a4057604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117612a4057604052565b919082519283825260005b848110612b4e5750507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8460006020809697860101520116010190565b80602080928401015182828601015201612b0f565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361098d57565b6004359067ffffffffffffffff8216820361098d57565b92919267ffffffffffffffff8211612a405760405191612be5601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200184612ac3565b82948184528183011161098d578281602093846000960137010152565b9080601f8301121561098d57816020612c1d93359101612b9d565b90565b9181601f8401121561098d5782359167ffffffffffffffff831161098d576020808501948460051b01011161098d57565b60407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc82011261098d5760043567ffffffffffffffff811161098d5781612c9a91600401612c20565b929092916024359067ffffffffffffffff821161098d57612cbd91600401612c20565b9091565b60407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc82011261098d5760043567ffffffffffffffff8116810361098d579160243567ffffffffffffffff811161098d578260238201121561098d5780600401359267ffffffffffffffff841161098d576024848301011161098d576024019190565b9181601f8401121561098d5782359167ffffffffffffffff831161098d576020808501946060850201011161098d57565b35906fffffffffffffffffffffffffffffffff8216820361098d57565b919082606091031261098d57604051612daa81612aa7565b8092803590811515820361098d576040612dd99181938552612dce60208201612d75565b602086015201612d75565b910152565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18136030182121561098d570180359067ffffffffffffffff821161098d5760200191813603831361098d57565b3573ffffffffffffffffffffffffffffffffffffffff8116810361098d5790565b3567ffffffffffffffff8116810361098d5790565b9067ffffffffffffffff612c1d92166000526007602052600560406000200190602081519101209060019160005201602052604060002054151590565b67ffffffffffffffff8111612a405760051b60200190565b9291612ec582612ea2565b93612ed36040519586612ac3565b602085848152019260051b810191821161098d57915b818310612ef557505050565b823573ffffffffffffffffffffffffffffffffffffffff8116810361098d57815260209283019201612ee9565b9190811015612f325760051b0190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b9190811015612f32576060020190565b8051821015612f325760209160051b010190565b90600182811c92168015612fce575b6020831014612f9f57565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b91607f1691612f94565b9060405191826000825492612fec84612f85565b808452936001811690811561305a5750600114613013575b5061301192500383612ac3565b565b90506000929192526020600020906000915b81831061303e5750509060206130119282010138613004565b6020919350806001915483858901015201910190918492613025565b602093506130119592507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0091501682840152151560051b82010138613004565b601f82602094937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0938186528686013760008582860101520116010190565b604051906130e682612a8b565b60006080838281528260208201528260408201528260608201520152565b9060405161311181612a8b565b60806001829460ff81546fffffffffffffffffffffffffffffffff8116865263ffffffff81861c16602087015260a01c161515604085015201546fffffffffffffffffffffffffffffffff81166060840152811c910152565b67ffffffffffffffff166000526007602052612c1d6004604060002001612fd8565b818110613197575050565b6000815560010161318c565b818102929181159184041417156131b657565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b80518015613254576020036132165760208180518101031261098d5760208101519060ff8211613216575060ff1690565b6112d3906040519182917f953576f7000000000000000000000000000000000000000000000000000000008352602060048401526024830190612b04565b50507f000000000000000000000000000000000000000000000000000000000000000090565b9060ff8091169116039060ff82116131b657565b60ff16604d81116131b657600a0a90565b81156132a9570490565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b907f00000000000000000000000000000000000000000000000000000000000000009060ff82169060ff8116928284146133de578284116133b4579061331d9161327a565b91604d60ff841611801561337b575b6133455750509061333f612c1d9261328e565b906131a3565b9091507fa9cb113d0000000000000000000000000000000000000000000000000000000060005260045260245260445260646000fd5b506133858361328e565b80156132a9577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04841161332c565b6133bd9161327a565b91604d60ff841611613345575050906133d8612c1d9261328e565b9061329f565b5050505090565b9081602091031261098d5751801515810361098d5790565b73ffffffffffffffffffffffffffffffffffffffff60015416330361341e57565b7f2b5c74de0000000000000000000000000000000000000000000000000000000060005260046000fd5b908051156136885767ffffffffffffffff8151602083012092169182600052600760205261347d81600560406000200161410d565b156136445760005260086020526040600020815167ffffffffffffffff8111612a40576134aa8254612f85565b601f8111613612575b506020601f821160011461354c5791613526827f7d628c9a1796743d365ab521a8b2a4686e419b3269919dc9145ea2ce853b54ea959361353c95600091613541575b507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8260011b9260031b1c19161790565b9055604051918291602083526020830190612b04565b0390a2565b9050840151386134f5565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe082169083600052806000209160005b8181106135fa57509261353c9492600192827f7d628c9a1796743d365ab521a8b2a4686e419b3269919dc9145ea2ce853b54ea9896106135c3575b5050811b019055611112565b8501517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60f88460031b161c1916905538806135b7565b9192602060018192868a01518155019401920161357c565b61363e90836000526020600020601f840160051c810191602085106108b857601f0160051c019061318c565b386134b3565b50906112d36040519283927f393b8ad20000000000000000000000000000000000000000000000000000000084526004840152604060248401526044830190612b04565b7f8579befe0000000000000000000000000000000000000000000000000000000060005260046000fd5b67ffffffffffffffff1660008181526006602052604090205490929190156137b457916137b160e09261377d856137097f0350d63aa5f270e01729d00d627eeb8f3429772b1818c016c66a588a864f912b97613874565b8460005260076020526137208160406000206139bb565b61372983613874565b8460005260076020526137438360026040600020016139bb565b60405194855260208501906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b60808301906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565ba1565b827f1e670e4b0000000000000000000000000000000000000000000000000000000060005260045260246000fd5b919082039182116131b657565b6137f76130d9565b506fffffffffffffffffffffffffffffffff6060820151166fffffffffffffffffffffffffffffffff8083511691613854602085019361384e61384163ffffffff875116426137e2565b85608089015116906131a3565b90614046565b8082101561386d57505b16825263ffffffff4216905290565b905061385e565b805115613914576fffffffffffffffffffffffffffffffff6040820151166fffffffffffffffffffffffffffffffff602083015116106138b15750565b606490613912604051917f8020d12400000000000000000000000000000000000000000000000000000000835260048301906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565bfd5b6fffffffffffffffffffffffffffffffff6040820151161580159061399c575b61393b5750565b606490613912604051917fd68af9cc00000000000000000000000000000000000000000000000000000000835260048301906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b506fffffffffffffffffffffffffffffffff6020820151161515613934565b7f9ea3374b67bf275e6bb9c8ae68f9cae023e1c528b4b27e092f0bb209d3531c1991613af460609280546139f863ffffffff8260801c16426137e2565b9081613b33575b50506fffffffffffffffffffffffffffffffff6001816020860151169282815416808510600014613b2b57508280855b16167fffffffffffffffffffffffffffffffff00000000000000000000000000000000825416178155613aa88651151582907fffffffffffffffffffffff00ffffffffffffffffffffffffffffffffffffffff74ff0000000000000000000000000000000000000000835492151560a01b169116179055565b60408601517fffffffffffffffffffffffffffffffff0000000000000000000000000000000060809190911b16939092166fffffffffffffffffffffffffffffffff1692909217910155565b6137b160405180926fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b838091613a2f565b6fffffffffffffffffffffffffffffffff91613b68839283613b616001880154948286169560801c906131a3565b9116614046565b80821015613be757505b83547fffffffffffffffffffffffff00000000ffffffffffffffffffffffffffffffff9290911692909216167fffffffffffffffffffffffff0000000000000000000000000000000000000000909116174260801b73ffffffff000000000000000000000000000000001617815538806139ff565b9050613b72565b906040519182815491828252602082019060005260206000209260005b818110613c2057505061301192500383612ac3565b8454835260019485019487945060209093019201613c0b565b8054821015612f325760005260206000200190600090565b6000818152600360205260409020548015613de0577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81018181116131b657600254907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82019182116131b657818103613d71575b5050506002548015613d42577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff01613cff816002613c39565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b19169055600255600052600360205260006040812055600190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603160045260246000fd5b613dc8613d82613d93936002613c39565b90549060031b1c9283926002613c39565b81939154907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9060031b92831b921b19161790565b90556000526003602052604060002055388080613cc6565b5050600090565b6000818152600660205260409020548015613de0577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81018181116131b657600554907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82019182116131b657818103613ed8575b5050506005548015613d42577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff01613e95816005613c39565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b19169055600555600052600660205260006040812055600190565b613efa613ee9613d93936005613c39565b90549060031b1c9283926005613c39565b90556000526006602052604060002055388080613e5c565b906001820191816000528260205260406000205480151560001461403d577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff81018181116131b6578254907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82019182116131b657818103614006575b50505080548015613d42577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0190613fc78282613c39565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b191690555560005260205260006040812055600190565b614026614016613d939386613c39565b90549060031b1c92839286613c39565b905560005283602052604060002055388080613f8f565b50505050600090565b919082018092116131b657565b806000526003602052604060002054156000146140ad5760025468010000000000000000811015612a4057614094613d938260018594016002556002613c39565b9055600254906000526003602052604060002055600190565b50600090565b806000526006602052604060002054156000146140ad5760055468010000000000000000811015612a40576140f4613d938260018594016005556005613c39565b9055600554906000526006602052604060002055600190565b6000828152600182016020526040902054613de05780549068010000000000000000821015612a40578261414b613d93846001809601855584613c39565b905580549260005201602052604060002055600190565b9182549060ff8260a01c161580156143a1575b61439b576fffffffffffffffffffffffffffffffff821691600185019081546141ba63ffffffff6fffffffffffffffffffffffffffffffff83169360801c16426137e2565b90816142fd575b50508481106142b1575083831061421b5750506141f06fffffffffffffffffffffffffffffffff9283926137e2565b16167fffffffffffffffffffffffffffffffff00000000000000000000000000000000825416179055565b5460801c9161422a81856137e2565b927fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8101908082116131b65761427861427d9273ffffffffffffffffffffffffffffffffffffffff96614046565b61329f565b7fd0c8d23a000000000000000000000000000000000000000000000000000000006000526004526024521660445260646000fd5b828573ffffffffffffffffffffffffffffffffffffffff927f1a76572a000000000000000000000000000000000000000000000000000000006000526004526024521660445260646000fd5b828692939611614371576143189261384e9160801c906131a3565b8084101561436c5750825b85547fffffffffffffffffffffffff00000000ffffffffffffffffffffffffffffffff164260801b73ffffffff00000000000000000000000000000000161786559238806141c1565b614323565b7f9725942a0000000000000000000000000000000000000000000000000000000060005260046000fd5b50505050565b50821561417556fea164736f6c634300081a000a' diff --git a/ccip-sdk/src/token-admin/evm/bytecodes/FactoryBurnMintERC20.ts b/ccip-sdk/src/token-admin/evm/bytecodes/FactoryBurnMintERC20.ts new file mode 100644 index 00000000..af5772c9 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/bytecodes/FactoryBurnMintERC20.ts @@ -0,0 +1,9 @@ +/** + * FactoryBurnMintERC20 deployment bytecode (v1.6.2). + * Lazy-loaded via dynamic import(). + * + * Source: chainlink-ccip release/contracts-ccip-1.6.2 + * chains/evm/gobindings/generated/v1_6_2/factory_burn_mint_erc20/factory_burn_mint_erc20.go + */ +export const FACTORY_BURN_MINT_ERC20_BYTECODE = + '0x60c0604052346104b55761280e80380380610019816104ba565b92833981019060c0818303126104b55780516001600160401b0381116104b557826100459183016104df565b602082015190926001600160401b0382116104b5576100659183016104df565b604082015160ff811681036104b55760608301519160a060808501519401519460018060a01b0386168096036104b5578051906001600160401b0382116103b25760035490600182811c921680156104ab575b60208310146103925781601f84931161043b575b50602090601f83116001146103d3576000926103c8575b50508160011b916000199060031b1c1916176003555b8051906001600160401b0382116103b25760045490600182811c921680156103a8575b60208310146103925781601f849311610322575b50602090601f83116001146102ba576000926102af575b50508160011b916000199060031b1c1916176004555b331561029e573360018060a01b031960065416176006556080528060a0528260018060a01b031960075416176007558082119081610294575b5061028057806101c9575b6040516122c3908161054b823960805181611240015260a05181818161042601526110700152f35b811561023b57600254908082018092116102255760207fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9160009360025584845283825260408420818154019055604051908152a338806101a1565b634e487b7160e01b600052601160045260246000fd5b60405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f2061646472657373006044820152606490fd5b63cbbf111360e01b60005260045260246000fd5b9050151538610196565b639b15e16f60e01b60005260046000fd5b015190503880610147565b600460009081528281209350601f198516905b81811061030a57509084600195949392106102f1575b505050811b0160045561015d565b015160001960f88460031b161c191690553880806102e3565b929360206001819287860151815501950193016102cd565b60046000529091507f8a35acfbc15ff81a39ae7d344fd709f28e8600b4aa8c65c6b64bfe7fe36bd19b601f840160051c81019160208510610388575b90601f859493920160051c01905b8181106103795750610130565b6000815584935060010161036c565b909150819061035e565b634e487b7160e01b600052602260045260246000fd5b91607f169161011c565b634e487b7160e01b600052604160045260246000fd5b0151905038806100e3565b600360009081528281209350601f198516905b818110610423575090846001959493921061040a575b505050811b016003556100f9565b015160001960f88460031b161c191690553880806103fc565b929360206001819287860151815501950193016103e6565b60036000529091507fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b601f840160051c810191602085106104a1575b90601f859493920160051c01905b81811061049257506100cc565b60008155849350600101610485565b9091508190610477565b91607f16916100b8565b600080fd5b6040519190601f01601f191682016001600160401b038111838210176103b257604052565b81601f820112156104b5578051906001600160401b0382116103b25761050e601f8301601f19166020016104ba565b92828452602083830101116104b55760005b82811061053557505060206000918301015290565b8060208092840101518282870101520161052056fe608080604052600436101561001357600080fd5b60003560e01c90816301ffc9a7146116ef5750806306fdde0314611612578063095ea7b31461145557806318160ddd14611419578063181f5a771461136357806323b872dd14611264578063313ce5671461120857806339509351146111cc57806340c10f1914610ff957806342966c6814610fa25780634334614a14610f3d5780634f5632f814610eac578063661884631461098a5780636b32810b14610e1757806370a0823114610db257806379ba509714610cc957806379cc67901461098f57806386fe8b4314610c285780638da5cb5b14610bd65780638fd6a6ac14610b8457806395d89b4114610a275780639dc29fac1461098f578063a457c2d71461098a578063a8fa343c146108df578063a9059cbb1461067d578063aa271e1a1461060e578063c2e3273d1461057d578063c630948d146104da578063c64d0ebc14610449578063d5abeb01146103f0578063d73dd623146103ab578063dd62ed3e1461031b578063f2fde38b1461022b5763f81094f31461019557600080fd5b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265773ffffffffffffffffffffffffffffffffffffffff6101e16118a6565b6101e9611d29565b166101f3816120d1565b6101f957005b60207fed998b960f6340d045f620c119730f7aa7995e7425c2401d3a5b64ff998a59e991604051908152a1005b600080fd5b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265773ffffffffffffffffffffffffffffffffffffffff6102776118a6565b61027f611d29565b163381146102f157807fffffffffffffffffffffffff0000000000000000000000000000000000000000600554161760055573ffffffffffffffffffffffffffffffffffffffff600654167fed8889f560326eb138920d842192f0eb3dd22b4f139c87a2c57538e05bae1278600080a3005b7fdad89dca0000000000000000000000000000000000000000000000000000000060005260046000fd5b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576103526118a6565b73ffffffffffffffffffffffffffffffffffffffff61036f6118c9565b9116600052600160205273ffffffffffffffffffffffffffffffffffffffff604060002091166000526020526020604060002054604051908152f35b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576103ee6103e56118a6565b60243590611b2f565b005b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265760206040517f00000000000000000000000000000000000000000000000000000000000000008152f35b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265773ffffffffffffffffffffffffffffffffffffffff6104956118a6565b61049d611d29565b166104a78161225c565b6104ad57005b60207f92308bb7573b2a3d17ddb868b39d8ebec433f3194421abc22d084f89658c9bad91604051908152a1005b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265773ffffffffffffffffffffffffffffffffffffffff6105266118a6565b61052e611d29565b16610538816121fc565b61054e575b610545611d29565b6104a78161225c565b7fe46fef8bbff1389d9010703cf8ebb363fb3daf5bf56edc27080b67bc8d9251ea6020604051838152a161053d565b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265773ffffffffffffffffffffffffffffffffffffffff6105c96118a6565b6105d1611d29565b166105db816121fc565b6105e157005b60207fe46fef8bbff1389d9010703cf8ebb363fb3daf5bf56edc27080b67bc8d9251ea91604051908152a1005b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657602061067373ffffffffffffffffffffffffffffffffffffffff61065f6118a6565b166000526009602052604060002054151590565b6040519015158152f35b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576106b46118a6565b73ffffffffffffffffffffffffffffffffffffffff1660243530821461022657331561085b5781156107d7573360005260006020526040600020548181106107535781903360005260006020520360406000205581600052600060205260406000208181540190556040519081527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef60203392a3602060405160018152f35b60846040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e742065786365656473206260448201527f616c616e636500000000000000000000000000000000000000000000000000006064820152fd5b60846040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201527f65737300000000000000000000000000000000000000000000000000000000006064820152fd5b60846040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f20616460448201527f64726573730000000000000000000000000000000000000000000000000000006064820152fd5b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576109166118a6565b61091e611d29565b73ffffffffffffffffffffffffffffffffffffffff80600754921691827fffffffffffffffffffffffff0000000000000000000000000000000000000000821617600755167f9524c9e4b0b61eb018dd58a1cd856e3e74009528328ab4a613b434fa631d7242600080a3005b6118ec565b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576109c66118a6565b6024356109e033600052600b602052604060002054151590565b156109f9576103ee916109f4823383611bce565b611d74565b7fc820b10b000000000000000000000000000000000000000000000000000000006000523360045260246000fd5b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265760405160006004548060011c90600181168015610b7a575b602083108114610b4d57828552908115610b0b5750600114610aab575b610aa783610a9b81850382611ab2565b6040519182918261183e565b0390f35b91905060046000527f8a35acfbc15ff81a39ae7d344fd709f28e8600b4aa8c65c6b64bfe7fe36bd19b916000905b808210610af157509091508101602001610a9b610a8b565b919260018160209254838588010152019101909291610ad9565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff001660208086019190915291151560051b84019091019150610a9b9050610a8b565b6024847f4e487b710000000000000000000000000000000000000000000000000000000081526022600452fd5b91607f1691610a6e565b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657602073ffffffffffffffffffffffffffffffffffffffff60075416604051908152f35b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657602073ffffffffffffffffffffffffffffffffffffffff60065416604051908152f35b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657604051806020600a54918281520190600a6000527fc65a7bb8d6351c1cf70c95a316cc6a92839c986682d98bc35f958f4883f9d2a89060005b818110610cb357610aa785610ca781870382611ab2565b60405191829182611a62565b8254845260209093019260019283019201610c90565b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265760055473ffffffffffffffffffffffffffffffffffffffff81163303610d88577fffffffffffffffffffffffff00000000000000000000000000000000000000006006549133828416176006551660055573ffffffffffffffffffffffffffffffffffffffff3391167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0600080a3005b7f02b543c60000000000000000000000000000000000000000000000000000000060005260046000fd5b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265773ffffffffffffffffffffffffffffffffffffffff610dfe6118a6565b1660005260006020526020604060002054604051908152f35b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265760405180602060085491828152019060086000527ff3f7a9fe364faab93b216da50a3214154f22a0a2b415b23a84c8169e8b636ee39060005b818110610e9657610aa785610ca781870382611ab2565b8254845260209093019260019283019201610e7f565b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265773ffffffffffffffffffffffffffffffffffffffff610ef86118a6565b610f00611d29565b16610f0a81611f3b565b610f1057005b60207f0a675452746933cefe3d74182e78db7afe57ba60eaa4234b5d85e9aa41b0610c91604051908152a1005b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657602061067373ffffffffffffffffffffffffffffffffffffffff610f8e6118a6565b16600052600b602052604060002054151590565b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657610fe833600052600b602052604060002054151590565b156109f9576103ee60043533611d74565b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576110306118a6565b6024359061104b336000526009602052604060002054151590565b1561119e5773ffffffffffffffffffffffffffffffffffffffff1690308214610226577f00000000000000000000000000000000000000000000000000000000000000008015159081611189575b506111505781156110f2577fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef6020826110d6600094600254611af3565b60025584845283825260408420818154019055604051908152a3005b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f2061646472657373006044820152fd5b61115c90600254611af3565b7fcbbf11130000000000000000000000000000000000000000000000000000000060005260045260246000fd5b905061119782600254611af3565b1183611099565b7fe2c8c9d5000000000000000000000000000000000000000000000000000000006000523360045260246000fd5b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265760206106736103e56118a6565b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657602060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b346102265760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265761129b6118a6565b6112a36118c9565b73ffffffffffffffffffffffffffffffffffffffff604435916112c7833386611bce565b16913083146102265773ffffffffffffffffffffffffffffffffffffffff1690811561085b5782156107d75781600052600060205260406000205481811061075357817fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9260209285600052600084520360406000205584600052600082526040600020818154019055604051908152a3602060405160018152f35b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657604051604081019080821067ffffffffffffffff8311176113ea57610aa791604052601a81527f466163746f72794275726e4d696e74455243323020312e362e3200000000000060208201526040519182918261183e565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576020600254604051908152f35b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265761148c6118a6565b73ffffffffffffffffffffffffffffffffffffffff1660243530821461022657331561158f57811561150b57336000526001602052604060002082600052602052806040600020556040519081527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3602060405160018152f35b60846040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f20616464726560448201527f73730000000000000000000000000000000000000000000000000000000000006064820152fd5b60846040517f08c379a0000000000000000000000000000000000000000000000000000000008152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f2061646460448201527f72657373000000000000000000000000000000000000000000000000000000006064820152fd5b346102265760007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126102265760405160006003548060011c906001811680156116e5575b602083108114610b4d57828552908115610b0b575060011461168557610aa783610a9b81850382611ab2565b91905060036000527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b916000905b8082106116cb57509091508101602001610a9b610a8b565b9192600181602092548385880101520191019092916116b3565b91607f1691611659565b346102265760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261022657600435907fffffffff00000000000000000000000000000000000000000000000000000000821680920361022657817f36372b070000000000000000000000000000000000000000000000000000000060209314908115611814575b81156117ea575b81156117c0575b8115611796575b5015158152f35b7f8fd6a6ac000000000000000000000000000000000000000000000000000000009150148361178f565b7f06e278470000000000000000000000000000000000000000000000000000000081149150611788565b7f01ffc9a70000000000000000000000000000000000000000000000000000000081149150611781565b7fe6599b4d000000000000000000000000000000000000000000000000000000008114915061177a565b9190916020815282519283602083015260005b8481106118905750507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8460006040809697860101520116010190565b8060208092840101516040828601015201611851565b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361022657565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361022657565b346102265760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610226576119236118a6565b6024359060009133835260016020526040832073ffffffffffffffffffffffffffffffffffffffff831684526020526040832054908082106119de5773ffffffffffffffffffffffffffffffffffffffff91039116913083146119db57331561158f57821561150b5760408291338152600160205281812085825260205220556040519081527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a360206001604051908152f35b80fd5b60846040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f7760448201527f207a65726f0000000000000000000000000000000000000000000000000000006064820152fd5b602060408183019282815284518094520192019060005b818110611a865750505090565b825173ffffffffffffffffffffffffffffffffffffffff16845260209384019390920191600101611a79565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff8211176113ea57604052565b91908201809211611b0057565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b90611b6b73ffffffffffffffffffffffffffffffffffffffff913360005260016020526040600020838516600052602052604060002054611af3565b91169030821461022657331561158f57811561150b57336000526001602052604060002082600052602052806040600020556040519081527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560203392a3600190565b73ffffffffffffffffffffffffffffffffffffffff909291921690816000526001602052604060002073ffffffffffffffffffffffffffffffffffffffff8416600052602052604060002054907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203611c49575b50505050565b808210611ccb5773ffffffffffffffffffffffffffffffffffffffff910392169130831461022657811561158f57821561150b5760207f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925918360005260018252604060002085600052825280604060002055604051908152a338808080611c43565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e63650000006044820152fd5b73ffffffffffffffffffffffffffffffffffffffff600654163303611d4a57565b7f2b5c74de0000000000000000000000000000000000000000000000000000000060005260046000fd5b73ffffffffffffffffffffffffffffffffffffffff168015611e705780600052600060205260406000205491808310611dec576020817fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef926000958587528684520360408620558060025403600255604051908152a3565b60846040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602260248201527f45524332303a206275726e20616d6f756e7420657863656564732062616c616e60448201527f63650000000000000000000000000000000000000000000000000000000000006064820152fd5b60846040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602160248201527f45524332303a206275726e2066726f6d20746865207a65726f2061646472657360448201527f73000000000000000000000000000000000000000000000000000000000000006064820152fd5b8054821015611f0c5760005260206000200190600090565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b6000818152600b602052604090205480156120ca577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8101818111611b0057600a54907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8201918211611b005781810361205b575b505050600a54801561202c577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff01611fe981600a611ef4565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b19169055600a55600052600b60205260006040812055600190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603160045260246000fd5b6120b261206c61207d93600a611ef4565b90549060031b1c928392600a611ef4565b81939154907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9060031b92831b921b19161790565b9055600052600b602052604060002055388080611fb0565b5050600090565b60008181526009602052604090205480156120ca577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8101818111611b0057600854907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8201918211611b00578181036121c2575b505050600854801561202c577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0161217f816008611ef4565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b19169055600855600052600960205260006040812055600190565b6121e46121d361207d936008611ef4565b90549060031b1c9283926008611ef4565b90556000526009602052604060002055388080612146565b8060005260096020526040600020541560001461225657600854680100000000000000008110156113ea5761223d61207d8260018594016008556008611ef4565b9055600854906000526009602052604060002055600190565b50600090565b80600052600b6020526040600020541560001461225657600a54680100000000000000008110156113ea5761229d61207d826001859401600a55600a611ef4565b9055600a5490600052600b60205260406000205560019056fea164736f6c634300081a000a' diff --git a/ccip-sdk/src/token-admin/evm/bytecodes/LockReleaseTokenPool.ts b/ccip-sdk/src/token-admin/evm/bytecodes/LockReleaseTokenPool.ts new file mode 100644 index 00000000..014bf65e --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/bytecodes/LockReleaseTokenPool.ts @@ -0,0 +1,3 @@ +/** LockReleaseTokenPool (v1.6.1) deployment bytecode. Lazy-loaded via dynamic import(). */ +export const LOCK_RELEASE_TOKEN_POOL_BYTECODE = + '0x6101008060405234610377576150b5803803809161001d82856103f6565b833981019060a0818303126103775780516001600160a01b038116918282036103775761004c60208201610419565b60408201519092906001600160401b0381116103775782019480601f87011215610377578551956001600160401b0387116103e0578660051b906020820197610098604051998a6103f6565b885260208089019282010192831161037757602001905b8282106103c8575050506100d160806100ca60608501610427565b9301610427565b9333156103b757600180546001600160a01b03191633179055801580156103a6575b8015610395575b6103845760049260209260805260c0526040519283809263313ce56760e01b82525afa60009181610343575b50610318575b5060a052600480546001600160a01b0319166001600160a01b03929092169190911790558051151560e08190526101fa575b604051614ad990816105dc82396080518181816103330152818161175b015281816119760152818161232f01528181612743015281816128f901528181612b5901528181612bd10152612cc8015260a051818181611a3801528181612ae00152818161376f01526137f2015260c051818181610d26015281816117f701526127df015260e051818181610cb60152818161183a015261246f0152f35b604051602061020981836103f6565b60008252600036813760e051156103075760005b8251811015610284576001906001600160a01b0361023b828661043b565b5116836102478261047d565b610254575b50500161021d565b7f800671136ab6cfee9fbe5ed1fb7ca417811aca3cf864800d127b927adedf756691604051908152a1388361024c565b50905060005b82518110156102fe576001906001600160a01b036102a8828661043b565b511680156102f857836102ba8261057b565b6102c8575b50505b0161028a565b7f2640d4d76caf8bf478aabfa982fa4e1c4eb71a37f93cd15e80dbc657911546d891604051908152a138836102bf565b506102c2565b5050503861015e565b6335f4a7b360e01b60005260046000fd5b60ff1660ff821681810361032c575061012c565b6332ad3e0760e11b60005260045260245260446000fd5b9091506020813d60201161037c575b8161035f602093836103f6565b810103126103775761037090610419565b9038610126565b600080fd5b3d9150610352565b6342bcdf7f60e11b60005260046000fd5b506001600160a01b038316156100fa565b506001600160a01b038516156100f3565b639b15e16f60e01b60005260046000fd5b602080916103d584610427565b8152019101906100af565b634e487b7160e01b600052604160045260246000fd5b601f909101601f19168101906001600160401b038211908210176103e057604052565b519060ff8216820361037757565b51906001600160a01b038216820361037757565b805182101561044f5760209160051b010190565b634e487b7160e01b600052603260045260246000fd5b805482101561044f5760005260206000200190600090565b600081815260036020526040902054801561057457600019810181811161055e5760025460001981019190821161055e5781810361050d575b50505060025480156104f757600019016104d1816002610465565b8154906000199060031b1b19169055600255600052600360205260006040812055600190565b634e487b7160e01b600052603160045260246000fd5b61054661051e61052f936002610465565b90549060031b1c9283926002610465565b819391549060031b91821b91600019901b19161790565b905560005260036020526040600020553880806104b6565b634e487b7160e01b600052601160045260246000fd5b5050600090565b806000526003602052604060002054156000146105d557600254680100000000000000008110156103e0576105bc61052f8260018594016002556002610465565b9055600254906000526003602052604060002055600190565b5060009056fe608080604052600436101561001357600080fd5b600090813560e01c90816301ffc9a714612dd8575080630a861f2a14612c74578063181f5a7714612bf557806321df0da714612b86578063240028e814612b0457806324f65ee714612aa8578063390775371461266f578063432a6ba31461261d5780634c5ef0ed146125b857806354c8a4f31461243b57806362ddd3c4146123b7578063663200871461219e5780636cfd1553146120c55780636d3d1a581461207357806379ba509714611f8e5780637d54534e14611ee15780638926f54f14611e7d5780638da5cb5b14611e2b578063962d402014611c875780639a4575b9146116b3578063a42a7b8b1461152e578063a7cd63b714611462578063acfecf911461133e578063af58d59f146112d7578063b0f479a114611285578063b79465801461122e578063c0d7865514611128578063c4bffe2b14610fdf578063c75eea9c14610f19578063cf7401f314610d4a578063dc0bd97114610cdb578063e0351e1314610c80578063e8a1da17146103ab578063eb521a4c146102915763f2fde38b146101a257600080fd5b3461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5773ffffffffffffffffffffffffffffffffffffffff6101ee613040565b6101f6613914565b1633811461026657807fffffffffffffffffffffffff000000000000000000000000000000000000000083541617825573ffffffffffffffffffffffffffffffffffffffff600154167fed8889f560326eb138920d842192f0eb3dd22b4f139c87a2c57538e05bae12788380a380f35b6004827fdad89dca000000000000000000000000000000000000000000000000000000008152fd5b80fd5b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5760043573ffffffffffffffffffffffffffffffffffffffff600a5416330361037f576040517f23b872dd0000000000000000000000000000000000000000000000000000000060208201523360248201523060448201526064808201839052815261035790610331608482612f66565b7f0000000000000000000000000000000000000000000000000000000000000000613ed2565b337fc17cea59c2955cb181b03393209566960365771dbba9dc3d510180e7cb3120888380a380f35b6024827f8e4a23d600000000000000000000000000000000000000000000000000000000815233600452fd5b503461028e576103ba36613100565b939190926103c6613914565b82915b808310610aeb575050508063ffffffff4216917ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffee1843603015b85821015610ae7578160051b85013581811215610ae35785019061012082360312610ae3576040519561043487612f2e565b823567ffffffffffffffff81168103610ade578752602083013567ffffffffffffffff8111610ada5783019536601f88011215610ada5786359661047788613351565b97610485604051998a612f66565b8089526020808a019160051b83010190368211610ad65760208301905b828210610aa3575050505060208801968752604084013567ffffffffffffffff8111610a9f576104d590369086016130b1565b9860408901998a526104ff6104ed3660608801613241565b9560608b0196875260c0369101613241565b9660808a019788526105118651613d8b565b61051b8851613d8b565b8a515115610a775761053767ffffffffffffffff8b511661470a565b15610a405767ffffffffffffffff8a5116815260076020526040812061067787516fffffffffffffffffffffffffffffffff604082015116906106326fffffffffffffffffffffffffffffffff6020830151169151151583608060405161059d81612f2e565b858152602081018c905260408101849052606081018690520152855474ff000000000000000000000000000000000000000091151560a01b919091167fffffffffffffffffffffff0000000000000000000000000000000000000000009091166fffffffffffffffffffffffffffffffff84161773ffffffff0000000000000000000000000000000060808b901b1617178555565b60809190911b7fffffffffffffffffffffffffffffffff00000000000000000000000000000000166fffffffffffffffffffffffffffffffff91909116176001830155565b61079d89516fffffffffffffffffffffffffffffffff604082015116906107586fffffffffffffffffffffffffffffffff602083015116915115158360806040516106c181612f2e565b858152602081018c9052604081018490526060810186905201526002860180547fffffffffffffffffffffff000000000000000000000000000000000000000000166fffffffffffffffffffffffffffffffff85161773ffffffff0000000000000000000000000000000060808c901b161791151560a01b74ff000000000000000000000000000000000000000016919091179055565b60809190911b7fffffffffffffffffffffffffffffffff00000000000000000000000000000000166fffffffffffffffffffffffffffffffff91909116176003830155565b60048c5191019080519067ffffffffffffffff8211610a13576107c08354613434565b601f81116109d8575b50602090601f831160011461093957610817929185918361092e575b50507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8260011b9260031b1c19161790565b90555b805b89518051821015610852579061084c600192610845838f67ffffffffffffffff90511692613420565b519061395f565b0161081c565b5050975097987f8d340f17e19058004c20453540862a9c62778504476f6756755cb33bcd6c38c29295939661092067ffffffffffffffff600197949c51169251935191516108ec6108b760405196879687526101006020880152610100870190612fe1565b9360408601906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b60a08401906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b0390a1019093949291610402565b0151905038806107e5565b83855281852091907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08416865b8181106109c05750908460019594939210610989575b505050811b01905561081a565b01517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60f88460031b161c1916905538808061097c565b92936020600181928786015181550195019301610966565b610a039084865260208620601f850160051c81019160208610610a09575b601f0160051c019061363b565b386107c9565b90915081906109f6565b6024847f4e487b710000000000000000000000000000000000000000000000000000000081526041600452fd5b60249067ffffffffffffffff8b51167f1d5ad3c5000000000000000000000000000000000000000000000000000000008252600452fd5b807f8579befe0000000000000000000000000000000000000000000000000000000060049252fd5b8680fd5b813567ffffffffffffffff8111610ad257602091610ac783928336918901016130b1565b8152019101906104a2565b8a80fd5b8880fd5b8580fd5b600080fd5b8380fd5b8280f35b9092919367ffffffffffffffff610b0b610b068785886133d1565b6132ff565b1695610b168761443e565b15610c54578684526007602052610b3260056040862001614245565b94845b8651811015610b6b576001908987526007602052610b6460056040892001610b5d838b613420565b5190614569565b5001610b35565b5093945094909580855260076020526005604086208681558660018201558660028201558660038201558660048201610ba48154613434565b80610c13575b5050500180549086815581610bf5575b5050907f5204aec90a3c794d8e90fded8b46ae9c7c552803e7e832e0c1d358396d8599166020600193604051908152a10191909493946103c9565b865260208620908101905b81811015610bba57868155600101610c00565b601f8111600114610c295750555b863880610baa565b81835260208320610c4491601f01861c81019060010161363b565b8082528160208120915555610c21565b602484887f1e670e4b000000000000000000000000000000000000000000000000000000008252600452fd5b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5760206040517f000000000000000000000000000000000000000000000000000000000000000015158152f35b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602060405173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b503461028e5760e07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57610d82613063565b9060607fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffdc36011261028e57604051610db981612f4a565b6024358015158103610f155781526044356fffffffffffffffffffffffffffffffff81168103610f155760208201526064356fffffffffffffffffffffffffffffffff81168103610f1557604082015260607fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7c360112610f115760405190610e4082612f4a565b6084358015158103610ae357825260a4356fffffffffffffffffffffffffffffffff81168103610ae357602083015260c4356fffffffffffffffffffffffffffffffff81168103610ae357604083015273ffffffffffffffffffffffffffffffffffffffff6009541633141580610eef575b610ec357610ec09293613bc9565b80f35b6024837f8e4a23d600000000000000000000000000000000000000000000000000000000815233600452fd5b5073ffffffffffffffffffffffffffffffffffffffff60015416331415610eb2565b5080fd5b8280fd5b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57610f82610f7d6040610fdb9367ffffffffffffffff610f66613063565b610f6e613588565b501681526007602052206135b3565b613d06565b6040519182918291909160806fffffffffffffffffffffffffffffffff8160a084019582815116855263ffffffff6020820151166020860152604081015115156040860152826060820151166060860152015116910152565b0390f35b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57604051906005548083528260208101600584526020842092845b81811061110f57505061103d92500383612f66565b815161106161104b82613351565b916110596040519384612f66565b808352613351565b917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0602083019301368437805b84518110156110c0578067ffffffffffffffff6110ad60019388613420565b51166110b98286613420565b520161108e565b50925090604051928392602084019060208552518091526040840192915b8181106110ec575050500390f35b825167ffffffffffffffff168452859450602093840193909201916001016110de565b8454835260019485019487945060209093019201611028565b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57611160613040565b611168613914565b73ffffffffffffffffffffffffffffffffffffffff811690811561120657600480547fffffffffffffffffffffffff0000000000000000000000000000000000000000811690931790556040805173ffffffffffffffffffffffffffffffffffffffff93841681529190921660208201527f02dc5c233404867c793b749c6d644beb2277536d18a7e7974d3f238e4c6f168491819081015b0390a180f35b6004837f8579befe000000000000000000000000000000000000000000000000000000008152fd5b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57610fdb61127161126c613063565b613619565b604051918291602083526020830190612fe1565b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602073ffffffffffffffffffffffffffffffffffffffff60045416604051908152f35b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57610f82610f7d60026040610fdb9467ffffffffffffffff611326613063565b61132e613588565b50168152600760205220016135b3565b503461028e5767ffffffffffffffff61135636613170565b929091611361613914565b169161137a836000526006602052604060002054151590565b156114365782845260076020526113a96005604086200161139c36848661307a565b6020815191012090614569565b156113ee57907f52d00ee4d9bd51b40168f2afc5848837288ce258784ad914278791464b3f4d76916113e8604051928392602084526020840191613549565b0390a280f35b82611432836040519384937f74f23c7c0000000000000000000000000000000000000000000000000000000085526004850152604060248501526044840191613549565b0390fd5b602484847f1e670e4b000000000000000000000000000000000000000000000000000000008252600452fd5b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57604051600254808252602082018091600285526020852090855b81811061151857505050826114c1910383612f66565b604051928392602084019060208552518091526040840192915b8181106114e9575050500390f35b825173ffffffffffffffffffffffffffffffffffffffff168452859450602093840193909201916001016114db565b82548452602090930192600192830192016114ab565b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5767ffffffffffffffff61156f613063565b168152600760205261158660056040832001614245565b80517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe06115cb6115b583613351565b926115c36040519485612f66565b808452613351565b01835b8181106116a2575050825b825181101561161f57806115ef60019285613420565b518552600860205261160360408620613487565b61160d8285613420565b526116188184613420565b50016115d9565b81846040519182916020830160208452825180915260408401602060408360051b870101940192905b82821061165757505050500390f35b91936020611692827fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc060019597998495030186528851612fe1565b9601920192018594939192611648565b8060606020809386010152016115ce565b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5760043567ffffffffffffffff8111610f115760a07ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc8236030112610f11576060602060405161173181612f12565b828152015260848101611743816132de565b73ffffffffffffffffffffffffffffffffffffffff807f000000000000000000000000000000000000000000000000000000000000000016911603611c3d5750602481019077ffffffffffffffff000000000000000000000000000000006117aa836132ff565b60801b16604051907f2cbc26bb000000000000000000000000000000000000000000000000000000008252600482015260208160248173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000165afa908115611b5e578491611c0e575b50611be657611838604482016132de565b7f0000000000000000000000000000000000000000000000000000000000000000611b94575b5067ffffffffffffffff611871836132ff565b16611889816000526006602052604060002054151590565b15611b6957602073ffffffffffffffffffffffffffffffffffffffff60045416916024604051809481937fa8d87a3b00000000000000000000000000000000000000000000000000000000835260048301525afa8015611b5e578490611afb575b73ffffffffffffffffffffffffffffffffffffffff9150163303611acf578167ffffffffffffffff7ff33bc26b4413b0e7f19f1ea739fdf99098c0061f1f87d954b11f5293fad9ae1061126c93611a9e9661199e6040606461194e611a2e9a6132ff565b940135958694169283815260076020522073ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000169586916147b9565b6040805173ffffffffffffffffffffffffffffffffffffffff86168152602081018490527fff0133389f9bb82d5b9385826160eaf2328039f6fa950eeb8cf0836da81789449190a267ffffffffffffffff6119f8856132ff565b6040805173ffffffffffffffffffffffffffffffffffffffff9690961686523360208701528501929092521691606090a26132ff565b610fdb60405160ff7f000000000000000000000000000000000000000000000000000000000000000016602082015260208152611a6c604082612f66565b60405192611a7984612f12565b8352602083019081526040519384936020855251604060208601526060850190612fe1565b90517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0848303016040850152612fe1565b6024837f728fe07b00000000000000000000000000000000000000000000000000000000815233600452fd5b506020813d602011611b56575b81611b1560209383612f66565b81010312610ae3575173ffffffffffffffffffffffffffffffffffffffff81168103610ae35773ffffffffffffffffffffffffffffffffffffffff906118ea565b3d9150611b08565b6040513d86823e3d90fd5b7fa9902c7e000000000000000000000000000000000000000000000000000000008452600452602483fd5b73ffffffffffffffffffffffffffffffffffffffff168084526003602052604084205461185e577fd0d25976000000000000000000000000000000000000000000000000000000008452600452602483fd5b6004837f53ad11d8000000000000000000000000000000000000000000000000000000008152fd5b611c30915060203d602011611c36575b611c288183612f66565b8101906138fc565b38611827565b503d611c1e565b8273ffffffffffffffffffffffffffffffffffffffff611c5e6024936132de565b7f961c9a4f00000000000000000000000000000000000000000000000000000000835216600452fd5b503461028e5760607ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5760043567ffffffffffffffff8111610f1157611cd79036906004016130cf565b60243567ffffffffffffffff8111610ae357611cf79036906004016131f3565b60449291923567ffffffffffffffff8111610ada57611d1a9036906004016131f3565b91909273ffffffffffffffffffffffffffffffffffffffff6009541633141580611e09575b611ddd57818114801590611dd3575b611dab57865b818110611d5f578780f35b80611da5611d73610b06600194868c6133d1565b611d7e83878b613410565b611d9f611d97611d8f868b8d613410565b923690613241565b913690613241565b91613bc9565b01611d54565b6004877f568efce2000000000000000000000000000000000000000000000000000000008152fd5b5082811415611d4e565b6024877f8e4a23d600000000000000000000000000000000000000000000000000000000815233600452fd5b5073ffffffffffffffffffffffffffffffffffffffff60015416331415611d3f565b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602073ffffffffffffffffffffffffffffffffffffffff60015416604051908152f35b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e576020611ed767ffffffffffffffff611ec3613063565b166000526006602052604060002054151590565b6040519015158152f35b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e577f44676b5284b809a22248eba0da87391d79098be38bb03154be88a58bf4d09174602073ffffffffffffffffffffffffffffffffffffffff611f51613040565b611f59613914565b16807fffffffffffffffffffffffff00000000000000000000000000000000000000006009541617600955604051908152a180f35b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57805473ffffffffffffffffffffffffffffffffffffffff8116330361204b577fffffffffffffffffffffffff000000000000000000000000000000000000000060015491338284161760015516825573ffffffffffffffffffffffffffffffffffffffff3391167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e08380a380f35b6004827f02b543c6000000000000000000000000000000000000000000000000000000008152fd5b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602073ffffffffffffffffffffffffffffffffffffffff60095416604051908152f35b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e577f64187bd7b97e66658c91904f3021d7c28de967281d18b1a20742348afdd6a6b373ffffffffffffffffffffffffffffffffffffffff612133613040565b61213b613914565b611200600a54918381167fffffffffffffffffffffffff0000000000000000000000000000000000000000841617600a55604051938493168390929173ffffffffffffffffffffffffffffffffffffffff60209181604085019616845216910152565b503461028e5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e576121d6613040565b602435906121e2613914565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82146122ce575b73ffffffffffffffffffffffffffffffffffffffff1690813b15610f15576040517f0a861f2a000000000000000000000000000000000000000000000000000000008152816004820152838160248183875af18015611b5e57612297575b5060207f6fa7abcf1345d1d478e5ea0da6b5f26a90eadb0546ef15ed3833944fbfd1db6291604051908152a280f35b836122c67f6fa7abcf1345d1d478e5ea0da6b5f26a90eadb0546ef15ed3833944fbfd1db629395602093612f66565b939150612268565b90506040517f70a0823100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff8216600482015260208160248173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000165afa80156123ac578390612366575b91905061220a565b506020813d6020116123a4575b8161238060209383612f66565b81010312610f155773ffffffffffffffffffffffffffffffffffffffff905161235e565b3d9150612373565b6040513d85823e3d90fd5b503461028e576123c636613170565b6123d293929193613914565b67ffffffffffffffff82166123f4816000526006602052604060002054151590565b156124105750610ec0929361240a91369161307a565b9061395f565b7f1e670e4b000000000000000000000000000000000000000000000000000000008452600452602483fd5b503461028e576124659061246d61245136613100565b959161245e939193613914565b3691613369565b933691613369565b7f00000000000000000000000000000000000000000000000000000000000000001561259057815b8351811015612508578073ffffffffffffffffffffffffffffffffffffffff6124c060019387613420565b51166124cb816142a8565b6124d7575b5001612495565b60207f800671136ab6cfee9fbe5ed1fb7ca417811aca3cf864800d127b927adedf756691604051908152a1386124d0565b5090805b825181101561258c578073ffffffffffffffffffffffffffffffffffffffff61253760019386613420565b5116801561258657612548816146aa565b612555575b505b0161250c565b60207f2640d4d76caf8bf478aabfa982fa4e1c4eb71a37f93cd15e80dbc657911546d891604051908152a18461254d565b5061254f565b5080f35b6004827f35f4a7b3000000000000000000000000000000000000000000000000000000008152fd5b503461028e5760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e576125f0613063565b906024359067ffffffffffffffff821161028e576020611ed78461261736600487016130b1565b90613314565b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602073ffffffffffffffffffffffffffffffffffffffff600a5416604051908152f35b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5760043567ffffffffffffffff8111610f115780600401916101007ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc833603011261028e57806040516126f081612ec7565b5261271d61271361270e61270760c486018761328d565b369161307a565b6136fb565b60648401356137ef565b916084810161272b816132de565b73ffffffffffffffffffffffffffffffffffffffff807f000000000000000000000000000000000000000000000000000000000000000016911603611c3d5750602481019077ffffffffffffffff00000000000000000000000000000000612792836132ff565b60801b16604051907f2cbc26bb000000000000000000000000000000000000000000000000000000008252600482015260208160248173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000165afa908115611b5e578491612a89575b50611be65767ffffffffffffffff612826836132ff565b1661283e816000526006602052604060002054151590565b15611b6957602073ffffffffffffffffffffffffffffffffffffffff60045416916044604051809481937f83826b2b00000000000000000000000000000000000000000000000000000000835260048301523360248301525afa908115611b5e578491612a6a575b5015611acf576128b5826132ff565b6128ca60a4830191612617612707848a61328d565b15612a2357508394506128dc826132ff565b67ffffffffffffffff1692838152600760205260409020600201927f00000000000000000000000000000000000000000000000000000000000000009373ffffffffffffffffffffffffffffffffffffffff8516809661293b926147b9565b6040805173ffffffffffffffffffffffffffffffffffffffff87168152602081018890527f50f6fbee3ceedce6b7fd7eaef18244487867e6718aec7208187efb6b7908c14c9190a26044019184612991846132de565b61299a92613694565b6129a3906132ff565b906129ad906132de565b60405192835233602084015273ffffffffffffffffffffffffffffffffffffffff16604083015282606083015267ffffffffffffffff169060807ffc5e3a5bddc11d92c2dc20fae6f7d5eb989f056be35239f7de7e86150609abc091a280604051612a1781612ec7565b52604051908152602090f35b612a2d908661328d565b6114326040519283927f24eb47e5000000000000000000000000000000000000000000000000000000008452602060048501526024840191613549565b612a83915060203d602011611c3657611c288183612f66565b386128a6565b612aa2915060203d602011611c3657611c288183612f66565b3861280f565b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602060405160ff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602090612b3f613040565b905073ffffffffffffffffffffffffffffffffffffffff807f0000000000000000000000000000000000000000000000000000000000000000169116146040519015158152f35b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e57602060405173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b503461028e57807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5750610fdb604051612c36604082612f66565b601a81527f4c6f636b52656c65617365546f6b656e506f6f6c20312e362e310000000000006020820152604051918291602083526020830190612fe1565b503461028e5760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261028e5760043573ffffffffffffffffffffffffffffffffffffffff600a5416330361037f577f00000000000000000000000000000000000000000000000000000000000000006040517f70a0823100000000000000000000000000000000000000000000000000000000815230600482015260208160248173ffffffffffffffffffffffffffffffffffffffff86165afa8015611b5e5783918591612da3575b5010612d7b5781612d53913390613694565b337fc2c3f06e49b9f15e7b4af9055e183b0d73362e033ad82a07dec9bf98401717198380a380f35b6004837fbb55fd27000000000000000000000000000000000000000000000000000000008152fd5b9150506020813d602011612dd0575b81612dbf60209383612f66565b81010312610ae35782905138612d41565b3d9150612db2565b905034610f115760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610f11576004357fffffffff000000000000000000000000000000000000000000000000000000008116809103610f1557602092507faff2afbf000000000000000000000000000000000000000000000000000000008114908115612e9d575b8115612e73575b5015158152f35b7f01ffc9a70000000000000000000000000000000000000000000000000000000091501438612e6c565b7f0e64dd290000000000000000000000000000000000000000000000000000000081149150612e65565b6020810190811067ffffffffffffffff821117612ee357604052565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6040810190811067ffffffffffffffff821117612ee357604052565b60a0810190811067ffffffffffffffff821117612ee357604052565b6060810190811067ffffffffffffffff821117612ee357604052565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff821117612ee357604052565b67ffffffffffffffff8111612ee357601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01660200190565b919082519283825260005b84811061302b5750507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8460006020809697860101520116010190565b80602080928401015182828601015201612fec565b6004359073ffffffffffffffffffffffffffffffffffffffff82168203610ade57565b6004359067ffffffffffffffff82168203610ade57565b92919261308682612fa7565b916130946040519384612f66565b829481845281830111610ade578281602093846000960137010152565b9080601f83011215610ade578160206130cc9335910161307a565b90565b9181601f84011215610ade5782359167ffffffffffffffff8311610ade576020808501948460051b010111610ade57565b60407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc820112610ade5760043567ffffffffffffffff8111610ade5781613149916004016130cf565b929092916024359067ffffffffffffffff8211610ade5761316c916004016130cf565b9091565b60407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc820112610ade5760043567ffffffffffffffff81168103610ade579160243567ffffffffffffffff8111610ade5782602382011215610ade5780600401359267ffffffffffffffff8411610ade5760248483010111610ade576024019190565b9181601f84011215610ade5782359167ffffffffffffffff8311610ade5760208085019460608502010111610ade57565b35906fffffffffffffffffffffffffffffffff82168203610ade57565b9190826060910312610ade5760405161325981612f4a565b80928035908115158203610ade576040613288918193855261327d60208201613224565b602086015201613224565b910152565b9035907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe181360301821215610ade570180359067ffffffffffffffff8211610ade57602001918136038313610ade57565b3573ffffffffffffffffffffffffffffffffffffffff81168103610ade5790565b3567ffffffffffffffff81168103610ade5790565b9067ffffffffffffffff6130cc92166000526007602052600560406000200190602081519101209060019160005201602052604060002054151590565b67ffffffffffffffff8111612ee35760051b60200190565b929161337482613351565b936133826040519586612f66565b602085848152019260051b8101918211610ade57915b8183106133a457505050565b823573ffffffffffffffffffffffffffffffffffffffff81168103610ade57815260209283019201613398565b91908110156133e15760051b0190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b91908110156133e1576060020190565b80518210156133e15760209160051b010190565b90600182811c9216801561347d575b602083101461344e57565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b91607f1691613443565b906040519182600082549261349b84613434565b808452936001811690811561350957506001146134c2575b506134c092500383612f66565b565b90506000929192526020600020906000915b8183106134ed5750509060206134c092820101386134b3565b60209193508060019154838589010152019101909184926134d4565b602093506134c09592507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0091501682840152151560051b820101386134b3565b601f82602094937fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0938186528686013760008582860101520116010190565b6040519061359582612f2e565b60006080838281528260208201528260408201528260608201520152565b906040516135c081612f2e565b60806001829460ff81546fffffffffffffffffffffffffffffffff8116865263ffffffff81861c16602087015260a01c161515604085015201546fffffffffffffffffffffffffffffffff81166060840152811c910152565b67ffffffffffffffff1660005260076020526130cc6004604060002001613487565b818110613646575050565b6000815560010161363b565b8181029291811591840414171561366557565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b6040517fa9059cbb00000000000000000000000000000000000000000000000000000000602082015273ffffffffffffffffffffffffffffffffffffffff9290921660248301526044808301939093529181526134c0916136f6606483612f66565b613ed2565b8051801561376b5760200361372d578051602082810191830183900312610ade57519060ff821161372d575060ff1690565b611432906040519182917f953576f7000000000000000000000000000000000000000000000000000000008352602060048401526024830190612fe1565b50507f000000000000000000000000000000000000000000000000000000000000000090565b9060ff8091169116039060ff821161366557565b60ff16604d811161366557600a0a90565b81156137c0570490565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b907f00000000000000000000000000000000000000000000000000000000000000009060ff82169060ff8116928284146138f5578284116138cb579061383491613791565b91604d60ff8416118015613892575b61385c575050906138566130cc926137a5565b90613652565b9091507fa9cb113d0000000000000000000000000000000000000000000000000000000060005260045260245260445260646000fd5b5061389c836137a5565b80156137c0577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff048411613843565b6138d491613791565b91604d60ff84161161385c575050906138ef6130cc926137a5565b906137b6565b5050505090565b90816020910312610ade57518015158103610ade5790565b73ffffffffffffffffffffffffffffffffffffffff60015416330361393557565b7f2b5c74de0000000000000000000000000000000000000000000000000000000060005260046000fd5b90805115613b9f5767ffffffffffffffff81516020830120921691826000526007602052613994816005604060002001614764565b15613b5b5760005260086020526040600020815167ffffffffffffffff8111612ee3576139c18254613434565b601f8111613b29575b506020601f8211600114613a635791613a3d827f7d628c9a1796743d365ab521a8b2a4686e419b3269919dc9145ea2ce853b54ea9593613a5395600091613a58575b507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8260011b9260031b1c19161790565b9055604051918291602083526020830190612fe1565b0390a2565b905084015138613a0c565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe082169083600052806000209160005b818110613b11575092613a539492600192827f7d628c9a1796743d365ab521a8b2a4686e419b3269919dc9145ea2ce853b54ea989610613ada575b5050811b019055611271565b8501517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60f88460031b161c191690553880613ace565b9192602060018192868a015181550194019201613a93565b613b5590836000526020600020601f840160051c81019160208510610a0957601f0160051c019061363b565b386139ca565b50906114326040519283927f393b8ad20000000000000000000000000000000000000000000000000000000084526004840152604060248401526044830190612fe1565b7f8579befe0000000000000000000000000000000000000000000000000000000060005260046000fd5b67ffffffffffffffff166000818152600660205260409020549092919015613ccb5791613cc860e092613c9485613c207f0350d63aa5f270e01729d00d627eeb8f3429772b1818c016c66a588a864f912b97613d8b565b846000526007602052613c37816040600020614012565b613c4083613d8b565b846000526007602052613c5a836002604060002001614012565b60405194855260208501906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b60808301906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565ba1565b827f1e670e4b0000000000000000000000000000000000000000000000000000000060005260045260246000fd5b9190820391821161366557565b613d0e613588565b506fffffffffffffffffffffffffffffffff6060820151166fffffffffffffffffffffffffffffffff8083511691613d6b6020850193613d65613d5863ffffffff87511642613cf9565b8560808901511690613652565b9061469d565b80821015613d8457505b16825263ffffffff4216905290565b9050613d75565b805115613e2b576fffffffffffffffffffffffffffffffff6040820151166fffffffffffffffffffffffffffffffff60208301511610613dc85750565b606490613e29604051917f8020d12400000000000000000000000000000000000000000000000000000000835260048301906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565bfd5b6fffffffffffffffffffffffffffffffff60408201511615801590613eb3575b613e525750565b606490613e29604051917fd68af9cc00000000000000000000000000000000000000000000000000000000835260048301906fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b506fffffffffffffffffffffffffffffffff6020820151161515613e4b565b73ffffffffffffffffffffffffffffffffffffffff613f61911691604092600080855193613f008786612f66565b602085527f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564602086015260208151910182855af13d1561400a573d91613f4583612fa7565b92613f5287519485612f66565b83523d6000602085013e614a00565b80519081613f6e57505050565b602080613f7f9383010191016138fc565b15613f875750565b608490517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e60448201527f6f742073756363656564000000000000000000000000000000000000000000006064820152fd5b606091614a00565b7f9ea3374b67bf275e6bb9c8ae68f9cae023e1c528b4b27e092f0bb209d3531c199161414b606092805461404f63ffffffff8260801c1642613cf9565b908161418a575b50506fffffffffffffffffffffffffffffffff600181602086015116928281541680851060001461418257508280855b16167fffffffffffffffffffffffffffffffff000000000000000000000000000000008254161781556140ff8651151582907fffffffffffffffffffffff00ffffffffffffffffffffffffffffffffffffffff74ff0000000000000000000000000000000000000000835492151560a01b169116179055565b60408601517fffffffffffffffffffffffffffffffff0000000000000000000000000000000060809190911b16939092166fffffffffffffffffffffffffffffffff1692909217910155565b613cc860405180926fffffffffffffffffffffffffffffffff60408092805115158552826020820151166020860152015116910152565b838091614086565b6fffffffffffffffffffffffffffffffff916141bf8392836141b86001880154948286169560801c90613652565b911661469d565b8082101561423e57505b83547fffffffffffffffffffffffff00000000ffffffffffffffffffffffffffffffff9290911692909216167fffffffffffffffffffffffff0000000000000000000000000000000000000000909116174260801b73ffffffff00000000000000000000000000000000161781553880614056565b90506141c9565b906040519182815491828252602082019060005260206000209260005b8181106142775750506134c092500383612f66565b8454835260019485019487945060209093019201614262565b80548210156133e15760005260206000200190600090565b6000818152600360205260409020548015614437577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff810181811161366557600254907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8201918211613665578181036143c8575b5050506002548015614399577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff01614356816002614290565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b19169055600255600052600360205260006040812055600190565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603160045260246000fd5b61441f6143d96143ea936002614290565b90549060031b1c9283926002614290565b81939154907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9060031b92831b921b19161790565b9055600052600360205260406000205538808061431d565b5050600090565b6000818152600660205260409020548015614437577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff810181811161366557600554907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82019182116136655781810361452f575b5050506005548015614399577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff016144ec816005614290565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b19169055600555600052600660205260006040812055600190565b6145516145406143ea936005614290565b90549060031b1c9283926005614290565b905560005260066020526040600020553880806144b3565b9060018201918160005282602052604060002054801515600014614694577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8101818111613665578254907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82019182116136655781810361465d575b50505080548015614399577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff019061461e8282614290565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82549160031b1b191690555560005260205260006040812055600190565b61467d61466d6143ea9386614290565b90549060031b1c92839286614290565b9055600052836020526040600020553880806145e6565b50505050600090565b9190820180921161366557565b806000526003602052604060002054156000146147045760025468010000000000000000811015612ee3576146eb6143ea8260018594016002556002614290565b9055600254906000526003602052604060002055600190565b50600090565b806000526006602052604060002054156000146147045760055468010000000000000000811015612ee35761474b6143ea8260018594016005556005614290565b9055600554906000526006602052604060002055600190565b60008281526001820160205260409020546144375780549068010000000000000000821015612ee357826147a26143ea846001809601855584614290565b905580549260005201602052604060002055600190565b9182549060ff8260a01c161580156149f8575b6149f2576fffffffffffffffffffffffffffffffff8216916001850190815461481163ffffffff6fffffffffffffffffffffffffffffffff83169360801c1642613cf9565b9081614954575b505084811061490857508383106148725750506148476fffffffffffffffffffffffffffffffff928392613cf9565b16167fffffffffffffffffffffffffffffffff00000000000000000000000000000000825416179055565b5460801c916148818185613cf9565b927fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff810190808211613665576148cf6148d49273ffffffffffffffffffffffffffffffffffffffff9661469d565b6137b6565b7fd0c8d23a000000000000000000000000000000000000000000000000000000006000526004526024521660445260646000fd5b828573ffffffffffffffffffffffffffffffffffffffff927f1a76572a000000000000000000000000000000000000000000000000000000006000526004526024521660445260646000fd5b8286929396116149c85761496f92613d659160801c90613652565b808410156149c35750825b85547fffffffffffffffffffffffff00000000ffffffffffffffffffffffffffffffff164260801b73ffffffff0000000000000000000000000000000016178655923880614818565b61497a565b7f9725942a0000000000000000000000000000000000000000000000000000000060005260046000fd5b50505050565b5082156147cc565b91929015614a7b5750815115614a14575090565b3b15614a1d5790565b60646040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152fd5b825190915015614a8e5750805190602001fd5b611432906040519182917f08c379a0000000000000000000000000000000000000000000000000000000008352602060048401526024830190612fe156fea164736f6c634300081a000a' diff --git a/ccip-sdk/src/token-admin/evm/evm-accept-admin-role.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-accept-admin-role.fork.test.ts new file mode 100644 index 00000000..08988cc5 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-accept-admin-role.fork.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, JsonRpcProvider, Wallet, ZeroAddress } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const SEPOLIA_REGISTRY_MODULE = '0xa3c796d480638d7476792230da1E2ADa86e031b0' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// Minimal ABI +const TAR_ABI = [ + { + inputs: [{ name: 'token', type: 'address' }], + name: 'getTokenConfig', + outputs: [ + { + type: 'tuple', + components: [ + { name: 'administrator', type: 'address' }, + { name: 'pendingAdministrator', type: 'address' }, + { name: 'tokenPool', type: 'address' }, + ], + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin acceptAdminRole Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let tokenAddress: string + let walletAddress: string + let tarAddress: string + + before(async () => { + // Fork Sepolia so we have a real Router with offRamps + anvilInstance = Instance.anvil({ + port: 8750, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, // latest + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + walletAddress = await wallet.getAddress() + + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // Deploy a token + const tokenResult = await admin.deployToken(wallet, { + name: 'Accept Admin Test Token', + symbol: 'AATT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + tokenAddress = tokenResult.tokenAddress + + // Discover TAR + tarAddress = await admin.getTokenAdminRegistryFor(SEPOLIA_ROUTER) + + // Propose admin first (required before accept) + await admin.proposeAdminRole(wallet, { + tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // Verify pending administrator is set after propose + // =========================================================================== + + it('should have pending administrator set after propose', async () => { + const tar = new Contract(tarAddress, TAR_ABI, provider) + const config = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config.pendingAdministrator as string).toLowerCase(), + walletAddress.toLowerCase(), + 'pendingAdministrator should match wallet address', + ) + }) + + // =========================================================================== + // acceptAdminRole — Happy Path + // =========================================================================== + + it('should accept admin role and verify on-chain', async () => { + const result = await admin.acceptAdminRole(wallet, { + tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: administrator should be set, pendingAdministrator cleared + const tar = new Contract(tarAddress, TAR_ABI, provider) + const config = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config.administrator as string).toLowerCase(), + walletAddress.toLowerCase(), + 'administrator should match wallet address', + ) + assert.equal( + (config.pendingAdministrator as string).toLowerCase(), + ZeroAddress.toLowerCase(), + 'pendingAdministrator should be cleared', + ) + }) + + // =========================================================================== + // generateUnsignedAcceptAdminRole — structure verification + // =========================================================================== + + it('should produce unsigned tx with correct shape', async () => { + // Deploy + propose another token for this test + const tokenResult = await admin.deployToken(wallet, { + name: 'Unsigned Accept Test', + symbol: 'UAT', + decimals: 18, + }) + + await admin.proposeAdminRole(wallet, { + tokenAddress: tokenResult.tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + const unsigned = await admin.generateUnsignedAcceptAdminRole({ + tokenAddress: tokenResult.tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.ok(tx.to, 'should have a to address (TAR contract)') + assert.equal( + (tx.to as string).toLowerCase(), + tarAddress.toLowerCase(), + 'to should be TAR address', + ) + assert.ok(tx.data, 'should have calldata') + }) + + // =========================================================================== + // transferAdminRole — Round-trip + // =========================================================================== + + it('should transfer admin to second wallet and verify on-chain', async () => { + // Second anvil account + const wallet2 = new Wallet( + '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', + provider, + ) + const wallet2Address = await wallet2.getAddress() + + // Transfer admin from wallet → wallet2 + const transferResult = await admin.transferAdminRole(wallet, { + tokenAddress, + newAdmin: wallet2Address, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(transferResult.txHash, 'should return tx hash') + assert.match(transferResult.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: pendingAdministrator should be wallet2 + const tar = new Contract(tarAddress, TAR_ABI, provider) + const config1 = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config1.administrator as string).toLowerCase(), + walletAddress.toLowerCase(), + 'administrator should still be wallet (not yet accepted)', + ) + assert.equal( + (config1.pendingAdministrator as string).toLowerCase(), + wallet2Address.toLowerCase(), + 'pendingAdministrator should be wallet2', + ) + + // Accept with wallet2 + const acceptResult = await admin.acceptAdminRole(wallet2, { + tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(acceptResult.txHash, 'accept should return tx hash') + + // Verify on-chain: administrator is now wallet2 + const config2 = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config2.administrator as string).toLowerCase(), + wallet2Address.toLowerCase(), + 'administrator should now be wallet2', + ) + assert.equal( + (config2.pendingAdministrator as string).toLowerCase(), + ZeroAddress.toLowerCase(), + 'pendingAdministrator should be cleared', + ) + + // Transfer back: wallet2 → wallet + const transferBackResult = await admin.transferAdminRole(wallet2, { + tokenAddress, + newAdmin: walletAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(transferBackResult.txHash, 'transfer back should return tx hash') + + // Accept with wallet + const acceptBackResult = await admin.acceptAdminRole(wallet, { + tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(acceptBackResult.txHash, 'accept back should return tx hash') + + // Verify on-chain: administrator is back to wallet + const config3 = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config3.administrator as string).toLowerCase(), + walletAddress.toLowerCase(), + 'administrator should be back to original wallet', + ) + assert.equal( + (config3.pendingAdministrator as string).toLowerCase(), + ZeroAddress.toLowerCase(), + 'pendingAdministrator should be cleared after round-trip', + ) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-accept-admin-role.test.ts b/ccip-sdk/src/token-admin/evm/evm-accept-admin-role.test.ts new file mode 100644 index 00000000..39bb974c --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-accept-admin-role.test.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPAcceptAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +describe('EVMTokenAdmin — acceptAdminRole', () => { + // ============================================================================= + // generateUnsignedAcceptAdminRole — Validation + // ============================================================================= + + describe('generateUnsignedAcceptAdminRole — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + } + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedAcceptAdminRole({ ...validParams, tokenAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptAdminRoleParamsInvalidError) + assert.equal(err.code, 'ACCEPT_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedAcceptAdminRole({ ...validParams, routerAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + }) + + // ============================================================================= + // acceptAdminRole — Wallet Validation + // ============================================================================= + + describe('acceptAdminRole — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + } + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.acceptAdminRole({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.acceptAdminRole(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-append-remote-pool-addresses.test.ts b/ccip-sdk/src/token-admin/evm/evm-append-remote-pool-addresses.test.ts new file mode 100644 index 00000000..fbfe2a03 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-append-remote-pool-addresses.test.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPAppendRemotePoolAddressesFailedError, + CCIPAppendRemotePoolAddressesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import TokenPool_2_0_ABI from '../../evm/abi/TokenPool_2_0.ts' +import { type NetworkInfo, CCIPVersion, ChainFamily, NetworkType } from '../../types.ts' +import type { AppendRemotePoolAddressesParams } from '../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +/** Creates an admin with mocked typeAndVersion to avoid RPC calls. */ +function makeAdminWithVersion(provider: JsonRpcProvider, version: string): EVMTokenAdmin { + const admin = makeAdmin(provider) + admin.typeAndVersion = async () => ['TokenPool', version, `TokenPool ${version}`] + return admin +} + +const validParams: AppendRemotePoolAddressesParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + remoteChainSelector: 16015286601757825753n, + remotePoolAddresses: ['0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD'], +} + +describe('EVMTokenAdmin — appendRemotePoolAddresses', () => { + // ============================================================================= + // generateUnsignedAppendRemotePoolAddresses — Validation + // ============================================================================= + + describe('generateUnsignedAppendRemotePoolAddresses — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses({ + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.code, 'APPEND_REMOTE_POOL_ADDRESSES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses({ + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses array', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses({ + ...validParams, + remotePoolAddresses: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty address in remotePoolAddresses', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses({ + ...validParams, + remotePoolAddresses: [''], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses[0]') + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedAppendRemotePoolAddresses — Happy path (v2.0) + // ============================================================================= + + describe('generateUnsignedAppendRemotePoolAddresses — happy path (v2.0)', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V2_0) + + it.after(() => provider.destroy()) + + it('should produce correct UnsignedEVMTx shape', async () => { + const unsigned = await admin.generateUnsignedAppendRemotePoolAddresses(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + assert.equal(tx.to, validParams.poolAddress) + assert.ok(tx.data) + + // Verify the function selector matches addRemotePool + const iface = new Interface(TokenPool_2_0_ABI) + const selector = iface.getFunction('addRemotePool')!.selector + assert.ok(tx.data.startsWith(selector)) + }) + + it('should produce 2 txs for 2 addresses', async () => { + const unsigned = await admin.generateUnsignedAppendRemotePoolAddresses({ + ...validParams, + remotePoolAddresses: [ + '0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD', + '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + ], + }) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 2) + }) + + it('should reject v1.5 pools', async () => { + const provider15 = new JsonRpcProvider('http://localhost:8545') + const admin15 = makeAdminWithVersion(provider15, CCIPVersion.V1_5) + + try { + await assert.rejects( + () => admin15.generateUnsignedAppendRemotePoolAddresses(validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesFailedError) + return true + }, + ) + } finally { + provider15.destroy() + } + }) + }) + + // ============================================================================= + // appendRemotePoolAddresses — Wallet Validation + // ============================================================================= + + describe('appendRemotePoolAddresses — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.appendRemotePoolAddresses({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.appendRemotePoolAddresses(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-apply-chain-updates.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-apply-chain-updates.fork.test.ts new file mode 100644 index 00000000..8f9f7a7f --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-apply-chain-updates.fork.test.ts @@ -0,0 +1,256 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, Interface, JsonRpcProvider, Wallet } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' +import TokenPool_2_0_ABI from '../../evm/abi/TokenPool_2_0.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const SEPOLIA_REGISTRY_MODULE = '0xa3c796d480638d7476792230da1E2ADa86e031b0' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// A valid chain selector for testing (Solana devnet) +const REMOTE_CHAIN_SELECTOR = 16423721717087811551n + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin applyChainUpdates Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let tokenAddress: string + let poolAddress: string + + before(async () => { + // Fork Sepolia so we have a real Router + anvilInstance = Instance.anvil({ + port: 8751, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // 1. Deploy token + const tokenResult = await admin.deployToken(wallet, { + name: 'Apply Chain Updates Test Token', + symbol: 'ACUT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + tokenAddress = tokenResult.tokenAddress + + // 2. Deploy pool + const poolResult = await admin.deployPool(wallet, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + poolAddress = poolResult.poolAddress + + // 3. Propose + accept admin (for setting pool later) + await admin.proposeAdminRole(wallet, { + tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + await admin.acceptAdminRole(wallet, { + tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + // 4. Set pool in TAR + await admin.setPool(wallet, { + tokenAddress, + poolAddress, + routerAddress: SEPOLIA_ROUTER, + }) + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // applyChainUpdates — Happy Path + // =========================================================================== + + it('should apply chain updates and verify on-chain', async () => { + const remotePoolAddress = '0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD' + const remoteTokenAddress = '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888' + + const result = await admin.applyChainUpdates(wallet, { + poolAddress, + remoteChainSelectorsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector: REMOTE_CHAIN_SELECTOR, + remotePoolAddresses: [remotePoolAddress], + remoteTokenAddress: remoteTokenAddress, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: isSupportedChain should return true + const pool = new Contract(poolAddress, TokenPool_2_0_ABI, provider) + const isSupported = await pool.getFunction('isSupportedChain')(REMOTE_CHAIN_SELECTOR) + assert.equal(isSupported, true, 'chain should be supported after applyChainUpdates') + }) + + // =========================================================================== + // generateUnsignedApplyChainUpdates — shape verification + // =========================================================================== + + it('should produce unsigned tx with correct shape', async () => { + const unsigned = await admin.generateUnsignedApplyChainUpdates({ + poolAddress, + remoteChainSelectorsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector: 999n, + remotePoolAddresses: ['0x1111111111111111111111111111111111111111'], + remoteTokenAddress: '0x2222222222222222222222222222222222222222', + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal( + (tx.to as string).toLowerCase(), + poolAddress.toLowerCase(), + 'to should be pool address', + ) + assert.ok(tx.data, 'should have calldata') + + // Verify function selector + const iface = new Interface(TokenPool_2_0_ABI) + const selector = iface.getFunction('applyChainUpdates')!.selector + assert.ok(tx.data.startsWith(selector), 'should use applyChainUpdates selector') + }) + + // =========================================================================== + // appendRemotePoolAddresses — Happy Path + // =========================================================================== + + it('should append a remote pool address to an existing chain config', async () => { + // The chain config was already created by the applyChainUpdates test above + const newRemotePool = '0x3333333333333333333333333333333333333333' + + const result = await admin.appendRemotePoolAddresses(wallet, { + poolAddress, + remoteChainSelector: REMOTE_CHAIN_SELECTOR, + remotePoolAddresses: [newRemotePool], + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: getRemotePools should include the new pool address + const pool = new Contract(poolAddress, TokenPool_2_0_ABI, provider) + const remotePools = (await pool.getFunction('getRemotePools')( + REMOTE_CHAIN_SELECTOR, + )) as string[] + // The new pool address should be in the list (encoded as 32-byte left-padded bytes) + assert.ok(remotePools.length >= 2, 'should have at least 2 remote pools after append') + }) + + // =========================================================================== + // removeRemotePoolAddresses — Happy Path + // =========================================================================== + + it('should remove a remote pool address and verify on-chain', async () => { + // The chain config was already created by applyChainUpdates + appendRemotePoolAddresses + // At this point there should be at least 2 remote pools + const pool = new Contract(poolAddress, TokenPool_2_0_ABI, provider) + const remotePoolsBefore = (await pool.getFunction('getRemotePools')( + REMOTE_CHAIN_SELECTOR, + )) as string[] + assert.ok(remotePoolsBefore.length >= 2, 'should have at least 2 remote pools before remove') + + // Remove the pool that was added by appendRemotePoolAddresses + const poolToRemove = '0x3333333333333333333333333333333333333333' + const result = await admin.removeRemotePoolAddresses(wallet, { + poolAddress, + remoteChainSelector: REMOTE_CHAIN_SELECTOR, + remotePoolAddresses: [poolToRemove], + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: getRemotePools should have one fewer pool + const remotePoolsAfter = (await pool.getFunction('getRemotePools')( + REMOTE_CHAIN_SELECTOR, + )) as string[] + assert.equal( + remotePoolsAfter.length, + remotePoolsBefore.length - 1, + 'should have one fewer remote pool after remove', + ) + }) + + // =========================================================================== + // deleteChainConfig — Happy Path + // =========================================================================== + + it('should delete a chain config and verify on-chain', async () => { + // The chain config was already created by applyChainUpdates test above + const pool = new Contract(poolAddress, TokenPool_2_0_ABI, provider) + + // Verify chain is currently supported + const isSupportedBefore = await pool.getFunction('isSupportedChain')(REMOTE_CHAIN_SELECTOR) + assert.equal(isSupportedBefore, true, 'chain should be supported before delete') + + const result = await admin.deleteChainConfig(wallet, { + poolAddress, + remoteChainSelector: REMOTE_CHAIN_SELECTOR, + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: isSupportedChain should return false + const isSupportedAfter = await pool.getFunction('isSupportedChain')(REMOTE_CHAIN_SELECTOR) + assert.equal(isSupportedAfter, false, 'chain should not be supported after deleteChainConfig') + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-apply-chain-updates.test.ts b/ccip-sdk/src/token-admin/evm/evm-apply-chain-updates.test.ts new file mode 100644 index 00000000..71843789 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-apply-chain-updates.test.ts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPApplyChainUpdatesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import TokenPool_2_0_ABI from '../../evm/abi/TokenPool_2_0.ts' +import { type NetworkInfo, CCIPVersion, ChainFamily, NetworkType } from '../../types.ts' +import type { ApplyChainUpdatesParams } from '../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +/** Creates an admin with mocked typeAndVersion to avoid RPC calls. */ +function makeAdminWithVersion(provider: JsonRpcProvider, version: string): EVMTokenAdmin { + const admin = makeAdmin(provider) + admin.typeAndVersion = async () => ['TokenPool', version, `TokenPool ${version}`] + return admin +} + +const validParams: ApplyChainUpdatesParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + remoteChainSelectorsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector: 16015286601757825753n, + remotePoolAddresses: ['0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD'], + remoteTokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], +} + +describe('EVMTokenAdmin — applyChainUpdates', () => { + // ============================================================================= + // generateUnsignedApplyChainUpdates — Validation + // ============================================================================= + + describe('generateUnsignedApplyChainUpdates — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates({ + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.code, 'APPLY_CHAIN_UPDATES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates({ + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remoteChainSelector: 0n }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates({ + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remotePoolAddresses: [] }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty remoteTokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates({ + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remoteTokenAddress: '' }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remoteTokenAddress') + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedApplyChainUpdates — Happy path (v2.0) + // ============================================================================= + + describe('generateUnsignedApplyChainUpdates — happy path (v2.0)', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V2_0) + + it.after(() => provider.destroy()) + + it('should produce correct UnsignedEVMTx shape', async () => { + const unsigned = await admin.generateUnsignedApplyChainUpdates(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + assert.equal(tx.to, validParams.poolAddress) + assert.ok(tx.data) + + // Verify the function selector matches applyChainUpdates + const iface = new Interface(TokenPool_2_0_ABI) + const selector = iface.getFunction('applyChainUpdates')!.selector + assert.ok(tx.data.startsWith(selector)) + }) + + it('should handle empty chainsToAdd with removes only', async () => { + const unsigned = await admin.generateUnsignedApplyChainUpdates({ + poolAddress: validParams.poolAddress, + remoteChainSelectorsToRemove: [16015286601757825753n], + chainsToAdd: [], + }) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(unsigned.transactions[0]!.to, validParams.poolAddress) + }) + }) + + // ============================================================================= + // applyChainUpdates — Wallet Validation + // ============================================================================= + + describe('applyChainUpdates — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.applyChainUpdates({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.applyChainUpdates(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-delete-chain-config.test.ts b/ccip-sdk/src/token-admin/evm/evm-delete-chain-config.test.ts new file mode 100644 index 00000000..daf7089b --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-delete-chain-config.test.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPDeleteChainConfigParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import TokenPool_2_0_ABI from '../../evm/abi/TokenPool_2_0.ts' +import { type NetworkInfo, CCIPVersion, ChainFamily, NetworkType } from '../../types.ts' +import type { DeleteChainConfigParams } from '../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +/** Creates an admin with mocked typeAndVersion to avoid RPC calls. */ +function makeAdminWithVersion(provider: JsonRpcProvider, version: string): EVMTokenAdmin { + const admin = makeAdmin(provider) + admin.typeAndVersion = async () => ['TokenPool', version, `TokenPool ${version}`] + return admin +} + +const validParams: DeleteChainConfigParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + remoteChainSelector: 16015286601757825753n, +} + +describe('EVMTokenAdmin — deleteChainConfig', () => { + // ============================================================================= + // generateUnsignedDeleteChainConfig — Validation + // ============================================================================= + + describe('generateUnsignedDeleteChainConfig — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeleteChainConfig({ + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPDeleteChainConfigParamsInvalidError) + assert.equal(err.code, 'DELETE_CHAIN_CONFIG_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeleteChainConfig({ + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPDeleteChainConfigParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedDeleteChainConfig — Happy path (v2.0) + // ============================================================================= + + describe('generateUnsignedDeleteChainConfig — happy path (v2.0)', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V2_0) + + it.after(() => provider.destroy()) + + it('should produce correct UnsignedEVMTx shape', async () => { + const unsigned = await admin.generateUnsignedDeleteChainConfig(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + assert.equal(tx.to, validParams.poolAddress) + assert.ok(tx.data) + + // Verify the function selector matches applyChainUpdates + const iface = new Interface(TokenPool_2_0_ABI) + const selector = iface.getFunction('applyChainUpdates')!.selector + assert.ok(tx.data.startsWith(selector), 'should use applyChainUpdates selector') + }) + }) + + // ============================================================================= + // generateUnsignedDeleteChainConfig — v1.5.1 + // ============================================================================= + + describe('generateUnsignedDeleteChainConfig — v1.6', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V1_6) + + it.after(() => provider.destroy()) + + it('should produce correct UnsignedEVMTx shape for v1.6', async () => { + const unsigned = await admin.generateUnsignedDeleteChainConfig(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + assert.equal(tx.to, validParams.poolAddress) + assert.ok(tx.data) + }) + }) + + // ============================================================================= + // deleteChainConfig — Wallet Validation + // ============================================================================= + + describe('deleteChainConfig — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.deleteChainConfig({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.deleteChainConfig(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-grant-mint-burn-access.test.ts b/ccip-sdk/src/token-admin/evm/evm-grant-mint-burn-access.test.ts new file mode 100644 index 00000000..a22766d0 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-grant-mint-burn-access.test.ts @@ -0,0 +1,243 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, JsonRpcProvider, id } from 'ethers' + +import BurnMintERC20ABI from './abi/BurnMintERC20.ts' +import FactoryBurnMintERC20ABI from './abi/FactoryBurnMintERC20.ts' +import { EVMTokenAdmin } from './index.ts' +import { + CCIPGrantMintBurnAccessParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { GrantMintBurnAccessParams } from '../types.ts' + +const MINTER_ROLE = id('MINTER_ROLE') +const BURNER_ROLE = id('BURNER_ROLE') + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const validParams: GrantMintBurnAccessParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + authority: '0xabcdef1234567890abcdef1234567890abcdef12', +} + +describe('EVMTokenAdmin — grantMintBurnAccess', () => { + // ============================================================================= + // generateUnsignedGrantMintBurnAccess — Validation + // ============================================================================= + + describe('generateUnsignedGrantMintBurnAccess — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + async () => + admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.code, 'GRANT_MINT_BURN_ACCESS_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty authority', async () => { + await assert.rejects( + async () => + admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + authority: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedGrantMintBurnAccess — Happy Path + // ============================================================================= + + describe('generateUnsignedGrantMintBurnAccess — happy path', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should return UnsignedEVMTx with correct family', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess(validParams) + assert.equal(unsigned.family, ChainFamily.EVM) + }) + + it('should return 1 transaction', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess(validParams) + assert.equal(unsigned.transactions.length, 1) + }) + + it('should target the token address', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess(validParams) + assert.equal(unsigned.transactions[0]!.to, validParams.tokenAddress) + }) + + it('should encode grantMintAndBurnRoles call data', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess(validParams) + const iface = new Interface(BurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantMintAndBurnRoles', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should encode grantMintAndBurnRoles when role is mintAndBurn', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + role: 'mintAndBurn', + }) + const iface = new Interface(BurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantMintAndBurnRoles', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should encode grantRole(MINTER_ROLE) when role is mint', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + role: 'mint', + }) + const iface = new Interface(BurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantRole', [MINTER_ROLE, validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should encode grantRole(BURNER_ROLE) when role is burn', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + role: 'burn', + }) + const iface = new Interface(BurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantRole', [BURNER_ROLE, validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should default to grantMintAndBurnRoles when role is omitted', async () => { + const withoutRole = { + tokenAddress: validParams.tokenAddress, + authority: validParams.authority, + } + const unsigned = admin.generateUnsignedGrantMintBurnAccess(withoutRole) + const iface = new Interface(BurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantMintAndBurnRoles', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + }) + + // ============================================================================= + // grantMintBurnAccess — Wallet Validation + // ============================================================================= + + describe('grantMintBurnAccess — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedGrantMintBurnAccess — FactoryBurnMintERC20 + // ============================================================================= + + describe('generateUnsignedGrantMintBurnAccess — factoryBurnMintERC20', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should encode grantMintRole when tokenType is factoryBurnMintERC20 and role is mint', () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + role: 'mint', + tokenType: 'factoryBurnMintERC20', + }) + const iface = new Interface(FactoryBurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantMintRole', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should encode grantBurnRole when tokenType is factoryBurnMintERC20 and role is burn', () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + role: 'burn', + tokenType: 'factoryBurnMintERC20', + }) + const iface = new Interface(FactoryBurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantBurnRole', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should encode grantMintAndBurnRoles when tokenType is factoryBurnMintERC20 and role is mintAndBurn', () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + ...validParams, + role: 'mintAndBurn', + tokenType: 'factoryBurnMintERC20', + }) + const iface = new Interface(FactoryBurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantMintAndBurnRoles', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should default to grantMintAndBurnRoles when role is omitted', () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + tokenAddress: validParams.tokenAddress, + authority: validParams.authority, + tokenType: 'factoryBurnMintERC20', + }) + const iface = new Interface(FactoryBurnMintERC20ABI) + const expected = iface.encodeFunctionData('grantMintAndBurnRoles', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-pool-deploy.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-pool-deploy.fork.test.ts new file mode 100644 index 00000000..c2ebdd99 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-pool-deploy.fork.test.ts @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, JsonRpcProvider, Wallet } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// Minimal ABI for verifying deployed pool state +const POOL_ABI = [ + { + inputs: [], + name: 'getToken', + outputs: [{ internalType: 'contract IERC20', name: 'token', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getRouter', + outputs: [{ internalType: 'address', name: 'router', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +] as const + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin Pool Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let tokenAddress: string + + before(async () => { + // Fork Sepolia so we have a real Router with getArmProxy() + anvilInstance = Instance.anvil({ + port: 8748, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, // latest + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // Deploy a token first (needed by pool constructor) + const tokenResult = await admin.deployToken(wallet, { + name: 'Pool Test Token', + symbol: 'PTT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + tokenAddress = tokenResult.tokenAddress + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // deployPool — BurnMint + // =========================================================================== + + it('should deploy BurnMintTokenPool and verify contract state', async () => { + const result = await admin.deployPool(wallet, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(result.poolAddress, 'should return pool address') + assert.match(result.poolAddress, /^0x[0-9a-fA-F]{40}$/, 'should be valid address') + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify deployed contract state + const pool = new Contract(result.poolAddress, POOL_ABI, provider) + const token: string = await pool.getFunction('getToken')() + const router: string = await pool.getFunction('getRouter')() + const owner: string = await pool.getFunction('owner')() + + assert.equal(token.toLowerCase(), tokenAddress.toLowerCase(), 'pool token should match') + assert.equal(router.toLowerCase(), SEPOLIA_ROUTER.toLowerCase(), 'pool router should match') + assert.equal( + owner.toLowerCase(), + (await wallet.getAddress()).toLowerCase(), + 'deployer should be owner', + ) + }) + + // =========================================================================== + // deployPool — LockRelease + // =========================================================================== + + it('should deploy LockReleaseTokenPool and verify contract state', async () => { + const result = await admin.deployPool(wallet, { + poolType: 'lock-release', + tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(result.poolAddress, 'should return pool address') + assert.match(result.poolAddress, /^0x[0-9a-fA-F]{40}$/) + assert.ok(result.txHash, 'should return tx hash') + + const pool = new Contract(result.poolAddress, POOL_ABI, provider) + const token: string = await pool.getFunction('getToken')() + const router: string = await pool.getFunction('getRouter')() + + assert.equal(token.toLowerCase(), tokenAddress.toLowerCase()) + assert.equal(router.toLowerCase(), SEPOLIA_ROUTER.toLowerCase()) + }) + + // =========================================================================== + // generateUnsignedDeployPool — manual sign + // =========================================================================== + + it('should produce unsigned tx that deploys successfully when signed manually', async () => { + const unsigned = await admin.generateUnsignedDeployPool({ + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal(tx.to, null) + + const populated = await wallet.populateTransaction(tx) + populated.from = undefined + const response = await wallet.sendTransaction(populated) + const receipt = await response.wait(1, 30_000) + + assert.ok(receipt, 'should get receipt') + assert.equal(receipt.status, 1, 'tx should succeed') + assert.ok(receipt.contractAddress, 'should have contract address') + + const pool = new Contract(receipt.contractAddress, POOL_ABI, provider) + const token: string = await pool.getFunction('getToken')() + assert.equal(token.toLowerCase(), tokenAddress.toLowerCase()) + }) + + // =========================================================================== + // deployPool — with allowlist + // =========================================================================== + + it('should deploy pool with non-empty allowlist', async () => { + const allowlist = [ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000002', + ] + + const result = await admin.deployPool(wallet, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + allowlist, + }) + + assert.ok(result.poolAddress) + assert.ok(result.txHash) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-pool-deploy.test.ts b/ccip-sdk/src/token-admin/evm/evm-pool-deploy.test.ts new file mode 100644 index 00000000..9f36b981 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-pool-deploy.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { CCIPPoolDeployParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +describe('EVMTokenAdmin — deployPool', () => { + // ============================================================================= + // generateUnsignedDeployPool — Validation + // ============================================================================= + + describe('generateUnsignedDeployPool — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + poolType: 'burn-mint' as const, + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + localTokenDecimals: 18, + routerAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + } + + it('should reject invalid poolType', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool({ + ...validParams, + poolType: 'invalid' as 'burn-mint', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.code, 'POOL_DEPLOY_PARAMS_INVALID') + assert.equal(err.context.param, 'poolType') + return true + }, + ) + }) + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedDeployPool({ ...validParams, tokenAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedDeployPool({ ...validParams, routerAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + + it('should accept lock-release poolType', async () => { + // This will fail at the RPC call (no running node), but validates params pass + await assert.rejects( + () => admin.generateUnsignedDeployPool({ ...validParams, poolType: 'lock-release' }), + (err: unknown) => { + // Should NOT be a params invalid error — params are valid + assert.ok(!(err instanceof CCIPPoolDeployParamsInvalidError)) + return true + }, + ) + }) + + it('should accept burn-mint poolType with valid params (fails at RPC)', async () => { + // Validation passes, fails at getArmProxy RPC call + await assert.rejects( + () => admin.generateUnsignedDeployPool(validParams), + (err: unknown) => { + assert.ok(!(err instanceof CCIPPoolDeployParamsInvalidError)) + return true + }, + ) + }) + }) + + // ============================================================================= + // deployPool — Wallet Validation + // ============================================================================= + + describe('deployPool — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => + admin.deployPool( + {}, + { + poolType: 'burn-mint', + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + localTokenDecimals: 18, + routerAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + }, + ), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => + admin.deployPool(null, { + poolType: 'burn-mint', + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + localTokenDecimals: 18, + routerAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-propose-admin-role.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-propose-admin-role.fork.test.ts new file mode 100644 index 00000000..0b019d08 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-propose-admin-role.fork.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, JsonRpcProvider, Wallet } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const SEPOLIA_REGISTRY_MODULE = '0xa3c796d480638d7476792230da1E2ADa86e031b0' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// Minimal ABIs +const TAR_ABI = [ + { + inputs: [{ name: 'token', type: 'address' }], + name: 'getTokenConfig', + outputs: [ + { + type: 'tuple', + components: [ + { name: 'administrator', type: 'address' }, + { name: 'pendingAdministrator', type: 'address' }, + { name: 'tokenPool', type: 'address' }, + ], + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +] as const + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin proposeAdminRole Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let tokenAddress: string + let walletAddress: string + let tarAddress: string + + before(async () => { + // Fork Sepolia so we have a real Router with offRamps + anvilInstance = Instance.anvil({ + port: 8749, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, // latest + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + walletAddress = await wallet.getAddress() + + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // Deploy a token first (needed to propose admin for it) + const tokenResult = await admin.deployToken(wallet, { + name: 'Admin Test Token', + symbol: 'ATT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + tokenAddress = tokenResult.tokenAddress + + // Discover TAR for verification + tarAddress = await admin.getTokenAdminRegistryFor(SEPOLIA_ROUTER) + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // getTokenAdminRegistryFor — inherited from EVMChain + // =========================================================================== + + it('should discover TokenAdminRegistry from router', async () => { + assert.ok(tarAddress, 'should return TAR address') + assert.match(tarAddress, /^0x[0-9a-fA-F]{40}$/, 'should be valid address') + assert.notEqual( + tarAddress.toLowerCase(), + '0x0000000000000000000000000000000000000000', + 'should not be zero address', + ) + }) + + // =========================================================================== + // proposeAdminRole — Happy Path (via registerAdminViaGetCCIPAdmin) + // =========================================================================== + + it('should propose admin role via registerAdminViaGetCCIPAdmin and verify on-chain', async () => { + // BurnMintERC20 implements getCCIPAdmin() (not owner()), so use 'getCCIPAdmin' method + const result = await admin.proposeAdminRole(wallet, { + tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: read getTokenConfig from the TAR + const tar = new Contract(tarAddress, TAR_ABI, provider) + const config = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config.pendingAdministrator as string).toLowerCase(), + walletAddress.toLowerCase(), + 'pendingAdministrator should match wallet address (token owner)', + ) + }) + + // =========================================================================== + // generateUnsignedProposeAdminRole — structure verification + // =========================================================================== + + it('should produce unsigned tx with correct shape', async () => { + const unsigned = admin.generateUnsignedProposeAdminRole({ + tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.ok(tx.to, 'should have a to address (RegistryModule contract)') + assert.equal( + (tx.to as string).toLowerCase(), + SEPOLIA_REGISTRY_MODULE.toLowerCase(), + 'to should be RegistryModule address', + ) + assert.ok(tx.data, 'should have calldata') + }) + + // =========================================================================== + // generateUnsignedProposeAdminRole — manual sign (token owner) + // =========================================================================== + + it('should produce unsigned tx that succeeds when signed by token owner', async () => { + // Deploy a fresh token for this test + const tokenResult = await admin.deployToken(wallet, { + name: 'Manual Sign Test Token', + symbol: 'MST', + decimals: 18, + }) + + const unsigned = admin.generateUnsignedProposeAdminRole({ + tokenAddress: tokenResult.tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + // Use the wallet (token owner) to submit + const tx = unsigned.transactions[0]! + const populated = await wallet.populateTransaction(tx) + const response = await wallet.sendTransaction(populated) + const receipt = await response.wait(1, 30_000) + + assert.ok(receipt, 'should get receipt') + assert.equal(receipt.status, 1, 'tx should succeed') + + // Verify on-chain + const tar = new Contract(tarAddress, TAR_ABI, provider) + const config = await tar.getFunction('getTokenConfig')(tokenResult.tokenAddress) + + assert.equal( + (config.pendingAdministrator as string).toLowerCase(), + walletAddress.toLowerCase(), + 'pendingAdministrator should match', + ) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-propose-admin-role.test.ts b/ccip-sdk/src/token-admin/evm/evm-propose-admin-role.test.ts new file mode 100644 index 00000000..ab7137ff --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-propose-admin-role.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPProposeAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +describe('EVMTokenAdmin — proposeAdminRole', () => { + // ============================================================================= + // generateUnsignedProposeAdminRole — Validation + // ============================================================================= + + describe('generateUnsignedProposeAdminRole — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + registryModuleAddress: '0xa3c796d480638d7476792230da1E2ADa86e031b0', + } + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + async () => admin.generateUnsignedProposeAdminRole({ ...validParams, tokenAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.code, 'PROPOSE_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty registryModuleAddress', async () => { + await assert.rejects( + async () => + admin.generateUnsignedProposeAdminRole({ ...validParams, registryModuleAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'registryModuleAddress') + return true + }, + ) + }) + + it('should produce unsigned tx with correct shape', async () => { + const unsigned = admin.generateUnsignedProposeAdminRole(validParams) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal( + (tx.to as string).toLowerCase(), + validParams.registryModuleAddress.toLowerCase(), + 'to should be registryModule address', + ) + assert.ok(tx.data, 'should have calldata') + // registerAdminViaOwner(address) selector = first 4 bytes + assert.ok(tx.data.startsWith('0x'), 'data should be hex') + }) + }) + + // ============================================================================= + // proposeAdminRole — Wallet Validation + // ============================================================================= + + describe('proposeAdminRole — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + registryModuleAddress: '0xa3c796d480638d7476792230da1E2ADa86e031b0', + } + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.proposeAdminRole({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.proposeAdminRole(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-remove-remote-pool-addresses.test.ts b/ccip-sdk/src/token-admin/evm/evm-remove-remote-pool-addresses.test.ts new file mode 100644 index 00000000..12feea7c --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-remove-remote-pool-addresses.test.ts @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPRemoveRemotePoolAddressesFailedError, + CCIPRemoveRemotePoolAddressesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import TokenPool_2_0_ABI from '../../evm/abi/TokenPool_2_0.ts' +import { type NetworkInfo, CCIPVersion, ChainFamily, NetworkType } from '../../types.ts' +import type { RemoveRemotePoolAddressesParams } from '../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +/** Creates an admin with mocked typeAndVersion to avoid RPC calls. */ +function makeAdminWithVersion(provider: JsonRpcProvider, version: string): EVMTokenAdmin { + const admin = makeAdmin(provider) + admin.typeAndVersion = async () => ['TokenPool', version, `TokenPool ${version}`] + return admin +} + +const validParams: RemoveRemotePoolAddressesParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + remoteChainSelector: 16015286601757825753n, + remotePoolAddresses: ['0xaabbccdd11223344556677889900aabbccdd1122'], +} + +describe('EVMTokenAdmin — removeRemotePoolAddresses', () => { + // ============================================================================= + // generateUnsignedRemoveRemotePoolAddresses — Validation + // ============================================================================= + + describe('generateUnsignedRemoveRemotePoolAddresses — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses({ + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.code, 'REMOVE_REMOTE_POOL_ADDRESSES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses({ + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses array', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses({ + ...validParams, + remotePoolAddresses: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty address in array', async () => { + await assert.rejects( + () => + admin.generateUnsignedRemoveRemotePoolAddresses({ + ...validParams, + remotePoolAddresses: [''], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses[0]') + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedRemoveRemotePoolAddresses — v1.5 rejection + // ============================================================================= + + describe('generateUnsignedRemoveRemotePoolAddresses — v1.5 rejection', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V1_5) + + it.after(() => provider.destroy()) + + it('should reject v1.5 pools', async () => { + await assert.rejects( + () => admin.generateUnsignedRemoveRemotePoolAddresses(validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesFailedError) + assert.ok(err.message.includes('not available on v1.5')) + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedRemoveRemotePoolAddresses — Happy path (v2.0) + // ============================================================================= + + describe('generateUnsignedRemoveRemotePoolAddresses — happy path (v2.0)', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V2_0) + + it.after(() => provider.destroy()) + + it('should produce correct UnsignedEVMTx shape', async () => { + const unsigned = await admin.generateUnsignedRemoveRemotePoolAddresses(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + assert.equal(tx.to, validParams.poolAddress) + assert.ok(tx.data) + + // Verify the function selector matches removeRemotePool + const iface = new Interface(TokenPool_2_0_ABI) + const selector = iface.getFunction('removeRemotePool')!.selector + assert.ok(tx.data.startsWith(selector), 'should use removeRemotePool selector') + }) + + it('should produce one tx per address', async () => { + const unsigned = await admin.generateUnsignedRemoveRemotePoolAddresses({ + ...validParams, + remotePoolAddresses: [ + '0xaabbccdd11223344556677889900aabbccdd1122', + '0x1111111111111111111111111111111111111111', + ], + }) + + assert.equal(unsigned.transactions.length, 2) + }) + }) + + // ============================================================================= + // generateUnsignedRemoveRemotePoolAddresses — v1.6 + // ============================================================================= + + describe('generateUnsignedRemoveRemotePoolAddresses — v1.6', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V1_6) + + it.after(() => provider.destroy()) + + it('should produce correct UnsignedEVMTx shape for v1.6', async () => { + const unsigned = await admin.generateUnsignedRemoveRemotePoolAddresses(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + assert.equal(tx.to, validParams.poolAddress) + assert.ok(tx.data) + }) + }) + + // ============================================================================= + // removeRemotePoolAddresses — Wallet Validation + // ============================================================================= + + describe('removeRemotePoolAddresses — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.removeRemotePoolAddresses({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.removeRemotePoolAddresses(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-revoke-mint-burn-access.test.ts b/ccip-sdk/src/token-admin/evm/evm-revoke-mint-burn-access.test.ts new file mode 100644 index 00000000..b49bf0aa --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-revoke-mint-burn-access.test.ts @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, JsonRpcProvider, id } from 'ethers' + +import BurnMintERC20ABI from './abi/BurnMintERC20.ts' +import FactoryBurnMintERC20ABI from './abi/FactoryBurnMintERC20.ts' +import { EVMTokenAdmin } from './index.ts' +import { + CCIPRevokeMintBurnAccessParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { RevokeMintBurnAccessParams } from '../types.ts' + +const MINTER_ROLE = id('MINTER_ROLE') +const BURNER_ROLE = id('BURNER_ROLE') + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const validParams: RevokeMintBurnAccessParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + authority: '0xabcdef1234567890abcdef1234567890abcdef12', + role: 'mint', +} + +describe('EVMTokenAdmin — revokeMintBurnAccess', () => { + // ============================================================================= + // generateUnsignedRevokeMintBurnAccess — Validation + // ============================================================================= + + describe('generateUnsignedRevokeMintBurnAccess — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty tokenAddress', () => { + assert.throws( + () => + admin.generateUnsignedRevokeMintBurnAccess({ + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.equal(err.code, 'REVOKE_MINT_BURN_ACCESS_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty authority', () => { + assert.throws( + () => + admin.generateUnsignedRevokeMintBurnAccess({ + ...validParams, + authority: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + return true + }, + ) + }) + + it('should reject invalid role', () => { + assert.throws( + () => + admin.generateUnsignedRevokeMintBurnAccess({ + ...validParams, + role: 'invalid' as 'mint', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'role') + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedRevokeMintBurnAccess — Happy Path + // ============================================================================= + + describe('generateUnsignedRevokeMintBurnAccess — happy path', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should return UnsignedEVMTx with correct family', () => { + const unsigned = admin.generateUnsignedRevokeMintBurnAccess(validParams) + assert.equal(unsigned.family, ChainFamily.EVM) + }) + + it('should return 1 transaction', () => { + const unsigned = admin.generateUnsignedRevokeMintBurnAccess(validParams) + assert.equal(unsigned.transactions.length, 1) + }) + + it('should target the token address', () => { + const unsigned = admin.generateUnsignedRevokeMintBurnAccess(validParams) + assert.equal(unsigned.transactions[0]!.to, validParams.tokenAddress) + }) + + it('should encode revokeRole(MINTER_ROLE) when role is mint', () => { + const unsigned = admin.generateUnsignedRevokeMintBurnAccess({ + ...validParams, + role: 'mint', + }) + const iface = new Interface(BurnMintERC20ABI) + const expected = iface.encodeFunctionData('revokeRole', [MINTER_ROLE, validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should encode revokeRole(BURNER_ROLE) when role is burn', () => { + const unsigned = admin.generateUnsignedRevokeMintBurnAccess({ + ...validParams, + role: 'burn', + }) + const iface = new Interface(BurnMintERC20ABI) + const expected = iface.encodeFunctionData('revokeRole', [BURNER_ROLE, validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + }) + + // ============================================================================= + // revokeMintBurnAccess — Wallet Validation + // ============================================================================= + + describe('revokeMintBurnAccess — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.revokeMintBurnAccess({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.revokeMintBurnAccess(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedRevokeMintBurnAccess — FactoryBurnMintERC20 + // ============================================================================= + + describe('generateUnsignedRevokeMintBurnAccess — factoryBurnMintERC20', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should encode revokeMintRole when tokenType is factoryBurnMintERC20 and role is mint', () => { + const unsigned = admin.generateUnsignedRevokeMintBurnAccess({ + ...validParams, + tokenType: 'factoryBurnMintERC20', + }) + const iface = new Interface(FactoryBurnMintERC20ABI) + const expected = iface.encodeFunctionData('revokeMintRole', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + + it('should encode revokeBurnRole when tokenType is factoryBurnMintERC20 and role is burn', () => { + const unsigned = admin.generateUnsignedRevokeMintBurnAccess({ + ...validParams, + role: 'burn', + tokenType: 'factoryBurnMintERC20', + }) + const iface = new Interface(FactoryBurnMintERC20ABI) + const expected = iface.encodeFunctionData('revokeBurnRole', [validParams.authority]) + assert.equal(unsigned.transactions[0]!.data, expected) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-set-pool.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-set-pool.fork.test.ts new file mode 100644 index 00000000..da27f98c --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-set-pool.fork.test.ts @@ -0,0 +1,280 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, JsonRpcProvider, Wallet, ZeroAddress } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const SEPOLIA_REGISTRY_MODULE = '0xa3c796d480638d7476792230da1E2ADa86e031b0' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// Minimal ABI for reading TAR config +const TAR_ABI = [ + { + inputs: [{ name: 'token', type: 'address' }], + name: 'getTokenConfig', + outputs: [ + { + type: 'tuple', + components: [ + { name: 'administrator', type: 'address' }, + { name: 'pendingAdministrator', type: 'address' }, + { name: 'tokenPool', type: 'address' }, + ], + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin setPool Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let tokenAddress: string + let poolAddress: string + let tarAddress: string + + before(async () => { + anvilInstance = Instance.anvil({ + port: 8753, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // 1. Deploy token + const tokenResult = await admin.deployToken(wallet, { + name: 'Set Pool Test Token', + symbol: 'SPTT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + tokenAddress = tokenResult.tokenAddress + + // 2. Deploy pool + const poolResult = await admin.deployPool(wallet, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + poolAddress = poolResult.poolAddress + + // 3. Propose + accept admin + await admin.proposeAdminRole(wallet, { + tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + await admin.acceptAdminRole(wallet, { + tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + // Discover TAR for verification + tarAddress = await admin.getTokenAdminRegistryFor(SEPOLIA_ROUTER) + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // Verify pool is not set before setPool + // =========================================================================== + + it('should have no pool set before setPool', async () => { + const tar = new Contract(tarAddress, TAR_ABI, provider) + const config = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config.tokenPool as string).toLowerCase(), + ZeroAddress.toLowerCase(), + 'tokenPool should be zero address before setPool', + ) + }) + + // =========================================================================== + // setPool — Happy Path + // =========================================================================== + + it('should set pool and verify on-chain', async () => { + const result = await admin.setPool(wallet, { + tokenAddress, + poolAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: tokenPool should be set + const tar = new Contract(tarAddress, TAR_ABI, provider) + const config = await tar.getFunction('getTokenConfig')(tokenAddress) + + assert.equal( + (config.tokenPool as string).toLowerCase(), + poolAddress.toLowerCase(), + 'tokenPool should match pool address after setPool', + ) + }) + + // =========================================================================== + // generateUnsignedSetPool — structure verification + // =========================================================================== + + it('should produce unsigned tx with correct shape', async () => { + // Deploy another token + pool for this test + const tokenResult = await admin.deployToken(wallet, { + name: 'Unsigned SetPool Test', + symbol: 'USPT', + decimals: 18, + }) + + const poolResult = await admin.deployPool(wallet, { + poolType: 'burn-mint', + tokenAddress: tokenResult.tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + + await admin.proposeAdminRole(wallet, { + tokenAddress: tokenResult.tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + await admin.acceptAdminRole(wallet, { + tokenAddress: tokenResult.tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + const unsigned = await admin.generateUnsignedSetPool({ + tokenAddress: tokenResult.tokenAddress, + poolAddress: poolResult.poolAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.ok(tx.to, 'should have a to address (TAR contract)') + assert.equal( + (tx.to as string).toLowerCase(), + tarAddress.toLowerCase(), + 'to should be TAR address', + ) + assert.ok(tx.data, 'should have calldata') + }) + + // =========================================================================== + // grantMintBurnAccess — Happy Path + // =========================================================================== + + it('should grant mint/burn access to pool and verify on-chain', async () => { + const result = await admin.grantMintBurnAccess(wallet, { + tokenAddress, + authority: poolAddress, + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain using hasRole directly (faster than getMintBurnRoles which scans events) + const ROLE_ABI = [ + { + inputs: [ + { name: 'role', type: 'bytes32' }, + { name: 'account', type: 'address' }, + ], + name: 'hasRole', + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'MINTER_ROLE', + outputs: [{ name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'BURNER_ROLE', + outputs: [{ name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + ] as const + + const token = new Contract(tokenAddress, ROLE_ABI, provider) + const [minterRole, burnerRole] = await Promise.all([ + token.getFunction('MINTER_ROLE')() as Promise, + token.getFunction('BURNER_ROLE')() as Promise, + ]) + + const [hasMinter, hasBurner] = await Promise.all([ + token.getFunction('hasRole')(minterRole, poolAddress) as Promise, + token.getFunction('hasRole')(burnerRole, poolAddress) as Promise, + ]) + + assert.ok(hasMinter, 'pool should have MINTER_ROLE') + assert.ok(hasBurner, 'pool should have BURNER_ROLE') + }) + + // =========================================================================== + // generateUnsignedGrantMintBurnAccess — structure verification + // =========================================================================== + + it('should produce unsigned grantMintBurnAccess tx with correct shape', async () => { + const unsigned = admin.generateUnsignedGrantMintBurnAccess({ + tokenAddress, + authority: poolAddress, + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal( + (tx.to as string).toLowerCase(), + tokenAddress.toLowerCase(), + 'to should be token address', + ) + assert.ok(tx.data, 'should have calldata') + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-set-pool.test.ts b/ccip-sdk/src/token-admin/evm/evm-set-pool.test.ts new file mode 100644 index 00000000..5dab8b1c --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-set-pool.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { CCIPSetPoolParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +describe('EVMTokenAdmin — setPool', () => { + // ============================================================================= + // generateUnsignedSetPool — Validation + // ============================================================================= + + describe('generateUnsignedSetPool — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + poolAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + } + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedSetPool({ ...validParams, tokenAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetPoolParamsInvalidError) + assert.equal(err.code, 'SET_POOL_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedSetPool({ ...validParams, poolAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetPoolParamsInvalidError) + assert.equal(err.code, 'SET_POOL_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedSetPool({ ...validParams, routerAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetPoolParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + }) + + // ============================================================================= + // setPool — Wallet Validation + // ============================================================================= + + describe('setPool — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + poolAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + } + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.setPool({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.setPool(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-set-rate-limit-admin.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-set-rate-limit-admin.fork.test.ts new file mode 100644 index 00000000..68370b0f --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-set-rate-limit-admin.fork.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, JsonRpcProvider, Wallet } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' +import TokenPool_1_6_ABI from '../../evm/abi/LockReleaseTokenPool_1_6_1.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// Second anvil account for the new rate limit admin +const NEW_RATE_LIMIT_ADMIN = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin setRateLimitAdmin Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let poolAddress: string + + before(async () => { + // Fork Sepolia so we have a real Router + anvilInstance = Instance.anvil({ + port: 8753, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // 1. Deploy token + const tokenResult = await admin.deployToken(wallet, { + name: 'Rate Limit Admin Test Token', + symbol: 'RLAT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + + // 2. Deploy pool (deploys v1.6.1 BurnMintTokenPool) + const poolResult = await admin.deployPool(wallet, { + poolType: 'burn-mint', + tokenAddress: tokenResult.tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + poolAddress = poolResult.poolAddress + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // setRateLimitAdmin — Happy Path (v1.6 pool) + // =========================================================================== + + it('should set rate limit admin and verify on-chain via getRateLimitAdmin', async () => { + const result = await admin.setRateLimitAdmin(wallet, { + poolAddress, + rateLimitAdmin: NEW_RATE_LIMIT_ADMIN, + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: v1.6 has getRateLimitAdmin() + const pool = new Contract(poolAddress, TokenPool_1_6_ABI, provider) + const rateLimitAdmin: string = await pool.getFunction('getRateLimitAdmin')() + assert.equal( + rateLimitAdmin.toLowerCase(), + NEW_RATE_LIMIT_ADMIN.toLowerCase(), + 'rate limit admin should match', + ) + }) + + it('should update rate limit admin to a different address', async () => { + const anotherAdmin = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' // anvil account #2 + await admin.setRateLimitAdmin(wallet, { + poolAddress, + rateLimitAdmin: anotherAdmin, + }) + + // Verify on-chain + const pool = new Contract(poolAddress, TokenPool_1_6_ABI, provider) + const rateLimitAdmin: string = await pool.getFunction('getRateLimitAdmin')() + assert.equal( + rateLimitAdmin.toLowerCase(), + anotherAdmin.toLowerCase(), + 'rate limit admin should be updated', + ) + }) + + // =========================================================================== + // generateUnsignedSetRateLimitAdmin — shape verification + // =========================================================================== + + it('should produce unsigned tx with correct shape', async () => { + const unsigned = await admin.generateUnsignedSetRateLimitAdmin({ + poolAddress, + rateLimitAdmin: NEW_RATE_LIMIT_ADMIN, + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal( + (tx.to as string).toLowerCase(), + poolAddress.toLowerCase(), + 'to should be pool address', + ) + assert.ok(tx.data, 'should have calldata') + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-set-rate-limit-admin.test.ts b/ccip-sdk/src/token-admin/evm/evm-set-rate-limit-admin.test.ts new file mode 100644 index 00000000..bca57b3a --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-set-rate-limit-admin.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPSetRateLimitAdminParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { SetRateLimitAdminParams } from '../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +const validParams: SetRateLimitAdminParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + rateLimitAdmin: '0xabcdef1234567890abcdef1234567890abcdef12', +} + +describe('EVMTokenAdmin — setRateLimitAdmin', () => { + // ============================================================================= + // generateUnsignedSetRateLimitAdmin — Validation + // ============================================================================= + + describe('generateUnsignedSetRateLimitAdmin — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetRateLimitAdmin({ + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimitAdminParamsInvalidError) + assert.equal(err.code, 'SET_RATE_LIMIT_ADMIN_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty rateLimitAdmin', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetRateLimitAdmin({ + ...validParams, + rateLimitAdmin: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimitAdminParamsInvalidError) + assert.equal(err.context.param, 'rateLimitAdmin') + return true + }, + ) + }) + }) + + // ============================================================================= + // setRateLimitAdmin — Wallet Validation + // ============================================================================= + + describe('setRateLimitAdmin — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.setRateLimitAdmin({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.setRateLimitAdmin(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-set-rate-limiter-config.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-set-rate-limiter-config.fork.test.ts new file mode 100644 index 00000000..a506b338 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-set-rate-limiter-config.fork.test.ts @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, Interface, JsonRpcProvider, Wallet } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' +import TokenPool_1_6_ABI from '../../evm/abi/LockReleaseTokenPool_1_6_1.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const SEPOLIA_REGISTRY_MODULE = '0xa3c796d480638d7476792230da1E2ADa86e031b0' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// A valid chain selector for testing (Solana devnet) +const REMOTE_CHAIN_SELECTOR = 16423721717087811551n + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin setChainRateLimiterConfig Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let poolAddress: string + + before(async () => { + // Fork Sepolia so we have a real Router + anvilInstance = Instance.anvil({ + port: 8752, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // 1. Deploy token + const tokenResult = await admin.deployToken(wallet, { + name: 'Rate Limiter Config Test Token', + symbol: 'RLCT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + const tokenAddress = tokenResult.tokenAddress + + // 2. Deploy pool (deploys v1.6.1 BurnMintTokenPool) + const poolResult = await admin.deployPool(wallet, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + poolAddress = poolResult.poolAddress + + // 3. Propose + accept admin + await admin.proposeAdminRole(wallet, { + tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + await admin.acceptAdminRole(wallet, { + tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + // 4. Set pool in TAR + await admin.setPool(wallet, { + tokenAddress, + poolAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + // 5. Apply chain updates (add a remote chain so we can set rate limits) + await admin.applyChainUpdates(wallet, { + poolAddress, + remoteChainSelectorsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector: REMOTE_CHAIN_SELECTOR, + remotePoolAddresses: ['0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD'], + remoteTokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + }) + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // setChainRateLimiterConfig — Happy Path (v1.6 pool) + // =========================================================================== + + it('should set rate limiter config and verify on-chain', async () => { + const result = await admin.setChainRateLimiterConfig(wallet, { + poolAddress, + chainConfigs: [ + { + remoteChainSelector: REMOTE_CHAIN_SELECTOR, + outboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000', + rate: '167000000000000000', + }, + inboundRateLimiterConfig: { + isEnabled: true, + capacity: '200000000000000000000', + rate: '334000000000000000', + }, + }, + ], + }) + + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify on-chain: v1.6 has separate getters per direction + const pool = new Contract(poolAddress, TokenPool_1_6_ABI, provider) + + const outbound = await pool.getFunction('getCurrentOutboundRateLimiterState')( + REMOTE_CHAIN_SELECTOR, + ) + assert.equal(outbound.isEnabled, true, 'outbound should be enabled') + assert.equal(outbound.capacity, 100000000000000000000n, 'outbound capacity should match') + assert.equal(outbound.rate, 167000000000000000n, 'outbound rate should match') + + const inbound = await pool.getFunction('getCurrentInboundRateLimiterState')( + REMOTE_CHAIN_SELECTOR, + ) + assert.equal(inbound.isEnabled, true, 'inbound should be enabled') + assert.equal(inbound.capacity, 200000000000000000000n, 'inbound capacity should match') + assert.equal(inbound.rate, 334000000000000000n, 'inbound rate should match') + }) + + // =========================================================================== + // generateUnsignedSetChainRateLimiterConfig — shape verification + // =========================================================================== + + it('should produce unsigned tx with correct shape for v1.6', async () => { + const unsigned = await admin.generateUnsignedSetChainRateLimiterConfig({ + poolAddress, + chainConfigs: [ + { + remoteChainSelector: REMOTE_CHAIN_SELECTOR, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + }) + + // v1.6 produces one tx per chain config + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal( + (tx.to as string).toLowerCase(), + poolAddress.toLowerCase(), + 'to should be pool address', + ) + assert.ok(tx.data, 'should have calldata') + + // Verify function selector matches setChainRateLimiterConfig (v1.6) + const iface = new Interface(TokenPool_1_6_ABI) + const selector = iface.getFunction('setChainRateLimiterConfig')!.selector + assert.ok( + tx.data.startsWith(selector), + 'should use setChainRateLimiterConfig selector for v1.6', + ) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-set-rate-limiter-config.test.ts b/ccip-sdk/src/token-admin/evm/evm-set-rate-limiter-config.test.ts new file mode 100644 index 00000000..60957d55 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-set-rate-limiter-config.test.ts @@ -0,0 +1,292 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPSetRateLimiterConfigParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import TokenPool_1_6_ABI from '../../evm/abi/LockReleaseTokenPool_1_6_1.ts' +import TokenPool_2_0_ABI from '../../evm/abi/TokenPool_2_0.ts' +import { type NetworkInfo, CCIPVersion, ChainFamily, NetworkType } from '../../types.ts' +import type { SetChainRateLimiterConfigParams } from '../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +/** Creates an admin with mocked typeAndVersion to avoid RPC calls. */ +function makeAdminWithVersion(provider: JsonRpcProvider, version: string): EVMTokenAdmin { + const admin = makeAdmin(provider) + admin.typeAndVersion = async () => ['TokenPool', version, `TokenPool ${version}`] + return admin +} + +const validParams: SetChainRateLimiterConfigParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + chainConfigs: [ + { + remoteChainSelector: 16015286601757825753n, + outboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + inboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + }, + ], +} + +describe('EVMTokenAdmin — setChainRateLimiterConfig', () => { + // ============================================================================= + // generateUnsignedSetChainRateLimiterConfig — Validation + // ============================================================================= + + describe('generateUnsignedSetChainRateLimiterConfig — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig({ + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.code, 'SET_RATE_LIMITER_CONFIG_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty chainConfigs', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig({ + ...validParams, + chainConfigs: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig({ + ...validParams, + chainConfigs: [{ ...validParams.chainConfigs[0]!, remoteChainSelector: 0n }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs[0].remoteChainSelector') + return true + }, + ) + }) + + it('should reject invalid capacity string', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig({ + ...validParams, + chainConfigs: [ + { + ...validParams.chainConfigs[0]!, + outboundRateLimiterConfig: { isEnabled: true, capacity: 'abc', rate: '0' }, + }, + ], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs[0].outboundRateLimiterConfig.capacity') + return true + }, + ) + }) + + it('should reject empty rate string', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig({ + ...validParams, + chainConfigs: [ + { + ...validParams.chainConfigs[0]!, + inboundRateLimiterConfig: { isEnabled: true, capacity: '100', rate: '' }, + }, + ], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs[0].inboundRateLimiterConfig.rate') + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedSetChainRateLimiterConfig — Happy path (v2.0) + // ============================================================================= + + describe('generateUnsignedSetChainRateLimiterConfig — happy path (v2.0)', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V2_0) + + it.after(() => provider.destroy()) + + it('should produce correct UnsignedEVMTx shape', async () => { + const unsigned = await admin.generateUnsignedSetChainRateLimiterConfig(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + assert.equal(tx.to, validParams.poolAddress) + assert.ok(tx.data) + + // Verify the function selector matches setRateLimitConfig (v2.0) + const iface = new Interface(TokenPool_2_0_ABI) + const selector = iface.getFunction('setRateLimitConfig')!.selector + assert.ok(tx.data.startsWith(selector)) + }) + + it('should handle multiple chain configs in single tx', async () => { + const multiParams: SetChainRateLimiterConfigParams = { + poolAddress: validParams.poolAddress, + chainConfigs: [ + validParams.chainConfigs[0]!, + { + remoteChainSelector: 3734403246176062136n, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + } + + const unsigned = await admin.generateUnsignedSetChainRateLimiterConfig(multiParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.ok(unsigned.transactions[0]!.data) + }) + + it('should handle disabled rate limiters with zero values', async () => { + const disabledParams: SetChainRateLimiterConfigParams = { + poolAddress: validParams.poolAddress, + chainConfigs: [ + { + remoteChainSelector: 16015286601757825753n, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + } + + const unsigned = await admin.generateUnsignedSetChainRateLimiterConfig(disabledParams) + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + }) + }) + + // ============================================================================= + // generateUnsignedSetChainRateLimiterConfig — Happy path (v1.6) + // ============================================================================= + + describe('generateUnsignedSetChainRateLimiterConfig — happy path (v1.6)', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdminWithVersion(provider, CCIPVersion.V1_6) + + it.after(() => provider.destroy()) + + it('should use setChainRateLimiterConfig selector for v1.6', async () => { + const unsigned = await admin.generateUnsignedSetChainRateLimiterConfig(validParams) + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + + const tx = unsigned.transactions[0]! + const iface = new Interface(TokenPool_1_6_ABI) + const selector = iface.getFunction('setChainRateLimiterConfig')!.selector + assert.ok((tx.data as string).startsWith(selector)) + }) + + it('should produce one tx per chain config for v1.6', async () => { + const multiParams: SetChainRateLimiterConfigParams = { + poolAddress: validParams.poolAddress, + chainConfigs: [ + validParams.chainConfigs[0]!, + { + remoteChainSelector: 3734403246176062136n, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + } + + const unsigned = await admin.generateUnsignedSetChainRateLimiterConfig(multiParams) + + assert.equal(unsigned.transactions.length, 2) + }) + }) + + // ============================================================================= + // setChainRateLimiterConfig — Wallet Validation + // ============================================================================= + + describe('setChainRateLimiterConfig — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.setChainRateLimiterConfig({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.setChainRateLimiterConfig(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-token-admin.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-token-admin.fork.test.ts new file mode 100644 index 00000000..f30bc96f --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-token-admin.fork.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, JsonRpcProvider, Wallet } from 'ethers' +import { Instance } from 'prool' + +import BurnMintERC20ABI from './abi/BurnMintERC20.ts' +import { EVMTokenAdmin } from './index.ts' + +// ── Constants ── + +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin Fork Tests', { skip, timeout: 60_000 }, () => { + let provider: JsonRpcProvider + let wallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + + before(async () => { + anvilInstance = Instance.anvil({ port: 8747 }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + wallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // deployToken — Full integration + // =========================================================================== + + it('should deploy BurnMintERC20 and verify all contract state', async () => { + const maxSupply = 1_000_000n * 10n ** 18n + const initialSupply = 10_000n * 10n ** 18n + + const result = await admin.deployToken(wallet, { + name: 'Test Token', + symbol: 'TT', + decimals: 18, + maxSupply, + initialSupply, + }) + + // Verify result shape + assert.ok(result.tokenAddress, 'should return token address') + assert.match(result.tokenAddress, /^0x[0-9a-fA-F]{40}$/, 'should be valid address') + assert.ok(result.txHash, 'should return tx hash') + assert.match(result.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify all deployed contract state + const token = new Contract(result.tokenAddress, BurnMintERC20ABI, provider) + const name: string = await token.getFunction('name')() + const symbol: string = await token.getFunction('symbol')() + const decimals: bigint = await token.getFunction('decimals')() + const supply: bigint = await token.getFunction('totalSupply')() + const max: bigint = await token.getFunction('maxSupply')() + const balance: bigint = await token.getFunction('balanceOf')(await wallet.getAddress()) + const ccipAdmin: string = await token.getFunction('getCCIPAdmin')() + + assert.equal(name, 'Test Token') + assert.equal(symbol, 'TT') + assert.equal(decimals, 18n) + assert.equal(supply, initialSupply) + assert.equal(max, maxSupply) + assert.equal(balance, initialSupply, 'deployer should receive initial supply') + assert.equal( + ccipAdmin.toLowerCase(), + (await wallet.getAddress()).toLowerCase(), + 'deployer should be CCIP admin', + ) + }) + + it('should deploy with 0 decimals and unlimited supply', async () => { + const result = await admin.deployToken(wallet, { + name: 'Zero Decimal', + symbol: 'ZD', + decimals: 0, + maxSupply: 0n, + initialSupply: 100n, + }) + + const token = new Contract(result.tokenAddress, BurnMintERC20ABI, provider) + const decimals: bigint = await token.getFunction('decimals')() + const supply: bigint = await token.getFunction('totalSupply')() + const max: bigint = await token.getFunction('maxSupply')() + + assert.equal(decimals, 0n) + assert.equal(supply, 100n) + assert.equal(max, 0n, 'maxSupply 0 means unlimited') + }) + + // =========================================================================== + // generateUnsignedDeployToken — Verify unsigned tx can be signed manually + // =========================================================================== + + it('should produce unsigned tx that deploys successfully when signed manually', async () => { + const unsigned = await admin.generateUnsignedDeployToken({ + name: 'Manual Token', + symbol: 'MAN', + decimals: 8, + initialSupply: 500n, + }) + + assert.equal(unsigned.transactions.length, 1) + const tx = unsigned.transactions[0]! + assert.equal(tx.to, null) + + // Sign and send manually + const populated = await wallet.populateTransaction(tx) + populated.from = undefined + const response = await wallet.sendTransaction(populated) + const receipt = await response.wait(1, 30_000) + + assert.ok(receipt, 'should get receipt') + assert.equal(receipt.status, 1, 'tx should succeed') + assert.ok(receipt.contractAddress, 'should have contract address') + + // Verify the deployed contract + const token = new Contract(receipt.contractAddress, BurnMintERC20ABI, provider) + const name: string = await token.getFunction('name')() + assert.equal(name, 'Manual Token') + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-token-admin.test.ts b/ccip-sdk/src/token-admin/evm/evm-token-admin.test.ts new file mode 100644 index 00000000..41b0e4ef --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-token-admin.test.ts @@ -0,0 +1,323 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { AbiCoder, JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { CCIPTokenDeployParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +// ============================================================================= +// EVMTokenAdmin — Construction +// ============================================================================= + +describe('EVMTokenAdmin', () => { + describe('constructor', () => { + it('should create instance with provider', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + assert.equal(admin.provider, provider) + provider.destroy() + }) + }) + + // ============================================================================= + // generateUnsignedDeployToken — Validation + // ============================================================================= + + describe('generateUnsignedDeployToken', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + // Cleanup + + it.after(() => provider.destroy()) + + it('should reject empty name', async () => { + await assert.rejects( + () => admin.generateUnsignedDeployToken({ name: '', symbol: 'MTK', decimals: 18 }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.code, 'TOKEN_DEPLOY_PARAMS_INVALID') + assert.equal(err.context.param, 'name') + return true + }, + ) + }) + + it('should reject empty symbol', async () => { + await assert.rejects( + () => admin.generateUnsignedDeployToken({ name: 'Token', symbol: '', decimals: 18 }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'symbol') + return true + }, + ) + }) + + it('should reject negative maxSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken({ + name: 'Token', + symbol: 'MTK', + decimals: 18, + maxSupply: -1n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'maxSupply') + return true + }, + ) + }) + + it('should reject initialSupply > maxSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken({ + name: 'Token', + symbol: 'MTK', + decimals: 18, + maxSupply: 100n, + initialSupply: 200n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'initialSupply') + return true + }, + ) + }) + + // ========================================================================= + // generateUnsignedDeployToken — Happy Path + // ========================================================================= + + it('should return UnsignedEVMTx with correct family', async () => { + const result = await admin.generateUnsignedDeployToken({ + name: 'My Token', + symbol: 'MTK', + decimals: 18, + }) + + assert.equal(result.family, ChainFamily.EVM) + assert.equal(result.transactions.length, 1) + }) + + it('should set to: null for contract creation', async () => { + const result = await admin.generateUnsignedDeployToken({ + name: 'My Token', + symbol: 'MTK', + decimals: 18, + }) + + assert.equal(result.transactions[0]!.to, null) + }) + + it('should encode constructor args in deploy data', async () => { + const result = await admin.generateUnsignedDeployToken({ + name: 'My Token', + symbol: 'MTK', + decimals: 18, + maxSupply: 1000n, + initialSupply: 100n, + }) + + const data = result.transactions[0]!.data as string + assert.ok(data.startsWith('0x')) + + // Verify constructor args are encoded at the end of the data + const expectedArgs = AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'uint8', 'uint256', 'uint256'], + ['My Token', 'MTK', 18, 1000n, 100n], + ) + assert.ok(data.endsWith(expectedArgs.slice(2)), 'deploy data should end with encoded args') + }) + + it('should default maxSupply and initialSupply to 0', async () => { + const result = await admin.generateUnsignedDeployToken({ + name: 'My Token', + symbol: 'MTK', + decimals: 18, + }) + + const data = result.transactions[0]!.data as string + const expectedArgs = AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'uint8', 'uint256', 'uint256'], + ['My Token', 'MTK', 18, 0n, 0n], + ) + assert.ok(data.endsWith(expectedArgs.slice(2))) + }) + + it('should accept decimals: 0', async () => { + const result = await admin.generateUnsignedDeployToken({ + name: 'Zero Dec Token', + symbol: 'ZDT', + decimals: 0, + }) + + assert.equal(result.transactions.length, 1) + }) + + it('should lazy-load bytecode consistently', async () => { + const { BURN_MINT_ERC20_BYTECODE } = await import('./bytecodes/BurnMintERC20.ts') + const result = await admin.generateUnsignedDeployToken({ + name: 'My Token', + symbol: 'MTK', + decimals: 18, + }) + + const data = result.transactions[0]!.data as string + assert.ok(data.startsWith(BURN_MINT_ERC20_BYTECODE), 'deploy data should start with bytecode') + }) + }) + + // ============================================================================= + // deployToken — Wallet Validation + // ============================================================================= + + describe('deployToken', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.deployToken({}, { name: 'Token', symbol: 'MTK', decimals: 18 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.deployToken(null, { name: 'Token', symbol: 'MTK', decimals: 18 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) + + // ============================================================================= + // generateUnsignedDeployToken — FactoryBurnMintERC20 + // ============================================================================= + + describe('generateUnsignedDeployToken — factoryBurnMintERC20', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should require ownerAddress for unsigned path', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken({ + name: 'Factory Token', + symbol: 'FTK', + decimals: 18, + tokenType: 'factoryBurnMintERC20', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'ownerAddress') + return true + }, + ) + }) + + it('should reject empty ownerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken({ + name: 'Factory Token', + symbol: 'FTK', + decimals: 18, + tokenType: 'factoryBurnMintERC20', + ownerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'ownerAddress') + return true + }, + ) + }) + + it('should return UnsignedEVMTx with to: null', async () => { + const result = await admin.generateUnsignedDeployToken({ + name: 'Factory Token', + symbol: 'FTK', + decimals: 18, + tokenType: 'factoryBurnMintERC20', + ownerAddress: '0x1234567890abcdef1234567890abcdef12345678', + }) + + assert.equal(result.family, ChainFamily.EVM) + assert.equal(result.transactions.length, 1) + assert.equal(result.transactions[0]!.to, null) + }) + + it('should encode 6-param constructor args', async () => { + const ownerAddress = '0x1234567890abcdef1234567890abcdef12345678' + const result = await admin.generateUnsignedDeployToken({ + name: 'Factory Token', + symbol: 'FTK', + decimals: 18, + maxSupply: 1000n, + initialSupply: 100n, + tokenType: 'factoryBurnMintERC20', + ownerAddress, + }) + + const data = result.transactions[0]!.data as string + const expectedArgs = AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'uint8', 'uint256', 'uint256', 'address'], + ['Factory Token', 'FTK', 18, 1000n, 100n, ownerAddress], + ) + assert.ok(data.endsWith(expectedArgs.slice(2)), 'deploy data should end with 6-param args') + }) + + it('should use FactoryBurnMintERC20 bytecode', async () => { + const { FACTORY_BURN_MINT_ERC20_BYTECODE } = + await import('./bytecodes/FactoryBurnMintERC20.ts') + const result = await admin.generateUnsignedDeployToken({ + name: 'Factory Token', + symbol: 'FTK', + decimals: 18, + tokenType: 'factoryBurnMintERC20', + ownerAddress: '0x1234567890abcdef1234567890abcdef12345678', + }) + + const data = result.transactions[0]!.data as string + assert.ok( + data.startsWith(FACTORY_BURN_MINT_ERC20_BYTECODE), + 'deploy data should start with FactoryBurnMintERC20 bytecode', + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-transfer-admin-role.test.ts b/ccip-sdk/src/token-admin/evm/evm-transfer-admin-role.test.ts new file mode 100644 index 00000000..b26a6bbc --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-transfer-admin-role.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPTransferAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +describe('EVMTokenAdmin — transferAdminRole', () => { + // ============================================================================= + // generateUnsignedTransferAdminRole — Validation + // ============================================================================= + + describe('generateUnsignedTransferAdminRole — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + newAdmin: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + } + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedTransferAdminRole({ ...validParams, tokenAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty newAdmin', async () => { + await assert.rejects( + () => admin.generateUnsignedTransferAdminRole({ ...validParams, newAdmin: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'newAdmin') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedTransferAdminRole({ ...validParams, routerAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + }) + + // ============================================================================= + // transferAdminRole — Wallet Validation + // ============================================================================= + + describe('transferAdminRole — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + tokenAddress: '0x1234567890abcdef1234567890abcdef12345678', + newAdmin: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + } + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.transferAdminRole({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.transferAdminRole(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-transfer-ownership.fork.test.ts b/ccip-sdk/src/token-admin/evm/evm-transfer-ownership.fork.test.ts new file mode 100644 index 00000000..932a628c --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-transfer-ownership.fork.test.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict' +import { execSync } from 'node:child_process' +import { after, before, describe, it } from 'node:test' + +import { Contract, JsonRpcProvider, Wallet } from 'ethers' +import { Instance } from 'prool' + +import { EVMTokenAdmin } from './index.ts' + +// ── Constants ── + +const SEPOLIA_RPC = process.env['RPC_SEPOLIA'] || 'https://ethereum-sepolia-rpc.publicnode.com' +const SEPOLIA_ROUTER = '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59' +const SEPOLIA_REGISTRY_MODULE = '0xa3c796d480638d7476792230da1E2ADa86e031b0' +const ANVIL_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' +// Second Anvil default account +const BURNER_PRIVATE_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' + +// ── Helpers ── + +function isAnvilAvailable(): boolean { + try { + execSync('anvil --version', { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +// Minimal ABI for reading pool owner +const OWNABLE_ABI = [ + { + inputs: [], + name: 'owner', + outputs: [{ name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, +] as const + +// ── Tests ── + +const skip = !!process.env.SKIP_INTEGRATION_TESTS || !isAnvilAvailable() + +const testLogger = process.env.VERBOSE + ? console + : { debug() {}, info() {}, warn: console.warn, error: console.error } + +describe('EVMTokenAdmin transferOwnership Fork Tests', { skip, timeout: 120_000 }, () => { + let provider: JsonRpcProvider + let ownerWallet: Wallet + let burnerWallet: Wallet + let admin: EVMTokenAdmin + let anvilInstance: ReturnType | undefined + let poolAddress: string + + before(async () => { + anvilInstance = Instance.anvil({ + port: 8754, + forkUrl: SEPOLIA_RPC, + forkBlockNumber: undefined, + }) + await anvilInstance.start() + + const anvilUrl = `http://${anvilInstance.host}:${anvilInstance.port}` + provider = new JsonRpcProvider(anvilUrl, undefined, { cacheTimeout: -1 }) + ownerWallet = new Wallet(ANVIL_PRIVATE_KEY, provider) + burnerWallet = new Wallet(BURNER_PRIVATE_KEY, provider) + + admin = await EVMTokenAdmin.fromUrl(anvilUrl, { logger: testLogger, apiClient: null }) + + // 1. Deploy token + const tokenResult = await admin.deployToken(ownerWallet, { + name: 'Ownership Test Token', + symbol: 'OTT', + decimals: 18, + initialSupply: 1_000_000n * 10n ** 18n, + }) + + // 2. Deploy pool + const poolResult = await admin.deployPool(ownerWallet, { + poolType: 'burn-mint', + tokenAddress: tokenResult.tokenAddress, + localTokenDecimals: 18, + routerAddress: SEPOLIA_ROUTER, + }) + poolAddress = poolResult.poolAddress + + // 3. Propose + accept admin (needed to register pool) + await admin.proposeAdminRole(ownerWallet, { + tokenAddress: tokenResult.tokenAddress, + registryModuleAddress: SEPOLIA_REGISTRY_MODULE, + registrationMethod: 'getCCIPAdmin', + }) + + await admin.acceptAdminRole(ownerWallet, { + tokenAddress: tokenResult.tokenAddress, + routerAddress: SEPOLIA_ROUTER, + }) + + // 4. Set pool + await admin.setPool(ownerWallet, { + tokenAddress: tokenResult.tokenAddress, + poolAddress, + routerAddress: SEPOLIA_ROUTER, + }) + }) + + after(async () => { + provider.destroy() + await anvilInstance?.stop() + }) + + // =========================================================================== + // Verify initial owner + // =========================================================================== + + it('should have owner as initial pool owner', async () => { + const pool = new Contract(poolAddress, OWNABLE_ABI, provider) + const currentOwner = (await pool.getFunction('owner')()) as string + assert.equal( + currentOwner.toLowerCase(), + ownerWallet.address.toLowerCase(), + 'initial pool owner should be deployer', + ) + }) + + // =========================================================================== + // transferOwnership + acceptOwnership round-trip + // =========================================================================== + + it('should transfer ownership to burner wallet and accept', async () => { + // Transfer ownership (propose burner as new owner) + const transferResult = await admin.transferOwnership(ownerWallet, { + poolAddress, + newOwner: burnerWallet.address, + }) + + assert.ok(transferResult.txHash, 'should return tx hash') + assert.match(transferResult.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Owner should still be the original owner (pending transfer) + const pool = new Contract(poolAddress, OWNABLE_ABI, provider) + const ownerAfterProposal = (await pool.getFunction('owner')()) as string + assert.equal( + ownerAfterProposal.toLowerCase(), + ownerWallet.address.toLowerCase(), + 'owner should not change until acceptance', + ) + + // Accept ownership from burner wallet + const acceptResult = await admin.acceptOwnership(burnerWallet, { + poolAddress, + }) + + assert.ok(acceptResult.txHash, 'should return tx hash') + assert.match(acceptResult.txHash, /^0x[0-9a-fA-F]{64}$/, 'should be valid tx hash') + + // Verify new owner + const newOwner = (await pool.getFunction('owner')()) as string + assert.equal( + newOwner.toLowerCase(), + burnerWallet.address.toLowerCase(), + 'pool owner should be burner wallet after acceptance', + ) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/evm-transfer-ownership.test.ts b/ccip-sdk/src/token-admin/evm/evm-transfer-ownership.test.ts new file mode 100644 index 00000000..60b61acb --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/evm-transfer-ownership.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { JsonRpcProvider } from 'ethers' + +import { EVMTokenAdmin } from './index.ts' +import { + CCIPAcceptOwnershipParamsInvalidError, + CCIPTransferOwnershipParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Helpers ── + +const dummyNetwork: NetworkInfo = { + name: 'test', + family: ChainFamily.EVM, + chainSelector: 1n, + chainId: 1, + networkType: NetworkType.Testnet, +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +function makeAdmin(provider: JsonRpcProvider): EVMTokenAdmin { + return new EVMTokenAdmin(provider, dummyNetwork, { logger: silentLogger, apiClient: null }) +} + +// ============================================================================= +// EVMTokenAdmin — transferOwnership +// ============================================================================= + +describe('EVMTokenAdmin — transferOwnership', () => { + // =========================================================================== + // generateUnsignedTransferOwnership — Validation + // =========================================================================== + + describe('generateUnsignedTransferOwnership — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + newOwner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + } + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedTransferOwnership({ ...validParams, poolAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferOwnershipParamsInvalidError) + assert.equal(err.code, 'TRANSFER_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty newOwner', async () => { + await assert.rejects( + () => admin.generateUnsignedTransferOwnership({ ...validParams, newOwner: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferOwnershipParamsInvalidError) + assert.equal(err.code, 'TRANSFER_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'newOwner') + return true + }, + ) + }) + }) + + // =========================================================================== + // transferOwnership — Wallet Validation + // =========================================================================== + + describe('transferOwnership — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + const validParams = { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + newOwner: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + } + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => admin.transferOwnership({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.transferOwnership(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) + +// ============================================================================= +// EVMTokenAdmin — acceptOwnership +// ============================================================================= + +describe('EVMTokenAdmin — acceptOwnership', () => { + // =========================================================================== + // generateUnsignedAcceptOwnership — Validation + // =========================================================================== + + describe('generateUnsignedAcceptOwnership — validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => admin.generateUnsignedAcceptOwnership({ poolAddress: '' }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptOwnershipParamsInvalidError) + assert.equal(err.code, 'ACCEPT_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + }) + + // =========================================================================== + // acceptOwnership — Wallet Validation + // =========================================================================== + + describe('acceptOwnership — wallet validation', () => { + const provider = new JsonRpcProvider('http://localhost:8545') + const admin = makeAdmin(provider) + + it.after(() => provider.destroy()) + + it('should reject non-signer wallet', async () => { + await assert.rejects( + () => + admin.acceptOwnership({}, { poolAddress: '0x1234567890abcdef1234567890abcdef12345678' }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => + admin.acceptOwnership(null, { + poolAddress: '0x1234567890abcdef1234567890abcdef12345678', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/evm/index.ts b/ccip-sdk/src/token-admin/evm/index.ts new file mode 100644 index 00000000..961683c3 --- /dev/null +++ b/ccip-sdk/src/token-admin/evm/index.ts @@ -0,0 +1,2492 @@ +/** + * EVM token admin — deploy BurnMintERC20 tokens and CCIP token pools on EVM chains. + * + * @example Using EVMTokenAdmin with a wallet (signed deploy) + * ```typescript + * import { EVMTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/evm/index.ts' + * + * const admin = await EVMTokenAdmin.fromUrl('https://rpc.sepolia.org') + * const { tokenAddress, txHash } = await admin.deployToken(wallet, { + * name: 'My Token', symbol: 'MTK', decimals: 18, + * }) + * ``` + * + * @packageDocumentation + */ + +import { + type JsonRpcApiProvider, + type Log, + type TransactionRequest, + AbiCoder, + Contract, + Interface, + JsonRpcProvider, + WebSocketProvider, + concat, + dataLength, + id, +} from 'ethers' + +import type { ChainContext } from '../../chain.ts' +import { + CCIPAcceptAdminRoleFailedError, + CCIPAcceptAdminRoleParamsInvalidError, + CCIPAcceptOwnershipFailedError, + CCIPAcceptOwnershipParamsInvalidError, + CCIPAppendRemotePoolAddressesFailedError, + CCIPAppendRemotePoolAddressesParamsInvalidError, + CCIPApplyChainUpdatesFailedError, + CCIPApplyChainUpdatesParamsInvalidError, + CCIPDeleteChainConfigFailedError, + CCIPDeleteChainConfigParamsInvalidError, + CCIPGrantMintBurnAccessFailedError, + CCIPGrantMintBurnAccessParamsInvalidError, + CCIPPoolDeployFailedError, + CCIPPoolDeployParamsInvalidError, + CCIPProposeAdminRoleFailedError, + CCIPProposeAdminRoleParamsInvalidError, + CCIPRemoveRemotePoolAddressesFailedError, + CCIPRemoveRemotePoolAddressesParamsInvalidError, + CCIPRevokeMintBurnAccessFailedError, + CCIPRevokeMintBurnAccessParamsInvalidError, + CCIPSetPoolFailedError, + CCIPSetPoolParamsInvalidError, + CCIPSetRateLimitAdminFailedError, + CCIPSetRateLimitAdminParamsInvalidError, + CCIPSetRateLimiterConfigFailedError, + CCIPSetRateLimiterConfigParamsInvalidError, + CCIPTokenDeployFailedError, + CCIPTokenDeployParamsInvalidError, + CCIPTransferAdminRoleFailedError, + CCIPTransferAdminRoleParamsInvalidError, + CCIPTransferOwnershipFailedError, + CCIPTransferOwnershipParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import TokenPool_1_5_ABI from '../../evm/abi/LockReleaseTokenPool_1_5.ts' +import TokenPool_1_6_ABI from '../../evm/abi/LockReleaseTokenPool_1_6_1.ts' +import RegistryModuleOwnerCustomABI from '../../evm/abi/RegistryModuleOwnerCustom_1_6.ts' +import RouterABI from '../../evm/abi/Router.ts' +import TokenAdminRegistryABI from '../../evm/abi/TokenAdminRegistry_1_5.ts' +import TokenPool_2_0_ABI from '../../evm/abi/TokenPool_2_0.ts' +import { EVMChain, isSigner, submitTransaction } from '../../evm/index.ts' +import { getEvmLogs } from '../../evm/logs.ts' +import type { UnsignedEVMTx } from '../../evm/types.ts' +import { type NetworkInfo, CCIPVersion, ChainFamily } from '../../types.ts' +import { networkInfo } from '../../utils.ts' +import { + encodeRemoteAddress, + validateAppendRemotePoolAddressesParams, + validateApplyChainUpdatesParams, + validateDeleteChainConfigParams, + validateRemoveRemotePoolAddressesParams, +} from '../apply-chain-updates-utils.ts' +import { validateSetChainRateLimiterConfigParams } from '../set-rate-limiter-config-utils.ts' +import type { + AcceptAdminRoleParams, + AcceptAdminRoleResult, + AcceptOwnershipParams, + AppendRemotePoolAddressesParams, + AppendRemotePoolAddressesResult, + ApplyChainUpdatesParams, + ApplyChainUpdatesResult, + DeleteChainConfigParams, + DeleteChainConfigResult, + DeployPoolResult, + DeployTokenResult, + EVMDeployPoolParams, + EVMDeployTokenParams, + EVMMintBurnRolesResult, + EVMProposeAdminRoleParams, + EVMRegistrationMethod, + GrantMintBurnAccessParams, + GrantMintBurnAccessResult, + OwnershipResult, + ProposeAdminRoleResult, + RemoveRemotePoolAddressesParams, + RemoveRemotePoolAddressesResult, + RevokeMintBurnAccessParams, + RevokeMintBurnAccessResult, + SetChainRateLimiterConfigParams, + SetChainRateLimiterConfigResult, + SetPoolParams, + SetPoolResult, + SetRateLimitAdminParams, + SetRateLimitAdminResult, + TransferAdminRoleParams, + TransferAdminRoleResult, + TransferOwnershipParams, +} from '../types.ts' +import BurnMintERC20ABI from './abi/BurnMintERC20.ts' +import FactoryBurnMintERC20ABI from './abi/FactoryBurnMintERC20.ts' + +// OZ AccessControl role hashes — keccak256('MINTER_ROLE') / keccak256('BURNER_ROLE') +const MINTER_ROLE = id('MINTER_ROLE') +const BURNER_ROLE = id('BURNER_ROLE') + +/** Maps registration method to RegistryModuleOwnerCustom function name. */ +const REGISTRATION_FUNCTION_NAMES: Record = { + owner: 'registerAdminViaOwner', + getCCIPAdmin: 'registerAdminViaGetCCIPAdmin', + accessControlDefaultAdmin: 'registerAccessControlDefaultAdmin', +} + +/** + * Validates deploy parameters for EVM BurnMintERC20. + * @throws {@link CCIPTokenDeployParamsInvalidError} on invalid params + */ +function validateParams(params: EVMDeployTokenParams): void { + if (!params.name || params.name.trim().length === 0) { + throw new CCIPTokenDeployParamsInvalidError('name', 'must be non-empty') + } + if (!params.symbol || params.symbol.trim().length === 0) { + throw new CCIPTokenDeployParamsInvalidError('symbol', 'must be non-empty') + } + if (params.maxSupply !== undefined && params.maxSupply < 0n) { + throw new CCIPTokenDeployParamsInvalidError('maxSupply', 'must be non-negative') + } + if (params.initialSupply !== undefined && params.initialSupply < 0n) { + throw new CCIPTokenDeployParamsInvalidError('initialSupply', 'must be non-negative') + } + if ( + params.maxSupply !== undefined && + params.maxSupply > 0n && + params.initialSupply !== undefined && + params.initialSupply > params.maxSupply + ) { + throw new CCIPTokenDeployParamsInvalidError('initialSupply', 'exceeds maxSupply') + } +} + +/** + * Validates deploy parameters for EVM token pool. + * @throws {@link CCIPPoolDeployParamsInvalidError} on invalid params + */ +function validatePoolParams(params: EVMDeployPoolParams): void { + const poolType: string = params.poolType + if (poolType !== 'burn-mint' && poolType !== 'lock-release') { + throw new CCIPPoolDeployParamsInvalidError('poolType', "must be 'burn-mint' or 'lock-release'") + } + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPPoolDeployParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPPoolDeployParamsInvalidError('routerAddress', 'must be non-empty') + } +} + +/** + * Validates proposeAdminRole parameters for EVM. + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} on invalid params + */ +function validateProposeAdminRoleParams(params: EVMProposeAdminRoleParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.registryModuleAddress || params.registryModuleAddress.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('registryModuleAddress', 'must be non-empty') + } +} + +/** + * Validates acceptAdminRole parameters for EVM. + * @throws {@link CCIPAcceptAdminRoleParamsInvalidError} on invalid params + */ +function validateAcceptAdminRoleParams(params: AcceptAdminRoleParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPAcceptAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPAcceptAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } +} + +/** + * Validates transferAdminRole parameters. + * @throws {@link CCIPTransferAdminRoleParamsInvalidError} on invalid params + */ +function validateTransferAdminRoleParams(params: TransferAdminRoleParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.newAdmin || params.newAdmin.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('newAdmin', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } +} + +/** + * Validates setPool parameters for EVM. + * @throws {@link CCIPSetPoolParamsInvalidError} on invalid params + */ +function validateSetPoolParams(params: SetPoolParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('routerAddress', 'must be non-empty') + } +} + +/** + * EVM token admin for deploying CCIP-compatible BurnMintERC20 tokens. + * + * Extends {@link EVMChain} — inherits provider, logger, and chain discovery + * methods like `getTokenAdminRegistryFor`. + * + * @example From URL + * ```typescript + * const admin = await EVMTokenAdmin.fromUrl('https://rpc.sepolia.org') + * ``` + * + * @example Direct construction + * ```typescript + * const admin = new EVMTokenAdmin(provider, network, { logger }) + * ``` + */ +export class EVMTokenAdmin extends EVMChain { + /** Creates a new EVMTokenAdmin instance. */ + constructor(provider: JsonRpcApiProvider, network: NetworkInfo, ctx?: ChainContext) { + super(provider, network, ctx) + } + + /** + * Creates an EVMTokenAdmin from a URL. + * + * Connects to the RPC endpoint, detects the network, and returns + * a fully initialized EVMTokenAdmin instance. + * + * @param url - RPC endpoint URL (http/https/ws/wss) + * @param ctx - Optional context with logger and API client configuration + * @returns A new EVMTokenAdmin instance + * + * @example + * ```typescript + * const admin = await EVMTokenAdmin.fromUrl('https://rpc.sepolia.org') + * ``` + */ + static override async fromUrl(url: string, ctx?: ChainContext): Promise { + let provider: JsonRpcApiProvider + if (url.startsWith('ws')) { + const ws = new WebSocketProvider(url) + await new Promise((resolve, reject) => { + ws.websocket.onerror = reject + ws._waitUntilReady() + .then(() => resolve()) + .catch(reject) + }) + provider = ws + } else { + provider = new JsonRpcProvider(url) + } + + try { + const network = networkInfo(Number((await provider.getNetwork()).chainId)) + return new EVMTokenAdmin(provider, network, ctx) + } catch (err) { + provider.destroy() + throw err + } + } + + /** + * Detects the token pool version and returns the matching ABI. + * + * @param poolAddress - Pool contract address + * @returns Pool version string and the appropriate ABI for that version + */ + private async getPoolVersionAndABI(poolAddress: string) { + const [, version] = await this.typeAndVersion(poolAddress) + if (version <= CCIPVersion.V1_5) { + return { version, abi: TokenPool_1_5_ABI } + } + if (version < CCIPVersion.V2_0) { + return { version, abi: TokenPool_1_6_ABI } + } + return { version, abi: TokenPool_2_0_ABI } + } + + /** + * Builds an unsigned deploy transaction for BurnMintERC20. + * + * The bytecode is lazy-loaded — only fetched when this method is called. + * This ensures zero cost for consumers who never call deployToken. + * + * @param params - Token deployment parameters + * @returns Unsigned EVM transaction set (single deploy tx with `to: null`) + * @throws {@link CCIPTokenDeployParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedDeployToken({ + * name: 'My Token', symbol: 'MTK', decimals: 18, + * }) + * // unsigned.transactions[0].to === null (contract creation) + * ``` + */ + async generateUnsignedDeployToken(params: EVMDeployTokenParams): Promise { + validateParams(params) + + const tokenType = params.tokenType ?? 'burnMintERC20' + const maxSupply = params.maxSupply ?? 0n + const initialSupply = params.initialSupply ?? 0n + + let deployData: string + if (tokenType === 'factoryBurnMintERC20') { + if (!params.ownerAddress || params.ownerAddress.trim().length === 0) { + throw new CCIPTokenDeployParamsInvalidError( + 'ownerAddress', + 'required for factoryBurnMintERC20 (use signed deployToken to auto-fill from signer)', + ) + } + // Lazy-load bytecode — tree-shaking friendly + const { FACTORY_BURN_MINT_ERC20_BYTECODE } = + await import('./bytecodes/FactoryBurnMintERC20.ts') + // Constructor: (name, symbol, decimals_, maxSupply_, preMint, newOwner) + const encodedArgs = AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'uint8', 'uint256', 'uint256', 'address'], + [ + params.name, + params.symbol, + params.decimals, + maxSupply, + initialSupply, + params.ownerAddress, + ], + ) + deployData = concat([FACTORY_BURN_MINT_ERC20_BYTECODE, encodedArgs]) + } else { + // Lazy-load bytecode — tree-shaking friendly + const { BURN_MINT_ERC20_BYTECODE } = await import('./bytecodes/BurnMintERC20.ts') + // Constructor: (name, symbol, decimals_, maxSupply_, preMint) + const encodedArgs = AbiCoder.defaultAbiCoder().encode( + ['string', 'string', 'uint8', 'uint256', 'uint256'], + [params.name, params.symbol, params.decimals, maxSupply, initialSupply], + ) + deployData = concat([BURN_MINT_ERC20_BYTECODE, encodedArgs]) + } + + const tx: Pick = { + to: null, // contract creation + data: deployData, + } + + this.logger.debug('generateUnsignedDeployToken: bytecode size =', dataLength(deployData)) + + return { + family: ChainFamily.EVM, + transactions: [tx], + } + } + + /** + * Deploys a BurnMintERC20 token, signing and submitting with the provided wallet. + * + * @param wallet - Ethers Signer with signing capability + * @param params - Token deployment parameters + * @returns Unified deploy result with `tokenAddress` and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPTokenDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenDeployFailedError} if the deploy transaction fails + * + * @example + * ```typescript + * const { tokenAddress, txHash } = await admin.deployToken(wallet, { + * name: 'My Token', + * symbol: 'MTK', + * decimals: 18, + * maxSupply: 1_000_000n * 10n ** 18n, + * initialSupply: 10_000n * 10n ** 18n, + * }) + * console.log(`Deployed at ${tokenAddress}, tx: ${txHash}`) + * ``` + */ + async deployToken(wallet: unknown, params: EVMDeployTokenParams): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + // Auto-fill ownerAddress from signer for factoryBurnMintERC20 + const effectiveParams = { ...params } + if (effectiveParams.tokenType === 'factoryBurnMintERC20' && !effectiveParams.ownerAddress) { + effectiveParams.ownerAddress = await wallet.getAddress() + } + + const unsigned = await this.generateUnsignedDeployToken(effectiveParams) + let deployTx: TransactionRequest = unsigned.transactions[0]! + + const tokenType = effectiveParams.tokenType ?? 'burnMintERC20' + this.logger.debug('deployToken: deploying', tokenType, '...') + + try { + deployTx = await wallet.populateTransaction(deployTx) + deployTx.from = undefined // some signers don't like receiving pre-populated `from` + const response = await submitTransaction(wallet, deployTx, this.provider) + + this.logger.debug('deployToken: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPTokenDeployFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPTokenDeployFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + const tokenAddress = receipt.contractAddress + if (!tokenAddress) { + throw new CCIPTokenDeployFailedError('no contract address in receipt', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('deployToken: deployed at', tokenAddress, 'tx =', response.hash) + + return { tokenAddress, txHash: response.hash } + } catch (error) { + if (error instanceof CCIPTokenDeployFailedError) throw error + throw new CCIPTokenDeployFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Pool Deployment ────────────────────────────────────────────────────── + + /** + * Builds an unsigned deploy transaction for a CCIP token pool. + * + * Both BurnMintTokenPool and LockReleaseTokenPool (v1.6.1) share an + * identical constructor: `(token, localTokenDecimals, allowlist[], rmnProxy, router)`. + * `rmnProxy` is derived automatically via `Router.getArmProxy()`. + * + * Bytecodes are lazy-loaded based on `poolType`. + * + * @param params - Pool deployment parameters + * @returns Unsigned EVM transaction set (single deploy tx with `to: null`) + * @throws {@link CCIPPoolDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPPoolDeployFailedError} if rmnProxy derivation fails + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedDeployPool({ + * poolType: 'burn-mint', + * tokenAddress: '0xa42BA...', + * localTokenDecimals: 18, + * routerAddress: '0x0BF3...', + * }) + * ``` + */ + async generateUnsignedDeployPool(params: EVMDeployPoolParams): Promise { + validatePoolParams(params) + + // Derive rmnProxy from Router.getArmProxy() + const router = new Contract(params.routerAddress, RouterABI, this.provider) + let rmnProxy: string + try { + rmnProxy = (await router.getFunction('getArmProxy')()) as string + } catch (error) { + throw new CCIPPoolDeployFailedError( + `failed to derive rmnProxy from router ${params.routerAddress}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined }, + ) + } + + this.logger.debug('generateUnsignedDeployPool: rmnProxy =', rmnProxy) + + // Lazy-load bytecode based on pool type + const bytecode = + params.poolType === 'burn-mint' + ? (await import('./bytecodes/BurnMintTokenPool.ts')).BURN_MINT_TOKEN_POOL_BYTECODE + : (await import('./bytecodes/LockReleaseTokenPool.ts')).LOCK_RELEASE_TOKEN_POOL_BYTECODE + + // Both pool constructors: (token, localTokenDecimals, allowlist[], rmnProxy, router) + const encodedArgs = AbiCoder.defaultAbiCoder().encode( + ['address', 'uint8', 'address[]', 'address', 'address'], + [ + params.tokenAddress, + params.localTokenDecimals, + params.allowlist ?? [], + rmnProxy, + params.routerAddress, + ], + ) + + const deployData = concat([bytecode, encodedArgs]) + + const tx: Pick = { + to: null, + data: deployData, + } + + this.logger.debug( + 'generateUnsignedDeployPool:', + params.poolType, + 'bytecode size =', + dataLength(deployData), + ) + + return { + family: ChainFamily.EVM, + transactions: [tx], + } + } + + /** + * Deploys a CCIP token pool, signing and submitting with the provided wallet. + * + * @param wallet - Ethers Signer with signing capability + * @param params - Pool deployment parameters + * @returns Unified deploy result with `poolAddress` and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPPoolDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPPoolDeployFailedError} if the deploy transaction fails + * + * @example + * ```typescript + * const { poolAddress, txHash } = await admin.deployPool(wallet, { + * poolType: 'burn-mint', + * tokenAddress: '0xa42BA...', + * localTokenDecimals: 18, + * routerAddress: '0x0BF3...', + * }) + * console.log(`Pool at ${poolAddress}, tx: ${txHash}`) + * ``` + */ + async deployPool(wallet: unknown, params: EVMDeployPoolParams): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedDeployPool(params) + let deployTx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('deployPool: deploying', params.poolType, 'pool...') + + try { + deployTx = await wallet.populateTransaction(deployTx) + deployTx.from = undefined + const response = await submitTransaction(wallet, deployTx, this.provider) + + this.logger.debug('deployPool: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPPoolDeployFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPPoolDeployFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + const poolAddress = receipt.contractAddress + if (!poolAddress) { + throw new CCIPPoolDeployFailedError('no contract address in receipt', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('deployPool: deployed at', poolAddress, 'tx =', response.hash) + + return { poolAddress, txHash: response.hash } + } catch (error) { + if (error instanceof CCIPPoolDeployFailedError) throw error + throw new CCIPPoolDeployFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Propose Admin Role ──────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction to propose the caller as administrator + * for a token via the RegistryModuleOwnerCustom contract. + * + * The `registrationMethod` determines which function is called: + * - `'owner'` (default) — `registerAdminViaOwner(token)` — token has `owner()` + * - `'getCCIPAdmin'` — `registerAdminViaGetCCIPAdmin(token)` — token has `getCCIPAdmin()` + * - `'accessControlDefaultAdmin'` — `registerAccessControlDefaultAdmin(token)` — OZ AccessControl + * + * @param params - Propose admin role parameters + * @returns Unsigned EVM transaction set (single tx) + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedProposeAdminRole({ + * tokenAddress: '0xa42BA...', + * registryModuleAddress: '0xa3c7...', + * registrationMethod: 'owner', // default + * }) + * ``` + */ + generateUnsignedProposeAdminRole(params: EVMProposeAdminRoleParams): UnsignedEVMTx { + validateProposeAdminRoleParams(params) + + const method = params.registrationMethod ?? 'owner' + const functionName = REGISTRATION_FUNCTION_NAMES[method] + + const iface = new Interface(RegistryModuleOwnerCustomABI) + const data = iface.encodeFunctionData(functionName, [params.tokenAddress]) + + const tx: Pick = { + to: params.registryModuleAddress, + data, + } + + this.logger.debug( + `generateUnsignedProposeAdminRole: registryModule = ${params.registryModuleAddress}, method = ${method}, token = ${params.tokenAddress}`, + ) + + return { + family: ChainFamily.EVM, + transactions: [tx], + } + } + + /** + * Proposes the caller as administrator for a token via the + * RegistryModuleOwnerCustom contract, signing and submitting with the provided wallet. + * + * The wallet must have the appropriate authority over the token, depending + * on the `registrationMethod` (token owner, CCIP admin, or AccessControl admin). + * + * @param wallet - Ethers Signer with signing capability + * @param params - Propose admin role parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} if params are invalid + * @throws {@link CCIPProposeAdminRoleFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.proposeAdminRole(wallet, { + * tokenAddress: '0xa42BA...', + * registryModuleAddress: '0xa3c7...', + * }) + * console.log(`Proposed admin, tx: ${txHash}`) + * ``` + */ + async proposeAdminRole( + wallet: unknown, + params: EVMProposeAdminRoleParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = this.generateUnsignedProposeAdminRole(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('proposeAdminRole: proposing administrator...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('proposeAdminRole: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPProposeAdminRoleFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPProposeAdminRoleFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('proposeAdminRole: proposed admin, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPProposeAdminRoleFailedError) throw error + if (error instanceof CCIPProposeAdminRoleParamsInvalidError) throw error + throw new CCIPProposeAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Accept Admin Role ──────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for accepting an administrator role + * in the TokenAdminRegistry. + * + * Calls `acceptAdminRole(localToken)` directly on the TokenAdminRegistry contract. + * The caller must be the pending administrator for the token. + * + * @param params - Accept admin role parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPAcceptAdminRoleParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedAcceptAdminRole({ + * tokenAddress: '0xa42BA...', + * routerAddress: '0x0BF3...', + * }) + * ``` + */ + async generateUnsignedAcceptAdminRole(params: AcceptAdminRoleParams): Promise { + validateAcceptAdminRoleParams(params) + + // Discover the TokenAdminRegistry address from the router + const tarAddress = await this.getTokenAdminRegistryFor(params.routerAddress) + + const iface = new Interface(TokenAdminRegistryABI) + const data = iface.encodeFunctionData('acceptAdminRole', [params.tokenAddress]) + const tx: TransactionRequest = { to: tarAddress, data } + + this.logger.debug( + 'generateUnsignedAcceptAdminRole: TAR =', + tarAddress, + 'token =', + params.tokenAddress, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Accepts an administrator role for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pending administrator) + * @param params - Accept admin role parameters + * @returns Result with `txHash` + * + * @example + * ```typescript + * const { txHash } = await admin.acceptAdminRole(wallet, { + * tokenAddress: '0xa42BA...', + * routerAddress: '0x0BF3...', + * }) + * console.log(`Accepted admin, tx: ${txHash}`) + * ``` + */ + async acceptAdminRole( + wallet: unknown, + params: AcceptAdminRoleParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedAcceptAdminRole(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('acceptAdminRole: accepting administrator role...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('acceptAdminRole: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPAcceptAdminRoleFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPAcceptAdminRoleFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('acceptAdminRole: accepted admin, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPAcceptAdminRoleFailedError) throw error + if (error instanceof CCIPAcceptAdminRoleParamsInvalidError) throw error + throw new CCIPAcceptAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Transfer Admin Role ───────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for transferring the administrator role + * for a token in the TokenAdminRegistry. + * + * Encodes `transferAdminRole(address localToken, address newAdmin)` on the TAR. + * + * @param params - Transfer admin role parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPTransferAdminRoleParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedTransferAdminRole({ + * tokenAddress: '0xa42BA...', + * newAdmin: '0x1234...', + * routerAddress: '0x0BF3...', + * }) + * ``` + */ + async generateUnsignedTransferAdminRole(params: TransferAdminRoleParams): Promise { + validateTransferAdminRoleParams(params) + + // Discover the TokenAdminRegistry address from the router + const tarAddress = await this.getTokenAdminRegistryFor(params.routerAddress) + + const iface = new Interface(TokenAdminRegistryABI) + const data = iface.encodeFunctionData('transferAdminRole', [ + params.tokenAddress, + params.newAdmin, + ]) + const tx: TransactionRequest = { to: tarAddress, data } + + this.logger.debug( + 'generateUnsignedTransferAdminRole: TAR =', + tarAddress, + 'token =', + params.tokenAddress, + 'newAdmin =', + params.newAdmin, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Transfers the administrator role for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the current administrator) + * @param params - Transfer admin role parameters + * @returns Result with `txHash` + * + * @example + * ```typescript + * const { txHash } = await admin.transferAdminRole(wallet, { + * tokenAddress: '0xa42BA...', + * newAdmin: '0x1234...', + * routerAddress: '0x0BF3...', + * }) + * console.log(`Transferred admin, tx: ${txHash}`) + * ``` + */ + async transferAdminRole( + wallet: unknown, + params: TransferAdminRoleParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedTransferAdminRole(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('transferAdminRole: transferring administrator role...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('transferAdminRole: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPTransferAdminRoleFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPTransferAdminRoleFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('transferAdminRole: transferred admin, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPTransferAdminRoleFailedError) throw error + if (error instanceof CCIPTransferAdminRoleParamsInvalidError) throw error + throw new CCIPTransferAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Set Pool ───────────────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for registering a pool in the TokenAdminRegistry. + * + * Encodes `setPool(address localToken, address pool)` on the TAR contract. + * + * @param params - Set pool parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPSetPoolParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedSetPool({ + * tokenAddress: '0xa42BA...', + * poolAddress: '0xd7BF...', + * routerAddress: '0x0BF3...', + * }) + * ``` + */ + async generateUnsignedSetPool(params: SetPoolParams): Promise { + validateSetPoolParams(params) + + const tarAddress = await this.getTokenAdminRegistryFor(params.routerAddress) + + const iface = new Interface(TokenAdminRegistryABI) + const data = iface.encodeFunctionData('setPool', [params.tokenAddress, params.poolAddress]) + const tx: TransactionRequest = { to: tarAddress, data } + + this.logger.debug( + 'generateUnsignedSetPool: TAR =', + tarAddress, + 'token =', + params.tokenAddress, + 'pool =', + params.poolAddress, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Registers a pool in the TokenAdminRegistry, signing and submitting + * with the provided wallet. + * + * @param wallet - EVM signer (must be the token administrator) + * @param params - Set pool parameters + * @returns Result with `txHash` + * + * @example + * ```typescript + * const { txHash } = await admin.setPool(wallet, { + * tokenAddress: '0xa42BA...', + * poolAddress: '0xd7BF...', + * routerAddress: '0x0BF3...', + * }) + * console.log(`Pool registered, tx: ${txHash}`) + * ``` + */ + async setPool(wallet: unknown, params: SetPoolParams): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedSetPool(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('setPool: registering pool...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('setPool: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPSetPoolFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPSetPoolFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('setPool: pool registered, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPSetPoolFailedError) throw error + if (error instanceof CCIPSetPoolParamsInvalidError) throw error + throw new CCIPSetPoolFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Apply Chain Updates ────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for configuring remote chains on a token pool. + * + * Encodes `applyChainUpdates(uint64[] removes, ChainUpdate[] adds)` on the + * TokenPool contract. Remote addresses are encoded to 32-byte left-padded bytes. + * + * @param params - Apply chain updates parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPApplyChainUpdatesParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedApplyChainUpdates({ + * poolAddress: '0x1234...', + * remoteChainSelectorsToRemove: [], + * chainsToAdd: [{ + * remoteChainSelector: '16015286601757825753', + * remotePoolAddresses: ['0xd7BF...'], + * remoteTokenAddress: '0xa42B...', + * outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * }], + * }) + * ``` + */ + async generateUnsignedApplyChainUpdates(params: ApplyChainUpdatesParams): Promise { + validateApplyChainUpdatesParams(params) + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + const iface = new Interface(abi) + + let data: string + + if (version < CCIPVersion.V1_5) { + // v1.5: applyChainUpdates(ChainUpdate[]) — single pool address, `allowed` field + const chains = [ + ...params.chainsToAdd.map((chain) => ({ + remoteChainSelector: chain.remoteChainSelector, + allowed: true, + remotePoolAddress: encodeRemoteAddress(chain.remotePoolAddresses[0]!), + remoteTokenAddress: encodeRemoteAddress(chain.remoteTokenAddress), + outboundRateLimiterConfig: { + isEnabled: chain.outboundRateLimiterConfig.isEnabled, + capacity: BigInt(chain.outboundRateLimiterConfig.capacity), + rate: BigInt(chain.outboundRateLimiterConfig.rate), + }, + inboundRateLimiterConfig: { + isEnabled: chain.inboundRateLimiterConfig.isEnabled, + capacity: BigInt(chain.inboundRateLimiterConfig.capacity), + rate: BigInt(chain.inboundRateLimiterConfig.rate), + }, + })), + ...params.remoteChainSelectorsToRemove.map((s) => ({ + remoteChainSelector: BigInt(s), + allowed: false, + remotePoolAddress: '0x', + remoteTokenAddress: '0x', + outboundRateLimiterConfig: { isEnabled: false, capacity: 0n, rate: 0n }, + inboundRateLimiterConfig: { isEnabled: false, capacity: 0n, rate: 0n }, + })), + ] + data = iface.encodeFunctionData('applyChainUpdates', [chains]) + } else { + // v1.5.1+ and v2.0: applyChainUpdates(uint64[] removes, ChainUpdate[] adds) + const chainsToAdd = params.chainsToAdd.map((chain) => ({ + remoteChainSelector: chain.remoteChainSelector, + remotePoolAddresses: chain.remotePoolAddresses.map((addr) => encodeRemoteAddress(addr)), + remoteTokenAddress: encodeRemoteAddress(chain.remoteTokenAddress), + outboundRateLimiterConfig: { + isEnabled: chain.outboundRateLimiterConfig.isEnabled, + capacity: BigInt(chain.outboundRateLimiterConfig.capacity), + rate: BigInt(chain.outboundRateLimiterConfig.rate), + }, + inboundRateLimiterConfig: { + isEnabled: chain.inboundRateLimiterConfig.isEnabled, + capacity: BigInt(chain.inboundRateLimiterConfig.capacity), + rate: BigInt(chain.inboundRateLimiterConfig.rate), + }, + })) + + const remoteChainSelectorsToRemove = params.remoteChainSelectorsToRemove.map((s) => BigInt(s)) + data = iface.encodeFunctionData('applyChainUpdates', [ + remoteChainSelectorsToRemove, + chainsToAdd, + ]) + } + + const tx: TransactionRequest = { to: params.poolAddress, data } + + this.logger.debug( + 'generateUnsignedApplyChainUpdates: pool =', + params.poolAddress, + 'version =', + version, + 'adds =', + params.chainsToAdd.length, + 'removes =', + params.remoteChainSelectorsToRemove.length, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Configures remote chains on a token pool, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pool owner) + * @param params - Apply chain updates parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPApplyChainUpdatesParamsInvalidError} if params are invalid + * @throws {@link CCIPApplyChainUpdatesFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.applyChainUpdates(wallet, { + * poolAddress: '0x1234...', + * remoteChainSelectorsToRemove: [], + * chainsToAdd: [{ + * remoteChainSelector: '16015286601757825753', + * remotePoolAddresses: ['0xd7BF...'], + * remoteTokenAddress: '0xa42B...', + * outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * }], + * }) + * ``` + */ + async applyChainUpdates( + wallet: unknown, + params: ApplyChainUpdatesParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedApplyChainUpdates(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('applyChainUpdates: applying chain updates...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('applyChainUpdates: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPApplyChainUpdatesFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPApplyChainUpdatesFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('applyChainUpdates: applied chain updates, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPApplyChainUpdatesFailedError) throw error + if (error instanceof CCIPApplyChainUpdatesParamsInvalidError) throw error + throw new CCIPApplyChainUpdatesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Append Remote Pool Addresses ──────────────────────────────────────── + + /** + * Builds unsigned transactions for appending remote pool addresses to an existing chain config. + * + * Encodes `addRemotePool(uint64 remoteChainSelector, bytes remotePoolAddress)` on the + * TokenPool contract. One transaction per address. Requires v1.5.1+ (not available on v1.5). + * + * @param params - Append remote pool addresses parameters + * @returns Unsigned EVM transactions (one per address) + * @throws {@link CCIPAppendRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPAppendRemotePoolAddressesFailedError} if pool version is v1.5 (no addRemotePool) + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedAppendRemotePoolAddresses({ + * poolAddress: '0x1234...', + * remoteChainSelector: '16015286601757825753', + * remotePoolAddresses: ['0xd7BF...', '0xaabb...'], + * }) + * ``` + */ + async generateUnsignedAppendRemotePoolAddresses( + params: AppendRemotePoolAddressesParams, + ): Promise { + validateAppendRemotePoolAddressesParams(params) + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + + if (version <= CCIPVersion.V1_5) { + throw new CCIPAppendRemotePoolAddressesFailedError( + 'addRemotePool is not available on v1.5 pools. Use applyChainUpdates to re-initialize the chain config instead.', + ) + } + + const iface = new Interface(abi) + const transactions: TransactionRequest[] = [] + + for (const remotePoolAddress of params.remotePoolAddresses) { + const encodedAddress = encodeRemoteAddress(remotePoolAddress) + const data = iface.encodeFunctionData('addRemotePool', [ + params.remoteChainSelector, + encodedAddress, + ]) + transactions.push({ to: params.poolAddress, data }) + } + + this.logger.debug( + 'generateUnsignedAppendRemotePoolAddresses: pool =', + params.poolAddress, + 'version =', + version, + 'addresses =', + params.remotePoolAddresses.length, + ) + + return { family: ChainFamily.EVM, transactions } + } + + /** + * Appends remote pool addresses to an existing chain config, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pool owner) + * @param params - Append remote pool addresses parameters + * @returns Result with `txHash` of the last transaction + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPAppendRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPAppendRemotePoolAddressesFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.appendRemotePoolAddresses(wallet, { + * poolAddress: '0x1234...', + * remoteChainSelector: '16015286601757825753', + * remotePoolAddresses: ['0xd7BF...'], + * }) + * ``` + */ + async appendRemotePoolAddresses( + wallet: unknown, + params: AppendRemotePoolAddressesParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedAppendRemotePoolAddresses(params) + + this.logger.debug('appendRemotePoolAddresses: appending remote pool addresses...') + + try { + let lastTxHash = '' + for (const unsignedTx of unsigned.transactions) { + const tx = await wallet.populateTransaction(unsignedTx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug( + 'appendRemotePoolAddresses: waiting for confirmation, tx =', + response.hash, + ) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPAppendRemotePoolAddressesFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPAppendRemotePoolAddressesFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + lastTxHash = response.hash + } + + this.logger.info( + 'appendRemotePoolAddresses: appended remote pool addresses, tx =', + lastTxHash, + ) + + return { txHash: lastTxHash } + } catch (error) { + if (error instanceof CCIPAppendRemotePoolAddressesFailedError) throw error + if (error instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) throw error + throw new CCIPAppendRemotePoolAddressesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Delete Chain Config ────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for removing a remote chain configuration from a token pool. + * + * Wraps the existing `applyChainUpdates` ABI call with only the removal selector: + * - v1.5: `applyChainUpdates([{ remoteChainSelector, allowed: false, ... }])` + * - v1.5.1+/v2.0: `applyChainUpdates([remoteChainSelector], [])` + * + * @param params - Delete chain config parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPDeleteChainConfigParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedDeleteChainConfig({ + * poolAddress: '0x1234...', + * remoteChainSelector: '16015286601757825753', + * }) + * ``` + */ + async generateUnsignedDeleteChainConfig(params: DeleteChainConfigParams): Promise { + validateDeleteChainConfigParams(params) + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + const iface = new Interface(abi) + + let data: string + + if (version < CCIPVersion.V1_5) { + // v1.5: applyChainUpdates(ChainUpdate[]) — mark chain as not allowed + const chains = [ + { + remoteChainSelector: params.remoteChainSelector, + allowed: false, + remotePoolAddress: '0x', + remoteTokenAddress: '0x', + outboundRateLimiterConfig: { isEnabled: false, capacity: 0n, rate: 0n }, + inboundRateLimiterConfig: { isEnabled: false, capacity: 0n, rate: 0n }, + }, + ] + data = iface.encodeFunctionData('applyChainUpdates', [chains]) + } else { + // v1.5.1+ and v2.0: applyChainUpdates(uint64[] removes, ChainUpdate[] adds) + data = iface.encodeFunctionData('applyChainUpdates', [[params.remoteChainSelector], []]) + } + + const tx: TransactionRequest = { to: params.poolAddress, data } + + this.logger.debug( + 'generateUnsignedDeleteChainConfig: pool =', + params.poolAddress, + 'version =', + version, + 'remoteChainSelector =', + params.remoteChainSelector, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Removes a remote chain configuration from a token pool, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pool owner) + * @param params - Delete chain config parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPDeleteChainConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPDeleteChainConfigFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.deleteChainConfig(wallet, { + * poolAddress: '0x1234...', + * remoteChainSelector: '16015286601757825753', + * }) + * ``` + */ + async deleteChainConfig( + wallet: unknown, + params: DeleteChainConfigParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedDeleteChainConfig(params) + + this.logger.debug('deleteChainConfig: deleting chain config...') + + try { + const unsignedTx = unsigned.transactions[0]! + const tx = await wallet.populateTransaction(unsignedTx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('deleteChainConfig: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPDeleteChainConfigFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPDeleteChainConfigFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('deleteChainConfig: deleted chain config, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPDeleteChainConfigFailedError) throw error + if (error instanceof CCIPDeleteChainConfigParamsInvalidError) throw error + throw new CCIPDeleteChainConfigFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Remove Remote Pool Addresses ──────────────────────────────────────── + + /** + * Builds unsigned transactions for removing specific remote pool addresses from an existing chain config. + * + * Encodes `removeRemotePool(uint64 remoteChainSelector, bytes remotePoolAddress)` on the + * TokenPool contract. One transaction per address. Requires v1.5.1+ (not available on v1.5). + * + * @param params - Remove remote pool addresses parameters + * @returns Unsigned EVM transactions (one per address) + * @throws {@link CCIPRemoveRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPRemoveRemotePoolAddressesFailedError} if pool version is v1.5 (no removeRemotePool) + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedRemoveRemotePoolAddresses({ + * poolAddress: '0x1234...', + * remoteChainSelector: '16015286601757825753', + * remotePoolAddresses: ['0xd7BF...'], + * }) + * ``` + */ + async generateUnsignedRemoveRemotePoolAddresses( + params: RemoveRemotePoolAddressesParams, + ): Promise { + validateRemoveRemotePoolAddressesParams(params) + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + + if (version <= CCIPVersion.V1_5) { + throw new CCIPRemoveRemotePoolAddressesFailedError( + 'removeRemotePool is not available on v1.5 pools. Use applyChainUpdates to re-initialize the chain config instead.', + ) + } + + const iface = new Interface(abi) + const transactions: TransactionRequest[] = [] + + for (const remotePoolAddress of params.remotePoolAddresses) { + const encodedAddress = encodeRemoteAddress(remotePoolAddress) + const data = iface.encodeFunctionData('removeRemotePool', [ + params.remoteChainSelector, + encodedAddress, + ]) + transactions.push({ to: params.poolAddress, data }) + } + + this.logger.debug( + 'generateUnsignedRemoveRemotePoolAddresses: pool =', + params.poolAddress, + 'version =', + version, + 'addresses =', + params.remotePoolAddresses.length, + ) + + return { family: ChainFamily.EVM, transactions } + } + + /** + * Removes specific remote pool addresses from an existing chain config, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pool owner) + * @param params - Remove remote pool addresses parameters + * @returns Result with `txHash` of the last transaction + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPRemoveRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPRemoveRemotePoolAddressesFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.removeRemotePoolAddresses(wallet, { + * poolAddress: '0x1234...', + * remoteChainSelector: '16015286601757825753', + * remotePoolAddresses: ['0xd7BF...'], + * }) + * ``` + */ + async removeRemotePoolAddresses( + wallet: unknown, + params: RemoveRemotePoolAddressesParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedRemoveRemotePoolAddresses(params) + + this.logger.debug('removeRemotePoolAddresses: removing remote pool addresses...') + + try { + let lastTxHash = '' + for (const unsignedTx of unsigned.transactions) { + const tx = await wallet.populateTransaction(unsignedTx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug( + 'removeRemotePoolAddresses: waiting for confirmation, tx =', + response.hash, + ) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPRemoveRemotePoolAddressesFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPRemoveRemotePoolAddressesFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + lastTxHash = response.hash + } + + this.logger.info('removeRemotePoolAddresses: removed remote pool addresses, tx =', lastTxHash) + + return { txHash: lastTxHash } + } catch (error) { + if (error instanceof CCIPRemoveRemotePoolAddressesFailedError) throw error + if (error instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) throw error + throw new CCIPRemoveRemotePoolAddressesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Set Chain Rate Limiter Config ──────────────────────────────────────── + + /** + * Builds an unsigned transaction for updating rate limiter configurations on a token pool. + * + * Encodes `setRateLimitConfig(RateLimitConfigArgs[])` on the TokenPool 2.0 contract. + * Each entry targets a specific remote chain selector with outbound/inbound rate limits. + * + * @param params - Set chain rate limiter config parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedSetChainRateLimiterConfig({ + * poolAddress: '0x1234...', + * chainConfigs: [{ + * remoteChainSelector: '16015286601757825753', + * outboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * }], + * }) + * ``` + */ + async generateUnsignedSetChainRateLimiterConfig( + params: SetChainRateLimiterConfigParams, + ): Promise { + validateSetChainRateLimiterConfigParams(params) + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + const iface = new Interface(abi) + + const transactions: TransactionRequest[] = [] + + if (version >= CCIPVersion.V2_0) { + // v2.0: setRateLimitConfig(RateLimitConfigArgs[]) — batch with customBlockConfirmations + const rateLimitConfigArgs = params.chainConfigs.map((config) => ({ + remoteChainSelector: config.remoteChainSelector, + customBlockConfirmations: config.customBlockConfirmations ?? false, + outboundRateLimiterConfig: { + isEnabled: config.outboundRateLimiterConfig.isEnabled, + capacity: BigInt(config.outboundRateLimiterConfig.capacity), + rate: BigInt(config.outboundRateLimiterConfig.rate), + }, + inboundRateLimiterConfig: { + isEnabled: config.inboundRateLimiterConfig.isEnabled, + capacity: BigInt(config.inboundRateLimiterConfig.capacity), + rate: BigInt(config.inboundRateLimiterConfig.rate), + }, + })) + const data = iface.encodeFunctionData('setRateLimitConfig', [rateLimitConfigArgs]) + transactions.push({ to: params.poolAddress, data }) + } else { + // v1.5/v1.6: setChainRateLimiterConfig(selector, outbound, inbound) — one per chain + for (const config of params.chainConfigs) { + const data = iface.encodeFunctionData('setChainRateLimiterConfig', [ + config.remoteChainSelector, + { + isEnabled: config.outboundRateLimiterConfig.isEnabled, + capacity: BigInt(config.outboundRateLimiterConfig.capacity), + rate: BigInt(config.outboundRateLimiterConfig.rate), + }, + { + isEnabled: config.inboundRateLimiterConfig.isEnabled, + capacity: BigInt(config.inboundRateLimiterConfig.capacity), + rate: BigInt(config.inboundRateLimiterConfig.rate), + }, + ]) + transactions.push({ to: params.poolAddress, data }) + } + } + + this.logger.debug( + 'generateUnsignedSetChainRateLimiterConfig: pool =', + params.poolAddress, + 'version =', + version, + 'configs =', + params.chainConfigs.length, + ) + + return { family: ChainFamily.EVM, transactions } + } + + /** + * Updates rate limiter configurations on a token pool, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pool owner or rate limit admin) + * @param params - Set chain rate limiter config parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPSetRateLimiterConfigFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.setChainRateLimiterConfig(wallet, { + * poolAddress: '0x1234...', + * chainConfigs: [{ + * remoteChainSelector: '16015286601757825753', + * outboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * }], + * }) + * ``` + */ + async setChainRateLimiterConfig( + wallet: unknown, + params: SetChainRateLimiterConfigParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedSetChainRateLimiterConfig(params) + + this.logger.debug('setChainRateLimiterConfig: updating rate limits...') + + try { + let lastTxHash = '' + for (const unsignedTx of unsigned.transactions) { + const tx = await wallet.populateTransaction(unsignedTx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug( + 'setChainRateLimiterConfig: waiting for confirmation, tx =', + response.hash, + ) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPSetRateLimiterConfigFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPSetRateLimiterConfigFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + lastTxHash = response.hash + } + + this.logger.info('setChainRateLimiterConfig: updated rate limits, tx =', lastTxHash) + + return { txHash: lastTxHash } + } catch (error) { + if (error instanceof CCIPSetRateLimiterConfigFailedError) throw error + if (error instanceof CCIPSetRateLimiterConfigParamsInvalidError) throw error + throw new CCIPSetRateLimiterConfigFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // setRateLimitAdmin + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Builds an unsigned transaction to set the rate limit admin on a token pool. + * + * Automatically detects the pool version: + * - **v1.5/v1.6**: uses `setRateLimitAdmin(address)` + * - **v2.0+**: uses `setDynamicConfig(router, rateLimitAdmin, feeAdmin)` — reads current + * dynamic config first and preserves `router` and `feeAdmin` values + * + * @param params - Set rate limit admin parameters + * @returns Unsigned EVM transaction set + * @throws {@link CCIPSetRateLimitAdminParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedSetRateLimitAdmin({ + * poolAddress: '0x1234...', + * rateLimitAdmin: '0xabcd...', + * }) + * ``` + */ + async generateUnsignedSetRateLimitAdmin(params: SetRateLimitAdminParams): Promise { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPSetRateLimitAdminParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.rateLimitAdmin || params.rateLimitAdmin.trim().length === 0) { + throw new CCIPSetRateLimitAdminParamsInvalidError('rateLimitAdmin', 'must be non-empty') + } + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + const iface = new Interface(abi) + + let data: string + + if (version >= CCIPVersion.V2_0) { + // v2.0: read current dynamic config, update only rateLimitAdmin + const contract = new Contract(params.poolAddress, abi, this.provider) + const [router, , feeAdmin] = (await contract.getFunction('getDynamicConfig')()) as [ + string, + unknown, + string, + ] + data = iface.encodeFunctionData('setDynamicConfig', [router, params.rateLimitAdmin, feeAdmin]) + } else { + // v1.5/v1.6: standalone setRateLimitAdmin(address) + data = iface.encodeFunctionData('setRateLimitAdmin', [params.rateLimitAdmin]) + } + + const tx: TransactionRequest = { to: params.poolAddress, data } + + this.logger.debug( + 'generateUnsignedSetRateLimitAdmin: pool =', + params.poolAddress, + 'version =', + version, + 'admin =', + params.rateLimitAdmin, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Sets the rate limit admin on a token pool, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pool owner) + * @param params - Set rate limit admin parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPSetRateLimitAdminParamsInvalidError} if params are invalid + * @throws {@link CCIPSetRateLimitAdminFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.setRateLimitAdmin(wallet, { + * poolAddress: '0x1234...', + * rateLimitAdmin: '0xabcd...', + * }) + * ``` + */ + async setRateLimitAdmin( + wallet: unknown, + params: SetRateLimitAdminParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedSetRateLimitAdmin(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('setRateLimitAdmin: updating rate limit admin...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('setRateLimitAdmin: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPSetRateLimitAdminFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPSetRateLimitAdminFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('setRateLimitAdmin: updated rate limit admin, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPSetRateLimitAdminFailedError) throw error + if (error instanceof CCIPSetRateLimitAdminParamsInvalidError) throw error + throw new CCIPSetRateLimitAdminFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Grant Mint/Burn Access ───────────────────────────────────────────── + + /** + * Builds an unsigned transaction for granting mint and burn roles on a + * BurnMintERC20 token to the specified authority address. + * + * Calls `grantMintAndBurnRoles(authority)` on the token contract. + * + * @param params - Grant mint/burn access parameters + * @returns Unsigned EVM transaction set + * @throws {@link CCIPGrantMintBurnAccessParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedGrantMintBurnAccess({ + * tokenAddress: '0xa42BA...', + * authority: '0x1234...', + * }) + * ``` + */ + generateUnsignedGrantMintBurnAccess(params: GrantMintBurnAccessParams): UnsignedEVMTx { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPGrantMintBurnAccessParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.authority || params.authority.trim().length === 0) { + throw new CCIPGrantMintBurnAccessParamsInvalidError('authority', 'must be non-empty') + } + + const role = params.role ?? 'mintAndBurn' + const tokenType = params.tokenType ?? 'burnMintERC20' + + let data: string + if (tokenType === 'factoryBurnMintERC20') { + const iface = new Interface(FactoryBurnMintERC20ABI) + switch (role) { + case 'mint': + data = iface.encodeFunctionData('grantMintRole', [params.authority]) + break + case 'burn': + data = iface.encodeFunctionData('grantBurnRole', [params.authority]) + break + case 'mintAndBurn': + default: + data = iface.encodeFunctionData('grantMintAndBurnRoles', [params.authority]) + break + } + } else { + const iface = new Interface(BurnMintERC20ABI) + switch (role) { + case 'mint': + data = iface.encodeFunctionData('grantRole', [MINTER_ROLE, params.authority]) + break + case 'burn': + data = iface.encodeFunctionData('grantRole', [BURNER_ROLE, params.authority]) + break + case 'mintAndBurn': + default: + data = iface.encodeFunctionData('grantMintAndBurnRoles', [params.authority]) + break + } + } + + const tx: TransactionRequest = { to: params.tokenAddress, data } + + this.logger.debug( + 'generateUnsignedGrantMintBurnAccess: token =', + params.tokenAddress, + 'authority =', + params.authority, + 'role =', + role, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Grants mint and burn roles on a BurnMintERC20 token, signing and + * submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the token owner) + * @param params - Grant mint/burn access parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPGrantMintBurnAccessParamsInvalidError} if params are invalid + * @throws {@link CCIPGrantMintBurnAccessFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.grantMintBurnAccess(wallet, { + * tokenAddress: '0xa42BA...', + * authority: '0x1234...', + * }) + * ``` + */ + async grantMintBurnAccess( + wallet: unknown, + params: GrantMintBurnAccessParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = this.generateUnsignedGrantMintBurnAccess(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('grantMintBurnAccess: granting mint/burn roles...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('grantMintBurnAccess: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPGrantMintBurnAccessFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPGrantMintBurnAccessFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('grantMintBurnAccess: granted mint/burn roles, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPGrantMintBurnAccessFailedError) throw error + if (error instanceof CCIPGrantMintBurnAccessParamsInvalidError) throw error + throw new CCIPGrantMintBurnAccessFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Revoke Mint/Burn Access ─────────────────────────────────────────────── + + /** + * Builds an unsigned transaction to revoke mint or burn access from an + * address on a BurnMintERC20 token. + * + * @param params - Revoke mint/burn access parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPRevokeMintBurnAccessParamsInvalidError} if params are invalid + */ + generateUnsignedRevokeMintBurnAccess(params: RevokeMintBurnAccessParams): UnsignedEVMTx { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPRevokeMintBurnAccessParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.authority || params.authority.trim().length === 0) { + throw new CCIPRevokeMintBurnAccessParamsInvalidError('authority', 'must be non-empty') + } + if ((params.role as string) !== 'mint' && (params.role as string) !== 'burn') { + throw new CCIPRevokeMintBurnAccessParamsInvalidError('role', "must be 'mint' or 'burn'") + } + + const tokenType = params.tokenType ?? 'burnMintERC20' + + let data: string + if (tokenType === 'factoryBurnMintERC20') { + const iface = new Interface(FactoryBurnMintERC20ABI) + data = + params.role === 'mint' + ? iface.encodeFunctionData('revokeMintRole', [params.authority]) + : iface.encodeFunctionData('revokeBurnRole', [params.authority]) + } else { + const iface = new Interface(BurnMintERC20ABI) + data = + params.role === 'mint' + ? iface.encodeFunctionData('revokeRole', [MINTER_ROLE, params.authority]) + : iface.encodeFunctionData('revokeRole', [BURNER_ROLE, params.authority]) + } + + const tx: TransactionRequest = { to: params.tokenAddress, data } + + this.logger.debug( + 'generateUnsignedRevokeMintBurnAccess: token =', + params.tokenAddress, + 'authority =', + params.authority, + 'role =', + params.role, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Revokes mint or burn access from an address on a BurnMintERC20 token, + * signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the token owner) + * @param params - Revoke mint/burn access parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPRevokeMintBurnAccessParamsInvalidError} if params are invalid + * @throws {@link CCIPRevokeMintBurnAccessFailedError} if the transaction fails + */ + async revokeMintBurnAccess( + wallet: unknown, + params: RevokeMintBurnAccessParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = this.generateUnsignedRevokeMintBurnAccess(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('revokeMintBurnAccess: revoking', params.role, 'role...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('revokeMintBurnAccess: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPRevokeMintBurnAccessFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPRevokeMintBurnAccessFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('revokeMintBurnAccess: revoked', params.role, 'role, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPRevokeMintBurnAccessFailedError) throw error + if (error instanceof CCIPRevokeMintBurnAccessParamsInvalidError) throw error + throw new CCIPRevokeMintBurnAccessFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Get Mint/Burn Roles (read-only) ────────────────────────────────────── + + /** + * Queries mint and burn role holders on a BurnMintERC20 token. + * + * Tries `AccessControlEnumerable` (`getRoleMemberCount` / `getRoleMember`) + * first. If the contract only implements `AccessControl` (no enumeration), + * falls back to scanning `RoleGranted` events and verifying with `hasRole`. + * + * @param tokenAddress - ERC20 contract address + * @returns Lists of minter and burner addresses + * + * @example + * ```typescript + * const { minters, burners } = await admin.getMintBurnRoles('0xa42BA...') + * ``` + */ + async getMintBurnRoles(tokenAddress: string): Promise { + // Try FactoryBurnMintERC20 fast path first — getMinters()/getBurners() + try { + const factoryContract = new Contract(tokenAddress, FactoryBurnMintERC20ABI, this.provider) + const [minters, burners] = await Promise.all([ + factoryContract.getFunction('getMinters')() as Promise, + factoryContract.getFunction('getBurners')() as Promise, + ]) + + this.logger.debug( + `getMintBurnRoles: factory path (getMinters/getBurners), token=${tokenAddress}, minters=${minters.length}, burners=${burners.length}`, + ) + + return { minters: [...minters], burners: [...burners] } + } catch { + this.logger.debug( + 'getMintBurnRoles: getMinters/getBurners not available, trying AccessControl', + ) + } + + const contract = new Contract(tokenAddress, BurnMintERC20ABI, this.provider) + + const [minterRole, burnerRole] = await Promise.all([ + contract.getFunction('MINTER_ROLE')() as Promise, + contract.getFunction('BURNER_ROLE')() as Promise, + ]) + + // Try AccessControlEnumerable (fast path for BurnMintERC20 with enumeration) + try { + const [minterCount, burnerCount] = await Promise.all([ + contract.getFunction('getRoleMemberCount')(minterRole) as Promise, + contract.getFunction('getRoleMemberCount')(burnerRole) as Promise, + ]) + + const minterPromises: Promise[] = [] + for (let i = 0n; i < minterCount; i++) { + minterPromises.push(contract.getFunction('getRoleMember')(minterRole, i) as Promise) + } + const burnerPromises: Promise[] = [] + for (let i = 0n; i < burnerCount; i++) { + burnerPromises.push(contract.getFunction('getRoleMember')(burnerRole, i) as Promise) + } + + const [minters, burners] = await Promise.all([ + Promise.all(minterPromises), + Promise.all(burnerPromises), + ]) + + this.logger.debug( + `getMintBurnRoles: enumerable path, token=${tokenAddress}, minters=${minters.length}, burners=${burners.length}`, + ) + + return { minters, burners } + } catch { + // AccessControlEnumerable not available, fall back to event scanning + this.logger.debug( + 'getMintBurnRoles: getRoleMemberCount not available, scanning RoleGranted events', + ) + } + + // Fallback: scan RoleGranted events + verify with hasRole + // Uses getEvmLogs for consistent pagination + archive-RPC fallback + const roleGrantedTopic = Interface.from(BurnMintERC20ABI).getEvent('RoleGranted')!.topicHash + + const scanLogs = async (roleTopic: string) => { + const logs: Log[] = [] + for await (const log of getEvmLogs( + { + address: tokenAddress, + topics: [[roleGrantedTopic], roleTopic], + startBlock: 1, + onlyFallback: false, + }, + { provider: this.provider, logger: this.logger }, + )) { + logs.push(log) + } + return logs + } + + const [minterGrantedLogs, burnerGrantedLogs] = await Promise.all([ + scanLogs(minterRole), + scanLogs(burnerRole), + ]) + + // Collect unique candidate addresses from event topic[2] (indexed `account`) + const minterCandidates = [ + ...new Set( + minterGrantedLogs.map( + (l) => AbiCoder.defaultAbiCoder().decode(['address'], l.topics[2]!)[0] as string, + ), + ), + ] + const burnerCandidates = [ + ...new Set( + burnerGrantedLogs.map( + (l) => AbiCoder.defaultAbiCoder().decode(['address'], l.topics[2]!)[0] as string, + ), + ), + ] + + // Verify each candidate still has the role + const [minterChecks, burnerChecks] = await Promise.all([ + Promise.all( + minterCandidates.map((addr) => + (contract.getFunction('hasRole')(minterRole, addr) as Promise).then((has) => + has ? addr : null, + ), + ), + ), + Promise.all( + burnerCandidates.map((addr) => + (contract.getFunction('hasRole')(burnerRole, addr) as Promise).then((has) => + has ? addr : null, + ), + ), + ), + ]) + + const minters = minterChecks.filter((a): a is string => a !== null) + const burners = burnerChecks.filter((a): a is string => a !== null) + + this.logger.debug( + `getMintBurnRoles: event scan path, token=${tokenAddress}, minters=${minters.length}, burners=${burners.length}`, + ) + + return { minters, burners } + } + + // ── Transfer Ownership ─────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for proposing a new pool owner. + * + * Encodes `transferOwnership(address to)` on the pool contract. + * + * @param params - Transfer ownership parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPTransferOwnershipParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedTransferOwnership({ + * poolAddress: '0x1234...', + * newOwner: '0xabcd...', + * }) + * ``` + */ + async generateUnsignedTransferOwnership(params: TransferOwnershipParams): Promise { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPTransferOwnershipParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.newOwner || params.newOwner.trim().length === 0) { + throw new CCIPTransferOwnershipParamsInvalidError('newOwner', 'must be non-empty') + } + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + const iface = new Interface(abi) + + // All versions (v1.5, v1.6, v2.0) use the same transferOwnership(address) signature + // inherited from OpenZeppelin Ownable2Step. Version-aware branching kept for + // forward-compatibility — if a future version changes the signature, add a branch here. + let data: string + if (version >= CCIPVersion.V2_0) { + data = iface.encodeFunctionData('transferOwnership', [params.newOwner]) + } else { + data = iface.encodeFunctionData('transferOwnership', [params.newOwner]) + } + + const tx: TransactionRequest = { to: params.poolAddress, data } + + this.logger.debug( + 'generateUnsignedTransferOwnership: pool =', + params.poolAddress, + 'newOwner =', + params.newOwner, + 'version =', + version, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Proposes a new pool owner, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the current pool owner) + * @param params - Transfer ownership parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPTransferOwnershipParamsInvalidError} if params are invalid + * @throws {@link CCIPTransferOwnershipFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.transferOwnership(wallet, { + * poolAddress: '0x1234...', + * newOwner: '0xabcd...', + * }) + * ``` + */ + async transferOwnership( + wallet: unknown, + params: TransferOwnershipParams, + ): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedTransferOwnership(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('transferOwnership: proposing new owner...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('transferOwnership: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPTransferOwnershipFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPTransferOwnershipFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('transferOwnership: ownership proposed, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPTransferOwnershipFailedError) throw error + if (error instanceof CCIPTransferOwnershipParamsInvalidError) throw error + throw new CCIPTransferOwnershipFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Accept Ownership ───────────────────────────────────────────────────── + + /** + * Builds an unsigned transaction for accepting pool ownership. + * + * Encodes `acceptOwnership()` on the pool contract. + * + * @param params - Accept ownership parameters + * @returns Unsigned EVM transaction + * @throws {@link CCIPAcceptOwnershipParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const unsigned = await admin.generateUnsignedAcceptOwnership({ + * poolAddress: '0x1234...', + * }) + * ``` + */ + async generateUnsignedAcceptOwnership(params: AcceptOwnershipParams): Promise { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPAcceptOwnershipParamsInvalidError('poolAddress', 'must be non-empty') + } + + const { version, abi } = await this.getPoolVersionAndABI(params.poolAddress) + const iface = new Interface(abi) + + // All versions (v1.5, v1.6, v2.0) use the same acceptOwnership() signature + // inherited from OpenZeppelin Ownable2Step. Version-aware branching kept for + // forward-compatibility — if a future version changes the signature, add a branch here. + let data: string + if (version >= CCIPVersion.V2_0) { + data = iface.encodeFunctionData('acceptOwnership', []) + } else { + data = iface.encodeFunctionData('acceptOwnership', []) + } + + const tx: TransactionRequest = { to: params.poolAddress, data } + + this.logger.debug( + 'generateUnsignedAcceptOwnership: pool =', + params.poolAddress, + 'version =', + version, + ) + + return { family: ChainFamily.EVM, transactions: [tx] } + } + + /** + * Accepts pool ownership, signing and submitting with the provided wallet. + * + * @param wallet - EVM signer (must be the pending/proposed owner) + * @param params - Accept ownership parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Signer + * @throws {@link CCIPAcceptOwnershipParamsInvalidError} if params are invalid + * @throws {@link CCIPAcceptOwnershipFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.acceptOwnership(wallet, { + * poolAddress: '0x1234...', + * }) + * ``` + */ + async acceptOwnership(wallet: unknown, params: AcceptOwnershipParams): Promise { + if (!isSigner(wallet)) throw new CCIPWalletInvalidError(wallet) + + const unsigned = await this.generateUnsignedAcceptOwnership(params) + let tx: TransactionRequest = unsigned.transactions[0]! + + this.logger.debug('acceptOwnership: accepting ownership...') + + try { + tx = await wallet.populateTransaction(tx) + tx.from = undefined + const response = await submitTransaction(wallet, tx, this.provider) + + this.logger.debug('acceptOwnership: waiting for confirmation, tx =', response.hash) + const receipt = await response.wait(1, 60_000) + + if (!receipt) { + throw new CCIPAcceptOwnershipFailedError('transaction receipt not received', { + context: { txHash: response.hash }, + }) + } + + if (receipt.status === 0) { + throw new CCIPAcceptOwnershipFailedError('transaction reverted', { + context: { txHash: response.hash }, + }) + } + + this.logger.info('acceptOwnership: ownership accepted, tx =', response.hash) + + return { txHash: response.hash } + } catch (error) { + if (error instanceof CCIPAcceptOwnershipFailedError) throw error + if (error instanceof CCIPAcceptOwnershipParamsInvalidError) throw error + throw new CCIPAcceptOwnershipFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } +} + +export type { EVMRegistrationMethod } from '../types.ts' diff --git a/ccip-sdk/src/token-admin/set-rate-limiter-config-utils.ts b/ccip-sdk/src/token-admin/set-rate-limiter-config-utils.ts new file mode 100644 index 00000000..339de8eb --- /dev/null +++ b/ccip-sdk/src/token-admin/set-rate-limiter-config-utils.ts @@ -0,0 +1,94 @@ +/** + * Shared utilities for setChainRateLimiterConfig across all chain families. + * + * Contains validation logic used by EVM, Solana, and Aptos implementations. + * + * @packageDocumentation + */ + +import type { RateLimiterConfig, SetChainRateLimiterConfigParams } from './types.ts' +import { CCIPSetRateLimiterConfigParamsInvalidError } from '../errors/index.ts' + +/** + * Validates a single rate limiter config object. + * + * @param config - Rate limiter config to validate + * @param prefix - Parameter path prefix for error messages (e.g., "chainConfigs[0].outboundRateLimiterConfig") + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} on invalid config + */ +function validateRateLimiterConfig(config: RateLimiterConfig, prefix: string): void { + if (config.capacity.trim().length === 0) { + throw new CCIPSetRateLimiterConfigParamsInvalidError(`${prefix}.capacity`, 'must be non-empty') + } + if (config.rate.trim().length === 0) { + throw new CCIPSetRateLimiterConfigParamsInvalidError(`${prefix}.rate`, 'must be non-empty') + } + // Validate they parse as non-negative bigints + try { + const cap = BigInt(config.capacity) + if (cap < 0n) { + throw new CCIPSetRateLimiterConfigParamsInvalidError( + `${prefix}.capacity`, + 'must be non-negative', + ) + } + } catch (e) { + if (e instanceof CCIPSetRateLimiterConfigParamsInvalidError) throw e + throw new CCIPSetRateLimiterConfigParamsInvalidError( + `${prefix}.capacity`, + 'must be a valid integer string', + ) + } + try { + const r = BigInt(config.rate) + if (r < 0n) { + throw new CCIPSetRateLimiterConfigParamsInvalidError(`${prefix}.rate`, 'must be non-negative') + } + } catch (e) { + if (e instanceof CCIPSetRateLimiterConfigParamsInvalidError) throw e + throw new CCIPSetRateLimiterConfigParamsInvalidError( + `${prefix}.rate`, + 'must be a valid integer string', + ) + } +} + +/** + * Validates setChainRateLimiterConfig parameters. + * + * Checks that poolAddress is non-empty, chainConfigs is non-empty, and each + * config entry has a valid remoteChainSelector and rate limiter configs. + * + * @param params - Set chain rate limiter config parameters to validate + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} on invalid params + */ +export function validateSetChainRateLimiterConfigParams( + params: SetChainRateLimiterConfigParams, +): void { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPSetRateLimiterConfigParamsInvalidError('poolAddress', 'must be non-empty') + } + if (params.chainConfigs.length === 0) { + throw new CCIPSetRateLimiterConfigParamsInvalidError( + 'chainConfigs', + 'must have at least one entry', + ) + } + for (let i = 0; i < params.chainConfigs.length; i++) { + const config = params.chainConfigs[i]! + if (config.remoteChainSelector == null || config.remoteChainSelector === 0n) { + throw new CCIPSetRateLimiterConfigParamsInvalidError( + `chainConfigs[${i}].remoteChainSelector`, + 'must be non-zero', + ) + } + validateRateLimiterConfig( + config.outboundRateLimiterConfig, + `chainConfigs[${i}].outboundRateLimiterConfig`, + ) + validateRateLimiterConfig( + config.inboundRateLimiterConfig, + `chainConfigs[${i}].inboundRateLimiterConfig`, + ) + } +} diff --git a/ccip-sdk/src/token-admin/solana/index.ts b/ccip-sdk/src/token-admin/solana/index.ts new file mode 100644 index 00000000..72cebab2 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/index.ts @@ -0,0 +1,3267 @@ +/** + * Solana token admin — deploy SPL Token mints and initialize CCIP token pools. + * + * @example Using SolanaTokenAdmin with a wallet (signed deploy) + * ```typescript + * import { SolanaChain } from '@chainlink/ccip-sdk' + * import { SolanaTokenAdmin } from '@chainlink/ccip-sdk/src/token-admin/solana/index.ts' + * + * const chain = await SolanaChain.fromUrl('https://api.devnet.solana.com') + * const admin = SolanaTokenAdmin.fromChain(chain) + * const { tokenAddress, txHash } = await admin.deployToken(wallet, { + * name: 'My Token', symbol: 'MTK', decimals: 9, + * }) + * ``` + * + * @packageDocumentation + */ + +import { Program } from '@coral-xyz/anchor' +import { + AuthorityType, + MULTISIG_SIZE, + MintLayout, + MultisigLayout, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createInitializeMint2Instruction, + createInitializeMultisigInstruction, + createMintToInstruction, + createSetAuthorityInstruction, + getAssociatedTokenAddressSync, + getMintLen, +} from '@solana/spl-token' +import { + type Connection, + type Transaction, + type TransactionInstruction, + type VersionedTransaction, + AddressLookupTableProgram, + Keypair, + PublicKey, + SYSVAR_INSTRUCTIONS_PUBKEY, + SystemProgram, +} from '@solana/web3.js' +import BN from 'bn.js' + +import type { ChainContext } from '../../chain.ts' +import { + CCIPAcceptAdminRoleFailedError, + CCIPAcceptAdminRoleParamsInvalidError, + CCIPAcceptOwnershipFailedError, + CCIPAcceptOwnershipParamsInvalidError, + CCIPAppendRemotePoolAddressesFailedError, + CCIPAppendRemotePoolAddressesParamsInvalidError, + CCIPApplyChainUpdatesFailedError, + CCIPApplyChainUpdatesParamsInvalidError, + CCIPCreatePoolMultisigFailedError, + CCIPCreatePoolMultisigParamsInvalidError, + CCIPCreatePoolTokenAccountFailedError, + CCIPCreatePoolTokenAccountParamsInvalidError, + CCIPCreateTokenAltFailedError, + CCIPCreateTokenAltParamsInvalidError, + CCIPDeleteChainConfigFailedError, + CCIPDeleteChainConfigParamsInvalidError, + CCIPGrantMintBurnAccessFailedError, + CCIPGrantMintBurnAccessParamsInvalidError, + CCIPPoolDeployFailedError, + CCIPPoolDeployParamsInvalidError, + CCIPProposeAdminRoleFailedError, + CCIPProposeAdminRoleParamsInvalidError, + CCIPRemoveRemotePoolAddressesFailedError, + CCIPRemoveRemotePoolAddressesParamsInvalidError, + CCIPRevokeMintBurnAccessParamsInvalidError, + CCIPSetPoolFailedError, + CCIPSetPoolParamsInvalidError, + CCIPSetRateLimitAdminFailedError, + CCIPSetRateLimitAdminParamsInvalidError, + CCIPSetRateLimiterConfigFailedError, + CCIPSetRateLimiterConfigParamsInvalidError, + CCIPTokenDeployFailedError, + CCIPTokenDeployParamsInvalidError, + CCIPTokenPoolInfoNotFoundError, + CCIPTransferAdminRoleFailedError, + CCIPTransferAdminRoleParamsInvalidError, + CCIPTransferMintAuthorityFailedError, + CCIPTransferMintAuthorityParamsInvalidError, + CCIPTransferOwnershipFailedError, + CCIPTransferOwnershipParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { + type BaseTokenPool, + IDL as BASE_TOKEN_POOL_IDL, +} from '../../solana/idl/1.6.0/BASE_TOKEN_POOL.ts' +import { + type BurnmintTokenPool, + IDL as BURN_MINT_TOKEN_POOL_IDL, +} from '../../solana/idl/1.6.0/BURN_MINT_TOKEN_POOL.ts' +import { IDL as CCIP_ROUTER_IDL } from '../../solana/idl/1.6.0/CCIP_ROUTER.ts' +import { + type LockreleaseTokenPool, + IDL as LOCK_RELEASE_TOKEN_POOL_IDL, +} from '../../solana/idl/1.6.0/LOCK_RELEASE_TOKEN_POOL.ts' +import { SolanaChain } from '../../solana/index.ts' +import { type UnsignedSolanaTx, type Wallet, isWallet } from '../../solana/types.ts' +import { derivePoolSignerPDA, simulateAndSendTxs, simulationProvider } from '../../solana/utils.ts' +import { type NetworkInfo, type WithLogger, ChainFamily } from '../../types.ts' +import { + encodeRemoteAddress, + encodeRemoteAddressBytes, + encodeRemotePoolAddressBytes, + validateAppendRemotePoolAddressesParams, + validateApplyChainUpdatesParams, + validateDeleteChainConfigParams, + validateRemoveRemotePoolAddressesParams, +} from '../apply-chain-updates-utils.ts' +import { validateSetChainRateLimiterConfigParams } from '../set-rate-limiter-config-utils.ts' +import type { + AcceptAdminRoleParams, + AcceptAdminRoleResult, + AcceptOwnershipParams, + AppendRemotePoolAddressesParams, + AppendRemotePoolAddressesResult, + ApplyChainUpdatesParams, + ApplyChainUpdatesResult, + CreatePoolMintAuthorityMultisigParams, + CreatePoolMintAuthorityMultisigResult, + CreatePoolTokenAccountParams, + CreatePoolTokenAccountResult, + CreateTokenAltParams, + CreateTokenAltResult, + DeleteChainConfigParams, + DeleteChainConfigResult, + DeployPoolResult, + DeployTokenResult, + GrantMintBurnAccessParams, + GrantMintBurnAccessResult, + OwnershipResult, + ProposeAdminRoleResult, + RemoveRemotePoolAddressesParams, + RemoveRemotePoolAddressesResult, + RevokeMintBurnAccessParams, + SetChainRateLimiterConfigParams, + SetChainRateLimiterConfigResult, + SetPoolResult, + SetRateLimitAdminParams, + SetRateLimitAdminResult, + SolanaDeployPoolParams, + SolanaDeployTokenParams, + SolanaMintBurnRolesResult, + SolanaProposeAdminRoleParams, + SolanaSetPoolParams, + TransferAdminRoleParams, + TransferAdminRoleResult, + TransferMintAuthorityParams, + TransferMintAuthorityResult, + TransferOwnershipParams, +} from '../types.ts' + +/** Metaplex Token Metadata Program ID. */ +const TOKEN_METADATA_PROGRAM_ID = new PublicKey('metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s') + +/** BPF Loader Upgradeable Program ID (for deriving programData accounts). */ +const BPF_LOADER_UPGRADEABLE_PROGRAM_ID = new PublicKey( + 'BPFLoaderUpgradeab1e11111111111111111111111', +) + +// ── PDA Seeds ──────────────────────────────────────────────────────────────── + +/** Metaplex metadata PDA seed. */ +const METADATA_SEED = 'metadata' + +/** CCIP token pool config PDA seed. */ +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' + +/** Pool program config PDA seed. */ +const CONFIG_SEED = 'config' + +/** Token admin registry PDA seed (on the Router program). */ +const TOKEN_ADMIN_REGISTRY_SEED = 'token_admin_registry' + +/** CCIP token pool chain config PDA seed. */ +const CCIP_TOKENPOOL_CHAINCONFIG_SEED = 'ccip_tokenpool_chainconfig' + +/** Router external token pools signer PDA seed. */ +const EXTERNAL_TOKEN_POOLS_SIGNER_SEED = 'external_token_pools_signer' + +/** Fee quoter billing token config PDA seed. */ +const FEE_BILLING_TOKEN_CONFIG_SEED = 'fee_billing_token_config' + +// ── Metaplex Instruction Constants ─────────────────────────────────────────── + +/** Metaplex Create instruction discriminator (Create V1). */ +const METAPLEX_CREATE_DISCRIMINATOR = 42 + +/** Metaplex token standard value for fungible tokens. */ +const METAPLEX_TOKEN_STANDARD_FUNGIBLE = 2 + +/** + * Validates deploy parameters for Solana SPL Token. + * @throws {@link CCIPTokenDeployParamsInvalidError} on invalid params + */ +function validateParams(params: SolanaDeployTokenParams): void { + if (!params.name || params.name.trim().length === 0) { + throw new CCIPTokenDeployParamsInvalidError('name', 'must be non-empty') + } + if (!params.symbol || params.symbol.trim().length === 0) { + throw new CCIPTokenDeployParamsInvalidError('symbol', 'must be non-empty') + } + if (params.initialSupply !== undefined && params.initialSupply < 0n) { + throw new CCIPTokenDeployParamsInvalidError('initialSupply', 'must be non-negative') + } + if (params.maxSupply !== undefined && params.maxSupply < 0n) { + throw new CCIPTokenDeployParamsInvalidError('maxSupply', 'must be non-negative') + } + if ( + params.maxSupply !== undefined && + params.maxSupply > 0n && + params.initialSupply !== undefined && + params.initialSupply > params.maxSupply + ) { + throw new CCIPTokenDeployParamsInvalidError('initialSupply', 'exceeds maxSupply') + } +} + +/** + * Validates deploy parameters for Solana pool initialization. + * @throws {@link CCIPPoolDeployParamsInvalidError} on invalid params + */ +function validatePoolParams(params: SolanaDeployPoolParams): void { + const poolType: string = params.poolType + if (poolType !== 'burn-mint' && poolType !== 'lock-release') { + throw new CCIPPoolDeployParamsInvalidError('poolType', "must be 'burn-mint' or 'lock-release'") + } + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPPoolDeployParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.poolProgramId || params.poolProgramId.trim().length === 0) { + throw new CCIPPoolDeployParamsInvalidError('poolProgramId', 'must be non-empty') + } +} + +/** + * Validates proposeAdminRole parameters for Solana. + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} on invalid params + */ +function validateProposeAdminRoleParams(params: SolanaProposeAdminRoleParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.administrator || params.administrator.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('administrator', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPProposeAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } +} + +/** + * Validates accept admin role params. + * @throws {@link CCIPAcceptAdminRoleParamsInvalidError} on invalid params + */ +function validateAcceptAdminRoleParams(params: AcceptAdminRoleParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPAcceptAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPAcceptAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } +} + +function validateTransferAdminRoleParams(params: TransferAdminRoleParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.newAdmin || params.newAdmin.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('newAdmin', 'must be non-empty') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPTransferAdminRoleParamsInvalidError('routerAddress', 'must be non-empty') + } +} + +function validateSolanaSetPoolParams(params: SolanaSetPoolParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('tokenAddress', 'must be non-empty') + } + try { + new PublicKey(params.tokenAddress) + } catch { + throw new CCIPSetPoolParamsInvalidError('tokenAddress', 'must be a valid public key') + } + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('poolAddress', 'must be non-empty') + } + try { + new PublicKey(params.poolAddress) + } catch { + throw new CCIPSetPoolParamsInvalidError('poolAddress', 'must be a valid public key') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('routerAddress', 'must be non-empty') + } + try { + new PublicKey(params.routerAddress) + } catch { + throw new CCIPSetPoolParamsInvalidError('routerAddress', 'must be a valid public key') + } + if (!params.poolLookupTable || params.poolLookupTable.trim().length === 0) { + throw new CCIPSetPoolParamsInvalidError('poolLookupTable', 'must be non-empty') + } + try { + new PublicKey(params.poolLookupTable) + } catch { + throw new CCIPSetPoolParamsInvalidError('poolLookupTable', 'must be a valid public key') + } +} + +/** Borsh-encode a string: u32 little-endian length prefix + UTF-8 bytes. */ +function borshString(s: string): Buffer { + const bytes = Buffer.from(s) + const len = Buffer.alloc(4) + len.writeUInt32LE(bytes.length) + return Buffer.concat([len, bytes]) +} + +// ── Pool IDL Merging ────────────────────────────────────────────────────────── + +/** + * Pool-specific IDLs (BURN_MINT_TOKEN_POOL, LOCK_RELEASE_TOKEN_POOL) reference + * types like `RateLimitConfig`, `RemoteConfig`, `RemoteAddress` via `{ defined: '...' }` + * but don't include their definitions. Those types live in BASE_TOKEN_POOL. + * + * We create merged IDL types that combine pool instructions with base type definitions, + * so Anchor's `Program` class can serialize the referenced types correctly. + */ +type BurnMintMergedIdl = BurnmintTokenPool & Pick +type LockReleaseMergedIdl = LockreleaseTokenPool & Pick + +const BURN_MINT_MERGED_IDL: BurnMintMergedIdl = { + ...BURN_MINT_TOKEN_POOL_IDL, + types: [...BASE_TOKEN_POOL_IDL.types], +} + +const LOCK_RELEASE_MERGED_IDL: LockReleaseMergedIdl = { + ...LOCK_RELEASE_TOKEN_POOL_IDL, + types: [...BASE_TOKEN_POOL_IDL.types], +} + +/** + * Creates an Anchor Program instance for a pool program. + * + * Uses the appropriate merged IDL (burn-mint or lock-release) based on the + * pool program's name. Falls back to burn-mint if the pool type is unknown + * (both share the same instruction set for the operations we use). + * + * @param ctx - Connection and logger for the simulation provider + * @param poolProgramId - Pool program public key + * @param poolType - Optional pool type hint ('burn-mint' or 'lock-release') + * @returns Anchor Program instance + */ +function createPoolProgram( + ctx: { connection: Connection } & WithLogger, + poolProgramId: PublicKey, + poolType?: 'burn-mint' | 'lock-release', +) { + if (poolType === 'lock-release') { + return new Program(LOCK_RELEASE_MERGED_IDL, poolProgramId, simulationProvider(ctx)) + } + return new Program(BURN_MINT_MERGED_IDL, poolProgramId, simulationProvider(ctx)) +} + +/** + * Creates an Anchor Program instance for the CCIP Router. + */ +function createRouterProgram( + ctx: { connection: Connection } & WithLogger, + routerProgramId: PublicKey, +) { + return new Program(CCIP_ROUTER_IDL, routerProgramId, simulationProvider(ctx)) +} + +/** + * Builds a Metaplex Create (V1) instruction (discriminator 42). + * Supports both SPL Token and Token-2022 via the splTokenProgram account. + * Avoids importing metaplex-foundation — the instruction is built manually. + */ +function createMetadataInstruction( + metadataPDA: PublicKey, + mint: PublicKey, + mintAuthority: PublicKey, + payer: PublicKey, + updateAuthority: PublicKey, + name: string, + symbol: string, + uri: string, + decimals: number, + tokenProgramId: PublicKey, +): TransactionInstruction { + // Create instruction (discriminator = 42), CreateArgs::V1 variant (0) + const parts: Buffer[] = [ + Buffer.from([METAPLEX_CREATE_DISCRIMINATOR, 0]), + // AssetData struct (borsh): + borshString(name), + borshString(symbol), + borshString(uri), + // sellerFeeBasisPoints (u16) = 0 + Buffer.from([0, 0]), + // creators (Option>) = None + Buffer.from([0]), + // primarySaleHappened (bool) = false + Buffer.from([0]), + // isMutable (bool) = true + Buffer.from([1]), + // tokenStandard (u8) = Fungible + Buffer.from([METAPLEX_TOKEN_STANDARD_FUNGIBLE]), + // collection (Option) = None + Buffer.from([0]), + // uses (Option) = None + Buffer.from([0]), + // collectionDetails (Option) = None + Buffer.from([0]), + // ruleSet (Option) = None + Buffer.from([0]), + // decimals: Option = Some(decimals) + Buffer.from([1, decimals]), + // printSupply: Option = None (fungible) + Buffer.from([0]), + ] + + return { + programId: TOKEN_METADATA_PROGRAM_ID, + keys: [ + { pubkey: metadataPDA, isSigner: false, isWritable: true }, + // masterEdition — not needed for fungible, use program ID as placeholder + { pubkey: TOKEN_METADATA_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: true, isWritable: true }, + { pubkey: mintAuthority, isSigner: true, isWritable: false }, + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: updateAuthority, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }, + { pubkey: tokenProgramId, isSigner: false, isWritable: false }, + ], + data: Buffer.concat(parts), + } +} + +/** + * Validates parameters for creating a pool mint authority multisig. + * @throws {@link CCIPCreatePoolMultisigParamsInvalidError} on invalid params + */ +function validateCreatePoolMultisigParams(params: CreatePoolMintAuthorityMultisigParams): void { + if (!params.mint || params.mint.trim().length === 0) { + throw new CCIPCreatePoolMultisigParamsInvalidError('mint', 'must be non-empty') + } + if (!params.poolProgramId || params.poolProgramId.trim().length === 0) { + throw new CCIPCreatePoolMultisigParamsInvalidError('poolProgramId', 'must be non-empty') + } + if (!Array.isArray(params.additionalSigners) || params.additionalSigners.length === 0) { + throw new CCIPCreatePoolMultisigParamsInvalidError( + 'additionalSigners', + 'must have at least one additional signer', + ) + } + for (const signer of params.additionalSigners) { + if (!signer || signer.trim().length === 0) { + throw new CCIPCreatePoolMultisigParamsInvalidError( + 'additionalSigners', + 'all signers must be non-empty', + ) + } + } + // Total signers = 1 (pool signer PDA) + additionalSigners.length + // SPL Token multisig supports max 11 signers + const totalSigners = 1 + params.additionalSigners.length + if (totalSigners > 11) { + throw new CCIPCreatePoolMultisigParamsInvalidError( + 'additionalSigners', + `total signers (${totalSigners}) exceeds SPL Token multisig limit of 11`, + ) + } + if (!Number.isInteger(params.threshold) || params.threshold < 1) { + throw new CCIPCreatePoolMultisigParamsInvalidError('threshold', 'must be a positive integer') + } + if (params.threshold > totalSigners) { + throw new CCIPCreatePoolMultisigParamsInvalidError( + 'threshold', + `threshold (${params.threshold}) exceeds total signers (${totalSigners})`, + ) + } +} + +/** Validates TransferMintAuthorityParams, throwing on first invalid field. */ +function validateTransferMintAuthorityParams(params: TransferMintAuthorityParams): void { + if (!params.mint || params.mint.trim().length === 0) { + throw new CCIPTransferMintAuthorityParamsInvalidError('mint', 'must be non-empty') + } + try { + new PublicKey(params.mint) + } catch { + throw new CCIPTransferMintAuthorityParamsInvalidError('mint', 'must be a valid public key') + } + if (!params.newMintAuthority || params.newMintAuthority.trim().length === 0) { + throw new CCIPTransferMintAuthorityParamsInvalidError('newMintAuthority', 'must be non-empty') + } + try { + new PublicKey(params.newMintAuthority) + } catch { + throw new CCIPTransferMintAuthorityParamsInvalidError( + 'newMintAuthority', + 'must be a valid public key', + ) + } +} + +/** Validates CreateTokenAltParams, throwing on first invalid field. */ +function validateCreateTokenAltParams(params: CreateTokenAltParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPCreateTokenAltParamsInvalidError('tokenAddress', 'must be non-empty') + } + try { + new PublicKey(params.tokenAddress) + } catch { + throw new CCIPCreateTokenAltParamsInvalidError('tokenAddress', 'must be a valid public key') + } + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPCreateTokenAltParamsInvalidError('poolAddress', 'must be non-empty') + } + try { + new PublicKey(params.poolAddress) + } catch { + throw new CCIPCreateTokenAltParamsInvalidError('poolAddress', 'must be a valid public key') + } + if (!params.routerAddress || params.routerAddress.trim().length === 0) { + throw new CCIPCreateTokenAltParamsInvalidError('routerAddress', 'must be non-empty') + } + try { + new PublicKey(params.routerAddress) + } catch { + throw new CCIPCreateTokenAltParamsInvalidError('routerAddress', 'must be a valid public key') + } + if (params.authority != null) { + if (params.authority.trim().length === 0) { + throw new CCIPCreateTokenAltParamsInvalidError('authority', 'must be non-empty when provided') + } + try { + new PublicKey(params.authority) + } catch { + throw new CCIPCreateTokenAltParamsInvalidError('authority', 'must be a valid public key') + } + } + if (params.additionalAddresses != null) { + // 10 base addresses + additional must not exceed 256 + if (params.additionalAddresses.length > 246) { + throw new CCIPCreateTokenAltParamsInvalidError( + 'additionalAddresses', + `too many additional addresses (${params.additionalAddresses.length}), max 246 (256 total - 10 base)`, + ) + } + for (const addr of params.additionalAddresses) { + if (!addr || addr.trim().length === 0) { + throw new CCIPCreateTokenAltParamsInvalidError( + 'additionalAddresses', + 'all addresses must be non-empty', + ) + } + try { + new PublicKey(addr) + } catch { + throw new CCIPCreateTokenAltParamsInvalidError( + 'additionalAddresses', + `invalid public key: ${addr}`, + ) + } + } + } +} + +function validateCreatePoolTokenAccountParams(params: CreatePoolTokenAccountParams): void { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPCreatePoolTokenAccountParamsInvalidError('tokenAddress', 'must be non-empty') + } + try { + new PublicKey(params.tokenAddress) + } catch { + throw new CCIPCreatePoolTokenAccountParamsInvalidError( + 'tokenAddress', + 'must be a valid public key', + ) + } + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPCreatePoolTokenAccountParamsInvalidError('poolAddress', 'must be non-empty') + } + try { + new PublicKey(params.poolAddress) + } catch { + throw new CCIPCreatePoolTokenAccountParamsInvalidError( + 'poolAddress', + 'must be a valid public key', + ) + } +} + +/** + * Solana token admin for deploying SPL Token mints with optional Metaplex metadata. + * + * Extends {@link SolanaChain} — inherits connection, logger, and chain discovery + * methods like `getTokenAdminRegistryFor`. + * + * @example Direct construction + * ```typescript + * const admin = new SolanaTokenAdmin(connection, network, { logger }) + * ``` + */ +export class SolanaTokenAdmin extends SolanaChain { + /** Creates a new SolanaTokenAdmin instance. */ + constructor(connection: Connection, network: NetworkInfo, ctx?: ChainContext) { + super(connection, network, ctx) + } + + /** + * Builds unsigned instructions for deploying an SPL Token mint. + * + * The returned instructions include: + * 1. SystemProgram.createAccount — allocate mint account + * 2. InitializeMint2 — initialize the mint + * 3. Create (V1) — Metaplex metadata (supports both SPL Token and Token-2022) + * 4. CreateAssociatedTokenAccount + MintTo (if initialSupply \> 0) + * + * A new mint keypair is generated and returned. The caller must include + * this keypair as a signer when submitting the transaction. + * + * @param sender - Wallet public key (base58) used as payer and default authority + * @param params - Token deployment parameters + * @returns Unsigned Solana transaction, mint keypair, and Metaplex metadata PDA + * @throws {@link CCIPTokenDeployParamsInvalidError} if params are invalid + * + * @example + * ```typescript + * const { unsigned, mintKeypair } = await admin.generateUnsignedDeployToken( + * wallet.publicKey.toBase58(), + * { name: 'My Token', symbol: 'MTK', decimals: 9 }, + * ) + * ``` + */ + async generateUnsignedDeployToken( + sender: string, + params: SolanaDeployTokenParams, + ): Promise<{ unsigned: UnsignedSolanaTx; mintKeypair: Keypair; metadataAddress: string }> { + validateParams(params) + + const payer = new PublicKey(sender) + const mintKeypair = Keypair.generate() + const mint = mintKeypair.publicKey + + const tokenProgramId = + params.tokenProgram === 'token-2022' ? TOKEN_2022_PROGRAM_ID : TOKEN_PROGRAM_ID + + const mintAuthority = params.mintAuthority ? new PublicKey(params.mintAuthority) : payer + const freezeAuthority = + params.freezeAuthority === null + ? null + : params.freezeAuthority + ? new PublicKey(params.freezeAuthority) + : payer + + const instructions: TransactionInstruction[] = [] + + // 1. Create mint account + const mintLen = getMintLen([]) + const lamports = await this.connection.getMinimumBalanceForRentExemption(mintLen) + + instructions.push( + SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: mint, + space: mintLen, + lamports, + programId: tokenProgramId, + }), + ) + + // 2. Initialize mint + instructions.push( + createInitializeMint2Instruction( + mint, + params.decimals, + mintAuthority, + freezeAuthority, + tokenProgramId, + ), + ) + + // 3. Metaplex metadata (always create if name/symbol provided — strongly recommended) + const [metadataPDA] = PublicKey.findProgramAddressSync( + [Buffer.from(METADATA_SEED), TOKEN_METADATA_PROGRAM_ID.toBuffer(), mint.toBuffer()], + TOKEN_METADATA_PROGRAM_ID, + ) + + instructions.push( + createMetadataInstruction( + metadataPDA, + mint, + mintAuthority, + payer, + mintAuthority, + params.name, + params.symbol, + params.metadataUri ?? '', + params.decimals, + tokenProgramId, + ), + ) + + // 4. Mint initial supply if requested + const initialSupply = params.initialSupply ?? 0n + if (initialSupply > 0n) { + const recipient = params.recipient ? new PublicKey(params.recipient) : payer + const ata = getAssociatedTokenAddressSync(mint, recipient, false, tokenProgramId) + + instructions.push( + createAssociatedTokenAccountIdempotentInstruction( + payer, + ata, + recipient, + mint, + tokenProgramId, + ), + ) + instructions.push( + createMintToInstruction(mint, ata, mintAuthority, initialSupply, [], tokenProgramId), + ) + } + + this.logger.debug( + 'generateUnsignedDeployToken: mint =', + mint.toBase58(), + 'instructions =', + instructions.length, + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions, + mainIndex: 0, + }, + mintKeypair, + metadataAddress: metadataPDA.toBase58(), + } + } + + /** + * Builds an unsigned instruction for initializing a CCIP token pool. + * + * The pool program must already be deployed on-chain. This method builds + * the Anchor `initialize` instruction with the correct PDA derivations: + * - state PDA: `["ccip_tokenpool_config", mint]` on the pool program + * - config PDA: `["config"]` on the pool program + * - programData: `[poolProgramId]` on BPF Loader Upgradeable + * + * @param sender - Wallet public key (base58) used as payer/authority + * @param params - Pool deployment parameters + * @returns Unsigned Solana transaction and the pool state PDA address + * @throws {@link CCIPPoolDeployParamsInvalidError} if params are invalid + */ + async generateUnsignedDeployPool( + sender: string, + params: SolanaDeployPoolParams, + ): Promise<{ unsigned: UnsignedSolanaTx; poolAddress: string }> { + validatePoolParams(params) + + const authority = new PublicKey(sender) + const mint = new PublicKey(params.tokenAddress) + const poolProgramId = new PublicKey(params.poolProgramId) + + // Derive PDAs + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const [configPda] = PublicKey.findProgramAddressSync([Buffer.from(CONFIG_SEED)], poolProgramId) + + const [programData] = PublicKey.findProgramAddressSync( + [poolProgramId.toBuffer()], + BPF_LOADER_UPGRADEABLE_PROGRAM_ID, + ) + + const poolProgram = createPoolProgram(this, poolProgramId, params.poolType) + + const instruction = await poolProgram.methods + .initialize() + .accountsStrict({ + state: statePda, + mint, + authority, + systemProgram: SystemProgram.programId, + program: poolProgramId, + programData, + config: configPda, + }) + .instruction() + + // Auto-detect token program from mint account + const mintInfo = await this.connection.getAccountInfo(mint) + if (!mintInfo) { + throw new CCIPPoolDeployParamsInvalidError('tokenAddress', 'mint account not found on-chain') + } + const tokenProgramId = mintInfo.owner + + // Derive Pool Signer PDA and its ATA + const [poolSignerPda] = derivePoolSignerPDA(mint, poolProgramId) + const poolTokenAta = getAssociatedTokenAddressSync( + mint, + poolSignerPda, + true, // allowOwnerOffCurve — PDAs are off-curve + tokenProgramId, + ) + + // Append idempotent ATA creation — safe even if ATA already exists + const createAtaIx = createAssociatedTokenAccountIdempotentInstruction( + authority, // payer + poolTokenAta, // ATA address + poolSignerPda, // owner (Pool Signer PDA) + mint, // token mint + tokenProgramId, // token program + ) + + this.logger.debug( + 'generateUnsignedDeployPool: statePda =', + statePda.toBase58(), + 'poolProgram =', + poolProgramId.toBase58(), + 'poolTokenAta =', + poolTokenAta.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction, createAtaIx], + mainIndex: 0, + }, + poolAddress: statePda.toBase58(), + } + } + + /** + * Initializes a CCIP token pool, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability + * @param params - Pool deployment parameters + * @returns Deploy result with `poolAddress` and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPPoolDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPPoolDeployFailedError} if the transaction fails + */ + async deployPool(wallet: unknown, params: SolanaDeployPoolParams): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned, poolAddress } = await this.generateUnsignedDeployPool(sender, params) + + this.logger.debug('deployPool: initializing CCIP token pool...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('deployPool: initialized pool at', poolAddress, 'tx =', signature) + + return { poolAddress, txHash: signature } + } catch (error) { + if (error instanceof CCIPPoolDeployFailedError) throw error + throw new CCIPPoolDeployFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Propose Admin Role ──────────────────────────────────────────────────── + + /** + * Builds an unsigned instruction for proposing an administrator in the + * TokenAdminRegistry (built into the Router program on Solana). + * + * Uses the `owner_propose_administrator` Anchor instruction with 5 accounts: + * 1. config (read-only) — Router config PDA + * 2. tokenAdminRegistry (writable) — TAR PDA for the mint + * 3. mint (read-only) — Token mint + * 4. authority/sender (writable, signer) — Mint authority + * 5. systemProgram (read-only) + * + * @param sender - Wallet public key (base58) used as authority + * @param params - Propose admin role parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} if params are invalid + */ + async generateUnsignedProposeAdminRole( + sender: string, + params: SolanaProposeAdminRoleParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateProposeAdminRoleParams(params) + + const authority = new PublicKey(sender) + const mint = new PublicKey(params.tokenAddress) + const routerProgramId = new PublicKey(params.routerAddress) + const administrator = new PublicKey(params.administrator) + + // Derive PDAs on the Router program + const [configPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CONFIG_SEED)], + routerProgramId, + ) + + const [tokenAdminRegistryPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ADMIN_REGISTRY_SEED), mint.toBuffer()], + routerProgramId, + ) + + const routerProgram = createRouterProgram(this, routerProgramId) + + const instruction = await routerProgram.methods + .ownerProposeAdministrator(administrator) + .accountsStrict({ + config: configPda, + tokenAdminRegistry: tokenAdminRegistryPda, + mint, + authority, + systemProgram: SystemProgram.programId, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedProposeAdminRole: TAR PDA =', + tokenAdminRegistryPda.toBase58(), + 'router =', + routerProgramId.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + } + } + + /** + * Proposes an administrator for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability + * @param params - Propose admin role parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPProposeAdminRoleParamsInvalidError} if params are invalid + * @throws {@link CCIPProposeAdminRoleFailedError} if the transaction fails + */ + async proposeAdminRole( + wallet: unknown, + params: SolanaProposeAdminRoleParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedProposeAdminRole(sender, params) + + this.logger.debug('proposeAdminRole: proposing administrator...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('proposeAdminRole: proposed admin, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPProposeAdminRoleFailedError) throw error + throw new CCIPProposeAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Accept Admin Role ───────────────────────────────────────────────────── + + /** + * Builds an unsigned instruction for accepting an administrator role in the + * TokenAdminRegistry (built into the Router program on Solana). + * + * Uses the `accept_admin_role_token_admin_registry` Anchor instruction with 5 accounts: + * 1. config (read-only) — Router config PDA + * 2. tokenAdminRegistry (writable) — TAR PDA for the mint + * 3. mint (read-only) — Token mint + * 4. authority/sender (writable, signer) — Pending administrator + * 5. systemProgram (read-only) + * + * @param sender - Wallet public key (base58) of the pending administrator + * @param params - Accept admin role parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPAcceptAdminRoleParamsInvalidError} if params are invalid + */ + async generateUnsignedAcceptAdminRole( + sender: string, + params: AcceptAdminRoleParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateAcceptAdminRoleParams(params) + + const authority = new PublicKey(sender) + const mint = new PublicKey(params.tokenAddress) + const routerProgramId = new PublicKey(params.routerAddress) + + const [configPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CONFIG_SEED)], + routerProgramId, + ) + + const [tokenAdminRegistryPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ADMIN_REGISTRY_SEED), mint.toBuffer()], + routerProgramId, + ) + + const routerProgram = createRouterProgram(this, routerProgramId) + + const instruction = await routerProgram.methods + .acceptAdminRoleTokenAdminRegistry() + .accountsStrict({ + config: configPda, + tokenAdminRegistry: tokenAdminRegistryPda, + mint, + authority, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedAcceptAdminRole: TAR PDA =', + tokenAdminRegistryPda.toBase58(), + 'router =', + routerProgramId.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + } + } + + /** + * Accepts an administrator role for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be the pending administrator) + * @param params - Accept admin role parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPAcceptAdminRoleParamsInvalidError} if params are invalid + * @throws {@link CCIPAcceptAdminRoleFailedError} if the transaction fails + */ + async acceptAdminRole( + wallet: unknown, + params: AcceptAdminRoleParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedAcceptAdminRole(sender, params) + + this.logger.debug('acceptAdminRole: accepting administrator role...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('acceptAdminRole: accepted admin, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPAcceptAdminRoleParamsInvalidError) throw error + if (error instanceof CCIPAcceptAdminRoleFailedError) throw error + throw new CCIPAcceptAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Transfer Admin Role ───────────────────────────────────────────────── + + /** + * Builds an unsigned instruction for transferring the administrator role in the + * TokenAdminRegistry (built into the Router program on Solana). + * + * Uses the `transferAdminRoleTokenAdminRegistry` Anchor instruction with 4 accounts: + * 1. config (read-only) — Router config PDA + * 2. tokenAdminRegistry (writable) — TAR PDA for the mint + * 3. mint (read-only) — Token mint + * 4. authority/sender (writable, signer) — Current administrator + * + * @param sender - Wallet public key (base58) of the current administrator + * @param params - Transfer admin role parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPTransferAdminRoleParamsInvalidError} if params are invalid + */ + async generateUnsignedTransferAdminRole( + sender: string, + params: TransferAdminRoleParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateTransferAdminRoleParams(params) + + const authority = new PublicKey(sender) + const mint = new PublicKey(params.tokenAddress) + const routerProgramId = new PublicKey(params.routerAddress) + const newAdmin = new PublicKey(params.newAdmin) + + const [configPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CONFIG_SEED)], + routerProgramId, + ) + + const [tokenAdminRegistryPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ADMIN_REGISTRY_SEED), mint.toBuffer()], + routerProgramId, + ) + + const routerProgram = createRouterProgram(this, routerProgramId) + + const instruction = await routerProgram.methods + .transferAdminRoleTokenAdminRegistry(newAdmin) + .accountsStrict({ + config: configPda, + tokenAdminRegistry: tokenAdminRegistryPda, + mint, + authority, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedTransferAdminRole: TAR PDA =', + tokenAdminRegistryPda.toBase58(), + 'router =', + routerProgramId.toBase58(), + 'newAdmin =', + params.newAdmin, + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + } + } + + /** + * Transfers the administrator role for a token in the TokenAdminRegistry, + * signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be the current administrator) + * @param params - Transfer admin role parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPTransferAdminRoleParamsInvalidError} if params are invalid + * @throws {@link CCIPTransferAdminRoleFailedError} if the transaction fails + */ + async transferAdminRole( + wallet: unknown, + params: TransferAdminRoleParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedTransferAdminRole(sender, params) + + this.logger.debug('transferAdminRole: transferring administrator role...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('transferAdminRole: transferred admin, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPTransferAdminRoleParamsInvalidError) throw error + if (error instanceof CCIPTransferAdminRoleFailedError) throw error + throw new CCIPTransferAdminRoleFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Set Pool ───────────────────────────────────────────────────────────── + + /** + * Builds unsigned instructions for registering a pool in the TokenAdminRegistry. + * + * Uses the `setPool` Anchor instruction with 5 accounts: + * 1. config (read-only) — Router config PDA + * 2. tokenAdminRegistry (writable) — TAR PDA for the mint + * 3. mint (read-only) — Token mint + * 4. poolLookuptable (read-only) — Address Lookup Table for the pool + * 5. authority (writable, signer) — Token administrator + * + * The `writableIndexes` arg ([3, 4, 7]) is a byte array indicating which ALT + * entries are writable during pool operations: + * - Index 3: Pool Config PDA + * - Index 4: Pool Token Account (ATA) + * - Index 7: Token Mint + * + * @param sender - Wallet public key (base58) of the token administrator + * @param params - Set pool parameters (includes poolLookupTable) + * @returns Unsigned Solana transaction + * @throws {@link CCIPSetPoolParamsInvalidError} if params are invalid + */ + async generateUnsignedSetPool( + sender: string, + params: SolanaSetPoolParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateSolanaSetPoolParams(params) + + const authority = new PublicKey(sender) + const mint = new PublicKey(params.tokenAddress) + const routerProgramId = new PublicKey(params.routerAddress) + const poolLookupTable = new PublicKey(params.poolLookupTable) + + const [configPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CONFIG_SEED)], + routerProgramId, + ) + + const [tokenAdminRegistryPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ADMIN_REGISTRY_SEED), mint.toBuffer()], + routerProgramId, + ) + + const routerProgram = createRouterProgram(this, routerProgramId) + + // writableIndexes [3, 4, 7]: Pool Config PDA, Pool Token ATA, Token Mint + const instruction = await routerProgram.methods + .setPool(Buffer.from([3, 4, 7])) + .accountsStrict({ + config: configPda, + tokenAdminRegistry: tokenAdminRegistryPda, + mint, + poolLookuptable: poolLookupTable, + authority, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedSetPool: TAR PDA =', + tokenAdminRegistryPda.toBase58(), + 'router =', + routerProgramId.toBase58(), + 'pool ALT =', + poolLookupTable.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + } + } + + /** + * Registers a pool in the TokenAdminRegistry, signing and submitting + * with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be the token administrator) + * @param params - Set pool parameters (includes poolLookupTable) + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPSetPoolParamsInvalidError} if params are invalid + * @throws {@link CCIPSetPoolFailedError} if the transaction fails + */ + async setPool(wallet: unknown, params: SolanaSetPoolParams): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedSetPool(sender, params) + + this.logger.debug('setPool: registering pool...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('setPool: pool registered, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPSetPoolParamsInvalidError) throw error + if (error instanceof CCIPSetPoolFailedError) throw error + throw new CCIPSetPoolFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Apply Chain Updates ────────────────────────────────────────────────── + + /** + * Auto-discovers the pool program ID and mint from a pool state account. + * + * @param poolAddress - Pool state PDA address (base58) + * @returns Pool program ID and mint public keys + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool account not found + */ + private async discoverPoolInfo( + poolAddress: string, + ): Promise<{ poolProgramId: PublicKey; mint: PublicKey }> { + const poolPubkey = new PublicKey(poolAddress) + const accountInfo = await this.connection.getAccountInfo(poolPubkey) + if (!accountInfo) throw new CCIPTokenPoolInfoNotFoundError(poolAddress) + + const poolProgramId = accountInfo.owner + + // Get mint via existing getTokenForTokenPool (which decodes pool state) + const mintStr = await this.getTokenForTokenPool(poolAddress) + const mint = new PublicKey(mintStr) + + return { poolProgramId, mint } + } + + /** + * Builds unsigned instructions for configuring remote chains on a token pool. + * + * Auto-discovers the pool program ID and mint from the pool address. + * For each chain to add, builds 2 instructions: + * - `init_chain_remote_config` — creates the chain config PDA + * - `set_chain_rate_limit` — sets inbound/outbound rate limits + * + * For each chain to remove, builds a `delete_chain_config` instruction. + * + * @param sender - Wallet public key (base58) used as authority + * @param params - Apply chain updates parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPApplyChainUpdatesParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool account not found + */ + async generateUnsignedApplyChainUpdates( + sender: string, + params: ApplyChainUpdatesParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateApplyChainUpdatesParams(params) + + const authority = new PublicKey(sender) + + // Auto-discover poolProgramId and mint from pool address + const { poolProgramId, mint } = await this.discoverPoolInfo(params.poolAddress) + + // Derive state PDA + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const poolProgram = createPoolProgram(this, poolProgramId) + const instructions: TransactionInstruction[] = [] + + // Build delete instructions for chains to remove + for (const selectorStr of params.remoteChainSelectorsToRemove) { + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(BigInt(selectorStr)) + + const [chainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + const deleteIx = await poolProgram.methods + .deleteChainConfig(new BN(selectorStr), mint) + .accountsStrict({ + state: statePda, + chainConfig: chainConfigPda, + authority, + }) + .instruction() + + instructions.push(deleteIx) + } + + // Collect selectors being removed so we know if a chain is being deleted then re-added + const selectorsBeingRemoved = new Set(params.remoteChainSelectorsToRemove) + + // Build init + append pool addresses + rate limit instructions for chains to add + // Solana requires 3 separate instructions per chain: + // 1. initChainRemoteConfig — with EMPTY pool addresses (creates the chain config PDA) + // 2. appendRemotePoolAddresses — adds pool addresses to the initialized config + // 3. setChainRateLimit — configures inbound/outbound rate limiters + // If the chain config PDA already exists and is NOT being deleted, skip step 1. + for (const chain of params.chainsToAdd) { + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(chain.remoteChainSelector) + + const [chainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + // Check if chain config PDA already exists (idempotency) + // If the chain is being deleted in the same tx, we must re-init it + const existingConfig = await this.connection.getAccountInfo(chainConfigPda) + const beingDeleted = selectorsBeingRemoved.has(chain.remoteChainSelector) + const chainAlreadyInitialized = existingConfig !== null && !beingDeleted + + if (!chainAlreadyInitialized) { + // === Step 1: init_chain_remote_config (EMPTY pool addresses) === + const tokenAddressBytes = encodeRemoteAddressBytes(chain.remoteTokenAddress) + + const initIx = await poolProgram.methods + .initChainRemoteConfig(new BN(chain.remoteChainSelector), mint, { + poolAddresses: [], + tokenAddress: { address: Buffer.from(tokenAddressBytes) }, + decimals: chain.remoteTokenDecimals ?? 0, + }) + .accountsStrict({ + state: statePda, + chainConfig: chainConfigPda, + authority, + systemProgram: SystemProgram.programId, + }) + .instruction() + + instructions.push(initIx) + + this.logger.debug( + 'applyChainUpdates: init chain config for selector', + chain.remoteChainSelector, + ) + } else { + this.logger.debug( + 'applyChainUpdates: chain config already exists for selector', + chain.remoteChainSelector, + '— skipping init', + ) + } + + // === Step 2: appendRemotePoolAddresses === + if (chain.remotePoolAddresses.length > 0) { + const addresses = chain.remotePoolAddresses.map((addr) => ({ + address: Buffer.from(encodeRemotePoolAddressBytes(addr)), + })) + + const appendIx = await poolProgram.methods + .appendRemotePoolAddresses(new BN(chain.remoteChainSelector), mint, addresses) + .accountsStrict({ + state: statePda, + chainConfig: chainConfigPda, + authority, + systemProgram: SystemProgram.programId, + }) + .instruction() + + instructions.push(appendIx) + } + + // === Step 3: setChainRateLimit === + const rateLimitIx = await poolProgram.methods + .setChainRateLimit( + new BN(chain.remoteChainSelector), + mint, + { + enabled: chain.inboundRateLimiterConfig.isEnabled, + capacity: new BN(chain.inboundRateLimiterConfig.capacity), + rate: new BN(chain.inboundRateLimiterConfig.rate), + }, + { + enabled: chain.outboundRateLimiterConfig.isEnabled, + capacity: new BN(chain.outboundRateLimiterConfig.capacity), + rate: new BN(chain.outboundRateLimiterConfig.rate), + }, + ) + .accountsStrict({ + state: statePda, + chainConfig: chainConfigPda, + authority, + }) + .instruction() + + instructions.push(rateLimitIx) + } + + this.logger.debug( + 'generateUnsignedApplyChainUpdates: pool =', + params.poolAddress, + 'instructions =', + instructions.length, + 'poolProgram =', + poolProgramId.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions, + mainIndex: 0, + }, + } + } + + /** + * Configures remote chains on a token pool, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be pool owner) + * @param params - Apply chain updates parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPApplyChainUpdatesParamsInvalidError} if params are invalid + * @throws {@link CCIPApplyChainUpdatesFailedError} if the transaction fails + */ + async applyChainUpdates( + wallet: unknown, + params: ApplyChainUpdatesParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedApplyChainUpdates(sender, params) + + this.logger.debug('applyChainUpdates: applying chain updates...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('applyChainUpdates: applied chain updates, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPApplyChainUpdatesParamsInvalidError) throw error + if (error instanceof CCIPApplyChainUpdatesFailedError) throw error + throw new CCIPApplyChainUpdatesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Append Remote Pool Addresses ──────────────────────────────────────── + + /** + * Builds unsigned instructions for appending remote pool addresses to an existing chain config. + * + * Auto-discovers the pool program ID and mint from the pool address. + * Builds a single `appendRemotePoolAddresses` instruction with all addresses. + * + * @param sender - Wallet public key (base58) used as authority + * @param params - Append remote pool addresses parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPAppendRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool account not found + */ + async generateUnsignedAppendRemotePoolAddresses( + sender: string, + params: AppendRemotePoolAddressesParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateAppendRemotePoolAddressesParams(params) + + const authority = new PublicKey(sender) + const { poolProgramId, mint } = await this.discoverPoolInfo(params.poolAddress) + + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(params.remoteChainSelector) + + const [chainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + const poolProgram = createPoolProgram(this, poolProgramId) + + const addresses = params.remotePoolAddresses.map((addr) => ({ + address: Buffer.from(encodeRemotePoolAddressBytes(addr)), + })) + + const appendIx = await poolProgram.methods + .appendRemotePoolAddresses(new BN(params.remoteChainSelector), mint, addresses) + .accountsStrict({ + state: statePda, + chainConfig: chainConfigPda, + authority, + systemProgram: SystemProgram.programId, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedAppendRemotePoolAddresses: pool =', + params.poolAddress, + 'addresses =', + params.remotePoolAddresses.length, + 'poolProgram =', + poolProgramId.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [appendIx], + mainIndex: 0, + }, + } + } + + /** + * Appends remote pool addresses to an existing chain config, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be pool owner) + * @param params - Append remote pool addresses parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPAppendRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPAppendRemotePoolAddressesFailedError} if the transaction fails + */ + async appendRemotePoolAddresses( + wallet: unknown, + params: AppendRemotePoolAddressesParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedAppendRemotePoolAddresses(sender, params) + + this.logger.debug('appendRemotePoolAddresses: appending remote pool addresses...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('appendRemotePoolAddresses: appended remote pool addresses, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) throw error + if (error instanceof CCIPAppendRemotePoolAddressesFailedError) throw error + throw new CCIPAppendRemotePoolAddressesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Delete Chain Config ────────────────────────────────────────────────── + + /** + * Builds unsigned instructions for removing a remote chain configuration from a token pool. + * + * Auto-discovers the pool program ID and mint from the pool address. + * Calls the `deleteChainConfig` IDL instruction which closes the chain config PDA. + * + * @param sender - Wallet public key (base58) used as authority + * @param params - Delete chain config parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPDeleteChainConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool account not found + */ + async generateUnsignedDeleteChainConfig( + sender: string, + params: DeleteChainConfigParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateDeleteChainConfigParams(params) + + const authority = new PublicKey(sender) + const { poolProgramId, mint } = await this.discoverPoolInfo(params.poolAddress) + + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(params.remoteChainSelector) + + const [chainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + const poolProgram = createPoolProgram(this, poolProgramId) + + const deleteIx = await poolProgram.methods + .deleteChainConfig(new BN(params.remoteChainSelector), mint) + .accountsStrict({ + state: statePda, + chainConfig: chainConfigPda, + authority, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedDeleteChainConfig: pool =', + params.poolAddress, + 'remoteChainSelector =', + params.remoteChainSelector, + 'poolProgram =', + poolProgramId.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [deleteIx], + mainIndex: 0, + }, + } + } + + /** + * Removes a remote chain configuration from a token pool, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be pool owner) + * @param params - Delete chain config parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPDeleteChainConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPDeleteChainConfigFailedError} if the transaction fails + */ + async deleteChainConfig( + wallet: unknown, + params: DeleteChainConfigParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedDeleteChainConfig(sender, params) + + this.logger.debug('deleteChainConfig: deleting chain config...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('deleteChainConfig: deleted chain config, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPDeleteChainConfigParamsInvalidError) throw error + if (error instanceof CCIPDeleteChainConfigFailedError) throw error + throw new CCIPDeleteChainConfigFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Remove Remote Pool Addresses ──────────────────────────────────────── + + /** + * Removes specific remote pool addresses from an existing chain config. + * + * Solana has no on-chain `removeRemotePool` instruction. This method implements a + * workaround: read current config, delete the chain config, then re-apply with + * the remaining pools (minus the ones being removed). + * + * @param wallet - Solana wallet with signing capability (must be pool owner) + * @param params - Remove remote pool addresses parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPRemoveRemotePoolAddressesParamsInvalidError} if params are invalid + * @throws {@link CCIPRemoveRemotePoolAddressesFailedError} if the transaction fails + */ + async removeRemotePoolAddresses( + wallet: unknown, + params: RemoveRemotePoolAddressesParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + validateRemoveRemotePoolAddressesParams(params) + + this.logger.debug('removeRemotePoolAddresses: reading current config...') + + try { + // Step 1: Read current chain config via SolanaChain + const solanaChain = new SolanaChain(this.connection, this.network, { + logger: this.logger, + }) + const remotes = await solanaChain.getTokenPoolRemotes( + params.poolAddress, + params.remoteChainSelector, + ) + + // Find the config for this chain selector + const remoteConfig = Object.values(remotes)[0] + if (!remoteConfig) { + throw new CCIPRemoveRemotePoolAddressesFailedError( + `No chain config found for remote chain selector ${params.remoteChainSelector}`, + ) + } + + // Step 2: Filter out the addresses to remove + // Normalize to 32-byte left-padded hex for comparison (on-chain addresses may + // be returned with padding, e.g., "0x000...6666..." for a 20-byte EVM address) + const addressesToRemove = new Set( + params.remotePoolAddresses.map((a) => encodeRemoteAddress(a).toLowerCase()), + ) + const remainingPools = remoteConfig.remotePools.filter( + (pool) => !addressesToRemove.has(encodeRemoteAddress(pool).toLowerCase()), + ) + + if (remainingPools.length === remoteConfig.remotePools.length) { + throw new CCIPRemoveRemotePoolAddressesFailedError( + 'None of the specified pool addresses were found in the current chain config', + ) + } + + if (remainingPools.length === 0) { + throw new CCIPRemoveRemotePoolAddressesFailedError( + 'Cannot remove all pool addresses — use deleteChainConfig instead to remove the entire chain config', + ) + } + + // Step 3: Convert RateLimiterState to RateLimiterConfig + const toConfig = (state: { tokens: bigint; capacity: bigint; rate: bigint } | null) => { + if (!state || (state.capacity === 0n && state.rate === 0n)) { + return { isEnabled: false, capacity: '0', rate: '0' } + } + return { + isEnabled: true, + capacity: state.capacity.toString(), + rate: state.rate.toString(), + } + } + + // Step 4: Re-apply with delete + re-add (remaining pools only) + const result = await this.applyChainUpdates(wallet, { + poolAddress: params.poolAddress, + remoteChainSelectorsToRemove: [params.remoteChainSelector], + chainsToAdd: [ + { + remoteChainSelector: params.remoteChainSelector, + remotePoolAddresses: remainingPools, + remoteTokenAddress: remoteConfig.remoteToken, + outboundRateLimiterConfig: toConfig(remoteConfig.outboundRateLimiterState), + inboundRateLimiterConfig: toConfig(remoteConfig.inboundRateLimiterState), + }, + ], + }) + + this.logger.info( + 'removeRemotePoolAddresses: removed remote pool addresses via re-apply, tx =', + result.txHash, + ) + + return { txHash: result.txHash } + } catch (error) { + if (error instanceof CCIPRemoveRemotePoolAddressesFailedError) throw error + if (error instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) throw error + throw new CCIPRemoveRemotePoolAddressesFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Set Chain Rate Limiter Config ──────────────────────────────────────── + + /** + * Builds unsigned instructions for updating rate limiter configurations on a token pool. + * + * Auto-discovers the pool program ID and mint from the pool address. + * For each chain config, builds a `setChainRateLimit` instruction. + * + * @param sender - Wallet public key (base58) used as authority + * @param params - Set chain rate limiter config parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenPoolInfoNotFoundError} if pool account not found + */ + async generateUnsignedSetChainRateLimiterConfig( + sender: string, + params: SetChainRateLimiterConfigParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + validateSetChainRateLimiterConfigParams(params) + + const authority = new PublicKey(sender) + + // Auto-discover poolProgramId and mint from pool address + const { poolProgramId, mint } = await this.discoverPoolInfo(params.poolAddress) + + // Derive state PDA + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const poolProgram = createPoolProgram(this, poolProgramId) + const instructions: TransactionInstruction[] = [] + + for (const config of params.chainConfigs) { + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(config.remoteChainSelector) + + const [chainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + const rateLimitIx = await poolProgram.methods + .setChainRateLimit( + new BN(config.remoteChainSelector), + mint, + { + enabled: config.inboundRateLimiterConfig.isEnabled, + capacity: new BN(config.inboundRateLimiterConfig.capacity), + rate: new BN(config.inboundRateLimiterConfig.rate), + }, + { + enabled: config.outboundRateLimiterConfig.isEnabled, + capacity: new BN(config.outboundRateLimiterConfig.capacity), + rate: new BN(config.outboundRateLimiterConfig.rate), + }, + ) + .accountsStrict({ + state: statePda, + chainConfig: chainConfigPda, + authority, + }) + .instruction() + + instructions.push(rateLimitIx) + } + + this.logger.debug( + 'generateUnsignedSetChainRateLimiterConfig: pool =', + params.poolAddress, + 'instructions =', + instructions.length, + 'poolProgram =', + poolProgramId.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions, + mainIndex: 0, + }, + } + } + + /** + * Updates rate limiter configurations on a token pool, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be pool owner or rate limit admin) + * @param params - Set chain rate limiter config parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPSetRateLimiterConfigParamsInvalidError} if params are invalid + * @throws {@link CCIPSetRateLimiterConfigFailedError} if the transaction fails + */ + async setChainRateLimiterConfig( + wallet: unknown, + params: SetChainRateLimiterConfigParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned } = await this.generateUnsignedSetChainRateLimiterConfig(sender, params) + + this.logger.debug('setChainRateLimiterConfig: updating rate limits...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('setChainRateLimiterConfig: updated rate limits, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPSetRateLimiterConfigParamsInvalidError) throw error + if (error instanceof CCIPSetRateLimiterConfigFailedError) throw error + throw new CCIPSetRateLimiterConfigFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // setRateLimitAdmin + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Builds an unsigned transaction to set the rate limit admin on a Solana token pool. + * + * Uses the pool's `setRateLimitAdmin(mint, newRateLimitAdmin)` instruction. + * + * @param sender - Public key (base58) of the transaction sender (pool owner) + * @param params - Set rate limit admin parameters + * @returns Unsigned Solana transaction with pool address + * @throws {@link CCIPSetRateLimitAdminParamsInvalidError} if params are invalid + */ + async generateUnsignedSetRateLimitAdmin( + sender: string, + params: SetRateLimitAdminParams, + ): Promise<{ unsigned: UnsignedSolanaTx; poolAddress: string }> { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPSetRateLimitAdminParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.rateLimitAdmin || params.rateLimitAdmin.trim().length === 0) { + throw new CCIPSetRateLimitAdminParamsInvalidError('rateLimitAdmin', 'must be non-empty') + } + + const authority = new PublicKey(sender) + const newRateLimitAdmin = new PublicKey(params.rateLimitAdmin) + + // Auto-discover pool program and mint from the pool state address + const { poolProgramId, mint } = await this.discoverPoolInfo(params.poolAddress) + + // Derive state PDA + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const poolProgram = createPoolProgram(this, poolProgramId) + + const instruction = await poolProgram.methods + .setRateLimitAdmin(mint, newRateLimitAdmin) + .accountsStrict({ + state: statePda, + authority, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedSetRateLimitAdmin: pool =', + params.poolAddress, + 'admin =', + params.rateLimitAdmin, + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + poolAddress: statePda.toBase58(), + } + } + + /** + * Sets the rate limit admin on a Solana token pool, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability + * @param params - Set rate limit admin parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPSetRateLimitAdminParamsInvalidError} if params are invalid + * @throws {@link CCIPSetRateLimitAdminFailedError} if the transaction fails + */ + async setRateLimitAdmin( + wallet: unknown, + params: SetRateLimitAdminParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + try { + const { unsigned } = await this.generateUnsignedSetRateLimitAdmin( + wallet.publicKey.toBase58(), + params, + ) + + this.logger.debug('setRateLimitAdmin: submitting transaction...') + + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('setRateLimitAdmin: updated rate limit admin, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPSetRateLimitAdminParamsInvalidError) throw error + if (error instanceof CCIPSetRateLimitAdminFailedError) throw error + throw new CCIPSetRateLimitAdminFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Create Pool Mint Authority Multisig ────────────────────────────────── + + /** + * Builds unsigned instructions for creating an SPL Token multisig with the + * Pool Signer PDA as the first signer. **Solana burn-mint pools only.** + * + * The instructions include: + * 1. SystemProgram.createAccount (or createAccountWithSeed if `seed` is provided) + * 2. InitializeMultisig — sets signers and threshold + * + * @param sender - Wallet public key (base58) used as payer + * @param params - Multisig creation parameters + * @returns Unsigned Solana transaction, optional multisig keypair (when no seed), and result metadata + * @throws {@link CCIPCreatePoolMultisigParamsInvalidError} if params are invalid + */ + async generateUnsignedCreatePoolMintAuthorityMultisig( + sender: string, + params: CreatePoolMintAuthorityMultisigParams, + ): Promise<{ + unsigned: UnsignedSolanaTx + multisigKeypair?: Keypair + result: Omit + }> { + validateCreatePoolMultisigParams(params) + + const payer = new PublicKey(sender) + const mint = new PublicKey(params.mint) + const poolProgramId = new PublicKey(params.poolProgramId) + + // 1. Derive Pool Signer PDA + const [poolSignerPda] = derivePoolSignerPDA(mint, poolProgramId) + + // 2. Build full signers list: [poolSignerPda, ...additionalSigners] + const allSignerPubkeys = [ + poolSignerPda, + ...params.additionalSigners.map((s) => new PublicKey(s)), + ] + + // 3. Auto-detect token program from mint account + const mintInfo = await this.connection.getAccountInfo(mint) + if (!mintInfo) { + throw new CCIPCreatePoolMultisigParamsInvalidError('mint', 'mint account not found on-chain') + } + const isToken2022 = mintInfo.owner.equals(TOKEN_2022_PROGRAM_ID) + const isTokenProgram = mintInfo.owner.equals(TOKEN_PROGRAM_ID) + if (!isToken2022 && !isTokenProgram) { + throw new CCIPCreatePoolMultisigParamsInvalidError( + 'mint', + `mint owned by ${mintInfo.owner.toBase58()}, expected SPL Token or Token-2022`, + ) + } + const tokenProgramId = mintInfo.owner + + // 4. Get rent exemption for multisig account + const lamports = await this.connection.getMinimumBalanceForRentExemption(MULTISIG_SIZE) + + const instructions: TransactionInstruction[] = [] + let multisigKeypair: Keypair | undefined + let multisigPubkey: PublicKey + + if (params.seed) { + // Deterministic: use createAccountWithSeed + multisigPubkey = await PublicKey.createWithSeed(payer, params.seed, tokenProgramId) + instructions.push( + SystemProgram.createAccountWithSeed({ + fromPubkey: payer, + newAccountPubkey: multisigPubkey, + basePubkey: payer, + seed: params.seed, + lamports, + space: MULTISIG_SIZE, + programId: tokenProgramId, + }), + ) + } else { + // Random keypair (standard SPL pattern) + multisigKeypair = Keypair.generate() + multisigPubkey = multisigKeypair.publicKey + instructions.push( + SystemProgram.createAccount({ + fromPubkey: payer, + newAccountPubkey: multisigPubkey, + space: MULTISIG_SIZE, + lamports, + programId: tokenProgramId, + }), + ) + } + + // 5. Initialize multisig instruction + instructions.push( + createInitializeMultisigInstruction( + multisigPubkey, + allSignerPubkeys, + params.threshold, + tokenProgramId, + ), + ) + + const allSigners = allSignerPubkeys.map((pk) => pk.toBase58()) + + this.logger.debug( + 'generateUnsignedCreatePoolMintAuthorityMultisig: multisig =', + multisigPubkey.toBase58(), + 'poolSignerPda =', + poolSignerPda.toBase58(), + 'signers =', + allSigners.length, + 'threshold =', + params.threshold, + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions, + mainIndex: 1, + }, + multisigKeypair, + result: { + multisigAddress: multisigPubkey.toBase58(), + poolSignerPda: poolSignerPda.toBase58(), + allSigners, + }, + } + } + + /** + * Creates an SPL Token multisig with the Pool Signer PDA, signing and + * submitting with the provided wallet. **Solana burn-mint pools only.** + * + * @param wallet - Solana wallet with signing capability + * @param params - Multisig creation parameters + * @returns Result with `multisigAddress`, `poolSignerPda`, `allSigners`, and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPCreatePoolMultisigParamsInvalidError} if params are invalid + * @throws {@link CCIPCreatePoolMultisigFailedError} if the transaction fails + */ + async createPoolMintAuthorityMultisig( + wallet: unknown, + params: CreatePoolMintAuthorityMultisigParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned, multisigKeypair, result } = + await this.generateUnsignedCreatePoolMintAuthorityMultisig(sender, params) + + this.logger.debug('createPoolMintAuthorityMultisig: creating multisig...') + + try { + // If multisigKeypair exists (no seed), wrap wallet to co-sign + const effectiveWallet: Wallet = multisigKeypair + ? { + publicKey: wallet.publicKey, + async signTransaction(tx: T): Promise { + if ('version' in tx) { + tx.sign([multisigKeypair]) + } else { + tx.partialSign(multisigKeypair) + } + return wallet.signTransaction(tx) + }, + } + : wallet + + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + effectiveWallet, + unsigned, + ) + + this.logger.info( + 'createPoolMintAuthorityMultisig: created multisig at', + result.multisigAddress, + 'tx =', + signature, + ) + + return { ...result, txHash: signature } + } catch (error) { + if (error instanceof CCIPCreatePoolMultisigFailedError) throw error + throw new CCIPCreatePoolMultisigFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Transfer Mint Authority ───────────────────────────────────────────── + + /** + * Builds unsigned instructions for transferring mint authority to a new + * address (typically a multisig). **Solana only.** + * + * @param sender - Wallet public key (base58) — must be the current mint authority + * @param params - Transfer mint authority parameters + * @returns Unsigned Solana transaction and empty result placeholder + * @throws {@link CCIPTransferMintAuthorityParamsInvalidError} if params are invalid + */ + async generateUnsignedTransferMintAuthority( + sender: string, + params: TransferMintAuthorityParams, + ): Promise<{ unsigned: UnsignedSolanaTx; result: TransferMintAuthorityResult }> { + validateTransferMintAuthorityParams(params) + + const senderPubkey = new PublicKey(sender) + const mint = new PublicKey(params.mint) + const newMintAuthority = new PublicKey(params.newMintAuthority) + + // Auto-detect token program from mint account + const mintInfo = await this.connection.getAccountInfo(mint) + if (!mintInfo) { + throw new CCIPTransferMintAuthorityParamsInvalidError( + 'mint', + 'mint account not found on-chain', + ) + } + const isToken2022 = mintInfo.owner.equals(TOKEN_2022_PROGRAM_ID) + const isTokenProgram = mintInfo.owner.equals(TOKEN_PROGRAM_ID) + if (!isToken2022 && !isTokenProgram) { + throw new CCIPTransferMintAuthorityParamsInvalidError( + 'mint', + `mint owned by ${mintInfo.owner.toBase58()}, expected SPL Token or Token-2022`, + ) + } + const tokenProgramId = mintInfo.owner + + const instruction = createSetAuthorityInstruction( + mint, + senderPubkey, + AuthorityType.MintTokens, + newMintAuthority, + [], + tokenProgramId, + ) + + this.logger.debug( + 'generateUnsignedTransferMintAuthority: mint =', + params.mint, + 'newMintAuthority =', + params.newMintAuthority, + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + result: { txHash: '' }, + } + } + + /** + * Transfers mint authority on an SPL token, signing and submitting with + * the provided wallet. **Solana only.** + * + * @param wallet - Solana wallet with signing capability (must be current mint authority) + * @param params - Transfer mint authority parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPTransferMintAuthorityParamsInvalidError} if params are invalid + * @throws {@link CCIPTransferMintAuthorityFailedError} if the transaction fails + */ + async transferMintAuthority( + wallet: unknown, + params: TransferMintAuthorityParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + try { + const { unsigned } = await this.generateUnsignedTransferMintAuthority( + wallet.publicKey.toBase58(), + params, + ) + + this.logger.debug('transferMintAuthority: submitting transaction...') + + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('transferMintAuthority: transferred mint authority, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPTransferMintAuthorityFailedError) throw error + if (error instanceof CCIPTransferMintAuthorityParamsInvalidError) throw error + throw new CCIPTransferMintAuthorityFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Grant Mint/Burn Access ───────────────────────────────────────────── + + /** + * Builds an unsigned transaction for granting mint/burn access on a Solana + * SPL token by transferring the mint authority to the specified address. + * + * This wraps {@link generateUnsignedTransferMintAuthority} with the unified + * `GrantMintBurnAccessParams` interface, mapping `tokenAddress` → `mint` + * and `authority` → `newMintAuthority`. + * + * @param sender - Current mint authority public key (base58) + * @param params - Grant mint/burn access parameters + * @returns Unsigned Solana transaction and result + * @throws {@link CCIPGrantMintBurnAccessParamsInvalidError} if params are invalid + */ + async generateUnsignedGrantMintBurnAccess( + sender: string, + params: GrantMintBurnAccessParams, + ): Promise<{ unsigned: UnsignedSolanaTx; result: GrantMintBurnAccessResult }> { + if (!params.tokenAddress || params.tokenAddress.trim().length === 0) { + throw new CCIPGrantMintBurnAccessParamsInvalidError('tokenAddress', 'must be non-empty') + } + if (!params.authority || params.authority.trim().length === 0) { + throw new CCIPGrantMintBurnAccessParamsInvalidError('authority', 'must be non-empty') + } + if (params.role === 'burn') { + throw new CCIPGrantMintBurnAccessParamsInvalidError( + 'role', + "Solana SPL tokens do not have a separate burn authority — any token holder can burn. Use 'mint' or 'mintAndBurn' instead", + ) + } + + try { + const { unsigned, result } = await this.generateUnsignedTransferMintAuthority(sender, { + mint: params.tokenAddress, + newMintAuthority: params.authority, + }) + return { unsigned, result: { txHash: result.txHash } } + } catch (error) { + if (error instanceof CCIPTransferMintAuthorityParamsInvalidError) { + const param = error.context.param === 'mint' ? 'tokenAddress' : 'authority' + throw new CCIPGrantMintBurnAccessParamsInvalidError(param, String(error.context.reason)) + } + throw error + } + } + + /** + * Grants mint/burn access on a Solana SPL token by transferring the mint + * authority, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be current mint authority) + * @param params - Grant mint/burn access parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPGrantMintBurnAccessParamsInvalidError} if params are invalid + * @throws {@link CCIPGrantMintBurnAccessFailedError} if the transaction fails + * + * @example + * ```typescript + * const { txHash } = await admin.grantMintBurnAccess(wallet, { + * tokenAddress: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * authority: '2e8X9v1s9nro5ezG3osRm7bpusdYknNrQYzQMxsA4Gwh', + * }) + * ``` + */ + async grantMintBurnAccess( + wallet: unknown, + params: GrantMintBurnAccessParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + try { + const { unsigned } = await this.generateUnsignedGrantMintBurnAccess( + wallet.publicKey.toBase58(), + params, + ) + + this.logger.debug('grantMintBurnAccess: submitting transaction...') + + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('grantMintBurnAccess: granted mint/burn access, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPGrantMintBurnAccessFailedError) throw error + if (error instanceof CCIPGrantMintBurnAccessParamsInvalidError) throw error + if (error instanceof CCIPWalletInvalidError) throw error + throw new CCIPGrantMintBurnAccessFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Revoke Mint/Burn Access ─────────────────────────────────────────────── + + /** + * Not supported on Solana. SPL tokens use a single mint authority model — + * use {@link transferMintAuthority} to transfer authority instead. + * + * @throws {@link CCIPRevokeMintBurnAccessParamsInvalidError} always + */ + async revokeMintBurnAccess(_wallet: unknown, _params: RevokeMintBurnAccessParams): Promise { + throw new CCIPRevokeMintBurnAccessParamsInvalidError( + 'chain', + 'Solana SPL tokens do not support role-based revoke. Use transferMintAuthority() to transfer mint authority instead', + ) + } + + /** + * Deploys an SPL Token mint, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability + * @param params - Token deployment parameters + * @returns Unified deploy result with `tokenAddress` and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPTokenDeployParamsInvalidError} if params are invalid + * @throws {@link CCIPTokenDeployFailedError} if the deploy transaction fails + */ + async deployToken(wallet: unknown, params: SolanaDeployTokenParams): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned, mintKeypair } = await this.generateUnsignedDeployToken(sender, params) + + this.logger.debug('deployToken: deploying SPL Token mint...') + + try { + // Wrap the wallet to also sign with the mint keypair + const wrappedWallet: Wallet = { + publicKey: wallet.publicKey, + async signTransaction(tx: T): Promise { + // Sign with mint keypair first — simulateAndSendTxs uses VersionedTransaction + if ('version' in tx) { + tx.sign([mintKeypair]) + } else { + tx.partialSign(mintKeypair) + } + // Then sign with the user's wallet + return wallet.signTransaction(tx) + }, + } + + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wrappedWallet, + unsigned, + ) + + const mint = mintKeypair.publicKey + const [metadataPDA] = PublicKey.findProgramAddressSync( + [Buffer.from(METADATA_SEED), TOKEN_METADATA_PROGRAM_ID.toBuffer(), mint.toBuffer()], + TOKEN_METADATA_PROGRAM_ID, + ) + + this.logger.info( + 'deployToken: deployed at', + mintKeypair.publicKey.toBase58(), + 'metadata =', + metadataPDA.toBase58(), + 'tx =', + signature, + ) + + return { + tokenAddress: mintKeypair.publicKey.toBase58(), + txHash: signature, + metadataAddress: metadataPDA.toBase58(), + } + } catch (error) { + if (error instanceof CCIPTokenDeployFailedError) throw error + throw new CCIPTokenDeployFailedError(error instanceof Error ? error.message : String(error), { + cause: error instanceof Error ? error : undefined, + }) + } + } + + // ── Get Mint/Burn Roles (read-only) ────────────────────────────────────── + + /** + * Queries the mint authority on an SPL token and, if it is a multisig, + * returns the threshold and member list. + * + * @param params - `tokenAddress` (SPL mint, base58) + * @returns Mint authority info including multisig details + * + * @example + * ```typescript + * const roles = await admin.getMintBurnRoles({ + * tokenAddress: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * }) + * ``` + */ + async getMintBurnRoles(params: { tokenAddress: string }): Promise { + const mintPubkey = new PublicKey(params.tokenAddress) + const mintAccountInfo = await this.connection.getAccountInfo(mintPubkey) + if (!mintAccountInfo) { + throw new CCIPGrantMintBurnAccessParamsInvalidError( + 'tokenAddress', + 'mint account not found on-chain', + ) + } + + // Parse mint data + const rawMint = MintLayout.decode(mintAccountInfo.data) + const mintAuthority = + rawMint.mintAuthorityOption === 1 ? rawMint.mintAuthority.toBase58() : null + + if (!mintAuthority) { + return { mintAuthority: null, isMultisig: false } + } + + // Check if mint authority is a multisig + const authorityPubkey = new PublicKey(mintAuthority) + const authorityInfo = await this.connection.getAccountInfo(authorityPubkey) + + const isOwnedByTokenProgram = + authorityInfo?.owner.equals(TOKEN_PROGRAM_ID) || + authorityInfo?.owner.equals(TOKEN_2022_PROGRAM_ID) + + if (!authorityInfo || authorityInfo.data.length !== MULTISIG_SIZE || !isOwnedByTokenProgram) { + return { mintAuthority, isMultisig: false } + } + + // Parse multisig account + const rawMultisig = MultisigLayout.decode(authorityInfo.data) + if (!rawMultisig.isInitialized) { + return { mintAuthority, isMultisig: false } + } + + const signerKeys = [ + 'signer1', + 'signer2', + 'signer3', + 'signer4', + 'signer5', + 'signer6', + 'signer7', + 'signer8', + 'signer9', + 'signer10', + 'signer11', + ] as const + + const members: Array<{ address: string }> = [] + for (let i = 0; i < rawMultisig.n; i++) { + const signer = rawMultisig[signerKeys[i]!] + members.push({ address: signer.toBase58() }) + } + + this.logger.debug( + `getMintBurnRoles: tokenAddress=${params.tokenAddress}, authority=${mintAuthority}, isMultisig=true, threshold=${rawMultisig.m}, members=${members.length}`, + ) + + return { + mintAuthority, + isMultisig: true, + multisigThreshold: rawMultisig.m, + multisigMembers: members, + } + } + + // ── Create Token Address Lookup Table ─────────────────────────────────── + + /** + * Builds unsigned instructions for creating an Address Lookup Table (ALT) + * populated with the 10 base CCIP addresses for a token's pool. **Solana only.** + * + * The ALT is required before calling `setPool` on the router. It contains + * accounts that the CCIP router references during cross-chain pool operations. + * + * **ALT account ordering (10 base addresses):** + * + * | Index | Account | + * |-------|---------| + * | 0 | ALT self-reference | + * | 1 | Token Admin Registry PDA (`["token_admin_registry", mint]` on router) | + * | 2 | Pool Program ID (derived from poolAddress owner) | + * | 3 | Pool Config PDA (`["ccip_tokenpool_config", mint]` on pool program) — **writable** | + * | 4 | Pool Token ATA (pool signer's associated token account) — **writable** | + * | 5 | Pool Signer PDA (`["ccip_tokenpool_signer", mint]` on pool program) | + * | 6 | Token Program ID (SPL Token or Token-2022, auto-detected) | + * | 7 | Token Mint — **writable** | + * | 8 | Fee Token Config PDA (`["fee_billing_token_config", mint]` on feeQuoter) | + * | 9 | Router Pool Signer PDA (`["external_token_pools_signer", poolProgramId]` on router) | + * + * Additional addresses (e.g., SPL Token Multisig for burn-mint with multisig + * governance) are appended after index 9. + * + * @param sender - Wallet public key (base58) — pays for ALT creation + * @param params - Create token ALT parameters + * @returns Unsigned Solana transaction and result with lookupTableAddress + * @throws {@link CCIPCreateTokenAltParamsInvalidError} if params are invalid + */ + async generateUnsignedCreateTokenAlt( + sender: string, + params: CreateTokenAltParams, + ): Promise<{ unsigned: UnsignedSolanaTx; result: Omit }> { + validateCreateTokenAltParams(params) + + const payer = new PublicKey(sender) + const mint = new PublicKey(params.tokenAddress) + const routerProgramId = new PublicKey(params.routerAddress) + const authority = params.authority ? new PublicKey(params.authority) : payer + + // 1. Derive poolProgramId from the pool state PDA's on-chain owner + const poolStateInfo = await this.connection.getAccountInfo(new PublicKey(params.poolAddress)) + if (!poolStateInfo) { + throw new CCIPCreateTokenAltParamsInvalidError( + 'poolAddress', + 'pool state account not found on-chain', + ) + } + const poolProgramId = poolStateInfo.owner + + // 2. Auto-detect token program from mint account + const mintInfo = await this.connection.getAccountInfo(mint) + if (!mintInfo) { + throw new CCIPCreateTokenAltParamsInvalidError( + 'tokenAddress', + 'mint account not found on-chain', + ) + } + const isToken2022 = mintInfo.owner.equals(TOKEN_2022_PROGRAM_ID) + const isTokenProgram = mintInfo.owner.equals(TOKEN_PROGRAM_ID) + if (!isToken2022 && !isTokenProgram) { + throw new CCIPCreateTokenAltParamsInvalidError( + 'tokenAddress', + `mint owned by ${mintInfo.owner.toBase58()}, expected SPL Token or Token-2022`, + ) + } + const tokenProgramId = mintInfo.owner + + // 3. Discover feeQuoter from router config + const routerConfig = await this._getRouterConfig(params.routerAddress) + const feeQuoterProgramId: PublicKey = routerConfig.feeQuoter + + // 4. Derive all 10 base CCIP addresses + + // [1] Token Admin Registry PDA + const [tokenAdminRegistryPda] = PublicKey.findProgramAddressSync( + [Buffer.from(TOKEN_ADMIN_REGISTRY_SEED), mint.toBuffer()], + routerProgramId, + ) + + // [3] Pool Config PDA (writable during pool operations) + const [poolConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + // [5] Pool Signer PDA + const [poolSignerPda] = derivePoolSignerPDA(mint, poolProgramId) + + // [4] Pool Token ATA (writable during pool operations) + const poolTokenAta = getAssociatedTokenAddressSync( + mint, + poolSignerPda, + true, // allowOwnerOffCurve (PDA) + tokenProgramId, + ) + + // [8] Fee Token Config PDA + const [feeTokenConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(FEE_BILLING_TOKEN_CONFIG_SEED), mint.toBuffer()], + feeQuoterProgramId, + ) + + // [9] Router External Token Pools Signer PDA + const [routerPoolSignerPda] = PublicKey.findProgramAddressSync( + [Buffer.from(EXTERNAL_TOKEN_POOLS_SIGNER_SEED), poolProgramId.toBuffer()], + routerProgramId, + ) + + // 5. Create ALT and build extend instructions + const recentSlot = await this.connection.getSlot() + const [createIx, lookupTableAddress] = AddressLookupTableProgram.createLookupTable({ + authority, + payer, + recentSlot, + }) + + // Fixed ordering per CCIP convention (indexes 0-9) + const baseAddresses: PublicKey[] = [ + lookupTableAddress, // [0] ALT self-reference + tokenAdminRegistryPda, // [1] Token Admin Registry PDA + poolProgramId, // [2] Pool Program ID + poolConfigPda, // [3] Pool Config PDA (writable) + poolTokenAta, // [4] Pool Token ATA (writable) + poolSignerPda, // [5] Pool Signer PDA + tokenProgramId, // [6] Token Program ID + mint, // [7] Token Mint (writable) + feeTokenConfigPda, // [8] Fee Token Config PDA + routerPoolSignerPda, // [9] Router External Token Pools Signer PDA + ] + + const additionalPubkeys = (params.additionalAddresses ?? []).map((a) => new PublicKey(a)) + const allAddresses = [...baseAddresses, ...additionalPubkeys] + + // Chunk addresses into extend instructions (max 30 per instruction) + const extendIxs: TransactionInstruction[] = [] + const CHUNK_SIZE = 30 + for (let i = 0; i < allAddresses.length; i += CHUNK_SIZE) { + const chunk = allAddresses.slice(i, i + CHUNK_SIZE) + extendIxs.push( + AddressLookupTableProgram.extendLookupTable({ + payer, + authority, + lookupTable: lookupTableAddress, + addresses: chunk, + }), + ) + } + + const instructions = [createIx, ...extendIxs] + + this.logger.debug( + 'generateUnsignedCreateTokenAlt: ALT =', + lookupTableAddress.toBase58(), + 'mint =', + mint.toBase58(), + 'poolProgram =', + poolProgramId.toBase58(), + 'addresses =', + allAddresses.length, + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions, + mainIndex: 0, + }, + result: { + lookupTableAddress: lookupTableAddress.toBase58(), + }, + } + } + + /** + * Creates an Address Lookup Table (ALT) for a token's CCIP pool, + * signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability + * @param params - Create token ALT parameters + * @returns Result with `lookupTableAddress` and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPCreateTokenAltParamsInvalidError} if params are invalid + * @throws {@link CCIPCreateTokenAltFailedError} if the transaction fails + */ + async createTokenAlt( + wallet: unknown, + params: CreateTokenAltParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned, result } = await this.generateUnsignedCreateTokenAlt(sender, params) + + this.logger.debug('createTokenAlt: creating address lookup table...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info( + 'createTokenAlt: created ALT at', + result.lookupTableAddress, + 'tx =', + signature, + ) + + return { ...result, txHash: signature } + } catch (error) { + if (error instanceof CCIPCreateTokenAltFailedError) throw error + throw new CCIPCreateTokenAltFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Create Pool Token Account ─────────────────────────────────────────────── + + /** + * Builds an unsigned instruction to create the Pool Signer's Associated Token + * Account (ATA). This ATA is the token "vault" the pool uses to hold tokens + * during cross-chain operations and **must** exist before any CCIP transfer. + * + * Uses `createAssociatedTokenAccountIdempotentInstruction` so it is safe to + * call even if the ATA already exists (no-op in that case). + * + * This is also automatically appended to {@link generateUnsignedDeployPool}, + * but is exposed separately for existing pools that were deployed before this + * step was added. + * + * @param sender - Wallet public key (base58) — pays rent for the ATA + * @param params - Token and pool addresses + * @returns Unsigned Solana transaction and result with pool token account details + * @throws {@link CCIPCreatePoolTokenAccountParamsInvalidError} if params are invalid + */ + async generateUnsignedCreatePoolTokenAccount( + sender: string, + params: CreatePoolTokenAccountParams, + ): Promise<{ unsigned: UnsignedSolanaTx; result: Omit }> { + validateCreatePoolTokenAccountParams(params) + + const payer = new PublicKey(sender) + const mint = new PublicKey(params.tokenAddress) + + // Derive poolProgramId from the pool state PDA's on-chain owner + const poolStateInfo = await this.connection.getAccountInfo(new PublicKey(params.poolAddress)) + if (!poolStateInfo) { + throw new CCIPCreatePoolTokenAccountParamsInvalidError( + 'poolAddress', + 'pool state account not found on-chain', + ) + } + const poolProgramId = poolStateInfo.owner + + // Auto-detect token program from mint account + const mintInfo = await this.connection.getAccountInfo(mint) + if (!mintInfo) { + throw new CCIPCreatePoolTokenAccountParamsInvalidError( + 'tokenAddress', + 'mint account not found on-chain', + ) + } + const isToken2022 = mintInfo.owner.equals(TOKEN_2022_PROGRAM_ID) + const isTokenProgram = mintInfo.owner.equals(TOKEN_PROGRAM_ID) + if (!isToken2022 && !isTokenProgram) { + throw new CCIPCreatePoolTokenAccountParamsInvalidError( + 'tokenAddress', + `mint owned by ${mintInfo.owner.toBase58()}, expected SPL Token or Token-2022`, + ) + } + const tokenProgramId = mintInfo.owner + + // Derive Pool Signer PDA and its ATA + const [poolSignerPda] = derivePoolSignerPDA(mint, poolProgramId) + const poolTokenAta = getAssociatedTokenAddressSync( + mint, + poolSignerPda, + true, // allowOwnerOffCurve — PDAs are off-curve + tokenProgramId, + ) + + const createAtaIx = createAssociatedTokenAccountIdempotentInstruction( + payer, // payer + poolTokenAta, // ATA address + poolSignerPda, // owner (Pool Signer PDA) + mint, // token mint + tokenProgramId, // token program + ) + + this.logger.debug( + 'generateUnsignedCreatePoolTokenAccount: poolTokenAta =', + poolTokenAta.toBase58(), + 'poolSignerPda =', + poolSignerPda.toBase58(), + 'tokenProgram =', + tokenProgramId.toBase58(), + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [createAtaIx], + mainIndex: 0, + }, + result: { + poolTokenAccount: poolTokenAta.toBase58(), + poolSignerPda: poolSignerPda.toBase58(), + }, + } + } + + /** + * Creates the Pool Signer's Associated Token Account (ATA), + * signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability + * @param params - Token and pool addresses + * @returns Result with `poolTokenAccount`, `poolSignerPda`, and `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPCreatePoolTokenAccountParamsInvalidError} if params are invalid + * @throws {@link CCIPCreatePoolTokenAccountFailedError} if the transaction fails + */ + async createPoolTokenAccount( + wallet: unknown, + params: CreatePoolTokenAccountParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + const sender = wallet.publicKey.toBase58() + const { unsigned, result } = await this.generateUnsignedCreatePoolTokenAccount(sender, params) + + this.logger.debug('createPoolTokenAccount: creating pool token ATA...') + + try { + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info( + 'createPoolTokenAccount: created ATA at', + result.poolTokenAccount, + 'owner =', + result.poolSignerPda, + 'tx =', + signature, + ) + + return { ...result, txHash: signature } + } catch (error) { + if (error instanceof CCIPCreatePoolTokenAccountFailedError) throw error + throw new CCIPCreatePoolTokenAccountFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Transfer Ownership ─────────────────────────────────────────────────── + + /** + * Builds an unsigned instruction for proposing a new pool owner. + * + * Uses the pool's `transferOwnership(proposedOwner)` instruction. + * + * @param sender - Public key (base58) of the transaction sender (current pool owner) + * @param params - Transfer ownership parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPTransferOwnershipParamsInvalidError} if params are invalid + */ + async generateUnsignedTransferOwnership( + sender: string, + params: TransferOwnershipParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPTransferOwnershipParamsInvalidError('poolAddress', 'must be non-empty') + } + if (!params.newOwner || params.newOwner.trim().length === 0) { + throw new CCIPTransferOwnershipParamsInvalidError('newOwner', 'must be non-empty') + } + + let proposedOwner: PublicKey + try { + proposedOwner = new PublicKey(params.newOwner) + } catch { + throw new CCIPTransferOwnershipParamsInvalidError( + 'newOwner', + 'must be a valid Solana public key', + ) + } + + const authority = new PublicKey(sender) + + const { poolProgramId, mint } = await this.discoverPoolInfo(params.poolAddress) + + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const poolProgram = createPoolProgram(this, poolProgramId) + + const instruction = await poolProgram.methods + .transferOwnership(proposedOwner) + .accountsStrict({ + state: statePda, + mint, + authority, + }) + .instruction() + + this.logger.debug( + 'generateUnsignedTransferOwnership: pool =', + params.poolAddress, + 'newOwner =', + params.newOwner, + ) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + } + } + + /** + * Proposes a new pool owner, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be current pool owner) + * @param params - Transfer ownership parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPTransferOwnershipParamsInvalidError} if params are invalid + * @throws {@link CCIPTransferOwnershipFailedError} if the transaction fails + */ + async transferOwnership( + wallet: unknown, + params: TransferOwnershipParams, + ): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + try { + const { unsigned } = await this.generateUnsignedTransferOwnership( + wallet.publicKey.toBase58(), + params, + ) + + this.logger.debug('transferOwnership: submitting transaction...') + + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('transferOwnership: ownership proposed, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPTransferOwnershipParamsInvalidError) throw error + if (error instanceof CCIPTransferOwnershipFailedError) throw error + throw new CCIPTransferOwnershipFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } + + // ── Accept Ownership ───────────────────────────────────────────────────── + + /** + * Builds an unsigned instruction for accepting pool ownership. + * + * Uses the pool's `acceptOwnership()` instruction. + * + * @param sender - Public key (base58) of the transaction sender (proposed owner) + * @param params - Accept ownership parameters + * @returns Unsigned Solana transaction + * @throws {@link CCIPAcceptOwnershipParamsInvalidError} if params are invalid + */ + async generateUnsignedAcceptOwnership( + sender: string, + params: AcceptOwnershipParams, + ): Promise<{ unsigned: UnsignedSolanaTx }> { + if (!params.poolAddress || params.poolAddress.trim().length === 0) { + throw new CCIPAcceptOwnershipParamsInvalidError('poolAddress', 'must be non-empty') + } + + const authority = new PublicKey(sender) + + const { poolProgramId, mint } = await this.discoverPoolInfo(params.poolAddress) + + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + const poolProgram = createPoolProgram(this, poolProgramId) + + const instruction = await poolProgram.methods + .acceptOwnership() + .accountsStrict({ + state: statePda, + mint, + authority, + }) + .instruction() + + this.logger.debug('generateUnsignedAcceptOwnership: pool =', params.poolAddress) + + return { + unsigned: { + family: ChainFamily.Solana, + instructions: [instruction], + mainIndex: 0, + }, + } + } + + /** + * Accepts pool ownership, signing and submitting with the provided wallet. + * + * @param wallet - Solana wallet with signing capability (must be proposed owner) + * @param params - Accept ownership parameters + * @returns Result with `txHash` + * @throws {@link CCIPWalletInvalidError} if wallet is not a valid Solana Wallet + * @throws {@link CCIPAcceptOwnershipParamsInvalidError} if params are invalid + * @throws {@link CCIPAcceptOwnershipFailedError} if the transaction fails + */ + async acceptOwnership(wallet: unknown, params: AcceptOwnershipParams): Promise { + if (!isWallet(wallet)) throw new CCIPWalletInvalidError(wallet) + + try { + const { unsigned } = await this.generateUnsignedAcceptOwnership( + wallet.publicKey.toBase58(), + params, + ) + + this.logger.debug('acceptOwnership: submitting transaction...') + + const signature = await simulateAndSendTxs( + { connection: this.connection, logger: this.logger }, + wallet, + unsigned, + ) + + this.logger.info('acceptOwnership: ownership accepted, tx =', signature) + + return { txHash: signature } + } catch (error) { + if (error instanceof CCIPAcceptOwnershipParamsInvalidError) throw error + if (error instanceof CCIPAcceptOwnershipFailedError) throw error + throw new CCIPAcceptOwnershipFailedError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ) + } + } +} + +export type { TransferMintAuthorityParams } from '../types.ts' diff --git a/ccip-sdk/src/token-admin/solana/solana-accept-admin-role.test.ts b/ccip-sdk/src/token-admin/solana/solana-accept-admin-role.test.ts new file mode 100644 index 00000000..e8958ba1 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-accept-admin-role.test.ts @@ -0,0 +1,255 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPAcceptAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const routerAddress = Keypair.generate().publicKey.toBase58() + +// ============================================================================= +// SolanaTokenAdmin — acceptAdminRole +// ============================================================================= + +describe('SolanaTokenAdmin — acceptAdminRole', () => { + // =========================================================================== + // generateUnsignedAcceptAdminRole — Validation + // =========================================================================== + + describe('generateUnsignedAcceptAdminRole — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress: '', + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptAdminRoleParamsInvalidError) + assert.equal(err.code, 'ACCEPT_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedAcceptAdminRole — Happy Path + // =========================================================================== + + describe('generateUnsignedAcceptAdminRole — happy path', () => { + const admin = makeAdmin() + + it('should return UnsignedSolanaTx with correct family', async () => { + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should build instruction with correct programId (routerAddress)', async () => { + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), routerAddress) + }) + + it('should build instruction with 4 accounts', async () => { + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 4) + }) + + it('should have config PDA as first account (read-only)', async () => { + const routerPubkey = new PublicKey(routerAddress) + const [expectedConfig] = PublicKey.findProgramAddressSync( + [Buffer.from('config')], + routerPubkey, + ) + + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[0]!.pubkey.toBase58(), expectedConfig.toBase58()) + assert.equal(ix.keys[0]!.isSigner, false) + assert.equal(ix.keys[0]!.isWritable, false) + }) + + it('should have token admin registry PDA as second account (writable)', async () => { + const routerPubkey = new PublicKey(routerAddress) + const mint = new PublicKey(tokenAddress) + const [expectedTarPda] = PublicKey.findProgramAddressSync( + [Buffer.from('token_admin_registry'), mint.toBuffer()], + routerPubkey, + ) + + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[1]!.pubkey.toBase58(), expectedTarPda.toBase58()) + assert.equal(ix.keys[1]!.isSigner, false) + assert.equal(ix.keys[1]!.isWritable, true) + }) + + it('should have mint as third account (read-only)', async () => { + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[2]!.pubkey.toBase58(), tokenAddress) + assert.equal(ix.keys[2]!.isSigner, false) + assert.equal(ix.keys[2]!.isWritable, false) + }) + + it('should have authority as fourth account (signer, writable)', async () => { + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[3]!.pubkey.toBase58(), sender) + assert.equal(ix.keys[3]!.isSigner, true) + assert.equal(ix.keys[3]!.isWritable, true) + }) + + it('should have exactly 4 accounts (no SystemProgram per IDL)', async () => { + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + // IDL only defines 4 accounts: config, tokenAdminRegistry, mint, authority + assert.equal(ix.keys.length, 4) + }) + + it('should have 8-byte discriminator only as instruction data (no arguments)', async () => { + const { unsigned } = await admin.generateUnsignedAcceptAdminRole(sender, { + tokenAddress, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + // 8 bytes discriminator only — no arguments for accept + assert.equal(ix.data.length, 8) + }) + }) + + // =========================================================================== + // acceptAdminRole — Wallet Validation + // =========================================================================== + + describe('acceptAdminRole — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.acceptAdminRole({}, { tokenAddress, routerAddress }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => + admin.acceptAdminRole(null, { + tokenAddress, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => + admin.acceptAdminRole(undefined, { + tokenAddress, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-append-remote-pool-addresses.test.ts b/ccip-sdk/src/token-admin/solana/solana-append-remote-pool-addresses.test.ts new file mode 100644 index 00000000..e7a8ed32 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-append-remote-pool-addresses.test.ts @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' +import { sha256, toUtf8Bytes } from 'ethers' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPAppendRemotePoolAddressesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { AppendRemotePoolAddressesParams } from '../types.ts' + +// ── Constants ── + +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey +const poolProgramId = Keypair.generate().publicKey +const remoteEvmPool = '0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD' +const remoteChainSelector = 16015286601757825753n + +// Derive pool state PDA +const [poolStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, +) + +/** + * Creates a mock connection that simulates pool state discovery. + * Returns account info with the correct owner (poolProgramId) and + * makes getTokenForTokenPool return the mint. + */ +function createMockConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + // Return mock pool state account with correct owner + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: Buffer.alloc(0), // Will be decoded by getTokenForTokenPool + lamports: 0, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection +} + +const validParams: AppendRemotePoolAddressesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelector, + remotePoolAddresses: [remoteEvmPool], +} + +// ============================================================================= +// SolanaTokenAdmin — appendRemotePoolAddresses +// ============================================================================= + +describe('SolanaTokenAdmin — appendRemotePoolAddresses', () => { + // =========================================================================== + // generateUnsignedAppendRemotePoolAddresses — Validation + // =========================================================================== + + describe('generateUnsignedAppendRemotePoolAddresses — validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.code, 'APPEND_REMOTE_POOL_ADDRESSES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + remotePoolAddresses: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty address in remotePoolAddresses array', async () => { + await assert.rejects( + () => + admin.generateUnsignedAppendRemotePoolAddresses(sender, { + ...validParams, + remotePoolAddresses: [''], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAppendRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses[0]') + return true + }, + ) + }) + }) + + // =========================================================================== + // appendRemotePoolAddresses — Wallet Validation + // =========================================================================== + + describe('appendRemotePoolAddresses — wallet validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.appendRemotePoolAddresses({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.appendRemotePoolAddresses(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedAppendRemotePoolAddresses — Happy Path + // =========================================================================== + + describe('generateUnsignedAppendRemotePoolAddresses — Happy Path', () => { + /** + * Build a Borsh-encoded buffer that matches the on-chain `state` account layout + * so that `tokenPoolCoder.accounts.decode('state', data)` succeeds and returns + * the expected mint. + * + * Layout: + * 8 bytes — Anchor account discriminator: sha256("account:State")[0..8] + * 1 byte — version (u8) + * BaseConfig struct (all public-keys are 32-byte LE): + * tokenProgram, mint, decimals(u8), poolSigner, poolTokenAccount, + * owner, proposedOwner, rateLimitAdmin, routerOnrampAuthority, + * router, rebalancer, canAcceptLiquidity(bool), listEnabled(bool), + * allowList(vec: u32 len + items), rmnRemote + */ + function buildMockStateData(mintPubkey: PublicKey): Buffer { + const discriminator = Buffer.from(sha256(toUtf8Bytes('account:State')).slice(2, 18), 'hex') + // version + const version = Buffer.from([0]) + + // BaseConfig fields — use zero-pubkeys for all except mint + const zeroPk = Buffer.alloc(32) + const parts: Buffer[] = [ + discriminator, // 8 + version, // 1 + zeroPk, // tokenProgram + mintPubkey.toBuffer(), // mint — this is what getTokenForTokenPool reads + Buffer.from([9]), // decimals + zeroPk, // poolSigner + zeroPk, // poolTokenAccount + zeroPk, // owner + zeroPk, // proposedOwner + zeroPk, // rateLimitAdmin + zeroPk, // routerOnrampAuthority + zeroPk, // router + zeroPk, // rebalancer + Buffer.from([0]), // canAcceptLiquidity + Buffer.from([0]), // listEnabled + Buffer.alloc(4), // allowList vec length = 0 (u32 LE) + zeroPk, // rmnRemote + ] + return Buffer.concat(parts) + } + + const stateData = buildMockStateData(mint) + + /** + * Creates a mock connection with properly encoded pool state so + * discoverPoolInfo + getTokenForTokenPool can succeed. + */ + function createHappyPathConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: stateData, + lamports: 1_000_000, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection + } + + function makeHappyAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(createHappyPathConnection(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + } + + it('should return family: ChainFamily.Solana', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedAppendRemotePoolAddresses( + sender, + validParams, + ) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return mainIndex 0', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedAppendRemotePoolAddresses( + sender, + validParams, + ) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should return 1 instruction (single appendRemotePoolAddresses)', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedAppendRemotePoolAddresses( + sender, + validParams, + ) + assert.equal(unsigned.instructions.length, 1) + }) + + it('should have correct discriminator (sha256 of global:append_remote_pool_addresses)', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedAppendRemotePoolAddresses( + sender, + validParams, + ) + const ix = unsigned.instructions[0]! + const expectedDisc = Buffer.from( + sha256(toUtf8Bytes('global:append_remote_pool_addresses')).slice(2, 18), + 'hex', + ) + const actualDisc = Buffer.from(ix.data.subarray(0, 8)) + assert.deepEqual(actualDisc, expectedDisc) + }) + + it('should have all instruction programIds matching poolProgramId', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedAppendRemotePoolAddresses( + sender, + validParams, + ) + for (const ix of unsigned.instructions) { + assert.equal(ix.programId.toBase58(), poolProgramId.toBase58()) + } + }) + + it('should return 1 instruction even for multiple addresses (single ix with all)', async () => { + const admin = makeHappyAdmin() + const secondRemotePool = '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888' + const multiParams: AppendRemotePoolAddressesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelector, + remotePoolAddresses: [remoteEvmPool, secondRemotePool], + } + const { unsigned } = await admin.generateUnsignedAppendRemotePoolAddresses( + sender, + multiParams, + ) + assert.equal(unsigned.instructions.length, 1) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-apply-chain-updates.test.ts b/ccip-sdk/src/token-admin/solana/solana-apply-chain-updates.test.ts new file mode 100644 index 00000000..9763f01b --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-apply-chain-updates.test.ts @@ -0,0 +1,523 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' +import { sha256, toUtf8Bytes } from 'ethers' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPApplyChainUpdatesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { ApplyChainUpdatesParams } from '../types.ts' + +// ── Constants ── + +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' +const CCIP_TOKENPOOL_CHAINCONFIG_SEED = 'ccip_tokenpool_chainconfig' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey +const poolProgramId = Keypair.generate().publicKey +const remoteEvmPool = '0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD' +const remoteEvmToken = '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888' +const remoteChainSelector = 16015286601757825753n + +// Derive pool state PDA +const [poolStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, +) + +/** + * Creates a mock connection that simulates pool state discovery. + * Returns account info with the correct owner (poolProgramId) and + * makes getTokenForTokenPool return the mint. + */ +function createMockConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + // Return mock pool state account with correct owner + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: Buffer.alloc(0), // Will be decoded by getTokenForTokenPool + lamports: 0, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection +} + +const validParams: ApplyChainUpdatesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelectorsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector, + remotePoolAddresses: [remoteEvmPool], + remoteTokenAddress: remoteEvmToken, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], +} + +// ============================================================================= +// SolanaTokenAdmin — applyChainUpdates +// ============================================================================= + +describe('SolanaTokenAdmin — applyChainUpdates', () => { + // =========================================================================== + // generateUnsignedApplyChainUpdates — Validation + // =========================================================================== + + describe('generateUnsignedApplyChainUpdates — validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.code, 'APPLY_CHAIN_UPDATES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remoteChainSelector: 0n }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remotePoolAddresses: [] }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty remoteTokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedApplyChainUpdates(sender, { + ...validParams, + chainsToAdd: [{ ...validParams.chainsToAdd[0]!, remoteTokenAddress: '' }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPApplyChainUpdatesParamsInvalidError) + assert.equal(err.context.param, 'chainsToAdd[0].remoteTokenAddress') + return true + }, + ) + }) + }) + + // =========================================================================== + // applyChainUpdates — Wallet Validation + // =========================================================================== + + describe('applyChainUpdates — wallet validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.applyChainUpdates({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.applyChainUpdates(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) + + // =========================================================================== + // Discriminator verification + // =========================================================================== + + describe('discriminators', () => { + it('init_chain_remote_config discriminator should be 8 bytes from SHA256', () => { + const hash = sha256(toUtf8Bytes('global:init_chain_remote_config')) + const expected = Buffer.from(hash.slice(2, 18), 'hex') + assert.equal(expected.length, 8) + }) + + it('set_chain_rate_limit discriminator should be 8 bytes from SHA256', () => { + const hash = sha256(toUtf8Bytes('global:set_chain_rate_limit')) + const expected = Buffer.from(hash.slice(2, 18), 'hex') + assert.equal(expected.length, 8) + }) + + it('delete_chain_config discriminator should be 8 bytes from SHA256', () => { + const hash = sha256(toUtf8Bytes('global:delete_chain_config')) + const expected = Buffer.from(hash.slice(2, 18), 'hex') + assert.equal(expected.length, 8) + }) + }) + + // =========================================================================== + // PDA derivation verification + // =========================================================================== + + // =========================================================================== + // generateUnsignedApplyChainUpdates — Happy Path + // =========================================================================== + + describe('generateUnsignedApplyChainUpdates — Happy Path', () => { + /** + * Build a Borsh-encoded buffer that matches the on-chain `state` account layout + * so that `tokenPoolCoder.accounts.decode('state', data)` succeeds and returns + * the expected mint. + * + * Layout: + * 8 bytes — Anchor account discriminator: sha256("account:State")[0..8] + * 1 byte — version (u8) + * BaseConfig struct (all public-keys are 32-byte LE): + * tokenProgram, mint, decimals(u8), poolSigner, poolTokenAccount, + * owner, proposedOwner, rateLimitAdmin, routerOnrampAuthority, + * router, rebalancer, canAcceptLiquidity(bool), listEnabled(bool), + * allowList(vec: u32 len + items), rmnRemote + */ + function buildMockStateData(mintPubkey: PublicKey): Buffer { + const discriminator = Buffer.from(sha256(toUtf8Bytes('account:State')).slice(2, 18), 'hex') + // version + const version = Buffer.from([0]) + + // BaseConfig fields — use zero-pubkeys for all except mint + const zeroPk = Buffer.alloc(32) + const parts: Buffer[] = [ + discriminator, // 8 + version, // 1 + zeroPk, // tokenProgram + mintPubkey.toBuffer(), // mint — this is what getTokenForTokenPool reads + Buffer.from([9]), // decimals + zeroPk, // poolSigner + zeroPk, // poolTokenAccount + zeroPk, // owner + zeroPk, // proposedOwner + zeroPk, // rateLimitAdmin + zeroPk, // routerOnrampAuthority + zeroPk, // router + zeroPk, // rebalancer + Buffer.from([0]), // canAcceptLiquidity + Buffer.from([0]), // listEnabled + Buffer.alloc(4), // allowList vec length = 0 (u32 LE) + zeroPk, // rmnRemote + ] + return Buffer.concat(parts) + } + + const stateData = buildMockStateData(mint) + + /** + * Creates a mock connection with properly encoded pool state so + * discoverPoolInfo + getTokenForTokenPool can succeed. + */ + function createHappyPathConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: stateData, + lamports: 1_000_000, + executable: false, + rentEpoch: 0, + } + } + // For chain config PDA lookups — return null (not yet initialized) + return null + }, + } as unknown as Connection + } + + function makeHappyAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(createHappyPathConnection(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + } + + it('should return family: ChainFamily.Solana', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return mainIndex 0', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should return 3 instructions for one add-chain (init + appendPool + rateLimit)', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + // For each chain to add: initChainRemoteConfig + appendRemotePoolAddresses + setChainRateLimit + assert.equal(unsigned.instructions.length, 3) + }) + + it('should have all instruction programIds matching poolProgramId', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + for (const ix of unsigned.instructions) { + assert.equal(ix.programId.toBase58(), poolProgramId.toBase58()) + } + }) + + it('should have init_chain_remote_config discriminator on first instruction', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + const ix = unsigned.instructions[0]! + const expectedDisc = Buffer.from( + sha256(toUtf8Bytes('global:init_chain_remote_config')).slice(2, 18), + 'hex', + ) + const actualDisc = Buffer.from(ix.data.subarray(0, 8)) + assert.deepEqual(actualDisc, expectedDisc) + }) + + it('should have append_remote_pool_addresses discriminator on second instruction', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + const ix = unsigned.instructions[1]! + const expectedDisc = Buffer.from( + sha256(toUtf8Bytes('global:append_remote_pool_addresses')).slice(2, 18), + 'hex', + ) + const actualDisc = Buffer.from(ix.data.subarray(0, 8)) + assert.deepEqual(actualDisc, expectedDisc) + }) + + it('should have set_chain_rate_limit discriminator on third instruction', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + const ix = unsigned.instructions[2]! + const expectedDisc = Buffer.from( + sha256(toUtf8Bytes('global:set_chain_rate_limit')).slice(2, 18), + 'hex', + ) + const actualDisc = Buffer.from(ix.data.subarray(0, 8)) + assert.deepEqual(actualDisc, expectedDisc) + }) + + it('should return 1 instruction for remove-only scenario', async () => { + const admin = makeHappyAdmin() + const removeOnlyParams: ApplyChainUpdatesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelectorsToRemove: [remoteChainSelector], + chainsToAdd: [], + } + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, removeOnlyParams) + // One delete_chain_config instruction + assert.equal(unsigned.instructions.length, 1) + }) + + it('should have delete_chain_config discriminator for remove instruction', async () => { + const admin = makeHappyAdmin() + const removeOnlyParams: ApplyChainUpdatesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelectorsToRemove: [remoteChainSelector], + chainsToAdd: [], + } + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, removeOnlyParams) + const ix = unsigned.instructions[0]! + const expectedDisc = Buffer.from( + sha256(toUtf8Bytes('global:delete_chain_config')).slice(2, 18), + 'hex', + ) + const actualDisc = Buffer.from(ix.data.subarray(0, 8)) + assert.deepEqual(actualDisc, expectedDisc) + }) + + it('should return 4 instructions for delete-then-re-add same chain', async () => { + const admin = makeHappyAdmin() + const deleteAndReAddParams: ApplyChainUpdatesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelectorsToRemove: [remoteChainSelector], + chainsToAdd: [ + { + remoteChainSelector, + remotePoolAddresses: [remoteEvmPool], + remoteTokenAddress: remoteEvmToken, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + } + const { unsigned } = await admin.generateUnsignedApplyChainUpdates( + sender, + deleteAndReAddParams, + ) + // 1 delete + 3 add (init + append + rateLimit) = 4 + assert.equal(unsigned.instructions.length, 4) + }) + + it('should return 6 instructions for adding two chains', async () => { + const admin = makeHappyAdmin() + const secondRemoteChainSelector = 4949039107694359620n + const twoChainsParams: ApplyChainUpdatesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelectorsToRemove: [], + chainsToAdd: [ + { + remoteChainSelector, + remotePoolAddresses: [remoteEvmPool], + remoteTokenAddress: remoteEvmToken, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + { + remoteChainSelector: secondRemoteChainSelector, + remotePoolAddresses: [remoteEvmPool], + remoteTokenAddress: remoteEvmToken, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + } + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, twoChainsParams) + // 3 instructions per chain * 2 chains = 6 + assert.equal(unsigned.instructions.length, 6) + }) + + it('should skip init when chain config already exists (idempotency)', async () => { + // Create a connection where the chain config PDA already exists + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(BigInt(remoteChainSelector)) + const [chainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + const connectionWithExistingChain = { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: stateData, + lamports: 1_000_000, + executable: false, + rentEpoch: 0, + } + } + // Chain config PDA already exists + if (pubkey.equals(chainConfigPda)) { + return { + owner: poolProgramId, + data: Buffer.alloc(100), + lamports: 1_000_000, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection + + const admin = new SolanaTokenAdmin(connectionWithExistingChain, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + const { unsigned } = await admin.generateUnsignedApplyChainUpdates(sender, validParams) + // Should skip initChainRemoteConfig, so only appendRemotePoolAddresses + setChainRateLimit = 2 + assert.equal(unsigned.instructions.length, 2) + }) + }) + + // =========================================================================== + // PDA derivation verification + // =========================================================================== + + describe('PDA derivation', () => { + it('chain config PDA should use correct seeds', () => { + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(BigInt(remoteChainSelector)) + + const [chainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + // Verify PDA is deterministic + const [chainConfigPda2] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CHAINCONFIG_SEED), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + + assert.equal(chainConfigPda.toBase58(), chainConfigPda2.toBase58()) + }) + + it('state PDA should use correct seeds', () => { + const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + + assert.equal(statePda.toBase58(), poolStatePda.toBase58()) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-create-pool-multisig.test.ts b/ccip-sdk/src/token-admin/solana/solana-create-pool-multisig.test.ts new file mode 100644 index 00000000..7bd53522 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-create-pool-multisig.test.ts @@ -0,0 +1,505 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { type Connection, Keypair, PublicKey, SystemProgram } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPCreatePoolMultisigParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { derivePoolSignerPDA } from '../../solana/utils.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +/** Mock connection that returns a valid SPL Token mint for getAccountInfo. */ +function mockConnectionWithMint(tokenProgramId: PublicKey = TOKEN_PROGRAM_ID) { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => ({ + owner: tokenProgramId, + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + }), + getMinimumBalanceForRentExemption: async () => 2_039_280, + } as unknown as Connection +} + +/** Mock connection where mint does not exist. */ +const mockConnectionNoMint = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + getMinimumBalanceForRentExemption: async () => 2_039_280, +} as unknown as Connection + +/** Mock connection where mint is owned by an unknown program. */ +const mockConnectionBadMint = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => ({ + owner: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + }), + getMinimumBalanceForRentExemption: async () => 2_039_280, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(connection?: Connection): SolanaTokenAdmin { + return new SolanaTokenAdmin(connection ?? mockConnectionWithMint(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey.toBase58() +const poolProgramId = Keypair.generate().publicKey.toBase58() +const additionalSigner1 = Keypair.generate().publicKey.toBase58() +const additionalSigner2 = Keypair.generate().publicKey.toBase58() + +const validParams = { + mint, + poolProgramId, + additionalSigners: [additionalSigner1], + threshold: 1, +} + +// ============================================================================= +// SolanaTokenAdmin — createPoolMintAuthorityMultisig +// ============================================================================= + +describe('SolanaTokenAdmin — createPoolMintAuthorityMultisig', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedCreatePoolMintAuthorityMultisig — Validation', () => { + const admin = makeAdmin() + + it('should reject empty mint', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + mint: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.code, 'CREATE_POOL_MULTISIG_PARAMS_INVALID') + assert.equal(err.context.param, 'mint') + return true + }, + ) + }) + + it('should reject empty poolProgramId', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + poolProgramId: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'poolProgramId') + return true + }, + ) + }) + + it('should reject empty additionalSigners array', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + additionalSigners: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'additionalSigners') + return true + }, + ) + }) + + it('should reject additionalSigners with empty string', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + additionalSigners: [''], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'additionalSigners') + return true + }, + ) + }) + + it('should reject threshold < 1', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + threshold: 0, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'threshold') + return true + }, + ) + }) + + it('should reject non-integer threshold', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + threshold: 1.5, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'threshold') + return true + }, + ) + }) + + it('should reject threshold > total signers', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + additionalSigners: [additionalSigner1], + threshold: 3, // 2 total signers (PDA + 1), threshold 3 + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'threshold') + return true + }, + ) + }) + + it('should reject total signers > 11', async () => { + // 11 additional + 1 PDA = 12 total + const tooManySigners = Array.from({ length: 11 }, () => + Keypair.generate().publicKey.toBase58(), + ) + await assert.rejects( + () => + admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + additionalSigners: tooManySigners, + threshold: 1, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'additionalSigners') + return true + }, + ) + }) + + it('should reject when mint account not found on-chain', async () => { + const admin = makeAdmin(mockConnectionNoMint) + await assert.rejects( + () => admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'mint') + assert.ok(err.message.includes('not found')) + return true + }, + ) + }) + + it('should reject when mint owned by unknown program', async () => { + const admin = makeAdmin(mockConnectionBadMint) + await assert.rejects( + () => admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPCreatePoolMultisigParamsInvalidError) + assert.equal(err.context.param, 'mint') + assert.ok(err.message.includes('expected SPL Token or Token-2022')) + return true + }, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedCreatePoolMintAuthorityMultisig — Happy Path', () => { + it('should return UnsignedSolanaTx with correct family', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig( + sender, + validParams, + ) + + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 2 instructions (createAccount + initializeMultisig)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig( + sender, + validParams, + ) + + assert.equal(unsigned.instructions.length, 2) + }) + + it('should have mainIndex = 1 (initializeMultisig)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig( + sender, + validParams, + ) + + assert.equal(unsigned.mainIndex, 1) + }) + + it('should derive poolSignerPda correctly', async () => { + const admin = makeAdmin() + const mintPubkey = new PublicKey(mint) + const poolProgram = new PublicKey(poolProgramId) + const [expectedPda] = derivePoolSignerPDA(mintPubkey, poolProgram) + + const { result } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig( + sender, + validParams, + ) + + assert.equal(result.poolSignerPda, expectedPda.toBase58()) + }) + + it('should include poolSignerPda as first signer in allSigners', async () => { + const admin = makeAdmin() + const mintPubkey = new PublicKey(mint) + const poolProgram = new PublicKey(poolProgramId) + const [expectedPda] = derivePoolSignerPDA(mintPubkey, poolProgram) + + const { result } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig( + sender, + validParams, + ) + + assert.equal(result.allSigners[0], expectedPda.toBase58()) + assert.equal(result.allSigners[1], additionalSigner1) + assert.equal(result.allSigners.length, 2) + }) + + it('should return multisigKeypair when no seed is provided', async () => { + const admin = makeAdmin() + const { multisigKeypair, result } = + await admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, validParams) + + assert.ok(multisigKeypair instanceof Keypair) + assert.equal(result.multisigAddress, multisigKeypair.publicKey.toBase58()) + }) + + it('should use createAccountWithSeed when seed is provided', async () => { + const admin = makeAdmin() + const seed = 'ccip-pool-multisig' + const { unsigned, multisigKeypair, result } = + await admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + seed, + }) + + // No keypair when seed is used + assert.equal(multisigKeypair, undefined) + + // Verify deterministic address + const expectedAddress = await PublicKey.createWithSeed( + new PublicKey(sender), + seed, + TOKEN_PROGRAM_ID, + ) + assert.equal(result.multisigAddress, expectedAddress.toBase58()) + + // First instruction should be createAccountWithSeed (SystemProgram) + const createIx = unsigned.instructions[0]! + assert.equal(createIx.programId.toBase58(), SystemProgram.programId.toBase58()) + }) + + it('should produce deterministic address with same seed', async () => { + const admin = makeAdmin() + const seed = 'test-seed' + + const { result: r1 } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + seed, + }) + const { result: r2 } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + seed, + }) + + assert.equal(r1.multisigAddress, r2.multisigAddress) + }) + + it('should auto-detect Token-2022 program', async () => { + const admin = makeAdmin(mockConnectionWithMint(TOKEN_2022_PROGRAM_ID)) + const seed = 'test-2022' + + const { result } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + seed, + }) + + // Deterministic address should be derived with Token-2022 program + const expectedAddress = await PublicKey.createWithSeed( + new PublicKey(sender), + seed, + TOKEN_2022_PROGRAM_ID, + ) + assert.equal(result.multisigAddress, expectedAddress.toBase58()) + }) + + it('should support multiple additional signers', async () => { + const admin = makeAdmin() + const { result } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig(sender, { + ...validParams, + additionalSigners: [additionalSigner1, additionalSigner2], + threshold: 2, + }) + + assert.equal(result.allSigners.length, 3) // PDA + 2 additional + assert.equal(result.allSigners[1], additionalSigner1) + assert.equal(result.allSigners[2], additionalSigner2) + }) + + it('should use first instruction as SystemProgram.createAccount', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig( + sender, + validParams, + ) + + const createIx = unsigned.instructions[0]! + assert.equal(createIx.programId.toBase58(), SystemProgram.programId.toBase58()) + }) + + it('should use second instruction as InitializeMultisig (SPL Token)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolMintAuthorityMultisig( + sender, + validParams, + ) + + const initIx = unsigned.instructions[1]! + assert.equal(initIx.programId.toBase58(), TOKEN_PROGRAM_ID.toBase58()) + }) + }) + + // =========================================================================== + // derivePoolSignerPDA utility + // =========================================================================== + + describe('derivePoolSignerPDA', () => { + it('should derive deterministic PDA from mint and poolProgramId', () => { + const mintPk = new PublicKey(mint) + const programPk = new PublicKey(poolProgramId) + + const [pda1] = derivePoolSignerPDA(mintPk, programPk) + const [pda2] = derivePoolSignerPDA(mintPk, programPk) + + assert.equal(pda1.toBase58(), pda2.toBase58()) + }) + + it('should produce different PDAs for different mints', () => { + const mint1 = Keypair.generate().publicKey + const mint2 = Keypair.generate().publicKey + const programPk = new PublicKey(poolProgramId) + + const [pda1] = derivePoolSignerPDA(mint1, programPk) + const [pda2] = derivePoolSignerPDA(mint2, programPk) + + assert.notEqual(pda1.toBase58(), pda2.toBase58()) + }) + + it('should produce different PDAs for different pool programs', () => { + const mintPk = new PublicKey(mint) + const program1 = Keypair.generate().publicKey + const program2 = Keypair.generate().publicKey + + const [pda1] = derivePoolSignerPDA(mintPk, program1) + const [pda2] = derivePoolSignerPDA(mintPk, program2) + + assert.notEqual(pda1.toBase58(), pda2.toBase58()) + }) + + it('should match expected seeds ["ccip_tokenpool_signer", mint]', () => { + const mintPk = new PublicKey(mint) + const programPk = new PublicKey(poolProgramId) + + const [pda] = derivePoolSignerPDA(mintPk, programPk) + const [expected] = PublicKey.findProgramAddressSync( + [Buffer.from('ccip_tokenpool_signer'), mintPk.toBuffer()], + programPk, + ) + + assert.equal(pda.toBase58(), expected.toBase58()) + }) + }) + + // =========================================================================== + // createPoolMintAuthorityMultisig — Wallet Validation + // =========================================================================== + + describe('createPoolMintAuthorityMultisig — Wallet Validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.createPoolMintAuthorityMultisig({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.createPoolMintAuthorityMultisig(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject string wallet', async () => { + await assert.rejects( + () => admin.createPoolMintAuthorityMultisig('not-a-wallet', validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-create-pool-token-account.test.ts b/ccip-sdk/src/token-admin/solana/solana-create-pool-token-account.test.ts new file mode 100644 index 00000000..a5013511 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-create-pool-token-account.test.ts @@ -0,0 +1,273 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + getAssociatedTokenAddressSync, +} from '@solana/spl-token' +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPCreatePoolTokenAccountParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const MOCK_POOL_PROGRAM_ID = Keypair.generate().publicKey + +function mockConnection( + opts: { + tokenProgramId?: PublicKey + poolProgramId?: PublicKey + mintExists?: boolean + poolExists?: boolean + } = {}, +) { + const { + tokenProgramId = TOKEN_PROGRAM_ID, + poolProgramId = MOCK_POOL_PROGRAM_ID, + mintExists = true, + poolExists = true, + } = opts + + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + const key = pubkey.toBase58() + if (key === poolAddress && poolExists) { + return { + owner: poolProgramId, + data: Buffer.alloc(300), + executable: false, + lamports: 1_000_000, + } + } + if (key === tokenAddress && mintExists) { + return { + owner: tokenProgramId, + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + } + } + return null + }, + } as unknown as Connection +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(connection?: Connection): SolanaTokenAdmin { + return new SolanaTokenAdmin(connection ?? mockConnection(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const poolAddress = Keypair.generate().publicKey.toBase58() + +const validParams = { + tokenAddress, + poolAddress, +} + +// ============================================================================= +// SolanaTokenAdmin — createPoolTokenAccount +// ============================================================================= + +describe('SolanaTokenAdmin — createPoolTokenAccount', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedCreatePoolTokenAccount — Validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolTokenAccount(sender, { + ...validParams, + tokenAddress: '', + }), + CCIPCreatePoolTokenAccountParamsInvalidError, + ) + }) + + it('should reject invalid tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolTokenAccount(sender, { + ...validParams, + tokenAddress: 'not-a-pubkey', + }), + CCIPCreatePoolTokenAccountParamsInvalidError, + ) + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolTokenAccount(sender, { + ...validParams, + poolAddress: '', + }), + CCIPCreatePoolTokenAccountParamsInvalidError, + ) + }) + + it('should reject invalid poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreatePoolTokenAccount(sender, { + ...validParams, + poolAddress: 'not-a-pubkey', + }), + CCIPCreatePoolTokenAccountParamsInvalidError, + ) + }) + + it('should reject when pool state not found on-chain', async () => { + const admin = makeAdmin(mockConnection({ poolExists: false })) + await assert.rejects( + () => admin.generateUnsignedCreatePoolTokenAccount(sender, validParams), + CCIPCreatePoolTokenAccountParamsInvalidError, + ) + }) + + it('should reject when mint not found on-chain', async () => { + const admin = makeAdmin(mockConnection({ mintExists: false })) + await assert.rejects( + () => admin.generateUnsignedCreatePoolTokenAccount(sender, validParams), + CCIPCreatePoolTokenAccountParamsInvalidError, + ) + }) + + it('should reject when mint owned by unknown program', async () => { + const admin = makeAdmin( + mockConnection({ tokenProgramId: new PublicKey('11111111111111111111111111111111') }), + ) + await assert.rejects( + () => admin.generateUnsignedCreatePoolTokenAccount(sender, validParams), + CCIPCreatePoolTokenAccountParamsInvalidError, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedCreatePoolTokenAccount — Happy Path', () => { + it('should return correct family (Solana)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolTokenAccount(sender, validParams) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return exactly 1 instruction (idempotent ATA creation)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolTokenAccount(sender, validParams) + assert.equal(unsigned.instructions.length, 1) + }) + + it('should return mainIndex 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolTokenAccount(sender, validParams) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should return correct poolTokenAccount address', async () => { + const admin = makeAdmin() + const { result } = await admin.generateUnsignedCreatePoolTokenAccount(sender, validParams) + + // Derive expected ATA + const mint = new PublicKey(tokenAddress) + const [poolSignerPda] = PublicKey.findProgramAddressSync( + [Buffer.from('ccip_tokenpool_signer'), mint.toBuffer()], + MOCK_POOL_PROGRAM_ID, + ) + const expectedAta = getAssociatedTokenAddressSync(mint, poolSignerPda, true, TOKEN_PROGRAM_ID) + + assert.equal(result.poolTokenAccount, expectedAta.toBase58()) + }) + + it('should return correct poolSignerPda', async () => { + const admin = makeAdmin() + const { result } = await admin.generateUnsignedCreatePoolTokenAccount(sender, validParams) + + const mint = new PublicKey(tokenAddress) + const [expectedPda] = PublicKey.findProgramAddressSync( + [Buffer.from('ccip_tokenpool_signer'), mint.toBuffer()], + MOCK_POOL_PROGRAM_ID, + ) + + assert.equal(result.poolSignerPda, expectedPda.toBase58()) + }) + + it('should work with Token-2022 mint', async () => { + const admin = makeAdmin(mockConnection({ tokenProgramId: TOKEN_2022_PROGRAM_ID })) + const { unsigned, result } = await admin.generateUnsignedCreatePoolTokenAccount( + sender, + validParams, + ) + assert.equal(unsigned.family, ChainFamily.Solana) + assert.ok(result.poolTokenAccount) + assert.ok(result.poolSignerPda) + }) + + it('should set payer as signer in the instruction', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreatePoolTokenAccount(sender, validParams) + const ix = unsigned.instructions[0]! + const payerKey = ix.keys.find((k) => k.pubkey.toBase58() === sender && k.isSigner) + assert.ok(payerKey, 'payer should be a signer in the ATA creation instruction') + }) + }) + + // =========================================================================== + // Wallet Validation + // =========================================================================== + + describe('createPoolTokenAccount — Wallet Validation', () => { + it('should reject non-wallet object', async () => { + const admin = makeAdmin() + await assert.rejects( + () => admin.createPoolTokenAccount({}, validParams), + CCIPWalletInvalidError, + ) + }) + + it('should reject null wallet', async () => { + const admin = makeAdmin() + await assert.rejects( + () => admin.createPoolTokenAccount(null, validParams), + CCIPWalletInvalidError, + ) + }) + + it('should reject string wallet', async () => { + const admin = makeAdmin() + await assert.rejects( + () => admin.createPoolTokenAccount('not-a-wallet', validParams), + CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-create-token-alt.test.ts b/ccip-sdk/src/token-admin/solana/solana-create-token-alt.test.ts new file mode 100644 index 00000000..f8fed2d2 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-create-token-alt.test.ts @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { CCIPCreateTokenAltParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const MOCK_POOL_PROGRAM_ID = Keypair.generate().publicKey +const MOCK_FEE_QUOTER = Keypair.generate().publicKey + +/** + * Mock connection for createTokenAlt tests. + * - getAccountInfo dispatches based on the requested address: + * - mint address → returns account owned by the token program + * - pool address → returns account owned by the pool program + * - everything else → null + * - getSlot returns a fixed slot + * - _getRouterConfig is handled by mocking the router program account + */ +function mockConnection( + opts: { + tokenProgramId?: PublicKey + poolProgramId?: PublicKey + mintExists?: boolean + poolExists?: boolean + } = {}, +) { + const { + tokenProgramId = TOKEN_PROGRAM_ID, + poolProgramId = MOCK_POOL_PROGRAM_ID, + mintExists = true, + poolExists = true, + } = opts + + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + const key = pubkey.toBase58() + // Pool address check: return account owned by pool program + if (key === poolAddress && poolExists) { + return { + owner: poolProgramId, + data: Buffer.alloc(300), + executable: false, + lamports: 1_000_000, + } + } + // Mint address check: return account owned by token program + if (key === tokenAddress && mintExists) { + return { + owner: tokenProgramId, + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + } + } + return null + }, + getSlot: async () => 12345, + } as unknown as Connection +} + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +/** + * Creates a SolanaTokenAdmin with a patched _getRouterConfig that returns + * a mock feeQuoter instead of fetching from an actual router program. + */ +function makeAdmin(connection?: Connection): SolanaTokenAdmin { + const admin = new SolanaTokenAdmin(connection ?? mockConnection(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + // Patch _getRouterConfig to avoid real Anchor program fetch + ;( + admin as unknown as { _getRouterConfig: (r: string) => Promise<{ feeQuoter: PublicKey }> } + )._getRouterConfig = async () => ({ feeQuoter: MOCK_FEE_QUOTER }) + return admin +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const poolAddress = Keypair.generate().publicKey.toBase58() +const routerAddress = Keypair.generate().publicKey.toBase58() + +const validParams = { + tokenAddress, + poolAddress, + routerAddress, +} + +// ============================================================================= +// SolanaTokenAdmin — createTokenAlt +// ============================================================================= + +describe('SolanaTokenAdmin — createTokenAlt', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedCreateTokenAlt — Validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + tokenAddress: '', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject invalid tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + tokenAddress: 'not-a-pubkey', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + poolAddress: '', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject invalid poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + poolAddress: 'not-a-pubkey', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + routerAddress: '', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject invalid routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + routerAddress: 'not-a-pubkey', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject empty authority when provided', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + authority: '', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject invalid authority', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + authority: 'not-a-pubkey', + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject too many additionalAddresses', async () => { + const tooMany = Array.from({ length: 247 }, () => Keypair.generate().publicKey.toBase58()) + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + additionalAddresses: tooMany, + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject invalid additionalAddresses entry', async () => { + await assert.rejects( + () => + admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + additionalAddresses: ['not-a-pubkey'], + }), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject when pool state not found on-chain', async () => { + const admin = makeAdmin(mockConnection({ poolExists: false })) + await assert.rejects( + () => admin.generateUnsignedCreateTokenAlt(sender, validParams), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject when mint not found on-chain', async () => { + const admin = makeAdmin(mockConnection({ mintExists: false })) + await assert.rejects( + () => admin.generateUnsignedCreateTokenAlt(sender, validParams), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + + it('should reject when mint owned by unknown program', async () => { + const admin = makeAdmin( + mockConnection({ tokenProgramId: new PublicKey('11111111111111111111111111111111') }), + ) + await assert.rejects( + () => admin.generateUnsignedCreateTokenAlt(sender, validParams), + CCIPCreateTokenAltParamsInvalidError, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedCreateTokenAlt — Happy Path', () => { + it('should return correct family (Solana)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreateTokenAlt(sender, validParams) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return create + extend instructions (10 base, no additional)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreateTokenAlt(sender, validParams) + // 10 addresses → 1 create + 1 extend (chunk size 30) + assert.equal(unsigned.instructions.length, 2) + }) + + it('should return mainIndex 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreateTokenAlt(sender, validParams) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should return lookupTableAddress in result', async () => { + const admin = makeAdmin() + const { result } = await admin.generateUnsignedCreateTokenAlt(sender, validParams) + assert.ok(result.lookupTableAddress) + // Should be a valid base58 pubkey + new PublicKey(result.lookupTableAddress) + }) + + it('should include additional addresses when provided', async () => { + const extra1 = Keypair.generate().publicKey.toBase58() + const extra2 = Keypair.generate().publicKey.toBase58() + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + additionalAddresses: [extra1, extra2], + }) + // 12 addresses → 1 create + 1 extend (chunk size 30) + assert.equal(unsigned.instructions.length, 2) + }) + + it('should chunk addresses when exceeding 30', async () => { + const extras = Array.from({ length: 25 }, () => Keypair.generate().publicKey.toBase58()) + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + additionalAddresses: extras, + }) + // 35 total addresses → 1 create + 2 extend (30 + 5) + assert.equal(unsigned.instructions.length, 3) + }) + + it('should work with Token-2022 mint', async () => { + const admin = makeAdmin(mockConnection({ tokenProgramId: TOKEN_2022_PROGRAM_ID })) + const { unsigned, result } = await admin.generateUnsignedCreateTokenAlt(sender, validParams) + assert.equal(unsigned.family, ChainFamily.Solana) + assert.ok(result.lookupTableAddress) + }) + + it('should use custom authority when provided', async () => { + const customAuthority = Keypair.generate().publicKey.toBase58() + const admin = makeAdmin() + // Should not throw — authority is valid + const { result } = await admin.generateUnsignedCreateTokenAlt(sender, { + ...validParams, + authority: customAuthority, + }) + assert.ok(result.lookupTableAddress) + }) + }) + + // =========================================================================== + // Wallet Validation + // =========================================================================== + + describe('createTokenAlt — Wallet Validation', () => { + it('should reject non-wallet object', async () => { + const admin = makeAdmin() + await assert.rejects(() => admin.createTokenAlt({}, validParams), CCIPWalletInvalidError) + }) + + it('should reject null wallet', async () => { + const admin = makeAdmin() + await assert.rejects(() => admin.createTokenAlt(null, validParams), CCIPWalletInvalidError) + }) + + it('should reject string wallet', async () => { + const admin = makeAdmin() + await assert.rejects( + () => admin.createTokenAlt('not-a-wallet', validParams), + CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-delete-chain-config.test.ts b/ccip-sdk/src/token-admin/solana/solana-delete-chain-config.test.ts new file mode 100644 index 00000000..486dbf97 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-delete-chain-config.test.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' +import { sha256, toUtf8Bytes } from 'ethers' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPDeleteChainConfigParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { DeleteChainConfigParams } from '../types.ts' + +// ── Constants ── + +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey +const poolProgramId = Keypair.generate().publicKey +const remoteChainSelector = 16015286601757825753n + +// Derive pool state PDA +const [poolStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, +) + +const validParams: DeleteChainConfigParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelector, +} + +// ============================================================================= +// SolanaTokenAdmin — deleteChainConfig +// ============================================================================= + +describe('SolanaTokenAdmin — deleteChainConfig', () => { + // =========================================================================== + // generateUnsignedDeleteChainConfig — Validation + // =========================================================================== + + describe('generateUnsignedDeleteChainConfig — validation', () => { + const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + } as unknown as Connection + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeleteChainConfig(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPDeleteChainConfigParamsInvalidError) + assert.equal(err.code, 'DELETE_CHAIN_CONFIG_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeleteChainConfig(sender, { + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPDeleteChainConfigParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + }) + + // =========================================================================== + // deleteChainConfig — Wallet Validation + // =========================================================================== + + describe('deleteChainConfig — wallet validation', () => { + const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + } as unknown as Connection + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.deleteChainConfig({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.deleteChainConfig(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedDeleteChainConfig — Happy Path + // =========================================================================== + + describe('generateUnsignedDeleteChainConfig — Happy Path', () => { + /** + * Build a Borsh-encoded buffer that matches the on-chain `state` account layout + * so that `tokenPoolCoder.accounts.decode('state', data)` succeeds and returns + * the expected mint. + */ + function buildMockStateData(mintPubkey: PublicKey): Buffer { + const discriminator = Buffer.from(sha256(toUtf8Bytes('account:State')).slice(2, 18), 'hex') + const version = Buffer.from([0]) + const zeroPk = Buffer.alloc(32) + const parts: Buffer[] = [ + discriminator, + version, + zeroPk, // tokenProgram + mintPubkey.toBuffer(), // mint + Buffer.from([9]), // decimals + zeroPk, // poolSigner + zeroPk, // poolTokenAccount + zeroPk, // owner + zeroPk, // proposedOwner + zeroPk, // rateLimitAdmin + zeroPk, // routerOnrampAuthority + zeroPk, // router + zeroPk, // rebalancer + Buffer.from([0]), // canAcceptLiquidity + Buffer.from([0]), // listEnabled + Buffer.alloc(4), // allowList vec length = 0 + zeroPk, // rmnRemote + ] + return Buffer.concat(parts) + } + + const stateData = buildMockStateData(mint) + + function createHappyPathConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: stateData, + lamports: 1_000_000, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection + } + + function makeHappyAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(createHappyPathConnection(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + } + + it('should return family: ChainFamily.Solana', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedDeleteChainConfig(sender, validParams) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return mainIndex 0', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedDeleteChainConfig(sender, validParams) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should return 1 instruction', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedDeleteChainConfig(sender, validParams) + assert.equal(unsigned.instructions.length, 1) + }) + + it('should have correct discriminator (sha256 of global:delete_chain_config)', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedDeleteChainConfig(sender, validParams) + const ix = unsigned.instructions[0]! + const expectedDisc = Buffer.from( + sha256(toUtf8Bytes('global:delete_chain_config')).slice(2, 18), + 'hex', + ) + const actualDisc = Buffer.from(ix.data.subarray(0, 8)) + assert.deepEqual(actualDisc, expectedDisc) + }) + + it('should have instruction programId matching poolProgramId', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedDeleteChainConfig(sender, validParams) + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), poolProgramId.toBase58()) + }) + + it('should have 3 accounts (state, chainConfig, authority)', async () => { + const admin = makeHappyAdmin() + const { unsigned } = await admin.generateUnsignedDeleteChainConfig(sender, validParams) + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 3) + + // state — not writable, not signer + assert.equal(ix.keys[0]!.isWritable, false) + assert.equal(ix.keys[0]!.isSigner, false) + + // chainConfig — writable, not signer + assert.equal(ix.keys[1]!.isWritable, true) + assert.equal(ix.keys[1]!.isSigner, false) + + // authority — writable, signer + assert.equal(ix.keys[2]!.isWritable, true) + assert.equal(ix.keys[2]!.isSigner, true) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-get-mint-burn-roles.test.ts b/ccip-sdk/src/token-admin/solana/solana-get-mint-burn-roles.test.ts new file mode 100644 index 00000000..382cb17f --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-get-mint-burn-roles.test.ts @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + MULTISIG_SIZE, + MintLayout, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from '@solana/spl-token' +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { CCIPGrantMintBurnAccessParamsInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(connection: Connection): SolanaTokenAdmin { + return new SolanaTokenAdmin(connection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +/** Build a valid 82-byte mint buffer. When `authority` is provided, mintAuthorityOption = 1. */ +function encodeMintData(authority?: PublicKey): Buffer { + const data = Buffer.alloc(MintLayout.span) + if (authority) { + // mintAuthorityOption = 1 (Some) + data.writeUInt32LE(1, 0) + authority.toBuffer().copy(data, 4) + } else { + // mintAuthorityOption = 0 (None) + data.writeUInt32LE(0, 0) + } + // decimals at offset 44 + data.writeUInt8(9, 44) + // isInitialized at offset 45 + data.writeUInt8(1, 45) + return data +} + +/** Build a valid 355-byte multisig buffer with given threshold, count, and signers. */ +function encodeMultisigData(m: number, signers: PublicKey[]): Buffer { + const data = Buffer.alloc(MULTISIG_SIZE) + // m (u8) at offset 0 + data.writeUInt8(m, 0) + // n (u8) at offset 1 + data.writeUInt8(signers.length, 1) + // isInitialized (bool) at offset 2 + data.writeUInt8(1, 2) + // signer1..signer11 start at offset 3, each 32 bytes + for (let i = 0; i < signers.length; i++) { + signers[i]!.toBuffer().copy(data, 3 + i * 32) + } + return data +} + +/** + * Creates a mock connection that returns different account info per pubkey. + * `accounts` is a map from base58 pubkey to the account info to return. + */ +function mockConnection( + accounts: Record, +): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + const key = pubkey.toBase58() + const entry = accounts[key] + if (entry === undefined || entry === null) return null + return { + owner: entry.owner, + data: entry.data, + executable: false, + lamports: 1_000_000, + } + }, + getMinimumBalanceForRentExemption: async () => 2_039_280, + } as unknown as Connection +} + +const mintKeypair = Keypair.generate() +const mintAddress = mintKeypair.publicKey.toBase58() + +// ============================================================================= +// SolanaTokenAdmin — getMintBurnRoles +// ============================================================================= + +describe('SolanaTokenAdmin — getMintBurnRoles', () => { + // =========================================================================== + // Mint authority disabled + // =========================================================================== + + it('should return mintAuthority: null when mint authority is disabled', async () => { + const mintData = encodeMintData() // no authority + const conn = mockConnection({ + [mintAddress]: { owner: TOKEN_PROGRAM_ID, data: mintData }, + }) + const admin = makeAdmin(conn) + + const result = await admin.getMintBurnRoles({ tokenAddress: mintAddress }) + + assert.equal(result.mintAuthority, null) + assert.equal(result.isMultisig, false) + assert.equal(result.multisigThreshold, undefined) + assert.equal(result.multisigMembers, undefined) + }) + + // =========================================================================== + // Regular (non-multisig) authority + // =========================================================================== + + it('should return isMultisig: false when mint authority is a regular account', async () => { + const authority = Keypair.generate().publicKey + const mintData = encodeMintData(authority) + const conn = mockConnection({ + [mintAddress]: { owner: TOKEN_PROGRAM_ID, data: mintData }, + // Authority account exists but is NOT multisig size (e.g., a regular wallet) + [authority.toBase58()]: { + owner: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(0), + }, + }) + const admin = makeAdmin(conn) + + const result = await admin.getMintBurnRoles({ tokenAddress: mintAddress }) + + assert.equal(result.mintAuthority, authority.toBase58()) + assert.equal(result.isMultisig, false) + assert.equal(result.multisigThreshold, undefined) + assert.equal(result.multisigMembers, undefined) + }) + + // =========================================================================== + // Multisig authority + // =========================================================================== + + it('should return isMultisig: true with correct threshold and members when authority is a multisig', async () => { + const signer1 = Keypair.generate().publicKey + const signer2 = Keypair.generate().publicKey + const signer3 = Keypair.generate().publicKey + const multisigAddress = Keypair.generate().publicKey + + const mintData = encodeMintData(multisigAddress) + const multisigData = encodeMultisigData(2, [signer1, signer2, signer3]) + + const conn = mockConnection({ + [mintAddress]: { owner: TOKEN_PROGRAM_ID, data: mintData }, + [multisigAddress.toBase58()]: { + owner: TOKEN_PROGRAM_ID, + data: multisigData, + }, + }) + const admin = makeAdmin(conn) + + const result = await admin.getMintBurnRoles({ tokenAddress: mintAddress }) + + assert.equal(result.mintAuthority, multisigAddress.toBase58()) + assert.equal(result.isMultisig, true) + assert.equal(result.multisigThreshold, 2) + assert.ok(result.multisigMembers) + assert.equal(result.multisigMembers.length, 3) + assert.equal(result.multisigMembers[0]!.address, signer1.toBase58()) + assert.equal(result.multisigMembers[1]!.address, signer2.toBase58()) + assert.equal(result.multisigMembers[2]!.address, signer3.toBase58()) + }) + + // =========================================================================== + // Authority account not found + // =========================================================================== + + it('should return isMultisig: false when authority account not found', async () => { + const authority = Keypair.generate().publicKey + const mintData = encodeMintData(authority) + const conn = mockConnection({ + [mintAddress]: { owner: TOKEN_PROGRAM_ID, data: mintData }, + // authority account not present in mock — getAccountInfo returns null + }) + const admin = makeAdmin(conn) + + const result = await admin.getMintBurnRoles({ tokenAddress: mintAddress }) + + assert.equal(result.mintAuthority, authority.toBase58()) + assert.equal(result.isMultisig, false) + }) + + // =========================================================================== + // Mint account not found + // =========================================================================== + + it('should throw when mint account not found', async () => { + const conn = mockConnection({}) + const admin = makeAdmin(conn) + + await assert.rejects( + () => admin.getMintBurnRoles({ tokenAddress: mintAddress }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'tokenAddress') + assert.ok(err.message.includes('not found')) + return true + }, + ) + }) + + // =========================================================================== + // Token-2022 multisig + // =========================================================================== + + it('should detect multisig owned by Token-2022 program', async () => { + const signer1 = Keypair.generate().publicKey + const multisigAddress = Keypair.generate().publicKey + + const mintData = encodeMintData(multisigAddress) + const multisigData = encodeMultisigData(1, [signer1]) + + const conn = mockConnection({ + [mintAddress]: { owner: TOKEN_2022_PROGRAM_ID, data: mintData }, + [multisigAddress.toBase58()]: { + owner: TOKEN_2022_PROGRAM_ID, + data: multisigData, + }, + }) + const admin = makeAdmin(conn) + + const result = await admin.getMintBurnRoles({ tokenAddress: mintAddress }) + + assert.equal(result.isMultisig, true) + assert.equal(result.multisigThreshold, 1) + assert.ok(result.multisigMembers) + assert.equal(result.multisigMembers.length, 1) + assert.equal(result.multisigMembers[0]!.address, signer1.toBase58()) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-grant-mint-burn-access.test.ts b/ccip-sdk/src/token-admin/solana/solana-grant-mint-burn-access.test.ts new file mode 100644 index 00000000..0813da7f --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-grant-mint-burn-access.test.ts @@ -0,0 +1,321 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPGrantMintBurnAccessParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +/** Mock connection that returns a valid SPL Token mint for getAccountInfo. */ +function mockConnectionWithMint(tokenProgramId: PublicKey = TOKEN_PROGRAM_ID) { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => ({ + owner: tokenProgramId, + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + }), + getMinimumBalanceForRentExemption: async () => 2_039_280, + } as unknown as Connection +} + +/** Mock connection where mint does not exist. */ +const mockConnectionNoMint = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + getMinimumBalanceForRentExemption: async () => 2_039_280, +} as unknown as Connection + +/** Mock connection where mint is owned by an unknown program. */ +const mockConnectionBadMint = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => ({ + owner: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + }), + getMinimumBalanceForRentExemption: async () => 2_039_280, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(connection?: Connection): SolanaTokenAdmin { + return new SolanaTokenAdmin(connection ?? mockConnectionWithMint(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const authority = Keypair.generate().publicKey.toBase58() + +const validParams = { + tokenAddress, + authority, +} + +// ============================================================================= +// SolanaTokenAdmin — grantMintBurnAccess +// ============================================================================= + +describe('SolanaTokenAdmin — grantMintBurnAccess', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedGrantMintBurnAccess — Validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + tokenAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.code, 'GRANT_MINT_BURN_ACCESS_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty authority', async () => { + await assert.rejects( + () => + admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + authority: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + return true + }, + ) + }) + + it('should reject invalid tokenAddress public key', async () => { + await assert.rejects( + () => + admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + tokenAddress: 'not-a-valid-pubkey!!!', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject invalid authority public key', async () => { + await assert.rejects( + () => + admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + authority: 'not-a-valid-pubkey!!!', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'authority') + return true + }, + ) + }) + + it('should reject when mint account not found on-chain', async () => { + const admin = makeAdmin(mockConnectionNoMint) + await assert.rejects( + () => admin.generateUnsignedGrantMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'tokenAddress') + assert.ok(err.message.includes('not found')) + return true + }, + ) + }) + + it('should reject when mint owned by unknown program', async () => { + const admin = makeAdmin(mockConnectionBadMint) + await assert.rejects( + () => admin.generateUnsignedGrantMintBurnAccess(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'tokenAddress') + assert.ok(err.message.includes('expected SPL Token or Token-2022')) + return true + }, + ) + }) + + it('should reject role: burn (Solana has no separate burn authority)', async () => { + const admin = makeAdmin() + await assert.rejects( + () => + admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + role: 'burn', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPGrantMintBurnAccessParamsInvalidError) + assert.equal(err.context.param, 'role') + assert.ok(err.message.includes('burn authority')) + return true + }, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedGrantMintBurnAccess — Happy Path', () => { + it('should return UnsignedSolanaTx with correct family', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 1 instruction', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + assert.equal(unsigned.instructions.length, 1) + }) + + it('should have mainIndex = 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + assert.equal(unsigned.mainIndex, 0) + }) + + it('should use SPL Token program for instruction', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), TOKEN_PROGRAM_ID.toBase58()) + }) + + it('should auto-detect Token-2022 program', async () => { + const admin = makeAdmin(mockConnectionWithMint(TOKEN_2022_PROGRAM_ID)) + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()) + }) + + it('should include tokenAddress as first key in instruction', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[0]!.pubkey.toBase58(), tokenAddress) + assert.ok(ix.keys[0]!.isWritable) + }) + + it('should include sender as second key (current authority)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[1]!.pubkey.toBase58(), sender) + assert.ok(ix.keys[1]!.isSigner) + }) + + it('should return empty txHash in unsigned result', async () => { + const admin = makeAdmin() + const { result } = await admin.generateUnsignedGrantMintBurnAccess(sender, validParams) + + assert.equal(result.txHash, '') + }) + + it('should succeed with role: mint (same as default)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + role: 'mint', + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.instructions.length, 1) + }) + + it('should succeed with role: mintAndBurn (same as default)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedGrantMintBurnAccess(sender, { + ...validParams, + role: 'mintAndBurn', + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.instructions.length, 1) + }) + }) + + // =========================================================================== + // grantMintBurnAccess — Wallet Validation + // =========================================================================== + + describe('grantMintBurnAccess — Wallet Validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject string wallet', async () => { + await assert.rejects( + () => admin.grantMintBurnAccess('not-a-wallet', validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-pool-deploy.test.ts b/ccip-sdk/src/token-admin/solana/solana-pool-deploy.test.ts new file mode 100644 index 00000000..e454db72 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-pool-deploy.test.ts @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { type Connection, Keypair, PublicKey, SystemProgram } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { CCIPPoolDeployParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + const key = pubkey.toBase58() + // Return mint info for the token address (owned by SPL Token program) + if (key === tokenAddress) { + return { + owner: TOKEN_PROGRAM_ID, + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + } + } + return null + }, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const poolProgramId = Keypair.generate().publicKey.toBase58() + +// ============================================================================= +// SolanaTokenAdmin — deployPool +// ============================================================================= + +describe('SolanaTokenAdmin — deployPool', () => { + // =========================================================================== + // generateUnsignedDeployPool — Validation + // =========================================================================== + + describe('generateUnsignedDeployPool', () => { + const admin = makeAdmin() + + it('should reject invalid poolType', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + poolType: 'invalid' as 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.code, 'POOL_DEPLOY_PARAMS_INVALID') + assert.equal(err.context.param, 'poolType') + return true + }, + ) + }) + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress: '', + localTokenDecimals: 9, + poolProgramId, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty poolProgramId', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPPoolDeployParamsInvalidError) + assert.equal(err.context.param, 'poolProgramId') + return true + }, + ) + }) + + // ========================================================================= + // generateUnsignedDeployPool — Happy Path + // ========================================================================= + + it('should return UnsignedSolanaTx with correct family for burn-mint', async () => { + const { unsigned } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + // 2 instructions: pool initialize + create pool token ATA + assert.equal(unsigned.instructions.length, 2) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should return UnsignedSolanaTx with correct family for lock-release', async () => { + const { unsigned } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'lock-release', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + // 2 instructions: pool initialize + create pool token ATA + assert.equal(unsigned.instructions.length, 2) + }) + + it('should return poolAddress as state PDA', async () => { + const mint = new PublicKey(tokenAddress) + const program = new PublicKey(poolProgramId) + + const [expectedStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from('ccip_tokenpool_config'), mint.toBuffer()], + program, + ) + + const { poolAddress } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + assert.equal(poolAddress, expectedStatePda.toBase58()) + }) + + it('should build instruction with correct programId', async () => { + const { unsigned } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), poolProgramId) + }) + + it('should build instruction with 7 accounts', async () => { + const { unsigned } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 7) + }) + + it('should set authority as signer and writable', async () => { + const { unsigned } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + const ix = unsigned.instructions[0]! + const authorityKey = ix.keys[2]! + assert.equal(authorityKey.pubkey.toBase58(), sender) + assert.equal(authorityKey.isSigner, true) + assert.equal(authorityKey.isWritable, true) + }) + + it('should set state PDA as writable and not signer', async () => { + const { unsigned, poolAddress } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + const ix = unsigned.instructions[0]! + const stateKey = ix.keys[0]! + assert.equal(stateKey.pubkey.toBase58(), poolAddress) + assert.equal(stateKey.isSigner, false) + assert.equal(stateKey.isWritable, true) + }) + + it('should include SystemProgram as account', async () => { + const { unsigned } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + const ix = unsigned.instructions[0]! + const systemKey = ix.keys[3]! + assert.equal(systemKey.pubkey.toBase58(), SystemProgram.programId.toBase58()) + }) + + it('should have 8-byte discriminator as instruction data', async () => { + const { unsigned } = await admin.generateUnsignedDeployPool(sender, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.data.length, 8) + }) + }) + + // =========================================================================== + // deployPool — Wallet Validation + // =========================================================================== + + describe('deployPool', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => + admin.deployPool( + {}, + { poolType: 'burn-mint', tokenAddress, localTokenDecimals: 9, poolProgramId }, + ), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => + admin.deployPool(null, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => + admin.deployPool(undefined, { + poolType: 'burn-mint', + tokenAddress, + localTokenDecimals: 9, + poolProgramId, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-propose-admin-role.test.ts b/ccip-sdk/src/token-admin/solana/solana-propose-admin-role.test.ts new file mode 100644 index 00000000..ef6cf2d2 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-propose-admin-role.test.ts @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey, SystemProgram } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPProposeAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const administrator = Keypair.generate().publicKey.toBase58() +const routerAddress = Keypair.generate().publicKey.toBase58() + +// ============================================================================= +// SolanaTokenAdmin — proposeAdminRole +// ============================================================================= + +describe('SolanaTokenAdmin — proposeAdminRole', () => { + // =========================================================================== + // generateUnsignedProposeAdminRole — Validation + // =========================================================================== + + describe('generateUnsignedProposeAdminRole — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress: '', + administrator, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.code, 'PROPOSE_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty administrator', async () => { + await assert.rejects( + () => + admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator: '', + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'administrator') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPProposeAdminRoleParamsInvalidError) + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedProposeAdminRole — Happy Path + // =========================================================================== + + describe('generateUnsignedProposeAdminRole — happy path', () => { + const admin = makeAdmin() + + it('should return UnsignedSolanaTx with correct family', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should build instruction with correct programId (routerAddress)', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), routerAddress) + }) + + it('should build instruction with 5 accounts', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 5) + }) + + it('should have config PDA as first account (read-only)', async () => { + const routerPubkey = new PublicKey(routerAddress) + const [expectedConfig] = PublicKey.findProgramAddressSync( + [Buffer.from('config')], + routerPubkey, + ) + + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[0]!.pubkey.toBase58(), expectedConfig.toBase58()) + assert.equal(ix.keys[0]!.isSigner, false) + assert.equal(ix.keys[0]!.isWritable, false) + }) + + it('should have token admin registry PDA as second account (writable)', async () => { + const routerPubkey = new PublicKey(routerAddress) + const mint = new PublicKey(tokenAddress) + const [expectedTarPda] = PublicKey.findProgramAddressSync( + [Buffer.from('token_admin_registry'), mint.toBuffer()], + routerPubkey, + ) + + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[1]!.pubkey.toBase58(), expectedTarPda.toBase58()) + assert.equal(ix.keys[1]!.isSigner, false) + assert.equal(ix.keys[1]!.isWritable, true) + }) + + it('should have mint as third account (read-only)', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[2]!.pubkey.toBase58(), tokenAddress) + assert.equal(ix.keys[2]!.isSigner, false) + assert.equal(ix.keys[2]!.isWritable, false) + }) + + it('should have authority as fourth account (signer, writable)', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[3]!.pubkey.toBase58(), sender) + assert.equal(ix.keys[3]!.isSigner, true) + assert.equal(ix.keys[3]!.isWritable, true) + }) + + it('should include SystemProgram as fifth account', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[4]!.pubkey.toBase58(), SystemProgram.programId.toBase58()) + }) + + it('should have 8-byte discriminator + 32-byte pubkey as instruction data', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + // 8 bytes discriminator + 32 bytes administrator pubkey + assert.equal(ix.data.length, 40) + }) + + it('should encode administrator pubkey in instruction data', async () => { + const { unsigned } = await admin.generateUnsignedProposeAdminRole(sender, { + tokenAddress, + administrator, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + const adminPubkeyBytes = new PublicKey(administrator).toBuffer() + const dataAdminBytes = ix.data.subarray(8) // skip 8-byte discriminator + assert.deepEqual(Buffer.from(dataAdminBytes), adminPubkeyBytes) + }) + }) + + // =========================================================================== + // proposeAdminRole — Wallet Validation + // =========================================================================== + + describe('proposeAdminRole — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.proposeAdminRole({}, { tokenAddress, administrator, routerAddress }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => + admin.proposeAdminRole(null, { + tokenAddress, + administrator, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => + admin.proposeAdminRole(undefined, { + tokenAddress, + administrator, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-remove-remote-pool-addresses.test.ts b/ccip-sdk/src/token-admin/solana/solana-remove-remote-pool-addresses.test.ts new file mode 100644 index 00000000..f84c3011 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-remove-remote-pool-addresses.test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPRemoveRemotePoolAddressesParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { RemoveRemotePoolAddressesParams } from '../types.ts' + +// ── Constants ── + +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const mint = Keypair.generate().publicKey +const poolProgramId = Keypair.generate().publicKey +const remoteChainSelector = 16015286601757825753n + +// Derive pool state PDA +const [poolStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, +) + +const validParams: RemoveRemotePoolAddressesParams = { + poolAddress: poolStatePda.toBase58(), + remoteChainSelector, + remotePoolAddresses: ['0x1234567890abcdef1234567890abcdef12345678'], +} + +// ── Test Suite ── + +describe('SolanaTokenAdmin — removeRemotePoolAddresses', () => { + // ============================================================================= + // Validation (via wallet method — validation runs before RPC calls) + // ============================================================================= + + describe('removeRemotePoolAddresses — validation', () => { + const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + } as unknown as Connection + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + const mockWallet = { + publicKey: Keypair.generate().publicKey, + signTransaction: async (tx: unknown) => tx, + } + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.removeRemotePoolAddresses(mockWallet, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.code, 'REMOVE_REMOTE_POOL_ADDRESSES_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.removeRemotePoolAddresses(mockWallet, { + ...validParams, + remoteChainSelector: 0n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remoteChainSelector') + return true + }, + ) + }) + + it('should reject empty remotePoolAddresses array', async () => { + await assert.rejects( + () => + admin.removeRemotePoolAddresses(mockWallet, { + ...validParams, + remotePoolAddresses: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses') + return true + }, + ) + }) + + it('should reject empty address in array', async () => { + await assert.rejects( + () => + admin.removeRemotePoolAddresses(mockWallet, { + ...validParams, + remotePoolAddresses: [''], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPRemoveRemotePoolAddressesParamsInvalidError) + assert.equal(err.context.param, 'remotePoolAddresses[0]') + return true + }, + ) + }) + }) + + // ============================================================================= + // Wallet Validation + // ============================================================================= + + describe('removeRemotePoolAddresses — wallet validation', () => { + const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + } as unknown as Connection + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet', async () => { + await assert.rejects( + () => admin.removeRemotePoolAddresses({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.removeRemotePoolAddresses(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-revoke-mint-burn-access.test.ts b/ccip-sdk/src/token-admin/solana/solana-revoke-mint-burn-access.test.ts new file mode 100644 index 00000000..c7bf5018 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-revoke-mint-burn-access.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { CCIPRevokeMintBurnAccessParamsInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { RevokeMintBurnAccessParams } from '../types.ts' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, +} as unknown as Connection + +function makeAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const validParams: RevokeMintBurnAccessParams = { + tokenAddress: Keypair.generate().publicKey.toBase58(), + authority: Keypair.generate().publicKey.toBase58(), + role: 'mint', +} + +// ============================================================================= +// SolanaTokenAdmin — revokeMintBurnAccess +// ============================================================================= + +describe('SolanaTokenAdmin — revokeMintBurnAccess', () => { + it('should always throw — Solana does not support role-based revoke', async () => { + const admin = makeAdmin() + await assert.rejects( + () => admin.revokeMintBurnAccess({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.equal(err.code, 'REVOKE_MINT_BURN_ACCESS_PARAMS_INVALID') + assert.equal(err.context.param, 'chain') + assert.ok(err.message.includes('transferMintAuthority')) + return true + }, + ) + }) + + it('should throw for role: burn as well', async () => { + const admin = makeAdmin() + await assert.rejects( + () => admin.revokeMintBurnAccess({}, { ...validParams, role: 'burn' }), + (err: unknown) => { + assert.ok(err instanceof CCIPRevokeMintBurnAccessParamsInvalidError) + assert.ok(err.message.includes('transferMintAuthority')) + return true + }, + ) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-set-pool.test.ts b/ccip-sdk/src/token-admin/solana/solana-set-pool.test.ts new file mode 100644 index 00000000..c6a82fd9 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-set-pool.test.ts @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { CCIPSetPoolParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +function mockConnection() { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + getSlot: async () => 12345, + } as unknown as Connection +} + +function makeAdmin(connection?: Connection): SolanaTokenAdmin { + return new SolanaTokenAdmin(connection ?? mockConnection(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const poolAddress = Keypair.generate().publicKey.toBase58() +const routerAddress = Keypair.generate().publicKey.toBase58() +const poolLookupTable = Keypair.generate().publicKey.toBase58() + +const validParams = { + tokenAddress, + poolAddress, + routerAddress, + poolLookupTable, +} + +// ============================================================================= +// SolanaTokenAdmin — setPool +// ============================================================================= + +describe('SolanaTokenAdmin — setPool', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedSetPool — Validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + tokenAddress: '', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + + it('should reject invalid tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + tokenAddress: 'not-a-pubkey', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + poolAddress: '', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + + it('should reject invalid poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + poolAddress: 'not-a-pubkey', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + routerAddress: '', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + + it('should reject invalid routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + routerAddress: 'not-a-pubkey', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + + it('should reject empty poolLookupTable', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + poolLookupTable: '', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + + it('should reject invalid poolLookupTable', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetPool(sender, { + ...validParams, + poolLookupTable: 'not-a-pubkey', + }), + CCIPSetPoolParamsInvalidError, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedSetPool — Happy Path', () => { + it('should return correct family (Solana)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetPool(sender, validParams) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 1 instruction with mainIndex 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetPool(sender, validParams) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should have 5 accounts in correct order', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetPool(sender, validParams) + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 5) + + const routerProgramId = new PublicKey(routerAddress) + const mint = new PublicKey(tokenAddress) + + // Account 0: config PDA (read-only, not signer) + const [expectedConfig] = PublicKey.findProgramAddressSync( + [Buffer.from('config')], + routerProgramId, + ) + assert.equal(ix.keys[0]!.pubkey.toBase58(), expectedConfig.toBase58()) + assert.equal(ix.keys[0]!.isWritable, false) + assert.equal(ix.keys[0]!.isSigner, false) + + // Account 1: TAR PDA (writable, not signer) + const [expectedTar] = PublicKey.findProgramAddressSync( + [Buffer.from('token_admin_registry'), mint.toBuffer()], + routerProgramId, + ) + assert.equal(ix.keys[1]!.pubkey.toBase58(), expectedTar.toBase58()) + assert.equal(ix.keys[1]!.isWritable, true) + assert.equal(ix.keys[1]!.isSigner, false) + + // Account 2: mint (read-only) + assert.equal(ix.keys[2]!.pubkey.toBase58(), tokenAddress) + assert.equal(ix.keys[2]!.isWritable, false) + + // Account 3: poolLookuptable (read-only) + assert.equal(ix.keys[3]!.pubkey.toBase58(), poolLookupTable) + assert.equal(ix.keys[3]!.isWritable, false) + + // Account 4: authority (writable, signer) + assert.equal(ix.keys[4]!.pubkey.toBase58(), sender) + assert.equal(ix.keys[4]!.isWritable, true) + assert.equal(ix.keys[4]!.isSigner, true) + }) + + it('should have instruction data containing writable indexes [3, 4, 7]', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetPool(sender, validParams) + const ix = unsigned.instructions[0]! + // The instruction data should contain the bytes [3, 4, 7] as the writableIndexes argument + const data = Buffer.from(ix.data) + // Anchor uses an 8-byte discriminator + borsh-encoded args + // For bytes type, Borsh encodes as u32 length prefix + raw bytes + // Skip 8-byte discriminator, then read u32 length (3), then bytes [3, 4, 7] + const lengthOffset = 8 + const length = data.readUInt32LE(lengthOffset) + assert.equal(length, 3, 'writableIndexes should have 3 entries') + assert.equal(data[lengthOffset + 4], 3) + assert.equal(data[lengthOffset + 5], 4) + assert.equal(data[lengthOffset + 6], 7) + }) + }) + + // =========================================================================== + // Wallet Validation + // =========================================================================== + + describe('setPool — Wallet Validation', () => { + it('should reject non-wallet object', async () => { + const admin = makeAdmin() + await assert.rejects(() => admin.setPool({}, validParams), CCIPWalletInvalidError) + }) + + it('should reject null wallet', async () => { + const admin = makeAdmin() + await assert.rejects(() => admin.setPool(null, validParams), CCIPWalletInvalidError) + }) + + it('should reject string wallet', async () => { + const admin = makeAdmin() + await assert.rejects(() => admin.setPool('not-a-wallet', validParams), CCIPWalletInvalidError) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-set-rate-limit-admin.test.ts b/ccip-sdk/src/token-admin/solana/solana-set-rate-limit-admin.test.ts new file mode 100644 index 00000000..36754588 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-set-rate-limit-admin.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPSetRateLimitAdminParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { SetRateLimitAdminParams } from '../types.ts' + +// ── Constants ── + +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey +const poolProgramId = Keypair.generate().publicKey + +// Derive pool state PDA +const [poolStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, +) + +function createMockConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: Buffer.alloc(0), + lamports: 0, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection +} + +const validParams: SetRateLimitAdminParams = { + poolAddress: poolStatePda.toBase58(), + rateLimitAdmin: Keypair.generate().publicKey.toBase58(), +} + +describe('SolanaTokenAdmin — setRateLimitAdmin', () => { + // =========================================================================== + // generateUnsignedSetRateLimitAdmin — Validation + // =========================================================================== + + describe('generateUnsignedSetRateLimitAdmin — validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetRateLimitAdmin(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimitAdminParamsInvalidError) + assert.equal(err.code, 'SET_RATE_LIMIT_ADMIN_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty rateLimitAdmin', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetRateLimitAdmin(sender, { + ...validParams, + rateLimitAdmin: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimitAdminParamsInvalidError) + assert.equal(err.context.param, 'rateLimitAdmin') + return true + }, + ) + }) + }) + + // =========================================================================== + // setRateLimitAdmin — Wallet Validation + // =========================================================================== + + // =========================================================================== + // generateUnsignedSetRateLimitAdmin — Happy Path + // =========================================================================== + + describe('generateUnsignedSetRateLimitAdmin — Happy Path', () => { + function makeAdmin(): SolanaTokenAdmin { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + // Override getTokenForTokenPool to bypass BorshCoder decode of on-chain data + admin.getTokenForTokenPool = async () => mint.toBase58() + return admin + } + + it('should return correct family (Solana)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetRateLimitAdmin(sender, validParams) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 1 instruction with mainIndex 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetRateLimitAdmin(sender, validParams) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should set programId to the pool program', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetRateLimitAdmin(sender, validParams) + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), poolProgramId.toBase58()) + }) + + it('should include authority (sender) as a signer', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetRateLimitAdmin(sender, validParams) + const ix = unsigned.instructions[0]! + const senderKey = new PublicKey(sender) + const authorityAccount = ix.keys.find((k) => k.pubkey.equals(senderKey)) + assert.ok(authorityAccount, 'authority account should be present') + assert.equal(authorityAccount.isSigner, true) + }) + + it('should include the state PDA as an account', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetRateLimitAdmin(sender, validParams) + const ix = unsigned.instructions[0]! + const stateAccount = ix.keys.find((k) => k.pubkey.equals(poolStatePda)) + assert.ok(stateAccount, 'state PDA should be present in accounts') + }) + + it('should return poolAddress matching the state PDA', async () => { + const admin = makeAdmin() + const { poolAddress } = await admin.generateUnsignedSetRateLimitAdmin(sender, validParams) + assert.equal(poolAddress, poolStatePda.toBase58()) + }) + }) + + // =========================================================================== + // setRateLimitAdmin — Wallet Validation + // =========================================================================== + + describe('setRateLimitAdmin — wallet validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.setRateLimitAdmin({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.setRateLimitAdmin(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-set-rate-limiter-config.test.ts b/ccip-sdk/src/token-admin/solana/solana-set-rate-limiter-config.test.ts new file mode 100644 index 00000000..898eabbb --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-set-rate-limiter-config.test.ts @@ -0,0 +1,340 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' +import { sha256, toUtf8Bytes } from 'ethers' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPSetRateLimiterConfigParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { SetChainRateLimiterConfigParams } from '../types.ts' + +// ── Constants ── + +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey +const poolProgramId = Keypair.generate().publicKey +const remoteChainSelector = 16015286601757825753n + +// Derive pool state PDA +const [poolStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, +) + +/** + * Creates a mock connection that simulates pool state discovery. + */ +function createMockConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: Buffer.alloc(0), + lamports: 0, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection +} + +const validParams: SetChainRateLimiterConfigParams = { + poolAddress: poolStatePda.toBase58(), + chainConfigs: [ + { + remoteChainSelector, + outboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + inboundRateLimiterConfig: { + isEnabled: true, + capacity: '100000000000000000000000', + rate: '167000000000000000000', + }, + }, + ], +} + +describe('SolanaTokenAdmin — setChainRateLimiterConfig', () => { + // =========================================================================== + // generateUnsignedSetChainRateLimiterConfig — Validation + // =========================================================================== + + describe('generateUnsignedSetChainRateLimiterConfig — validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.code, 'SET_RATE_LIMITER_CONFIG_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty chainConfigs', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + chainConfigs: [], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs') + return true + }, + ) + }) + + it('should reject empty remoteChainSelector', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + chainConfigs: [{ ...validParams.chainConfigs[0]!, remoteChainSelector: 0n }], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs[0].remoteChainSelector') + return true + }, + ) + }) + + it('should reject invalid capacity string', async () => { + await assert.rejects( + () => + admin.generateUnsignedSetChainRateLimiterConfig(sender, { + ...validParams, + chainConfigs: [ + { + ...validParams.chainConfigs[0]!, + outboundRateLimiterConfig: { isEnabled: true, capacity: 'not-a-number', rate: '0' }, + }, + ], + }), + (err: unknown) => { + assert.ok(err instanceof CCIPSetRateLimiterConfigParamsInvalidError) + assert.equal(err.context.param, 'chainConfigs[0].outboundRateLimiterConfig.capacity') + return true + }, + ) + }) + }) + + // =========================================================================== + // setChainRateLimiterConfig — Wallet Validation + // =========================================================================== + + describe('setChainRateLimiterConfig — wallet validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.setChainRateLimiterConfig({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.setChainRateLimiterConfig(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedSetChainRateLimiterConfig — Happy Path + // =========================================================================== + + describe('generateUnsignedSetChainRateLimiterConfig — Happy Path', () => { + // Use u64-safe values (capacity and rate must fit in 8 bytes) + const happyPathParams: SetChainRateLimiterConfigParams = { + poolAddress: poolStatePda.toBase58(), + chainConfigs: [ + { + remoteChainSelector, + outboundRateLimiterConfig: { + isEnabled: true, + capacity: '1000000000000', + rate: '167000000000', + }, + inboundRateLimiterConfig: { + isEnabled: true, + capacity: '1000000000000', + rate: '167000000000', + }, + }, + ], + } + + function makeAdmin(): SolanaTokenAdmin { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + // Stub getTokenForTokenPool so discoverPoolInfo can resolve the mint + // without needing real Borsh-encoded account data + ;(admin as any).getTokenForTokenPool = async () => mint.toBase58() + return admin + } + + it('should return correct family (Solana)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetChainRateLimiterConfig( + sender, + happyPathParams, + ) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 1 instruction per chainConfig with mainIndex 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetChainRateLimiterConfig( + sender, + happyPathParams, + ) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should return N instructions for N chainConfigs', async () => { + const admin = makeAdmin() + const secondChainSelector = 3734025716853652079n + const multiParams: SetChainRateLimiterConfigParams = { + ...happyPathParams, + chainConfigs: [ + happyPathParams.chainConfigs[0]!, + { + remoteChainSelector: secondChainSelector, + outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + }, + ], + } + const { unsigned } = await admin.generateUnsignedSetChainRateLimiterConfig( + sender, + multiParams, + ) + assert.equal(unsigned.instructions.length, 2) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should set instruction programId to the pool program', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetChainRateLimiterConfig( + sender, + happyPathParams, + ) + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), poolProgramId.toBase58()) + }) + + it('should include authority (sender) as a signer', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetChainRateLimiterConfig( + sender, + happyPathParams, + ) + const ix = unsigned.instructions[0]! + const senderPubkey = new PublicKey(sender) + const authorityAccount = ix.keys.find((k: any) => k.pubkey.equals(senderPubkey)) + assert.ok(authorityAccount, 'authority account should be present in instruction keys') + assert.equal(authorityAccount.isSigner, true, 'authority should be a signer') + }) + + it('should include state PDA and chain config PDA as accounts', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedSetChainRateLimiterConfig( + sender, + happyPathParams, + ) + const ix = unsigned.instructions[0]! + + // state PDA should be derived from CCIP_TOKENPOOL_CONFIG_SEED + mint + const [expectedStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, + ) + const stateAccount = ix.keys.find((k: any) => k.pubkey.equals(expectedStatePda)) + assert.ok(stateAccount, 'state PDA should be present in instruction keys') + + // chain config PDA should be derived from CCIP_TOKENPOOL_CHAINCONFIG_SEED + chainSelector + mint + const chainSelectorBuf = Buffer.alloc(8) + chainSelectorBuf.writeBigUInt64LE(BigInt(remoteChainSelector)) + const [expectedChainConfigPda] = PublicKey.findProgramAddressSync( + [Buffer.from('ccip_tokenpool_chainconfig'), chainSelectorBuf, mint.toBuffer()], + poolProgramId, + ) + const chainConfigAccount = ix.keys.find((k: any) => k.pubkey.equals(expectedChainConfigPda)) + assert.ok(chainConfigAccount, 'chain config PDA should be present in instruction keys') + }) + }) + + // =========================================================================== + // Instruction data layout verification + // =========================================================================== + + describe('instruction data layout', () => { + it('setChainRateLimit discriminator should be 8 bytes from SHA256', () => { + const hash = sha256(toUtf8Bytes('global:set_chain_rate_limit')) + const expected = Buffer.from(hash.slice(2, 18), 'hex') + assert.equal(expected.length, 8) + }) + + it('instruction data should be exactly 82 bytes (8+8+32+2*(1+8+8))', () => { + // discriminator(8) + chainSelector(8) + mint(32) + 2 * (enabled(1) + capacity(8) + rate(8)) + const expectedSize = 8 + 8 + 32 + 2 * (1 + 8 + 8) + assert.equal(expectedSize, 82) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-token-admin.test.ts b/ccip-sdk/src/token-admin/solana/solana-token-admin.test.ts new file mode 100644 index 00000000..3b4db206 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-token-admin.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import { describe, it, mock } from 'node:test' + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, getMintLen } from '@solana/spl-token' +import { type Connection, Keypair, PublicKey, SystemProgram } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { CCIPTokenDeployParamsInvalidError, CCIPWalletInvalidError } from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockGetMinimumBalanceForRentExemption = mock.fn(async (_size: number) => 1_461_600) + +const mockConnection = { + getMinimumBalanceForRentExemption: mockGetMinimumBalanceForRentExemption, + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +// ============================================================================= +// SolanaTokenAdmin — Construction +// ============================================================================= + +describe('SolanaTokenAdmin', () => { + describe('constructor', () => { + it('should create instance with connection', () => { + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { apiClient: null }) + assert.equal(admin.connection, mockConnection) + }) + }) + + // =========================================================================== + // generateUnsignedDeployToken — Validation + // =========================================================================== + + describe('generateUnsignedDeployToken', () => { + const admin = makeAdmin() + const sender = Keypair.generate().publicKey.toBase58() + + it('should reject empty name', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: '', + symbol: 'MTK', + decimals: 9, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.code, 'TOKEN_DEPLOY_PARAMS_INVALID') + assert.equal(err.context.param, 'name') + return true + }, + ) + }) + + it('should reject empty symbol', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: '', + decimals: 9, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'symbol') + return true + }, + ) + }) + + it('should reject negative initialSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: 'MTK', + decimals: 9, + initialSupply: -1n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'initialSupply') + return true + }, + ) + }) + + it('should reject negative maxSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: 'MTK', + decimals: 9, + maxSupply: -1n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'maxSupply') + return true + }, + ) + }) + + it('should reject initialSupply > maxSupply', async () => { + await assert.rejects( + () => + admin.generateUnsignedDeployToken(sender, { + name: 'Token', + symbol: 'MTK', + decimals: 9, + maxSupply: 100n, + initialSupply: 200n, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTokenDeployParamsInvalidError) + assert.equal(err.context.param, 'initialSupply') + return true + }, + ) + }) + + // ========================================================================= + // generateUnsignedDeployToken — Happy Path + // ========================================================================= + + it('should return UnsignedSolanaTx with correct family', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return a mintKeypair', async () => { + const { mintKeypair } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + }) + + assert.ok(mintKeypair instanceof Keypair) + assert.ok(mintKeypair.publicKey instanceof PublicKey) + }) + + it('should include createAccount + initializeMint + metadata instructions (no supply)', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + }) + + // 3 instructions: createAccount, initializeMint2, createMetadata + assert.equal(unsigned.instructions.length, 3) + }) + + it('should include ATA + mintTo instructions when initialSupply > 0', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + initialSupply: 1_000_000n, + }) + + // 5 instructions: createAccount, initializeMint2, createMetadata, createATA, mintTo + assert.equal(unsigned.instructions.length, 5) + }) + + it('should use TOKEN_PROGRAM_ID by default', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + }) + + // The createAccount instruction's programId (last param) should be TOKEN_PROGRAM_ID + const createAccountIx = unsigned.instructions[0]! + assert.equal(createAccountIx.programId.toBase58(), SystemProgram.programId.toBase58()) + // Check the keys — the newAccountPubkey should use TOKEN_PROGRAM_ID as the owner + const createAccountData = createAccountIx.data + // The space should match getMintLen([]) + assert.ok(createAccountData.length > 0) + }) + + it('should use TOKEN_2022_PROGRAM_ID when tokenProgram is token-2022', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + tokenProgram: 'token-2022', + }) + + // The initializeMint2 instruction should use TOKEN_2022_PROGRAM_ID + const initMintIx = unsigned.instructions[1]! + assert.equal(initMintIx.programId.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()) + }) + + it('should use TOKEN_PROGRAM_ID for spl-token', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + tokenProgram: 'spl-token', + }) + + const initMintIx = unsigned.instructions[1]! + assert.equal(initMintIx.programId.toBase58(), TOKEN_PROGRAM_ID.toBase58()) + }) + + it('should accept decimals: 0', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'Zero Dec Token', + symbol: 'ZDT', + decimals: 0, + }) + + assert.equal(unsigned.instructions.length, 3) + }) + + it('should set mainIndex to 0', async () => { + const { unsigned } = await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + }) + + assert.equal(unsigned.mainIndex, 0) + }) + + it('should call getMinimumBalanceForRentExemption with correct mint length', async () => { + await admin.generateUnsignedDeployToken(sender, { + name: 'My Token', + symbol: 'MTK', + decimals: 9, + }) + + const expectedLen = getMintLen([]) + const calls = mockGetMinimumBalanceForRentExemption.mock.calls + const lastCall = calls[calls.length - 1]! + assert.equal(lastCall.arguments[0], expectedLen) + }) + }) + + // =========================================================================== + // deployToken — Wallet Validation + // =========================================================================== + + describe('deployToken', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.deployToken({}, { name: 'Token', symbol: 'MTK', decimals: 9 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.deployToken(null, { name: 'Token', symbol: 'MTK', decimals: 9 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => admin.deployToken(undefined, { name: 'Token', symbol: 'MTK', decimals: 9 }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-transfer-admin-role.test.ts b/ccip-sdk/src/token-admin/solana/solana-transfer-admin-role.test.ts new file mode 100644 index 00000000..fe728a38 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-transfer-admin-role.test.ts @@ -0,0 +1,290 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPTransferAdminRoleParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +const mockConnection = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(): SolanaTokenAdmin { + return new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const tokenAddress = Keypair.generate().publicKey.toBase58() +const newAdmin = Keypair.generate().publicKey.toBase58() +const routerAddress = Keypair.generate().publicKey.toBase58() + +// ============================================================================= +// SolanaTokenAdmin — transferAdminRole +// ============================================================================= + +describe('SolanaTokenAdmin — transferAdminRole', () => { + // =========================================================================== + // generateUnsignedTransferAdminRole — Validation + // =========================================================================== + + describe('generateUnsignedTransferAdminRole — validation', () => { + const admin = makeAdmin() + + it('should reject empty tokenAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress: '', + newAdmin, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'tokenAddress') + return true + }, + ) + }) + + it('should reject empty newAdmin', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin: '', + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'newAdmin') + return true + }, + ) + }) + + it('should reject empty routerAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferAdminRoleParamsInvalidError) + assert.equal(err.code, 'TRANSFER_ADMIN_ROLE_PARAMS_INVALID') + assert.equal(err.context.param, 'routerAddress') + return true + }, + ) + }) + }) + + // =========================================================================== + // generateUnsignedTransferAdminRole — Happy Path + // =========================================================================== + + describe('generateUnsignedTransferAdminRole — happy path', () => { + const admin = makeAdmin() + + it('should return UnsignedSolanaTx with correct family', async () => { + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + assert.equal(unsigned.family, ChainFamily.Solana) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should build instruction with correct programId (routerAddress)', async () => { + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), routerAddress) + }) + + it('should build instruction with 4 accounts', async () => { + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 4) + }) + + it('should have config PDA as first account (read-only)', async () => { + const routerPubkey = new PublicKey(routerAddress) + const [expectedConfig] = PublicKey.findProgramAddressSync( + [Buffer.from('config')], + routerPubkey, + ) + + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[0]!.pubkey.toBase58(), expectedConfig.toBase58()) + assert.equal(ix.keys[0]!.isSigner, false) + assert.equal(ix.keys[0]!.isWritable, false) + }) + + it('should have token admin registry PDA as second account (writable)', async () => { + const routerPubkey = new PublicKey(routerAddress) + const mint = new PublicKey(tokenAddress) + const [expectedTarPda] = PublicKey.findProgramAddressSync( + [Buffer.from('token_admin_registry'), mint.toBuffer()], + routerPubkey, + ) + + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[1]!.pubkey.toBase58(), expectedTarPda.toBase58()) + assert.equal(ix.keys[1]!.isSigner, false) + assert.equal(ix.keys[1]!.isWritable, true) + }) + + it('should have mint as third account (read-only)', async () => { + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[2]!.pubkey.toBase58(), tokenAddress) + assert.equal(ix.keys[2]!.isSigner, false) + assert.equal(ix.keys[2]!.isWritable, false) + }) + + it('should have authority as fourth account (signer, writable)', async () => { + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[3]!.pubkey.toBase58(), sender) + assert.equal(ix.keys[3]!.isSigner, true) + assert.equal(ix.keys[3]!.isWritable, true) + }) + + it('should have 8-byte discriminator + 32-byte pubkey as instruction data', async () => { + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + // 8 bytes discriminator + 32 bytes for newAdmin pubkey + assert.equal(ix.data.length, 40) + }) + + it('should encode newAdmin pubkey in instruction data', async () => { + const { unsigned } = await admin.generateUnsignedTransferAdminRole(sender, { + tokenAddress, + newAdmin, + routerAddress, + }) + + const ix = unsigned.instructions[0]! + const newAdminPubkeyBytes = new PublicKey(newAdmin).toBuffer() + const dataAdminBytes = ix.data.subarray(8) // skip 8-byte discriminator + assert.deepEqual(Buffer.from(dataAdminBytes), newAdminPubkeyBytes) + }) + }) + + // =========================================================================== + // transferAdminRole — Wallet Validation + // =========================================================================== + + describe('transferAdminRole — wallet validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.transferAdminRole({}, { tokenAddress, newAdmin, routerAddress }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => + admin.transferAdminRole(null, { + tokenAddress, + newAdmin, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject undefined wallet', async () => { + await assert.rejects( + () => + admin.transferAdminRole(undefined, { + tokenAddress, + newAdmin, + routerAddress, + }), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-transfer-mint-authority.test.ts b/ccip-sdk/src/token-admin/solana/solana-transfer-mint-authority.test.ts new file mode 100644 index 00000000..3615f7dd --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-transfer-mint-authority.test.ts @@ -0,0 +1,282 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPTransferMintAuthorityParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' + +// ── Mocks ── + +/** Mock connection that returns a valid SPL Token mint for getAccountInfo. */ +function mockConnectionWithMint(tokenProgramId: PublicKey = TOKEN_PROGRAM_ID) { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => ({ + owner: tokenProgramId, + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + }), + getMinimumBalanceForRentExemption: async () => 2_039_280, + } as unknown as Connection +} + +/** Mock connection where mint does not exist. */ +const mockConnectionNoMint = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => null, + getMinimumBalanceForRentExemption: async () => 2_039_280, +} as unknown as Connection + +/** Mock connection where mint is owned by an unknown program. */ +const mockConnectionBadMint = { + getSignaturesForAddress: async () => [], + getAccountInfo: async () => ({ + owner: new PublicKey('11111111111111111111111111111111'), + data: Buffer.alloc(82), + executable: false, + lamports: 1_000_000, + }), + getMinimumBalanceForRentExemption: async () => 2_039_280, +} as unknown as Connection + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +// ── Helpers ── + +function makeAdmin(connection?: Connection): SolanaTokenAdmin { + return new SolanaTokenAdmin(connection ?? mockConnectionWithMint(), dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey.toBase58() +const newMintAuthority = Keypair.generate().publicKey.toBase58() + +const validParams = { + mint, + newMintAuthority, +} + +// ============================================================================= +// SolanaTokenAdmin — transferMintAuthority +// ============================================================================= + +describe('SolanaTokenAdmin — transferMintAuthority', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedTransferMintAuthority — Validation', () => { + const admin = makeAdmin() + + it('should reject empty mint', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferMintAuthority(sender, { + ...validParams, + mint: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferMintAuthorityParamsInvalidError) + assert.equal(err.code, 'TRANSFER_MINT_AUTHORITY_PARAMS_INVALID') + assert.equal(err.context.param, 'mint') + return true + }, + ) + }) + + it('should reject empty newMintAuthority', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferMintAuthority(sender, { + ...validParams, + newMintAuthority: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferMintAuthorityParamsInvalidError) + assert.equal(err.context.param, 'newMintAuthority') + return true + }, + ) + }) + + it('should reject invalid mint public key', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferMintAuthority(sender, { + ...validParams, + mint: 'not-a-valid-pubkey!!!', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferMintAuthorityParamsInvalidError) + assert.equal(err.context.param, 'mint') + return true + }, + ) + }) + + it('should reject invalid newMintAuthority public key', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferMintAuthority(sender, { + ...validParams, + newMintAuthority: 'not-a-valid-pubkey!!!', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferMintAuthorityParamsInvalidError) + assert.equal(err.context.param, 'newMintAuthority') + return true + }, + ) + }) + + it('should reject when mint account not found on-chain', async () => { + const admin = makeAdmin(mockConnectionNoMint) + await assert.rejects( + () => admin.generateUnsignedTransferMintAuthority(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferMintAuthorityParamsInvalidError) + assert.equal(err.context.param, 'mint') + assert.ok(err.message.includes('not found')) + return true + }, + ) + }) + + it('should reject when mint owned by unknown program', async () => { + const admin = makeAdmin(mockConnectionBadMint) + await assert.rejects( + () => admin.generateUnsignedTransferMintAuthority(sender, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferMintAuthorityParamsInvalidError) + assert.equal(err.context.param, 'mint') + assert.ok(err.message.includes('expected SPL Token or Token-2022')) + return true + }, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedTransferMintAuthority — Happy Path', () => { + it('should return UnsignedSolanaTx with correct family', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 1 instruction', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + assert.equal(unsigned.instructions.length, 1) + }) + + it('should have mainIndex = 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + assert.equal(unsigned.mainIndex, 0) + }) + + it('should use SPL Token program for instruction', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), TOKEN_PROGRAM_ID.toBase58()) + }) + + it('should auto-detect Token-2022 program', async () => { + const admin = makeAdmin(mockConnectionWithMint(TOKEN_2022_PROGRAM_ID)) + const { unsigned } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), TOKEN_2022_PROGRAM_ID.toBase58()) + }) + + it('should include mint as first key in instruction', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[0]!.pubkey.toBase58(), mint) + assert.ok(ix.keys[0]!.isWritable) + }) + + it('should include sender as second key (current authority)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + const ix = unsigned.instructions[0]! + assert.equal(ix.keys[1]!.pubkey.toBase58(), sender) + assert.ok(ix.keys[1]!.isSigner) + }) + + it('should return empty txHash in unsigned result', async () => { + const admin = makeAdmin() + const { result } = await admin.generateUnsignedTransferMintAuthority(sender, validParams) + + assert.equal(result.txHash, '') + }) + }) + + // =========================================================================== + // transferMintAuthority — Wallet Validation + // =========================================================================== + + describe('transferMintAuthority — Wallet Validation', () => { + const admin = makeAdmin() + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.transferMintAuthority({}, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.transferMintAuthority(null, validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + + it('should reject string wallet', async () => { + await assert.rejects( + () => admin.transferMintAuthority('not-a-wallet', validParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/solana/solana-transfer-ownership.test.ts b/ccip-sdk/src/token-admin/solana/solana-transfer-ownership.test.ts new file mode 100644 index 00000000..17ad20c8 --- /dev/null +++ b/ccip-sdk/src/token-admin/solana/solana-transfer-ownership.test.ts @@ -0,0 +1,354 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { type Connection, Keypair, PublicKey } from '@solana/web3.js' + +import { SolanaTokenAdmin } from './index.ts' +import { + CCIPAcceptOwnershipParamsInvalidError, + CCIPTransferOwnershipParamsInvalidError, + CCIPWalletInvalidError, +} from '../../errors/index.ts' +import { type NetworkInfo, ChainFamily, NetworkType } from '../../types.ts' +import type { AcceptOwnershipParams, TransferOwnershipParams } from '../types.ts' + +// ── Constants ── + +const CCIP_TOKENPOOL_CONFIG_SEED = 'ccip_tokenpool_config' + +// ── Mocks ── + +const silentLogger = { debug() {}, info() {}, warn() {}, error() {} } + +const dummyNetwork: NetworkInfo = { + name: 'solana-devnet', + family: ChainFamily.Solana, + chainSelector: 1n, + chainId: 'solana-devnet', + networkType: NetworkType.Testnet, +} + +const sender = Keypair.generate().publicKey.toBase58() +const mint = Keypair.generate().publicKey +const poolProgramId = Keypair.generate().publicKey +const newOwner = Keypair.generate().publicKey.toBase58() + +// Derive pool state PDA +const [poolStatePda] = PublicKey.findProgramAddressSync( + [Buffer.from(CCIP_TOKENPOOL_CONFIG_SEED), mint.toBuffer()], + poolProgramId, +) + +function createMockConnection(): Connection { + return { + getSignaturesForAddress: async () => [], + getAccountInfo: async (pubkey: PublicKey) => { + if (pubkey.equals(poolStatePda)) { + return { + owner: poolProgramId, + data: Buffer.alloc(0), + lamports: 0, + executable: false, + rentEpoch: 0, + } + } + return null + }, + } as unknown as Connection +} + +const validTransferParams: TransferOwnershipParams = { + poolAddress: poolStatePda.toBase58(), + newOwner, +} + +const validAcceptParams: AcceptOwnershipParams = { + poolAddress: poolStatePda.toBase58(), +} + +// ============================================================================= +// SolanaTokenAdmin — transferOwnership +// ============================================================================= + +describe('SolanaTokenAdmin — transferOwnership', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedTransferOwnership — validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferOwnership(sender, { + ...validTransferParams, + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferOwnershipParamsInvalidError) + assert.equal(err.code, 'TRANSFER_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + + it('should reject empty newOwner', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferOwnership(sender, { + ...validTransferParams, + newOwner: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferOwnershipParamsInvalidError) + assert.equal(err.context.param, 'newOwner') + return true + }, + ) + }) + + it('should reject invalid newOwner pubkey', async () => { + await assert.rejects( + () => + admin.generateUnsignedTransferOwnership(sender, { + ...validTransferParams, + newOwner: 'not-a-pubkey', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPTransferOwnershipParamsInvalidError) + assert.equal(err.context.param, 'newOwner') + return true + }, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedTransferOwnership — Happy Path', () => { + function makeAdmin(): SolanaTokenAdmin { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + admin.getTokenForTokenPool = async () => mint.toBase58() + return admin + } + + it('should return correct family (Solana)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferOwnership( + sender, + validTransferParams, + ) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 1 instruction with mainIndex 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferOwnership( + sender, + validTransferParams, + ) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should have 3 accounts (state, mint, authority)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferOwnership( + sender, + validTransferParams, + ) + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 3) + + // Account 0: state PDA (writable, not signer) + assert.equal(ix.keys[0]!.pubkey.toBase58(), poolStatePda.toBase58()) + assert.equal(ix.keys[0]!.isWritable, true) + assert.equal(ix.keys[0]!.isSigner, false) + + // Account 1: mint (read-only, not signer) + assert.equal(ix.keys[1]!.pubkey.toBase58(), mint.toBase58()) + assert.equal(ix.keys[1]!.isWritable, false) + assert.equal(ix.keys[1]!.isSigner, false) + + // Account 2: authority (signer) + assert.equal(ix.keys[2]!.pubkey.toBase58(), sender) + assert.equal(ix.keys[2]!.isSigner, true) + }) + + it('should set programId to the pool program', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedTransferOwnership( + sender, + validTransferParams, + ) + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), poolProgramId.toBase58()) + }) + }) + + // =========================================================================== + // Wallet Validation + // =========================================================================== + + describe('transferOwnership — wallet validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.transferOwnership({}, validTransferParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.transferOwnership(null, validTransferParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) + +// ============================================================================= +// SolanaTokenAdmin — acceptOwnership +// ============================================================================= + +describe('SolanaTokenAdmin — acceptOwnership', () => { + // =========================================================================== + // Validation + // =========================================================================== + + describe('generateUnsignedAcceptOwnership — validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject empty poolAddress', async () => { + await assert.rejects( + () => + admin.generateUnsignedAcceptOwnership(sender, { + poolAddress: '', + }), + (err: unknown) => { + assert.ok(err instanceof CCIPAcceptOwnershipParamsInvalidError) + assert.equal(err.code, 'ACCEPT_OWNERSHIP_PARAMS_INVALID') + assert.equal(err.context.param, 'poolAddress') + return true + }, + ) + }) + }) + + // =========================================================================== + // Happy Path + // =========================================================================== + + describe('generateUnsignedAcceptOwnership — Happy Path', () => { + function makeAdmin(): SolanaTokenAdmin { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + admin.getTokenForTokenPool = async () => mint.toBase58() + return admin + } + + it('should return correct family (Solana)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedAcceptOwnership(sender, validAcceptParams) + assert.equal(unsigned.family, ChainFamily.Solana) + }) + + it('should return 1 instruction with mainIndex 0', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedAcceptOwnership(sender, validAcceptParams) + assert.equal(unsigned.instructions.length, 1) + assert.equal(unsigned.mainIndex, 0) + }) + + it('should have 3 accounts (state, mint, authority)', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedAcceptOwnership(sender, validAcceptParams) + const ix = unsigned.instructions[0]! + assert.equal(ix.keys.length, 3) + + // Account 0: state PDA (writable, not signer) + assert.equal(ix.keys[0]!.pubkey.toBase58(), poolStatePda.toBase58()) + assert.equal(ix.keys[0]!.isWritable, true) + + // Account 1: mint (read-only, not signer) + assert.equal(ix.keys[1]!.pubkey.toBase58(), mint.toBase58()) + assert.equal(ix.keys[1]!.isWritable, false) + + // Account 2: authority (signer) + assert.equal(ix.keys[2]!.pubkey.toBase58(), sender) + assert.equal(ix.keys[2]!.isSigner, true) + }) + + it('should set programId to the pool program', async () => { + const admin = makeAdmin() + const { unsigned } = await admin.generateUnsignedAcceptOwnership(sender, validAcceptParams) + const ix = unsigned.instructions[0]! + assert.equal(ix.programId.toBase58(), poolProgramId.toBase58()) + }) + }) + + // =========================================================================== + // Wallet Validation + // =========================================================================== + + describe('acceptOwnership — wallet validation', () => { + const mockConnection = createMockConnection() + const admin = new SolanaTokenAdmin(mockConnection, dummyNetwork, { + logger: silentLogger, + apiClient: null, + }) + + it('should reject non-wallet object', async () => { + await assert.rejects( + () => admin.acceptOwnership({}, validAcceptParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + assert.equal(err.code, 'WALLET_INVALID') + return true + }, + ) + }) + + it('should reject null wallet', async () => { + await assert.rejects( + () => admin.acceptOwnership(null, validAcceptParams), + (err: unknown) => { + assert.ok(err instanceof CCIPWalletInvalidError) + return true + }, + ) + }) + }) +}) diff --git a/ccip-sdk/src/token-admin/treeshake.test.ts b/ccip-sdk/src/token-admin/treeshake.test.ts new file mode 100644 index 00000000..0fdf256b --- /dev/null +++ b/ccip-sdk/src/token-admin/treeshake.test.ts @@ -0,0 +1,166 @@ +/** + * Tree-shaking verification tests. + * + * Uses esbuild JS API to bundle specific entry points and verifies that: + * 1. Each bundle contains its expected primary export (positive assertion) + * 2. Unwanted code (bytecodes, cross-chain deps) is excluded (negative assertion) + * 3. Bundle sizes stay within budgets + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, it } from 'node:test' + +import * as esbuild from 'esbuild' + +/** Derive external packages from package.json dependencies + peerDependencies. */ +function getExternalPackages(): string[] { + const pkgPath = path.resolve(import.meta.dirname, '../../package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + return [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.peerDependencies ?? {}), + 'node:*', + ] +} + +const EXTERNAL = getExternalPackages() + +/** SDK source root for import paths. */ +const sdkSrc = path.resolve(import.meta.dirname, '..') + +/** + * Bundle entry code with esbuild and return the output string. + * Uses the JS API for speed and determinism (no npx cold-start). + */ +async function bundle(entryCode: string, opts?: { splitting?: boolean }): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'treeshake-')) + const entryFile = path.join(tmpDir, 'entry.ts') + + try { + fs.writeFileSync(entryFile, entryCode) + + const splitting = opts?.splitting ?? false + const outdir = path.join(tmpDir, 'out') + await esbuild.build({ + entryPoints: [entryFile], + bundle: true, + format: 'esm', + treeShaking: true, + platform: 'node', + write: true, + outdir, + splitting, + external: EXTERNAL, + }) + + return fs.readFileSync(path.join(outdir, 'entry.js'), 'utf8') + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +} + +// All EVM bytecode constant names +const EVM_BYTECODES = [ + 'BURN_MINT_ERC20_BYTECODE', + 'BURN_MINT_TOKEN_POOL_BYTECODE', + 'LOCK_RELEASE_TOKEN_POOL_BYTECODE', + 'FACTORY_BURN_MINT_ERC20_BYTECODE', +] + +// Representative Aptos Move module markers (one per bytecode file) +const APTOS_MOVE_MARKERS = [ + 'module managed_token::managed_token', + 'module managed_token_pool::managed_token_pool', + 'module burn_mint_token_pool::burn_mint_token_pool', + 'module lock_release_token_pool::lock_release_token_pool', + 'module regulated_token_pool::regulated_token_pool', + 'module ccip::token_admin_registry', + 'MCMS_MCMS_MOVE', +] + +describe('tree-shaking verification', () => { + // ------------------------------------------------------------------------- + // Main SDK entry — must exclude all CCT bytecodes and Move sources + // ------------------------------------------------------------------------- + it('main entry excludes all EVM bytecodes and Aptos Move sources', async () => { + const output = await bundle(`import '${sdkSrc}/index.ts'`) + + // The bare import with sideEffects:false tree-shakes to near-empty. + // Verify none of the heavy CCT constants survived. + for (const name of EVM_BYTECODES) { + assert.ok(!output.includes(name), `main entry should not contain ${name}`) + } + for (const marker of APTOS_MOVE_MARKERS) { + assert.ok(!output.includes(marker), `main entry should not contain "${marker}"`) + } + }) + + // ------------------------------------------------------------------------- + // Cross-chain isolation: full 3×2 matrix + // ------------------------------------------------------------------------- + const chains = [ + { name: 'EVM', class: 'EVMTokenAdmin', path: 'token-admin/evm/index.ts' }, + { name: 'Solana', class: 'SolanaTokenAdmin', path: 'token-admin/solana/index.ts' }, + { name: 'Aptos', class: 'AptosTokenAdmin', path: 'token-admin/aptos/index.ts' }, + ] as const + + for (const importer of chains) { + for (const excluded of chains) { + if (importer.name === excluded.name) continue + + it(`${importer.name} token-admin does NOT include ${excluded.name} token-admin code`, async () => { + const output = await bundle( + `import { ${importer.class} } from '${sdkSrc}/${importer.path}'; console.log(${importer.class})`, + ) + + // Positive: the bundle contains the expected class + assert.ok(output.includes(importer.class), `bundle should contain ${importer.class}`) + + // Negative: the bundle excludes the other chain's class + assert.ok( + !output.includes(excluded.class), + `${importer.name} token-admin should not contain ${excluded.class}`, + ) + }) + } + } + + // ------------------------------------------------------------------------- + // Code-splitting: bytecodes and Move sources stay in separate chunks + // ------------------------------------------------------------------------- + // Code-splitting: the entry chunk should contain the class but NOT the heavy + // bytecode/source data — those should be deferred to separate chunks via dynamic import(). + // We check for distinctive substrings of the actual data, not the constant names. + it('EVM token-admin entry chunk does NOT eagerly include bytecode data (code-splitting)', async () => { + const output = await bundle( + `import { EVMTokenAdmin } from '${sdkSrc}/token-admin/evm/index.ts'; console.log(EVMTokenAdmin)`, + { splitting: true }, + ) + + assert.ok(output.includes('EVMTokenAdmin'), 'entry chunk should contain EVMTokenAdmin') + + // Distinctive substring from BurnMintERC20 bytecode hex + assert.ok( + !output.includes('60c060405234801562000010575f80fd5b50'), + 'entry chunk should not contain BurnMintERC20 bytecode hex data', + ) + }) + + it('Aptos token-admin entry chunk does NOT eagerly include Move source data (code-splitting)', async () => { + const output = await bundle( + `import { AptosTokenAdmin } from '${sdkSrc}/token-admin/aptos/index.ts'; console.log(AptosTokenAdmin)`, + { splitting: true }, + ) + + assert.ok(output.includes('AptosTokenAdmin'), 'entry chunk should contain AptosTokenAdmin') + + // Distinctive substring from managed_token Move source + assert.ok( + !output.includes('module managed_token::managed_token'), + 'entry chunk should not contain managed_token Move source code', + ) + }) +}) diff --git a/ccip-sdk/src/token-admin/types.ts b/ccip-sdk/src/token-admin/types.ts new file mode 100644 index 00000000..7dfcce55 --- /dev/null +++ b/ccip-sdk/src/token-admin/types.ts @@ -0,0 +1,1493 @@ +/** + * Shared types for token-admin entry points. + * + * These types define the unified interface for deploying CCIP-compatible tokens + * across all supported chain families (EVM, Solana, Aptos). + * + * @packageDocumentation + */ + +/** + * Base parameters for deploying a new CCIP-compatible token. + * Extended by chain-specific param types. + * + * @example + * ```typescript + * const params: DeployTokenParams = { + * name: 'My Token', + * symbol: 'MTK', + * decimals: 18, + * maxSupply: 1_000_000n * 10n ** 18n, + * initialSupply: 10_000n * 10n ** 18n, + * } + * ``` + */ +export interface DeployTokenParams { + /** Token name (e.g., "My Token"). Must be non-empty. */ + name: string + /** Token symbol (e.g., "MTK"). Must be non-empty. */ + symbol: string + /** Token decimals (0-18 for EVM, 0-9 for Solana, typically 8 for Aptos). */ + decimals: number + /** Maximum supply cap. `undefined` or `0n` means unlimited. */ + maxSupply?: bigint + /** Amount to pre-mint to the deployer or recipient. `undefined` or `0n` means none. */ + initialSupply?: bigint +} + +/** + * Unified result from {@link deployToken} on any chain family. + * + * Identical for EVM, Solana, and Aptos — matches the SDK pattern where + * signed methods return unified types (e.g., `sendMessage() -> CCIPRequest`). + * + * Chain-specific details (e.g., Aptos multi-tx hashes, Solana metadata PDA) + * are only exposed via the unsigned path ({@link generateUnsignedDeployToken}). + * + * @example + * ```typescript + * const { tokenAddress, txHash } = await admin.deployToken({ + * name: 'My Token', symbol: 'MTK', decimals: 18, + * }) + * console.log(`Deployed at ${tokenAddress}, tx: ${txHash}`) + * ``` + */ +export interface DeployTokenResult { + /** + * Deployed token address. + * - EVM: contract address (from `receipt.contractAddress`) + * - Solana: mint pubkey (base58) + * - Aptos: fungible asset metadata address (grandchild of the code object) + */ + tokenAddress: string + /** + * Primary deploy transaction hash or signature. + * - EVM: deploy tx hash + * - Solana: transaction signature (base58) + * - Aptos: publish tx hash (first of the sequential txs) + */ + txHash: string + + // ── Chain-specific optional fields ────────────────────────────────────────── + // These are populated when relevant for downstream operations (e.g., deployPool). + + /** + * Aptos code object address (parent of the FA metadata object). + * Needed as `managed_token` named address when deploying a token pool. + * Only set on Aptos deploys. + */ + codeObjectAddress?: string + /** + * Solana Metaplex metadata PDA for the mint. + * Only set on Solana deploys. + */ + metadataAddress?: string +} + +// ─── EVM ────────────────────────────────────────────────────────────────────── + +/** + * EVM token contract type. + * + * - `'burnMintERC20'` — OZ AccessControl, owner = `msg.sender` (default) + * - `'factoryBurnMintERC20'` — Ownable with explicit `newOwner` constructor param, + * has `grantMintRole`/`revokeMintRole`/`getMinters()`/`getBurners()` + */ +export type EVMTokenType = 'burnMintERC20' | 'factoryBurnMintERC20' + +/** + * EVM-specific parameters for deploying a CCIP-compatible token. + * + * When `tokenType` is `'burnMintERC20'` (default): + * - Constructor: `(name, symbol, decimals_, maxSupply_, preMint)` + * - Owner = `msg.sender` (deployer wallet) + * + * When `tokenType` is `'factoryBurnMintERC20'`: + * - Constructor: `(name, symbol, decimals_, maxSupply_, preMint, newOwner)` + * - `ownerAddress` is required for unsigned path; auto-filled from signer in signed path + * + * @example + * ```typescript + * // Default: BurnMintERC20 + * const params: EVMDeployTokenParams = { + * name: 'My Token', + * symbol: 'MTK', + * decimals: 18, + * } + * + * // FactoryBurnMintERC20 with explicit owner + * const factoryParams: EVMDeployTokenParams = { + * name: 'My Token', + * symbol: 'MTK', + * decimals: 18, + * tokenType: 'factoryBurnMintERC20', + * ownerAddress: '0x1234...', + * } + * ``` + */ +export interface EVMDeployTokenParams extends DeployTokenParams { + /** Token contract type. Default: `'burnMintERC20'`. */ + tokenType?: EVMTokenType + /** + * Owner address for the deployed token. + * - `factoryBurnMintERC20`: passed as `newOwner` constructor param. + * Required for unsigned path; auto-filled from signer in signed path. + * - `burnMintERC20`: ignored (owner = msg.sender / deployer). + */ + ownerAddress?: string +} + +// ─── Solana ─────────────────────────────────────────────────────────────────── + +/** + * Solana-specific parameters for deploying an SPL Token mint. + * + * Supports both SPL Token and Token-2022 programs. Metaplex metadata is + * **strongly recommended** — without it, wallets and explorers will show + * "Unknown Token". + * + * @example + * ```typescript + * const params: SolanaDeployTokenParams = { + * name: 'My Token', + * symbol: 'MTK', + * decimals: 9, + * tokenProgram: 'spl-token', + * metadataUri: 'https://arweave.net/abc123', + * initialSupply: 1_000_000n * 10n ** 9n, + * } + * ``` + */ +export interface SolanaDeployTokenParams extends DeployTokenParams { + /** Token program to use. Default: `'spl-token'`. */ + tokenProgram?: 'spl-token' | 'token-2022' + /** + * Metaplex metadata JSON URI. + * **Strongly recommended** — without it, wallets and explorers display "Unknown Token". + */ + metadataUri?: string + /** Mint authority pubkey. Default: sender/wallet pubkey. */ + mintAuthority?: string + /** Freeze authority. `null` disables freeze. Default: sender/wallet pubkey. */ + freezeAuthority?: string | null + /** Recipient for `initialSupply`. Default: sender/wallet pubkey. */ + recipient?: string +} + +// ─── Aptos ──────────────────────────────────────────────────────────────────── + +/** + * Aptos-specific parameters for deploying a managed_token Move module. + * + * Publishes the `managed_token` module bytecode, then calls `initialize()`. + * If `initialSupply > 0`, also calls `mint()`. + * + * @example + * ```typescript + * const params: AptosDeployTokenParams = { + * name: 'My Token', + * symbol: 'MTK', + * decimals: 8, + * initialSupply: 100_000_000_000n, + * icon: 'https://example.com/icon.png', + * } + * ``` + */ +export interface AptosDeployTokenParams extends DeployTokenParams { + /** Token icon URI. Passed to `initialize()` as empty string if omitted. */ + icon?: string + /** Project URL. Passed to `initialize()` as empty string if omitted. */ + project?: string + /** Recipient for `initialSupply`. Default: sender/deployer address. */ + recipient?: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Pool Deployment Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Supported CCIP token pool types. + * + * - `'burn-mint'` — Pool burns tokens on source and mints on destination. + * - `'lock-release'` — Pool locks tokens on source and releases on destination. + */ +export type PoolType = 'burn-mint' | 'lock-release' + +/** + * Base parameters for deploying a CCIP token pool. + * Extended by chain-specific param types. + * + * @example + * ```typescript + * const params: DeployPoolParams = { + * poolType: 'burn-mint', + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * localTokenDecimals: 18, + * } + * ``` + */ +export interface DeployPoolParams { + /** Pool type to deploy. */ + poolType: PoolType + /** + * Token address the pool manages. + * - EVM: ERC20 contract address + * - Solana: SPL mint pubkey (base58) + * - Aptos: fungible asset metadata address + */ + tokenAddress: string + /** Token decimals on this chain (must match the deployed token). */ + localTokenDecimals: number +} + +/** + * Unified result from {@link deployPool} on any chain family. + * + * @example + * ```typescript + * const { poolAddress, txHash } = await admin.deployPool(wallet, { + * poolType: 'burn-mint', + * tokenAddress: '0xa42BA...', + * localTokenDecimals: 18, + * routerAddress: '0x0BF3...', + * }) + * console.log(`Pool at ${poolAddress}, tx: ${txHash}`) + * ``` + */ +export interface DeployPoolResult { + /** + * Deployed pool address. + * - EVM: contract address + * - Solana: pool config PDA (base58) + * - Aptos: pool object address + */ + poolAddress: string + /** Primary deploy transaction hash/signature. */ + txHash: string + /** + * Whether the pool is fully initialized and ready to use. + * + * `false` for Aptos generic pools (`burn_mint_token_pool`, `lock_release_token_pool`) + * — the token creator module must call `initialize()` with stored capability refs + * (`BurnRef`/`MintRef`/`TransferRef`) before the pool can be used for CCIP operations. + * + * `true` (or `undefined` for backward compatibility) for managed/regulated pools and + * all EVM/Solana pools, which are fully initialized at deploy time. + */ + initialized?: boolean +} + +// ─── EVM Pool ──────────────────────────────────────────────────────────────── + +/** + * EVM-specific parameters for deploying a CCIP token pool. + * + * Both BurnMintTokenPool and LockReleaseTokenPool (v1.6.1) share an + * identical constructor: `(token, localTokenDecimals, allowlist[], rmnProxy, router)`. + * `rmnProxy` is derived automatically via `Router.getArmProxy()`. + * + * @example + * ```typescript + * const params: EVMDeployPoolParams = { + * poolType: 'burn-mint', + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * localTokenDecimals: 18, + * routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + * } + * ``` + */ +export interface EVMDeployPoolParams extends DeployPoolParams { + /** CCIP Router address. Used to derive rmnProxy via `Router.getArmProxy()`. */ + routerAddress: string + /** Optional allowlist of sender addresses. Default: `[]` (open). */ + allowlist?: string[] +} + +// ─── Solana Pool ───────────────────────────────────────────────────────────── + +/** + * Solana-specific parameters for deploying (initializing) a CCIP token pool. + * + * Solana pools are pre-deployed programs. Users call `initialize` on an + * existing program — no binary deployment is needed. + * + * @example + * ```typescript + * const params: SolanaDeployPoolParams = { + * poolType: 'burn-mint', + * tokenAddress: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * localTokenDecimals: 9, + * poolProgramId: '', + * } + * ``` + */ +export interface SolanaDeployPoolParams extends DeployPoolParams { + /** + * Program ID of the pre-deployed pool program. + * - burn-mint: burnmint_token_pool program + * - lock-release: lockrelease_token_pool program + */ + poolProgramId: string +} + +// ─── Aptos Pool ────────────────────────────────────────────────────────────── + +/** + * Aptos token module variant. Determines which Move pool module is compiled and deployed. + * + * Aptos has multiple pool implementations, each designed for a specific token standard. + * The `poolType` (`'burn-mint'` | `'lock-release'`) specifies the pool **behaviour**, + * while `tokenModule` specifies the token **standard** the pool targets. + * + * | tokenModule | poolType | Move module deployed | Use case | + * |---------------|-----------------|--------------------------------|----------| + * | `'managed'` | `'burn-mint'` | `managed_token_pool` | Tokens deployed with SDK's `deployToken()` | + * | `'generic'` | `'burn-mint'` | `burn_mint_token_pool` | Standard Fungible Asset tokens with BurnRef/MintRef | + * | `'generic'` | `'lock-release'`| `lock_release_token_pool` | Standard FA tokens (custody-based) | + * | `'regulated'` | `'burn-mint'` | `regulated_token_pool` | Tokens with pause/freeze/role-based access | + * + * Only `'generic'` supports `poolType: 'lock-release'`. Both `'managed'` and `'regulated'` + * are inherently burn-mint — they will reject `'lock-release'`. + * + * Default: `'managed'` + */ +export type AptosTokenModule = 'managed' | 'generic' | 'regulated' + +/** + * Aptos-specific parameters for deploying a CCIP token pool Move module. + * + * Publishes the appropriate pool bytecode — `init_module` runs automatically + * and creates the pool state, registers callbacks with the CCIP router. + * + * The `tokenModule` field (default: `'managed'`) selects which Move pool module + * to compile. If you deployed your token with `admin.deployToken()`, use the + * default. See {@link AptosTokenModule} for all options. + * + * For managed and regulated tokens, the SDK automatically resolves the code object + * address from the `tokenAddress` (FA metadata) by querying the on-chain object + * ownership chain. No separate code object address parameter is needed. + * + * @example Deploy pool for a managed token (default — matches `deployToken()` output) + * ```typescript + * const params: AptosDeployPoolParams = { + * poolType: 'burn-mint', + * tokenAddress: '0x89fd6b...', // FA metadata address from deployToken() + * localTokenDecimals: 8, + * routerAddress: '0xabc...', + * mcmsAddress: '0x123...', + * } + * ``` + * + * @example Deploy pool for a generic Fungible Asset (lock-release) + * ```typescript + * const params: AptosDeployPoolParams = { + * poolType: 'lock-release', + * tokenModule: 'generic', + * tokenAddress: '0x89fd6b...', + * localTokenDecimals: 8, + * routerAddress: '0xabc...', + * mcmsAddress: '0x123...', + * } + * ``` + * + * @example Deploy pool for a regulated token + * ```typescript + * const params: AptosDeployPoolParams = { + * poolType: 'burn-mint', + * tokenModule: 'regulated', + * tokenAddress: '0x89fd6b...', // FA metadata address + * localTokenDecimals: 8, + * routerAddress: '0xabc...', + * adminAddress: '0x456...', + * mcmsAddress: '0x123...', + * } + * ``` + */ +export interface AptosDeployPoolParams extends DeployPoolParams { + /** + * Aptos token module variant. Determines which Move pool is compiled. + * + * - `'managed'` (default) — For tokens deployed with the SDK's `deployToken()`. + * Only supports `poolType: 'burn-mint'`. + * - `'generic'` — For standard Aptos Fungible Asset tokens. + * Supports both `'burn-mint'` and `'lock-release'`. + * - `'regulated'` — For tokens deployed with the `regulated_token` package (pause/freeze/roles). + * Only supports `poolType: 'burn-mint'`. + * + * Default: `'managed'` + */ + tokenModule?: AptosTokenModule + /** CCIP router module address (`ccip` named address). */ + routerAddress: string + /** Address of the deployed `mcms` package. */ + mcmsAddress: string + /** + * Admin address for the regulated token's access control. + * **Required when `tokenModule` is `'regulated'`.** + * + * This is the `admin` named address in the regulated_token Move.toml — + * typically the account that manages roles (minter, burner, pauser, etc.). + */ + adminAddress?: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Propose Admin Role Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Base parameters for proposing an administrator in the TokenAdminRegistry. + * Extended by chain-specific param types. + * + * @example + * ```typescript + * const params: ProposeAdminRoleParams = { + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * administrator: '0x1234567890abcdef1234567890abcdef12345678', + * } + * ``` + */ +export interface ProposeAdminRoleParams { + /** Token address to propose an administrator for. */ + tokenAddress: string + /** Address of the proposed administrator. */ + administrator: string +} + +/** + * Unified result from {@link proposeAdminRole} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.proposeAdminRole(wallet, params) + * console.log(`Proposed admin, tx: ${txHash}`) + * ``` + */ +export interface ProposeAdminRoleResult { + /** Transaction hash/signature of the propose admin role transaction. */ + txHash: string +} + +// ─── EVM Propose Admin Role ────────────────────────────────────────────────── + +/** + * Registration method for the RegistryModuleOwnerCustom contract. + * + * - `owner` — token implements `owner()` (Ownable pattern, most common) + * - `getCCIPAdmin` — token implements `getCCIPAdmin()` (dedicated CCIP admin) + * - `accessControlDefaultAdmin` — token uses OZ AccessControl `DEFAULT_ADMIN_ROLE` + */ +export type EVMRegistrationMethod = 'owner' | 'getCCIPAdmin' | 'accessControlDefaultAdmin' + +/** + * EVM-specific parameters for proposing an administrator. + * + * On EVM, registration goes through the RegistryModuleOwnerCustom contract, + * which verifies the caller's authority over the token and then internally + * calls `proposeAdministrator(token, caller)` on the TokenAdminRegistry. + * + * The `registryModuleAddress` can be found via the CCIP API: + * `https://docs.chain.link/api/ccip/v1/chains?environment=testnet` → `registryModule` + * + * @example + * ```typescript + * // Most common: token uses Ownable (owner() method) + * const params: EVMProposeAdminRoleParams = { + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * registryModuleAddress: '0xa3c796d480638d7476792230da1E2ADa86e031b0', + * registrationMethod: 'owner', + * } + * ``` + */ +export interface EVMProposeAdminRoleParams { + /** Token address to propose admin for. */ + tokenAddress: string + /** RegistryModuleOwnerCustom contract address. */ + registryModuleAddress: string + /** Registration method — determines how the contract verifies caller authority. Defaults to `'owner'`. */ + registrationMethod?: EVMRegistrationMethod +} + +// ─── Solana Propose Admin Role ─────────────────────────────────────────────── + +/** + * Solana-specific parameters for proposing an administrator. + * + * On Solana, the TokenAdminRegistry is built into the Router program. + * + * @example + * ```typescript + * const params: SolanaProposeAdminRoleParams = { + * tokenAddress: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * administrator: '5YNmS1R9nNSCDzb5a7mMJ1dwK9uHeAAF4CmPEwKgVWr8', + * routerAddress: '', + * } + * ``` + */ +export interface SolanaProposeAdminRoleParams extends ProposeAdminRoleParams { + /** Router address (bundles the TokenAdminRegistry on Solana). */ + routerAddress: string +} + +// ─── Aptos Propose Admin Role ──────────────────────────────────────────────── + +/** + * Aptos-specific parameters for proposing an administrator. + * + * On Aptos, the TokenAdminRegistry is a module within the CCIP router package + * (`routerAddress::token_admin_registry`). + * + * @example + * ```typescript + * const params: AptosProposeAdminRoleParams = { + * tokenAddress: '0x89fd6b...', + * administrator: '0x1234...', + * routerAddress: '0xabc...', + * } + * ``` + */ +export interface AptosProposeAdminRoleParams extends ProposeAdminRoleParams { + /** CCIP router module address. */ + routerAddress: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Accept Admin Role Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Base parameters for accepting an administrator role in the TokenAdminRegistry. + * Extended by chain-specific param types. + * + * @example + * ```typescript + * const params: AcceptAdminRoleParams = { + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + * } + * ``` + */ +export interface AcceptAdminRoleParams { + /** Token address to accept admin role for. */ + tokenAddress: string + /** Router address (used to discover the TokenAdminRegistry). */ + routerAddress: string +} + +/** + * Unified result from {@link acceptAdminRole} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.acceptAdminRole(wallet, params) + * console.log(`Accepted admin, tx: ${txHash}`) + * ``` + */ +export interface AcceptAdminRoleResult { + /** Transaction hash/signature of the accept admin role transaction. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Transfer Admin Role Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for transferring a token administrator role. + * + * Called by the **current** administrator to hand off the admin role to a new + * address. The new admin must call {@link acceptAdminRole} to complete the transfer. + * + * Consistent across all chain families (EVM, Solana, Aptos). + * + * @example + * ```typescript + * const params: TransferAdminRoleParams = { + * tokenAddress: '0xa42BA...', + * newAdmin: '0x1234...', + * routerAddress: '0x0BF3...', + * } + * ``` + */ +export interface TransferAdminRoleParams { + /** Token address to transfer admin role for. */ + tokenAddress: string + /** Address of the new administrator. */ + newAdmin: string + /** Router address (used to discover the TokenAdminRegistry). */ + routerAddress: string +} + +/** + * Unified result from {@link transferAdminRole} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.transferAdminRole(wallet, params) + * console.log(`Transferred admin, tx: ${txHash}`) + * ``` + */ +export interface TransferAdminRoleResult { + /** Transaction hash/signature of the transfer admin role transaction. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Apply Chain Updates Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Rate limiter configuration for a remote chain. + * + * Controls the inbound/outbound token flow rate for a specific remote chain. + * Set `isEnabled: false` with `capacity: '0'` and `rate: '0'` to disable. + * + * @example + * ```typescript + * // Disabled rate limiter + * const disabled: RateLimiterConfig = { isEnabled: false, capacity: '0', rate: '0' } + * + * // Enabled: 100k tokens capacity, refilling at 167 tokens/sec (~10k/min) + * const enabled: RateLimiterConfig = { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' } + * ``` + */ +export interface RateLimiterConfig { + /** Whether the rate limiter is enabled. */ + isEnabled: boolean + /** Maximum token capacity (bigint as string to avoid JS precision loss). */ + capacity: string + /** Token refill rate per second (bigint as string). */ + rate: string +} + +/** + * Configuration for a single remote chain in a token pool. + * + * Defines how a local pool connects to its counterpart on a remote chain: + * the remote pool address(es), remote token address, and rate limits. + * + * Addresses are in their **native format** — hex for EVM/Aptos, base58 for Solana. + * The SDK handles encoding to 32-byte padded bytes internally. + * + * @example + * ```typescript + * const remoteChain: RemoteChainConfig = { + * remoteChainSelector: 16015286601757825753n, // Ethereum Sepolia + * remotePoolAddresses: ['0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD'], + * remoteTokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * } + * ``` + */ +export interface RemoteChainConfig { + /** Remote chain selector. */ + remoteChainSelector: bigint + /** Remote pool address(es) in native format. At least one required. */ + remotePoolAddresses: string[] + /** Remote token address in native format. */ + remoteTokenAddress: string + /** Remote token decimals. Required for Solana pools (used in init_chain_remote_config). Ignored on EVM/Aptos. */ + remoteTokenDecimals?: number + /** Outbound rate limiter (local → remote). */ + outboundRateLimiterConfig: RateLimiterConfig + /** Inbound rate limiter (remote → local). */ + inboundRateLimiterConfig: RateLimiterConfig +} + +/** + * Parameters for configuring remote chains on a token pool. + * + * Uniform across all chain families — only `poolAddress` is needed. + * The SDK auto-discovers chain-specific details (program ID, mint, module name) + * from the pool account on-chain. + * + * @example + * ```typescript + * const params: ApplyChainUpdatesParams = { + * poolAddress: '0x1234...', + * remoteChainSelectorsToRemove: [], + * chainsToAdd: [{ + * remoteChainSelector: 16015286601757825753n, + * remotePoolAddresses: ['0xd7BF...'], + * remoteTokenAddress: '0xa42B...', + * outboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * inboundRateLimiterConfig: { isEnabled: false, capacity: '0', rate: '0' }, + * }], + * } + * ``` + */ +export interface ApplyChainUpdatesParams { + /** Local pool address. */ + poolAddress: string + /** Remote chain selectors to remove (can be empty). */ + remoteChainSelectorsToRemove: bigint[] + /** Remote chain configurations to add (can be empty). */ + chainsToAdd: RemoteChainConfig[] +} + +/** + * Unified result from {@link applyChainUpdates} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.applyChainUpdates(wallet, params) + * console.log(`Chain updates applied, tx: ${txHash}`) + * ``` + */ +export interface ApplyChainUpdatesResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Append Remote Pool Addresses Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for appending remote pool addresses to an existing chain config. + * + * Unlike {@link ApplyChainUpdatesParams}, this only adds pool addresses to a + * chain config that was already initialized via `applyChainUpdates`. No rate + * limiter configuration or chain initialization is performed. + * + * @example + * ```typescript + * const params: AppendRemotePoolAddressesParams = { + * poolAddress: '0x1234...', + * remoteChainSelector: 16015286601757825753n, + * remotePoolAddresses: ['0xd7BF...', '0xaabb...'], + * } + * ``` + */ +export interface AppendRemotePoolAddressesParams { + /** Local pool address. */ + poolAddress: string + /** Remote chain selector (uint64 as string). Must already be configured via applyChainUpdates. */ + remoteChainSelector: bigint + /** Remote pool addresses in native format. At least one required. */ + remotePoolAddresses: string[] +} + +/** + * Unified result from {@link appendRemotePoolAddresses} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.appendRemotePoolAddresses(wallet, params) + * console.log(`Remote pool addresses appended, tx: ${txHash}`) + * ``` + */ +export interface AppendRemotePoolAddressesResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Remove Remote Pool Addresses Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for removing specific remote pool addresses from an existing chain config. + * + * Unlike {@link DeleteChainConfigParams}, this preserves the chain config and only + * removes specific pool addresses. The chain config must have been initialized via + * `applyChainUpdates` and must contain the specified pool addresses. + * + * @example + * ```typescript + * const params: RemoveRemotePoolAddressesParams = { + * poolAddress: '0x1234...', + * remoteChainSelector: 16015286601757825753n, + * remotePoolAddresses: ['0xd7BF...'], + * } + * ``` + */ +export interface RemoveRemotePoolAddressesParams { + /** Local pool address. */ + poolAddress: string + /** Remote chain selector (uint64 as string). Must already be configured via applyChainUpdates. */ + remoteChainSelector: bigint + /** Remote pool addresses to remove, in native format. At least one required. */ + remotePoolAddresses: string[] +} + +/** + * Unified result from removeRemotePoolAddresses on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.removeRemotePoolAddresses(wallet, params) + * console.log(`Remote pool addresses removed, tx: ${txHash}`) + * ``` + */ +export interface RemoveRemotePoolAddressesResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Delete Chain Config Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for removing an entire remote chain configuration from a token pool. + * + * This is a convenience wrapper around applyChainUpdates with only removals. + * The remote chain config must already exist (created via applyChainUpdates). + * + * @example + * ```typescript + * const params: DeleteChainConfigParams = { + * poolAddress: '0x1234...', + * remoteChainSelector: 16015286601757825753n, + * } + * ``` + */ +export interface DeleteChainConfigParams { + /** Local pool address. */ + poolAddress: string + /** Remote chain selector (uint64 as string) to remove. Must be currently configured. */ + remoteChainSelector: bigint +} + +/** + * Unified result from deleteChainConfig on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.deleteChainConfig(wallet, params) + * console.log(`Chain config deleted, tx: ${txHash}`) + * ``` + */ +export interface DeleteChainConfigResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Set Chain Rate Limiter Config Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Rate limiter configuration for a specific remote chain. + * + * Used by {@link SetChainRateLimiterConfigParams} to update rate limits + * on an already-configured remote chain. Unlike {@link RemoteChainConfig}, + * this does not include pool/token address fields — only rate limits. + * + * @example + * ```typescript + * const config: ChainRateLimiterConfig = { + * remoteChainSelector: 16015286601757825753n, + * outboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * } + * ``` + */ +export interface ChainRateLimiterConfig { + /** Remote chain selector (uint64 as string). */ + remoteChainSelector: bigint + /** Outbound rate limiter (local → remote). */ + outboundRateLimiterConfig: RateLimiterConfig + /** Inbound rate limiter (remote → local). */ + inboundRateLimiterConfig: RateLimiterConfig + /** + * Whether to set the custom block confirmations (FTF) rate limits. + * + * - `false` (default): sets the default rate limits (normal finality transfers) + * - `true`: sets the FTF (Faster-Than-Finality) rate limits bucket + * + * Only applies to EVM v2.0+ pools. Ignored on v1.5/v1.6 pools and non-EVM chains. + */ + customBlockConfirmations?: boolean +} + +/** + * Parameters for updating rate limiter configurations on a token pool. + * + * Updates rate limits for one or more already-configured remote chains. + * The remote chains must have been previously added via {@link applyChainUpdates}. + * + * @example + * ```typescript + * const params: SetChainRateLimiterConfigParams = { + * poolAddress: '0x1234...', + * chainConfigs: [{ + * remoteChainSelector: 16015286601757825753n, + * outboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * inboundRateLimiterConfig: { isEnabled: true, capacity: '100000000000000000000000', rate: '167000000000000000000' }, + * }], + * } + * ``` + */ +export interface SetChainRateLimiterConfigParams { + /** Local pool address. */ + poolAddress: string + /** Rate limiter configurations per remote chain. */ + chainConfigs: ChainRateLimiterConfig[] +} + +/** + * Unified result from {@link setChainRateLimiterConfig} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.setChainRateLimiterConfig(wallet, params) + * console.log(`Rate limits updated, tx: ${txHash}`) + * ``` + */ +export interface SetChainRateLimiterConfigResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ── Set Rate Limit Admin ────────────────────────────────────────────────────── + +/** + * Parameters for {@link setRateLimitAdmin} — delegates rate-limit management + * to a separate admin address (EVM and Solana only; not available on Aptos). + * + * @example + * ```typescript + * const params: SetRateLimitAdminParams = { + * poolAddress: '0x1234...', + * rateLimitAdmin: '0xabcd...', + * } + * ``` + */ +export interface SetRateLimitAdminParams { + /** Local pool address. */ + poolAddress: string + /** New rate limit admin address. */ + rateLimitAdmin: string +} + +/** + * Unified result from {@link setRateLimitAdmin} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.setRateLimitAdmin(wallet, params) + * console.log(`Rate limit admin updated, tx: ${txHash}`) + * ``` + */ +export interface SetRateLimitAdminResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Create Pool Mint Authority Multisig Types (Solana-only) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for creating an SPL Token multisig with the pool signer PDA + * as one of the signers. **Solana burn-mint pools only.** + * + * The Pool Signer PDA is automatically derived from `mint` and `poolProgramId` + * and included as the first signer. This allows the pool to autonomously + * mint/burn tokens for CCIP operations, while additional signers (e.g., a + * Squads vault) can also mint independently. + * + * @example + * ```typescript + * const params: CreatePoolMintAuthorityMultisigParams = { + * mint: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * poolProgramId: '41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB', + * additionalSigners: ['59eNrRrxrZMdqJxS7J3WGaV4MLLog2er14kePiWVjXtY'], + * threshold: 1, + * } + * ``` + */ +export interface CreatePoolMintAuthorityMultisigParams { + /** SPL token mint pubkey (base58). */ + mint: string + /** Pool program ID (burn-mint pool program). */ + poolProgramId: string + /** Additional signers (e.g., Squads vault). Pool Signer PDA is auto-included as first signer. */ + additionalSigners: string[] + /** Required number of signers (m-of-n). Must be explicitly set — no default. */ + threshold: number + /** Optional seed for deterministic address derivation via createAccountWithSeed. If omitted, a random keypair is used (standard SPL pattern). */ + seed?: string +} + +/** + * Result from {@link createPoolMintAuthorityMultisig}. + * + * @example + * ```typescript + * const { multisigAddress, poolSignerPda, allSigners } = + * await admin.createPoolMintAuthorityMultisig(wallet, params) + * console.log(`Multisig: ${multisigAddress}, Pool Signer PDA: ${poolSignerPda}`) + * ``` + */ +// ═══════════════════════════════════════════════════════════════════════════════ +// Transfer Mint Authority Types (Solana-only) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for transferring SPL token mint authority to a new address. + * **Solana only.** + * + * @example + * ```typescript + * const params: TransferMintAuthorityParams = { + * mint: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * newMintAuthority: '2e8X9v1s9nro5ezG3osRm7bpusdYknNrQYzQMxsA4Gwh', + * } + * ``` + */ +export interface TransferMintAuthorityParams { + /** SPL token mint pubkey (base58). */ + mint: string + /** New mint authority address (base58) — typically a multisig. */ + newMintAuthority: string +} + +/** + * Result from {@link transferMintAuthority}. + * + * @example + * ```typescript + * const { txHash } = await admin.transferMintAuthority(wallet, params) + * console.log(`Mint authority transferred, tx: ${txHash}`) + * ``` + */ +export interface TransferMintAuthorityResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Grant Mint/Burn Access Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Which role(s) to grant on a token. + * + * - `'mintAndBurn'` — grant both mint and burn (default, backwards compatible) + * - `'mint'` — grant mint only + * - `'burn'` — grant burn only + * + * **Chain-specific notes:** + * - **Solana:** Only `'mint'` and `'mintAndBurn'` are valid (SPL tokens have a + * single mint authority; burn is implicit for token holders). `'burn'` will + * throw an error. + */ +export type MintBurnRole = 'mint' | 'burn' | 'mintAndBurn' + +/** + * Parameters for granting mint and burn permissions on a token. + * + * This is a **token** operation — it modifies permissions on the token, + * not the pool. The `authority` receives permission to mint/burn. + * + * | Chain | `tokenAddress` | `authority` | What happens | + * |---------|--------------------|----------------------------------|-------------| + * | EVM | ERC20 address | Pool address | `grantMintAndBurnRoles(authority)` / `grantMintRole` / `grantBurnRole` | + * | Solana | SPL mint (base58) | New mint authority (multisig/PDA) | `setAuthority(MintTokens)` | + * | Aptos | FA metadata addr | Pool object address | Auto-detects pool type, grants access | + * + * @example + * ```typescript + * // Grant both roles (default) + * const params: GrantMintBurnAccessParams = { + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * authority: '0x1234567890abcdef1234567890abcdef12345678', + * } + * + * // Grant mint only + * const mintOnly: GrantMintBurnAccessParams = { + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * authority: '0x1234567890abcdef1234567890abcdef12345678', + * role: 'mint', + * } + * ``` + */ +export interface GrantMintBurnAccessParams { + /** Token address (EVM contract, Solana mint, Aptos FA metadata). */ + tokenAddress: string + /** Address to grant mint/burn access to (pool, multisig, etc.). */ + authority: string + /** Which role(s) to grant. Defaults to `'mintAndBurn'`. */ + role?: MintBurnRole + /** + * EVM token type. Controls which ABI is used for the grant call. + * - `'burnMintERC20'` (default): uses OZ AccessControl `grantRole(bytes32, address)` + * - `'factoryBurnMintERC20'`: uses Ownable `grantMintRole(address)` / `grantBurnRole(address)` + * Ignored on Solana/Aptos. + */ + tokenType?: EVMTokenType +} + +/** + * Unified result from {@link grantMintBurnAccess} on any chain family. + * + * @example + * ```typescript + * const { txHash } = await admin.grantMintBurnAccess(wallet, params) + * console.log(`Granted mint/burn access, tx: ${txHash}`) + * ``` + */ +export interface GrantMintBurnAccessResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Revoke Mint/Burn Access Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for revoking mint or burn permissions on a token. + * + * This is a **token** operation — it modifies permissions on the token, + * not the pool. The `authority` loses the specified role. + * + * | Chain | `role: 'mint'` | `role: 'burn'` | + * |---------|---------------------------------------|---------------------------------------| + * | EVM | `revokeMintRole(authority)` | `revokeBurnRole(authority)` | + * | Aptos | Remove from minter allowlist / revoke MINTER_ROLE | Remove from burner allowlist / revoke BURNER_ROLE | + * | Solana | Not supported (use `transferMintAuthority`) | Not supported | + * + * @example + * ```typescript + * const params: RevokeMintBurnAccessParams = { + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * authority: '0x1234567890abcdef1234567890abcdef12345678', + * role: 'mint', + * } + * ``` + */ +export interface RevokeMintBurnAccessParams { + /** Token address (EVM contract, Aptos FA metadata). */ + tokenAddress: string + /** Address to revoke mint/burn access from. */ + authority: string + /** Which role to revoke — must be specified explicitly. */ + role: 'mint' | 'burn' + /** + * EVM token type. Controls which ABI is used for the revoke call. + * - `'burnMintERC20'` (default): uses OZ AccessControl `revokeRole(bytes32, address)` + * - `'factoryBurnMintERC20'`: uses Ownable `revokeMintRole(address)` / `revokeBurnRole(address)` + * Ignored on Solana/Aptos. + */ + tokenType?: EVMTokenType +} + +/** + * Unified result from {@link revokeMintBurnAccess} on any chain family. + */ +export interface RevokeMintBurnAccessResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Get Mint/Burn Roles Types (read-only) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * EVM result from querying mint/burn roles on a BurnMintERC20 token. + * + * Uses OpenZeppelin AccessControl `getRoleMember` / `getRoleMemberCount` + * to enumerate all addresses with `MINTER_ROLE` and `BURNER_ROLE`. + */ +export interface EVMMintBurnRolesResult { + /** Addresses with the MINTER_ROLE. */ + minters: string[] + /** Addresses with the BURNER_ROLE. */ + burners: string[] +} + +/** + * Solana result from querying mint/burn authority on an SPL token. + * + * SPL tokens have a single `mintAuthority`. If the authority is an + * SPL Token multisig account, the members and threshold are returned. + */ +export interface SolanaMintBurnRolesResult { + /** Current mint authority (base58), or `null` if disabled. */ + mintAuthority: string | null + /** Whether the mint authority is an SPL Token multisig. */ + isMultisig: boolean + /** Multisig threshold (m-of-n). Only set when `isMultisig` is true. */ + multisigThreshold?: number + /** Multisig members. Only set when `isMultisig` is true. */ + multisigMembers?: Array<{ address: string }> +} + +/** + * Aptos result from querying mint/burn roles on a managed or regulated token. + * + * - **managed**: `get_allowed_minters()` / `get_allowed_burners()` + * - **regulated**: `get_minters()` / `get_burners()` / `get_bridge_minters_or_burners()` + */ +export interface AptosMintBurnRolesResult { + /** Detected token module type. */ + tokenModule: 'managed' | 'regulated' | 'unknown' + /** Owner of the code object — can always mint/burn as owner, independent of the allowed lists. */ + owner?: string + /** Addresses allowed to mint. */ + allowedMinters?: string[] + /** Addresses allowed to burn. */ + allowedBurners?: string[] + /** Addresses with BRIDGE_MINTER_OR_BURNER role (regulated only). */ + bridgeMintersOrBurners?: string[] +} + +/** + * Result from {@link createPoolMintAuthorityMultisig} on Solana. + */ +export interface CreatePoolMintAuthorityMultisigResult { + /** The created SPL Token multisig account address (base58). */ + multisigAddress: string + /** The auto-derived Pool Signer PDA (base58). */ + poolSignerPda: string + /** All signers in order: [poolSignerPda, ...additionalSigners]. */ + allSigners: string[] + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Create Pool Token Account Types (Solana-only) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for creating the Pool Signer's Associated Token Account (ATA). + * + * The Pool Token ATA is owned by the Pool Signer PDA and acts as the token + * "vault" the pool uses to hold/transfer tokens during cross-chain operations. + * This account **must** exist before any CCIP transfer involving this pool. + * + * @example + * ```typescript + * const params: CreatePoolTokenAccountParams = { + * tokenAddress: '4w7NYkV9pLjPMeCyg8L2TPEQRJh7xpqpKPokQSfjUfLv', + * poolAddress: '7SWikMcRz3Ffdkm3fYCqfN7DNqhRa7y3GzcGFnLNqLbz', + * } + * ``` + */ +export interface CreatePoolTokenAccountParams { + /** SPL token mint pubkey (base58). */ + tokenAddress: string + /** Pool state PDA (base58). The SDK derives poolProgramId from its on-chain owner. */ + poolAddress: string +} + +/** + * Result from creating the Pool Token Account. + * + * @example + * ```typescript + * const { poolTokenAccount, poolSignerPda, txHash } = await admin.createPoolTokenAccount(wallet, params) + * console.log(`Pool ATA created at ${poolTokenAccount}, tx: ${txHash}`) + * ``` + */ +export interface CreatePoolTokenAccountResult { + /** Address of the created ATA (base58). */ + poolTokenAccount: string + /** Pool Signer PDA that owns this ATA (base58). */ + poolSignerPda: string + /** Transaction signature. Empty string if account already existed. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Create Token Address Lookup Table Types (Solana-only) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for creating an Address Lookup Table (ALT) for a token's CCIP pool. + * + * The ALT contains 10 base CCIP addresses auto-derived from the token, pool, and router. + * These addresses are used by the CCIP router during cross-chain pool operations. + * + * @example + * ```typescript + * const params: CreateTokenAltParams = { + * tokenAddress: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * poolAddress: '2pGY9WAjanpR3RnY5hQ1a23uDNomzFCAD5HMBgo8nH6M', + * routerAddress: 'Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C', + * } + * ``` + */ +export interface CreateTokenAltParams { + /** SPL token mint pubkey (base58). */ + tokenAddress: string + /** Pool state PDA (base58). The SDK derives poolProgramId from its on-chain owner. */ + poolAddress: string + /** CCIP Router program ID (base58). The SDK discovers feeQuoter from its config. */ + routerAddress: string + /** + * ALT authority (base58). Defaults to sender (wallet) if omitted. + * Can differ from the payer — useful for multisig setups where the authority + * is a Squads vault that can later extend/close the ALT. + */ + authority?: string + /** + * Extra addresses to append after the 10 base CCIP addresses (max 246). + * + * When to use: + * - **Burn-mint with SPL Token Multisig**: pass the multisig address here. + * The pool's on-chain mint instruction needs the multisig account in the + * transaction to mint through it (appended at index 10). + * - **Lock-release**: not needed (10 base addresses are sufficient). + * - **Burn-mint with direct mint authority**: not needed. + */ + additionalAddresses?: string[] +} + +/** + * Result from creating a token Address Lookup Table. + * + * @example + * ```typescript + * const { lookupTableAddress, txHash } = await admin.createTokenAlt(wallet, params) + * console.log(`ALT created at ${lookupTableAddress}, tx: ${txHash}`) + * ``` + */ +export interface CreateTokenAltResult { + /** Address of the created ALT (base58). */ + lookupTableAddress: string + /** Transaction signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Set Pool Types +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for setPool — register a pool in the TokenAdminRegistry. + * + * Links a token to its pool so the CCIP router can route cross-chain + * messages through it. + * + * @example + * ```typescript + * const params: SetPoolParams = { + * tokenAddress: '0xa42BA090720aEE0602aD4381FAdcC9380aD3d888', + * poolAddress: '0xd7BF0d8E6C242b6Dde4490Ab3aFc8C1e811ec9aD', + * routerAddress: '0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59', + * } + * ``` + */ +export interface SetPoolParams { + /** Token address (EVM hex / Solana base58 / Aptos hex). */ + tokenAddress: string + /** Pool address to link (EVM: pool contract / Solana: pool state PDA / Aptos: pool resource address). */ + poolAddress: string + /** Router address (used to discover TokenAdminRegistry on EVM, program ID on Solana/Aptos). */ + routerAddress: string +} + +/** + * Solana-specific setPool params — extends base with ALT requirement. + * + * @example + * ```typescript + * const params: SolanaSetPoolParams = { + * tokenAddress: 'J6fECVXwSX5UAeJuC2oCKrsJRjTizWa9uF1FjqzYLa9M', + * poolAddress: '99UxveAueaH64QFiTMKdo9NYD99dMVnMmiqUKv9JQ7xr', + * routerAddress: 'Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C', + * poolLookupTable: 'C6jBE3MDmnqTzo5Dc3BopMyP8vc8jsEDwuHi5rwQgLxC', + * } + * ``` + */ +export interface SolanaSetPoolParams extends SetPoolParams { + /** Address Lookup Table (base58) created via `createTokenAlt`. Required on Solana. */ + poolLookupTable: string +} + +/** + * Result of setPool operation. + * + * @example + * ```typescript + * const { txHash } = await admin.setPool(wallet, params) + * console.log(`Pool registered, tx: ${txHash}`) + * ``` + */ +export interface SetPoolResult { + /** Transaction hash/signature. */ + txHash: string +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Transfer Ownership Types (2-step pool ownership transfer) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Parameters for transferOwnership — propose new pool owner. + * + * @example + * ```typescript + * const params: TransferOwnershipParams = { + * poolAddress: '0x1234...', + * newOwner: '0xabcd...', + * } + * ``` + */ +export interface TransferOwnershipParams { + /** Pool address (EVM hex / Solana base58 / Aptos hex). */ + poolAddress: string + /** New owner address to propose. */ + newOwner: string +} + +/** + * Parameters for acceptOwnership — accept proposed pool ownership. + * + * @example + * ```typescript + * const params: AcceptOwnershipParams = { + * poolAddress: '0x1234...', + * } + * ``` + */ +export interface AcceptOwnershipParams { + /** Pool address (EVM hex / Solana base58 / Aptos hex). */ + poolAddress: string +} + +/** + * Parameters for executeOwnershipTransfer — Aptos-only 3rd step. + * + * Aptos uses a 3-step ownership transfer: + * 1. `transferOwnership(newOwner)` — current owner proposes + * 2. `acceptOwnership()` — proposed owner signals acceptance + * 3. `executeOwnershipTransfer(newOwner)` — current owner finalizes the AptosFramework object transfer + * + * @example + * ```typescript + * const params: ExecuteOwnershipTransferParams = { + * poolAddress: '0x1234...', + * newOwner: '0xabcd...', + * } + * ``` + */ +export interface ExecuteOwnershipTransferParams { + /** Pool address (Aptos hex). */ + poolAddress: string + /** New owner address — must match the address that called acceptOwnership. */ + newOwner: string +} + +/** + * Result of transferOwnership, acceptOwnership, or executeOwnershipTransfer. + * + * @example + * ```typescript + * const { txHash } = await admin.transferOwnership(wallet, params) + * console.log(`Ownership proposed, tx: ${txHash}`) + * ``` + */ +export interface OwnershipResult { + /** Transaction hash/signature. */ + txHash: string +} diff --git a/docs/cct-poc.md b/docs/cct-poc.md new file mode 100644 index 00000000..1bc13f20 --- /dev/null +++ b/docs/cct-poc.md @@ -0,0 +1,1094 @@ +# CCT PoC Guide: Cross-Chain Token Deployment & Transfer Testing + +This guide walks through the complete flow for deploying cross-chain tokens and pools using `ccip-cli`, configuring a 3-chain mesh (EVM, Solana, Aptos), and testing cross-chain transfers. Based on real testnet results (Sepolia, Solana Devnet, Aptos Testnet). + +**Token/Pool stack used in this guide:** + +| Chain | Token Type | Pool Type | Decimals | +|-------|-----------|-----------|----------| +| EVM (Sepolia) | FactoryBurnMintERC20 | BurnMint | 18 | +| Solana (Devnet) | Token-2022 (SPL) | BurnMint | 9 | +| Aptos (Testnet) | Managed Token | Managed Token Pool | 8 | + +> **Prerequisite**: Run all commands from the `ccip-cli/` directory. + +--- + +## Table of Contents + +1. [Prerequisites & Wallet Setup](#1-prerequisites--wallet-setup) +2. [Phase 1: Deploy Tokens](#2-phase-1-deploy-tokens) +3. [Phase 2: Mint Tokens](#3-phase-2-mint-tokens) +4. [Phase 3: Deploy Pools](#4-phase-3-deploy-pools) +5. [Phase 4: Register as Token Admin](#5-phase-4-register-as-token-admin) +6. [Phase 5: Grant Mint/Burn Access to Pool](#6-phase-5-grant-mintburn-access-to-pool) +7. [Phase 6: Create Token ALT (Solana only)](#7-phase-6-create-token-alt-solana-only) +8. [Phase 7: Apply Chain Updates (Mesh Configuration)](#8-phase-7-apply-chain-updates-mesh-configuration) +9. [Phase 8: Set Pool in TokenAdminRegistry](#9-phase-8-set-pool-in-tokenadminregistry) +10. [Phase 9: Cross-Chain Transfers](#10-phase-9-cross-chain-transfers) +11. [Additional Operations](#11-additional-operations) +12. [Known Issues & Gotchas](#12-known-issues--gotchas) + +--- + +## 1. Prerequisites & Wallet Setup + +### Tools Required + +- Node.js 20+ +- `spl-token` CLI — for Solana token minting +- `cast` (from [Foundry](https://book.getfoundry.sh/)) — for EVM direct contract calls (minting) +- `aptos` CLI — for Aptos token minting and (optionally) contract deployment + +### Build the Project + +Clone the repo and build both the SDK and CLI before running any commands: + +```bash +git clone && cd ccip-tools-ts + +# Install dependencies +npm install + +# Build SDK + CLI (must be done from the repo root) +npm run build +``` + +After building, the CLI is available at `ccip-cli/dist/index.js`. All commands in this guide should be run from the `ccip-cli/` directory: + +```bash +cd ccip-cli +node dist/index.js --help +``` + +### Accounts & Funding + +You need accounts on all 3 chains with enough native tokens to cover transaction fees: + +| Chain | Account | How to fund | Estimated cost | +|-------|---------|-------------|----------------| +| EVM (Sepolia) | Generate with any Ethereum wallet (MetaMask, etc.) | [Sepolia faucet](https://faucets.chain.link/sepolia) | ~0.1 ETH | +| Solana (Devnet) | `solana-keygen new -o ~/.config/solana/id.json` | `solana airdrop 5 --url devnet` | ~5 SOL | +| Aptos (Testnet) | Derive from same private key (Ed25519) | [Aptos faucet](https://aptos.dev/en/network/faucet) | ~2 APT | + +> The same 32-byte hex private key can be used for both EVM and Aptos (they derive different addresses from it). Solana requires a separate keypair file (`~/.config/solana/id.json`). + +### `.env` File Setup + +Create a `.env` file in the `ccip-cli/` directory with your RPC endpoints and private key. The CLI reads this by default (`--rpcs-file ./.env`): + +```bash +# RPC endpoints (one per chain) +RPC_ETHEREUM_SEPOLIA=https://1rpc.io/sepolia +RPC_SOLANA_DEVNET=https://api.devnet.solana.com +RPC_APTOS_TESTNET=https://fullnode.testnet.aptoslabs.com/v1 + +# EVM/Aptos private key (32-byte hex, no 0x prefix) +# Used automatically when --wallet is omitted for EVM/Aptos commands +PRIVATE_KEY= +``` + +### Wallet Configuration + +| Chain | How to pass wallet | Wallet location | Notes | +|--------|-------------------|-----------------|-------| +| EVM | `-w ` or `PRIVATE_KEY` in `.env` | `.env` file | 32-byte hex, auto-loaded from `.env` if `--wallet` omitted | +| Solana | `-w ~/.config/solana/id.json` | `~/.config/solana/id.json` | JSON keypair file (64-byte array). Must always pass `-w` explicitly — `PRIVATE_KEY` from `.env` won't work for Solana | +| Aptos | `-w ` or `PRIVATE_KEY` in `.env` | `.env` file | Same 32-byte hex as EVM (Ed25519 seed), derives a different address | + +### CCIP Contract Addresses (Testnet) + +Fetch from: `https://docs.chain.link/api/ccip/v1/chains?environment=testnet` + +| Chain | Router | Registry Module (EVM) | +|-------|--------|-----------------------| +| EVM (Sepolia) | `0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59` | `0xa3c796d480638d7476792230da1E2ADa86e031b0` | +| Solana (Devnet) | `Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C` | — | +| Aptos (Testnet) | `0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45` | — | + +**Solana Pool Program IDs:** +- BurnMint: `41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB` +- LockRelease: `8eqh8wppT9c5rw4ERqNCffvU6cNFJWff9WmkcYtmGiqC` + +**Aptos MCMS Address:** `0xbdf1b9aacb4e21bf6f255105831df0172e911d4748e488196fde10d2e2a4e32d` + +--- + +## 2. Phase 1: Deploy Tokens + +Deploy a token on each chain. All chains use the same token name/symbol for consistency. + +### EVM (Sepolia) — FactoryBurnMintERC20, 18 decimals + +FactoryBurnMintERC20 uses dedicated `grantMintRole`/`grantBurnRole` functions (simpler than BurnMintERC20's AccessControl). The deployer is the owner but does **not** have the mint role by default — you must call `grantMintRole` before any subsequent minting (see Phase 2). + +> `--initial-supply` mints tokens in the constructor (bypasses the role check). For additional minting later, you need `grantMintRole` first. + +```bash +ccip-cli token deploy \ + -n ethereum-testnet-sepolia \ + --name "CCT Test Token" \ + --symbol CCTEST \ + --decimals 18 \ + --initial-supply 1000000 \ + --token-type factoryBurnMintERC20 \ + -f json +``` + +Output: `tokenAddress` and `txHash`. + +### Solana (Devnet) — Token-2022, 9 decimals + +Token-2022 (SPL Token Extensions) with Metaplex metadata: + +```bash +ccip-cli token deploy \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --name "CCT Test Token" \ + --symbol CCTEST \ + --decimals 9 \ + --token-program token-2022 \ + --metadata-uri "https://cyan-pleasant-anteater-613.mypinata.cloud/ipfs/bafkreieirlwjqbtzniqsgcjebzexlcspcmvd4woh3ajvf2p4fuivkenw6i" \ + --initial-supply 1000000 \ + -f json +``` + +Output: `tokenAddress`, `txHash`, `metadataAddress`. + +### Aptos (Testnet) — Managed Token, 8 decimals + +> **WARNING**: The Aptos `token deploy` command will likely be removed from the SDK. It requires the Aptos CLI installed locally to compile Move contracts, which makes it impractical to bundle in the SDK. **Recommendation**: Deploy Aptos tokens directly from the [`chainlink-aptos`](https://github.com/smartcontractkit/chainlink-aptos) repo using `aptos move deploy-object`. See the [Aptos CLI docs](https://aptos.dev/tools/aptos-cli/) for installation. + +Managed tokens use allowlist-based access control. The deployer is the owner and can add/remove minters and burners. + +```bash +ccip-cli token deploy \ + -n aptos-testnet \ + -w \ + --name "CCT Test Token" \ + --symbol CCTEST \ + --decimals 8 \ + --initial-supply 1000000 \ + -f json +``` + +Output: `tokenAddress`, `txHash`, `codeObjectAddress`. + +### Record Your Addresses + +Save the token addresses from each chain. You'll need them throughout the remaining steps. + +```bash +EVM_TOKEN=0x... +SOLANA_TOKEN= +APTOS_TOKEN=0x... +# Also save the Aptos code object address for minting later +APTOS_CODE_OBJECT=0x... +``` + +--- + +## 3. Phase 2: Mint Tokens + +Mint tokens to your wallet for transfer testing. + +### EVM (Sepolia) — FactoryBurnMintERC20 + +The FactoryBurnMintERC20 deployer is the **owner** but does NOT have the mint role by default. Grant it first (one-time), then mint: + +```bash +# Step 1 (one-time): Grant mint role to deployer +cast send $EVM_TOKEN \ + "grantMintRole(address)" \ + \ + --private-key \ + --rpc-url https://1rpc.io/sepolia + +# Step 2: Mint 1,000,000 tokens (1000000 * 10^18) +cast send $EVM_TOKEN \ + "mint(address,uint256)" \ + \ + 1000000000000000000000000 \ + --private-key \ + --rpc-url https://1rpc.io/sepolia +``` + +> FactoryBurnMintERC20 uses `grantMintRole(address)` instead of BurnMintERC20's `grantRole(bytes32, address)`. + +### Solana (Devnet) — Token-2022 + +Deployer is the mint authority. No extra permission needed: + +```bash +spl-token mint $SOLANA_TOKEN 1000000 --url devnet +``` + +### Aptos (Testnet) + +Uses the code object address from the token deploy output: + +```bash +aptos move run \ + --function-id "$APTOS_CODE_OBJECT::managed_token::mint" \ + --args "address:" "u64:100000000000000" \ + --url https://fullnode.testnet.aptoslabs.com/v1 \ + --private-key \ + --assume-yes +``` + +> The `u64` amount is in raw units (1,000,000 tokens * 10^8 decimals = 100000000000000). + +--- + +## 4. Phase 3: Deploy Pools + +Deploy a BurnMint token pool on each chain. + +### EVM (Sepolia) — BurnMint Pool + +```bash +ccip-cli pool deploy \ + -n ethereum-testnet-sepolia \ + --pool-type burn-mint \ + --token-address $EVM_TOKEN \ + --local-token-decimals 18 \ + --router-address 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + -f json +``` + +### Solana (Devnet) — BurnMint Pool + +```bash +ccip-cli pool deploy \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --pool-type burn-mint \ + --token-address $SOLANA_TOKEN \ + --local-token-decimals 9 \ + --pool-program-id 41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB \ + -f json +``` + +### Aptos (Testnet) — Managed Token Pool + +> **WARNING**: The Aptos `pool deploy` command will likely be removed from the SDK. It requires the Aptos CLI installed locally to compile Move contracts, which makes it impractical to bundle in the SDK. **Recommendation**: Deploy Aptos pools directly from the [`chainlink-aptos`](https://github.com/smartcontractkit/chainlink-aptos) repo using `aptos move deploy-object`. See the [Aptos CLI docs](https://aptos.dev/tools/aptos-cli/) for installation. + +```bash +ccip-cli pool deploy \ + -n aptos-testnet \ + -w \ + --pool-type burn-mint \ + --token-address $APTOS_TOKEN \ + --local-token-decimals 8 \ + --router-address 0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45 \ + --mcms-address 0xbdf1b9aacb4e21bf6f255105831df0172e911d4748e488196fde10d2e2a4e32d \ + -f json +``` + +The Aptos pool deploy is a 2-step process internally: (1) publish CCIPTokenPool shared dependency, (2) publish the managed_token_pool module. The SDK handles both steps automatically. + +### Record Pool Addresses + +```bash +EVM_POOL=0x... +SOLANA_POOL= +APTOS_POOL=0x... +``` + +--- + +## 5. Phase 4: Register as Token Admin + +This is a 2-step process: **propose** then **accept**. You must be the token owner/admin to propose. + +### Step 1: Propose Admin + +#### EVM (Sepolia) + +FactoryBurnMintERC20 tokens implement `getCCIPAdmin()`, so use `--registration-method get-ccip-admin`: + +```bash +ccip-cli token-admin propose-admin \ + -n ethereum-testnet-sepolia \ + --token-address $EVM_TOKEN \ + --registry-module-address 0xa3c796d480638d7476792230da1E2ADa86e031b0 \ + --registration-method get-ccip-admin \ + -f json +``` + +#### Solana (Devnet) + +```bash +ccip-cli token-admin propose-admin \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --token-address $SOLANA_TOKEN \ + --administrator \ + --router-address Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C \ + -f json +``` + +#### Aptos (Testnet) + +```bash +ccip-cli token-admin propose-admin \ + -n aptos-testnet \ + -w \ + --token-address $APTOS_TOKEN \ + --administrator \ + --router-address 0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45 \ + -f json +``` + +### Verify with `get-config` + +After proposing, verify that `pendingAdministrator` is set: + +```bash +ccip-cli token-admin get-config \ + -n ethereum-testnet-sepolia \ + --token-address $EVM_TOKEN \ + --router-address 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + -f json +``` + +Expected: `pendingAdministrator` = your wallet address, `administrator` = zero address. + +### Step 2: Accept Admin + +#### EVM + +```bash +ccip-cli token-admin accept-admin \ + -n ethereum-testnet-sepolia \ + --token-address $EVM_TOKEN \ + --router-address 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + -f json +``` + +#### Solana + +```bash +ccip-cli token-admin accept-admin \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --token-address $SOLANA_TOKEN \ + --router-address Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C \ + -f json +``` + +#### Aptos + +```bash +ccip-cli token-admin accept-admin \ + -n aptos-testnet \ + -w \ + --token-address $APTOS_TOKEN \ + --router-address 0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45 \ + -f json +``` + +After accepting, `get-config` should show `administrator` = your wallet, `pendingAdministrator` = zero. + +--- + +## 6. Phase 5: Grant Mint/Burn Access to Pool + +The pool needs mint and burn permissions on the token to process cross-chain transfers. + +### EVM (Sepolia) — FactoryBurnMintERC20 + +For FactoryBurnMintERC20, pass `--token-type factoryBurnMintERC20`. This uses dedicated `grantMintRole`/`grantBurnRole` functions instead of AccessControl's `grantRole`: + +```bash +ccip-cli token grant-mint-burn-access \ + -n ethereum-testnet-sepolia \ + -w \ + --token-address $EVM_TOKEN \ + --authority $EVM_POOL \ + --token-type factoryBurnMintERC20 \ + --rpc https://1rpc.io/sepolia \ + -f json +``` + +Default `--role mintAndBurn` grants both mint and burn. Use `--role mint` or `--role burn` for granular control. + +### Solana (Devnet) + +Solana uses SPL Token mint authority. This **transfers** mint authority to the specified address — your wallet loses direct minting ability. + +For CCIP, the recommended flow is: +1. Create an SPL Multisig (with `create-multisig`) containing the pool's signer PDA + your wallet +2. Transfer mint authority to the multisig + +```bash +# Step 1: Create multisig (1-of-2: pool signer PDA + your wallet) +# --token-address is an alias for --mint (standard Solana terminology) +ccip-cli token create-multisig \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --token-address $SOLANA_TOKEN \ + --pool-program-id 41FGToCmdaWa1dgZLKFAjvmx6e6AjVTX7SVRibvsMGVB \ + --additional-signers \ + --threshold 1 \ + --rpcs https://api.devnet.solana.com \ + -f json +``` + +Save the `multisigAddress` from the output: + +```bash +SOLANA_MULTISIG= +``` + +```bash +# Step 2: Transfer mint authority to multisig +ccip-cli token grant-mint-burn-access \ + -n solana-devnet \ + -w ~/.config/solana/id.json \ + --token-address $SOLANA_TOKEN \ + --authority $SOLANA_MULTISIG \ + --rpc https://api.devnet.solana.com \ + -f json +``` + +### Aptos (Testnet) + +For Managed tokens, this calls `apply_allowed_minter_updates` + `apply_allowed_burner_updates` (2 txs). The owner retains minting ability — this is additive, not a transfer. + +Pass the **pool address** as `--authority`. The SDK automatically resolves the pool's store address (resource signer PDA) internally via `get_store_address` and grants mint/burn to that address. + +```bash +ccip-cli token grant-mint-burn-access \ + -n aptos-testnet \ + -w \ + --token-address $APTOS_TOKEN \ + --authority $APTOS_POOL \ + --rpc https://fullnode.testnet.aptoslabs.com/v1 \ + -f json +``` + +### Verify with `get-mint-burn-info` + +```bash +# EVM — shows minters[] and burners[] arrays +ccip-cli token get-mint-burn-info \ + -n ethereum-testnet-sepolia \ + --token-address $EVM_TOKEN \ + --rpc https://1rpc.io/sepolia \ + -f json + +# Solana — shows mintAuthority, isMultisig, multisigThreshold, multisigMembers +ccip-cli token get-mint-burn-info \ + -n solana-devnet \ + --token-address $SOLANA_TOKEN \ + --rpc https://api.devnet.solana.com \ + -f json + +# Aptos — shows owner, allowedMinters[], allowedBurners[] +ccip-cli token get-mint-burn-info \ + -n aptos-testnet \ + --token-address $APTOS_TOKEN \ + --rpc https://fullnode.testnet.aptoslabs.com/v1 \ + -f json +``` + +> **Performance note**: `get-mint-burn-info` on FactoryBurnMintERC20 is ~3.5x faster than BurnMintERC20 (direct `getMinters()`/`getBurners()` view functions vs AccessControl role enumeration). + +--- + +## 7. Phase 6: Create Token ALT (Solana only) + +Solana requires an Address Lookup Table (ALT) containing 10 base CCIP addresses for the token's pool. This is a prerequisite for `set-pool`. + +```bash +ccip-cli token-admin create-token-alt \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --token-address $SOLANA_TOKEN \ + --pool-address $SOLANA_POOL \ + --router-address Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C \ + --additional-addresses $SOLANA_MULTISIG \ + --rpcs https://api.devnet.solana.com +``` + +> Include the SPL Multisig via `--additional-addresses` so the router can reference it in `releaseOrMintTokens` transactions. The ALT will contain 11 entries (10 base CCIP + 1 multisig). + +Save the ALT address: + +```bash +SOLANA_ALT= +``` + +--- + +## 8. Phase 7: Apply Chain Updates (Mesh Configuration) + +Configure each pool to know about the remote chains, their tokens, pools, and rate limiters. This creates a mesh where each pool knows how to reach every other pool. + +### Configuration File Format + +Create a JSON config file for each chain. Each file lists the other 2 chains as remotes. + +> **Rate limiter values are in the LOCAL token's smallest unit.** `capacity` is the max tokens in the bucket, `rate` is tokens per second refill. You must scale these values by the local token's decimals: +> - EVM (18 decimals): 10,000 tokens = `10000 × 10^18` = `10000000000000000000000` +> - Solana (9 decimals): 10,000 tokens = `10000 × 10^9` = `10000000000000` +> - Aptos (8 decimals): 10,000 tokens = `10000 × 10^8` = `1000000000000` + +#### EVM config (evm-config.json) + +```json +{ + "chainsToRemove": [], + "chainsToAdd": [ + { + "remoteChainSelector": "solana-devnet", + "remotePoolAddresses": [""], + "remoteTokenAddress": "", + "remoteTokenDecimals": 9, + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000000000000", + "rate": "1000000000000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000000000000", + "rate": "1000000000000000000000" + } + }, + { + "remoteChainSelector": "aptos-testnet", + "remotePoolAddresses": [""], + "remoteTokenAddress": "", + "remoteTokenDecimals": 8, + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000000000000", + "rate": "1000000000000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000000000000", + "rate": "1000000000000000000000" + } + } + ] +} +``` + +#### Solana config (solana-config.json) + +```json +{ + "chainsToRemove": [], + "chainsToAdd": [ + { + "remoteChainSelector": "ethereum-testnet-sepolia", + "remotePoolAddresses": [""], + "remoteTokenAddress": "", + "remoteTokenDecimals": 18, + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000", + "rate": "1000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000", + "rate": "1000000000000" + } + }, + { + "remoteChainSelector": "aptos-testnet", + "remotePoolAddresses": [""], + "remoteTokenAddress": "", + "remoteTokenDecimals": 8, + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000", + "rate": "1000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000", + "rate": "1000000000000" + } + } + ] +} +``` + +#### Aptos config (aptos-config.json) + +```json +{ + "chainsToRemove": [], + "chainsToAdd": [ + { + "remoteChainSelector": "ethereum-testnet-sepolia", + "remotePoolAddresses": [""], + "remoteTokenAddress": "", + "remoteTokenDecimals": 18, + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "1000000000000", + "rate": "100000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "1000000000000", + "rate": "100000000000" + } + }, + { + "remoteChainSelector": "solana-devnet", + "remotePoolAddresses": [""], + "remoteTokenAddress": "", + "remoteTokenDecimals": 9, + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "1000000000000", + "rate": "100000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "1000000000000", + "rate": "100000000000" + } + } + ] +} +``` + +### Apply on Each Chain + +#### EVM + +```bash +ccip-cli pool apply-chain-updates \ + -n ethereum-testnet-sepolia \ + --pool-address $EVM_POOL \ + --config /path/to/evm-config.json \ + -f json +``` + +#### Solana + +```bash +ccip-cli pool apply-chain-updates \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --pool-address $SOLANA_POOL \ + --config /path/to/solana-config.json \ + --rpcs https://api.devnet.solana.com \ + -f json +``` + +#### Aptos + +```bash +ccip-cli pool apply-chain-updates \ + -n aptos-testnet \ + -w \ + --pool-address $APTOS_POOL \ + --config /path/to/aptos-config.json \ + --rpc https://fullnode.testnet.aptoslabs.com/v1 \ + -f json +``` + +### Verify with `pool get-config` + +Check each pool to confirm remote chains, pool addresses, token addresses, and rate limiters are set correctly: + +```bash +# EVM — check Solana remote +ccip-cli pool get-config \ + -n ethereum-testnet-sepolia \ + --pool-address $EVM_POOL \ + --remote-chain solana-devnet \ + -f json + +# Solana — check EVM remote +ccip-cli pool get-config \ + -n solana-devnet \ + --pool-address $SOLANA_POOL \ + --remote-chain ethereum-testnet-sepolia \ + -f json + +# Aptos — check EVM remote +ccip-cli pool get-config \ + -n aptos-testnet \ + --pool-address $APTOS_POOL \ + --remote-chain ethereum-testnet-sepolia \ + -f json +``` + +Each should show `remotePools`, `remoteToken`, and `outboundRateLimiterState`/`inboundRateLimiterState` with the values from your config files. + +### Note: Solana Pool Token ATA (Existing Pools Only) + +If you are configuring an **existing** pool (not deployed via this tutorial), the Pool Signer PDA's Associated Token Account (ATA) must exist before inbound transfers. For pools deployed via `ccip-cli pool deploy`, this is created automatically. + +```bash +# Only needed for existing pools, NOT for fresh deploys from this tutorial +spl-token create-account $SOLANA_TOKEN \ + --owner \ + --fee-payer ~/.config/solana/id.json \ + --url devnet +``` + +--- + +## 9. Phase 8: Set Pool in TokenAdminRegistry + +Register the pool in the TokenAdminRegistry, linking token to pool so the CCIP router can route cross-chain messages through it. + +### EVM (Sepolia) + +```bash +ccip-cli token-admin set-pool \ + -n ethereum-testnet-sepolia \ + --token-address $EVM_TOKEN \ + --pool-address $EVM_POOL \ + --router-address 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 +``` + +### Solana (Devnet) + +Requires `--pool-lookup-table` (the ALT from Phase 6): + +```bash +ccip-cli token-admin set-pool \ + -n solana-devnet \ + --wallet ~/.config/solana/id.json \ + --token-address $SOLANA_TOKEN \ + --pool-address $SOLANA_POOL \ + --router-address Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C \ + --pool-lookup-table $SOLANA_ALT \ + --rpcs https://api.devnet.solana.com +``` + +### Aptos (Testnet) + +```bash +ccip-cli token-admin set-pool \ + -n aptos-testnet \ + --token-address $APTOS_TOKEN \ + --pool-address $APTOS_POOL \ + --router-address 0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45 +``` + +### Verify with `get-config` + +```bash +ccip-cli token-admin get-config \ + -n ethereum-testnet-sepolia \ + --token-address $EVM_TOKEN \ + --router-address 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + -f json +``` + +The `tokenPool` field should now match your pool address. On Solana, also shows `poolLookupTable` and `poolLookupTableEntries`. + +--- + +## 10. Phase 9: Cross-Chain Transfers + +With the mesh fully configured, send tokens between chains. + +### EVM → Solana + +```bash +ccip-cli send \ + -s ethereum-testnet-sepolia \ + -d solana-devnet \ + -r 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + --to \ + -t $EVM_TOKEN=1.0 \ + --ooo -L 0 -f log +``` + +### EVM → Aptos + +```bash +ccip-cli send \ + -s ethereum-testnet-sepolia \ + -d aptos-testnet \ + -r 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + --to \ + -t $EVM_TOKEN=1.0 \ + --ooo -L 0 -f log +``` + +### Solana → EVM + +```bash +ccip-cli send \ + -s solana-devnet \ + -d ethereum-testnet-sepolia \ + -r Ccip842gzYHhvdDkSyi2YVCoAWPbYJoApMFzSxQroE9C \ + --to \ + --wallet ~/.config/solana/id.json \ + -t $SOLANA_TOKEN=0.5 \ + --rpcs https://api.devnet.solana.com \ + --ooo -L 0 -f log +``` + +### Aptos → EVM + +```bash +ccip-cli send \ + -s aptos-testnet \ + -d ethereum-testnet-sepolia \ + -r 0xc748085bd02022a9696dfa2058774f92a07401208bbd34cfd0c6d0ac0287ee45 \ + --to \ + -w \ + -t $APTOS_TOKEN=1.0 \ + --rpc https://fullnode.testnet.aptoslabs.com/v1 \ + --ooo -L 0 -f log +``` + +### Track Message Status + +```bash +ccip-cli show \ + --rpcs \ + --rpcs \ + -f json +``` + +You can also track on the CCIP Explorer: `https://ccip.chain.link/msg/` + +### Transfer Flags + +| Flag | Description | +|------|-------------| +| `-s` | Source chain name | +| `-d` | Destination chain name | +| `-r` | Router address on source chain | +| `--to` | Recipient address on destination chain | +| `-t` | Token and amount (`=`) | +| `--ooo` | Out-of-order execution (recommended for testing) | +| `-L 0` | Gas limit 0 (no receiver contract execution) | + +### Aptos ↔ Solana + +Direct Aptos ↔ Solana lanes may not be configured at the router level on testnet. This is a Chainlink infrastructure limitation, not a code issue. If you get `E_UNSUPPORTED_DESTINATION_CHAIN`, the lane doesn't exist yet. Use EVM as a hub. + +--- + +## 11. Additional Operations + +### Append Remote Pool Addresses + +Add additional remote pool addresses to an existing chain config (e.g., when a new pool is deployed on a remote chain): + +```bash +ccip-cli pool append-remote-pool-addresses \ + -n ethereum-testnet-sepolia \ + --pool-address $EVM_POOL \ + --remote-chain solana-devnet \ + --remote-pool-addresses \ + -f json +``` + +### Remove Remote Pool Addresses + +```bash +ccip-cli pool remove-remote-pool-addresses \ + -n ethereum-testnet-sepolia \ + --pool-address $EVM_POOL \ + --remote-chain solana-devnet \ + --remote-pool-addresses \ + -f json +``` + +### Delete Chain Config + +Remove an entire remote chain configuration from a pool: + +```bash +ccip-cli pool delete-chain-config \ + -n ethereum-testnet-sepolia \ + --pool-address $EVM_POOL \ + --remote-chain solana-devnet \ + -f json +``` + +### Set Rate Limiter Config + +Uses a JSON config file (generate a template with `--generate-config`): + +```bash +# Generate template +ccip-cli pool set-rate-limiter-config --generate-config > rate-limiter-config.json + +# Apply (edit the template first with your values) +ccip-cli pool set-rate-limiter-config \ + -n ethereum-testnet-sepolia \ + --pool-address $EVM_POOL \ + --config rate-limiter-config.json \ + -f json +``` + +Example `rate-limiter-config.json` (values in local token's smallest unit): + +```json +{ + "chainConfigs": [ + { + "remoteChainSelector": "solana-devnet", + "outboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000000000000", + "rate": "1000000000000000000000" + }, + "inboundRateLimiterConfig": { + "isEnabled": true, + "capacity": "10000000000000000000000", + "rate": "1000000000000000000000" + } + } + ] +} +``` + +### Revoke Mint/Burn Access + +Revoke mint or burn permissions individually: + +```bash +ccip-cli token revoke-mint-burn-access \ + -n ethereum-testnet-sepolia \ + -w \ + --token-address $EVM_TOKEN \ + --authority $EVM_POOL \ + --role mint \ + --token-type factoryBurnMintERC20 \ + --rpc https://1rpc.io/sepolia \ + -f json +``` + +### Transfer Admin + +Transfer the TokenAdminRegistry admin role to another address (2-step: transfer then accept): + +```bash +# Current admin initiates transfer +ccip-cli token-admin transfer-admin \ + -n ethereum-testnet-sepolia \ + --token-address $EVM_TOKEN \ + --new-admin \ + --router-address 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + -f json + +# New admin accepts +ccip-cli token-admin accept-admin \ + -n ethereum-testnet-sepolia \ + -w \ + --token-address $EVM_TOKEN \ + --router-address 0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59 \ + -f json +``` + +### Pool Transfer Ownership + +**EVM/Solana** — 2-step process: +```bash +# Owner proposes new owner +ccip-cli pool transfer-ownership \ + -n ethereum-testnet-sepolia \ + --pool-address $EVM_POOL \ + --new-owner + +# New owner accepts +ccip-cli pool accept-ownership \ + -n ethereum-testnet-sepolia \ + -w \ + --pool-address $EVM_POOL +``` + +**Aptos** — 3-step process: +```bash +# Step 1: Current owner proposes +ccip-cli pool transfer-ownership \ + -n aptos-testnet \ + --pool-address $APTOS_POOL \ + --new-owner + +# Step 2: New owner signals acceptance +ccip-cli pool accept-ownership \ + -n aptos-testnet \ + -w \ + --pool-address $APTOS_POOL + +# Step 3: Current owner finalizes (AptosFramework object::transfer) +ccip-cli pool execute-ownership-transfer \ + -n aptos-testnet \ + -w \ + --pool-address $APTOS_POOL \ + --new-owner +``` + +--- + +## 12. Known Issues & Gotchas + +### Pool Address Encoding (Fixed) + +Remote pool addresses in `applyChainUpdates` must preserve their original byte length: +- **Token addresses**: left-padded to 32 bytes (correct for all chains) +- **Pool addresses**: raw bytes at original length (20 bytes for EVM addresses) + +The Solana on-chain program compares incoming `sourcePoolAddress` (20 raw bytes for EVM) against stored pool addresses. If stored as 32 bytes (left-padded), the comparison fails with `InvalidSourcePoolAddress`. + +### Solana Mint Authority Transfer + +`grant-mint-burn-access` on Solana **transfers** mint authority — your wallet loses direct minting ability. Always create a multisig first (via `create-multisig`) to retain access alongside the pool. + +### Aptos 3-Step Ownership Transfer + +Unlike EVM/Solana (2-step: propose → accept), Aptos requires 3 steps: propose → accept → execute. The current owner must call `execute-ownership-transfer` after the new owner accepts. + +### FactoryBurnMintERC20 vs BurnMintERC20 + +- **FactoryBurnMintERC20** (used in this guide): Dedicated functions `grantMintRole`/`grantBurnRole`/`revokeMintRole`/`revokeBurnRole`/`getMinters`/`getBurners`. Simpler and ~3.5x faster for `get-mint-burn-info`. +- **BurnMintERC20**: Uses OpenZeppelin `AccessControl` with `bytes32` role hashes. Requires `grantRole`/`revokeRole` with role constants. + +Always pass `--token-type factoryBurnMintERC20` for grant/revoke commands when using FactoryBurnMintERC20 tokens. + +### Aptos ↔ Solana Direct Lanes + +Direct lanes between Aptos Testnet and Solana Devnet may not exist at the router level. This is a Chainlink infrastructure limitation. Use EVM as a hub for Aptos ↔ Solana transfers. + +### `show` Command Crash on SVM Destinations + +The `show` command crashes when viewing SVM-destination messages because `looksUsdcData()` expects hex `BytesLike` but the CCIP API returns `extraData` as base64 for SVM destinations. + +--- + +## Quick Reference: Complete Flow Checklist + +``` +For each chain: + [ ] 1. Deploy token (EVM: FactoryBurnMintERC20, Solana: Token-2022, Aptos: Managed) + [ ] 2. Mint tokens to wallet + [ ] 3. Deploy pool (EVM/Solana: BurnMint, Aptos: Managed) + [ ] 4. Propose admin (token-admin propose-admin) + [ ] 5. Accept admin (token-admin accept-admin) + [ ] 6. Grant mint/burn access to pool + - EVM: --token-type factoryBurnMintERC20 + - Solana: create-multisig first, then grant to multisig + - Aptos: pass pool address as --authority (SDK auto-resolves store address; additive, owner keeps access) + [ ] 7. (Solana only) Create Token ALT (include multisig in --additional-addresses) + +Cross-chain mesh: + [ ] 8. Apply chain updates on EACH pool (pointing to all remote chains) + [ ] 9. Set pool on EACH chain (token-admin set-pool) + +Testing: + [ ] 10. Send cross-chain transfer (EVM↔Solana, EVM↔Aptos) + [ ] 11. Track message with `show` or CCIP Explorer +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 181a6156..57c72c4a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -114,6 +114,7 @@ export default defineConfig( }, { // Ban cli imports from @chainlink/ccip-sdk modules other than /src/index.ts + // and /src/token-admin/*/index.ts (heavy deps kept out of the main barrel). files: ['ccip-cli/src/**/*.ts'], rules: { 'no-restricted-imports': [ @@ -121,8 +122,10 @@ export default defineConfig( { patterns: [ { - regex: '^(?!@chainlink/ccip-sdk/src/index\\.ts$).*\\/ccip-sdk\\b', - message: 'Import from @chainlink/ccip-sdk/src/index.ts instead of other modules.', + regex: + '^(?!@chainlink/ccip-sdk/src/index\\.ts$|@chainlink/ccip-sdk/src/token-admin/[^/]+/index\\.ts$).*\\/ccip-sdk\\b', + message: + 'Import from @chainlink/ccip-sdk/src/index.ts or @chainlink/ccip-sdk/src/token-admin/*/index.ts instead of other modules.', }, ], },