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 token 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. This page is for LPs deciding whether to use it and for integrators encoding it.
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 PoolLiquidity.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. - Input haircut.
applyHaircut(liabIn, R_in, L_in, suppressor_in)→fairIn(Haircut). - Quote.
Pricing.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)(PoolLiquidity._markCap, Lemma B). - Depeg breaker.
priceBandGuardAll($, tokenOut, tokenIn, q.routeHops)over both legs and every interior hop (Depeg Halt). - Output haircut and shares.
applyHaircut(q.amountOut, R_out, L_out, suppressor_out)→liabOut;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 theminLpAmountOutcheck. 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), wherehaircutis the output-leg haircut only.
3. Two haircuts, and why the full face burns
liabIn is burned in full, but only fairIn, the post-haircut face, is re-denominated. Swapping out of an under-covered leg therefore costs the LP the coverage haircut exactly as an exit would: it is not a toll-free escape from a deficit, and it cannot dump that deficit onto the destination leg’s LPs.
The mark cap in step 5 closes the other side of the same hole. 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 haircut 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 |
This makes the call a coverage instrument as much as an exposure change, and the LP form leads with that. 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 the call is deliberately not routed through accrueLpFee: 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 eth_call returns a liability-swap quote. The only quote is the SDK mirror quoteSwapLiability(state, inLeg, outLeg, lpAmountIn) (@btr-protocol/sdk, src/pool/liability.ts), which replays the contract pipeline in f64: input haircut → anchor-path quote → mark cap → output haircut → shares. It returns null in exactly the cases the contract would revert (over-burn, zero output), so a reverting transaction is never quoted.
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 bites 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 15 s, MAX_FLOW_COOLDOWN 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 |
|---|---|---|
LIABILITY_SWAP_ENABLED_BIT | 1 << 2 (0x04) | PoolConstantsLib.sol |
| Deployed asset flags | SWAP_ENABLED_BIT | LIABILITY_SWAP_ENABLED_BIT = 0x06 | Arc pool deploy scripts |
HALT_MASK | 0x0041 (risk halt bit 0, guardian halt bit 6) | checked on both legs, see Pool |
HAIRCUT_SUPPRESSOR_FULL_BPS | 20_000 | PoolConstantsLib.sol |
| Mark-cap decimal correction | 10^(d_to − d_from) | PoolLiquidity._markCap |
| SDK default route slippage | DEFAULT_SLIP = 0.005 (0.5%) | sdk/src/router/lpRoutes.ts |
| App deadline, standalone debt swap | now + 600 s | front/src/hooks/usePoolData.ts |
The Arc testnet fleet ships 0x06, so the path is enabled on every listed asset there. On any other roster, check the flag on both legs before assuming the call is available.
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 |
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 withdrawal haircut
- 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