Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Building Custom Routers: Swaps

This guide shows how to build a custom router for executing market orders (swaps). For most use cases, consider using the built-in VifRouter instead.

Market orders in Vif are called "swaps" or "consume" operations. They execute immediately by matching against existing limit orders in the book.

Prerequisites

Before executing swaps, ensure:

  1. Market exists - The trading pair must be created by the protocol owner
  2. Router is authorized - User must authorize the router as an operator
  3. Token approval - User must approve the Vif contract to spend tokens

Basic Swap Pattern

All swaps follow the lock-callback pattern:

contract MyRouter is ILockCallback {
  IVif public immutable vif;
 
  function swap(...) external returns (...) {
    bytes memory data = abi.encode(...);
    bytes memory result = vif.lock(data);
    return abi.decode(result, ...);
  }
 
  function lockCallback(bytes calldata data) external returns (bytes memory) {
    require(msg.sender == address(vif), "Only Vif");
 
    // 1. Execute market order
    (uint256 gave, uint256 got, uint256 fee, uint256 bounty) =
      vif.consume(...);
 
    // 2. Settle debts and take credits
    _settleAndTake(...);
 
    return abi.encode(gave, got, fee, bounty);
  }
}

The consume Function

function consume(
  address taker,        // Who is taking the order
  bytes32 market,       // Market identifier
  int24 maxTick,        // Maximum acceptable tick (price limit)
  uint256 fillVolume,   // Amount to fill
  bool fillWants,       // If true: fillVolume is output; if false: input
  uint256 maxOffers     // Maximum offers to consume (gas limit)
) external returns (
  uint256 gave,         // Amount taker paid
  uint256 got,          // Amount taker received
  uint256 fee,          // Fee paid by taker
  uint256 bounty        // Bounty earned from cleaning expired offers
);

Parameters Explained

taker

The account executing the swap. Must be either:

  • The caller directly
  • An account that authorized the caller as operator

market

Market identifier calculated as:

bytes32 marketId = keccak256(abi.encodePacked(
  outboundToken,
  outboundUnits,
  inboundToken,
  inboundUnits,
  tickSpacing
));

maxTick

Price limit for the swap:

  • Stops consuming offers when offer tick > maxTick
  • Use type(int24).max for no price limit
  • Lower tick = better price for taker
// Example: WETH/USDC market
// Want to buy WETH but not pay more than 3100 USDC/WETH
 
// Calculate maxTick for 3100 USDC/WETH:
// price = 1.00001^tick
// 3100 = 1.00001^tick
// tick = log(3100) / log(1.00001)
// tick ≈ -2,055,000
 
maxTick = -2_055_000;  // Won't buy if price > 3100

fillVolume and fillWants

These parameters work together to specify the trade size:

Exact Input (fillWants = false):

fillVolume = 1000e6;     // Spend exactly 1000 USDC
fillWants = false;
// Result: got = ~0.32 WETH (depends on book)
//         gave = 1000e6 USDC (exactly as specified)

Exact Output (fillWants = true):

fillVolume = 1e18;       // Receive exactly 1 WETH
fillWants = true;
// Result: got = 1e18 WETH (exactly as specified)
//         gave = ~3000e6 USDC (depends on book)

maxOffers

Maximum number of offers to match against:

  • Limits gas consumption
  • Prevents running out of gas on deep books
  • Trade completes even if volume target not reached
maxOffers = 100;  // Match at most 100 offers
// If 150 offers needed for full fill, only fills first 100
Choosing maxOffers:
  • Small trades: 10-50 offers usually sufficient
  • Large trades: 100-500 offers for deep liquidity
  • Gas-sensitive: Lower for guaranteed execution
  • Fill-sensitive: Higher for better fill rates

Example: Simple WETH/USDC Swap

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
 
import {IVif} from "vif-core/interfaces/IVif.sol";
import {ILockCallback} from "vif-core/interfaces/ILockCallback.sol";
import {LibDeltasExt} from "vif-core/libraries/external/LibDeltasExt.sol";
import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
 
