A virtual machine for programmable token swaps. Execute complex trading strategies from bytecode programs without deploying contracts.
- Overview
- Deployment
- How It Works
- For Makers (Liquidity Providers)
- For Takers (Swap Executors)
- For Developers
- Security Model
- Advanced Topics
SwapVM is a computation engine that executes token swap strategies from bytecode programs. Instead of deploying smart contracts, you compose instructions into programs that are signed off-chain and executed on-demand.
Key Features:
- Static Balances - Fixed exchange rates for single-direction trades (limit orders, auctions, TWAP, DCA, RFQ)
- Dynamic Balances - Persistent, isolated AMM-style orders (each maker's liquidity is separate)
- Composable Instructions - Mix and match building blocks for complex strategies (combining pricing, fees, MEV protection)
- 🌾 Makers - Provide liquidity through limit orders, AMM-style orders, or complex strategies
- 🏃 Takers - Execute swaps to arbitrage or fulfill trades
- 🛠 Developers - Build custom instructions and integrate SwapVM
SwapVM is deployed across multiple chains with a unified address for seamless cross-chain integration.
Contract Address: 0x8fdd04dbf6111437b44bbca99c28882434e0958f
Supported Networks:
- Ethereum Mainnet
- Base
- Optimism
- Polygon
- Arbitrum
- Avalanche
- Binance Smart Chain
- Linea
- Sonic
- Unichain
- Gnosis
- zkSync
SwapVM uses 4 registers to compute token swaps:
┌────────────────────────────────────────────────────────────┐
│ SwapRegisters │
├────────────────────────────────────────────────────────────┤
│ balanceIn: Maker's available input token balance │
│ balanceOut: Maker's available output token balance │
│ amountIn: Input amount (taker provides OR VM computes) │
│ amountOut: Output amount (taker provides OR VM computes) │
└────────────────────────────────────────────────────────────┘
The Core Principle:
- Taker specifies ONE amount (either
amountInoramountOut) - VM computes the OTHER amount using the 4 registers
- Instructions modify registers to apply fees, adjust rates, etc.
The execution flow shows all available instructions and strategies for each balance type:
┌──────────────────────────────────────────────────────────┐
│ 1D STRATEGY (Static Balances, Single Direction) │
├──────────────────────────────────────────────────────────┤
│ BYTECODE COMPOSITION (Off-chain) │
│ │
│ 1. Balance Setup (Required) │
│ └─ _staticBalancesXD → Fixed exchange rate │
│ │
│ 2. Core Swap Logic (Choose One) │
│ ├─ _limitSwap1D → Partial fills allowed │
│ └─ _limitSwapOnlyFull1D → All-or-nothing │
│ │
│ 3. Order Invalidation (Required for Partial Fills) │
│ ├─ _invalidateBit1D → One-time order │
│ ├─ _invalidateTokenIn1D → Track input consumed │
│ └─ _invalidateTokenOut1D → Track output distributed │
│ │
│ 4. Dynamic Pricing (Optional, Combinable) │
│ ├─ _dutchAuctionBalanceIn1D → Decreasing input amount │
│ ├─ _dutchAuctionBalanceOut1D → Increasing output amount│
│ ├─ _oraclePriceAdjuster1D → External price feed │
│ └─ _baseFeeAdjuster1D → Gas-responsive pricing │
│ │
│ 5. Fee Mechanisms (Optional, Combinable) │
│ ├─ _flatFeeAmountInXD → Fee from input amount │
│ ├─ _flatFeeAmountOutXD → Fee from output amount │
│ ├─ _progressiveFeeInXD → Size-based dynamic fee (input)│
│ ├─ _progressiveFeeOutXD → Size-based dynamic fee (output)│
│ ├─ _protocolFeeAmountOutXD → Protocol revenue (ERC20) │
│ ├─ _aquaProtocolFeeAmountOutXD → Protocol revenue (Aqua)│
│ ├─ _dynamicProtocolFeeAmountInXD → Dynamic fee via provider│
│ └─ _aquaDynamicProtocolFeeAmountInXD → Dynamic Aqua fee│
│ │
│ 6. Advanced Strategies (Optional) │
│ ├─ _requireMinRate1D → Enforce minimum exchange rate │
│ ├─ _adjustMinRate1D → Adjust amounts to meet min rate │
│ ├─ _twap → Time-weighted average price execution │
│ └─ _extruction → Extract and execute custom logic │
│ │
│ 7. Control Flow (Optional) │
│ ├─ _jump → Skip instructions │
│ ├─ _jumpIfTokenIn → Conditional on exact input │
│ ├─ _jumpIfTokenOut → Conditional on exact output │
│ ├─ _deadline → Expiration check │
│ ├─ _onlyTakerTokenBalanceNonZero → Require balance > 0│
│ ├─ _onlyTakerTokenBalanceGte → Minimum balance check │
│ ├─ _onlyTakerTokenSupplyShareGte → Min % of supply │
│ └─ _salt → Order uniqueness (hash modifier) │
│ │
│ EXECUTION (On-chain) │
│ ├─ Verify signature & expiration │
│ ├─ Load static balances into 4 registers │
│ ├─ Execute bytecode instructions sequentially │
│ ├─ Update invalidator state (prevent replay/overfill) │
│ └─ Transfer tokens (single direction only) │
└──────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ AMM STRATEGIES (2D/XD Bidirectional, Two Balance Options) │
├────────────────────────────────────────────────────────────┤
│ BALANCE MANAGEMENT OPTIONS │
│ │
│ Option A: Dynamic Balances (SwapVM Internal) │
│ ├─ Setup: Sign order with EIP-712 │
│ ├─ Balance Instruction: _dynamicBalancesXD │
│ └─ Storage: SwapVM contract (self-managed) │
│ │
│ Option B: Aqua Protocol (External) │
│ ├─ Setup: Deposit via Aqua.ship() (on-chain) │
│ ├─ Balance Instruction: None (Aqua manages) │
│ ├─ Configuration: useAquaInsteadOfSignature = true │
│ └─ Storage: Aqua protocol (shared liquidity) │
│ │
├────────────────────────────────────────────────────────────┤
│ BYTECODE COMPOSITION (Same for Both) │
│ │
│ 1. Balance Setup │
│ ├─ Dynamic: _dynamicBalancesXD (required) │
│ └─ Aqua: Skip (balances in Aqua) │
│ │
│ 2. AMM Logic (Choose Primary Strategy) │
│ ├─ _xycSwapXD → Classic x*y=k constant product │
│ ├─ _peggedSwapGrowPriceRange2D → Curve for pegged assets│
│ └─ _xycConcentrateGrowLiquidityXD/2D → CLMM ranges │
│ │
│ 3. Fee Mechanisms (Optional, Combinable) │
│ ├─ _flatFeeAmountInXD → Fee from input amount │
│ ├─ _flatFeeAmountOutXD → Fee from output amount │
│ ├─ _progressiveFeeInXD → Size-based dynamic fee (input) │
│ ├─ _progressiveFeeOutXD → Size-based dynamic fee (output)│
│ ├─ _protocolFeeAmountOutXD → Protocol revenue (ERC20) │
│ ├─ _aquaProtocolFeeAmountOutXD → Protocol revenue (Aqua)│
│ ├─ _dynamicProtocolFeeAmountInXD → Dynamic fee via provider│
│ └─ _aquaDynamicProtocolFeeAmountInXD → Dynamic Aqua fee │
│ │
│ 4. MEV Protection (Optional) │
│ └─ _decayXD → Virtual reserves (Mooniswap-style) │
│ │
│ 5. Advanced Features (Optional) │
│ ├─ _twap → Time-weighted average price trading │
│ └─ _extruction → Extract and execute custom logic │
│ │
│ 6. Control Flow (Optional) │
│ ├─ _jump → Skip instructions │
│ ├─ _jumpIfTokenIn → Conditional jump on exact input │
│ ├─ _jumpIfTokenOut → Conditional jump on exact output │
│ ├─ _deadline → Expiration check │
│ ├─ _onlyTakerTokenBalanceNonZero → Require balance > 0 │
│ ├─ _onlyTakerTokenBalanceGte → Minimum balance check │
│ ├─ _onlyTakerTokenSupplyShareGte → Min % of supply │
│ └─ _salt → Order uniqueness (hash modifier) │
│ │
├────────────────────────────────────────────────────────────┤
│ EXECUTION (On-chain) │
│ │
│ Dynamic Balances Flow: │
│ ├─ Verify EIP-712 signature │
│ ├─ Load maker's isolated reserves from SwapVM │
│ ├─ Execute AMM calculations │
│ ├─ Update maker's state in SwapVM storage │
│ └─ Transfer tokens (bidirectional) │
│ │
│ Aqua Protocol Flow: │
│ ├─ Verify Aqua balance (no signature) │
│ ├─ Load reserves from Aqua protocol │
│ ├─ Execute AMM calculations (same logic!) │
│ ├─ Aqua updates balance accounting │
│ └─ Transfer tokens via Aqua settlement │
└────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ COMMON TAKER FLOW (All Strategies) │
├─────────────────────────────────────────────────────────┤
│ 1. Discovery (Off-chain) │
│ ├─ Find orders via indexer/API │
│ ├─ Filter by tokens, rates, liquidity │
│ └─ Simulate profitability │
│ │
│ 2. Quote (On-chain View) │
│ ├─ Call quote() to preview exact amounts │
│ ├─ Check slippage and fees │
│ └─ Verify execution conditions │
│ │
│ 3. Execution Parameters │
│ ├─ isExactIn → Specify input or output amount │
│ ├─ threshold → Minimum/maximum acceptable amount │
│ ├─ to → Recipient address │
│ └─ hooks → Pre/post swap callbacks │
│ │
│ 4. Settlement │
│ ├─ Maker → Taker (output token) │
│ └─ Taker → Maker (input token) │
└─────────────────────────────────────────────────────────┘
Programs are sequences of instructions, each encoded as:
[opcode_index][args_length][args_data]
↑ ↑ ↑
1 byte 1 byte N bytes
Example: A limit order might compile to:
[17][4A][balance_args][26][01][swap_args]
↑ ↑
staticBalances limitSwap
SwapVM offers two primary balance management approaches:
Use Case: Limit orders, Dutch auctions, TWAP, DCA, RFQ, range orders, stop-loss
- Fixed Rate: Exchange rate remains constant
- Partial Fills: Supports partial execution with amount invalidators
- No Storage: Pure function, no state persistence
- Direction: Single-direction trades (e.g., only sell ETH for USDC)
// Example: Sell 1 ETH for 2000 USDC
p.build(Balances._staticBalancesXD,
BalancesArgsBuilder.build(
dynamic([WETH, USDC]),
dynamic([1e18, 2000e6]) // Fixed rate
))Use Case: Constant product AMMs, CLMMs
- Self-Rebalancing: Balances update after each trade
- State Persistence: Order state stored in SwapVM
- Isolated Liquidity: Each maker's funds are separate (no pooling)
- Bidirectional: Supports trading in both directions
- Price Discovery: Price adjusts based on reserves
// Example: Initialize AMM-style order with 10 ETH and 20,000 USDC
p.build(Balances._dynamicBalancesXD,
BalancesArgsBuilder.build(
dynamic([WETH, USDC]),
dynamic([10e18, 20_000e6]) // Initial reserves
))SwapVM maintains fundamental invariants that ensure economic security and predictable behavior across all instructions:
Every instruction MUST maintain symmetry between exactIn and exactOut swaps:
- If
exactIn(X) → Y, thenexactOut(Y) → X(within rounding tolerance) - Critical for price consistency and preventing internal arbitrage
- Validated by test suite across all swap instructions
Splitting swaps must not provide better rates:
swap(A+B)should equalswap(A) + swap(B)for output amounts- Ensures no gaming through order splitting
- Larger trades cannot be improved by breaking into smaller ones
Numerical Consistency Guarantee:
quote()andswap()return identical(amountIn, amountOut)if both succeed- This ensures predictable execution and prevents quote-execution arbitrage
- Essential for MEV protection and reliable off-chain quoting
Execution Divergence via isStaticContext:
SwapVM uses ctx.vm.isStaticContext to enable gas-free quote previews by conditionally skipping side effects:
| Instruction Category | Quote Mode (isStaticContext=true) |
Swap Mode (isStaticContext=false) |
|---|---|---|
| Protocol Fees | Computes fee, skips token transfer | Computes fee, executes token transfer |
| Invalidators | Checks limits, skips state update | Checks limits, updates state (prevents replay) |
| Dynamic Balances | Reads balances, skips storage write | Reads balances, updates storage |
| Decay/TWAP | Uses current state, skips time update | Uses current state, updates timestamp |
Legitimate Divergence Cases:
- ✅ Quote succeeds, swap reverts - Missing balance/approval for protocol fee transfer
- ✅ Quote succeeds, swap reverts - Order already executed (invalidator state changed between calls)
- ✅ Quote succeeds, swap reverts - Insufficient remaining balance (partial fill exhausted)
These cases preserve numerical consistency (amounts match when both succeed) while allowing execution to fail due to external conditions.
Problematic Patterns (Maker Responsibility):
- ❌ Backward jumps to stateful instructions - Can break numerical consistency (quote and swap compute different amounts)
- ❌ Control flow depending on same-execution state changes - Violates the invariant
Best Practices:
- Makers: Avoid backward jumps to
isStaticContext-dependent instructions; test strategies with bothquote()andswap() - Takers: Always use threshold protection in TakerTraits; handle swap revert scenarios even after successful quote
- Integrations: Never rely on quote success as guarantee of swap success; use on-chain
quote()for accurate amounts
Larger trades receive equal or worse prices:
- Price defined as
amountOut/amountIn - Must decrease (or stay constant) as trade size increases
- Natural consequence of liquidity curves and market impact
All rounding operations must favor the liquidity provider:
- Small trades (few wei) shouldn't exceed theoretical spot price
amountInalways rounds UP (ceil)amountOutalways rounds DOWN (floor)- Protects makers from rounding-based value extraction
Trades cannot exceed available liquidity:
- Must revert if computed
amountOut > balanceOut - Prevents impossible trades and protects order integrity
- Enforced at the VM level before token transfers
These invariants are validated through comprehensive test suites and must be maintained by any new instruction implementations.
SwapVM provides a reusable CoreInvariants base contract for testing:
import { CoreInvariants } from "test/invariants/CoreInvariants.t.sol";
contract MyInstructionTest is Test, OpcodesDebug, CoreInvariants {
function test_MyInstruction_MaintainsInvariants() public {
// Create order with your instruction
ISwapVM.Order memory order = createOrderWithMyInstruction();
// Test all invariants at once
assertAllInvariants(swapVM, order, tokenIn, tokenOut);
// Or test specific invariants
assertSymmetryInvariant(swapVM, order, tokenIn, tokenOut,
amount, tolerance, exactInData, exactOutData);
assertMonotonicityInvariant(swapVM, order, tokenIn, tokenOut,
amounts, takerData);
}
}Configuration options for complex scenarios:
InvariantConfig memory config = createInvariantConfig(testAmounts, tolerance);
config.skipAdditivity = true; // For stateless orders
config.skipMonotonicity = true; // For fixed-rate orders
assertAllInvariantsWithConfig(swapVM, order, tokenIn, tokenOut, config);See test/invariants/ExampleInvariantUsage.t.sol for complete examples.
Makers provide liquidity by creating orders with custom swap logic.
- Define swap logic via bytecode programs (includes setting balances/exchange rate)
- Configure order parameters (expiration, fees, hooks)
- Sign orders off-chain (gasless)
// 1. Build your swap program
Program memory p = ProgramBuilder.init(_opcodes());
bytes memory program = bytes.concat(
// Set your exchange rate: 1000 USDC for 0.5 WETH
p.build(Balances._staticBalancesXD,
BalancesArgsBuilder.build(
dynamic([USDC, WETH]),
dynamic([1000e6, 0.5e18]) // Your offered rate
)),
// Execute the swap
p.build(LimitSwap._limitSwap1D,
LimitSwapArgsBuilder.build(USDC, WETH)),
// Track partial fills (prevents overfilling)
p.build(Invalidators._invalidateTokenOut1D,
InvalidatorsArgsBuilder.buildInvalidateByTokenOut(WETH))
);
// 2. Configure order parameters
ISwapVM.Order memory order = MakerTraitsLib.build(MakerTraitsLib.Args({
maker: yourAddress, // Your address
receiver: address(0), // You receive the tokens (0 = maker)
shouldUnwrapWeth: false, // Keep WETH (don't unwrap to ETH)
useAquaInsteadOfSignature: false, // Use standard EIP-712 signing
allowZeroAmountIn: false, // Require non-zero input
hasPreTransferInHook: false,
hasPostTransferInHook: false,
hasPreTransferOutHook: false,
hasPostTransferOutHook: false,
preTransferInTarget: address(0),
preTransferInData: "",
postTransferInTarget: address(0),
postTransferInData: "",
preTransferOutTarget: address(0),
preTransferOutData: "",
postTransferOutTarget: address(0),
postTransferOutData: "",
program: program // Your swap program
}));
// 3. Sign order off-chain (gasless)
bytes32 orderHash = swapVM.hash(order);
bytes memory signature = signEIP712(orderHash);Create a persistent, isolated AMM-style order (your liquidity only):
// Constant product AMM with 0.3% fee
bytes memory program = bytes.concat(
// Load/initialize balances
p.build(Balances._dynamicBalancesXD,
BalancesArgsBuilder.build(
dynamic([USDC, WETH]),
dynamic([100_000e6, 50e18]) // Initial liquidity
)),
// Apply trading fee
p.build(Fee._flatFeeAmountInXD,
FeeArgsBuilder.buildFlatFee(0.003e9)), // 0.3%
// Execute constant product swap (x*y=k)
p.build(XYCSwap._xycSwapXD)
);
⚠️ CRITICAL SECURITY NOTICE FOR MAKERSStrategies must ensure unique orderHash to prevent unintended cross-strategy token access. Always include balance instructions and use
_saltwhen needed.
For non-Aqua (signature-based) strategies:
orderHash = _hashTypedDataV4(keccak256(abi.encode(
ORDER_TYPEHASH,
order.maker,
order.traits,
keccak256(order.data) // Contains program bytecode (should include tokens list)
)));For Aqua strategies:
orderHash = keccak256(abi.encode(order))1. Always Include Balance Instructions for non-Aqua strategies (MANDATORY)
Balance instructions (_staticBalancesXD or _dynamicBalancesXD) encode token addresses directly into your program, tying approved tokens to specific strategies.
// ✅ SAFE - Tokens are encoded in program
bytes memory program = bytes.concat(
p.build(Balances._staticBalancesXD,
BalancesArgsBuilder.build(
dynamic([USDC, WETH]), // Tokens locked to this strategy
dynamic([1000e6, 0.5e18])
)),
p.build(LimitSwap._limitSwap1D, ...),
p.build(Controls._salt, abi.encodePacked(uint256(1))) // Unique ID
);
// ❌ UNSAFE - No token validation!
bytes memory program = bytes.concat(
p.build(LimitSwap._limitSwap1D, ...) // Taker can choose ANY tokens!
);2. Use _salt for Multiple Similar Strategies
If you create multiple strategies with identical instructions and parameters, add _salt with unique values:
// Strategy A
p.build(Controls._salt, abi.encodePacked(uint256(1)))
// Strategy B (same instructions but different salt)
p.build(Controls._salt, abi.encodePacked(uint256(2)))Without _salt, identical programs generate the same orderHash, causing:
- Shared storage state (one strategy affects the other)
- Inability to run multiple identical strategies simultaneously
- Potential accounting conflicts
3. Custom Accounting (_extruction) - Extra Validation Required
If using _extruction for custom token accounting:
- YOU MUST verify no hash collisions with your existing strategies
- Without balance validation, the strategy can access ALL approved tokens
- Hash collision = potential fund loss through unintended token access
// Custom accounting example - verify uniqueness!
bytes memory program = bytes.concat(
p.build(Extruction._extruction,
ExtructionArgsBuilder.build(customAccountingContract, args)),
p.build(Controls._salt, abi.encodePacked(keccak256("unique-id-v1")))
);Problem: Strategies Depend on Each Other Through Approvals
Scenario: Maker creates 3 strategies with common token approvals
Strategy A: ✓ USDC/WETH with proper _staticBalancesXD
Strategy B: ✓ DAI/WETH with proper _staticBalancesXD
Strategy C: ✗ Loose strategy without balance instruction
Risk: Strategy C can execute with ANY tokens that have SwapVM approvals
(USDC, DAI, WETH, etc.), bypassing intended token restrictions
Attack: Taker executes Strategy C, arbitrarily choosing tokenIn/tokenOut
from all approved tokens, potentially draining funds
Without Proper Hash Uniqueness:
| Risk | Description | Mitigation |
|---|---|---|
| Cross-Strategy Token Access for Non-Aqua mode | Loose strategies access all approved tokens | Always include _staticBalancesXD or _dynamicBalancesXD |
| Hash Collision | Identical programs share storage/state | Use _salt with unique values |
| Storage Conflicts | Multiple strategies interfere with each other | Ensure unique orderHash for each strategy |
| Approval Exploitation | Taker chooses unexpected token pairs | Encode tokens in program via balance instructions |
// ✓ COMPLETE SAFE EXAMPLE
Program memory p = ProgramBuilder.init(_opcodes());
bytes memory program = bytes.concat(
// 1. Include balance instruction (ties tokens to strategy)
p.build(Balances._staticBalancesXD,
BalancesArgsBuilder.build(
dynamic([USDC, WETH]),
dynamic([1000e6, 0.5e18])
)),
// 2. Your swap logic
p.build(LimitSwap._limitSwap1D,
LimitSwapArgsBuilder.build(USDC, WETH)),
// 3. Add salt for uniqueness (if you have multiple similar strategies)
p.build(Controls._salt, abi.encodePacked(uint256(1)))
);Key Takeaway: The orderHash identifies your strategy and determines storage isolation. Always ensure it's unique and that tokens are explicitly bound to each strategy through balance instructions.
// Fixed exchange rate for 1D strategies (limit orders, auctions)
p.build(Balances._staticBalancesXD, ...)Characteristics:
- Fixed exchange rate throughout order lifetime
- Supports partial fills with amount invalidators
- No state storage (pure function)
- Single-direction trades only
- Ideal for: Limit orders, Dutch auctions, TWAP, DCA, RFQ, range orders, stop-loss
Both options use the same AMM logic and support identical features. The only difference is where balances are stored:
// Persistent AMM-style order with isolated liquidity
p.build(Balances._dynamicBalancesXD, ...)
// Sign with EIP-712Storage: SwapVM contract (per-maker isolation)
Setup: Sign order off-chain (gasless)
Use Case: Individual AMM strategies, custom curves
Key Point: Replicates Aqua-like functionality but with signature-based orders (no deposits)
Note: Each maker's liquidity is isolated - no pooling with others
// Use Aqua's shared liquidity layer
MakerTraits makerTraits = MakerTraitsLib.build({
useAquaInsteadOfSignature: true
});
// Requires prior: aqua.ship(token, amount)Storage: Aqua protocol (external)
Setup: Deposit to Aqua via ship()
Use Case: Share liquidity across multiple strategies
Key Difference: Unlike isolated dynamic balances, Aqua enables shared liquidity
See Aqua Protocol for details
Your orders are protected by:
- EIP-712 Signatures - Orders cannot be modified
- Expiration Control - Orders expire when you want
- Balance Limits - Cannot trade more than specified
- Custom Receivers - Send tokens where you want
- Hooks - Custom validation logic
- Order Invalidation - One-time execution via bitmaps
Best Practices:
- Always set expiration dates
- Use
_invalidateBit1Dfor one-time orders - Validate rates match market conditions
- Consider MEV protection (
_decayXD) ⚠️ WETH Unwrapping: Only useshouldUnwrapWeth=truewith canonical WETH. Avoid any tokens withwithdraw()functions - underlying assets may get stuck in SwapVM
Takers execute swaps against maker orders to arbitrage or fulfill trades.
- Find profitable orders to execute
- Specify swap amount (either input or output)
- Provide dynamic data for adaptive instructions
- Execute swaps on-chain
// 1. Find an order to execute
ISwapVM.Order memory order = findProfitableOrder();
// 2. Preview the swap (free call)
(uint256 amountIn, uint256 amountOut) = swapVM.asView().quote(
order,
USDC, // Token you're trading
WETH, // Token you're receiving
1000e6, // Amount (input if isExactIn=true)
takerTraitsData // Your execution parameters
);
// 3. Prepare taker parameters
bytes memory takerTraits = TakerTraitsLib.build(TakerTraitsLib.Args({
isExactIn: true, // You specify input amount
threshold: minAmountOut, // Minimum output (slippage protection)
to: yourAddress, // Where to receive tokens
shouldUnwrapWeth: false, // Keep as WETH
// Optional features:
hasPreTransferInHook: false,
isFirstTransferFromTaker: false
}));
// 4. Execute the swap
(uint256 actualIn, uint256 actualOut, bytes32 orderHash) = swapVM.swap(
order,
USDC,
WETH,
1000e6, // Your input amount
abi.encodePacked(signature, takerTraits, customData)
);Some instructions read data from takers at execution time:
// Pack custom data for instructions
bytes memory customData = abi.encode(
oraclePrice, // For oracle-based adjustments
maxGasPrice, // For gas-sensitive orders
userPreference // Any custom parameters
);
// Instructions access via:
// ctx.tryChopTakerArgs(32) - extracts 32 bytesThe isExactIn flag determines which amount you control:
| isExactIn | You Specify | VM Computes | Use Case |
|---|---|---|---|
| true | Input amount | Output amount | "I want to sell exactly 1000 USDC" |
| false | Output amount | Input amount | "I want to buy exactly 0.5 WETH" |
Your swaps are protected by:
- Threshold Validation - Minimum output / maximum input
- Slippage Protection - Via threshold amounts
- Custom Recipients - Send tokens anywhere
- Pre-hooks - Validate before execution
- Quote Preview - Check amounts before executing
Best Practices:
- Always use
quote()beforeswap() - Set appropriate thresholds for slippage
- Verify order hasn't expired
- Check for MEV opportunities
- Consider gas costs vs profit
SwapVM creates MEV opportunities:
- Arbitrage - Price differences between orders
- Liquidations - Execute against distressed positions
- JIT Liquidity - Provide liquidity just-in-time
- Sandwich Protection - Some orders use
_decayXDfor protection
Build custom instructions and integrate SwapVM into your protocols.
Every instruction receives a Context with three components:
Context
├── VM (Execution State)
│ ├── nextPC ───────────────────── Program counter (MUTABLE - for jumps)
│ ├── programPtr ───────────────── Bytecode being executed
│ ├── takerArgsPtr ─────────────── Taker's dynamic data (MUTABLE - via tryChopTakerArgs)
│ └── opcodes ──────────────────── Available instructions array
│
├── SwapQuery (READ-ONLY)
│ ├── orderHash ────────────────── Unique order identifier
│ ├── maker ────────────────────── Liquidity provider address
│ ├── taker ────────────────────── Swap executor address
│ ├── tokenIn ──────────────────── Input token address
│ ├── tokenOut ─────────────────── Output token address
│ └── isExactIn ────────────────── Taker's swap direction (true = exact in, false = exact out)
│
└── SwapRegisters (MUTABLE)
├── balanceIn ────────────────── Maker's available input token balance
├── balanceOut ───────────────── Maker's available output token balance
├── amountIn ─────────────────── Input amount (taker provides OR VM computes)
└── amountOut ────────────────── Output amount (taker provides OR VM computes)
MakerTraits (256-bit packed)
├── Bit Flags (bits 245-255)
│ ├── shouldUnwrapWeth (255) ──── Unwrap WETH to ETH on output
│ ├── useAquaInsteadOfSignature (254) ─ Use Aqua balance instead of signature
│ ├── allowZeroAmountIn (253) ── Allow zero amountIn (skip validation)
│ ├── hasPreTransferInHook (252) ── Call maker before input transfer
│ ├── hasPostTransferInHook (251) ── Call maker after input transfer
│ ├── hasPreTransferOutHook (250) ── Call maker before output transfer
│ ├── hasPostTransferOutHook (249) ── Call maker after output transfer
│ ├── preTransferInHookHasTarget (248) ── Hook has custom target
│ ├── postTransferInHookHasTarget (247)
│ ├── preTransferOutHookHasTarget (246)
│ └── postTransferOutHookHasTarget (245)
│
├── Data Slices Indexes (bits 160-223, 64 bits)
│ └── Packed 4x uint16 offsets for hook data slices
│
└── Receiver (bits 0-159, 160 bits)
└── Custom recipient address (0 = maker)
TakerTraits (Variable-length with 176-bit header)
├── Header (22 bytes packed)
│ ├── Slices Indexes (160 bits) ── 10x uint16 offsets for data slices
│ └── Bit Flags (16 bits)
│ ├── isExactIn (0) ────────── true = specify input, false = output
│ ├── shouldUnwrapWeth (1) ── Unwrap WETH to ETH on output
│ ├── hasPreTransferInCallback (2) ── Call taker before input transfer
│ ├── hasPreTransferOutCallback (3) ── Call taker before output transfer
│ ├── isStrictThresholdAmount (4) ── true = exact, false = min/max
│ ├── isFirstTransferFromTaker (5) ── Who transfers first
│ └── useTransferFromAndAquaPush (6) ── SwapVM does transferFrom + Aqua push
│
└── Variable-length Data Slices
├── threshold (0 or 32 bytes) ── Min output or max input
├── to (0 or 20 bytes) ───────── Custom recipient
├── deadline (0 or 5 bytes) ──── Unix timestamp (uint40)
├── preTransferInHookData ────── Data for maker pre-in hook
├── postTransferInHookData ───── Data for maker post-in hook
├── preTransferOutHookData ──── Data for maker pre-out hook
├── postTransferOutHookData ─── Data for maker post-out hook
├── preTransferInCallbackData ─ Data for taker pre-in callback
├── preTransferOutCallbackData ─ Data for taker pre-out callback
├── instructionsArgs ──────────── Data consumed by VM instructions
└── signature ─────────────────── EIP-712 signature for order
Instructions compute swap amounts only - they do NOT execute the actual token transfers (except protocol fee instructions which can transfer fees). The swap itself happens after all instructions complete.
Instructions can only modify three aspects of the Context:
All four registers can be modified to calculate swap amounts:
balanceIn/balanceOut- Set or adjust available balances for calculationsamountIn/amountOut- Compute the missing swap amount
Control execution flow between instructions:
- Skip instructions (jump forward)
- Loop back to previous instructions
- Conditional branching based on computation state
Consume data provided by taker at execution time:
- Read dynamic parameters for calculations
- Process variable-length data
- Advance the taker data pointer
Instructions can invoke ctx.runLoop() to execute remaining instructions and then continue:
- Apply pre-processing, let other instructions compute amounts, then post-processing
- Wrap amount computations with fee calculations
- Wait for amount computation before validation
- Implement complex multi-phase amount calculations
Instructions operate within SwapVM's execution framework:
What Instructions CAN Do:
- ✅ Read all context data (query, VM state, registers)
- ✅ Modify the 4 swap registers
- ✅ Change program counter for control flow
- ✅ Consume taker-provided data
- ✅ Read and write to their own storage mappings
- ✅ Make external calls (via
_extruction) - ✅ Execute fee transfers (protocol fee instructions)
What Instructions CANNOT Do:
- ❌ Modify query data (maker, taker, tokens, etc. - immutable)
- ❌ Transfer swap tokens directly (except protocol fees)
- ❌ Bypass SwapVM's validation (thresholds, signatures, etc.)
- ❌ Modify core SwapVM protocol state
- ❌ Execute after swap is complete
Security Considerations:
- Reentrancy protection only for Aqua settlement (via transient storage when taker pushes)
- Gas limited by block and transaction
- External calls risk managed by maker's instruction choice
- Deterministic execution
Routers define available instructions:
contract MyRouter is SwapVM, Opcodes {
constructor(address aqua)
SwapVM(aqua, "MyRouter", "1.0")
Opcodes(aqua)
{}
function _instructions() internal pure override
returns (function(Context memory, bytes calldata) internal[] memory)
{
// Return your instruction set
return _opcodes();
}
}Use the provided CoreInvariants base contract to ensure your instructions maintain all invariants:
contract MyInstructionTest is Test, OpcodesDebug, CoreInvariants {
function test_MyInstruction() public {
// Build program with your instruction
bytes memory program = buildProgramWithMyInstruction();
ISwapVM.Order memory order = createOrder(program);
// Validate all core invariants are maintained
assertAllInvariants(swapVM, order, tokenA, tokenB);
}
}For manual testing:
function testMyInstructionManually() public {
// Create test context
Context memory ctx = Context({
vm: VM({
isStaticContext: false,
nextPC: 0,
programPtr: CalldataPtrLib.from(program),
takerArgsPtr: CalldataPtrLib.from(takerData),
opcodes: _opcodes()
}),
query: SwapQuery({
orderHash: bytes32(0),
maker: makeAddr("maker"),
taker: makeAddr("taker"),
tokenIn: address(tokenA),
tokenOut: address(tokenB),
isExactIn: true
}),
swap: SwapRegisters({
balanceIn: 1000e18,
balanceOut: 2000e18,
amountIn: 100e18,
amountOut: 0
})
});
// Execute instruction
bytes memory args = abi.encode(0.003e9); // 0.3% fee
MyInstruction._myInstruction(ctx, args);
// Verify results
assertGt(ctx.swap.amountOut, 0);
}SwapVM's security is built on maintaining fundamental invariants that ensure economic correctness:
- Exact In/Out Symmetry - Prevents internal arbitrage opportunities
- Swap Additivity - Ensures no gaming through order splitting
- Quote/Swap Consistency - Guarantees predictable execution
- Price Monotonicity - Natural market dynamics are preserved
- Rounding Favors Maker - Protects liquidity providers from value extraction
- Balance Sufficiency - Prevents impossible trades
These invariants are enforced at the VM level and validated through comprehensive test suites.
Core Security Features:
- EIP-712 Typed Signatures - Prevents signature malleability
- Order Hash Uniqueness - Each order has unique identifier
- Reentrancy Protection - Transient storage locks (EIP-1153)
- Overflow Protection - Solidity 0.8+ automatic checks
- Gas Limits - Block gas limit prevents infinite loops
- Invariant Validation - All instructions must maintain core invariants
Signature Verification:
// Standard EIP-712
orderHash = keccak256(abi.encode(
ORDER_TYPEHASH,
order.maker,
order.traits,
keccak256(order.program)
));
// Or Aqua Protocol (no signature needed)
if (useAquaInsteadOfSignature) {
require(AQUA.balances(maker, orderHash, token) >= amount);
}Protection Mechanisms:
| Feature | Description | Implementation |
|---|---|---|
| Signature Control | Orders cannot be modified | EIP-712 signatures |
| Expiration | Time-limited orders | _deadline instruction or TakerTraits deadline |
| Balance Limits | Cannot exceed specified amounts | Register bounds checking |
| One-time Execution | Prevent replay | _invalidateBit1D instruction |
| Custom Logic | Hooks for validation | Pre/post transfer hooks |
| Receiver Control | Specify token recipient | receiver in MakerTraits |
⚠️ HOOK EXECUTION ORDER WARNING FOR MAKERSTaker Controls Transfer Order: The taker specifies
isFirstTransferFromTakerflag, which determines whether the taker transfers input tokens first or receives output tokens first. This means the actual execution order of your hooks depends on the taker's choice.Complete Execution Sequence:
isFirstTransferFromTaker = true:
preTransferInHook(maker)preTransferInCallback(taker)- Input token settlement (details below)
postTransferInHook(maker)preTransferOutHook(maker)preTransferOutCallback(taker)- Output token settlement (details below)
postTransferOutHook(maker)
isFirstTransferFromTaker = false:
preTransferOutHook(maker)preTransferOutCallback(taker)- Output token settlement (details below)
postTransferOutHook(maker)preTransferInHook(maker)preTransferInCallback(taker)- Input token settlement (details below)
postTransferInHook(maker)Settlement Mechanisms:
- Input (non-Aqua):
IERC20.transferFrom(taker → receiver)- Input (Aqua + useTransferFromAndAquaPush):
IERC20.transferFrom(taker → SwapVM) + AQUA.push()- Input (Aqua without useTransferFromAndAquaPush): Balance validation only (taker must push tokens before
swap())- Output (non-Aqua):
IERC20.transferFrom(maker → to)- Output (Aqua):
AQUA.pull(maker → to)Maker Responsibility: When implementing hooks, you must account for both possible execution orders. Do not assume a fixed sequence (e.g., assuming
preTransferInHookalways executes beforepreTransferOutHook). Note that taker callbacks are executed between your hooks - this is controlled by the taker and outside your control.Best Practices:
- Design hooks to be order-independent (stateless validation)
- If order matters, explicitly check transfer direction within your hook logic
- Test your hooks with both
isFirstTransferFromTakervalues- Be aware that taker callbacks execute between your hooks
Risk Mitigations:
// Limit order exposure
p.build(Invalidators._invalidateBit1D, bitIndex);
// Add expiration via _deadline instruction in program
p.build(Controls._deadline, ControlsArgsBuilder.buildDeadline(block.timestamp + 1 hours));
// Or via TakerTraits deadline field
// MEV protection
p.build(Decay._decayXD, DecayArgsBuilder.build(30));Protection Mechanisms:
| Feature | Description | Implementation |
|---|---|---|
| Slippage Protection | Min output/max input | threshold in TakerTraits |
| Amount Validation | Exact amounts enforced | isStrictThresholdAmount flag |
| Preview Execution | Check before swap | quote() function |
| Custom Recipients | Control token destination | to in TakerTraits |
| Hook Validation | Pre-execution checks | hasPreTransferInHook |
Risk Mitigations:
// Set minimum output
takerTraits.threshold = minAcceptableOutput;
// Preview first
(amountIn, amountOut) = swapVM.asView().quote(...);
require(amountOut >= minRequired, "Insufficient output");
// Then execute
swapVM.swap(...);Sandboxed Execution:
┌─────────────────────────────────────────┐
│ Instruction Sandbox │
├─────────────────────────────────────────┤
│ ✅ Allowed: │
│ • Read context data │
│ • Modify swap registers │
│ • Control flow (jumps) │
│ • Pure computations │
├─────────────────────────────────────────┤
│ ❌ Restricted: │
│ • External calls │
│ • Storage modification │
│ • Query data modification │
│ • Infinite loops │
└─────────────────────────────────────────┘
Validation Example:
function _safeInstruction(Context memory ctx, bytes calldata args) internal {
// ✅ Can read and modify swap registers
ctx.swap.amountIn = ctx.swap.amountIn * 99 / 100;
// ✅ Can read query data (read-only)
address maker = ctx.query.maker;
// ✅ Can modify VM state for control flow
ctx.vm.nextPC = newPC;
// ✅ Can consume taker data
bytes calldata data = ctx.tryChopTakerArgs(32);
// ❌ Cannot do:
// IERC20(token).transfer(...); // No external calls
// ctx.query.maker = newMaker; // Query is read-only
// selfdestruct(); // No destructive operations
}Makers define programs that trade assets on their behalf and are responsible for correctness:
Logic Errors
- Risk: Incorrect instruction sequence or arguments
- Mitigation: Test thoroughly, use proven patterns, audit critical strategies
Replay Attacks
- Risk: Order executed multiple times or overfilled
- Mitigation:
- Include
_invalidateBit1Dfor one-time execution - Use
_invalidateTokenIn/Out1Dfor partial fills - Set appropriate expiration
- Include
Price Exposure
- Risk: Trades at unfavorable market conditions
- Mitigation:
- Add
_requireMinRate1Dchecks - Set expiration timestamps
- Use oracle price bounds
- Add
Order Uniqueness
- Risk: Cannot create multiple identical orders
- Mitigation: Use
_saltinstruction to differentiate, vary parameters slightly
Takers control execution parameters and must verify rates:
Rate Slippage
- Risk: Receive worse exchange rate than expected
- Mitigation Options:
- Threshold Protection:
- Exact:
isStrictThresholdAmount = true - Min output:
isExactIn = true, threshold = minOut - Max input:
isExactIn = false, threshold = maxIn
- Exact:
- Callback Validation:
- Pre-transfer hook:
hasPreTransferInHook = true - Custom logic via
ITakerCallbacks
- Pre-transfer hook:
- Return Data Verification:
- Check returned
(amountIn, amountOut) - Compare with
quote()results
- Check returned
- Threshold Protection:
MEV Attacks
- Risk: Front-running or sandwich attacks
- Mitigation: Use private mempools (Flashbots), set tight thresholds, use commit-reveal patterns
Failed Transactions
- Risk: Wasted gas from reverts
- Mitigation: Always call
quote()first, verify token balances, check order expiration
The protocol provides these built-in protections:
Parameter Integrity
- Never violates maker/taker constraints through strict trait enforcement
Balance Isolation
- Each maker's liquidity is separate using per-maker storage slots
Instruction Sandboxing
- No external calls from instructions (pure/view functions only)
Reentrancy Protection
- Prevents recursive calls using transient locks (EIP-1153)
Overflow Protection
- Safe arithmetic operations with Solidity 0.8+ checks
Deterministic Execution
- Same inputs always produce same outputs (no external dependencies in core logic)
The order in which instructions appear in an AMM program is critical for correct accounting — specifically for protocol fee isolation, liquidity growth, and conservation laws. The canonical orderings below are validated by AquaAccounting.t.sol and SwapVmAccounting.t.sol.
aquaProtocolFeeAmountIn → [decay?] → [concentrate?] → flatFee → swap / peggedSwap → salt
// XYC AMM with protocol fee + flat fee + MEV protection
bytes memory program = bytes.concat(
p.build(Fee._aquaProtocolFeeAmountInXD, FeeArgsBuilder.buildProtocolFee(protocolFeeBps, feeReceiver)),
p.build(Decay._decayXD, DecayArgsBuilder.build(decayPeriod)), // optional
p.build(XYCConcentrate._xycConcentrateGrowLiquidity2D, concentrateArgs), // optional
p.build(Fee._flatFeeAmountInXD, FeeArgsBuilder.buildFlatFee(flatFeeBps)),
p.build(XYCSwap._xycSwapXD),
p.build(Controls._salt, saltArgs)
);
// Pegged swap variant (replaces concentrate + xycSwap)
bytes memory program = bytes.concat(
p.build(Fee._aquaProtocolFeeAmountInXD, FeeArgsBuilder.buildProtocolFee(protocolFeeBps, feeReceiver)),
p.build(Decay._decayXD, DecayArgsBuilder.build(decayPeriod)), // optional
p.build(Fee._flatFeeAmountInXD, FeeArgsBuilder.buildFlatFee(flatFeeBps)),
p.build(PeggedSwap._peggedSwapGrowPriceRange2D, peggedArgs),
p.build(Controls._salt, saltArgs)
);protocolFeeAmountIn → dynamicBalances → [decay?] → [concentrate?] → flatFee → swap / peggedSwap → salt
// XYC AMM with protocol fee + flat fee + MEV protection
bytes memory program = bytes.concat(
p.build(Fee._protocolFeeAmountInXD, FeeArgsBuilder.buildProtocolFee(protocolFeeBps, feeReceiver)),
p.build(Balances._dynamicBalancesXD, BalancesArgsBuilder.build(tokens, initialBalances)),
p.build(Decay._decayXD, DecayArgsBuilder.build(decayPeriod)), // optional
p.build(XYCConcentrate._xycConcentrateGrowLiquidity2D, concentrateArgs), // optional
p.build(Fee._flatFeeAmountInXD, FeeArgsBuilder.buildFlatFee(flatFeeBps)),
p.build(XYCSwap._xycSwapXD),
p.build(Controls._salt, saltArgs)
);
// Pegged swap variant
bytes memory program = bytes.concat(
p.build(Fee._protocolFeeAmountInXD, FeeArgsBuilder.buildProtocolFee(protocolFeeBps, feeReceiver)),
p.build(Balances._dynamicBalancesXD, BalancesArgsBuilder.build(tokens, initialBalances)),
p.build(Decay._decayXD, DecayArgsBuilder.build(decayPeriod)), // optional
p.build(Fee._flatFeeAmountInXD, FeeArgsBuilder.buildFlatFee(flatFeeBps)),
p.build(PeggedSwap._peggedSwapGrowPriceRange2D, peggedArgs),
p.build(Controls._salt, saltArgs)
);| Position | Instruction | Reason |
|---|---|---|
| 1st | Protocol Fee | Extracted from amountIn before balances are touched — ensures fee is isolated from pool reserves and does not inflate liquidity |
| 2nd | Dynamic Balances (non-Aqua only) | Loads/initializes maker's isolated reserves; wraps all subsequent instructions via runLoop() |
| 3rd | Decay | Applies virtual reserve adjustment based on time since last trade — must see real balances |
| 4th | Concentrate | Shifts reserves into concentrated range — must happen before the swap but after decay |
| 5th | Flat Fee | Reduces effective amountIn before swap calculation — fee amount stays in the pool, growing liquidity |
| 6th | Swap / PeggedSwap | Core AMM calculation using final adjusted registers |
| Last | Salt | Order uniqueness — pure hash modifier, no effect on computation |
Key invariant: pool_balance + protocol_fee = initial_balance + total_amountIn (Token A conservation). Placing protocol fee first guarantees it is cleanly separated from pool accounting. Placing flat fee after concentrate ensures the retained fee grows liquidity correctly.
See test/AquaAccounting.t.sol and test/SwapVmAccounting.t.sol for comprehensive conservation law tests.
Provide liquidity within specific price ranges:
// Calculate concentration parameters
(uint256 deltaA, uint256 deltaB) = XYCConcentrateArgsBuilder.computeDeltas(
1000e6, // balanceA
0.5e18, // balanceB
2000e18, // current price
1900e18, // lower bound
2100e18 // upper bound
);
// Build CLMM strategy
bytes memory program = bytes.concat(
p.build(Balances._dynamicBalancesXD, balances),
p.build(XYCConcentrate._xycConcentrateGrowLiquidity2D,
XYCConcentrateArgsBuilder.build2D(tokenA, tokenB, deltaA, deltaB)),
p.build(Fee._flatFeeAmountInXD, fee),
p.build(XYCSwap._xycSwapXD)
);Complex multi-instruction strategies:
// Dutch auction + gas adjustment + oracle + rate limit
bytes memory program = bytes.concat(
p.build(Balances._staticBalancesXD, ...),
p.build(DutchAuction._dutchAuctionBalanceOut1D, ...),
p.build(BaseFeeAdjuster._baseFeeAdjuster1D, ...),
p.build(OraclePriceAdjuster._oraclePriceAdjuster1D, ...),
p.build(MinRate._adjustMinRate1D, ...),
p.build(LimitSwap._limitSwap1D, ...)
);SwapVM offers two protocol fee instructions with different settlement mechanisms:
1. _protocolFeeAmountOutXD - Direct ERC20 Transfer
- Uses standard
transferFromto collect fees - Requires maker to have approved SwapVM contract
- Fee is transferred directly from maker to recipient
- Suitable for standard ERC20 tokens
2. _aquaProtocolFeeAmountOutXD - Aqua Protocol Integration
- Uses Aqua's
pullfunction for fee collection - Works with orders using Aqua balance management
- No separate approval needed (uses Aqua's existing permissions)
- Enables batched fee collection and gas optimization
Usage Example:
// Direct ERC20 protocol fee
p.build(Fee._protocolFeeAmountOutXD,
FeeArgsBuilder.buildProtocolFee(10, treasury)); // 0.1% to treasury
// Aqua protocol fee (for Aqua-managed orders)
p.build(Fee._aquaProtocolFeeAmountOutXD,
FeeArgsBuilder.buildProtocolFee(10, treasury)); // 0.1% via AquaBoth calculate fees identically but differ in the transfer mechanism.
// Virtual balance decay
p.build(Decay._decayXD, DecayArgsBuilder.build(30)); // 30s decay
// Progressive fees (larger swaps pay more)
p.build(Fee._progressiveFeeInXD, ...); // or _progressiveFeeOutXD
/* Progressive Fee Improvements:
* New formula: dx_eff = dx / (1 + λ * dx / x)
* - Maintains near-perfect exact in/out symmetry
* - Only ~1 gwei asymmetry from safety ceiling operations
* - Mathematically reversible for consistent pricing
*/
// Time-based pricing
p.build(DutchAuction._dutchAuctionBalanceOut1D, ...);The _twap instruction implements a sophisticated selling strategy with:
- Linear liquidity unlocking over time
- Exponential price decay (Dutch auction) for price discovery
- Automatic price bumps after illiquidity periods
- Minimum trade size enforcement
Set minTradeAmountOut 1000x+ larger than expected gas costs:
| Network | Gas Cost | Recommended Min Trade |
|---|---|---|
| Ethereum | $50 | $50,000+ |
| Arbitrum/Optimism | $0.50 | $500+ |
| BSC/Polygon | $0.05 | $50+ |
This ensures gas costs remain <0.1% of trade value.
The priceBumpAfterIlliquidity compensates for mandatory waiting periods:
| Min Trade % of Balance | Unlock Time | Recommended Bump |
|---|---|---|
| 0.1% | 14.4 min | 5-10% (1.05e18 - 1.10e18) |
| 1% | 14.4 min | 10-20% (1.10e18 - 1.20e18) |
| 5% | 1.2 hours | 30-50% (1.30e18 - 1.50e18) |
| 10% | 2.4 hours | 50-100% (1.50e18 - 2.00e18) |
Additional factors:
- Network gas costs: Higher gas → larger bumps
- Pair volatility: Volatile pairs → larger bumps
- Market depth: Thin markets → higher bumps
SwapVM reserves opcodes 1-10 for debugging utilities, available only in debug routers:
Available Debug Instructions:
_printSwapRegisters- Logs all 4 swap registers (balances and amounts)_printSwapQuery- Logs query data (orderHash, maker, taker, tokens, isExactIn)_printContext- Logs complete execution context_printFreeMemoryPointer- Logs current memory usage_printGasLeft- Logs remaining gas
Usage:
// Deploy debug router
SwapVMRouterDebug debugRouter = new SwapVMRouterDebug(aquaAddress);
// Include debug instructions in program
bytes memory program = bytes.concat(
p.build(Balances._staticBalancesXD, ...),
p.build(Debug._printSwapRegisters), // Debug output
p.build(LimitSwap._limitSwap1D, ...),
p.build(Debug._printContext) // Final state
);Note: Debug instructions are no-ops in production routers and should only be used for development and testing.
SwapVM programs have an effective size limit of 65,535 bytes (64KB) due to control flow instruction addressing.
Technical Details:
- The VM itself (
ContextLib.runLoop) usesuint256for the program counter and can execute programs of any size - Control flow instructions (
_jump,_jumpIfTokenIn,_jumpIfTokenOut) useuint16(2-byte) encoding for jump targets - Jump targets are limited to positions 0-65,535 within the bytecode
- Programs larger than 65KB can execute, but jump instructions cannot address positions >= 65,536
Practical Impact:
- This limitation is not restrictive in practice
- Typical strategies are 100-1,000 bytes
- Even complex multi-instruction programs rarely exceed 5KB
- 65KB ≈ 1,000-30,000 instructions (depending on argument sizes)
Workarounds for Large Programs: If you need custom control flow beyond byte 65,535:
// Use Extruction with arbitrary uint256 nextPC
p.build(Extruction._extruction,
ExtructionArgsBuilder.build(customControlContract, args))The Extruction instruction can set arbitrary uint256 program counter values, enabling custom control flow logic for edge cases requiring programs larger than 64KB.
Example Program Sizes:
| Strategy Type | Typical Size |
|---|---|
| Simple limit order | ~50 bytes |
| Dutch auction + fees | ~100 bytes |
| AMM with MEV protection | ~200 bytes |
| Complex multi-conditional | ~500 bytes |
| Maximum practical | ~5,000 bytes |
Architecture Benefits:
- Transient storage (EIP-1153) for reentrancy guards
- Zero deployment cost for makers
- Compact bytecode encoding (8-bit opcodes)
Tips for Makers:
- Use
_staticBalancesXDfor single-direction trades with fixed rates - Use
_dynamicBalancesXDfor AMM strategies with automatic rebalancing - Pack multiple operations in single program
- Minimize argument sizes
Tips for Takers:
- Batch multiple swaps
- Use
quote()to avoid failed transactions - Consider gas costs in profit calculations
npm install @1inch/swap-vm
# or
yarn add @1inch/swap-vmimport { SwapVMRouter } from "@1inch/swap-vm/contracts/SwapVMRouter.sol";
import { Program, ProgramBuilder } from "@1inch/swap-vm/test/utils/ProgramBuilder.sol";
// Deploy router
SwapVMRouter router = new SwapVMRouter(aquaAddress, "MyDEX", "1.0");
// Create and execute orders...- GitHub: github.com/1inch/swap-vm
- Documentation: See
/docsdirectory - Tests: Comprehensive examples in
/test - Audits: Security review reports in
/audits
Fee-on-Transfer Tokens: SwapVM does not support fee-on-transfer tokens (tokens that deduct fees during transfers). Using such tokens will cause accounting mismatches between expected and actual transferred amounts.
ERC1155 Tokens: SwapVM does not support ERC1155 multi-token standard. Only ERC20 tokens are supported in the current version.
SwapVM currently supports maximum two tokens per strategy (2D strategies only). Multi-token (XD) functionality for more than two tokens is not available in this version.
What This Means:
- Each strategy can trade between exactly 2 tokens (token pair)
- AMM pools (XYCSwap, PeggedSwap, XYCConcentrate) operate on 2-token reserves
- Limit orders work with 1 token pair (1D single-direction)
- Cannot create strategies that simultaneously manage >2 different tokens
Recommendation: We strongly recommend against creating custom instructions with more than 2 tokens. The protocol is designed and tested for <= 2-token strategies only. Using more than 2 tokens may lead to unexpected behavior, security vulnerabilities, and is not supported.
Not Recommended: Using allowZeroAmountIn=true with AMM strategies (PeggedSwap, XYCSwap, XYCConcentrate) is not recommended. This flag is intended for:
- Cross-chain bridge integration
- Limit orders with specific use cases
For AMM pools, allowZeroAmountIn=true can enable theoretical (but economically infeasible) dust extraction.
_onlyTakerTokenBalanceGte / _onlyTakerTokenBalanceNonZero: These instructions verify the taker owns a minimum token balance. Limitations:
- Not compatible with routers/aggregators - these contracts don't hold user tokens
- Easily bypassed via flash loans - if the token supports flash lending, attackers can temporarily borrow the required balance
_onlyTakerTokenSupplyShareGte: This instruction verifies the taker owns a minimum percentage of total token supply. Limitations:
- Bypassed with ERC4626 vault tokens - attackers can wrap/unwrap to manipulate their share
- Bypassed with flash-mintable tokens (e.g., DAI) - attackers can temporarily mint tokens to meet the threshold
Use Case: These instructions provide basic access control for specific scenarios but should not be relied upon as strong security mechanisms. Consider them convenience features rather than robust protections.
1. Monotonicity Violations Larger trades may receive better rates due to relative rounding error:
- 20 wei trade: 0.3% fee = 0.06 wei → rounds UP to 1 wei (16x overcharge)
- 50 wei trade: 0.3% fee = 0.15 wei → rounds UP to 1 wei (6x overcharge)
- 100 wei trade: 0.3% fee = 0.30 wei → rounds UP to 1 wei (3x overcharge)
As amount increases, relative rounding error decreases, creating monotonic pricing.
2. Zero Outputs Some dust amounts may round down to 0 output, causing transaction reverts.
3. Quantization Steps Discrete jumps in exchange rates due to integer quantization.
SwapVM uses "rounding favors maker" for security:
- Fees round UP (
Math.ceilDiv) - maker receives full protection - Outputs round DOWN (floor division) - maker keeps extra dust
- Necessary to prevent value extraction via rounding attacks
For dust amounts, rounding error dominates actual swap calculations.
Gas Cost Dominance:
Transaction cost: ~$15 (@ 100k gas, 50 gwei, ETH=$3000)
Profit from rate improvement: ~$0.0000000000000001
Loss ratio: 500 trillion to 1
Why This is Safe:
- ❌ NOT economically exploitable - gas >> profit by 12+ orders of magnitude
- ✅ Self-limiting - only affects negligible amounts (<$0.000001)
- ✅ Maker protected - rounding favors liquidity providers
- ✅ No pool drain - would need billions of transactions
SwapVM test suite handles dust amounts with appropriate tolerances:
// DustAmounts.t.sol - tests 1-100 wei
monotonicityToleranceBps = 10000; // 100% tolerance
roundingToleranceBps = 1000; // 10% tolerance
// MicroAmounts.t.sol - tests 20 wei - 1B wei
monotonicityToleranceBps = 1; // 1 bps tolerance
roundingToleranceBps = 100; // 1% toleranceKey Insight: Invariant violations for dust amounts are mathematical artifacts without real-world impact. The test suite validates that all invariants hold for economically relevant trade sizes.
For pools ≥1e+27 tokens, integer quantization can create scenarios where:
- Exact-out swaps of 1 wei may require 0 wei input (due to rounding)
- This only occurs with
allowZeroAmountIn=true
Impact:
- Theoretical: ~1-10 wei extractable from pools >1e+27 tokens
- Economic: Completely infeasible (gas costs exceed profit)
Recommendations:
- ❌ DO NOT use
allowZeroAmountIn=truewith PeggedSwap AMM pools - ✅ DO use
allowZeroAmountIn=truefor limit orders (intended use case)
This project is licensed under the LicenseRef-Degensoft-SwapVM-1.1
See the LICENSE file for details. See the THIRD_PARTY_NOTICES file for information about third-party software, libraries, and dependencies used in this project.
Contact for licensing inquiries: