---
title: "Liability swaps"
description: "Moving an LP claim between two legs of one pool: swapLiability, the pool rate it converts at, the Lemma B mark cap, and the routes that embed it"
audience: both
type: guide
status: live
lang: en
publish: true
aliases: [4-4-liability-swaps]
---

# Liability swaps

A [liability swap](/docs/glossary#debt-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`](/docs/glossary#liabilities) counters; the pool's [reserves](/docs/glossary#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](#7-where-it-appears-inside-lp-routes)).

---

## 1. Entry point

```solidity
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. **Backing and rate.** `PoolHooksLib.requireBacked` on the input leg (its token balance must cover `liquidReserves + protocolFees`), then `fairIn = liabIn · C`, with `C` from `PoolSolvency.mintRate`, which reverts `FeedUnavailable` while any roster leg's mark is unusable ([Inventory Management §5](/docs/1-1-1-inventory-management#5-lp-settlement-at-the-pool-rate)).
4. **Quote.** `Pricing.anchorPathQuoteLp($, tokenIn, tokenOut, fairIn)`, the LP-path entry to the [anchor-tree](/docs/glossary#anchor-tree) quote, the same curve a market swap crosses, so the mover pays the full embedded spread, skew and toll ([Spread & Fees](/docs/1-1-4-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 ([Flow Guards](/docs/3-2-1-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 $\text{face}_{in}\, m_{in}/m_{out}$ with no output-leg [haircut](/docs/glossary#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 `requireWeightOk` (`WeightCapExceeded`) and `requireDepositCap` (`ExcessiveAmount`) on the output leg, then the `minLpAmountOut` check. The depositor allowlist (`DEPOSIT_GATED_BIT`) is **not** checked: the caller already holds a claim. 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 closes the other side of the same hole. Re-denomination credits at the oracle [mark](/docs/glossary#mark-price), 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](/docs/glossary#coverage-ratio) $c = R/L$ | rises | falls |

The call is 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](/docs/glossary#protocol-fee) is charged, and the call is 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 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|route`), 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](/docs/glossary#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](/docs/3-3-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](/docs/3-2-1-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](/docs/5-2-2-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](/docs/1-2-1-pool) |
| 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](/docs/1-1-1-inventory-management): coverage ratio and the pool rate
- [Spread & Fees](/docs/1-1-4-spread-fees): the spread, the coverage toll, and what the LP path pays
- [Pool](/docs/1-2-1-pool): risk flags, halt mask, module surface
- [Flow Guards](/docs/3-2-1-flow-guards): the anti-JIT cooldown
- [Quotes & Routing](/docs/5-2-2-quotes-routing): route enumeration and ranking