contract SimpleSwapRouter is ILockCallback {
  using LibDeltasExt for address;
  using SafeTransferLib for address;
 
  IVif public immutable VIF;
 
  struct Market {
    address outboundToken;
    uint64 outboundUnits;
    address inboundToken;
    uint64 inboundUnits;
    uint16 tickSpacing;
  }
 
  constructor(address _vif) {
    VIF = IVif(_vif);
  }
 
  function _marketId(Market memory market) internal pure returns (bytes32) {
    return keccak256(abi.encodePacked(
      market.outboundToken,
      market.outboundUnits,
      market.inboundToken,
      market.inboundUnits,
      market.tickSpacing
    ));
  }
 
  function _settleFullDelta(address token, address user) internal {
    int256 delta = address(VIF).deltaOf(token);
 
    if (delta > 0) {
      // Credit: take tokens
      VIF.take(token, uint256(delta), user);
    } else if (delta < 0) {
      // Debt: settle tokens
      uint256 amount = uint256(-delta);
 
      if (token == LibDeltasExt.NATIVE) {
        // Native token (ETH)
        VIF.settle{value: amount}(token, amount, address(this));
      } else {
        // ERC20 token
        VIF.settle(token, amount, user);
      }
    }
  }
 
  function swap(
    Market memory market,
    int24 maxTick,
    uint256 fillVolume,
    bool fillWants,
    uint256 maxOffers
  ) external payable returns (
    uint256 gave,
    uint256 got,
    uint256 fees,
    uint256 bounty
  ) {
    bytes memory data = abi.encode(
      market,
      msg.sender,
      maxTick,
      fillVolume,
      fillWants,
      maxOffers
    );
 
    bytes memory result = VIF.lock(data);
    (gave, got, fees, bounty) = abi.decode(
      result,
      (uint256, uint256, uint256, uint256)
    );
 
    // Return any leftover native token
    if (address(this).balance > 0) {
      msg.sender.safeTransferAllETH();
    }
  }
 
  function lockCallback(bytes calldata data)
    external
    returns (bytes memory)
  {
    require(msg.sender == address(VIF), "Only Vif");
 
    (
      Market memory market,
      address user,
      int24 maxTick,
      uint256 fillVolume,
      bool fillWants,
      uint256 maxOffers
    ) = abi.decode(
      data,
      (Market, address, int24, uint256, bool, uint256)
    );
 
    // Execute swap
    (uint256 gave, uint256 got, uint256 fees, uint256 bounty) =
      VIF.consume(user, _marketId(market), maxTick, fillVolume, fillWants, maxOffers);
 
    // Settle all balances
    _settleFullDelta(market.outboundToken, user);
    _settleFullDelta(market.inboundToken, user);
    _settleFullDelta(LibDeltasExt.NATIVE, user);
 
    return abi.encode(gave, got, fees, bounty);
  }
 
  receive() external payable {}
}

Usage Example

// 1. Deploy router
SimpleSwapRouter router = new SimpleSwapRouter(address(vif));
 
// 2. Authorize router
vif.authorize(address(router), true);
 
// 3. Approve tokens
USDC.approve(address(vif), type(uint256).max);
 
// 4. Define market
SimpleSwapRouter.Market memory market = SimpleSwapRouter.Market({
  outboundToken: address(WETH),
  inboundToken: address(USDC),
  outboundUnits: 1e13,      // 0.00001 WETH
  inboundUnits: 1e4,        // 0.0001 USDC
  tickSpacing: 1
});
 
// 5. Execute swap: Buy 1 WETH with USDC
(uint256 gave, uint256 got, uint256 fees, uint256 bounty) =
  router.swap(
    market,
    type(int24).max,  // No price limit
    1 ether,          // Want exactly 1 WETH
    true,             // fillWants = true (exact output)
    100               // Max 100 offers
  );
 
