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

  1. Guards. requireNoFlash(); revert on zero amount; revert InvalidInput if tokenIn == tokenOut; checkRiskFlags(..., LIABILITY_SWAP_ENABLED_BIT) on both legs, which also enforces HALT_MASK on both.
  2. Face. liabIn = lpAmountIn * liquidityIndexWad / WAD. Reverts InsufficientAmount if liabIn exceeds the leg’s live liabilities: shares never claim more face than the leg owes.
  3. Input haircut. applyHaircut(liabIn, R_in, L_in, suppressor_in)fairIn (Haircut).
  4. 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).
  5. Mark cap. The curve output is clamped at fairIn · markPrice, decimal-corrected by 10^(d_to − d_from) (PoolLiquidity._markCap, Lemma B).
  6. Depeg breaker. priceBandGuardAll($, tokenOut, tokenIn, q.routeHops) over both legs and every interior hop (Depeg Halt).
  7. 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. Reverts ZeroValue if that lands at zero.
  8. Ledger. assetIn.liabilities -= liabIn; assetOut.liabilities += liabOut; then the minLpAmountOut check. No line of the function touches .reserves.
  9. Receipts. Burn the source receipt, mint the destination receipt, both for msg.sender.
  10. Event. LiabilitySwapped(sender, tokenIn, tokenOut, lpAmountIn, lpAmountOut, haircut), where haircut is 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

QuantityInput legOutput leg
reservesunchangedunchanged
liabilities-liabIn+liabOut
Coverage ratio c=R/Lrisesfalls

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:

ConditionCauseWhere
Feature flagLIABILITY_SWAP_ENABLED_BIT unset on either leg (or either leg halted)checkRiskFlags; reverts Err.Resource.LIABILITY_SWAP
Cooldownanti-JIT lock still armed on the source receipt (ILPToken.locks)LPToken._beforeTokenTransfer
BalancelpAmountIn above the held receipt balanceclient-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:

RouteCalls
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

NameValueSource
LIABILITY_SWAP_ENABLED_BIT1 << 2 (0x04)PoolConstantsLib.sol
Deployed asset flagsSWAP_ENABLED_BIT | LIABILITY_SWAP_ENABLED_BIT = 0x06Arc pool deploy scripts
HALT_MASK0x0041 (risk halt bit 0, guardian halt bit 6)checked on both legs, see Pool
HAIRCUT_SUPPRESSOR_FULL_BPS20_000PoolConstantsLib.sol
Mark-cap decimal correction10^(d_to − d_from)PoolLiquidity._markCap
SDK default route slippageDEFAULT_SLIP = 0.005 (0.5%)sdk/src/router/lpRoutes.ts
App deadline, standalone debt swapnow + 600 sfront/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

RevertTrigger
InvalidInputtokenIn == tokenOut
InsufficientAmountliabIn exceeds the input leg’s live liabilities
FeatureDisabled(Err.Resource.LIABILITY_SWAP)flag unset on either leg
ZeroValuea fully hair-cut destination leg re-denominates to nothing
slippage revertdelivered shares below minLpAmountOut
depeg / staleness haltspriceBandGuardAll, 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.