Write Your Own Opcode

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:

Solidity
1
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:

Solidity
1
2
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.

Solidity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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 runtime if/else dispatcher. 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 what ProgramBuilder uses 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:

Solidity
1
2
3
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():

Solidity
1
2
3
4
5
6
7
8
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 (standard SwapVMRouter) registers the full set — control flow, balances, invalidators, XYC/concentrate/decay, limit, min-rate, Dutch auction, TWAP, fees, pegged, whitelist, extruction — opcodes 10–51.
  • AquaOpcodes (deployed AquaSwapVMRouter) registers only a curated subset — Controls, XYCSwap, XYCConcentrate, Decay, Fee (amount-in variants), PeggedSwap, Extruction — opcodes 10–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:

Solidity
1
2
3
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.

Did you find what you need?