Liability swaps
A liability swap moves an LP’s claim from one leg of a pool to another leg of the same pool, in one transaction. It burns the source leg’s receipt, mints the destination leg’s receipt, and moves only the two liabilities counters; the pool’s reserves are untouched. The app labels this “Debt swap” (third tab of the LP form, and the step label in the routing recap); the contract function is swapLiability.
AIMM receipts are per leg: IPool holds mapping(address leg => address) lpTokens, one ERC-20 clone per listed asset, and every LP position is single-sided. An LP holding USDC-leg shares who wants ETH-leg exposure would otherwise withdraw and redeposit. swapLiability is the single-call substitute, and it is also a leg of two of the four ranked LP routes (§7).
1. Entry point
function swapLiability(
address tokenIn,
address tokenOut,
uint256 lpAmountIn,
uint256 minLpAmountOut,
uint256 deadline
) external nonReentrant whenInitialized beforeDeadline(deadline) returns (uint256 lpAmountOut);Pool.sol, delegating to PoolLiquidityLib.swapLiability. tokenIn and tokenOut are the two assets, but the amounts are LP shares of the corresponding receipts. There is no recipient parameter: shares burn from and mint to msg.sender only. No ERC-20 approval is required: nothing leaves the caller’s account except their own receipt, which the pool burns.
2. What one call does, in order
- Guards.
requireNoFlash(); revert on zero amount; revertInvalidInputiftokenIn == tokenOut;checkRiskFlags(..., LIABILITY_SWAP_ENABLED_BIT)on both legs, which also enforcesHALT_MASKon both. - Face.
liabIn = lpAmountIn * liquidityIndexWad / WAD. RevertsInsufficientAmountifliabInexceeds the leg’s live liabilities: shares never claim more face than the leg owes. - Backing and rate.
PoolHooksLib.requireBackedon the input leg (its token balance must coverliquidReserves), thenfairIn = liabIn · C, withCfromPoolSolvencyLib.mintRate, which revertsFeedUnavailablewhile any roster leg’s mark is unusable (Inventory Management §5). - Quote.
PricingLib.anchorPathQuoteLp($, tokenIn, tokenOut, fairIn), the LP-path entry to the anchor-tree quote, the same curve a market swap crosses, so the mover pays the full embedded spread, skew and toll (Spread & Fees). - Mark cap. The curve output is clamped at
fairIn · markPrice, decimal-corrected by10^(d_to − d_from)(PoolLiquidityLib._markCap, Lemma B). - Depeg breaker.
PoolIOLib.priceBandGuardAllover both legs and every interior hop (Flow Guards). - Shares.
liabOut = q.amountOut · WAD / C: the quote output is a token amount andliabilitiesis a face book, so dividing byCre-denominates to with no output-leg haircut.lpAmountOut = liabOut · WAD / mintIndex(tokenOut), less any dead-share seed on a first-credit leg. RevertsZeroValueif that lands at zero. - Ledger.
assetIn.liabilities -= liabIn; assetOut.liabilities += liabOut;then onePoolSolvencyLib.requireCreditOkon the output leg, which carries both the weight cap (WeightCapExceeded) and the deposit cap (ExcessiveAmount) off a single solvency sweep, then theminLpAmountOutcheck. The per-leg depositor allowlist (DEPOSIT_GATED_BIT) is not checked: the caller already holds a claim. A foreign-admin pool that is not yet sealed still refuses non-admin callers withInvalidState. No line of the function touches.reserves. - Receipts. Burn the source receipt, mint the destination receipt, both for
msg.sender. - Event.
LiabilitySwapped(sender, tokenIn, tokenOut, lpAmountIn, lpAmountOut, haircut), wherehaircut = liabIn - fairInwhen positive:liabIn · (1 - C)below par, 0 at or above it.
3. The pool rate, and why the full face burns
liabIn is burned in full, but only fairIn = liabIn · C is re-denominated. The input leg’s own coverage is not read: a rate read off the leg being left rises as its liabilities burn, which is the slicing gain the pool rate removes. The conversion reads the same C a cross-asset exit does.
The mark cap in step 5 bounds the other direction. Re-denomination credits at the oracle mark, never at the skewed mid; without it, an under-covered input leg could mint destination claims above its fair value whenever adaptive dispersion widened the skew past the spread.
4. Coverage and fee consequences
| Quantity | Input leg | Output leg |
|---|---|---|
reserves | unchanged | unchanged |
liabilities | liabIn | liabOut |
| Coverage ratio | rises | falls |
The call changes coverage as well as exposure, and the LP form shows that first. It shows:
- pre/post coverage on both legs with a healing / hurting / neutral signal;
- the APR delta between the legs;
- “Best Coverage” / “Best APR” destination lists.
No protocol fee is charged, and no fee is booked through the index or the treasury: there is no physical outflow to skim and no retained token to back an index raise. The mover still pays the full embedded spread; both fee components land as reduced net liability, i.e. a global coverage gain, realisable only while some leg is under-covered.
5. Quoting
There is no on-chain preview. Pool exposes previewWithdraw and getSwapQuote; neither covers this path, and no view function returns a liability-swap quote. The exact figure is an eth_call of swapLiability itself from the holder’s address: the call is simulated and never mined. The SDK mirror is quoteSwapLiabilityAsync(state, inLeg, outLeg, lpAmountIn, opts) (@btr-protocol/sdk, src/pool/liability.ts): it computes from the pool state, converts fairIn over the backend pricer (POST /v1/quote per direct leg, POST /v1/quote-path for a spoke→spoke cross), applies the mark cap and divides back by , the same six steps as the chain. The synchronous quoteSwapLiabilityCore throws: the TS curve replica is deleted.
minLpAmountOut(quotedShares, slippageFrac) floors the quote. Slippage here guards LP shares, not tokens: an LP reusing the market-swap slippage percentage is guarding a different unit.
The mark cap can bind silently: when it does, LiabilitySwapped carries no trace of it. The SDK surfaces it as markCapBinding / markClampBps; a chain observer cannot reconstruct it. This is the observability gap tracked in Observability.
6. Pre-flight conditions
Three conditions block the call, each checked client-side before the button enables and enforced again on chain:
| Condition | Cause | Where |
|---|---|---|
| Feature flag | LIABILITY_SWAP_ENABLED_BIT unset on either leg (or either leg halted) | checkRiskFlags; reverts Err.Resource.LIABILITY_SWAP |
| Cooldown | anti-JIT lock still armed on the source receipt (ILPToken.locks) | LPToken._beforeTokenTransfer |
| Balance | lpAmountIn above the held receipt balance | client-side, then the burn |
The cooldown applies twice. The source burn is gated by the source receipt’s lock, and the mint arms a fresh lock over the destination shares. A user who just deposited cannot immediately debt-swap, and a user who just debt-swapped cannot immediately withdraw the destination; the rebalanced position exits no earlier than the original deposit could have. Cooldown bounds are in Flow Guards (DEFAULT_FLOW_COOLDOWN_SECS 15 s, MAX_FLOW_COOLDOWN_SECS 300 s). This is why the SDK returns the affected routes as feasible: false, reason: 'cooldown' instead of quoting a batch that would revert, and why those routes run sequentially rather than atomically.
7. Where it appears inside LP routes
The SDK ranks four LP routes; two contain a swapLiability leg:
| Route | Calls |
|---|---|
| Mint B: deposit-first | [approve?, deposit(X), swapLiability(X → target)] |
| Redeem B’: transfer-exit | [swapLiability(held → X), withdraw(X)] |
buildDepositCalls and buildRedeemCalls (sdk/src/router/index.ts) emit these on their 'transfer' branch. When one wins the ranking, a user performing what looks like a plain cross-asset deposit or withdrawal executes a liability swap inside the batch; the routing recap labels that step “Debt swap”. Route ranking itself is covered in Quotes & Routing.
8. Reading the event
LiabilitySwapped is not unique to this flow. A cross-asset withdrawTo, an ordinary withdrawal, also emits it, as LiabilitySwapped(..., lpAmountIn, 0, haircut) alongside a Withdrawn carrying lpAmount = 0.
Discriminator: lpAmountOut == 0 means it was not a liability swap.
The indexer registers the topic, stores the raw log, and flags the block economic to force a fresh coverage/parameter snapshot. It does not decode the event into a typed table the way swaps are decoded, so consumers wanting fields must decode the log themselves.
9. Constants and configuration
| Name | Value | Source |
|---|---|---|
Per-asset flag bits, HALT_MASK | see layout | PoolConstantsLib.sol |
| Mark-cap decimal correction | 10^(d_to − d_from) | PoolLiquidityLib._markCap |
| App default LP-route slippage | fixedPct default 0.5%, no SDK-side default: the caller supplies slippageFrac | sdk/src/router/index.ts, settings.tsx |
| App deadline, standalone debt swap | now + 600 s | usePoolData.ts |
Check the flag on both legs before assuming the call is available on a given roster.
10. Failure modes
| Revert | Trigger |
|---|---|
InvalidInput | tokenIn == tokenOut |
InsufficientAmount | liabIn exceeds the input leg’s live liabilities |
FeatureDisabled(Err.Resource.LIABILITY_SWAP) | flag unset on either leg |
FeedUnavailable | any roster leg’s mark is unusable (mintRate) |
WeightCapExceeded / ExcessiveAmount | requireCreditOk on the output leg |
Expired / CooldownActive | past deadline; source receipt still locked |
ZeroValue | a fully hair-cut destination leg re-denominates to nothing |
| slippage revert | delivered shares below minLpAmountOut |
| depeg / staleness halts | priceBandGuardAll, feed gate on any leg or interior hop |
The SDK mirror returns null for the over-burn and zero-output cases, so the app never quotes them.
11. Related documentation
- Inventory Management: coverage ratio and the pool rate
- Spread & Fees: the spread, the coverage toll, and what the LP path pays
- Pool: risk flags, halt mask, module surface
- Flow Guards: the anti-JIT cooldown
- Quotes & Routing: route enumeration and ranking