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)


         claimEscrow

Three 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 than poolCollateralBacking. getSwapNetAmount applies 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 emits BuyerTransferFailed. The buyer (or their authorized delegate) retrieves it later via claimEscrow. Funds always go back to the original swap.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:

  1. Loads the market and checks it's live (exists && !terminated).

  2. Reads baseRate from baseSwapRateOracle (tenor-dependent) and referenceRate from referenceRateOracle.

  3. Computes availableLiquidity from the pool's unlocked collateral via SwapFormulas.calculateAvailableLiquidity(totalLpC, baseRate, swapTerm, leverageMultiplier) and asserts notionalAmount > 0 && <= availableLiquidity.

  4. Integrates the kinked utilization fee curve over [uPre, uPost] using market.utilFeeSlopeWad, kinkUtilization, and maxKinkFeeWad.

  5. Reads riskPremium from the market's risk premium oracle (returns 0 if the address is zero).

  6. Builds swapRate: for BUY_FIXED, swapRate = baseRate + utilFee + riskPremium; for BUY_FLOATING, the fee leg is carried separately and the reference rate drives the fixed leg — see the source at SwapCore.sol:605-609.

  7. Applies slippage guards (see rateBound / maxMarkup below).

  8. Sizes requiredBuyerCollateral and totalLpCollateralRequired using the fee-inclusive and base-only formulas respectively; rejects if either side is below market.minCollateral or if the pool lacks enough unlocked collateral.

  9. Reads protocol fee config from Admin once and computes protocolFee = notionalAmount × feeRate × swapTerm / (SECONDS_IN_YEAR × WAD).

  10. Generates swapId, writes the SwapPosition to storage, assigns it to a bucket (Utils.addSwapToBucket), bumps pool.lockedCollateral += totalLpCollateralRequired, and increments the expiry-epoch counter.

  11. Pulls requiredBuyerCollateral + protocolFee from msg.sender. The protocol fee is split: if creatorFeeShare > 0 and the market has an owner, the creator's share is sent to market.marketOwner via a graceful low-level call (on failure the full fee goes to the multisig); the remainder goes to admin.getProtocolMultisig().

  12. Emits SwapCreated, plus ProtocolFeeCollected and CreatorFeeCollected when applicable.

Parameters:

Parameter
Meaning

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.

Returns: swapId, a unique bytes32 derived from keccak256(marketId, onBehalfOf, block.timestamp, ++globalNonce).

Payment from caller: requiredBuyerCollateral + protocolFee. 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 from Types.SwapPosition.

  • buckets[marketId][bucketId] updated with the new aggregates.

  • pool.lockedCollateral += totalLpCollateralRequired.

  • unsettledExpiryCount[marketId][expiryEpoch]++ and earliestUnsettledEpoch maintained.

  • IERC20(swapToken).transferFrom(msg.sender, address(this), requiredBuyerCollateral + protocolFee).

  • IERC20(swapToken).transfer (or low-level call for creator split) to the multisig and/or market owner.

  • Emits SwapCreated, ProtocolFeeCollected (if protocolFee > 0), CreatorFeeCollected (if the creator split transfer succeeded).

Reverts:

Code
Reason

E103

onBehalfOf == address(0).

E300

Market doesn't exist.

E304

Market is terminated — no new swaps.

E400 / swap-params revert

notionalAmount == 0.

E404

Pool has insufficient available collateral to back the LP side.

E503

requiredBuyerCollateral < minCollateral or totalLpCollateralRequired < minCollateral.

E510

Slippage — rate bound exceeded (BUY_FIXED: swapRate > rateBound; BUY_FLOATING: baseRate < rateBound).

E511

Slippage — utilFee + riskPremium > maxMarkup.

E606

Reference or base rate oracle returned invalid / unhealthy.

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.

  • maxMarkup is 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 quoted utilFee + riskPremium plus a small buffer.

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:

  1. Validates swap.entryTimestamp != 0 and !swap.settled.

  2. Updates the market's oracle index to the latest read.

  3. Resolves the historical oracle index at expiry via rateIndex.getIndexAt(oracle, expiryTimestamp). If no snapshot exists, it falls back to the freshly-updated index (prevents a permanent stuck-settlement edge case).

  4. Calls Utils.settleSwap to compute fixed and floating payments over the full term, net them, cap at each side's collateral, and determine netRecipient (0 = LP receives, 1 = buyer receives).

  5. 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] and BuyerTransferFailed is emitted.

  6. Marks swap.settled = true, records settlementPayouts[swapId] = buyerCollateralReleased, decrements the expiry-epoch counter, and advances earliestUnsettledEpoch if this was the last unsettled swap in that epoch.

  7. Emits SwapClosed with closureType = 0 and, 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):

Code
Reason

E500

swap.entryTimestamp == 0 (swap doesn't exist).

E501

Swap already settled.

E506

block.timestamp < expiry — too early to settle this swap.

E606

Reference rate oracle is unhealthy during the index update.

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:

Parameter
Meaning

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 (see makePayment).

  • Emits SwapClosed with closureType = 1 per swap.

Reverts:

Code
Reason

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.

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 market.liquidationIncentive out of the liquidated side's remaining collateral.

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. Paid out of the liquidated side's collateral as incentive = collateralPayout × market.liquidationIncentive / WAD. If the pool is the liquidated side, the incentive comes from poolCollateralBacking; if the buyer is, from their collateralBalance.

  • 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.totalCollateral adjusted for any pool-side loss.

  • unsettledExpiryCount decremented; earliestUnsettledEpoch advanced if needed.

  • Emits SwapLiquidated(marketId, swapId, liquidatedParty, buyerLiquidated, poolLiquidated, collateralTransferred, liquidator).

  • Emits SwapClosed with closureType = 2.

Reverts:

Code
Reason

E500

Swap doesn't exist.

E501

Already settled.

not-liquidatable revert from Utils.liquidateSwap

Neither buyer nor pool is underwater — the swap doesn't qualify for liquidation.

E606

Reference rate oracle unhealthy.

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:

Parameter
Meaning

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:

Code
Reason

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, swapToken, recipient, caller, amount).

Reverts:

Code
Reason

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