diff --git a/ccip-sdk/src/cct/evm/index.ts b/ccip-sdk/src/cct/evm/index.ts index 48b11e18..1d929a3a 100644 --- a/ccip-sdk/src/cct/evm/index.ts +++ b/ccip-sdk/src/cct/evm/index.ts @@ -56,6 +56,11 @@ import { type DeployTokenPoolParams, DeployTokenPool, } from './token-pool/operations/deploy-token-pool.ts' +import { + type GetRebalancerParams, + type GetRebalancerResult, + GetRebalancer, +} from './token-pool/operations/get-rebalancer.ts' import { type GetTokenPoolRemotesParams, type GetTokenPoolRemotesResult, @@ -66,6 +71,10 @@ import { type GetTokenPoolStateResult, GetTokenPoolState, } from './token-pool/operations/get-token-pool-state.ts' +import { + type ProvideLiquidityParams, + ProvideLiquidity, +} from './token-pool/operations/provide-liquidity.ts' import { type RemoveRemotePoolParams, RemoveRemotePool, @@ -82,11 +91,21 @@ import { type SetRateLimitAdminParams, SetRateLimitAdmin, } from './token-pool/operations/set-rate-limit-admin.ts' +import { type SetRebalancerParams, SetRebalancer } from './token-pool/operations/set-rebalancer.ts' import { type SetRemotePoolParams, SetRemotePool } from './token-pool/operations/set-remote-pool.ts' +import { + type TransferLiquidityParams, + TransferLiquidity, +} from './token-pool/operations/transfer-liquidity.ts' import { type TransferOwnershipParams, TransferOwnership, } from './token-pool/operations/transfer-ownership.ts' +import { + type WithdrawLiquidityParams, + WithdrawLiquidity, +} from './token-pool/operations/withdraw-liquidity.ts' +import { type ApproveTokenParams, ApproveToken } from './token/operations/approve-token.ts' import { type DeployTokenParams, DeployToken } from './token/operations/deploy-token.ts' import { type GetBurnersParams, @@ -107,6 +126,7 @@ export class EVMTokenManager extends TokenManager { readonly chain: EVMChain // Token operations readonly #deployToken = new DeployToken() + readonly #approveToken = new ApproveToken() readonly #mint = new Mint() readonly #getMinters = new GetMinters() readonly #getBurners = new GetBurners() @@ -134,6 +154,11 @@ export class EVMTokenManager extends TokenManager { readonly #setChainRateLimiterConfigs = new SetChainRateLimiterConfigs() readonly #setRateLimitAdmin = new SetRateLimitAdmin() readonly #setDynamicConfig = new SetDynamicConfig() + readonly #provideLiquidity = new ProvideLiquidity() + readonly #withdrawLiquidity = new WithdrawLiquidity() + readonly #transferLiquidity = new TransferLiquidity() + readonly #setRebalancer = new SetRebalancer() + readonly #getRebalancer = new GetRebalancer() // Lockbox operations readonly #deployLockbox = new DeployLockbox() @@ -646,6 +671,320 @@ export class EVMTokenManager extends TokenManager { return this.#setDynamicConfig.execute(this.chain, opts) } + /** + * Builds an unsigned ERC-20 `approve` tx (for multisig / offline signing): grants `spender` an + * allowance over `sender`'s tokens. + * @remarks The prerequisite for {@link generateUnsignedProvideLiquidity} — a pool deposits with + * `safeTransferFrom`, so a rebalancer must approve the **pool** for at least the deposit first, + * or the deposit reverts `ERC20InsufficientAllowance`. The cross-family counterpart of Solana's + * `approveToken`, which delegates SPL spend authority for the same reason. + * @remarks Works on **any** ERC-20, not only CCT-deployed tokens: `approve(address,uint256)` is + * identical across `FactoryBurnMintERC20` v1.5.1 / v1.6.2 and v2.0.0's `CrossChainToken`, and a + * LockRelease pool may escrow a third-party token. No `typeAndVersion` probe and no chain read. + * @remarks `amount` **replaces** the current allowance (it does not add to it) and is consumed + * as it is spent; `0n` revokes. + * @throws {@link CCTParamsInvalidError} if `tokenAddress` or `spender` is invalid or zero, or + * `amount` is not a `uint256` + * @example + * ```typescript + * // approve a LockRelease pool for a deposit, then deposit + * await cct.approveToken({ tokenAddress: token, spender: pool, amount, wallet }) + * await cct.provideLiquidity({ poolAddress: pool, amount, wallet }) + * ``` + */ + generateUnsignedApproveToken(opts: ApproveTokenParams): Promise { + return this.#approveToken.generate(this.chain, opts) + } + + /** + * Grants an ERC-20 allowance, signing + submitting with `opts.wallet`. `sender` defaults to the + * wallet's address and must equal it — the allowance comes out of the signing account's balance, + * so approving on behalf of another address is rejected rather than signed. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if any param is invalid, or `sender` is given and is not + * the wallet's address + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.approveToken({ + * tokenAddress: '0xToken...', + * spender: '0xPool...', + * amount: 1_000000000000000000n, + * wallet, // the rebalancer + * }) + * ``` + */ + approveToken(opts: EVMExecuteParams): Promise { + return this.#approveToken.execute(this.chain, opts) + } + + /** + * Builds an unsigned pool `provideLiquidity` tx (for multisig / offline signing): deposits + * `amount` of the pool's token into a **LockRelease** pool (v1.5.0–v1.6.1). + * @remarks Gated on the pool's `rebalancer`, **not** its owner: the pool accepts liquidity + * calls only from the account appointed with {@link generateUnsignedSetRebalancer}, and reverts + * `Unauthorized` for everyone else, the owner included. A given `sender` is checked against + * `getRebalancer()` before any calldata is built. + * @remarks The rebalancer must hold `amount` of the pool's token **and** have approved the pool + * for it — the deposit is a `transferFrom`. Set that allowance with + * {@link generateUnsignedApproveToken} / {@link approveToken}, `spender` being the pool. Both + * are read before the calldata is returned, so a missing approval is reported here instead of + * reverting `ERC20InsufficientAllowance` in the wallet. Matches Solana's `provideLiquidity`, + * which likewise refuses to build without the delegation behind it. + * @remarks On a v1.5.0 / v1.5.1 pool the immutable `acceptLiquidity` flag is read too: a pool + * deployed with it `false` can never take deposits, so that is reported before signing rather + * than as a `LiquidityNotAccepted` revert. v1.6.1 dropped the flag. + * @throws {@link CCTContractTypeInvalidError} if `poolAddress` is a BurnMint pool, which has no + * liquidity to manage + * @throws {@link CCTOperationUnsupportedError} on a **v2.0.0** pool, which escrows through an + * external `ERC20LockBox` instead — see {@link deployLockbox} / {@link authorizeLockboxCallers} + * @throws {@link CCTParamsInvalidError} if any param is invalid, `amount` is zero, the pool + * cannot accept liquidity, or `sender` is given and is not the pool's rebalancer + * @throws {@link CCTTxFailedError} if `sender` holds less than `amount` of the pool's token, or + * has approved the pool for less than `amount` + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + * @example + * ```typescript + * // build only — sign later (multisig / offline). `sender` must be the pool rebalancer. + * const unsigned = await cct.generateUnsignedProvideLiquidity({ + * poolAddress: '0xPool...', + * amount: 1_000000000000000000n, + * sender: '0xRebalancer...', + * }) + * ``` + */ + generateUnsignedProvideLiquidity(opts: ProvideLiquidityParams): Promise { + return this.#provideLiquidity.generate(this.chain, opts) + } + + /** + * Deposits liquidity into a LockRelease pool, signing + submitting with `opts.wallet`. `sender` + * defaults to the wallet's address and must equal it — the wallet must be the pool's + * rebalancer, and must have approved `amount` to the pool with {@link approveToken}. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, or the wallet is not the pool's rebalancer + * @throws {@link CCTTxFailedError} if the wallet's token balance or its approval to the pool is + * below `amount` + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.provideLiquidity({ + * poolAddress: '0xPool...', + * amount: 1_000000000000000000n, + * wallet, // the pool rebalancer + * }) + * ``` + */ + provideLiquidity(opts: EVMExecuteParams): Promise { + return this.#provideLiquidity.execute(this.chain, opts) + } + + /** + * Builds an unsigned pool `withdrawLiquidity` tx (for multisig / offline signing): pulls + * `amount` of the pool's token back out of a **LockRelease** pool (v1.5.0–v1.6.1). + * @remarks Gated on the pool's `rebalancer`, **not** its owner, and the tokens are sent to + * `msg.sender` — so they land with the rebalancer, whoever signs. A given `sender` is checked + * against `getRebalancer()` before any calldata is built. + * @remarks The pool's balance is read first, so withdrawing more than it can pay is reported + * before signing. Advisory only: every CCIP transfer moves that balance, so a later shortfall + * still reverts `InsufficientLiquidity`. + * @throws {@link CCTContractTypeInvalidError} if `poolAddress` is a BurnMint pool + * @throws {@link CCTOperationUnsupportedError} on a **v2.0.0** pool, which escrows through an + * external `ERC20LockBox` instead + * @throws {@link CCTParamsInvalidError} if any param is invalid, `amount` is zero, or `sender` + * is given and is not the pool's rebalancer + * @throws {@link CCTTxFailedError} if the pool's withdrawable liquidity is below `amount` + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + * @example + * ```typescript + * // build only — sign later (multisig / offline). `sender` must be the pool rebalancer. + * const unsigned = await cct.generateUnsignedWithdrawLiquidity({ + * poolAddress: '0xPool...', + * amount: 1_000000000000000000n, + * sender: '0xRebalancer...', + * }) + * ``` + */ + generateUnsignedWithdrawLiquidity(opts: WithdrawLiquidityParams): Promise { + return this.#withdrawLiquidity.generate(this.chain, opts) + } + + /** + * Withdraws liquidity from a LockRelease pool to the signing wallet, which must be the pool's + * rebalancer. `sender` defaults to the wallet's address and must equal it. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, or the wallet is not the pool's rebalancer + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain, e.g. + * `InsufficientLiquidity` + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.withdrawLiquidity({ + * poolAddress: '0xPool...', + * amount: 1_000000000000000000n, + * wallet, // the pool rebalancer, which also receives the tokens + * }) + * ``` + */ + withdrawLiquidity(opts: EVMExecuteParams): Promise { + return this.#withdrawLiquidity.execute(this.chain, opts) + } + + /** + * Builds an unsigned pool `transferLiquidity` tx (for multisig / offline signing): moves + * liquidity out of an older LockRelease pool (`from`) into this one (v1.5.0–v1.6.1). The + * pool-upgrade primitive. + * @remarks Two-step, because the new pool withdraws from the old one as its rebalancer: first + * point the **old** pool's rebalancer at the new pool with + * {@link generateUnsignedSetRebalancer}, then call this on the **new** pool. + * @remarks The source pool is read before any calldata is built: it must be a LockRelease pool + * escrowing the **same token**, hold the amount, and have `poolAddress` as its rebalancer. The + * token check has no on-chain counterpart, and a mismatch does not revert: the destination would + * silently receive an asset it does not manage. + * @remarks From v1.6.1, `amount: MaxUint256` means "the source pool's whole balance"; on a + * v1.5.x pool that sentinel does not exist and is rejected rather than left to revert. + * @throws {@link CCTContractTypeInvalidError} if `poolAddress` is a BurnMint pool, or a + * `SiloedLockReleaseTokenPool` — siloed liquidity is per-lane and has no `transferLiquidity` + * @throws {@link CCTOperationUnsupportedError} on a **v2.0.0** pool, which escrows through an + * external `ERC20LockBox` instead + * @throws {@link CCTParamsInvalidError} if any param is invalid, `from` equals `poolAddress`, + * `amount` is zero, `from` escrows a different token or does not have `poolAddress` as its + * rebalancer, or `sender` is given and does not own `poolAddress` + * @throws {@link CCTTxFailedError} if `from` holds less than `amount` + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + * @example + * ```typescript + * import { MaxUint256 } from 'ethers' + * + * // step 1, on the old pool: let the new pool withdraw from it + * await cct.setRebalancer({ poolAddress: oldPool, rebalancer: newPool, wallet }) + * // step 2, on the new pool: pull everything across (v1.6.1+) + * const unsigned = await cct.generateUnsignedTransferLiquidity({ + * poolAddress: newPool, + * from: oldPool, // the source pool, not the signer — see `sender` + * amount: MaxUint256, // the source pool's whole balance + * sender: '0xOwner...', + * }) + * ``` + */ + generateUnsignedTransferLiquidity(opts: TransferLiquidityParams): Promise { + return this.#transferLiquidity.generate(this.chain, opts) + } + + /** + * Migrates liquidity from an older LockRelease pool into this one, signing + submitting with + * `opts.wallet`. `sender` defaults to the wallet's address and must equal it — the wallet must + * own the destination pool. See {@link generateUnsignedTransferLiquidity} for the two-step + * rebalancer wiring this depends on. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, the source pool is not wired to `poolAddress`, or the wallet does not + * own `poolAddress` + * @throws {@link CCTTxFailedError} if `from` holds less than `amount` + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain, e.g. + * `InsufficientLiquidity` when the source pool holds less than `amount` + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.transferLiquidity({ + * poolAddress: newPool, + * from: oldPool, + * amount: 1_000000000000000000n, + * wallet, // owner of the new pool + * }) + * ``` + */ + transferLiquidity(opts: EVMExecuteParams): Promise { + return this.#transferLiquidity.execute(this.chain, opts) + } + + /** + * Builds an unsigned pool `setRebalancer` tx (for multisig / offline signing): appoints the + * **LockRelease** pool role allowed to move liquidity (v1.5.0–v1.6.1). + * @remarks Owner-only, and the appointee — not the owner — is who + * {@link generateUnsignedProvideLiquidity} and {@link generateUnsignedWithdrawLiquidity} then + * accept. When `sender` is supplied it is checked against the pool's `owner()` before any + * calldata is built; omit it and no owner read is made (nothing to compare against). + * + * A zero `rebalancer` is accepted and revokes the role, which stops liquidity movement + * entirely: the pool then accepts those calls from nobody. + * @throws {@link CCTContractTypeInvalidError} if `poolAddress` is a BurnMint pool + * @throws {@link CCTOperationUnsupportedError} on a **v2.0.0** pool, which authorizes liquidity + * on its `ERC20LockBox` instead — see {@link authorizeLockboxCallers} + * @throws {@link CCTParamsInvalidError} if any param is invalid, `poolAddress` is the zero + * address, or `sender` is given and is not the pool owner + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + * @example + * ```typescript + * // build only — sign later (multisig / offline). `sender` must be the pool owner. + * const unsigned = await cct.generateUnsignedSetRebalancer({ + * poolAddress: '0xPool...', + * rebalancer: '0xLiquidityOps...', + * sender: '0xOwner...', + * }) + * ``` + */ + generateUnsignedSetRebalancer(opts: SetRebalancerParams): Promise { + return this.#setRebalancer.generate(this.chain, opts) + } + + /** + * Appoints the pool's rebalancer, signing + submitting with `opts.wallet`. `sender` defaults to + * the wallet's address and must equal it — the wallet must be the pool owner. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool + * @throws {@link CCTParamsInvalidError} if any param is invalid, `sender` is given and is not + * the wallet's address, or the wallet is not the pool owner + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + * @throws {@link CCTTxFailedError} if submission fails before broadcast + * @throws {@link CCTTxNotConfirmedError} if it is not confirmed in time + * @example + * ```typescript + * const { hash } = await cct.setRebalancer({ + * poolAddress: '0xPool...', + * rebalancer: '0xLiquidityOps...', + * wallet, // the pool owner + * }) + * ``` + */ + setRebalancer(opts: EVMExecuteParams): Promise { + return this.#setRebalancer.execute(this.chain, opts) + } + + /** + * Reads a LockRelease pool's rebalancer — the account allowed to move its liquidity + * (v1.5.0–v1.6.1). + * @remarks Informational, for audit and UX: the liquidity write ops make this same check + * themselves, so there is no need to call this first. + * @remarks On a `SiloedLockReleaseTokenPool` this is the *unsiloed* rebalancer, which is what + * its plain `provideLiquidity` / `withdrawLiquidity` gate on. + * @returns The rebalancer, checksummed. The zero address when none is configured, meaning the + * pool accepts liquidity calls from nobody. + * @throws {@link CCTContractTypeInvalidError} if `poolAddress` is a BurnMint pool + * @throws {@link CCTOperationUnsupportedError} on a **v2.0.0** pool, which has no rebalancer — + * its `ERC20LockBox` authorizes its own callers + * @throws {@link CCTParamsInvalidError} if `poolAddress` is not a valid address + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + * @example + * ```typescript + * const rebalancer = await cct.getRebalancer({ poolAddress: '0xPool...' }) + * ``` + */ + getRebalancer(opts: GetRebalancerParams): Promise { + return this.#getRebalancer.query(this.chain, opts) + } + /** * Builds an unsigned `CrossChainToken` (v2.0.0) deployment tx (for multisig / offline * signing). The deployed address is only known once mined, so it is NOT returned here — @@ -1434,6 +1773,7 @@ export type { } from './token-admin-registry/operations/get-supported-tokens.ts' export * from './token-admin-registry/contracts.ts' export type { DeployTokenParams } from './token/operations/deploy-token.ts' +export type { ApproveTokenParams } from './token/operations/approve-token.ts' export type { MintParams } from './token/operations/mint.ts' export type { GetMintersParams, GetMintersResult } from './token/operations/get-minters.ts' export type { GetBurnersParams, GetBurnersResult } from './token/operations/get-burners.ts' @@ -1479,6 +1819,14 @@ export type { SetChainRateLimiterConfigsParams, } from './token-pool/operations/set-chain-rate-limiter-configs.ts' export type { RateLimitConfig } from './token-pool/rate-limit.ts' +export type { ProvideLiquidityParams } from './token-pool/operations/provide-liquidity.ts' +export type { WithdrawLiquidityParams } from './token-pool/operations/withdraw-liquidity.ts' +export type { TransferLiquidityParams } from './token-pool/operations/transfer-liquidity.ts' +export type { SetRebalancerParams } from './token-pool/operations/set-rebalancer.ts' +export type { + GetRebalancerParams, + GetRebalancerResult, +} from './token-pool/operations/get-rebalancer.ts' export * from './token-pool/contracts.ts' export type { DeployLockboxParams } from './lockbox/operations/deploy-lockbox.ts' export type { AuthorizeLockboxCallersParams } from './lockbox/operations/authorize-callers.ts' diff --git a/ccip-sdk/src/cct/evm/token-pool/contracts.test.ts b/ccip-sdk/src/cct/evm/token-pool/contracts.test.ts index a0f20f22..6252ff8d 100644 --- a/ccip-sdk/src/cct/evm/token-pool/contracts.test.ts +++ b/ccip-sdk/src/cct/evm/token-pool/contracts.test.ts @@ -13,6 +13,7 @@ import { TOKEN_POOL_INTERFACES, TOKEN_POOL_TYPES, TokenPoolVersion, + assertLockReleasePool, getTokenPoolFamily, getTokenPoolInterface, isLockReleaseTokenPoolType, @@ -225,6 +226,84 @@ describe('TOKEN_POOL_INTERFACES', () => { }) }) +/** The functions the LockRelease liquidity + rebalancer ops encode, checked against the ABIs. */ +const LIQUIDITY_FUNCTIONS = [ + 'provideLiquidity', + 'withdrawLiquidity', + 'transferLiquidity', + 'setRebalancer', + 'getRebalancer', +] as const + +describe('LockRelease liquidity surface', () => { + /** The versions the liquidity ops floor-match a single 1.5.0 encoder across. */ + const V1_X = [TokenPoolVersion.V1_5_0, TokenPoolVersion.V1_5_1, TokenPoolVersion.V1_6_1] as const + + it('declares every liquidity function with an identical signature at v1.5.0–v1.6.1', () => { + // this parity is what licenses one encoder entry at 1.5.0 instead of a per-version table + for (const fn of LIQUIDITY_FUNCTIONS) { + const [first, ...rest] = V1_X.map((version) => + TOKEN_POOL_INTERFACES.LockRelease[version].getFunction(fn)!.format('sighash'), + ) + for (const sighash of rest) assert.equal(sighash, first, `${fn} diverged across v1.x`) + } + }) + + it('drops every liquidity function at v2.0.0, which escrows through a lockbox', () => { + for (const fn of LIQUIDITY_FUNCTIONS) + assert.equal( + TOKEN_POOL_INTERFACES.LockRelease[TokenPoolVersion.V2_0_0].hasFunction(fn), + false, + `${fn} unexpectedly present at 2.0.0`, + ) + }) + + it('declares no liquidity function on the BurnMint family at any version', () => { + for (const version of Object.values(TokenPoolVersion)) + for (const fn of LIQUIDITY_FUNCTIONS) + assert.equal( + TOKEN_POOL_INTERFACES.BurnMint[version].hasFunction(fn), + false, + `${fn} unexpectedly present on BurnMint ${version}`, + ) + }) + + it('declares canAcceptLiquidity only at v1.5.0 and v1.5.1', () => { + assert.equal( + TOKEN_POOL_INTERFACES.LockRelease[TokenPoolVersion.V1_5_0].hasFunction('canAcceptLiquidity'), + true, + ) + assert.equal( + TOKEN_POOL_INTERFACES.LockRelease[TokenPoolVersion.V1_5_1].hasFunction('canAcceptLiquidity'), + true, + ) + // 1.6.1 dropped the immutable flag and always accepts deposits + assert.equal( + TOKEN_POOL_INTERFACES.LockRelease[TokenPoolVersion.V1_6_1].hasFunction('canAcceptLiquidity'), + false, + ) + }) +}) + +describe('assertLockReleasePool', () => { + it('passes every lock-release type through', () => { + for (const type of TOKEN_POOL_TYPES.filter(isLockReleaseTokenPoolType)) + assert.doesNotThrow(() => assertLockReleasePool('provideLiquidity', ADDR, type)) + }) + + it('rejects every burn-mint type, naming the operation', () => { + for (const type of TOKEN_POOL_TYPES.filter((t) => !isLockReleaseTokenPoolType(t))) + assert.throws( + () => assertLockReleasePool('provideLiquidity', ADDR, type), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && + err.context.address === ADDR && + err.context.actual === type && + err.context.operation === 'provideLiquidity', + ) + }) +}) + describe('getTokenPoolInterface', () => { it('returns the cached family Interface for the type+version (same instance across calls)', () => { const a = getTokenPoolInterface('BurnMintTokenPool', TokenPoolVersion.V1_5_1) diff --git a/ccip-sdk/src/cct/evm/token-pool/contracts.ts b/ccip-sdk/src/cct/evm/token-pool/contracts.ts index 2f8ba21f..96ca8ca6 100644 --- a/ccip-sdk/src/cct/evm/token-pool/contracts.ts +++ b/ccip-sdk/src/cct/evm/token-pool/contracts.ts @@ -5,13 +5,16 @@ * ({@link getTokenPoolArtifact}), the narrow role reads every owner-gated write pre-flights * `sender` against ({@link readTokenPoolOwner}, {@link readTokenPoolRateLimitAdmin}), the allowlist read * `applyAllowlistUpdates` pre-flights against ({@link readTokenPoolAllowlist}) plus the - * owner-only guard built on the first of them ({@link assertPoolOwner}). The write-side - * rate-limit shape lane-config ops share lives in `rate-limit.ts`. Mirrors `token/contracts.ts`. + * owner-only guard built on the first of them ({@link assertPoolOwner}), and the LockRelease + * liquidity layer: the rebalancer and liquidity reads plus the guards the liquidity ops pre-flight + * with ({@link assertLockReleasePool}, {@link assertPoolRebalancer}, + * {@link assertLiquidityFunding}, {@link assertPoolLiquidity}). The write-side rate-limit shape + * lane-config ops share lives in `rate-limit.ts`. Mirrors `token/contracts.ts`. * * @packageDocumentation */ -import { Interface, getAddress } from 'ethers' +import { Interface, ZeroAddress, getAddress } from 'ethers' import type { TypedContract } from 'ethers-abitype' import type { EVMChain } from '../../../evm/index.ts' @@ -21,10 +24,12 @@ import { CCTContractVersionUnsupportedError, CCTOperationUnsupportedError, CCTParamsInvalidError, + CCTTxFailedError, } from '../../errors.ts' import BURN_MINT_TOKEN_POOL_V1_5_0_ABI from '../artifacts/abi/V1_5_0/burn-mint-token-pool-and-proxy.ts' import LOCK_RELEASE_TOKEN_POOL_V1_5_0_ABI from '../artifacts/abi/V1_5_0/lock-release-token-pool-and-proxy.ts' import BURN_MINT_TOKEN_POOL_V1_5_1_ABI from '../artifacts/abi/V1_5_1/burn-mint-token-pool.ts' +import FACTORY_BURN_MINT_ERC20_V1_5_1_ABI from '../artifacts/abi/V1_5_1/factory-burn-mint-erc20.ts' import LOCK_RELEASE_TOKEN_POOL_V1_5_1_ABI from '../artifacts/abi/V1_5_1/lock-release-token-pool.ts' import BURN_MINT_TOKEN_POOL_V1_6_1_ABI from '../artifacts/abi/V1_6_1/burn-mint-token-pool.ts' import LOCK_RELEASE_TOKEN_POOL_V1_6_1_ABI from '../artifacts/abi/V1_6_1/lock-release-token-pool.ts' @@ -186,6 +191,30 @@ export async function assertPoolOwner( ) } +/** + * Guards a LockRelease-only op: the liquidity and rebalancer functions are absent from the + * `BurnMint` ABI, so without this the op would hand that {@link Interface} an unknown function + * name and fail as an opaque ethers error instead of naming the real problem. + * @param operation - Operation name, for the error's context. + * @param poolAddress - Token pool being acted on. + * @param type - Pool type, as resolved by {@link resolveTokenPool}. + * @throws {@link CCTContractTypeInvalidError} if `type` is not a {@link LockReleaseTokenPoolType} + */ +export function assertLockReleasePool( + operation: string, + poolAddress: string, + type: TokenPoolType, +): void { + if (isLockReleaseTokenPoolType(type)) return + throw new CCTContractTypeInvalidError( + poolAddress, + 'LockRelease token pool', + type, + `${operation} is a lock/release liquidity function, which the BurnMint pools do not declare`, + { context: { operation } }, + ) +} + /** * Cached pool {@link Interface}s per {@link TokenPoolFamily} and {@link TokenPoolVersion}, * built once from the vendored `artifacts/` ABIs (no per-call `new Interface`). `V1_5_0` @@ -223,19 +252,10 @@ export function getTokenPoolInterface(type: TokenPoolType, version: TokenPoolVer * same selector, same `address` return — by both {@link TOKEN_POOL_FAMILIES} at all four * supported versions, so the v1.5.0 `BurnMint` interface types the call for every pool. * @remarks **Deliberately not routed through the `getTokenPoolState` query op, and must not be - * "simplified" back to it.** Two reasons, the first of which is a correctness bug and not just a - * cost concern: - * - * 1. `getTokenPoolState` throws {@link CCTContractTypeInvalidError} for a v2.0.0 - * `SiloedLockReleaseTokenPool`, because that pool escrows per remote chain - * (`getLockBox(uint64)`) and so has no single `lockBox` field for the query's result shape to - * report. `SiloedLockReleaseTokenPool` is nonetheless a supported {@link TokenPoolType}, and - * the write ops' calldata is perfectly valid against it. Gating an owner check through that - * query would therefore make every one of those ops permanently unusable on siloed pools — - * failing on an unrelated result-shape limitation while `generateUnsigned*` works fine. - * 2. It costs 6–8 `eth_call`s (token, router, RMN proxy, rate-limit admin, supported chains, - * dynamic config, finality config, lockbox) plus a `getTokenInfo` round trip, and re-resolves - * `typeAndVersion`, all to obtain one address. + * "simplified" back to it.** That query costs 6–8 `eth_call`s (token, router, RMN proxy, + * rate-limit admin, supported chains, dynamic config, finality config, lockbox) plus a + * `getTokenInfo` round trip, and re-resolves `typeAndVersion`, all to obtain one address that + * this one call returns — on every owner-gated write op, at every version. * * This mirrors `token-admin-registry/operations/transfer-admin.ts`, which likewise does its own * narrow pre-tx read rather than going through a read op. @@ -323,6 +343,185 @@ export async function readTokenPoolRateLimitAdmin( return getAddress(resultToObject(await pool.getRateLimitAdmin())) } +/** + * Reads a LockRelease pool's `rebalancer` — the single account the pool accepts + * `provideLiquidity` / `withdrawLiquidity` from — in one `eth_call`. + * + * @remarks No dispatch, but callers must resolve the pool first + * ({@link assertLockReleasePool}, plus a v2.0.0 check): `getRebalancer()` is declared identically + * at v1.5.0–v1.6.1 by both LockRelease types, and is absent from a `BurnMint` pool and from + * v2.0.0, so those cases should report the type or version rather than a bare call failure. + * @remarks On a `SiloedLockReleaseTokenPool` this is the *unsiloed* rebalancer, which is what its + * plain liquidity entry points gate on; the per-lane `getChainRebalancer(uint64)` governs the + * siloed ones, which this SDK does not expose. + * @param chain - Chain to read from. + * @param poolAddress - LockRelease pool to read `getRebalancer()` from. + * @returns The current rebalancer, checksummed; the zero address when none is configured, which + * means the pool accepts liquidity calls from nobody. + */ +export async function readTokenPoolRebalancer( + chain: EVMChain, + poolAddress: string, +): Promise { + const pool = getTypedContract(chain, poolAddress, LOCK_RELEASE_TOKEN_POOL_V1_5_1_ABI) + return getAddress(resultToObject(await pool.getRebalancer())) +} + +/** + * Pre-flights `sender` against the pool's on-chain `getRebalancer()` for a liquidity write, the + * rebalancer-gated counterpart of {@link assertPoolOwner}. + * + * @remarks Deliberately *not* the owner: `provideLiquidity` and `withdrawLiquidity` compare + * `msg.sender` to `s_rebalancer` and revert `Unauthorized` for everyone else, the owner included. + * The owner's role is to appoint the rebalancer, not to move liquidity itself. + * @param operation - Operation name, for the error's `operation` field. + * @param chain - Chain to read the rebalancer from. + * @param poolAddress - Token pool being written to. + * @param sender - The address the tx will be sent from; compared checksummed. + * @throws {@link CCTParamsInvalidError} if `sender` is not the pool's rebalancer, or no + * rebalancer is configured + */ +export async function assertPoolRebalancer( + operation: string, + chain: EVMChain, + poolAddress: string, + sender: string, +): Promise { + const rebalancer = await readTokenPoolRebalancer(chain, poolAddress) + if (rebalancer !== ZeroAddress && getAddress(sender) === rebalancer) return + throw new CCTParamsInvalidError( + operation, + 'sender', + rebalancer === ZeroAddress + ? `no rebalancer is configured on ${poolAddress}, so it accepts liquidity calls from nobody; the pool owner must appoint one with setRebalancer` + : `must be the current pool rebalancer (${rebalancer})`, + ) +} + +/** + * Reads a v1.5.0 / v1.5.1 LockRelease pool's `canAcceptLiquidity()` in one `eth_call`. + * + * @remarks Only declared at v1.5.0 and v1.5.1, where the constructor fixes `i_acceptLiquidity` + * *immutable*: a pool deployed with it `false` rejects every deposit with `LiquidityNotAccepted` + * for its whole lifetime, which is why that is worth one call to catch before signing. v1.6.1 + * dropped the flag and always accepts, so callers must not reach here for it. Same shape as + * {@link readTokenPoolAllowlist}'s `enabled`. + * @param chain - Chain to read from. + * @param poolAddress - LockRelease pool to read from; must be v1.5.0 or v1.5.1. + * @returns Whether the pool accepts liquidity deposits at all. + */ +export async function readTokenPoolAcceptsLiquidity( + chain: EVMChain, + poolAddress: string, +): Promise { + const pool = getTypedContract(chain, poolAddress, LOCK_RELEASE_TOKEN_POOL_V1_5_1_ABI) + return resultToObject(await pool.canAcceptLiquidity()) +} + +/** + * The token a pool escrows, plus a handle to it, in one `eth_call`. `getToken()` is declared + * identically by every pool type and version, so this needs no dispatch. + * @param chain - Chain to read from. + * @param poolAddress - Token pool to read `getToken()` from. + * @returns The escrowed token, checksummed, and an ERC-20 contract bound to it. + */ +export async function readTokenPoolToken( + chain: EVMChain, + poolAddress: string, +): Promise<{ token: string; erc20: TypedContract }> { + const pool = getTypedContract(chain, poolAddress, LOCK_RELEASE_TOKEN_POOL_V1_5_1_ABI) + const token = getAddress(resultToObject(await pool.getToken())) + return { token, erc20: getTypedContract(chain, token, FACTORY_BURN_MINT_ERC20_V1_5_1_ABI) } +} + +/** + * Pre-flights a `provideLiquidity` deposit against the rebalancer's ERC-20 position: it must hold + * `amount` of the pool's token *and* have approved the pool to pull it, since the pool deposits + * with `safeTransferFrom`. + * + * @remarks Cross-family parity with Solana, whose `provideLiquidity` likewise refuses to build + * without the SPL delegation (`validateDelegation`) and the balance behind it. Without this the + * only signal is an `ERC20InsufficientAllowance` revert at wallet-confirmation time, naming + * neither the token to approve nor the pool to approve it to. The error names `approveToken`, + * which grants exactly this allowance. + * @remarks Advisory: an allowance can be spent or revoked between building and signing. It moves + * only on an explicit `approve` though, so unlike a pool balance it is stable enough to be worth + * the round trip. + * @param operation - Operation name, for the error's `operation` field. + * @param chain - Chain to read from. + * @param poolAddress - LockRelease pool being deposited into. + * @param account - The depositing rebalancer. + * @param amount - Deposit amount, in the token's smallest unit. + * @throws {@link CCTTxFailedError} if `account` holds less than `amount`, or has approved the + * pool for less than `amount` + */ +export async function assertLiquidityFunding( + operation: string, + chain: EVMChain, + poolAddress: string, + account: string, + amount: bigint, +): Promise { + const { token, erc20 } = await readTokenPoolToken(chain, poolAddress) + const [balance, allowance] = await Promise.all([ + erc20.balanceOf(account), + erc20.allowance(account, poolAddress), + ]) + if (balance < amount) + throw new CCTTxFailedError( + operation, + `${account} holds ${balance} of ${token}, but ${amount} is required; mint or transfer tokens first`, + ) + if (allowance < amount) + throw new CCTTxFailedError( + operation, + `${account} has approved ${allowance} of ${token} to pool ${poolAddress}, but ${amount} is required; the deposit is a transferFrom, so grant the allowance first with approveToken({ tokenAddress: '${token}', spender: '${poolAddress}', amount: ${amount}n })`, + ) +} + +/** + * Pre-flights a withdrawal against the pool's own ERC-20 balance, which is what it pays out of. + * + * @remarks Weaker than {@link assertLiquidityFunding}: a pool's balance moves with every CCIP + * transfer through it, so this catches "withdraw more than was ever provided" rather than proving + * the amount will still fit when the tx mines. + * @param operation - Operation name, for the error's `operation` field. + * @param chain - Chain to read from. + * @param poolAddress - LockRelease pool being withdrawn from. + * @param amount - Withdrawal amount, in the token's smallest unit. + * @throws {@link CCTTxFailedError} if the pool's balance is below `amount` + */ +export async function assertPoolLiquidity( + operation: string, + chain: EVMChain, + poolAddress: string, + amount: bigint, +): Promise { + const { token, liquidity } = await readTokenPoolLiquidity(chain, poolAddress) + if (liquidity >= amount) return + throw new CCTTxFailedError( + operation, + `pool ${poolAddress} holds ${liquidity} of ${token}, but ${amount} is required; it would revert InsufficientLiquidity`, + ) +} + +/** + * A pool's liquidity and the token it is denominated in, from one pair of calls. + * + * @remarks Returns the token as well so `transferLiquidity`, which checks both pools escrow the + * same one, needs no second read. + * @param chain - Chain to read from. + * @param poolAddress - LockRelease pool to read. + * @returns The escrowed token, checksummed, and the pool's balance of it. + */ +export async function readTokenPoolLiquidity( + chain: EVMChain, + poolAddress: string, +): Promise<{ token: string; liquidity: bigint }> { + const { token, erc20 } = await readTokenPoolToken(chain, poolAddress) + return { token, liquidity: await erc20.balanceOf(poolAddress) } +} + /** * Creation bytecode per deployable pool type (2.0.0 only — pre-2.0.0 bytecode is not vendored). * The keys define the deployable set ({@link DeployableTokenPoolType}). The burn-* variants share diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/get-rebalancer.test.ts b/ccip-sdk/src/cct/evm/token-pool/operations/get-rebalancer.test.ts new file mode 100644 index 00000000..a3788a63 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/get-rebalancer.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { ZeroAddress, makeError } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import { parseTypeAndVersion } from '../../../../utils.ts' +import { + CCTContractTypeInvalidError, + CCTOperationUnsupportedError, + CCTParamsInvalidError, +} from '../../../errors.ts' +import { type TokenPoolVersion, TOKEN_POOL_INTERFACES } from '../contracts.ts' +import { GetRebalancer } from './get-rebalancer.ts' + +const POOL = '0x' + '11'.repeat(20) +const REBALANCER = '0x' + '22'.repeat(20) + +/** The reads the op makes, in order, as decoded function names (`typeAndVersion` included). */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** EVMChain stub answering `typeAndVersion` and `getRebalancer()` off the LockRelease interface. */ +function stubChain({ + type = 'LockReleaseTokenPool', + version = '1.5.0' as TokenPoolVersion, + rebalancer = REBALANCER, + seen = newSeen(), +}: { + type?: string + version?: TokenPoolVersion + rebalancer?: string + seen?: Seen +} = {}): EVMChain { + const iface = TOKEN_POOL_INTERFACES.LockRelease['1.5.1'] + return { + provider: { + call: ({ data }: { data: string }) => { + const fn = iface.getFunction(data.slice(0, 10))?.name + if (fn !== 'getRebalancer') + throw makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: POOL, data }, + invocation: null, + revert: null, + }) + seen.calls.push(fn) + return Promise.resolve(iface.encodeFunctionResult(fn, [rebalancer])) + }, + }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + typeAndVersion: () => { + seen.calls.push('typeAndVersion') + return Promise.resolve(parseTypeAndVersion(`${type} ${version}`)) + }, + } as unknown as EVMChain +} + +const op = new GetRebalancer() + +describe('GetRebalancer (cct/evm)', () => { + for (const version of ['1.5.0', '1.5.1', '1.6.1'] as const) { + it(`reads the rebalancer of a LockRelease ${version} pool`, async () => { + const seen = newSeen() + assert.equal(await op.query(stubChain({ version, seen }), { poolAddress: POOL }), REBALANCER) + assert.deepEqual(seen.calls, ['typeAndVersion', 'getRebalancer']) + }) + } + + it('reads the unsiloed rebalancer of a siloed pool', async () => { + const chain = stubChain({ type: 'SiloedLockReleaseTokenPool', version: '1.6.1' }) + assert.equal(await op.query(chain, { poolAddress: POOL }), REBALANCER) + }) + + it('checksums the address the pool returns', async () => { + const chain = stubChain({ rebalancer: REBALANCER.toLowerCase() }) + assert.equal(await op.query(chain, { poolAddress: POOL }), REBALANCER) + }) + + it('reports an unset rebalancer as the zero address rather than throwing', async () => { + assert.equal( + await op.query(stubChain({ rebalancer: ZeroAddress }), { poolAddress: POOL }), + ZeroAddress, + ) + }) + + it('rejects a malformed poolAddress before any RPC', async () => { + const seen = newSeen() + await assert.rejects( + () => op.query(stubChain({ seen }), { poolAddress: 'not-an-address' }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'getRebalancer' && + err.context.param === 'poolAddress', + ) + assert.deepEqual(seen.calls, []) + }) + + it('rejects a BurnMint pool, which has no rebalancer', async () => { + await assert.rejects( + () => + op.query(stubChain({ type: 'BurnMintTokenPool', version: '1.5.1' }), { + poolAddress: POOL, + }), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && + err.context.address === POOL && + err.context.actual === 'BurnMintTokenPool', + ) + }) + + it('rejects a 2.0.0 pool, which escrows through a lockbox instead', async () => { + const seen = newSeen() + await assert.rejects( + () => op.query(stubChain({ version: '2.0.0', seen }), { poolAddress: POOL }), + (err: unknown) => + err instanceof CCTOperationUnsupportedError && + err.context.operation === 'getRebalancer' && + err.context.version === '2.0.0', + ) + // reported from the version alone; no call attempted against a selector that is not there + assert.deepEqual(seen.calls, ['typeAndVersion']) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/get-rebalancer.ts b/ccip-sdk/src/cct/evm/token-pool/operations/get-rebalancer.ts new file mode 100644 index 00000000..bba44ef8 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/get-rebalancer.ts @@ -0,0 +1,62 @@ +/** + * getRebalancer — reads the account a LockRelease pool accepts liquidity calls from + * (v1.5.0–v1.6.1). + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import { CCTOperationUnsupportedError } from '../../../errors.ts' +import { EVMQuery } from '../../query.ts' +import { validateAddress } from '../../validate.ts' +import { + TokenPoolVersion, + assertLockReleasePool, + readTokenPoolRebalancer, + resolveTokenPool, +} from '../contracts.ts' + +/** Parameters for {@link GetRebalancer}. */ +export type GetRebalancerParams = { + /** LockRelease pool to read. */ + poolAddress: string +} + +/** Result of {@link GetRebalancer}: the rebalancer address, checksummed; zero when unset. */ +export type GetRebalancerResult = string + +/** + * Reads a LockRelease pool's rebalancer — informational, for audit and UX. The liquidity write + * ops gate on this same read themselves, so nothing needs to call this first. + */ +export class GetRebalancer extends EVMQuery { + readonly name = 'getRebalancer' + + /** + * Validates the pool address; nothing to convert for {@link read}. + * @throws {@link CCTParamsInvalidError} if `poolAddress` is not a valid address + */ + protected prepare(params: GetRebalancerParams): GetRebalancerParams { + validateAddress(this.name, 'poolAddress', params.poolAddress) + return params + } + + /** + * Resolves the pool's type/version before the read, so a pool without the getter reports which + * of the two reasons applies rather than a bare call failure. + * @throws {@link CCTContractTypeInvalidError} if the pool is a BurnMint pool + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool, which has no rebalancer: it + * escrows through an external `ERC20LockBox`, which authorizes its own callers + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + */ + protected async read( + chain: EVMChain, + { poolAddress }: GetRebalancerParams, + ): Promise { + const { type, version } = await resolveTokenPool(chain, poolAddress) + assertLockReleasePool(this.name, poolAddress, type) + if (version === TokenPoolVersion.V2_0_0) + throw new CCTOperationUnsupportedError(this.name, version) + return readTokenPoolRebalancer(chain, poolAddress) + } +} diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/provide-liquidity.test.ts b/ccip-sdk/src/cct/evm/token-pool/operations/provide-liquidity.test.ts new file mode 100644 index 00000000..d3e1cc72 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/provide-liquidity.test.ts @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { parseTypeAndVersion } from '../../../../utils.ts' +import { + CCTContractTypeInvalidError, + CCTOperationUnsupportedError, + CCTParamsInvalidError, + CCTTxFailedError, +} from '../../../errors.ts' +import { type TokenPoolVersion, TOKEN_POOL_INTERFACES } from '../contracts.ts' +import { type ProvideLiquidityParams, ProvideLiquidity } from './provide-liquidity.ts' + +const POOL = '0x' + '11'.repeat(20) +const REBALANCER = '0x' + '22'.repeat(20) +const OWNER = '0x' + '33'.repeat(20) +const TOKEN = '0x' + '55'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) +const AMOUNT = 1_000000000000000000n + +/** + * Byte-parity oracle: a fresh Interface built from the signature literal, so the assertion is + * independent of the SDK's cached, ABI-derived interfaces. + */ +const IFACE = new Interface(['function provideLiquidity(uint256 amount)']) +const dataFor = (amount: bigint) => IFACE.encodeFunctionData('provideLiquidity', [amount]) + +/** The reads the op makes, in order, as decoded function names (`typeAndVersion` included). */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** ERC-20 side of the funding pre-flight, answered off a fresh Interface. */ +const ERC20 = new Interface([ + 'function balanceOf(address account) view returns (uint256)', + 'function allowance(address owner, address spender) view returns (uint256)', +]) + +/** + * EVMChain stub: `typeAndVersion` reports the requested pool type/version, and `provider.call` + * answers every read this op can make — `getRebalancer()`, `canAcceptLiquidity()` and `getToken()` + * on the pool, then `balanceOf` / `allowance` on that token. Any other selector reverts, which is + * what pins "no other RPC". + */ +function stubChain({ + type = 'LockReleaseTokenPool', + version = '1.5.0' as TokenPoolVersion, + rebalancer = REBALANCER, + acceptsLiquidity = true, + balance = AMOUNT, + allowance = AMOUNT, + seen = newSeen(), +}: { + type?: string + version?: TokenPoolVersion + rebalancer?: string + acceptsLiquidity?: boolean + /** The rebalancer's token balance; defaults to exactly the deposit. */ + balance?: bigint + /** The rebalancer's approval to the pool; defaults to exactly the deposit. */ + allowance?: bigint + seen?: Seen +} = {}): EVMChain { + const iface = TOKEN_POOL_INTERFACES.LockRelease['1.5.1'] + const pool: Record = { + getRebalancer: [rebalancer], + canAcceptLiquidity: [acceptsLiquidity], + getToken: [TOKEN], + owner: [OWNER], + } + const erc20: Record = { balanceOf: [balance], allowance: [allowance] } + return { + provider: { + call: ({ data }: { data: string }) => { + const selector = data.slice(0, 10) + const poolFn = iface.getFunction(selector)?.name + if (poolFn && pool[poolFn]) { + seen.calls.push(poolFn) + return Promise.resolve(iface.encodeFunctionResult(poolFn, pool[poolFn])) + } + const tokenFn = ERC20.getFunction(selector)?.name + if (tokenFn && erc20[tokenFn]) { + seen.calls.push(tokenFn) + return Promise.resolve(ERC20.encodeFunctionResult(tokenFn, erc20[tokenFn])) + } + throw makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: POOL, data }, + invocation: null, + revert: null, + }) + }, + }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + typeAndVersion: () => { + seen.calls.push('typeAndVersion') + return Promise.resolve(parseTypeAndVersion(`${type} ${version}`)) + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(address = REBALANCER, waitError?: Error) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +const op = new ProvideLiquidity() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { + poolAddress: POOL, + amount: AMOUNT, + sender: REBALANCER, + ...overrides, + }) +} + +/** Versions that declare `provideLiquidity`; 2.0.0 moved liquidity into the lockbox. */ +const SUPPORTED = ['1.5.0', '1.5.1', '1.6.1'] as const + +describe('ProvideLiquidity (cct/evm)', () => { + describe('generate', () => { + for (const version of SUPPORTED) { + it(`encodes provideLiquidity(amount) for a LockRelease ${version} pool`, async () => { + const unsigned = await generate(stubChain({ version })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, POOL) + assert.equal(tx.from, REBALANCER) + assert.equal(tx.data, dataFor(AMOUNT)) + }) + } + + it('emits identical calldata at every supported version', async () => { + const built = await Promise.all(SUPPORTED.map((version) => generate(stubChain({ version })))) + const [first] = built.map((unsigned) => unsigned.transactions[0]!.data) + for (const data of built.map((unsigned) => unsigned.transactions[0]!.data)) + assert.equal(data, first) + }) + + it('encodes the full uint256 range', async () => { + const amount = 2n ** 256n - 1n + // funded to match: the deposit is now pre-flighted against balance + allowance + const unsigned = await generate(stubChain({ balance: amount, allowance: amount }), { amount }) + assert.equal(unsigned.transactions[0]!.data, dataFor(amount)) + }) + + it('accepts a siloed pool, whose unsiloed bucket takes the same call', async () => { + const unsigned = await generate( + stubChain({ type: 'SiloedLockReleaseTokenPool', version: '1.6.1' }), + ) + assert.equal(unsigned.transactions[0]!.data, dataFor(AMOUNT)) + }) + + it('omits from — and skips the rebalancer read — when sender is not supplied', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ version: '1.6.1', seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + // typeAndVersion only: nothing to compare a rebalancer against, and 1.6.1 has no flag + assert.deepEqual(seen.calls, ['typeAndVersion']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['poolAddress', 'not-an-address'], + ['poolAddress', ZeroAddress], + ['amount', 1 as never], + ['amount', -1n], + ['amount', 2n ** 256n], + // a deposit of nothing: a siloed pool reverts on it, every other pool mines a no-op + ['amount', 0n], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'provideLiquidity' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('version and family dispatch', () => { + it('rejects a 2.0.0 pool — liquidity moved into the ERC20LockBox', async () => { + await assert.rejects( + () => generate(stubChain({ version: '2.0.0' })), + (err: unknown) => + err instanceof CCTOperationUnsupportedError && + err.context.operation === 'provideLiquidity' && + err.context.version === '2.0.0', + ) + }) + + it('rejects a BurnMint pool, which has no liquidity to manage', async () => { + await assert.rejects( + () => generate(stubChain({ type: 'BurnMintTokenPool', version: '1.5.1' })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && + err.context.address === POOL && + err.context.actual === 'BurnMintTokenPool', + ) + }) + }) + + describe('pre-transaction validation', () => { + it('rejects a sender that is not the pool rebalancer', async () => { + await assert.rejects( + () => generate(stubChain({ rebalancer: OWNER })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'provideLiquidity' && + err.context.param === 'sender' && + // names the rebalancer it read, so the caller can see which address it needed + err.message.includes(OWNER), + ) + }) + + it('rejects the owner, which the pool does not accept either', async () => { + await assert.rejects( + () => generate(stubChain(), { sender: OWNER }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('reports an unconfigured rebalancer as such, not as a mismatch', async () => { + await assert.rejects( + () => generate(stubChain({ rebalancer: ZeroAddress })), + (err: unknown) => + err instanceof CCTParamsInvalidError && /no rebalancer is configured/.test(err.message), + ) + }) + + for (const version of ['1.5.0', '1.5.1'] as const) { + it(`rejects a ${version} pool deployed with acceptLiquidity = false`, async () => { + await assert.rejects( + () => generate(stubChain({ version, acceptsLiquidity: false })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'poolAddress' && + /LiquidityNotAccepted/.test(err.message), + ) + }) + + it(`reads the immutable accept flag before the rebalancer at ${version}`, async () => { + const seen = newSeen() + await generate(stubChain({ version, seen })) + assert.deepEqual(seen.calls, [ + 'typeAndVersion', + 'canAcceptLiquidity', + 'getRebalancer', + 'getToken', + 'balanceOf', + 'allowance', + ]) + }) + } + + it('rejects a deposit larger than the rebalancer holds', async () => { + await assert.rejects( + () => generate(stubChain({ balance: AMOUNT - 1n })), + (err: unknown) => + err instanceof CCTTxFailedError && + err.context.operation === 'provideLiquidity' && + /holds 999999999999999999 of/.test(err.message) && + /mint or transfer tokens first/.test(err.message), + ) + }) + + it('rejects a deposit the pool has not been approved for — the ERC20InsufficientAllowance case', async () => { + await assert.rejects( + () => generate(stubChain({ allowance: 0n })), + (err: unknown) => + err instanceof CCTTxFailedError && + err.context.operation === 'provideLiquidity' && + /has approved 0 of/.test(err.message) && + // names the token, the pool and the fix + err.message.includes(TOKEN) && + err.message.includes(POOL) && + // names the op that grants it, so the fix is copy-pasteable + /approveToken\(\{ tokenAddress:/.test(err.message), + ) + }) + + it('accepts an allowance and balance above the deposit', async () => { + const unsigned = await generate(stubChain({ balance: AMOUNT * 2n, allowance: AMOUNT * 3n })) + assert.equal(unsigned.transactions[0]!.data, dataFor(AMOUNT)) + }) + + it('skips the funding reads when there is no sender to fund the deposit', async () => { + const seen = newSeen() + await generate(stubChain({ version: '1.6.1', seen }), { sender: undefined }) + assert.ok(!seen.calls.includes('allowance'), 'nothing to check an allowance for') + }) + + it('does not read the accept flag at 1.6.1, which dropped it', async () => { + const seen = newSeen() + await generate(stubChain({ version: '1.6.1', seen })) + assert.deepEqual(seen.calls, [ + 'typeAndVersion', + 'getRebalancer', + 'getToken', + 'balanceOf', + 'allowance', + ]) + }) + }) + + describe('execute', () => { + const params = { poolAddress: POOL, amount: AMOUNT } + + it('signs and submits as the rebalancer, resolving to the tx hash', async () => { + assert.deepEqual(await op.execute(stubChain(), { ...params, wallet: fakeSigner() }), { + hash: HASH, + }) + }) + + it('maps an on-chain revert — e.g. a missing allowance — to CCIPExecTxRevertedError', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + ...params, + wallet: fakeSigner(REBALANCER, makeError('execution reverted', 'CALL_EXCEPTION')), + }), + (err: unknown) => + err instanceof CCIPExecTxRevertedError && err.context.operation === 'provideLiquidity', + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: {} }), + CCIPWalletInvalidError, + ) + }) + + it('rejects a sender that is not the executing wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, sender: OWNER, wallet: fakeSigner() }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that is not the pool rebalancer', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: fakeSigner(OWNER) }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'provideLiquidity' && + err.context.param === 'sender', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/provide-liquidity.ts b/ccip-sdk/src/cct/evm/token-pool/operations/provide-liquidity.ts new file mode 100644 index 00000000..b99c3109 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/provide-liquidity.ts @@ -0,0 +1,147 @@ +/** + * provideLiquidity — deposits tokens into a LockRelease pool (v1.5.0–v1.6.1). + * + * @remarks **Rebalancer-gated, not owner-gated.** The pool compares `msg.sender` to + * `s_rebalancer` and reverts `Unauthorized` for anyone else, the owner included; the owner's part + * is to appoint the rebalancer with {@link SetRebalancer}. + * + * @remarks The deposit is a `transferFrom` on the rebalancer, so the tokens must be **approved to + * the pool** first — see `token/operations/approve-token.ts`. That is pre-flighted here + * ({@link assertLiquidityFunding}) rather than left to revert `ERC20InsufficientAllowance` in the + * wallet, matching Solana's `provideLiquidity`. + * + * @remarks **Removed in v2.0.0**, where a LockRelease pool escrows through an external + * `ERC20LockBox` instead of holding liquidity itself. + * + * @packageDocumentation + */ + +import type { Interface } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress, validatePositiveUint256 } from '../../validate.ts' +import { + TokenPoolVersion, + assertLiquidityFunding, + assertLockReleasePool, + assertPoolRebalancer, + getTokenPoolInterface, + readTokenPoolAcceptsLiquidity, + resolveEncoder, + resolveTokenPool, +} from '../contracts.ts' + +/** Parameters for {@link ProvideLiquidity}. */ +export type ProvideLiquidityParams = { + /** LockRelease pool to deposit into. Must be non-zero — it is the tx `to`, and a call to `0x0` + * hits no code, so it would mine as a successful no-op. */ + poolAddress: string + /** Amount of the pool's token to deposit (`uint256`), in the token's smallest unit. */ + amount: bigint + /** + * The pool's rebalancer. Sets `tx.from` for offline / multisig signing, and when supplied is + * checked against the pool's on-chain `getRebalancer()` before any calldata is built. Optional + * for {@link ProvideLiquidity.generate} (an offline builder may not yet know the signer); + * {@link ProvideLiquidity.execute} defaults it to the signing wallet, so the check always runs + * on a broadcast tx. + */ + sender?: string +} + +/** Encodes `provideLiquidity` calldata against the resolved pool {@link Interface}. */ +type Encoder = (iface: Interface, params: ProvideLiquidityParams) => UnsignedEVMTx + +const encodeProvideLiquidity: Encoder = (iface, { poolAddress, amount }) => + callTx(poolAddress, iface.encodeFunctionData('provideLiquidity', [amount])) + +/** Deposits tokens into a LockRelease pool as its rebalancer (v1.5.0–v1.6.1). */ +export class ProvideLiquidity extends EVMOperation { + readonly name = 'provideLiquidity' + + /** + * One 1.5.0 entry covers 1.5.1 and 1.6.1 by floor-match — the signature never changed — and the + * explicit `null` at 2.0.0 marks the removal. + */ + private readonly encoders: Partial> = { + [TokenPoolVersion.V1_5_0]: encodeProvideLiquidity, + [TokenPoolVersion.V2_0_0]: null, + } + + /** Validates the pool address and amount before any RPC; a zero `amount` moves nothing. */ + protected override validate({ poolAddress, amount }: ProvideLiquidityParams): void { + validateNonZeroAddress(this.name, 'poolAddress', poolAddress) + validatePositiveUint256(this.name, 'amount', amount) + } + + /** + * Resolves the pool's type/version, floor-matches the encoder, then confirms the pool takes + * deposits at all and that `sender` (when given) is its rebalancer. + * @remarks The v1.5.x `canAcceptLiquidity()` read is a property of the pool, not of the caller, + * so it runs first: `i_acceptLiquidity` is set *immutable* in the constructor, so a pool + * deployed with it `false` reverts every `provideLiquidity` for its whole lifetime and no + * choice of sender helps. v1.6.1 dropped the flag and always accepts. + * @remarks The checks live here, not in {@link execute}, so the offline / multisig path gets + * them too rather than being handed a transaction that reverts once signed. The funding check + * needs a depositor, so it runs only with a `sender`. + * @throws {@link CCTContractTypeInvalidError} if the pool is a BurnMint pool + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool, which escrows through an + * `ERC20LockBox` instead + * @throws {@link CCTParamsInvalidError} if the pool cannot accept liquidity, or `sender` is + * given and is not the pool's rebalancer + * @throws {@link CCTTxFailedError} if `sender` holds, or has approved the pool for, less than + * `amount` + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + */ + protected async buildUnsigned( + chain: EVMChain, + params: ProvideLiquidityParams, + ): Promise { + const { type, version } = await resolveTokenPool(chain, params.poolAddress) + assertLockReleasePool(this.name, params.poolAddress, type) + const encode = resolveEncoder(this.encoders, version, this.name) + const unsigned = encode(getTokenPoolInterface(type, version), params) + + const hasAcceptFlag = version === TokenPoolVersion.V1_5_0 || version === TokenPoolVersion.V1_5_1 + if (hasAcceptFlag && !(await readTokenPoolAcceptsLiquidity(chain, params.poolAddress))) + throw new CCTParamsInvalidError( + this.name, + 'poolAddress', + `pool ${params.poolAddress} was deployed with acceptLiquidity = false, which is immutable, so it rejects every deposit with LiquidityNotAccepted`, + ) + if (params.sender !== undefined) { + await assertPoolRebalancer(this.name, chain, params.poolAddress, params.sender) + await assertLiquidityFunding( + this.name, + chain, + params.poolAddress, + params.sender, + params.amount, + ) + } + return unsigned + } + + /** + * Signs and submits as the rebalancer, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s rebalancer check for a broadcast tx. See + * {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected rather + * than signed. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or the wallet is not the pool's rebalancer + * @throws {@link CCTTxFailedError} if the wallet's balance or its allowance to the pool is + * below `amount` + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/set-rebalancer.test.ts b/ccip-sdk/src/cct/evm/token-pool/operations/set-rebalancer.test.ts new file mode 100644 index 00000000..fac342bc --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/set-rebalancer.test.ts @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { parseTypeAndVersion } from '../../../../utils.ts' +import { + CCTContractTypeInvalidError, + CCTOperationUnsupportedError, + CCTParamsInvalidError, +} from '../../../errors.ts' +import { type TokenPoolVersion, TOKEN_POOL_INTERFACES } from '../contracts.ts' +import { type SetRebalancerParams, SetRebalancer } from './set-rebalancer.ts' + +const POOL = '0x' + '11'.repeat(20) +const OWNER = '0x' + '22'.repeat(20) +const REBALANCER = '0x' + '44'.repeat(20) +const NOT_THE_OWNER = '0x' + '88'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) + +/** + * Byte-parity oracle: a fresh Interface built from the signature literal, so the assertion is + * independent of the SDK's cached, ABI-derived interfaces. + */ +const IFACE = new Interface(['function setRebalancer(address rebalancer)']) +const dataFor = (rebalancer: string) => IFACE.encodeFunctionData('setRebalancer', [rebalancer]) + +/** The reads the op makes, in order, as decoded function names (`typeAndVersion` included). */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** + * EVMChain stub: `typeAndVersion` reports the requested pool type/version, and `provider.call` + * answers `owner()` — the only read this op makes — off the LockRelease interface. Every other + * selector reverts, which is what pins "no other RPC". + */ +function stubChain({ + type = 'LockReleaseTokenPool', + version = '1.5.0' as TokenPoolVersion, + owner = OWNER, + seen = newSeen(), +}: { + type?: string + version?: TokenPoolVersion + owner?: string + seen?: Seen +} = {}): EVMChain { + const iface = TOKEN_POOL_INTERFACES.LockRelease['1.5.1'] + return { + provider: { + call: ({ data }: { data: string }) => { + const fn = iface.getFunction(data.slice(0, 10))?.name + if (fn !== 'owner') + throw makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: POOL, data }, + invocation: null, + revert: null, + }) + seen.calls.push(fn) + return Promise.resolve(iface.encodeFunctionResult(fn, [owner])) + }, + }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + typeAndVersion: () => { + seen.calls.push('typeAndVersion') + return Promise.resolve(parseTypeAndVersion(`${type} ${version}`)) + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(address = OWNER, waitError?: Error) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +const op = new SetRebalancer() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { + poolAddress: POOL, + rebalancer: REBALANCER, + sender: OWNER, + ...overrides, + }) +} + +/** Versions that declare `setRebalancer`; 2.0.0 moved liquidity into the lockbox. */ +const SUPPORTED = ['1.5.0', '1.5.1', '1.6.1'] as const + +describe('SetRebalancer (cct/evm)', () => { + describe('generate', () => { + for (const version of SUPPORTED) { + it(`encodes setRebalancer(rebalancer) for a LockRelease ${version} pool`, async () => { + const unsigned = await generate(stubChain({ version })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, POOL) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, dataFor(REBALANCER)) + }) + } + + it('emits identical calldata at every supported version', async () => { + const built = await Promise.all(SUPPORTED.map((version) => generate(stubChain({ version })))) + const datas = built.map((unsigned) => unsigned.transactions[0]!.data) + for (const data of datas) assert.equal(data, datas[0]) + }) + + it('allows the zero address, which revokes the role', async () => { + const unsigned = await generate(stubChain(), { rebalancer: ZeroAddress }) + assert.equal(unsigned.transactions[0]!.data, dataFor(ZeroAddress)) + }) + + it('accepts a siloed pool, where this sets the unsiloed rebalancer', async () => { + const unsigned = await generate( + stubChain({ type: 'SiloedLockReleaseTokenPool', version: '1.6.1' }), + ) + assert.equal(unsigned.transactions[0]!.data, dataFor(REBALANCER)) + }) + + it('omits from — and skips the owner read — when sender is not supplied', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + // typeAndVersion only; no owner() round trip + assert.deepEqual(seen.calls, ['typeAndVersion']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['poolAddress', 'not-an-address'], + ['poolAddress', ZeroAddress], + ['rebalancer', 'not-an-address'], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${value} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'setRebalancer' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('version and family dispatch', () => { + it('rejects a 2.0.0 pool — liquidity is authorized on the lockbox instead', async () => { + await assert.rejects( + () => generate(stubChain({ version: '2.0.0' })), + (err: unknown) => + err instanceof CCTOperationUnsupportedError && + err.context.operation === 'setRebalancer' && + err.context.version === '2.0.0', + ) + }) + + it('rejects a BurnMint pool, which has no rebalancer', async () => { + await assert.rejects( + () => generate(stubChain({ type: 'BurnMintTokenPool', version: '1.5.1' })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && + err.context.address === POOL && + err.context.actual === 'BurnMintTokenPool', + ) + }) + }) + + describe('pre-transaction validation', () => { + it('rejects a sender that is not the pool owner', async () => { + await assert.rejects( + () => generate(stubChain({ owner: NOT_THE_OWNER })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'setRebalancer' && + err.context.param === 'sender', + ) + }) + + it('rejects the incumbent rebalancer, which cannot reassign its own role', async () => { + await assert.rejects( + () => generate(stubChain(), { sender: REBALANCER }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + }) + + describe('execute', () => { + const params = { poolAddress: POOL, rebalancer: REBALANCER } + + it('signs and submits as the owner, resolving to the tx hash', async () => { + assert.deepEqual(await op.execute(stubChain(), { ...params, wallet: fakeSigner() }), { + hash: HASH, + }) + }) + + it('maps an on-chain revert to CCIPExecTxRevertedError', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + ...params, + wallet: fakeSigner(OWNER, makeError('execution reverted', 'CALL_EXCEPTION')), + }), + (err: unknown) => + err instanceof CCIPExecTxRevertedError && err.context.operation === 'setRebalancer', + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: {} }), + CCIPWalletInvalidError, + ) + }) + + it('rejects a sender that is not the executing wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, sender: NOT_THE_OWNER, wallet: fakeSigner() }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that is not the pool owner', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: fakeSigner(NOT_THE_OWNER) }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'setRebalancer' && + err.context.param === 'sender' && + // names the owner it read, so the caller can see which address it needed + err.message.includes(OWNER), + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/set-rebalancer.ts b/ccip-sdk/src/cct/evm/token-pool/operations/set-rebalancer.ts new file mode 100644 index 00000000..09ed404f --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/set-rebalancer.ts @@ -0,0 +1,122 @@ +/** + * setRebalancer — appoints the LockRelease pool role allowed to move liquidity (v1.5.0–v1.6.1). + * + * @remarks Owner-only. The rebalancer is the *only* account `provideLiquidity` and + * `withdrawLiquidity` accept — not the owner — so this op is how an owner delegates liquidity + * management, and the zero address is how it revokes it. + * + * @remarks **Removed in v2.0.0**, which authorizes liquidity on the pool's external + * `ERC20LockBox` rather than through a pool-level role. + * + * @packageDocumentation + */ + +import type { Interface } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateAddress, validateNonZeroAddress } from '../../validate.ts' +import { + TokenPoolVersion, + assertLockReleasePool, + assertPoolOwner, + getTokenPoolInterface, + resolveEncoder, + resolveTokenPool, +} from '../contracts.ts' + +/** Parameters for {@link SetRebalancer}. */ +export type SetRebalancerParams = { + /** LockRelease pool whose rebalancer role is being assigned. Must be non-zero — it is the tx + * `to`, and a call to `0x0` hits no code, so it would mine as a successful no-op. */ + poolAddress: string + /** + * Address to appoint as rebalancer. Named to match the Solana op's public field + * (`cct/solana/token-pool/operations/set-rebalancer.ts`), so cross-family callers write one + * shape. + * + * The zero address is **allowed** and meaningful: it disables liquidity management entirely, + * since the pool then accepts `provideLiquidity` / `withdrawLiquidity` from nobody. Revoking a + * delegated rebalancer is a legitimate — and on incident response, urgent — operation. + */ + rebalancer: string + /** + * The pool owner. Sets `tx.from` for offline / multisig signing, and when supplied is checked + * against the pool's on-chain `owner()` before any calldata is built. Optional for + * {@link SetRebalancer.generate}; {@link SetRebalancer.execute} defaults it to the signing + * wallet, so the owner check always runs on a broadcast tx. + */ + sender?: string +} + +/** Encodes `setRebalancer` calldata against the resolved pool {@link Interface}. */ +type Encoder = (iface: Interface, params: SetRebalancerParams) => UnsignedEVMTx + +const encodeSetRebalancer: Encoder = (iface, { poolAddress, rebalancer }) => + callTx(poolAddress, iface.encodeFunctionData('setRebalancer', [rebalancer])) + +/** Appoints a LockRelease pool's rebalancer (v1.5.0–v1.6.1). Owner-only. */ +export class SetRebalancer extends EVMOperation { + readonly name = 'setRebalancer' + + /** + * One 1.5.0 entry covers 1.5.1 and 1.6.1 by floor-match — 1.6.1 added a `RebalancerSet` event + * but kept the signature — and the explicit `null` at 2.0.0 marks the removal. + */ + private readonly encoders: Partial> = { + [TokenPoolVersion.V1_5_0]: encodeSetRebalancer, + [TokenPoolVersion.V2_0_0]: null, + } + + /** Validates both addresses before any RPC; a zero `rebalancer` revokes the role. */ + protected override validate({ poolAddress, rebalancer }: SetRebalancerParams): void { + validateNonZeroAddress(this.name, 'poolAddress', poolAddress) + validateAddress(this.name, 'rebalancer', rebalancer) + } + + /** + * Resolves the pool's type/version, floor-matches the encoder, then confirms `sender` (when + * given) is the pool owner. + * @remarks The owner check lives here, not in {@link execute}, so the offline / multisig path + * gets it too rather than being handed a transaction that reverts once signed. + * @remarks Ordered *after* the encoder so a 2.0.0 pool reports the real problem (removed + * selector) rather than spending a round trip and failing on an authorization detail. + * @throws {@link CCTContractTypeInvalidError} if the pool is a BurnMint pool + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool, which authorizes liquidity on + * its `ERC20LockBox` instead + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the pool owner + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + */ + protected async buildUnsigned( + chain: EVMChain, + params: SetRebalancerParams, + ): Promise { + const { type, version } = await resolveTokenPool(chain, params.poolAddress) + assertLockReleasePool(this.name, params.poolAddress, type) + const encode = resolveEncoder(this.encoders, version, this.name) + const unsigned = encode(getTokenPoolInterface(type, version), params) + if (params.sender !== undefined) + await assertPoolOwner(this.name, chain, params.poolAddress, params.sender) + return unsigned + } + + /** + * Signs and submits as the pool owner, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s owner check for a broadcast tx. See + * {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected rather + * than signed. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or the wallet is not the pool owner + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/transfer-liquidity.test.ts b/ccip-sdk/src/cct/evm/token-pool/operations/transfer-liquidity.test.ts new file mode 100644 index 00000000..71063aea --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/transfer-liquidity.test.ts @@ -0,0 +1,399 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, MaxUint256, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { parseTypeAndVersion } from '../../../../utils.ts' +import { + CCTContractTypeInvalidError, + CCTOperationUnsupportedError, + CCTParamsInvalidError, + CCTTxFailedError, +} from '../../../errors.ts' +import { type TokenPoolVersion, TOKEN_POOL_INTERFACES } from '../contracts.ts' +import { type TransferLiquidityParams, TransferLiquidity } from './transfer-liquidity.ts' + +const POOL = '0x' + '11'.repeat(20) +const OLD_POOL = '0x' + '22'.repeat(20) +const OWNER = '0x' + '33'.repeat(20) +const TOKEN = '0x' + '55'.repeat(20) +const OTHER_TOKEN = '0x' + '66'.repeat(20) +const NOT_THE_OWNER = '0x' + '88'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) +const AMOUNT = 1_000000000000000000n + +/** + * Byte-parity oracle: a fresh Interface built from the signature literal, so the assertion is + * independent of the SDK's cached, ABI-derived interfaces. + */ +const IFACE = new Interface(['function transferLiquidity(address from, uint256 amount)']) +const dataFor = (from: string, amount: bigint) => + IFACE.encodeFunctionData('transferLiquidity', [from, amount]) + +/** The reads the op makes, in order, as `fn@address` (`typeAndVersion` included). */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +const REVERT = (to: string | null, data: string) => + makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to, data }, + invocation: null, + revert: null, + }) + +/** ERC-20 side of the source-liquidity check, answered off a fresh Interface. */ +const ERC20 = new Interface(['function balanceOf(address account) view returns (uint256)']) + +/** + * EVMChain stub covering both pools: `typeAndVersion` answers per address, and `provider.call` + * answers `owner()` / `getToken()` on the destination and `getToken()` / `getRebalancer()` on the + * source, plus `balanceOf` on their token. Any other pair reverts, which pins both which read + * goes where and "no other RPC". + */ +function stubChain({ + type = 'LockReleaseTokenPool', + version = '1.5.0' as TokenPoolVersion, + owner = OWNER, + /** The source pool's own type; a BurnMint pool holds no liquidity to transfer. */ + sourceType = 'LockReleaseTokenPool' as string | null, + /** The source pool's rebalancer; defaults to the destination, the wiring this op needs. */ + sourceRebalancer = POOL as string, + /** The source pool's escrowed token; defaults to the destination's. */ + sourceToken = TOKEN as string, + /** Liquidity the source pool holds; defaults to exactly the transfer. */ + sourceLiquidity = AMOUNT, + seen = newSeen(), +}: { + type?: string + version?: TokenPoolVersion + owner?: string + /** `null` stands in for a `from` that does not report `typeAndVersion` at all. */ + sourceType?: string | null + sourceRebalancer?: string + sourceToken?: string + sourceLiquidity?: bigint + seen?: Seen +} = {}): EVMChain { + const iface = TOKEN_POOL_INTERFACES.LockRelease['1.5.1'] + return { + provider: { + call: ({ to, data }: { to: string; data: string }) => { + const fn = iface.getFunction(data.slice(0, 10))?.name + const answer = (label: string, values: unknown[]) => { + seen.calls.push(label) + return Promise.resolve(iface.encodeFunctionResult(fn!, values)) + } + if (to === POOL && fn === 'owner') return answer('owner@pool', [owner]) + if (to === POOL && fn === 'getToken') return answer('getToken@pool', [TOKEN]) + if (to === OLD_POOL && fn === 'getToken') return answer('getToken@from', [sourceToken]) + if (to === OLD_POOL && fn === 'getRebalancer') + return answer('getRebalancer@from', [sourceRebalancer]) + if (ERC20.getFunction(data.slice(0, 10))?.name === 'balanceOf') { + seen.calls.push('balanceOf@from') + return Promise.resolve(ERC20.encodeFunctionResult('balanceOf', [sourceLiquidity])) + } + throw REVERT(to, data) + }, + }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + typeAndVersion: (address: string) => { + if (address === OLD_POOL) { + if (sourceType === null) return Promise.reject(REVERT(address, '0x181f5a77')) + seen.calls.push('typeAndVersion@from') + return Promise.resolve(parseTypeAndVersion(`${sourceType} 1.6.1`)) + } + seen.calls.push('typeAndVersion@pool') + return Promise.resolve(parseTypeAndVersion(`${type} ${version}`)) + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(address = OWNER, waitError?: Error) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +const op = new TransferLiquidity() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { + poolAddress: POOL, + from: OLD_POOL, + amount: AMOUNT, + sender: OWNER, + ...overrides, + }) +} + +/** Versions that declare `transferLiquidity`; 2.0.0 moved liquidity into the lockbox. */ +const SUPPORTED = ['1.5.0', '1.5.1', '1.6.1'] as const + +describe('TransferLiquidity (cct/evm)', () => { + describe('generate', () => { + for (const version of SUPPORTED) { + it(`encodes transferLiquidity(from, amount) for a LockRelease ${version} pool`, async () => { + const unsigned = await generate(stubChain({ version })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, POOL) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, dataFor(OLD_POOL, AMOUNT)) + }) + } + + it('emits identical calldata at every supported version', async () => { + const built = await Promise.all(SUPPORTED.map((version) => generate(stubChain({ version })))) + const datas = built.map((unsigned) => unsigned.transactions[0]!.data) + for (const data of datas) assert.equal(data, datas[0]) + }) + + it('reads the rebalancer from the source pool and the owner from the destination', async () => { + const seen = newSeen() + await generate(stubChain({ seen })) + assert.deepEqual(seen.calls, [ + 'typeAndVersion@pool', + 'typeAndVersion@from', + 'getToken@pool', + 'getToken@from', + 'getRebalancer@from', + 'balanceOf@from', + 'owner@pool', + ]) + }) + + it('omits from — and skips the owner read — when sender is not supplied', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + // the source-pool wiring is still checked: it holds regardless of who signs + assert.ok(seen.calls.includes('getRebalancer@from')) + assert.ok(!seen.calls.includes('owner@pool'), 'no sender to compare an owner against') + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['poolAddress', 'not-an-address'], + ['poolAddress', ZeroAddress], + ['from', 'not-an-address'], + ['from', ZeroAddress], + ['amount', 1 as never], + ['amount', -1n], + ['amount', 2n ** 256n], + ['amount', 0n], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferLiquidity' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + + it('rejects a self-transfer, which no pool authorizes', async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { from: POOL }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'from' && + /different pool/.test(err.message), + ) + assert.deepEqual(seen.calls, []) + }) + }) + + describe('version and family dispatch', () => { + it('rejects a 2.0.0 pool — liquidity moved into the ERC20LockBox', async () => { + await assert.rejects( + () => generate(stubChain({ version: '2.0.0' })), + (err: unknown) => + err instanceof CCTOperationUnsupportedError && + err.context.operation === 'transferLiquidity' && + err.context.version === '2.0.0', + ) + }) + + it('rejects a BurnMint pool, which has no liquidity to migrate', async () => { + await assert.rejects( + () => generate(stubChain({ type: 'BurnMintTokenPool', version: '1.5.1' })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && err.context.actual === 'BurnMintTokenPool', + ) + }) + + it('rejects a siloed destination pool, which does not declare transferLiquidity', async () => { + await assert.rejects( + () => generate(stubChain({ type: 'SiloedLockReleaseTokenPool', version: '1.6.1' })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && + err.context.address === POOL && + err.context.actual === 'SiloedLockReleaseTokenPool' && + err.context.expected === 'LockReleaseTokenPool', + ) + }) + + it('accepts the MaxUint256 transfer-all sentinel at 1.6.1', async () => { + const unsigned = await generate(stubChain({ version: '1.6.1' }), { amount: MaxUint256 }) + assert.equal(unsigned.transactions[0]!.data, dataFor(OLD_POOL, MaxUint256)) + }) + + for (const version of ['1.5.0', '1.5.1'] as const) { + it(`rejects the MaxUint256 sentinel at ${version}, which has no such branch`, async () => { + await assert.rejects( + () => generate(stubChain({ version }), { amount: MaxUint256 }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'amount' && + /v1\.6\.1/.test(err.message), + ) + }) + } + }) + + describe('pre-transaction validation', () => { + it('rejects a source pool whose rebalancer is not the destination pool', async () => { + await assert.rejects( + () => generate(stubChain({ sourceRebalancer: NOT_THE_OWNER })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferLiquidity' && + err.context.param === 'from' && + /must be the rebalancer of/.test(err.message) && + // names what it read, so the caller can see the wiring it has + err.message.includes(NOT_THE_OWNER), + ) + }) + + it('rejects a source pool with no rebalancer set', async () => { + await assert.rejects( + () => generate(stubChain({ sourceRebalancer: ZeroAddress })), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'from', + ) + }) + + it('rejects a source that does not answer typeAndVersion', async () => { + await assert.rejects(() => generate(stubChain({ sourceType: null }))) + }) + + it('rejects a BurnMint source pool, which holds no liquidity to transfer', async () => { + await assert.rejects( + () => generate(stubChain({ sourceType: 'BurnMintTokenPool' })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && + err.context.address === OLD_POOL && + err.context.actual === 'BurnMintTokenPool', + ) + }) + + it('rejects a source pool escrowing a different token, which the chain would not catch', async () => { + await assert.rejects( + () => generate(stubChain({ sourceToken: OTHER_TOKEN })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'from' && + err.message.includes(OTHER_TOKEN) && + /does not manage/.test(err.message), + ) + }) + + it('rejects a transfer larger than the source pool holds', async () => { + await assert.rejects( + () => generate(stubChain({ sourceLiquidity: AMOUNT - 1n })), + (err: unknown) => + err instanceof CCTTxFailedError && + err.context.operation === 'transferLiquidity' && + /holds 999999999999999999 of/.test(err.message), + ) + }) + + it('does not compare the sentinel to the source balance, which the pool resolves itself', async () => { + // an empty source pool still builds: transfer-all of nothing is a no-op, not a revert + const unsigned = await generate(stubChain({ version: '1.6.1', sourceLiquidity: 0n }), { + amount: MaxUint256, + }) + assert.equal(unsigned.transactions[0]!.data, dataFor(OLD_POOL, MaxUint256)) + }) + + it('rejects a sender that does not own the destination pool', async () => { + await assert.rejects( + () => generate(stubChain({ owner: NOT_THE_OWNER })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'transferLiquidity' && + err.context.param === 'sender', + ) + }) + }) + + describe('execute', () => { + const params = { poolAddress: POOL, from: OLD_POOL, amount: AMOUNT } + + it('signs and submits as the destination pool owner, resolving to the tx hash', async () => { + assert.deepEqual(await op.execute(stubChain(), { ...params, wallet: fakeSigner() }), { + hash: HASH, + }) + }) + + it('maps an on-chain revert — e.g. InsufficientLiquidity — to CCIPExecTxRevertedError', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + ...params, + wallet: fakeSigner(OWNER, makeError('execution reverted', 'CALL_EXCEPTION')), + }), + (err: unknown) => + err instanceof CCIPExecTxRevertedError && err.context.operation === 'transferLiquidity', + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: {} }), + CCIPWalletInvalidError, + ) + }) + + it('rejects a sender that is not the executing wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, sender: NOT_THE_OWNER, wallet: fakeSigner() }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that does not own the destination pool', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: fakeSigner(NOT_THE_OWNER) }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.param === 'sender' && + err.message.includes(OWNER), + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/transfer-liquidity.ts b/ccip-sdk/src/cct/evm/token-pool/operations/transfer-liquidity.ts new file mode 100644 index 00000000..b6c9289d --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/transfer-liquidity.ts @@ -0,0 +1,236 @@ +/** + * transferLiquidity — moves liquidity from an older LockRelease pool into this one + * (v1.5.0–v1.6.1). The pool-upgrade primitive. + * + * @remarks Owner-only on the *destination* pool (`poolAddress`), and it works by calling + * `withdrawLiquidity` on the source pool (`from`), which pays out to `msg.sender` — the + * destination pool. That only works if the destination pool is the source pool's rebalancer, so + * the migration is two steps: {@link SetRebalancer} on the old pool to point at the new one, + * then this op on the new one. Both are checked before any calldata is built. + * + * @remarks Everything the source pool decides is read up front ({@link assertSourcePool}): its + * rebalancer, its liquidity, and that it escrows the same token as the destination. That last one + * has no on-chain guard, and a mismatch moves an asset the destination pool does not manage. + * + * @remarks `SiloedLockReleaseTokenPool` does not declare `transferLiquidity` — its liquidity is + * partitioned per lane — so a siloed destination is rejected by type rather than by version. + * + * @remarks **Removed in v2.0.0**, where a LockRelease pool escrows through an external + * `ERC20LockBox` instead of holding liquidity itself. + * + * @packageDocumentation + */ + +import { type Interface, MaxUint256, getAddress } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import { + CCTContractTypeInvalidError, + CCTParamsInvalidError, + CCTTxFailedError, +} from '../../../errors.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress, validatePositiveUint256 } from '../../validate.ts' +import { + TokenPoolVersion, + assertLockReleasePool, + assertPoolOwner, + getTokenPoolInterface, + readTokenPoolLiquidity, + readTokenPoolRebalancer, + readTokenPoolToken, + resolveEncoder, + resolveTokenPool, +} from '../contracts.ts' + +/** Parameters for {@link TransferLiquidity}. */ +export type TransferLiquidityParams = { + /** Destination LockRelease pool — the one being written to, and the one that receives the + * liquidity. Must be non-zero: it is the tx `to`. */ + poolAddress: string + /** + * **Source pool** — not the tx sender, which is `sender`. The pool liquidity is pulled *out + * of*, typically the one being replaced; `poolAddress` is where it lands. Named after the + * on-chain `transferLiquidity(address from, uint256 amount)` parameter. + * + * Must already have `poolAddress` set as its rebalancer, which is what authorizes the + * withdrawal. + */ + from: string + /** + * Amount of the pool's token to move (`uint256`), in the token's smallest unit. + * @remarks `MaxUint256` is a v1.6.1 sentinel meaning "the source pool's whole balance". A + * v1.5.x pool has no such branch and would try to withdraw that literal amount, so it is + * rejected there rather than left to revert. + */ + amount: bigint + /** + * Owner of the destination pool. Sets `tx.from` for offline / multisig signing, and when + * supplied is checked against that pool's on-chain `owner()` before any calldata is built. + * Optional for {@link TransferLiquidity.generate}; {@link TransferLiquidity.execute} defaults + * it to the signing wallet, so the owner check always runs on a broadcast tx. + */ + sender?: string +} + +/** Encodes `transferLiquidity` calldata against the resolved pool {@link Interface}. */ +type Encoder = (iface: Interface, params: TransferLiquidityParams) => UnsignedEVMTx + +const encodeTransferLiquidity: Encoder = (iface, { poolAddress, from, amount }) => + callTx(poolAddress, iface.encodeFunctionData('transferLiquidity', [from, amount])) + +/** Migrates liquidity from an older LockRelease pool into this one (v1.5.0–v1.6.1). Owner-only. */ +/** + * Pre-flights what the *source* pool decides: that it is a LockRelease pool escrowing the same + * token as the destination, that it pays out to the destination, and that it holds the amount. + * + * @remarks The token check is the one with no on-chain counterpart. A mismatch does not revert: + * the destination takes whatever `from.withdrawLiquidity` pays out, so it silently receives an + * asset it does not escrow. + * @param operation - Operation name, for the errors' `operation` field. + * @param chain - Chain to read from. + * @param destination - The pool being written to, which must be `from`'s rebalancer. + * @param from - Source pool. + * @param amount - Transfer amount, or `undefined` for the transfer-all sentinel, where the pool + * substitutes the source's own balance and there is nothing to compare. + * @throws {@link CCTContractTypeInvalidError} if `from` is not a LockRelease pool + * @throws {@link CCTParamsInvalidError} if `from` escrows a different token or does not have + * `destination` as its rebalancer + * @throws {@link CCTTxFailedError} if `from` holds less than `amount` + */ +async function assertSourcePool( + operation: string, + chain: EVMChain, + destination: string, + from: string, + amount: bigint | undefined, +): Promise { + const { type } = await resolveTokenPool(chain, from) + assertLockReleasePool(operation, from, type) + + const [{ token: destinationToken }, source, rebalancer] = await Promise.all([ + readTokenPoolToken(chain, destination), + readTokenPoolLiquidity(chain, from), + readTokenPoolRebalancer(chain, from), + ]) + if (source.token !== destinationToken) + throw new CCTParamsInvalidError( + operation, + 'from', + `${from} escrows ${source.token} but ${destination} escrows ${destinationToken}; transferring between them would move a token the destination pool does not manage`, + ) + if (rebalancer !== getAddress(destination)) + throw new CCTParamsInvalidError( + operation, + 'from', + `pool ${destination} must be the rebalancer of ${from} to withdraw from it, but its rebalancer is ${rebalancer}; call setRebalancer on ${from} first`, + ) + if (amount !== undefined && source.liquidity < amount) + throw new CCTTxFailedError( + operation, + `source pool ${from} holds ${source.liquidity} of ${source.token}, but ${amount} is required; the withdrawal it makes would revert InsufficientLiquidity`, + ) +} + +export class TransferLiquidity extends EVMOperation { + readonly name = 'transferLiquidity' + + /** + * One 1.5.0 entry covers 1.5.1 and 1.6.1 by floor-match — 1.6.1 added the `MaxUint256` + * transfer-all branch but kept the signature — and the explicit `null` at 2.0.0 marks the + * removal. + */ + private readonly encoders: Partial> = { + [TokenPoolVersion.V1_5_0]: encodeTransferLiquidity, + [TokenPoolVersion.V2_0_0]: null, + } + + /** + * Validates both pool addresses and the amount before any RPC. `from` must differ from + * `poolAddress`: a pool is never its own rebalancer, so a self-transfer can only revert. + */ + protected override validate({ poolAddress, from, amount }: TransferLiquidityParams): void { + validateNonZeroAddress(this.name, 'poolAddress', poolAddress) + validateNonZeroAddress(this.name, 'from', from) + validatePositiveUint256(this.name, 'amount', amount) + if (getAddress(poolAddress) === getAddress(from)) + throw new CCTParamsInvalidError( + this.name, + 'from', + 'must be a different pool than poolAddress; a pool cannot withdraw its own liquidity', + ) + } + + /** + * Resolves the destination pool's type/version, floor-matches the encoder, then pre-flights + * what the two pools decide: {@link assertSourcePool} for everything about `from`, and + * `sender` (when given) owning the destination pool. + * @remarks Both live here, not in {@link execute}, so the offline / multisig path gets them + * too. Getting the rebalancer wiring wrong is this op's most likely failure, and would + * otherwise surface as an `Unauthorized` revert from a nested call. + * @throws {@link CCTContractTypeInvalidError} if either pool is a BurnMint pool, or the + * destination is siloed + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 destination pool, which escrows + * through an `ERC20LockBox` instead + * @throws {@link CCTParamsInvalidError} if `amount` is `MaxUint256` on a v1.5.x pool, the pools + * escrow different tokens, `from` does not have the destination pool as its rebalancer, or + * `sender` is given and does not own the destination pool + * @throws {@link CCTTxFailedError} if `from` holds less than `amount` + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + */ + protected async buildUnsigned( + chain: EVMChain, + params: TransferLiquidityParams, + ): Promise { + const { type, version } = await resolveTokenPool(chain, params.poolAddress) + assertLockReleasePool(this.name, params.poolAddress, type) + if (type === 'SiloedLockReleaseTokenPool') + throw new CCTContractTypeInvalidError( + params.poolAddress, + 'LockReleaseTokenPool', + type, + 'a siloed pool partitions liquidity per lane and does not declare transferLiquidity', + { context: { operation: this.name } }, + ) + const encode = resolveEncoder(this.encoders, version, this.name) + if (params.amount === MaxUint256 && version !== TokenPoolVersion.V1_6_1) + throw new CCTParamsInvalidError( + this.name, + 'amount', + `MaxUint256 means "transfer everything" only from v1.6.1; a ${version} pool would try to withdraw that amount and revert with InsufficientLiquidity`, + ) + const unsigned = encode(getTokenPoolInterface(type, version), params) + + await assertSourcePool( + this.name, + chain, + params.poolAddress, + params.from, + params.amount === MaxUint256 ? undefined : params.amount, + ) + if (params.sender !== undefined) + await assertPoolOwner(this.name, chain, params.poolAddress, params.sender) + return unsigned + } + + /** + * Signs and submits as the destination pool's owner, defaulting `sender` to the signing wallet + * — the only address that can satisfy {@link buildUnsigned}'s owner check for a broadcast tx. + * See {@link EVMOperation.resolveWalletSender} for why a divergent `sender` is rejected rather + * than signed. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or the wallet does not own the destination pool + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain, e.g. + * `InsufficientLiquidity` when the source pool holds less than `amount` + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/withdraw-liquidity.test.ts b/ccip-sdk/src/cct/evm/token-pool/operations/withdraw-liquidity.test.ts new file mode 100644 index 00000000..dd4587a9 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/withdraw-liquidity.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { parseTypeAndVersion } from '../../../../utils.ts' +import { + CCTContractTypeInvalidError, + CCTOperationUnsupportedError, + CCTParamsInvalidError, + CCTTxFailedError, +} from '../../../errors.ts' +import { type TokenPoolVersion, TOKEN_POOL_INTERFACES } from '../contracts.ts' +import { type WithdrawLiquidityParams, WithdrawLiquidity } from './withdraw-liquidity.ts' + +const POOL = '0x' + '11'.repeat(20) +const REBALANCER = '0x' + '22'.repeat(20) +const OWNER = '0x' + '33'.repeat(20) +const TOKEN = '0x' + '55'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) +const AMOUNT = 1_000000000000000000n + +/** + * Byte-parity oracle: a fresh Interface built from the signature literal, so the assertion is + * independent of the SDK's cached, ABI-derived interfaces. + */ +const IFACE = new Interface(['function withdrawLiquidity(uint256 amount)']) +const dataFor = (amount: bigint) => IFACE.encodeFunctionData('withdrawLiquidity', [amount]) + +/** The reads the op makes, in order, as decoded function names (`typeAndVersion` included). */ +type Seen = { calls: string[] } +const newSeen = (): Seen => ({ calls: [] }) + +/** ERC-20 side of the pool-balance pre-flight, answered off a fresh Interface. */ +const ERC20 = new Interface(['function balanceOf(address account) view returns (uint256)']) + +/** + * EVMChain stub: `typeAndVersion` reports the requested pool type/version, and `provider.call` + * answers the reads this op makes — `getRebalancer()` and `getToken()` on the pool, then + * `balanceOf` on that token. Any other selector reverts, which is what pins "no other RPC". + */ +function stubChain({ + type = 'LockReleaseTokenPool', + version = '1.5.0' as TokenPoolVersion, + rebalancer = REBALANCER, + poolBalance = AMOUNT, + seen = newSeen(), +}: { + type?: string + version?: TokenPoolVersion + rebalancer?: string + /** The pool's balance of the escrowed token; defaults to exactly the withdrawal. */ + poolBalance?: bigint + seen?: Seen +} = {}): EVMChain { + const iface = TOKEN_POOL_INTERFACES.LockRelease['1.5.1'] + const pool: Record = { + getRebalancer: [rebalancer], + getToken: [TOKEN], + } + return { + provider: { + call: ({ data }: { data: string }) => { + const selector = data.slice(0, 10) + const poolFn = iface.getFunction(selector)?.name + if (poolFn && pool[poolFn]) { + seen.calls.push(poolFn) + return Promise.resolve(iface.encodeFunctionResult(poolFn, pool[poolFn])) + } + if (ERC20.getFunction(selector)?.name === 'balanceOf') { + seen.calls.push('balanceOf') + return Promise.resolve(ERC20.encodeFunctionResult('balanceOf', [poolBalance])) + } + throw makeError('execution reverted', 'CALL_EXCEPTION', { + action: 'call', + data: '0x', + reason: null, + transaction: { to: POOL, data }, + invocation: null, + revert: null, + }) + }, + }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + typeAndVersion: () => { + seen.calls.push('typeAndVersion') + return Promise.resolve(parseTypeAndVersion(`${type} ${version}`)) + }, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(address = REBALANCER, waitError?: Error) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +const op = new WithdrawLiquidity() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { + poolAddress: POOL, + amount: AMOUNT, + sender: REBALANCER, + ...overrides, + }) +} + +/** Versions that declare `withdrawLiquidity`; 2.0.0 moved liquidity into the lockbox. */ +const SUPPORTED = ['1.5.0', '1.5.1', '1.6.1'] as const + +describe('WithdrawLiquidity (cct/evm)', () => { + describe('generate', () => { + for (const version of SUPPORTED) { + it(`encodes withdrawLiquidity(amount) for a LockRelease ${version} pool`, async () => { + const unsigned = await generate(stubChain({ version })) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, POOL) + assert.equal(tx.from, REBALANCER) + assert.equal(tx.data, dataFor(AMOUNT)) + }) + } + + it('emits identical calldata at every supported version', async () => { + const built = await Promise.all(SUPPORTED.map((version) => generate(stubChain({ version })))) + const datas = built.map((unsigned) => unsigned.transactions[0]!.data) + for (const data of datas) assert.equal(data, datas[0]) + }) + + it('encodes the full uint256 range', async () => { + const amount = 2n ** 256n - 1n + // funded to match: the withdrawal is now pre-flighted against the pool's balance + const unsigned = await generate(stubChain({ poolBalance: amount }), { amount }) + assert.equal(unsigned.transactions[0]!.data, dataFor(amount)) + }) + + it('accepts a siloed pool, which takes the same call', async () => { + const unsigned = await generate( + stubChain({ type: 'SiloedLockReleaseTokenPool', version: '1.6.1' }), + ) + assert.equal(unsigned.transactions[0]!.data, dataFor(AMOUNT)) + }) + + it('omits from — and skips the rebalancer read — when sender is not supplied', async () => { + const seen = newSeen() + const unsigned = await generate(stubChain({ seen }), { sender: undefined }) + + assert.equal(unsigned.transactions[0]!.from, undefined) + // no rebalancer read (nothing to compare against), but the pool balance still holds + assert.deepEqual(seen.calls, ['typeAndVersion', 'getToken', 'balanceOf']) + }) + + it('pre-flights the rebalancer, then the pool balance', async () => { + const seen = newSeen() + await generate(stubChain({ seen })) + assert.deepEqual(seen.calls, ['typeAndVersion', 'getRebalancer', 'getToken', 'balanceOf']) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['poolAddress', 'not-an-address'], + ['poolAddress', ZeroAddress], + ['amount', 1 as never], + ['amount', -1n], + ['amount', 2n ** 256n], + // a withdrawal of nothing would mine as a no-op + ['amount', 0n], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)} before any RPC`, async () => { + const seen = newSeen() + await assert.rejects( + () => generate(stubChain({ seen }), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'withdrawLiquidity' && + err.context.param === param, + ) + assert.deepEqual(seen.calls, []) + }) + } + }) + + describe('version and family dispatch', () => { + it('rejects a 2.0.0 pool — liquidity moved into the ERC20LockBox', async () => { + await assert.rejects( + () => generate(stubChain({ version: '2.0.0' })), + (err: unknown) => + err instanceof CCTOperationUnsupportedError && + err.context.operation === 'withdrawLiquidity' && + err.context.version === '2.0.0', + ) + }) + + it('rejects a BurnMint pool, which has no liquidity to manage', async () => { + await assert.rejects( + () => generate(stubChain({ type: 'BurnMintTokenPool', version: '1.5.1' })), + (err: unknown) => + err instanceof CCTContractTypeInvalidError && + err.context.address === POOL && + err.context.actual === 'BurnMintTokenPool', + ) + }) + }) + + describe('pre-transaction validation', () => { + it('rejects a sender that is not the pool rebalancer', async () => { + await assert.rejects( + () => generate(stubChain({ rebalancer: OWNER })), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'withdrawLiquidity' && + err.context.param === 'sender' && + // names the rebalancer it read, so the caller can see which address it needed + err.message.includes(OWNER), + ) + }) + + it('rejects a withdrawal larger than the pool holds', async () => { + await assert.rejects( + () => generate(stubChain({ poolBalance: AMOUNT - 1n })), + (err: unknown) => + err instanceof CCTTxFailedError && + err.context.operation === 'withdrawLiquidity' && + /holds 999999999999999999 of/.test(err.message) && + err.message.includes(TOKEN) && + /InsufficientLiquidity/.test(err.message), + ) + }) + + it('accepts a withdrawal below the pool balance', async () => { + const unsigned = await generate(stubChain({ poolBalance: AMOUNT * 5n })) + assert.equal(unsigned.transactions[0]!.data, dataFor(AMOUNT)) + }) + + it('reports an unconfigured rebalancer as such, not as a mismatch', async () => { + await assert.rejects( + () => generate(stubChain({ rebalancer: ZeroAddress })), + (err: unknown) => + err instanceof CCTParamsInvalidError && /no rebalancer is configured/.test(err.message), + ) + }) + }) + + describe('execute', () => { + const params = { poolAddress: POOL, amount: AMOUNT } + + it('signs and submits as the rebalancer, resolving to the tx hash', async () => { + assert.deepEqual(await op.execute(stubChain(), { ...params, wallet: fakeSigner() }), { + hash: HASH, + }) + }) + + it('maps an on-chain revert — e.g. InsufficientLiquidity — to CCIPExecTxRevertedError', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + ...params, + wallet: fakeSigner(REBALANCER, makeError('execution reverted', 'CALL_EXCEPTION')), + }), + (err: unknown) => + err instanceof CCIPExecTxRevertedError && err.context.operation === 'withdrawLiquidity', + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: {} }), + CCIPWalletInvalidError, + ) + }) + + it('rejects a sender that is not the executing wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, sender: OWNER, wallet: fakeSigner() }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('rejects a wallet that is not the pool rebalancer', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: fakeSigner(OWNER) }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'withdrawLiquidity' && + err.context.param === 'sender', + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token-pool/operations/withdraw-liquidity.ts b/ccip-sdk/src/cct/evm/token-pool/operations/withdraw-liquidity.ts new file mode 100644 index 00000000..8e72e274 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token-pool/operations/withdraw-liquidity.ts @@ -0,0 +1,120 @@ +/** + * withdrawLiquidity — pulls tokens back out of a LockRelease pool (v1.5.0–v1.6.1). + * + * @remarks **Rebalancer-gated, not owner-gated**, and the tokens go to `msg.sender`: the pool + * compares `msg.sender` to `s_rebalancer`, reverts `Unauthorized` for anyone else (the owner + * included), and transfers the amount to that same address. Appoint the rebalancer with + * {@link SetRebalancer}. + * + * @remarks **Removed in v2.0.0**, where a LockRelease pool escrows through an external + * `ERC20LockBox` instead of holding liquidity itself. + * + * @packageDocumentation + */ + +import type { Interface } from 'ethers' + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress, validatePositiveUint256 } from '../../validate.ts' +import { + TokenPoolVersion, + assertLockReleasePool, + assertPoolLiquidity, + assertPoolRebalancer, + getTokenPoolInterface, + resolveEncoder, + resolveTokenPool, +} from '../contracts.ts' + +/** Parameters for {@link WithdrawLiquidity}. */ +export type WithdrawLiquidityParams = { + /** LockRelease pool to withdraw from. Must be non-zero — it is the tx `to`, and a call to `0x0` + * hits no code, so it would mine as a successful no-op. */ + poolAddress: string + /** Amount of the pool's token to withdraw (`uint256`), in the token's smallest unit. */ + amount: bigint + /** + * The pool's rebalancer, which also receives the tokens. Sets `tx.from` for offline / multisig + * signing, and when supplied is checked against the pool's on-chain `getRebalancer()` before + * any calldata is built. Optional for {@link WithdrawLiquidity.generate}; + * {@link WithdrawLiquidity.execute} defaults it to the signing wallet. + */ + sender?: string +} + +/** Encodes `withdrawLiquidity` calldata against the resolved pool {@link Interface}. */ +type Encoder = (iface: Interface, params: WithdrawLiquidityParams) => UnsignedEVMTx + +const encodeWithdrawLiquidity: Encoder = (iface, { poolAddress, amount }) => + callTx(poolAddress, iface.encodeFunctionData('withdrawLiquidity', [amount])) + +/** Withdraws tokens from a LockRelease pool to its rebalancer (v1.5.0–v1.6.1). */ +export class WithdrawLiquidity extends EVMOperation { + readonly name = 'withdrawLiquidity' + + /** + * One 1.5.0 entry covers 1.5.1 and 1.6.1 by floor-match — the signature never changed — and the + * explicit `null` at 2.0.0 marks the removal. + */ + private readonly encoders: Partial> = { + [TokenPoolVersion.V1_5_0]: encodeWithdrawLiquidity, + [TokenPoolVersion.V2_0_0]: null, + } + + /** Validates the pool address and amount before any RPC; a zero `amount` moves nothing. */ + protected override validate({ poolAddress, amount }: WithdrawLiquidityParams): void { + validateNonZeroAddress(this.name, 'poolAddress', poolAddress) + validatePositiveUint256(this.name, 'amount', amount) + } + + /** + * Resolves the pool's type/version, floor-matches the encoder, then confirms `sender` (when + * given) is the pool's rebalancer. + * @remarks The rebalancer check lives here, not in {@link execute}, so the offline / multisig + * path gets it too rather than being handed a transaction that reverts once signed. + * @remarks The pool's balance is pre-flighted ({@link assertPoolLiquidity}). Advisory only: + * every CCIP transfer moves that balance, so a later shortfall still reverts + * `InsufficientLiquidity`. + * @throws {@link CCTContractTypeInvalidError} if the pool is a BurnMint pool + * @throws {@link CCTOperationUnsupportedError} on a v2.0.0 pool, which escrows through an + * `ERC20LockBox` instead + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the pool's rebalancer + * @throws {@link CCTTxFailedError} if the pool holds less than `amount` + * @throws {@link CCTContractVersionUnsupportedError} if the pool reports an unknown version + */ + protected async buildUnsigned( + chain: EVMChain, + params: WithdrawLiquidityParams, + ): Promise { + const { type, version } = await resolveTokenPool(chain, params.poolAddress) + assertLockReleasePool(this.name, params.poolAddress, type) + const encode = resolveEncoder(this.encoders, version, this.name) + const unsigned = encode(getTokenPoolInterface(type, version), params) + if (params.sender !== undefined) + await assertPoolRebalancer(this.name, chain, params.poolAddress, params.sender) + await assertPoolLiquidity(this.name, chain, params.poolAddress, params.amount) + return unsigned + } + + /** + * Signs and submits as the rebalancer, defaulting `sender` to the signing wallet — the only + * address that can satisfy {@link buildUnsigned}'s rebalancer check for a broadcast tx, and the + * address the tokens are sent to. See {@link EVMOperation.resolveWalletSender} for why a + * divergent `sender` is rejected rather than signed. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address, + * or the wallet is not the pool's rebalancer + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain, e.g. + * `InsufficientLiquidity` + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/token/operations/approve-token.test.ts b/ccip-sdk/src/cct/evm/token/operations/approve-token.test.ts new file mode 100644 index 00000000..522aad9a --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/approve-token.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Interface, MaxUint256, ZeroAddress, makeError } from 'ethers' + +import { CCIPExecTxRevertedError, CCIPWalletInvalidError } from '../../../../errors/index.ts' +import type { EVMChain } from '../../../../evm/index.ts' +import { ChainFamily } from '../../../../networks.ts' +import { CCTParamsInvalidError } from '../../../errors.ts' +import { type ApproveTokenParams, ApproveToken } from './approve-token.ts' + +const TOKEN = '0x' + '11'.repeat(20) +const OWNER = '0x' + '22'.repeat(20) +const POOL = '0x' + '33'.repeat(20) +const OTHER = '0x' + '44'.repeat(20) +const HASH = '0x' + 'ab'.repeat(32) +const AMOUNT = 1_000000000000000000n + +/** + * Byte-parity oracle: a fresh Interface built from the signature literal, so the assertion is + * independent of the SDK's cached, ABI-derived interfaces. + */ +const IFACE = new Interface(['function approve(address spender, uint256 amount) returns (bool)']) +const dataFor = (spender: string, amount: bigint) => + IFACE.encodeFunctionData('approve', [spender, amount]) + +/** EVMChain stub whose every `eth_call` / `typeAndVersion` throws: this op must make none. */ +function stubChain(seen: { calls: number } = { calls: 0 }): EVMChain { + const fail = () => { + seen.calls += 1 + throw new Error('approveToken must not touch the chain to build') + } + return { + provider: { call: fail }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + typeAndVersion: fail, + nextNonce: () => Promise.resolve(0), + rollbackNonce: () => {}, + } as unknown as EVMChain +} + +function fakeSigner(address = OWNER, waitError?: Error) { + return { + signTransaction: () => Promise.resolve('0x'), + getAddress: () => Promise.resolve(address), + populateTransaction: (tx: unknown) => Promise.resolve({ ...(tx as object) }), + sendTransaction: () => + Promise.resolve({ + hash: HASH, + wait: () => (waitError ? Promise.reject(waitError) : Promise.resolve({ status: 1 })), + }), + } +} + +const op = new ApproveToken() + +function generate(chain: EVMChain, overrides: Partial = {}) { + return op.generate(chain, { + tokenAddress: TOKEN, + spender: POOL, + amount: AMOUNT, + sender: OWNER, + ...overrides, + }) +} + +describe('ApproveToken (cct/evm)', () => { + describe('generate', () => { + it('encodes approve(spender, amount) to the token', async () => { + const unsigned = await generate(stubChain()) + const tx = unsigned.transactions[0]! + + assert.equal(unsigned.family, ChainFamily.EVM) + assert.equal(unsigned.transactions.length, 1) + assert.equal(tx.to, TOKEN) + assert.equal(tx.from, OWNER) + assert.equal(tx.data, dataFor(POOL, AMOUNT)) + }) + + it('builds without touching the chain — no version to resolve, nothing to read', async () => { + const seen = { calls: 0 } + await generate(stubChain(seen)) + assert.equal(seen.calls, 0) + }) + + it('accepts a zero amount, which revokes the allowance', async () => { + const unsigned = await generate(stubChain(), { amount: 0n }) + assert.equal(unsigned.transactions[0]!.data, dataFor(POOL, 0n)) + }) + + it('encodes the unlimited approval', async () => { + const unsigned = await generate(stubChain(), { amount: MaxUint256 }) + assert.equal(unsigned.transactions[0]!.data, dataFor(POOL, MaxUint256)) + }) + + it('omits from when sender is not supplied', async () => { + const unsigned = await generate(stubChain(), { sender: undefined }) + assert.equal(unsigned.transactions[0]!.from, undefined) + assert.equal(unsigned.transactions[0]!.data, dataFor(POOL, AMOUNT)) + }) + }) + + describe('validation', () => { + for (const [param, value] of [ + ['tokenAddress', 'not-an-address'], + ['tokenAddress', ZeroAddress], + ['spender', 'not-an-address'], + // OpenZeppelin's ERC-20 reverts ERC20InvalidSpender, so a zero spender is never meaningful + ['spender', ZeroAddress], + ['amount', 1 as never], + ['amount', -1n], + ['amount', 2n ** 256n], + ['sender', 'not-an-address'], + ] as const) { + it(`rejects ${param} = ${String(value)}`, async () => { + await assert.rejects( + () => generate(stubChain(), { [param]: value }), + (err: unknown) => + err instanceof CCTParamsInvalidError && + err.context.operation === 'approveToken' && + err.context.param === param, + ) + }) + } + }) + + describe('execute', () => { + const params = { tokenAddress: TOKEN, spender: POOL, amount: AMOUNT } + + it('signs and submits, resolving to the tx hash', async () => { + assert.deepEqual(await op.execute(stubChain(), { ...params, wallet: fakeSigner() }), { + hash: HASH, + }) + }) + + it('rejects a sender that is not the signing wallet — the allowance comes from the signer', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, sender: OTHER, wallet: fakeSigner() }), + (err: unknown) => err instanceof CCTParamsInvalidError && err.context.param === 'sender', + ) + }) + + it('maps an on-chain revert to CCIPExecTxRevertedError', async () => { + await assert.rejects( + () => + op.execute(stubChain(), { + ...params, + wallet: fakeSigner(OWNER, makeError('execution reverted', 'CALL_EXCEPTION')), + }), + (err: unknown) => + err instanceof CCIPExecTxRevertedError && err.context.operation === 'approveToken', + ) + }) + + it('rejects a non-signer wallet', async () => { + await assert.rejects( + () => op.execute(stubChain(), { ...params, wallet: {} }), + CCIPWalletInvalidError, + ) + }) + }) +}) diff --git a/ccip-sdk/src/cct/evm/token/operations/approve-token.ts b/ccip-sdk/src/cct/evm/token/operations/approve-token.ts new file mode 100644 index 00000000..8e529925 --- /dev/null +++ b/ccip-sdk/src/cct/evm/token/operations/approve-token.ts @@ -0,0 +1,90 @@ +/** + * approveToken — grants an ERC-20 allowance, the prerequisite for a pool liquidity deposit. + * + * @remarks The counterpart of Solana's `approveToken` (`cct/solana/token/operations/approve-token.ts`), + * which delegates spend authority on an SPL token account. `provideLiquidity` deposits with + * `safeTransferFrom`, so without an allowance to the pool it reverts + * `ERC20InsufficientAllowance`; this is how a rebalancer grants it. + * + * @remarks No version resolution and no chain read: `approve(address,uint256)` is ERC-20, declared + * identically by `FactoryBurnMintERC20` v1.5.1 / v1.6.2 and by v2.0.0's `CrossChainToken`. Nor is + * `tokenAddress` gated to a CCT token — a LockRelease pool can escrow an arbitrary ERC-20, and that + * is precisely the token an operator needs to approve. + * + * @packageDocumentation + */ + +import type { EVMChain } from '../../../../evm/index.ts' +import type { UnsignedEVMTx } from '../../../../evm/types.ts' +import type { TransactionResult } from '../../../operation.ts' +import { type EVMExecuteParams, EVMOperation, callTx } from '../../operation.ts' +import { validateNonZeroAddress, validateUint256 } from '../../validate.ts' +import { TokenVersion, getTokenInterface } from '../contracts.ts' + +/** Parameters for {@link ApproveToken}. */ +export type ApproveTokenParams = { + /** ERC-20 token to approve on — any ERC-20, not only a CCT-deployed one. Must be non-zero: it + * is the tx `to`, and a call to `0x0` hits no code, so it would mine as a successful no-op. */ + tokenAddress: string + /** + * Address allowed to spend. For a liquidity deposit this is the **token pool**. + * @remarks Must be non-zero — OpenZeppelin's ERC-20 reverts `ERC20InvalidSpender` on a zero + * spender, so unlike a role-clearing address this one can never be meaningful. + */ + spender: string + /** + * Allowance in the token's smallest unit. **Replaces** the current allowance rather than adding + * to it, and `0n` is allowed and revokes it. + * @remarks An allowance is consumed as it is spent, so a deposit of exactly `amount` leaves + * nothing for the next one. Approving the amount you intend to deposit each time is the tighter + * choice; approving `2n ** 256n - 1n` once is the convenient one. + */ + amount: bigint + /** + * The account whose tokens are being approved. Sets `tx.from` for offline / multisig signing. + * Optional for {@link ApproveToken.generate}; {@link ApproveToken.execute} defaults it to the + * signing wallet, since that is the account the allowance actually comes from. + */ + sender?: string +} + +/** Grants an ERC-20 allowance — e.g. a rebalancer approving a LockRelease pool before a deposit. */ +export class ApproveToken extends EVMOperation { + readonly name = 'approveToken' + + /** Validates both addresses and the amount before any RPC; a zero `amount` revokes. */ + protected override validate({ tokenAddress, spender, amount }: ApproveTokenParams): void { + validateNonZeroAddress(this.name, 'tokenAddress', tokenAddress) + validateNonZeroAddress(this.name, 'spender', spender) + validateUint256(this.name, 'amount', amount) + } + + /** + * Encodes `approve` with no chain access at all — there is no version to resolve and nothing + * about the caller's balance to check, since an ERC-20 allows approving more than is held. + */ + protected buildUnsigned( + _chain: EVMChain, + { tokenAddress, spender, amount }: ApproveTokenParams, + ): UnsignedEVMTx { + const iface = getTokenInterface(TokenVersion.V1_5_1) + return callTx(tokenAddress, iface.encodeFunctionData('approve', [spender, amount])) + } + + /** + * Signs and submits, defaulting `sender` to the signing wallet. + * @remarks The allowance is granted from `msg.sender`'s balance, so a `sender` that differs from + * the wallet would approve a *different* account's tokens than the one reviewed — rejected here + * rather than signed. See {@link EVMOperation.resolveWalletSender}. + * @throws {@link CCIPWalletInvalidError} if `wallet` is not a valid signer + * @throws {@link CCTParamsInvalidError} if `sender` is given and is not the wallet's address + * @throws {@link CCIPExecTxRevertedError} if the tx reverts on-chain + */ + override async execute( + chain: EVMChain, + params: EVMExecuteParams, + ): Promise { + const sender = await this.resolveWalletSender(params.wallet, params.sender) + return super.execute(chain, { ...params, sender }) + } +} diff --git a/ccip-sdk/src/cct/evm/validate.ts b/ccip-sdk/src/cct/evm/validate.ts index c7d110cc..08d24cc1 100644 --- a/ccip-sdk/src/cct/evm/validate.ts +++ b/ccip-sdk/src/cct/evm/validate.ts @@ -107,6 +107,18 @@ export function validateUint256(operation: string, param: string, value: unknown assertUintBits(operation, param, value, 256) } +/** + * Asserts `value` is a `bigint` in `[1, 2^256 − 1]` — a Solidity `uint256` that must move + * something. The amount guard for the liquidity ops, whose zero case is either a revert + * (`LiquidityAmountCannotBeZero` on a siloed pool) or a transfer of nothing. Mirrors Solana's + * `validateBigInt(..., 1n, U64_MAX)`. + * @throws {@link CCTParamsInvalidError} if `value` is not such a bigint + */ +export function validatePositiveUint256(operation: string, param: string, value: unknown): void { + validateUint256(operation, param, value) + if (value === 0n) throw new CCTParamsInvalidError(operation, param, 'must be greater than zero') +} + /** * Asserts `value` is a `bigint` in `[0, 2^128 − 1]` (a Solidity `uint128`), narrowing it to * `bigint` for callers.