Providing liquidity
A deposit is single-sided: you send one listed token and receive an ERC-20 receipt for that leg
only, never a proportional basket share. Denomination, price exposure and fee yield stay per leg.
Solvency does not: every mint and every exit settles at one pool-level rate , so a real loss on
any leg is shared pro-rata by every LP in the pool, and so
is every surplus. Covers the app surface (/pools → Manage) and the contract calls behind it.
Coverage ratio and the pool rate are derived in
Inventory Management §5;
what your liquidity charges a swap is in Spread & Fees.
1. What a position is
Each (pool, listed asset) pair has exactly one LPToken, an EIP-1167 clone-with-immutable-args deployed at initAsset (PoolConfigLib._deployLpToken). Its on-chain symbol is B{code}-{SYM} and its name BTR {Name} Pool {SYM} (e.g. BC-USDC, BTR Core Pool USDC). {code} is one letter and {Name} at most 12 letters, both set once at initialize: C Core, S Stable, F Forex, X Crypto, E Equity, L LSD, V Volatile. A pool with a foreign admin prefixes LP- and LP: instead. {SYM} is at most 8 alphanumerics taken from the listing (lpSym), not from token.symbol(); wraps keep the W (BC-WETH). Decimals equal the underlying token’s. The app shows the same symbol. The pool address is pinned in the clone’s code and is the sole mint and burn authority.
A receipt balance is not 1:1 with the underlying. The claim is
That underlying amount is face, denominated in your token. Your price exposure is that one token. What face redeems for is face times the pool rate (§5), and moves with every leg in the pool.
Your leg’s coverage is moved by the whole core: a swap that delivers your asset raises it, a swap that takes your asset lowers it. Cores are grouped by correlated asset class so that the two tend to offset. A drained leg against a filled one is inventory location, not loss; nets it out. Coverage affects you in one place: it caps what a same-asset exit can deliver in your own token (§5.1). Why a core holds what it holds: Pool Composition.
The liquidityIndexWad starts at LIQUIDITY_INDEX_INIT_WAD = (PoolConstantsLib.sol) and rises when LP fees accrue to the leg (PoolLiquidityLib._raiseIndex). It is a uint72: _raiseIndex clamps at type(uint72).max (about 4,722 WAD) rather than reverting, and value above the clamp stays in liabilities unclaimable. hookWriteDown floors the leg’s book at the smallest liability the index can still represent, so the index stays at or above 1 through any loss, a total loss included, and the leg stays live (test_total_loss_leaves_the_leg_live_and_depositable). mintIndex still reverts InvalidState on an index of 0, as a fail-closed guard.
2. Deposit
Pool.deposit(address token, uint256 amount) is payable and forwards to PoolLiquidityLib.deposit. Shares always mint to msg.sender: the entrypoint takes no recipient, no minLpOut and no deadline.
- Approve. The app reads
allowance(user, pool)and, if short, prependsapprove(pool, amount), the exact amount by default,MAX_UINT256only if you opt into infinite approval. Both calls go out as one EIP-5792 batch where the wallet supportssendCalls, otherwise sequentially. - Validate.
amount == 0revertsZeroValue.PoolIOLib.assetresolves the leg, mapping the NATIVE sentinel to wnative, and revertsNotFoundon an unlisted token.checkRiskFlagsrevertsFeatureDisabledif anyHALT_MASKbit is set on the leg (layout). A leg carryingDEPOSIT_GATED_BIT(0x200) revertsNotAuthorizedunless bit 9 of the caller’sAccessControl.permsbitmask is set: the guarded-launch allowlist, which gatesdepositanddonateonly, never an exit. A leg withreserves == 0 && liabilities != 0revertsInvalidState. - Rate.
PoolSolvencyLib.solvencycomputes and the call revertsFeedUnavailableif any armed leg’s mark failsFeedMathLib.gate: a deposit priced off a pool whose value is unknown refuses rather than degrades. On an armed poolPoolIOLib.priceBandGuardalso checks this leg’s depeg band. - Pull.
PoolIOLib.pullcallsrequireNoFlash(flash callbacks cannot deposit, closing the repay-by-depositing double credit), wrapsmsg.valueviaIWETH9.depositfor the NATIVE sentinel and refunds the excess, otherwise rejects nonzeromsg.valueand does a balance-deltasafeTransferFrom. A fee-on-transfer token therefore credits only what arrived. - Mint at C.
face = amt * 1e18 / C, floored toward the pool;idx = asset.liquidityIndexWad(mintIndexrevertsInvalidStateat 0);lpAmt = face * 1e18 / idx, less the dead-share seed on a first credit (§3); a leg’s first deposit also buys the treasury one pending share at par (Pool.treasuryShares, Protocol Fee Collection).face == 0orlpAmt == 0revertsZeroValue, so a dust deposit that would mint nothing cannot be gifted to existing LPs. - Book.
reserves += amtandliabilities += face, thenILPToken.mint. A deposit never calls the leg’s hook: only the keeper invests (Hooks). The call emitsDeposited(sender, token, amt, lpAmt)before a singlerequireCreditOkchecks the post-mint book: revertsWeightCapExceededif a non-zeromaxLiabWeightBpsis passed, revertsExcessiveAmountif the leg’s notional in base tokens passesdepositCapCode(m·10^ewhole base tokens;0only on a pre-upgrade leg, meaning uncapped), then emitsSolvencyUpdated(cWad, navBase, claimBase). ReturnsDepositResult{lpAmount, actualDeposit, deadLp}.
At you receive less face than you pay, so existing LPs’ surplus is not diluted. At you receive more. Either way the deposit does not move (testFuzz_deposit_preserves_the_rate). reserves and liabilities are uint128, and a deposit past that ceiling reverts Overflow.
A leg at reserves == 0 with liabilities outstanding refuses deposits (InvalidState). Pool.donate(token, amount) is the reopen path: it credits amt to reserves and face amt · 1e18 / C to liabilities, raises the index and mints nothing, so the gift lands on the leg’s LPs and does not move. It carries the same allowlist, band, weight and deposit-cap checks as a deposit and the same FeedUnavailable refusal. donate reverts InvalidState on a leg that was never credited (liabilities == 0), because reserves against an empty claim book would be claimed by the next depositor; open a leg by depositing.
Deposit is charged nothing: no protocol fee, no spread, no coverage toll. is not a charge; it prices the claim you buy.
3. The dead-share seed
The first depositor into a leg (receipt totalSupply() == 0) funds a permanent floor. PoolLiquidityLib.seedDeadShares does three things:
- computes
seed = 10**deadSeedPow10, or10**decimals / DEAD_SHARE_SEED_DIV(DEAD_SHARE_SEED_DIV= 1000, i.e. 0.001 token) whendeadSeedPow10is 0; - mints
deadLp = ceil(seed * 1e18 / idx)toaddress(0); - subtracts it from that depositor’s own mint (
lpAmt -= deadLp).
It emits DeadSharesSeeded and is paid once per leg; the seed is never burnable. Per-asset deadSeedPow10 is capped at the leg’s decimals plus DEAD_SEED_POW10_HEADROOM = 3.
Users cannot transfer a receipt to address(0) themselves: LPToken._beforeTokenTransfer reverts ZeroAddress unless the caller is the pool, since a user burn there would raise the dead floor with LP money.
4. The anti-JIT cooldown
| Parameter | Value | Site |
|---|---|---|
DEFAULT_FLOW_COOLDOWN_SECS | 15 s, set at Pool.initialize | PoolConstantsLib.sol |
MAX_FLOW_COOLDOWN_SECS | 300 s, ceiling on PoolConfigLib.setFlowCooldown | PoolConstantsLib.sol |
Admin.setFlowCooldown can only raise the window, instantly; lowering rides a TUNING-tier UPDATE_FEES, and a pending one that carries a shorter window is voided by a raise.
| Read at runtime | Pool.flowCooldownSecs() | Pool.sol |
The lock against JIT liquidity is armed at mint, on the receipt: LPToken.mint writes locks[to] = {stamp: block.timestamp, frozen: previousFrozen + amount}, resetting frozen to amount if the previous lock had already expired. It freezes a quantity, not the account; an older, unfrozen balance stays movable. Topping up inside an unexpired window restarts the clock over the whole recent parcel, and swapLiability arms a fresh lock on the destination shares.
Enforcement lives entirely in LPToken._beforeTokenTransfer, which reverts CooldownActive when balanceOf(from) < amount + frozen and block.timestamp < stamp + flowCooldownSecs. It gates the withdraw burn, the swapLiability burn and plain ERC-20 transfers of the receipt alike; PoolLiquidityLib performs no cooldown check of its own. Any lock older than MAX_FLOW_COOLDOWN_SECS short-circuits without reading the pool. Related block-level protections: Flow Guards.
5. Withdraw
Pool.withdraw(token, lpAmount, minAmountOut, deadline) forwards to PoolLiquidityLib.withdrawTo($, token, token, …); the same-asset case is literally withdrawTo with tokenFrom == tokenTo. Both entrypoints are nonReentrant, whenInitialized and beforeDeadline, and both endpoint legs are checked against HALT_MASK. No ERC-20 approval is needed: the pool burns the caller’s own receipt.
Every exit settles against the pool rate
over every leg on the pool’s roster, with the leg’s mark in base units (PoolSolvencyLib.solvency, derivation in Inventory Management §5). is uncapped: an over-solvent pool pays more than face.
Sequence, after requireNoFlash and the lpAmount == 0 check:
- Face value.
withdrawValue = lpAmount * assetFrom.liquidityIndexWad / 1e18. AboveassetFrom.liabilitiesrevertsInsufficientAmount; zero revertsZeroValue. - Rate. Same-asset: pay
face · min(c_leg, C), less the exit toll (§5.1). Cross-asset: convert fair valueface · C(§5.2). - Cross-asset only: anchor-path conversion and mark cap (§5.2).
- Guards.
amt == 0revertsZeroValuebefore any burn.preOutflowrecalls from the yield hook foramt + minLiquidity; after the ledger move,liquidReserves < minLiquidityrevertsThresholdViolation, as doesamt < minAmountOut. - Burn, where the cooldown is checked (§4), then settle and
PoolIOLib.pushthe output, unwrapping to ETH for the NATIVE sentinel.
5.1. Same-asset exit
PoolLiquidityLib._quoteWithdrawSame pays amt = face * mu / 1e18 with
(PoolLiquidityLib.exitMu, PoolSolvencyLib.exitCap). is what you are owed. is the in-kind delivery bound: a leg cannot hand out more of its own token than it holds, and pro-rata is race-free; paying face · C out of a drained leg would be a run. When the pool is short of that token, not of value, and the rest of your face · C claim is reachable through withdrawTo (§5.2) at that conversion’s spread. The same-asset payout is then net of the exit toll (Inventory Management §5.2).
What you receive:
face · μof your token; when both the pool and your leg are over-covered.- With any roster leg’s mark unusable, the exit still settles, oracle-free, at
min(c_leg, min(1, lastGoodCWad)), never more than the last observed healthy rate, or 1 on a pool that has never observed one (lastGoodCWad == 0). - On a pool that predates the roster (
getLegs()empty),face · min(c_leg, 1)until the owner’sGOVERNANCE-tierBACKFILL_LEGSop arms it. previewWithdrawreturns(amountOut, haircut)withhaircut= face - amtwhen positive.
Derivation, rate invariants and the test roster: Inventory Management §5.
5.2. Cross-asset exit (withdrawTo)
Pool.withdrawTo(tokenFrom, tokenTo, lpAmount, minAmountOut, deadline) burns a receipt on one leg and pays out in another. Fair value is face · C, converted along the anchor path by PricingLib.anchorPathQuoteLp with the path’s spread, treasury share and LP fee charged on the output leg as for a swap (Anchor Path Pricing), capped at the oracle mark path (_markCap), and depeg-checked on both legs and every hop (priceBandGuardAll). The from-leg must be physically backed (requireBacked), and its own coverage is never read, so slicing an exit buys nothing. There is no output-leg haircut: the claim was settled at on the way in. Any roster mark unusable reverts FeedUnavailable.
Pool.swapLiability(tokenIn, tokenOut, lpAmountIn, minLpAmountOut, deadline) is the receipt-to-receipt variant: the same fairIn = liabIn · C conversion, mark-capped, with the token output credited to the destination leg as face amountOut · 1e18 / C. It moves no reserves, is protocol-fee exempt, books no LP fee, requires LIABILITY_SWAP_ENABLED_BIT on both legs, and checks the weight and deposit caps on the credited leg as a deposit does; it is not gated by the depositor allowlist, since the caller already holds a claim. Full sequence: Liability Swaps.
5.3. Events
A same-asset withdraw emits one Withdrawn(sender, fromTk, amt, lpAmount). A cross-asset withdrawTo emits two: LiabilitySwapped(sender, fromTk, toTk, lpAmount, 0, haircut) for the burn, where haircut = face - face · C when positive, and Withdrawn(sender, toTk, amt, 0) for the payout; a single Withdrawn cannot carry both legs. An indexer that reads only Withdrawn corrupts both legs’ reconstructed balances. Every LP entrypoint on an armed pool also emits SolvencyUpdated(cWad, navBase, claimBase), the numerator and denominator in base units, so is reconstructable from logs without replaying marks.
6. What can stop you exiting
Four things gate an exit, and they do not gate the same paths. Source:
PoolLiquidityLib.withdrawTo and PricingLib.anchorPathQuoteLp.
| Condition | Same-asset withdraw | Cross-asset withdrawTo | swapLiability |
|---|---|---|---|
Any roster leg’s oracle feed is stale or dead (poolSolvencyWad() returns ok = false) | not blocked, degraded rate | blocked | blocked |
| A halt bit is set on the leg | blocked | blocked | blocked |
LIABILITY_SWAP_ENABLED_BIT clear on either leg (set per leg at listing; see deployed instances) | not blocked | blocked | blocked |
Executable reserves below minLiquidity | blocked | blocked | not applicable |
Row 1 scope: any leg on the roster with a non-empty book, not only the legs your exit touches,
because reads every mark. A same-asset withdraw needs no mark to settle: with one unusable it
pays min(c_leg, min(1, lastGoodCWad)) (§5.1), so a stale oracle cannot trap a position in the leg
it was deposited into. Everything that credits a claim against refuses instead: deposits,
donations, cross exits, liability swaps and hook yield credits all revert FeedUnavailable until
every mark is usable again. There is no par fallback on any of them
(Inventory Management §5.2).
A halt bit stops all three, including the same-asset path: the HALT_MASK check runs on both
endpoints of every withdraw. A guardian or the owner halts at once; lifting takes the owner, or on a
seated pool the seat (Access Control §2).
maxRedeem is a floor, not a promise: it is computed from executable liquidity at the time you read
it, and a withdraw re-checks it after any hook recall, so a large exit can revert even though the
app quoted it a moment earlier.
6.1. When the quote service is down
Deposits and withdrawals in the app need the price service: the recap’s minimum received and the send-time floor are both priced from its chain quote. When that service is unavailable the app pauses LP writes, greys the last numbers it holds and says so on the form; it does not invent a local price.
The pools stay permissionless, so this is a UX outage, not a custody one. Your receipt is still in
your wallet and the exit still exists on chain. To take it without the app, call
Pool.previewWithdraw(token, lpAmount) from a block explorer, then pass its amountOut as
minAmountOut to Pool.withdraw(token, lpAmount, minAmountOut, deadline). A cross-asset exit uses
Pool.withdrawTo(tokenFrom, tokenTo, lpAmount, minAmountOut, deadline) the same way. The SDK
exposes both calls in @btr-protocol/sdk; see Basic Operations.
7. What the position actually earns
Your claim grows through two numbers; the receipt balance does not change. The leg’s
liquidityIndexWad sets your face, and it moves on that leg alone: up with the LP share of swap
fees on that leg (net of the pool’s protoSharePct, read from getFeeParams()), with donations and with hook yield, each
credited at ; down with hookWriteDown, which cuts the leg’s liabilities by
ceil(loss · 1e18 / C) when a hooked venue loses money. The leg whose venue lost pays and no other
leg’s claim moves; there is no protocol backstop for the loss (Hooks).
The protocol share accrues per leg as treasury shares (Pool.treasuryShares) and is minted out only when pool.treasury() calls Admin.claimTreasury, which emits TreasuryClaimed (Protocol Fee Collection).
The pool rate sets what face is worth, and it moves for every LP in the pool at once: the
coverage toll and retained skew raise against unchanged on whichever leg they land, and a
real NAV loss on any leg, a mark-to-market LVR loss included, lowers it pro-rata for everyone. The
only bounds on how much of one leg’s risk spreads are the two soft caps in its RiskConfig,
depositCapCode and maxLiabWeightBps, which bind when a claim is credited and drift with marks
afterwards (Inventory Management §5.3; per-leg values: deployed instances).
The index includes no compensation for LVR. AIMM prices adverse selection into the spread so that flow pays for it rather than LPs; nothing compensates an LP for it after the fact. A position’s return is fees and tolls earned minus the adverse selection the spread did not cover.
8. In the app
The LP surface is the swap form: /pools → Manage writes ?pool=&asset=&action=deposit|withdraw|swap into the URL and renders <SwapForm mode="lp"> with fromSym == toSym, so the deposit/withdraw toggle is the same flip gesture as buy/sell.
- The withdraw amount field is in underlying units, converted to shares by
amt * WAD / index, that is, by face. The exit multiplier of §5.1 applies on top, so you receiveface · μ: less than the number you typed whenever . - Max / 75% read
Pool.maxRedeem(owner, token), which foldsHALT_MASK, a zero index, frozen shares fromLPToken.locks, andliquidReserves − minLiquiditydivided byindex · μinto a share capacity. It ignores hook recall, depeg bands andrequireNoFlash, so a call can still revert at a sizemaxRedeemallowed. minAmountOut = 0disables the on-chain floor onwithdrawandwithdrawTo; integrators set their own, and a recap slippage row is display-only unless it reaches the calldata.
9. Contract reference
| Call | Purpose |
|---|---|
Pool.deposit(token, amount) | single-sided mint, returns DepositResult |
Pool.withdraw(token, lpAmount, minAmountOut, deadline) | same-asset exit |
Pool.withdrawTo(tokenFrom, tokenTo, lpAmount, minAmountOut, deadline) | cross-asset exit |
Pool.swapLiability(tokenIn, tokenOut, lpAmountIn, minLpAmountOut, deadline) | receipt-to-receipt move |
Pool.donate(token, amount) | credit a leg at without minting |
Pool.previewWithdraw(tk, lp) | same-asset (amountOut, haircut): amountOut net of the exit toll, haircut the coverage shortfall at min(c_leg, C); indicative, arms no band guard |
Pool.maxRedeem(owner, tk) | redeemable share capacity |
Pool.poolSolvencyWad() | (cWad, ok): the live , and whether every roster mark was usable |
Pool.getLegs() | the roster sums over; empty means unarmed |
Pool.getLPBalance(u, tk) / Pool.lpToken(tk) | receipt balance / receipt address |
Pool.getAsset(tk) / Pool.getCoverageRatio(tk) | leg ledger and |
Pool.flowCooldownSecs() | live anti-JIT window |
SDK entry points are deposit / withdraw in @btr-protocol/sdk (src/pool), with EIP-5792 batch builders in src/router; see Cookbook §3 and Basic Operations.
10. LP checklist
Run before you commit capital. Every item is stated in full above.
Before depositing
- Read two ratios. from
Pool.poolSolvencyWad()prices every claim in the pool, yours from the moment you deposit; a real loss on any leg moves it. on your leg shifts the quoted mid through the inventory skew (saturating at and , fixed in code) and caps what a same-asset exit delivers in your own token (§5.1). - Deposit the asset you are willing to leave in. Same-asset
withdrawis not feed-gated; cross-assetwithdrawToandswapLiabilityare, so a dead or depegged feed shuts those exits (§6). - Check the fee split. is the share of every swap and flash spread booked as treasury shares for
pool.treasury(); the remainder raises your index. Set ininitdataatcreatePool, moved afterwards by aTUNING-tierUPDATE_FEESwith one hour of notice underPROD_DELAYS, so watch the queue (Incentivization §2). - If the leg runs a hook, read which venue first (Hooks). You carry its credit risk with no insurance layer, and part of the leg’s reserves sit off-pool: pricing uses full , your withdrawal executes against . Check against
minLiquidity, not : a leg can be solvent on paper and unwithdrawable in size in the same block.
While the position is open
- Fee APR is gross, not return. Realised return is the change in
liquidityIndexWadnet of the LVR paid to arbitrageurs against the external mark, and nothing rebates that (§7). Judge a leg on realised index growth over a full cycle. - Other LPs move your coverage without touching reserves.
swapLiabilityre-denominates a liability from one leg to another at the oracle mark, moving no cash: your leg’s coverage ratio, and your in-kind delivery bound, change with no reserve flow to watch for. - You cannot pin a version. A
GOVERNANCE-tier fleet upgrade atPoolFactorymoves every live pool at once. Your notice is the timelock; your only response is to exit (Admin).
When you exit
- Size from
Pool.maxRedeem, not from your balance. It folds the halt bits, a wiped index, your frozen shares and into one number, and returns0rather than a partial answer when any of them binds. It assumes no hook recall succeeds, so it is a floor, not a promise (§8). - Read
LPToken.locks(holder)and size frombalance − frozen. The cooldown freezes the freshly minted quantity againstwithdraw,withdrawTo,swapLiabilityand plaintransfer; a violation revertsCooldownActive(§4). - A halt blocks both directions.
checkRiskFlagsgatesdeposit,donate,withdrawToandswapLiabilityonHALT_MASK, so a halted leg cannot be exited by anyone at any price until it is cleared: by the owner, or on a seated pool by the seat, afterLISTINGwhen a guardian or the owner raised it (Access Control §2). A guardian never clears one. This is the single largest discretionary risk an LP carries. - Know your leg’s caps.
Pool.getAsset(tk)returnsdepositCapCode(leg notional cap in whole base tokens,m·10^efromm<<4|e),maxLiabWeightBpsandflags; bit 9 (DEPOSIT_GATED_BIT,0x0200) set means deposits are allowlisted. A capped leg refuses a deposit that would carry it past the cap, and refuses every deposit while its own mark is unusable.
11. Related documentation
- Inventory Management: coverage ratio and the pool rate in the pricing context
- Spread & Fees: how the LP fee reaches
liquidityIndexWad - Pool: the module, its storage and its full ABI
- Flow Guards: cooldowns and block-level protections
- Invariants: the liquidity floor a withdrawal must respect