Swaps
This page covers the full buyer lifecycle: opening a swap, moving it, closing it (three ways), and retrieving escrowed settlement if a direct transfer ever fails. Every function lives in SwapCore.sol.
Lifecycle at a glance
┌─────────────┐
│ buySwap │
└──────┬──────┘
│
(optionally transferSwapPosition)
│
┌─────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
makePayment exitSwapEarly liquidateSwap
(expiry) (before expiry, (underwater,
owner only) anyone)
│ │ │
└─────────┼─────────────────────┘
│
(if transfer failed)
│
▼
claimEscrowThree things to keep in mind across all of these:
Collateral cap. The net payment a swap can produce at settlement is capped at each side's posted collateral — the buyer can never lose more than
collateralBalance, the pool can never lose more thanpoolCollateralBacking.getSwapNetAmountapplies the same cap.Accrued-only liquidation. Liquidation triggers when the accrued P&L exceeds a side's posted collateral. There is no "projected" liquidation that closes a swap based on where it's trending. This keeps the rule simple and predictable.
Failed transfers escrow. If SwapCore can't deliver settlement to the buyer (blacklisted address, blocked receiver, etc.) the payout is held in
escrowedCollateral[swapId]and emitsBuyerTransferFailed. The buyer (or their authorized delegate) retrieves it later viaclaimEscrow. Funds always go back to the originalswap.userAddress— there is no admin redirect path.
buySwap
Who calls: anyone. buySwap is beneficial to onBehalfOf (they get the position), so unlike most other onBehalfOf entry points it does not require setAuthorization. The caller pays the collateral and fees.
What it does:
Loads the market, checks it's live (
exists && !terminated), and readsbaseRatefrombaseSwapRateOracle(tenor-dependent).Computes
availableLiquidityfrom the pool's unlocked collateral —totalLPAvailableCollateral(marketId)(i.e.totalCollateral − lockedCollateral) — viaSwapFormulas.calculateAvailableLiquidity(availableCollateral, baseRate, swapTerm, leverageMultiplier), and assertsnotionalAmount > 0 && ≤ availableLiquidity(revertingE509/E508respectively).Integrates the kinked utilization fee curve over
[uPre, uPost]usingmarket.utilFeeSlopeWad,kinkUtilization, andmaxKinkFeeWad. The utilization is measured against seasoned (EMA) liquidity, not the spot pool balance — the capacity gate in step 2 uses spot, but the fee is priced on the smoothed average (see Core concepts → Seasoned utilization fee). This is a JIT-deposit defense: a same-block LP deposit expands capacity but can't cheapen the fee.Reads
riskPremiumfrom the market's risk premium oracle (returns0if the address is zero).Builds
swapRate: for BUY_FIXED,swapRate = baseRate + utilFee + riskPremium; for BUY_FLOATING,swapRateis left at the sentinel value0and the floating leg is derived at settlement from the entry/expiry cumulative index. The buyer's pre-funded collateral still coversutilFee + riskPremiumon top ofbaseRate— those accrue to LPs through the floating leg at settlement.Applies slippage guards:
rateBoundandmaxMarkup(see below), then — after sizing collateral, fee, and bounty — checksrequiredBuyerCollateral + protocolFee + liquidationBounty ≤ maxTotalIn, revertingE513if exceeded.Sizes
requiredBuyerCollateralandtotalLpCollateralRequiredusing the fee-inclusive and base-only formulas respectively; rejects if either side is belowmarket.minCollateralor if the pool lacks enough unlocked collateral.Reads protocol fee config from Admin in a single
admin.getFeeConfig()call — returningfeeRate,creatorFeeShare, andprotocolMultisigtogether — and computesprotocolFee = notionalAmount × feeRate × swapTerm / (SECONDS_IN_YEAR × WAD).Computes
liquidationBounty = requiredBuyerCollateral × market.liquidationIncentive / WAD. This is prefunded by the buyer at entry, held outsidecollateralBalance, and either paid to a liquidator on liquidation or returned to the buyer at normal expiry.Snapshots the current cumulative index from
referenceRateOracleviarateIndex.update(revertsE728if it's at theMIN_INDEXfloor). The reference rate itself isn't materialized until settlement, when the entry/expiry index ratio is converted via the market'srateConvention.Generates
swapIdviaUtils.generateSwapId, writes theSwapPositionto storage (embedding the just-snapshotted index asentryFloatingIndex), assigns it to a bucket (Utils.addSwapToBucket), bumpspool.lockedCollateral += totalLpCollateralRequired, and appends theswapIdto the market'sexpiryQueueso settlement/LP-gate logic can find it later.Pulls
requiredBuyerCollateral + protocolFee + liquidationBountyfrommsg.sender. The protocol fee is split: ifcreatorFeeShare > 0and the market has an owner, the creator's share is sent tomarket.marketOwnervia a graceful low-level call (on failure the full fee goes to the multisig); the remainder goes to theprotocolMultisigreturned bygetFeeConfig.Emits
SwapCreated, plusProtocolFeeCollectedandCreatorFeeCollectedwhen applicable.
Parameters:
marketId
Which side of the pair to open on.
notionalAmount
Swap notional in swapToken decimals. Must be > 0 and ≤ getCalculatedAvailableLiquidity(marketId).
onBehalfOf
The address that will own the new swap. Must not be address(0).
rateBound
Slippage on the rate. For BUY_FIXED, this is a ceiling on swapRate — revert if swapRate > rateBound. For BUY_FLOATING, it's a floor on baseRate — revert if baseRate < rateBound. Pass type(int256).max to disable.
maxMarkup
Slippage on the fee markup: revert if utilFee + riskPremium > maxMarkup. Pass 0 to disable.
maxTotalIn
Ceiling on the total tokens pulled from msg.sender (requiredBuyerCollateral + protocolFee + liquidationBounty). Reverts E513 if exceeded. Pass type(uint256).max to disable. Necessary because buyer collateral is sized off abs(baseRate), so a favorable rateBound check (BUY_FLOATING with a high base rate, or BUY_FIXED with a negative base rate) can still inflate the posted collateral — rateBound alone doesn't bound tokens spent.
Returns: swapId — a unique bytes32 produced by Utils.generateSwapId(marketId, onBehalfOf, block.timestamp, ++globalNonce)
Payment from caller: requiredBuyerCollateral + protocolFee + liquidationBounty. Approve this amount on swapToken before calling. msg.sender is the token source; onBehalfOf is the position owner. This decoupling lets bundlers and routers pay on behalf of the end user.
State changes / events:
swapPositions[swapId]written with all fields fromTypes.SwapPosition.buckets[marketId][bucketId]updated with the new aggregates.pool.lockedCollateral += totalLpCollateralRequired.expiryQueue[marketId].push(swapId)— the swap ID is appended to the market's expiry queue. The separateexpiryQueuePointer[marketId](which tracks the earliest unsettled position) is advanced opportunistically byUtils.tryAdvanceExpiryPointeron later settlements, not on entry.IERC20(swapToken).transferFrom(msg.sender, address(this), requiredBuyerCollateral + protocolFee + liquidationBounty).IERC20(swapToken).transfer(or low-level call for creator split) to the multisig and/or market owner.Emits
SwapCreated,ProtocolFeeCollected(ifprotocolFee > 0),CreatorFeeCollected(if the creator split transfer succeeded).
Reverts:
E103
onBehalfOf == address(0).
E300
Market doesn't exist.
E304
Market is terminated — no new swaps.
E611
Base rate oracle returned an invalid reading.
E509
notionalAmount == 0.
E508
notionalAmount exceeds the pool's available liquidity.
E512
BUY_FIXED only — baseRate < 0 on a Cumulative reference oracle (floating leg can't go negative).
E510
Slippage — rate bound exceeded (BUY_FIXED: swapRate > rateBound; BUY_FLOATING: baseRate < rateBound).
E511
Slippage — utilFee + riskPremium > maxMarkup.
E503
requiredBuyerCollateral < minCollateral or totalLpCollateralRequired < minCollateral.
E404
Pool has insufficient available collateral to back the LP side.
E513
requiredBuyerCollateral + protocolFee + liquidationBounty > maxTotalIn — total-in slippage guard.
E606
Reference rate oracle returned invalid or zero data during the index update.
E728
oracleIndex <= SwapFormulas.MIN_INDEX — reference index at the floor.
ERC20 revert
swapToken.transferFrom failed (allowance or balance).
Choosing slippage bounds.
BUY_FIXED: you're locking in what you pay, so the risk is rates move down between your quote and the tx landing. Use
rateBound = quotedSwapRate × (1 + tolerance)to cap your worst case.BUY_FLOATING: you're locking in what you receive, so the risk is the base rate drops. Use
rateBound = quotedBaseRate × (1 − tolerance)as a floor.maxMarkupis useful in both directions — it caps the fee portion of the all-in rate so a utilization spike after quote time doesn't burn you. Set it to your quotedutilFee + riskPremiumplus a small buffer.Set
maxTotalInto your expected total cost plus a small buffer to bound worst-case tokens pulled, especially for BUY_FLOATING
See also: getCalculatedAvailableLiquidity, getSwapNetAmount, transferSwapPosition.
makePayment
Who calls: anyone. Settlement is permissionless — keepers, bots, adapters, even the buyer or LP themselves. There's no keeper whitelist.
What it does: iterates the swapIds array and, for each one that's reached expiry (block.timestamp ≥ swap.entryTimestamp + market.swapTerm), runs the settlement pipeline:
Validates
swap.entryTimestamp != 0and!swap.settled.Updates the market's oracle index to the latest read.
Resolves the historical oracle index at expiry: after best-effort calling
updateMarketRateIndexto densify snapshots, if no snapshot exists at or afterentryTimestamp + swapTermit reverts with E450 (refusing to extrapolate past the last known data); otherwise readsrateIndex.getIndexAt(oracle, expiryTimestamp).Calls
Utils.settleSwapto compute fixed and floating payments over the full term, net them, cap at each side's collateral, and determinenetRecipient(0= LP receives,1= buyer receives).Transfers the net to the winning side and releases the unused collateral back to its original owner (or to the pool). If the buyer-side transfer reverts, the payout is moved to
escrowedCollateral[swapId]andBuyerTransferFailedis emitted.Marks
swap.settled = true, recordssettlementPayouts[swapId] = buyerCollateralReleased, and advancesexpiryQueuePointer[marketId]past now-settled queue entries viaUtils.tryAdvanceExpiryPointer(the queue itself is append-only — settled entries are zeroed in place but the array does not shrink).Emits
SwapClosedwithclosureType = 0and, if pool collateral hit zero,PoolCollateralZeroed.
Returns: one Types.SettlementResult per input ID:
Batching. Pass an array because keepers typically sweep many swaps at once — the oracle index update per-swap is cheap, and batching amortizes the rateIndex.update call.
Reverts (per swap, the whole batch aborts):
E500
swap.entryTimestamp == 0 (swap doesn't exist).
E506
block.timestamp < expiry — too early to settle this swap.
E450
No oracle snapshot at or after swap.entryTimestamp + swapTerm — settlement refuses to extrapolate. Keepers should call updateMarketRateIndex post-maturity to advance the snapshot horizon.
Already-settled IDs in the batch are skipped (not reverted), so a partially pre-settled batch still succeeds for the rest.
See also: exitSwapEarly, liquidateSwap, claimEscrow, updateMarketRateIndex.
exitSwapEarly
Who calls: the owner of every swap in requests, or an authorized delegate of that owner. All requests[i].swapId must belong to onBehalfOf.
What it does: marks each swap isEarlyExit = true and settles them using the same settlement pipeline as makePayment — but with the earlyExit branch taken, which (a) uses block.timestamp as the effective expiry, (b) re-reads the base rate for the remaining tenor to project the unrealized fixed leg, and (c) applies market.earlyExitFee to the buyer. After settlement, each request's minExitAmount is checked against the buyer's total entitlement (settlementPayouts[swapId] + escrowedCollateral[swapId] — both are summed so an escrowed payout still satisfies the slippage check).
Requires the market to have been created with earlyExitAllowed = true, and the swap must still be open and already have at least one second of elapsed time.
Parameters:
requests[]
Array of ExitRequest { bytes32 swapId; uint256 minExitAmount; }. minExitAmount == 0 disables the slippage check for that entry.
onBehalfOf
Swap owner. All requests[i] must reference swaps owned by this address.
Returns: one SettlementResult per request, same shape as makePayment.
State changes / events:
Each swap:
swap.isEarlyExit = true, then full settlement (seemakePayment).Emits
SwapClosedwithclosureType = 1per swap.
Reverts:
E206
msg.sender not authorized for onBehalfOf.
E500
Swap doesn't exist.
E203
Swap is not owned by onBehalfOf.
E501
Swap already settled.
E506
swap.entryTimestamp >= block.timestamp — can't exit in the same second you opened.
E550
Market was created with earlyExitAllowed = false.
E502
Swap is already past expiry — use makePayment instead.
E611
Base rate oracle returned an invalid reading for the remaining tenor (fetched to project the unrealized fixed leg).
E606
Reference rate oracle returned invalid or zero data during the index update.
E552
Slippage — buyer payout below minExitAmount for some request.
See also: buySwap, makePayment.
liquidateSwap
Who calls: anyone. Liquidation is permissionless — msg.sender becomes the liquidator and is paid a reward whose source depends on which side is being liquidated. Buyer-side liquidations pay the prefunded liquidationBounty that was banked from the buyer at buySwap entry (held separately from collateralBalance and refunded to the buyer at normal expiry if liquidation never happens). Pool-side liquidations pay market.liquidationIncentive × poolCollateralBacking / WAD, drawn from the LP backing for this swap.
What it does: updates the market's oracle index, delegates to Utils.liquidateSwap, and emits SwapLiquidated + SwapClosed (with closureType = 2). Utils.liquidateSwap computes the accrued P&L and decides whether the buyer is liquidatable (their collateralBalance can't cover the payment they owe), whether the pool is liquidatable (its poolCollateralBacking can't cover the payment it owes), or neither.
Key properties:
Accrued, not projected. Liquidation is based on payments that have already been earned against the oracle index up to
block.timestamp— not on where the rate might go. A swap is only liquidatable when one side's collateral is mathematically insufficient right now.Liquidator incentive. The payout source depends on which side is liquidated:
Buyer-side: the liquidator receives the prefunded
swap.liquidationBounty— a fixed amount banked at swap entry asrequiredBuyerCollateral × market.liquidationIncentive / WAD. It is held outsidecollateralBalanceand is excluded from bucket aggregates; if the swap is never liquidated, the bounty is returned to the buyer at normal settlement.Pool-side: the liquidator receives
swap.poolCollateralBacking × market.liquidationIncentive / WAD, drawn from the LP backing reserved for this swap. The trigger threshold is set to align with this payout, so the liquidator's reward and the liquidatable boundary stay consistent.
Single side. At most one side is liquidated in a call; the other receives its normal settlement.
Escrow fallback. If the post-liquidation transfer to the buyer reverts, the payout is escrowed for later
claimEscrow.
Parameters: swapId — the position to liquidate.
State changes / events:
swap.settled = true;settlementPayouts[swapId]recorded.pool.lockedCollateral -= lpCollateralReleased;pool.totalCollateraladjusted for any pool-side loss.expiryQueuePointer[marketId]advanced past now-settled entries viaUtils.tryAdvanceExpiryPointer(the queue itself is append-only — settled entries are zeroed in place but the array does not shrink).Emits
SwapLiquidated(marketId, swapId, liquidatedParty, buyerLiquidated, poolLiquidated, collateralTransferred, liquidator).Emits
SwapClosedwithclosureType = 2.
Reverts:
E608
swapId doesn't exist — its zero-address reference oracle is uninitialized, so the index update reverts before liquidation runs.
E606
Reference rate oracle returned invalid or zero data during the index update.
E507
Not liquidatable — neither buyer nor pool is underwater on accrued P&L. Also covers already-settled and past-expiry swaps (settle those via makePayment).
See also: getSwapNetAmount to check a swap's P&L before attempting liquidation, claimEscrow for the escrow fallback.
transferSwapPosition
Who calls: the current swap owner, or an authorized delegate of that owner.
What it does: flips swap.userAddress from onBehalfOf to newOwner. After this call, the new owner is the one who will receive any positive net settlement and who can call exitSwapEarly / claimEscrow on this swap. The swap's economic terms are unchanged.
Parameters:
swapId
The swap to transfer. Must exist and be unsettled.
newOwner
The new owner. Must not be address(0). No authorization check is performed on newOwner — the transfer is one-sided, so only send it to an address you control or a contract that expects it.
onBehalfOf
The current owner. msg.sender must be authorized for this address unless they are onBehalfOf.
State changes / events:
swap.userAddress = newOwner.Emits
SwapTransferred(swapId, previousOwner, newOwner, caller).
Reverts:
E206
msg.sender not authorized for onBehalfOf.
E500
Swap doesn't exist.
E203
swap.userAddress != onBehalfOf.
E501
Swap already settled.
E103
newOwner == address(0).
This is the hook that SwapPositionWrapper (the ERC-721 wrapper contract) uses under the hood — transferring the NFT calls transferSwapPosition to move the underlying position to whoever holds the token.
See also: setAuthorization.
claimEscrow
Who calls: the original swap owner (swap.userAddress) or an authorized delegate.
What it does: retrieves collateral that SwapCore couldn't deliver at settlement or liquidation. When the buyer-side transfer in _settleSwaps or liquidateSwap reverts (e.g., the recipient became blacklisted between buySwap and settlement), the payout is moved to escrowedCollateral[swapId] and BuyerTransferFailed is emitted. claimEscrow pulls that balance, zeroes the entry, and sends it to swap.userAddress.
Crucially, funds are always delivered to the original swap.userAddress, never to msg.sender. An authorized delegate can trigger the claim but cannot redirect the money. If the recipient is still blocked, safeTransfer will revert and the escrow stays in place — try again later.
Parameters: swapId.
Returns: amount — the amount delivered.
State changes / events:
escrowedCollateral[swapId] = 0(before the transfer, CEI pattern).IERC20(swapToken).safeTransfer(swap.userAddress, amount).Emits
EscrowClaimed(swapId, recipient, caller, amount).
Reverts:
E206
msg.sender not authorized for swap.userAddress.
E400
escrowedCollateral[swapId] == 0 — nothing to claim.
ERC20 revert
Target is still blocked from receiving the token.
See also: makePayment, exitSwapEarly, liquidateSwap, setAuthorization.
Last updated