console.log("Gave:", gave / 1e6, "USDC");
console.log("Got:", got / 1e18, "WETH");
console.log("Fees:", fees / 1e6, "USDC");
console.log("Bounty:", bounty / 1e18, "ETH");

Multi-Hop Swaps

Flash accounting enables efficient multi-hop swaps:

function multiHopSwap(
  Market memory market1,  // WETH → USDC
  Market memory market2,  // USDC → DAI
  uint256 amountIn
) external returns (uint256 amountOut) {
  bytes memory data = abi.encode(market1, market2, msg.sender, amountIn);
  bytes memory result = VIF.lock(data);
  return abi.decode(result, (uint256));
}
 
function lockCallback(bytes calldata data) external returns (bytes memory) {
  (Market memory m1, Market memory m2, address user, uint256 amountIn) =
    abi.decode(data, (Market, Market, address, uint256));
 
  // Hop 1: WETH → USDC
  (uint256 gave1, uint256 got1,,) = VIF.consume(
    user,
    _marketId(m1),
    type(int24).max,
    amountIn,
    false,  // Exact input
    100
  );
 
  // Hop 2: USDC → DAI
  (uint256 gave2, uint256 got2,,) = VIF.consume(
    user,
    _marketId(m2),
    type(int24).max,
    got1,     // Use output from hop 1
    false,    // Exact input
    100
  );
 
  // Settle only first and last token
  _settleFullDelta(m1.inboundToken, user);   // WETH
  _settleFullDelta(m2.outboundToken, user);  // DAI
  // USDC is netted automatically - no transfer! ✓
 
  return abi.encode(got2);
}

Price Calculation

To calculate expected output before swapping:

// Read offers from the book
LibTreeExt.Tree memory tree = address(vif).treeFor(marketId);
LibTreeExt.Cursor memory cursor;
bool found;
(cursor, found) = tree.first();
 
uint256 remaining = fillVolume;
uint256 totalGot = 0;
 
while (found && remaining > 0) {
  int24 tick = cursor.index().tick(tickSpacing);
 
  if (tick > maxTick) break;
 
  LibOfferListExt.OfferList memory list =
    address(vif).offerListFor(marketId, tick);
 
  // Price at this tick
  uint256 price = LibTick.price(tick);
 
  // Available liquidity
  uint256 available = list.totalVolume * outboundUnits;
  uint256 consumed = min(available, remaining);
 
  totalGot += consumed;
  remaining -= calculateCost(consumed, price);
 
  found = cursor.next(tree);
}
 
return totalGot;

Slippage Protection

Always protect against slippage:

// Calculate minimum output (5% slippage tolerance)
uint256 expectedOut = quoteSwap(market, amountIn);
uint256 minOut = expectedOut * 95 / 100;
 
// Execute swap
(, uint256 got,,) = router.swap(market, maxTick, amountIn, false, 100);
 
require(got >= minOut, "Slippage too high");

Gas Optimization Tips

  1. Set realistic maxOffers: Don't over-allocate gas
  2. Use exact input: fillWants = false is slightly cheaper
  3. Batch operations: Use flash accounting to combine swaps
  4. Monitor bounties: Cleaning expired offers earns native tokens

Common Patterns

Limit Price Swap

// Only buy if price <= 3000 USDC/WETH
int24 maxTick = calculateTickFromPrice(3000e6, 1e18);
router.swap(market, maxTick, 1 ether, true, 100);

Fill-or-Kill

(, uint256 got,,) = router.swap(market, maxTick, amount, fillWants, maxOffers);
require(got == expectedAmount, "Partial fill not acceptable");

Partial Fill Acceptable

uint256 minAcceptable = expectedAmount * 90 / 100;
(, uint256 got,,) = router.swap(market, maxTick, amount, fillWants, maxOffers);
require(got >= minAcceptable, "Fill too small");

Related Concepts