Pool Contract

Pool is the contract an integrator actually calls: swap, deposit, withdraw, donate, liability swap, flash and hook entry points are all functions on one pool proxy. This page is the reference for that surface - signature, parameters, return struct, events and revert conditions for each operation - plus the anchor-tree routing and the state rules every operation shares. The pricing maths behind the numbers returned here lives under Inventory Management and Spread & Fees.


1. Overview

Pool (Pool.sol) implements AIMM’s primary liquidity operations as an ERC-1967 beacon proxy deployed via PoolFactory (every pool reads PoolFactory.implementation(), the factory being the beacon, so a timelocked impl swap re-points them all: Deployment & Upgrades §4.1). It is the whole pool-side entry surface: swap, liquidity, admin, flash and hook entry points all live on it and it dispatches to linked libraries (deposit/withdraw/withdrawTo/donate/swapLiability to PoolLiquidity.sol, swap to Pricing.sol, config writes to PoolConfig.sol); see AIMM Overview §2.1 for the full no-Diamond architecture.

Marks come from the IOracle each asset’s OracleConfig names - in the shipped fleet the keeper-pushed ExternalOracle - and never from the pool’s own reserves or trade history; see Oracles.

Key Functions:

  • swap() - exchange tokens with oracle-aware pricing (single entry; there is no on-chain batch/multi-swap call)
  • deposit() - add single-sided liquidity
  • withdraw() / withdrawTo() - remove liquidity with coverage-based haircuts (same or different asset)
  • donate() - gift tokens to LPs (raises the liquidity index; no LP mint)
  • swapLiability() - swap LP positions between assets

2. Swap

2.1. Function Signature

function swap( address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, address recipient, uint256 deadline ) external payable returns (uint256 amountOut)

(The full SwapQuote struct is only returned by the view getSwapQuote().)

2.2. Parameters

ParameterTypeDescription
tokenInaddressToken being sold (or NATIVE sentinel)
tokenOutaddressToken being bought
amountInuint256Exact amount to sell
minAmountOutuint256Slippage protection
recipientaddressReceives output tokens
deadlineuint256Unix timestamp, inclusive. Reverts Err.Expired if block.timestamp > deadline. Pass type(uint256).max to opt out; there is no 0 sentinel

2.3. Execution Flow

Validate inputs and feature flags

Pull input

Resolve anchor-tree path

Per-leg mark skew traverse

Settle spread toll fee

Update reserves and fees

Validate minAmountOut

Transfer output

(No oracle write happens on swap - the keeper is the only feed writer.)

2.4. Return Value

swap() returns uint256 amountOut. The view getSwapQuote() returns:

