Building Custom Routers: Limit Orders
This guide shows how to build a custom router for placing limit orders. For most use cases, consider using the built-in VifRouter instead.
Limit orders in Vif are called "make" operations. They provide liquidity to the order book at specific price levels and earn from the spread when filled.
Prerequisites
Before placing limit orders:
- Market exists - Trading pair must be created
- Router is authorized - User must authorize router as operator
- Token approval - Approve Vif contract for outbound token
- Native token - Have ETH/native token for provisions (if using expiry)
Basic Limit Order Pattern
contract MyRouter is ILockCallback {
IVif public immutable vif;
function placeLimitOrder(...) external payable returns (uint40 offerId, uint256 claimed) {
bytes memory data = abi.encode(...);
bytes memory result = vif.lock(data);
return abi.decode(result, (uint40, uint256));
}
function lockCallback(bytes calldata data) external returns (bytes memory) {
require(msg.sender == address(vif), "Only Vif");
// 1. Create/edit limit order
(uint40 offerId, uint256 claimed) = vif.make(...);
// 2. Settle debts (outbound token + provision)
_settleDebts(...);
// 3. Take credits (claimed inbound token)
_takeCredits(...);
return abi.encode(offerId, claimed);
}
}The make Function
function make(
address maker, // Owner of the offer
bytes32 market, // Market identifier
uint40 initialOfferId, // 0 for new, existing ID to edit
uint256 gives, // Amount to offer (in token decimals)
int24 tick, // Price level
uint32 expiry, // Expiration timestamp (0 = never)
uint24 provision // Native token provision (in provision units)
) external returns (
uint40 offerId, // Created/updated offer ID
uint256 claimedReceived // Amount claimed from previous fills
);Parameters Explained
maker
The offer owner. Must be:
- The caller, OR
- An account that authorized the caller
When editing, must match the original offer's maker.
market
Market identifier (same as for swaps):
bytes32 marketId = keccak256(abi.encodePacked(
outboundToken,
outboundUnits,
inboundToken,
inboundUnits,
tickSpacing
));initialOfferId
0: Create new offer> 0: Edit existing offer with this ID
// Create new offer
(uint40 newId,) = vif.make(maker, market, 0, gives, tick, expiry, provision);
// Edit existing offer
(uint40 sameId, uint256 claimed) = vif.make(
maker,
market,
newId, // Edit the offer we just created
newGives,
newTick,
newExpiry,
newProvision
);
// sameId == newId ✓gives
Amount of outbound token to offer, in token decimals:
// WETH market with outboundUnits = 1e13
gives = 1.5 ether; // 1.5 WETH
// Actual stored amount (floored to units):
actualGives = floor(1.5e18 / 1e13) * 1e13
= 150000 * 1e13
= 1.5e18 // Perfect fit ✓
// Another example with imperfect fit:
gives = 1.50000001 ether;
actualGives = floor(1.50000001e18 / 1e13) * 1e13
= 150000 * 1e13
= 1.5e18 // Truncated ✓tick
Price level for the offer:
- Lower tick = lower price = better for takers
- Higher tick = higher price = better for maker
- Must align with
tickSpacing
// Calculate tick from price
// price = inboundAmount / outboundAmount
// tick = log(price) / log(1.00001)
// Example: Sell 1 WETH for 3000 USDC
price = 3000e6 / 1e18 = 3000
tick = log(3000) / log(1.00001)
tick ≈ -2,054,985
// With tickSpacing = 10:
actualTick = round(tick / 10) * 10 = -2,054,990| Price (USDC/WETH) | Approximate Tick |
|---|---|
| 1000 | -2,072,336 |
| 2000 | -2,063,403 |
| 3000 | -2,054,985 |
| 4000 | -2,047,189 |
| 5000 | -2,039,906 |
expiry
Unix timestamp when offer expires:
expiry = 0; // Never expires
expiry = block.timestamp + 1 days; // Expires in 24 hours
expiry = 1735689600; // Jan 1, 2025provision
Amount of native token to lock with the offer, in provision units:
// Global provision units (set at deployment)
provisionUnits = 1e12;
// Lock 0.001 ETH
provision = 1000; // units
actualProvision = 1000 * 1e12 = 1e15 wei = 0.001 ETH
// When calling make:
vif.make{value: 0.001 ether}(maker, market, 0, gives, tick, expiry, 1000);Provision is:
- Locked with the offer
- Returned when cancelling or claiming after expiry
- Paid as bounty to cleaners of expired offers
Example: Simple Limit Order Router
// 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 SimpleLimitOrderRouter 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) {
VIF.settle{value: amount}(token, amount, address(this));
} else {
VIF.settle(token, amount, user);
}
}
}
function limitOrder(
Market memory market,
int24 tick,
uint40 initialOfferId,
uint256 gives,
uint32 expiry,
uint24 provision
) external payable returns (uint40 offerId, uint256 claimedReceived) {
bytes memory data = abi.encode(
market,
tick,
initialOfferId,
gives,
expiry,
provision
);
bytes memory result = VIF.lock(data);
(offerId, claimedReceived) = abi.decode(result, (uint40, uint256));
// Return 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,
int24 tick,
uint40 initialOfferId,
uint256 gives,
uint32 expiry,
uint24 provision
) = abi.decode(
data,
(Market, int24, uint40, uint256, uint32, uint24)
);
// Create/edit offer
uint256 claimedReceived;
uint40 offerId;
(offerId, claimedReceived) = VIF.make(
msg.sender,
_marketId(market),
initialOfferId,
gives,
tick,
expiry,
provision
);
// Settle all balances
_settleFullDelta(market.outboundToken, msg.sender);
_settleFullDelta(LibDeltasExt.NATIVE, msg.sender);
_settleFullDelta(market.inboundToken, msg.sender);
return abi.encode(offerId, claimedReceived);
}
receive() external payable {}
}Usage Examples
Create Simple Limit Order
// 1. Authorize router
vif.authorize(address(router), true);
// 2. Approve outbound token
WETH.approve(address(vif), type(uint256).max);
// 3. Define market
Market memory market = Market({
outboundToken: address(WETH),
inboundToken: address(USDC),
outboundUnits: 1e13,
inboundUnits: 1e4,
tickSpacing: 1
});
// 4. Place order: Sell 1 WETH for 3000 USDC
(uint40 offerId, uint256 claimed) = router.limitOrder(
market,
-2_054_990, // tick for ~3000 USDC/WETH
0, // new offer
1 ether, // give 1 WETH
0, // never expires
0 // no provision needed
);
console.log("Offer ID:", offerId);
console.log("Claimed:", claimed); // 0 for new offerCreate Expiring Limit Order
// Place order that expires in 24 hours
uint32 expiryTime = uint32(block.timestamp + 1 days);
uint24 provisionAmount = 1000; // 0.001 ETH in provision units
(uint40 offerId,) = router.limitOrder{value: 0.001 ether}(
market,
tick,
0, // new offer
1 ether,
expiryTime,
provisionAmount
);Edit Existing Order
// Update offer to sell more at a better price
(uint40 sameId, uint256 claimed) = router.limitOrder(
market,
-2_060_000, // better price (lower tick)
offerId, // edit existing
2 ether, // increase to 2 WETH
0,
0
);
console.log("Claimed from fills:", claimed / 1e6, "USDC");
// sameId == offerId ✓Claiming Filled Amounts
Using claim
function claim(bytes32 market, uint40 offerId)
external
returns (uint256 inbound, uint256 outbound, uint256 provision);// In router callback:
(uint256 inbound, uint256 outbound, uint256 prov) =
VIF.claim(marketId, offerId);
// Settle balances
_settleFullDelta(inboundToken, maker); // Claim filled amount
_settleFullDelta(outboundToken, maker); // If fully filled/expired
_settleFullDelta(NATIVE, maker); // If provision returned
return abi.encode(inbound, outbound, prov);- Offer was fully filled
- Offer expired
- Offer was cancelled
Cancelling Orders
Using cancel
function cancel(bytes32 market, uint40 offerId)
external
returns (uint256 inbound, uint256 outbound, uint256 provision);// In router callback:
(uint256 inbound, uint256 outbound, uint256 prov) =
VIF.cancel(marketId, offerId);
// Returns:
// - inbound: filled amount not yet claimed
// - outbound: unfilled amount
// - provision: locked native token
_settleFullDelta(inboundToken, maker);
_settleFullDelta(outboundToken, maker);
_settleFullDelta(NATIVE, maker);
return abi.encode(inbound, outbound, prov);Advanced Patterns
Market Making
Place orders on both sides of the book:
function provideLiquidity(
Market memory asks,
Market memory bids,
int24 askTick,
int24 bidTick,
uint256 size
) external {
bytes memory data = abi.encode(asks, bids, askTick, bidTick, size);
vif.lock(data);
}
function lockCallback(bytes calldata data) external returns (bytes memory) {
(Market memory asks, Market memory bids, int24 askTick, int24 bidTick, uint256 size) =
abi.decode(data, (Market, Market, int24, int24, uint256));
address user = tx.origin; // Or decode from data
// Place ask (sell WETH for USDC at higher price)
vif.make(user, _marketId(asks), 0, size, askTick, 0, 0);
// Place bid (sell USDC for WETH at lower price)
uint256 bidSize = calculateBidSize(size, askTick, bidTick);
vif.make(user, _marketId(bids), 0, bidSize, bidTick, 0, 0);
// Settle both sides
_settleFullDelta(asks.outboundToken, user); // WETH
_settleFullDelta(bids.outboundToken, user); // USDC
return "";
}Update and Claim in One Transaction
// Claim filled amount and update offer size
(uint40 offerId, uint256 claimed) = vif.make(
maker,
market,
existingOfferId, // Edit existing
newGives,
tick,
expiry,
provision
);
// `claimed` contains the filled amount
// Offer is updated with new parameters
// All in one transaction ✓Ladder Orders
Place multiple orders at different price levels:
function placeLadder(
Market memory market,
int24[] calldata ticks,
uint256[] calldata amounts
) external returns (uint40[] memory offerIds) {
require(ticks.length == amounts.length, "Length mismatch");
bytes memory data = abi.encode(market, ticks, amounts);
bytes memory result = vif.lock(data);
return abi.decode(result, (uint40[]));
}
function lockCallback(bytes calldata data) external returns (bytes memory) {
(Market memory market, int24[] memory ticks, uint256[] memory amounts) =
abi.decode(data, (Market, int24[], uint256[]));
uint40[] memory offerIds = new uint40[](ticks.length);
for (uint256 i = 0; i < ticks.length; i++) {
(offerIds[i],) = VIF.make(
msg.sender,
_marketId(market),
0,
amounts[i],
ticks[i],
0,
0
);
}
// Single settle for all offers
_settleFullDelta(market.outboundToken, msg.sender);
return abi.encode(offerIds);
}
// Usage:
int24[] memory ticks = new int24[](3);
ticks[0] = -2_050_000; // 3100 USDC/WETH
ticks[1] = -2_055_000; // 3000 USDC/WETH
ticks[2] = -2_060_000; // 2900 USDC/WETH
uint256[] memory amounts = new uint256[](3);
amounts[0] = 1 ether;
amounts[1] = 2 ether;
amounts[2] = 3 ether;
uint40[] memory ids = router.placeLadder(market, ticks, amounts);
// Created 3 offers with one transaction! ✓Price Calculation Helpers
Tick from Price
function tickFromPrice(
uint256 inboundAmount,
uint256 outboundAmount
) public pure returns (int24) {
// price = inboundAmount / outboundAmount
// tick = log(price) / log(1.00001)
// Using binary search or approximation
// This is a simplified version
uint256 price = (inboundAmount * 1e18) / outboundAmount;
return int24(int256(
(log2(price) * 1e18) / log2(100_001e13)
));
}
// Example:
int24 tick = tickFromPrice(3000e6, 1e18); // 3000 USDC per WETH
// tick ≈ -2,054,985Price from Tick
// Use LibTick from vif-core
import {LibTick} from "vif-core/libraries/LibTick.sol";
uint256 price = LibTick.price(tick);
// Returns Q128.128 fixed-point number
// Convert to readable price:
uint256 readablePrice = (price * outboundAmount) / (1 << 128) / inboundAmount;Gas Optimization
- Batch operations: Use flash accounting to combine multiple actions
- Larger tick spacing: Reduces tree traversal costs
- Avoid unnecessary edits: Each edit costs ~100k gas
- Claim with edits: When editing, claiming is free (included)
Common Pitfalls
❌ Wrong: Forgetting to settle provision
vif.make{value: 0.001 ether}(maker, market, 0, gives, tick, expiry, 1000);
// Sent ETH via msg.value, but also need to settle in callback!✓ Correct: Settle provision debt
vif.make(maker, market, 0, gives, tick, expiry, 1000);
// In callback:
_settleFullDelta(LibDeltasExt.NATIVE, maker); // Settles provision ✓❌ Wrong: Editing with wrong maker
// Offer created by Alice
uint40 id = vif.make(alice, market, 0, gives, tick, 0, 0);
// Bob tries to edit - REVERTS
vif.make(bob, market, id, newGives, tick, 0, 0); // ❌✓ Correct: Same maker for edits
vif.make(alice, market, id, newGives, tick, 0, 0); // ✓Related Concepts
- Flash Accounting - Delta management
- Offers & Lists - Offer structure details
- Creating Swaps - Consuming limit orders
- Tick - Price representation