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 liquiditywithdraw()/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
| Parameter | Type | Description |
|---|---|---|
tokenIn | address | Token being sold (or NATIVE sentinel) |
tokenOut | address | Token being bought |
amountIn | uint256 | Exact amount to sell |
minAmountOut | uint256 | Slippage protection |
recipient | address | Receives output tokens |
deadline | uint256 | Unix 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
(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
| Parameter | Type | Description |
|---|---|---|
token | address | Asset to deposit (NATIVE sentinel for native + msg.value) |
amount | uint256 | Amount to deposit |
3.3. Mechanics
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:
If then . 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
| Parameter | Type | Description |
|---|---|---|
token | address | Asset to withdraw |
lpAmount | uint256 | LP shares to burn |
minAmountOut | uint256 | Slippage protection |
deadline | uint256 | Unix 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
| Coverage | Suppression | Factor | Haircut |
|---|---|---|---|
| 90% | 0 | 1.0 | 10% |
| 80% | 0 | 1.0 | 20% |
| 80% | 10000 | 0.5 | 10% |
| 50% | 15000 | 0.25 | 12.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 payable5.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
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
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_BITmust 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.
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
| Constraint | Value | Purpose |
|---|---|---|
| Max depth | MAX_DEPTH = 4 | Bounds gas and the summed path fee |
| Max nodes | MAX_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 cycles | enforced | validateAnchor 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 = 0a same-asset exit is coverage-neutral: the payout is exactlyx·R/Lagainst a full-facexliability burn, socis unchanged. What raises the source leg’scis a cross exit -withdrawTointo a different token, orswapLiability- 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 ascfalls.
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):
| Reason | Constant | Direction | Site |
|---|---|---|---|
| 0 | INDEX_REASON_DONATE | up | PoolLiquidity.donate |
| 1 | INDEX_REASON_YIELD | up | Pool.hookCreditYield |
| 3 | INDEX_REASON_WRITEDOWN | down | Pool.hookWriteDown |
| 4 | INDEX_REASON_FEE | up | PoolLiquidity.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(). - Dual ledger: ; Pricing / coverage use , executable capacity is .
- Dispatch: liquid check first (0 CALL if buffer OK); shortfall → hard recall via
preOutflow, fail-closed. Deploy onpostInflow/ keeper, not on swap. - Yield via harvest →
hookCreditYield/hookWriteDown. - Admin: timelocked queue via generic Admin
requestOp(UPDATE_HOOK)(execute viaexecuteSetAssetHook, cancel viacancelTimelock); directclearAssetHook(invested == 0).
See Hooks.
11. Error Handling
| Error | Condition |
|---|---|
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):
| Operation | Gas (Cold) |
|---|---|
| Swap base -> spoke | 192.5k |
| Swap spoke -> base | 191.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).
13. Related Documentation
- Inventory Management - Coverage and skew
- Liquidity Shaping - Preset depth curves
- Spread & Fees - Fee calculation
- Feed Oracle - external keeper-pushed price feed (
ExternalOracle) - Hooks - Dual ledger / rehypothecation buffer
- Admin, Flash - peripheral singletons calling
Poolexternally