Strategy Template

A complete, copy-pasteable starting point for building an Aqua strategy. It ships an XYC (constant-product) strategy, runs a quote and a swap as a credentialed taker, and docks - in two flavours you can lift verbatim:

  • a Foundry test that runs against a mainnet fork and asserts every protocol event by its topic0 hash;
  • a TypeScript script using @1inch/aqua-sdk + @1inch/swap-vm-sdk, broadcast through your own RPC.

There is no separate starter repository - this page is the starter. Copy the two files below into a fresh Foundry / Node project and fill in your addresses and keys.

What the flow does

Aqua is a shared liquidity layer: a maker keeps inventory in its own wallet and grants an app the right to move it. A strategy is an Aqua program (a byte stream of VM instructions) embedded in an order. The lifecycle is:

  1. ship - the maker registers the strategy and its initial virtual balances with the Aqua registry (0x1111113ccf1426a8e30e2bff5e005d929bf6a90a). Emits Shipped plus one Pushed per token.
  2. quote - a taker simulates the swap through the AquaSwapVMRouter (0x111111338c5091e8440b67b168bae16a668ac0de) to read the output amount.
  3. swap - the taker executes. The registry pulls the maker's output token (Pulled) and pushes the taker's input token to the maker (Pushed); the router emits Swapped.
  4. dock - the maker closes the strategy for all tokens. Emits Docked.

The strategy here is credential-gated: it begins with the onlyTxOriginTokenBalanceNonZero instruction pointing at the KycNFT (0x26FFc7D378E8e49Be2c483295A3e3E511F96a468), so only a taker whose tx.origin holds a KycNFT may swap. Drop that instruction for a permissionless pool.

How the program bytes are laid out

An Aqua program is a sequence of [opcode][argsLength][args] instructions. The strategy built by AquaXYCAmmStrategy.new().withTxOriginAccessToken(KYC_NFT).build() compiles to exactly:

Bytes Instruction Aqua opcode index
21 14 <20-byte KycNFT> onlyTxOriginTokenBalanceNonZero(token) 33 (0x21)
11 00 xycSwapXD (x*y=k, no args) 17 (0x11)

The order that wraps the program uses MakerTraits.default(), which sets only bit 254 (useAquaInsteadOfSignature) - so traits = 1 << 254, with no hooks and a zero receiver. The shipped strategy bytes are simply abi.encode(Order{maker, traits, data}), and the strategy hash is keccak256 of those bytes.

1. Foundry test

Save as test/StrategyTemplate.t.sol. It builds the program inline (no SDK needed on the Solidity side), so the exact opcode bytes above are visible and auditable.

Solidity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {Test, Vm} from "forge-std/Test.sol";

