source-verifiedGreen This page shows how to add a custom instruction to a SwapVM router: write the instruction, register it in the router's opcode set, and build a program with ProgramBuilder. It also documents the compiler settings the dispatch mechanism depends on, the EIP-170 constraint that forces curation of the native opcode set, the taker-side TakerTraits layout and EIP-1271 signature path, and a set of verified tooling footguns that repeatedly bite integrators.
Everything below is verified against 1inch/swap-vm (src/, HEAD) and the 1inch/swap-vm-template reference project. Where a claim comes from an integrator report that could not be confirmed against source it is explicitly labelled reported, unverified.
The instruction model
A SwapVM program is a flat byte string. The run loop (src/libs/VM.sol, ContextLib.runLoop) walks it two bytes at a time: the first byte is the opcode, the second is the argument length (0–255), followed by that many argument bytes.
[opcode:1][argsLen:1][args:argsLen] [opcode:1][argsLen:1][args:argsLen] ...
For each instruction the loop calls a dispatcher stored as an internal function pointer on the VM (ctx.vm.dispatch), which the router wires to _runOpcode(ctx, opcode, args).
Every instruction has exactly this signature:
function _myInstruction(Context memory ctx, bytes calldata args) internal;
The instruction receives the whole execution Context (src/libs/VM.sol) and may read and mutate it:
| Field | Meaning | Writable by an instruction? |
|---|---|---|
ctx.query.taker |
The swap caller — set to msg.sender in SwapVM.swap/quote |
read-only |
ctx.query.maker |
Liquidity provider / order signer | read-only |
ctx.query.tokenIn / tokenOut |
Resolved swap direction | read-only |
ctx.query.orderHash |
Per-maker strategy/position id | read-only |
ctx.query.isExactIn |
Exact-in vs exact-out | read-only |
ctx.swap.balanceIn / balanceOut |
Maker balances (Aqua-shipped or injected) | yes |
ctx.swap.amountIn / amountOut |
The swap registers being computed | yes |
ctx.vm.nextPC |
Program counter — write it to jump | yes |
An opcode can computeamountIn, not onlyamountOut. The register that gets filled is whichever side is missing: isExactIn() ? amountOut : amountIn. In XYCSwap._xycSwapXD the exact-out branch writes ctx.swap.amountIn = Math.ceilDiv(amountOut * balanceIn, balanceOut - amountOut), and Fee._flatFeeAmountInXD adjusts ctx.swap.amountIn directly. Your instruction is free to price either direction.
ctx.query.takeris the caller. Both SwapVM.swap and SwapVM.quote construct the context with taker: msg.sender. Control instructions rely on this — e.g. Controls._onlyTakerTokenBalanceGte reads IERC20(token).balanceOf(ctx.query.taker).
Reading taker-supplied arguments
Two kinds of data reach an instruction. The program args (the args parameter) are baked into the maker's strategy and are the same for every taker. Taker args are supplied per-swap by the caller (the instructionsArgs slice of TakerTraits) and are consumed front-to-back via ContextLib:
bytes calldata all = ctx.takerArgs(); // remaining taker args
bytes calldata chunk = ctx.tryChopTakerArgs(32); // consume up to 32 bytes
Extruction._extruction is the canonical example: it forwards ctx.takerArgs() to an external contract and then tryChopTakerArgs(choppedLength) to consume exactly what that contract reports it used.
Step by step: write, register, build
1. Write the instruction contract
Create a contract under src/instructions/ exposing one or more internal functions with the instruction signature. Keep the logic deterministic — the same input must produce the same output in both quote() (static) and swap() contexts, or quote/swap consistency breaks.
// src/instructions/MyCap.sol
import { Context, ContextLib } from "../libs/VM.sol";
contract MyCap {
using ContextLib for Context;
error AmountOutExceedsCap(uint256 amountOut, uint256 cap);
/// args: [cap: 32 bytes]
function _capAmountOut1D(Context memory ctx, bytes calldata args) internal pure {
uint256 cap = uint256(bytes32(args));
require(ctx.swap.amountOut <= cap, AmountOutExceedsCap(ctx.swap.amountOut, cap));
}
}
2. Register it in the opcode set
An opcode set contract (src/opcodes/Opcodes.sol for the standard router, src/opcodes/AquaOpcodes.sol for the Aqua router) inherits every instruction contract and exposes two parallel structures that must stay in lockstep:
_runOpcode(ctx, opcode, args)— the runtimeif/elsedispatcher. Add your branch at the next free index._opcodes()— a fixed array of internal function pointers. Add your function pointer at the same index. This array is whatProgramBuilderuses to translate a function into its opcode byte.
The index in _runOpcode and the slot in _opcodes() MUST match exactly — the source comment says "Indices MUST mirror{_opcodes}exactly." A mismatch silently dispatches the wrong instruction. Indices 0–9 are reserved (_notInstruction, for debug tooling); real opcodes start at 10. Always append new instructions at the end to preserve backward compatibility with already-deployed programs.
// in _runOpcode(...)
else if (opcode == 52) MyCap._capAmountOut1D(ctx, args); // next free index
// in _opcodes() — append at the SAME position, and bump the array size
MyCap._capAmountOut1D
Finally, a router (src/routers/…) inherits the opcode set and overrides _dispatch to call _runOpcode. That is the whole wiring:
function _dispatch(Context memory ctx, uint256 opcode, bytes calldata args) internal override {
_runOpcode(ctx, opcode, args);
}
3. Build a program with ProgramBuilder
ProgramBuilder (test/utils/ProgramBuilder.sol) turns instruction references into bytecode. It resolves an opcode by internal function-pointer equality against the set returned by _opcodes():
Program memory program = ProgramBuilder.init(_opcodes());
bytes memory bytecode = bytes.concat(
program.build(_xycConcentrateGrowLiquidity2D, concentrateArgs),
program.build(_flatFeeAmountInXD, feeArgs),
program.build(_capAmountOut1D, abi.encodePacked(cap)), // your new opcode
program.build(_xycSwapXD) // no args
);
findOpcode loops _opcodes() and returns the index where self.opcodes[i] == targetOpcode. If the function pointer is not in the set it reverts OpcodeNotFound at build time. That equality check is the reason the whole project is compiled through the IR pipeline (next section).
The bytecode becomes the program field of a maker order via MakerTraitsLib.build(...). The order's data is tokenA ‖ tokenB ‖ hooks… ‖ program, with tokenA < tokenB enforced.
Required solc settings
The instruction dispatch and the ProgramBuilder opcode lookup both hinge on internal function pointers being stable, comparable values (self.opcodes[i] == targetOpcode). The project only ever compiles this under the Yul/IR pipeline; do the same, or the comparison — and therefore your generated programs — cannot be trusted.
| Setting | foundry.toml (repo) |
hardhat.config.ts (template) |
|---|---|---|
| solc | 0.8.30 |
0.8.30 |
| IR pipeline | via_ir = true |
viaIR: true |
| optimizer | optimizer = true |
enabled: true |
| optimizer runs | optimizer_runs = 700 |
runs: 1 (+ yulDetails optimizerSteps: "dhfoDgvulfnTUtnIf") |
| EVM version | not overridden (solc 0.8.30 default) | evmVersion: "cancun" |
Compile through IR. foundry.toml sets via_ir = true in both the default and ci profiles, and the template sets viaIR: true. Function-pointer identity (used by ProgramBuilder.findOpcode and the _opcodes() table) resolves correctly under the IR codegen the project builds with; do not switch to legacy codegen. (That the comparison misbehaves specifically under legacy codegen is reported by integrators and consistent with the source's exclusive use of IR, but was not independently reproduced here — treat "legacy breaks it" as reported, unverified; "build with via_ir" is verified fromfoundry.toml.)
Target Cancun. SwapVM guards reentrancy with transient storage (TransientLock, i.e. TSTORE/TLOAD), which requires an EVM target of cancun or newer. The template makes this explicit with evmVersion: "cancun"; foundry.toml relies on the solc 0.8.30 default. If you set evm_version yourself, do not pick a pre-Cancun target — the router will not deploy/run.
EIP-170 and curating the native set
A deployed contract may not exceed the EIP-170 bytecode limit (24,576 bytes). Every instruction the router inlines adds to that budget, so the two shipped opcode sets are deliberately different sizes:
Opcodes(standardSwapVMRouter) registers the full set — control flow, balances, invalidators, XYC/concentrate/decay, limit, min-rate, Dutch auction, TWAP, fees, pegged, whitelist, extruction — opcodes10–51.AquaOpcodes(deployedAquaSwapVMRouter) registers only a curated subset — Controls, XYCSwap, XYCConcentrate, Decay, Fee (amount-in variants), PeggedSwap, Extruction — opcodes10–33.
Out-of-subset opcodes are not universally available. The deployed Aqua router runs only the aquaInstructions subset. An instruction that exists in Opcodes but not in AquaOpcodes cannot be placed in an Aqua program: ProgramBuilder.findOpcode reverts OpcodeNotFound at build time (the function pointer is absent from that router's _opcodes()). If instead you hand-encode a raw opcode byte the router does not implement, there is no named runtime error: a reserved gap index resolves to the _notInstruction no-op, and an index past the registered _opcodes() table reverts with a Solidity array-out-of-bounds Panic(0x32). When you add a custom instruction, decide which router(s) should carry it and register it there — adding to every router may push a contract over EIP-170.
TakerTraits & the taker path
The taker controls execution through TakerTraits (src/libs/TakerTraits.sol), a packed uint176 header (18-byte slice-index table + 2-byte flags) followed by variable-length slices. Build it with TakerTraitsLib.build(Args). The fields that matter to integrators:
| Field | Purpose |
|---|---|
isExactIn |
Exact-input vs exact-output pricing |
isAToB |
Swap direction across the maker's sorted tokenA/tokenB |
threshold (32 bytes or empty) |
Slippage bound: minReturn (min amountOut) when exact-in, maxIn (max amountIn) when exact-out |
isStrictThresholdAmount |
Require the threshold to match exactly instead of as a min/max bound |
to |
Receiver of tokenOut; defaults to the taker when zero/omitted |
deadline (uint40) |
Expiry timestamp; 0 = no deadline |
shouldUnwrapWeth |
Deliver ETH instead of WETH to the receiver |
isFirstTransferFromTaker |
Ordering of the two transfers (taker→maker first, or maker→taker first) |
useTransferFromAndAquaPush |
Pull the input with transferFrom then AQUA.push (vs. relying on a callback) |
hasPreTransferInCallback / hasPreTransferOutCallback |
Enable ITakerCallbacks hooks around the transfers |
instructionsArgs |
Per-swap bytes consumed by instructions via ctx.takerArgs() |
signature |
The maker's order signature (non-Aqua orders only) |
The threshold and deadline are enforced in TakerTraitsLib.validate after the program runs: amountOut > 0 is required, the deadline is checked against block.timestamp, and the min/max (or strict) threshold is applied to the computed amount.
There is nopermitfield in TakerTraits.Args. Input-side funding is expressed through useTransferFromAndAquaPush and the pre-transfer callbacks, not an EIP-2612 permit blob. Do not expect one.
The EIP-1271 (smart-contract signature) path
For signature-based (non-Aqua) orders, SwapVM.swap verifies the maker with:
bytes calldata signature = takerTraits.signature(takerData);
require(order.maker.recoverOrIsValidSignature(orderHash, signature),
BadSignature(order.maker, orderHash, signature));
recoverOrIsValidSignature (from @1inch/solidity-utils ECDSA) first attempts EOA ecrecover and, failing that, falls back to the EIP-1271 isValidSignature(bytes32,bytes) call on order.maker. So a smart-contract wallet or a programmatic maker can sign orders — the signature simply travels in the signature slice of the taker data. Aqua orders (useAquaInsteadOfSignature) skip this entirely: authority comes from having shipped liquidity into Aqua under that orderHash, and the order is hashed with a plain keccak256(abi.encode(order)) rather than the EIP-712 typed hash.
Known tooling caveats & version drift
These are verified footguns. Read before you wire up tooling.
1. Constructor arity differs across builds — pin your version. In the repo (HEAD), AquaSwapVMRouter's constructor takes 5 arguments: (address aqua, address weth, address owner, string name, string version) — verified in src/routers/AquaSwapVMRouter.sol. Integrators report the published npm @1inch/swap-vm build exposing a 3-argument constructor with different import paths and different TakerTraits.Args fields (reported, unverified against the npm artifact). The template itself pins a GitHub tag (@1inch/swap-vm#0.0.4) while HEAD is 0.0.6, so the drift is real. Pin an exact commit/tag and treat the Solidity source you compile against — not a floating npm range — as canonical.
2. TheSwapVMHelpers.tsopcode enum is stale — trust the on-chain table. The TypeScript AquaOpcodes enum in test/utils/SwapVMHelpers.ts lists two concentrate entries (XYC_CONCENTRATE_GROW_LIQUIDITY_XD = 0x12 and …_2D = 0x13), but the on-chain AquaOpcodes._opcodes() registers only _xycConcentrateGrowLiquidity2D at 0x12, with Decay at 0x13. Everything after the phantom entry is shifted by one, so numbers emitted from the TS enum address the wrong instruction. Derive opcodes from the deployed _opcodes() set (via ProgramBuilder function-pointer resolution), never from the hand-maintained TS enum.
3.TakerTraits.Argsfield drift. The Solidity TakerTraits.Args struct includes isAToB and deadline; the TS TakerTraitsArgs interface in SwapVMHelpers.ts omits both (and its MakerTraitsArgs carries an expiration field with no Solidity counterpart). Encoding taker data from the TS helper can silently drop direction/deadline. Verified against source.
4. The template's test suite skips real swaps. test/AquaAMM.test.ts marks the deadline case with it.only(...) (line 708). On a fresh clone, it.only makes Mocha run only that test and silently skip the two actual swap tests ("execute swap with resolver contract" and "…with EOA as taker"). Remove it.only before trusting a green run.
5.AquaAMM.buildProgramcan revertOpcodeNotFound. The template's AquaAMM.buildProgram conditionally emits _aquaProtocolFeeAmountOutXD when protocolFeeBpsIn > 0. That instruction is not in AquaOpcodes._opcodes() on current swap-vm (the Aqua set carries only the amount-in fee variants; the amount-out Aqua fee lives in FeeExperimental, which the Aqua router does not inherit). So ProgramBuilder.findOpcode reverts OpcodeNotFound when a non-zero protocol fee is requested. Every shipped test passes protocolFeeBpsIn = 0, so the path is never exercised and the bug stays hidden until someone charges a protocol fee. Verified.
6. Use the canonical router, not the superseded Base build. The canonical router is AquaSwapVMRouter v1.0.2 at 0x111111338c5091e8440b67b168bae16a668ac0de, deployed across the 13 Aqua chains, with the Aqua registry at 0x1111113ccf1426a8e30e2bff5e005d929bf6a90a. An older superseded build at 0x8fdd04dbf6111437b44bbca99c28882434e0958f (a 28-opcode build seen on Base) is stale — integrators hit an index-32 out-of-bounds against it. Point resolvers at 0x111111338c…c0de and ignore 0x8fdd…958f.