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

  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. Backing and rate. PoolHooksLib.requireBacked on the input leg (its token balance must cover liquidReserves), then fairIn = liabIn · C, with C from PoolSolvencyLib.mintRate, which reverts FeedUnavailable while any roster leg’s mark is unusable (Inventory Management §5).
  4. 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).
  5. Mark cap. The curve output is clamped at fairIn · markPrice, decimal-corrected by 10^(d_to − d_from) (PoolLiquidityLib._markCap, Lemma B).
  6. Depeg breaker. PoolIOLib.priceBandGuardAll over both legs and every interior hop (Flow Guards).
  7. Shares. liabOut = q.amountOut · WAD / C: the quote output is a token amount and liabilities is a face book, so dividing by C re-denominates to faceinmin/mout with no output-leg haircut. 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 one PoolSolvencyLib.requireCreditOk on the output leg, which carries both the weight cap (WeightCapExceeded) and the deposit cap (ExcessiveAmount) off a single solvency sweep, then the minLpAmountOut check. 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 with InvalidState. 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 = liabIn - fairIn when 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

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

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 C 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 C, 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:

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 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:

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
Per-asset flag bits, HALT_MASKsee layoutPoolConstantsLib.sol
Mark-cap decimal correction10^(d_to − d_from)PoolLiquidityLib._markCap
App default LP-route slippagefixedPct default 0.5%, no SDK-side default: the caller supplies slippageFracsdk/src/router/index.ts, settings.tsx
App deadline, standalone debt swapnow + 600 susePoolData.ts

Check the flag on both legs before assuming the call is available on a given roster.


10. Failure modes

RevertTrigger
InvalidInputtokenIn == tokenOut
InsufficientAmountliabIn exceeds the input leg’s live liabilities
FeatureDisabled(Err.Resource.LIABILITY_SWAP)flag unset on either leg
FeedUnavailableany roster leg’s mark is unusable (mintRate)
WeightCapExceeded / ExcessiveAmountrequireCreditOk on the output leg
Expired / CooldownActivepast deadline; source receipt still locked
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.