interface IERC20 {
    function approve(address spender, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

interface IAqua {
    function ship(address app, bytes calldata strategy, address[] calldata tokens, uint256[] calldata amounts)
        external returns (bytes32 strategyHash);
    function dock(address app, bytes32 strategyHash, address[] calldata tokens) external;
    function rawBalances(address maker, address app, bytes32 strategyHash, address token)
        external view returns (uint248 balance, uint8 tokensCount);
}

interface ISwapVM {
    struct Order { address maker; uint256 traits; bytes data; }
    function quote(ISwapVM.Order calldata order, address tokenIn, address tokenOut, uint256 amount, bytes calldata takerTraitsAndData)
        external returns (uint256 amountIn, uint256 amountOut, bytes32 orderHash);
    function swap(ISwapVM.Order calldata order, address tokenIn, address tokenOut, uint256 amount, bytes calldata takerTraitsAndData)
        external returns (uint256 amountIn, uint256 amountOut, bytes32 orderHash);
}

/// @notice End-to-end Aqua strategy flow against a mainnet fork:
///         ship an XYC strategy -> quote + swap as a credentialed taker -> dock,
///         asserting every protocol event by its topic0 hash.
contract StrategyTemplateTest is Test {
    // --- Verified mainnet deployment (Aqua v1) ---
    IAqua   internal constant AQUA    = IAqua(0xE8026bF31E58b738647319362581AB11Be92139B);   // registry
    ISwapVM internal constant ROUTER  = ISwapVM(0x016B417Bc933370F5EAcC40B1d58B015ac72B070); // AquaSwapVMRouter v1.0.2
    address internal constant KYC_NFT = 0x26FFc7D378E8e49Be2c483295A3e3E511F96a468;
    IERC20  internal constant WETH    = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    IERC20  internal constant USDC    = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);

    // --- Event topic0 hashes (all Aqua/SwapVM events declare NO indexed params) ---
    bytes32 internal constant SHIPPED = 0xdc3622e06fb145651f567d421c9ef261d71d43e3778b761907bc0d70d42e52b0;
    bytes32 internal constant DOCKED  = 0xd173a1d140c154eb1ce9298d251d5eb8c4089cc2d16e70f1067bdc810c6fe004;
    bytes32 internal constant PULLED  = 0x3ad61047071575417c75e3311e5d46ff042e292b5dd8769ff18b4b254098ca7a;
    bytes32 internal constant PUSHED  = 0x3f18354abbd5306dd1665c2c90f614a4559e39dd620d04fbe5458e613b6588f3;
    bytes32 internal constant SWAPPED = 0x54bc5c027d15d7aa8ae083f994ab4411d2f223291672ecd3a344f3d92dcaf8b2;

    address internal maker = makeAddr("maker");
    address internal taker = makeAddr("taker");

    /// @dev Aqua program bytecode is a stream of [opcode][argsLen][args] instructions.
    ///      This is exactly what `AquaXYCAmmStrategy.new().withTxOriginAccessToken(KYC_NFT).build()`
    ///      produces in @1inch/swap-vm-sdk:
    ///        0x21 0x14 <20-byte KYC_NFT>  -> onlyTxOriginTokenBalanceNonZero  (aquaInstructions index 33)
    ///        0x11 0x00                    -> xycSwapXD (constant product x*y=k) (aquaInstructions index 17, no args)
    function _program() internal pure returns (bytes memory) {
        return abi.encodePacked(uint8(0x21), uint8(0x14), KYC_NFT, uint8(0x11), uint8(0x00));
    }

    /// @dev order.encode() == abi.encode(ISwapVM.Order{maker, traits, data}).
    ///      MakerTraits.default() sets only bit 254 (useAquaInsteadOfSignature); no hooks, receiver = 0.
    function _order() internal view returns (ISwapVM.Order memory) {
        return ISwapVM.Order({maker: maker, traits: uint256(1) << 254, data: _program()});
    }

    function testShipQuoteSwapDock() public {
        ISwapVM.Order memory order = _order();
        bytes memory strategy = abi.encode(order);
        bytes32 strategyHash = keccak256(strategy);

        // TakerTraits.default().encode(): 10 uint16 offsets (all 0) + uint16 flags.
        // flags = exactIn (bit0) | useTransferFromAndAquaPush (bit6) = 0x0041.
        bytes memory takerTraits = abi.encodePacked(bytes20(0), uint16(0x0041));

        // --- Maker seeds real inventory; funds stay in the maker wallet ---
        uint256 usdcSeed = 15_000e6;
        uint256 wethSeed = 5 ether;
        deal(address(USDC), maker, usdcSeed);
        deal(address(WETH), maker, wethSeed);

        // Approve the Aqua registry: Aqua.pull() transfers straight from the maker wallet.
        vm.startPrank(maker);
        USDC.approve(address(AQUA), type(uint256).max);
        WETH.approve(address(AQUA), type(uint256).max);
        vm.stopPrank();

        address[] memory tokens = new address[](2);
        tokens[0] = address(USDC);
        tokens[1] = address(WETH);
        uint256[] memory amounts = new uint256[](2);
        amounts[0] = usdcSeed;
        amounts[1] = wethSeed;

        // --- Ship (emits Shipped + one Pushed per token) ---
        vm.recordLogs();
        vm.prank(maker);
        bytes32 returnedHash = AQUA.ship(address(ROUTER), strategy, tokens, amounts);
        assertEq(returnedHash, strategyHash, "ship: strategyHash mismatch");
        Vm.Log[] memory shipLogs = vm.getRecordedLogs();
        assertTrue(_seen(shipLogs, SHIPPED), "no Shipped");
        assertTrue(_seen(shipLogs, PUSHED), "no Pushed on ship");

        // --- Credential the taker: a real taker holds a KycNFT minted after KYC ---
        uint256 amountIn = 1_000e6; // 1,000 USDC
        deal(address(USDC), taker, amountIn);
        vm.mockCall(KYC_NFT, abi.encodeWithSelector(IERC20.balanceOf.selector, taker), abi.encode(uint256(1)));
        vm.prank(taker);
        USDC.approve(address(ROUTER), type(uint256).max);

        // --- Quote: simulate on a snapshot, then roll the state back ---
        uint256 snap = vm.snapshotState();
        vm.prank(taker, taker); // (msg.sender, tx.origin): the credential guard reads tx.origin
        (, uint256 quotedOut,) = ROUTER.quote(order, address(USDC), address(WETH), amountIn, takerTraits);
        vm.revertToState(snap);
        assertGt(quotedOut, 0, "quote returned zero");

        // --- Swap (emits Pulled + Pushed + Swapped) ---
        uint256 takerWethBefore = WETH.balanceOf(taker);
        vm.recordLogs();
        vm.prank(taker, taker);
        (, uint256 amountOut,) = ROUTER.swap(order, address(USDC), address(WETH), amountIn, takerTraits);
        assertEq(amountOut, quotedOut, "swap != quote");
        assertEq(WETH.balanceOf(taker), takerWethBefore + amountOut, "taker did not receive WETH");
        Vm.Log[] memory swapLogs = vm.getRecordedLogs();
        assertTrue(_seen(swapLogs, PULLED), "no Pulled");
        assertTrue(_seen(swapLogs, PUSHED), "no Pushed on swap");
        assertTrue(_seen(swapLogs, SWAPPED), "no Swapped");

        // --- Dock: close the strategy for all tokens (emits Docked) ---
        vm.recordLogs();
        vm.prank(maker);
        AQUA.dock(address(ROUTER), strategyHash, tokens);
        Vm.Log[] memory dockLogs = vm.getRecordedLogs();
        assertTrue(_seen(dockLogs, DOCKED), "no Docked");

        (uint248 wethBal,) = AQUA.rawBalances(maker, address(ROUTER), strategyHash, address(WETH));
        assertEq(wethBal, 0, "balance not cleared after dock");
    }

    function _seen(Vm.Log[] memory logs, bytes32 topic0) internal pure returns (bool) {
        for (uint256 i = 0; i < logs.length; i++) {
            if (logs[i].topics.length > 0 && logs[i].topics[0] == topic0) return true;
        }
        return false;
    }
}

2. TypeScript script

Save as strategy.ts. This builds the identical strategy with the SDK (AquaXYCAmmStrategyOrder.newaqua.shipswapVm.quote/swapaqua.dock) and broadcasts through your own node. Note: use the current SDK releases (@1inch/aqua-sdk 0.3.0+, @1inch/swap-vm-sdk 0.4.0+) — their AQUA_CONTRACT_ADDRESSES/AQUA_SWAP_VM_CONTRACT_ADDRESSES constants carry the canonical vanity addresses. Older releases (0.1.1 / 0.2.2) return superseded addresses; if you cannot upgrade, take the registry and router addresses from the Verified Contract Addresses page.

Solidity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
// strategy.ts - Aqua "Strategy template" reference flow.
// Ship an XYC strategy, quote + swap as a credentialed taker, then dock.
// Everything is broadcast through YOUR OWN RPC endpoint - never a hosted gateway.
//
//   pnpm add @1inch/aqua-sdk @1inch/swap-vm-sdk viem dotenv
//   pnpm add -D ts-node typescript
//   npx ts-node strategy.ts

import 'dotenv/config'
import {
  createWalletClient,
  createPublicClient,
  http,
  decodeFunctionResult,
  isHex,
  type Hex,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { mainnet } from 'viem/chains'

import {
  AquaProtocolContract,
  AQUA_CONTRACT_ADDRESSES,
  ShippedEvent,
  PushedEvent,
  PulledEvent,
  DockedEvent,
} from '@1inch/aqua-sdk'

import {
  AQUA_SWAP_VM_CONTRACT_ADDRESSES,
  SwapVMContract,
  SwappedEvent,
  AquaXYCAmmStrategy,
  Order,
  MakerTraits,
  TakerTraits,
  Address,
  NetworkEnum,
  ABI,
} from '@1inch/swap-vm-sdk'

// --- Config: your RPC + two funded keys (maker provides liquidity, taker swaps) ---
const RPC_URL = process.env.RPC_URL as string          // e.g. https://your-node.example/eth
const MAKER_PK = process.env.MAKER_PRIVATE_KEY as Hex
const TAKER_PK = process.env.TAKER_PRIVATE_KEY as Hex
if (!RPC_URL) throw new Error('Set RPC_URL to your own node')
if (!isHex(MAKER_PK) || !isHex(TAKER_PK)) throw new Error('Set MAKER_PRIVATE_KEY / TAKER_PRIVATE_KEY')

const chainId = NetworkEnum.ETHEREUM                    // 1
const WETH = new Address('0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2')
const USDC = new Address('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')
const KYC_NFT = new Address('0x26FFc7D378E8e49Be2c483295A3e3E511F96a468')

const AQUA_ADDR = AQUA_CONTRACT_ADDRESSES[chainId]      // registry (Address)
const aqua = new AquaProtocolContract(AQUA_ADDR)
const swapVm = new SwapVMContract(AQUA_SWAP_VM_CONTRACT_ADDRESSES[chainId]) // AquaSwapVMRouter

// --- Transport: your RPC only ---
const transport = http(RPC_URL)
const maker = privateKeyToAccount(MAKER_PK)
const taker = privateKeyToAccount(TAKER_PK)
const makerClient = createWalletClient({ account: maker, chain: mainnet, transport })
const takerClient = createWalletClient({ account: taker, chain: mainnet, transport })
const pub = createPublicClient({ chain: mainnet, transport })

const ERC20_APPROVE = [{
  type: 'function', name: 'approve', stateMutability: 'nonpayable',
  inputs: [{ name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }],
  outputs: [{ name: '', type: 'bool' }],
}] as const

async function main() {
  // 1) Build a credential-gated XYC strategy.
  //    withTxOriginAccessToken() prepends onlyTxOriginTokenBalanceNonZero(KYC_NFT):
  //    only takers whose tx.origin holds a KycNFT may swap against this liquidity.
  const program = AquaXYCAmmStrategy.new()
    .withTxOriginAccessToken(KYC_NFT)
    .build()

  const order = Order.new({
    maker: new Address(maker.address),
    program,
    traits: MakerTraits.default(),
  })

  const strategy = order.encode()                        // HexString == abi.encode(Order)
  const strategyHash = AquaProtocolContract.calculateStrategyHash(strategy)

  const usdcSeed = 15_000n * 10n ** 6n
  const wethSeed = 5n * 10n ** 18n

  // 2) Maker approves the Aqua registry (Aqua pulls straight from the maker wallet).
  await makerClient.writeContract({
    address: USDC.toString() as Hex, abi: ERC20_APPROVE,
    functionName: 'approve', args: [AQUA_ADDR.toString() as Hex, usdcSeed],
  })
  await makerClient.writeContract({
    address: WETH.toString() as Hex, abi: ERC20_APPROVE,
    functionName: 'approve', args: [AQUA_ADDR.toString() as Hex, wethSeed],
  })

  // 3) Ship: registers the strategy and its virtual balances. app == the router.
  const shipTx = aqua.ship({
    app: new Address(swapVm.address.toString()),
    strategy,
    amountsAndTokens: [
      { token: USDC, amount: usdcSeed },
      { token: WETH, amount: wethSeed },
    ],
  })
  const shipHash = await makerClient.sendTransaction({
    to: shipTx.to as Hex, data: shipTx.data as Hex, value: shipTx.value,
  })
  const shipRcpt = await pub.waitForTransactionReceipt({ hash: shipHash })
  for (const log of shipRcpt.logs) {
    tryParse(() => console.log('Shipped', ShippedEvent.fromLog(log)))
    tryParse(() => console.log('Pushed ', PushedEvent.fromLog(log)))
  }

  // 4) Taker approves the router for tokenIn, then quotes.
  //    The taker's tx.origin must hold a KycNFT or the guard reverts.
  await takerClient.writeContract({
    address: USDC.toString() as Hex, abi: ERC20_APPROVE,
    functionName: 'approve', args: [swapVm.address.toString() as Hex, usdcSeed],
  })

  const amountIn = 1_000n * 10n ** 6n                    // 1,000 USDC
  const swapParams = {
    order,
    tokenIn: USDC,
    tokenOut: WETH,
    amount: amountIn,
    takerTraits: TakerTraits.default(),
  }

  // quote is a simulation: eth_call, decode (amountIn, amountOut, orderHash).
  const q = swapVm.quote(swapParams)
  const sim = await pub.call({ account: taker.address, to: q.to as Hex, data: q.data as Hex })
  const [, quotedOut] = decodeFunctionResult({
    abi: ABI.SWAP_VM_ABI, functionName: 'quote', data: sim.data as Hex,
  }) as unknown as [bigint, bigint, Hex]
  console.log('quoted amountOut (WETH):', quotedOut)

  // 5) Swap: broadcast through your RPC.
  const s = swapVm.swap(swapParams)
  const swapHash = await takerClient.sendTransaction({
    to: s.to as Hex, data: s.data as Hex, value: s.value,
  })
  const swapRcpt = await pub.waitForTransactionReceipt({ hash: swapHash })
  for (const log of swapRcpt.logs) {
    tryParse(() => console.log('Pulled ', PulledEvent.fromLog(log)))
    tryParse(() => console.log('Pushed ', PushedEvent.fromLog(log)))
    tryParse(() => console.log('Swapped', SwappedEvent.fromLog(log)))
  }

  // 6) Dock: close the strategy for every shipped token.
  const dockTx = aqua.dock({
    app: new Address(swapVm.address.toString()),
    strategyHash,
    tokens: [USDC, WETH],
  })
  const dockHash = await makerClient.sendTransaction({
    to: dockTx.to as Hex, data: dockTx.data as Hex, value: dockTx.value,
  })
  const dockRcpt = await pub.waitForTransactionReceipt({ hash: dockHash })
  for (const log of dockRcpt.logs) {
    tryParse(() => console.log('Docked ', DockedEvent.fromLog(log)))
  }
}

function tryParse(fn: () => void) {
  try { fn() } catch { /* log is from a different event - skip */ }
}

main().catch((e) => { console.error(e); process.exit(1) })

The maker approves the Aqua registry (not the router) for its tokens, because Aqua.pull() calls transferFrom straight from the maker wallet. The taker approves the router for its input token. Getting these two approvals backwards is the most common first-run failure.

3. How to run

Foundry

Bash
1
2
3
# Install Foundry via the official installer at https://getfoundry.sh, then:
foundryup
forge test --fork-url $RPC_URL --match-contract StrategyTemplateTest -vvv

Required foundry.toml - the Aqua/SwapVM contracts compile with solc 0.8.30, the cancun EVM target, the optimizer, and viaIR enabled:

[profile.default]
solc = "0.8.30"
evm_version = "cancun"
via_ir = true
optimizer = true
optimizer_runs = 1000000
fs_permissions = [{ access = "read", path = "./" }]

Add forge-std with forge install foundry-rs/forge-std. --fork-url must point at a full archive-capable mainnet RPC that you control.

TypeScript

Bash
1
2
3
pnpm add @1inch/aqua-sdk @1inch/swap-vm-sdk viem dotenv
pnpm add -D ts-node typescript
npx ts-node strategy.ts

Provide a .env with your own endpoint and two funded keys:

RPC_URL=https://your-node.example/eth
MAKER_PRIVATE_KEY=0x...   # provides liquidity
TAKER_PRIVATE_KEY=0x...   # holds a KycNFT, swaps

Adapting the strategy

The AquaXYCAmmStrategy builder composes further instructions before the final xycSwapXD. Common adjustments:

  • Concentrated liquidity - AquaXYCAmmStrategy.newConcentrate({ rawPriceMin, rawPriceMax }) (price = tokenGt/tokenLt, scaled by 1e18).
  • LP swap fee - .withFeeTokenIn(bps) (the only non-zero fee in v1). Fees use the 1e9 base, so the builder's FlatFeeArgs.fromBps maps fee = bps * 100000 (10000 bps = 100%): .withFeeTokenIn(30) = 30 bps = 0.30% = 3000000.
  • MEV decay - .withDecayPeriod(seconds).
  • Permissionless pool - drop .withTxOriginAccessToken(...) (and the 0x21 0x14 ... prefix in Solidity).

Only opcodes in the deployed Aqua subset are executable on-chain today: Controls, XYCSwap, XYCConcentrate, Decay, Fee, PeggedSwap and Extruction. The SDK can encode the full Swap VM instruction set, but programs using instructions outside that subset will not run on current Aqua deployments.

Did you find what you need?