Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
348 changes: 348 additions & 0 deletions ccip-sdk/src/cct/evm/index.ts

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions ccip-sdk/src/cct/evm/token-pool/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
TOKEN_POOL_INTERFACES,
TOKEN_POOL_TYPES,
TokenPoolVersion,
assertLockReleasePool,
getTokenPoolFamily,
getTokenPoolInterface,
isLockReleaseTokenPoolType,
Expand Down Expand Up @@ -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)
Expand Down
231 changes: 215 additions & 16 deletions ccip-sdk/src/cct/evm/token-pool/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<string> {
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<void> {
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<boolean> {
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<typeof FACTORY_BURN_MINT_ERC20_V1_5_1_ABI> }> {
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<void> {
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<void> {
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
Expand Down
Loading
Loading