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:
- Market exists - The trading pair must be created by the protocol owner
- Router is authorized - User must authorize the router as an operator
- 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).maxfor 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 > 3100fillVolume 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- 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
- Set realistic
maxOffers: Don't over-allocate gas - Use exact input:
fillWants = falseis slightly cheaper - Batch operations: Use flash accounting to combine swaps
- 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
- Flash Accounting - Understanding deltas
- Tick Tree Structure - How prices are organized
- Placing Limit Orders - Providing liquidity