struct SwapQuote { uint256 amountOut; // Tokens received uint256 amountIn; // Tokens paid uint16 spreadPbps; // Path ROUND-TRIP spread width (0.0001% units); this swap pays half uint256 protoFee; // Protocol share of that one half-spread fee uint256 lpFee; // LP share of the same fee int8 skewIn; // Input asset inventory skew int8 skewOut; // Output asset inventory skew uint256 markPrice; // Path oracle mark, exact WAD, tokenOut per tokenIn, chained across legs uint256 midPrice; // Inventory-skewed center the book quotes around, exact WAD uint256 covToll; // Coverage toll withheld from gross output BEFORE the fee (tokenOut units) address[] routeHops; // Full routing path uint256[] hopAmounts; // Amount at each hop uint256[] hopPrices; // Per-leg realized exec price, WAD; analytics views only }

OEV decomposes exactly off the two price fields: (exec - mid)/mid is the extractable part, (mid - mark)/mark is inventory skew.

markPrice and midPrice are exact WAD, not the packed B64 the V1 oracle stores: both are computed and handed straight out as a struct field and a log arg, never into a storage slot, so the packed encode saves no log bytes (ABI pads every non-indexed event arg to a full word) while costing 917 to 1,557 gas per swap and truncating the mantissa to 52 bits. Where a packed mark does buy the slot, the oracle uses it: Oracles §5.

2.5. Events

event Swapped( address indexed sender, address indexed recipient, address indexed tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint16 spreadPbps, uint256 protoFee, uint256 lpFee, uint256 markPrice, uint256 midPrice, uint256 covToll );

protoFee + lpFee is the whole fee and is denominated in tokenOut: there is no input-leg fee, so an indexer credits 100% of a swap’s fee to tokenOut.


3. Deposit

3.1. Function Signature

function deposit( address token, uint256 amount ) external payable returns (DepositResult memory result)

LP shares are credited to msg.sender; there is no minLPAmount / recipient parameter (share pricing is deterministic off the liquidity index).

3.2. Parameters

ParameterTypeDescription
tokenaddressAsset to deposit (NATIVE sentinel for native + msg.value)
amountuint256Amount to deposit

3.3. Mechanics

Validate inputs

Pull tokens

Compute lpAmount

Update reserves and mint

After state update, optional postInflow dispatch when the asset hook has HOOK_POST_INFLOW (see §10).

3.4. Coverage Impact

Deposits always improve coverage when the leg starts under-covered:

c0=R/L,c1=R+xL+x

If c0<1 then c1>c0. Proof: Invariants I-6.

3.5. Return Value

struct DepositResult { uint256 lpAmount; // LP tokens minted (net of deadLp) uint256 actualDeposit; // Tokens accepted uint256 deadLp; // Shares carved out and sunk to address(0) if this opened the leg }

4. Withdraw

4.1. Function Signature

function withdraw( address token, uint256 lpAmount, uint256 minAmountOut, uint256 deadline ) external returns (WithdrawResult memory result)

Output goes to msg.sender (no recipient parameter). withdrawTo(tokenFrom, tokenTo, lpAmount, minAmountOut, deadline) withdraws into a different asset, priced through the anchor-path quote. deadline is inclusive, same convention as swap. Deposit and donate carry no deadline: they set no minOut and mint at the current index, so there is nothing stale to protect.

4.2. Parameters

ParameterTypeDescription
tokenaddressAsset to withdraw
lpAmountuint256LP shares to burn
minAmountOutuint256Slippage protection
deadlineuint256Unix timestamp, inclusive

4.3. Haircut Mechanism

When coverage < 100%, withdrawals take a linear haircut (PoolLiquidity.applyHaircut):

function applyHaircut( uint256 amount, uint128 reserves, uint128 liabilities, uint16 haircutSuppressorBps ) internal pure returns (uint256 actualAmount, uint256 haircutAmount) { if (liabilities == 0 || reserves >= liabilities) return (amount, 0); uint256 deficit = ((liabilities - reserves) * 1e18) / liabilities; // No `>= 20000 ? 0` branch: config rejects that range, initAsset seeds BPS. uint256 factor = 1e18 - (haircutSuppressorBps * 1e18 / HAIRCUT_SUPPRESSOR_FULL_BPS); // 20_000 uint256 haircutRatio = (deficit * factor) / 1e18; // LINEAR, no power law if (haircutRatio > 1e18) haircutRatio = 1e18; haircutAmount = (amount * haircutRatio + 1e18 - 1) / 1e18; // rounds UP, dust stays with the pool actualAmount = amount - haircutAmount; }

setAssetParams rejects haircutSuppressorBps >= HAIRCUT_SUPPRESSOR_FULL_BPS (20_000) outright, so the haircut can never be fully switched off: an under-covered leg always socializes at least part of its deficit, and the first mover cannot exit at face value. Out-of-range stored state underflows here instead of silently zeroing the haircut, which fails closed. A coverage-walled leg (kappaCovBps > 0) is forced to haircutSuppressorBps = 0, the full haircut.

4.4. Haircut Examples

CoverageSuppressionFactorHaircut
90%01.010%
80%01.020%
80%100000.510%
50%150000.2512.5%

Suppression 20000 is not a configurable row: it is the rejected upper bound. Nor are the 10000 and 15000 rows reachable on a listed asset: requireNeverDepletable forces kappaCovBps != 0 at every writer and requireWallOk then forces haircutSuppressorBps == 0 alongside it, so every leg of the Arc fleet runs suppression 0 and the full linear haircut. The suppressed rows are the arithmetic of a value the write path will not admit.

4.5. Return Value

struct WithdrawResult { uint256 amountOut; // Tokens received (after haircut) uint256 lpBurned; // LP tokens burned }

5. Donate

5.1. Function Signature

function donate( address token, uint256 amount ) external payable

5.2. Purpose

Gifts value to existing LPs (protocol fee redistribution, third-party yield injection, recovery top-ups). The donation raises the liquidity index, so every existing LP share is worth more underlying.

donate() accrues no LP credit to the sender: it mints no shares and takes no position. The sender is logged - event Donated(address indexed sender, address indexed token, uint256 amount) (IPool.sol) - so the call is traceable, just not creditable.

Donating to a leg that has never been credited strands the gift. raiseIndex no-ops when liabBefore == 0, so everything above the dead-share seed lands in liabilities backing no share and is inherited by nobody. Deposit first if the intent is to open a leg; donate is the non-dilutive top-up for a leg that already has LPs.

5.3. Mechanics

Validate token

Pull tokens

Add to reserves

Add to liabilities

Raise liquidity index

5.4. Coverage Impact

Coverage is unchanged by a donation (reserves and liabilities rise equally). The value transfer to LPs happens through the liquidity index, not through coverage:

c_1 = (R + a) / (L + a) ~ c_0

This still helps an under-covered pool in absolute terms (the deficit L - R stays constant while L grows, so the relative deficit shrinks), but the coverage ratio itself does not improve.


6. Liability Swap

6.1. Function Signature

function swapLiability( address tokenIn, address tokenOut, uint256 lpAmountIn, uint256 minLpAmountOut, uint256 deadline ) external returns (uint256 lpAmountOut)

(The function is named swapLiability, not liabilitySwap; shares move for msg.sender - no recipient parameter.)

6.2. Purpose

Moves an LP position from one asset to another without a withdraw + deposit cycle.

6.3. Mechanics

Enable bits and halt checks

Compute liabIn

Haircut input if under-covered

Anchor-path quote

Mark cap on the payout

Depeg band guard

Haircut output if under-covered

Adjust liabilities burn mint

Check minLpAmountOut

The input-side haircut is what stops a liability swap out of an under-covered leg from escaping the exit toll and dumping the deficit on the destination leg’s LPs. The mark cap (PoolLiquidity._markCap, shared with withdrawTo’s cross path) closes the other half: the haircut face is credited through the path’s oracle mark, min(amountOut, fair · markPrice · 10^(d_to - d_from)), never through the inventory-skewed mid. The skew is a level that cancels on a trader’s paired crossings but not on a one-way LP conversion, and on an under-covered leg it is one-sided; the explicit decimal factor is required because markPrice is a whole-unit WAD ratio while the amount walk is rescaled per leg. Liability re-denomination is deliberately protocol-fee exempt: it moves no reserves, so there is no physical outflow to skim from. Both fee legs land as reduced net liability instead.

6.4. Requirements

  • LIABILITY_SWAP_ENABLED_BIT must be set on both assets

7. Swap Paths (Anchor Tree)

7.1. Topology

Every asset anchors to one parent, which need not be the base. Every chain terminates at the base, the root.

WBTC base

WETH

USDC

stETH

weETH

USDT

DAI

Examples:

  • stETH → DAI: path [stETH, WETH, WBTC, USDC, DAI] (4 legs; endpoints settle, interiors do not)
  • USDT → DAI: path [USDT, USDC, DAI] (2 legs; LCA is USDC; BTC mark never read)

7.2. Path Computation

The path is the unique tree path between the endpoints: walk up from each endpoint to the lowest common ancestor, then concatenate. There is nothing to search and no alternative decomposition. A malformed tree (an anchor chain that does not terminate at the root) reverts.

7.3. Path Constraints

ConstraintValuePurpose
Max depthMAX_DEPTH = 4Bounds gas and the summed path fee
Max nodesMAX_PATH_LENGTH = 9 (8 legs)Two walks meeting at the LCA; depth bounds one walk, a path is two of them (Anchor Path Pricing §1)
No cyclesenforcedvalidateAnchor walks to the root with an explicit current == asset check and an unconditional step cap. A disconnected 2-cycle never reaches the root, so a depth counter alone is insufficient

Why a tree rather than a star: correlated assets get their own edge, their own feed, their own sigma and their own fee floor. A BTC-based pool quotes USDT -> DAI off two stable feeds instead of round-tripping through the BTC mark.

Topology is configuration, not a contract state. The tree is general - any asset may anchor to a non-base parent up to MAX_DEPTH = 4, which is how curators price correlated pairs against their own reference and run distinct fee profiles inside one pool. Each pool’s anchor cells decide its actual shape: an empty cell anchors the leg directly to the base and makes it an endpoint leg; a filled cell deepens the tree and activates the interior fence for the routes through it. Depth 4 is the contract capability: the schema, the deploy scripts and the fence budgets are all general to it and nothing special-cases a shallower tree. Activating a deep edge needs the mark feed serving the cross pair rather than any contract change; the sequence is addAnchorFeeds() then requestAnchorTree() then, after the critical timelock, executeAnchorTree(). Every deep leg must carry quoteUnit = 0 and its own reference feed.

The depeg picture does not generalize for free. What does scale with depth is the feed-relative band: PoolIOLib.priceBandGuardPath runs it on every interior node except the base, and priceBandGuard runs it on both endpoints. The base parity halt does not scale: it is gated on the hop being the base token, so it tests the base mark against 1e18 and nothing else. Neither is a peg test for a parent: OracleConfig.refFeedId is a same-unit agreement check between two independent attestations of one pair, and a parent-depeg breaker needs a field that does not exist. See Anchor Path Pricing §7.1.


8. State Management

8.1. Transient Storage (EIP-1153)

Transient storage holds:

  • Reentrancy guard: Prevents recursive calls
  • Oracle cache: Avoid redundant price lookups within transaction

8.2. Reentrancy Protection

Pool inherits solady’s ReentrancyGuardTransient via @btr-shared/base/TransientGuard.sol, which overrides _useTransientReentrancyGuardOnlyOnMainnet() to return false. Solady defaults that to true and so TSTOREs only on chainid 1, falling back to SSTORE everywhere else at a measured +4,874 gas cold / +2,874 warm per guarded call; every target chain runs Cancun-or-later, so the fallback is pure cost. nonReentrant is not hand-rolled here.

A second, independent transient slot, FLASH_INFLIGHT_SLOT (PoolIOLib.sol), is set for the duration of a flash callback and read by PoolIOLib.requireNoFlash at four sites: PoolIOLib.pull (the reserve-crediting chokepoint behind deposit, donate and swap), PoolLiquidity.withdrawTo, PoolLiquidity.swapLiability, and the hook writers on Pool. The reentrancy slot is not held across the ERC-3156 callback, which is why the flash flag exists at all.

8.3. Liquidity Index Writers

Coverage is never rewritten by a background process: it moves only when a swap, deposit, withdrawal or donation moves it. Two live pricing mechanisms restore it, neither on a clock and neither reducing liabilities on its own.

  • The exit haircut (§4.3) makes every withdrawal from an under-covered leg pay a deficit-proportional penalty, so the leaver takes its own share of the deficit rather than handing it to those who stay. At the shipped haircutSuppressorBps = 0 a same-asset exit is coverage-neutral: the payout is exactly x·R/L against a full-face x liability burn, so c is unchanged. What raises the source leg’s c is a cross exit - withdrawTo into a different token, or swapLiability - which burns the full face from this leg’s liabilities while debiting the payout from the destination leg.
  • The convex coverage toll (kappaCovBps) prices any swap that drains a walled leg further, rising superlinearly as c falls.

Every listed asset including the hub must have kappaCovBps > 0 (Invariants §I-9). The toll is output-only: hub κ prices taking the hub out.

liquidityIndexWad has a small, explicit set of writers, each emitting IndexUpdated with a reason code (PoolConstantsLib.sol):

ReasonConstantDirectionSite
0INDEX_REASON_DONATEupPoolLiquidity.donate
1INDEX_REASON_YIELDupPool.hookCreditYield
3INDEX_REASON_WRITEDOWNdownPool.hookWriteDown
4INDEX_REASON_FEEupPoolLiquidity.accrueLpFee

hookWriteDown is the only writer that lowers the index, and it is reachable only from a configured asset hook booking a realized loss on rehypothecated reserves.

RiskConfig.flags live bits: 0 HALT_RISK_BIT, 1 SWAP_ENABLED_BIT, 2 LIABILITY_SWAP_ENABLED_BIT, 4 FLASH_ENABLED_BIT, 6 HALT_GUARDIAN_BIT; HALT_MASK = 0x0041.


9. Native Token Support

9.1. Sentinel Address

address constant NATIVE = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

9.2. Auto-Wrapping

// PoolIOLib.wrap if (token != NATIVE) return token; if ($.wnative == address(0)) revert InvalidInput(); // fail closed on a no-wrapper chain return $.wnative;

9.3. Transfer Handling

// PoolIOLib.pull requireNoFlash(); if (token == NATIVE) { if ($.wnative == address(0)) revert InvalidInput(); // no wrapper on this chain if (msg.value < amount) revert InsufficientAmount(msg.value, amount); IWETH9($.wnative).deposit{value: amount}(); uint256 excess = msg.value - amount; // overpay is refunded, if (excess > 0) safeTransferETH(msg.sender, excess); // not rejected return amount; } // Payable entrypoints exist solely for the native sentinel. if (msg.value != 0) revert InvalidInput(); uint256 balBefore = _balanceOf(token); safeTransferFrom(token, msg.sender, address(this), amount); return _balanceOf(token) - balBefore; // FoT captured by delta
// PoolIOLib.push if (token == NATIVE) { if ($.wnative == address(0)) revert InvalidInput(); IWETH9($.wnative).withdraw(amount); safeTransferETH(to, amount); } else { safeTransfer(token, to, amount); }

Three details a naive msg.value != amount reading would get wrong: the native branch compares with < and refunds the excess rather than reverting on it; the ERC-20 branch rejects any attached value outright, so value cannot land outside the reserve ledger; and the amount credited to reserves is the measured balance delta, so a fee-on-transfer token books what actually arrived. The native path is dead on Arc: the chain ships no WETH9-style wrapper, initialize was called with wnative_ == address(0) (Pool.sol), and every NATIVE call therefore reverts InvalidInput at the wrapper check.


10. Hook Integration

Per-asset IPoolHooks: 2 void callbacks (preOutflow, optional postInflow).

  • Storage: HookSlot {target, flags} + invested (Rinv).
  • Dual ledger: R=Rliq+Rinv; Pricing / coverage use R, executable capacity is Rliq.
  • Dispatch: liquid check first (0 CALL if buffer OK); shortfall → hard recall via preOutflow, fail-closed. Deploy on postInflow / keeper, not on swap.
  • Yield via harvest → hookCreditYield / hookWriteDown.
  • Admin: timelocked queue via generic Admin requestOp(UPDATE_HOOK) (execute via executeSetAssetHook, cancel via cancelTimelock); direct clearAssetHook (invested == 0).

See Hooks.


11. Error Handling

ErrorCondition
ZeroValue()Amount is zero
InsufficientAmount(available, required)Not enough reserves/balance
ThresholdViolation(value, threshold)Slippage exceeded (minAmountOut / minLpAmountOut); also a minLiquidity breach on withdraw (PoolLiquidity.withdrawTo) and the confidence-interval halt at MAX_CONFIDENCE_HALT_BPS (FeedMathLib.gate)
FeatureDisabled(resource)Swap/flash disabled
Reentrancy()Recursive call attempt
InvalidState()Pool not initialized (Pool.whenInitialized); also a wiped leg, liquidityIndexWad == 0, at PoolLiquidity.mintIndex, and a deposit into a leg holding reserves == 0 against non-zero liabilities

12. Gas Costs

Swap figures measured cold, end-to-end (quartic preset curve on the hot path), by test/gas/GasProbe.t.sol. Warm repeats of the same swap cost roughly a third of the cold figure (56.6k / 55.6k / 71.1k):

OperationGas (Cold)
Swap base -> spoke192.5k
Swap spoke -> base191.5k
Cross-spoke swap (spoke -> base -> spoke)222.4k
Deposit~100k
Withdraw~120k
Donate~60k

Curve components: eval 5.4k cold; O(1) range integral 11.2k cold (flat in trade size and segments crossed).