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
topic0hash; - 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:
- ship - the maker registers the strategy and its initial virtual balances with the Aqua registry (
0x1111113ccf1426a8e30e2bff5e005d929bf6a90a). EmitsShippedplus onePushedper token. - quote - a taker simulates the swap through the
AquaSwapVMRouter(0x111111338c5091e8440b67b168bae16a668ac0de) to read the output amount. - 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 emitsSwapped. - 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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
// 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 (AquaXYCAmmStrategy → Order.new → aqua.ship → swapVm.quote/swap → aqua.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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
// 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
123
# 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
123
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'sFlatFeeArgs.fromBpsmapsfee = bps * 100000(10000 bps = 100%):.withFeeTokenIn(30)= 30 bps = 0.30% =3000000. - MEV decay -
.withDecayPeriod(seconds). - Permissionless pool - drop
.withTxOriginAccessToken(...)(and the0x21 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.