This guide shows how to add custom pricing to 1inch Aqua, the shared liquidity layer, using its swap engine SwapVM. An AquaApp is an audited Solidity contract that owns pricing logic: it inherits AquaApp, registers strategies with ship() on the Aqua registry, and fills swaps by calling pull() and push().
Aqua is self-custodial. A maker's tokens stay in the maker's own wallet under a revocable, per-chain, per-token allowance, and move only when a taker fills a swap. pull() draws those tokens directly from the wallet and push() credits the maker; the registry holds 0 tokens, and the balances your app reads are internal virtual balances tracked in Aqua.sol. Standard smart-contract and approval risk still applies.
Three ways to author pricing
Aqua offers three authoring paths, from most to least custom. Pick the least custom one that expresses your pricing.
- Path A: write a custom AquaApp. A per-app, audited Solidity contract that owns pricing and calls
pull()/push()on the Aqua registry. Use this when your logic cannot be expressed with the built-in opcodes. This is the path covered in detail below. - Path B: compose SwapVM opcodes into a program. Assemble a strategy program from the opcodes already deployed on
AquaSwapVMRouter, with no new contract to write, audit, or deploy. Best when a constant-product, concentrated, or pegged curve already covers your needs. - Path C: embed proprietary pricing with Extruction. Keep an off-list or proprietary pricing formula in your own contract and reference it through
IExtruction, so you run inside the shared router without publishing a full AquaApp.
Deployed contracts, identical across all 13 supported chains: Aqua registry (balance accounting) 0x1111113ccf1426a8e30e2bff5e005d929bf6a90a; the SwapVM router AquaSwapVMRouter v1.0.2 0x111111338c5091e8440b67b168bae16a668ac0de; taker credential KycNFT 0x26FFc7D378E8e49Be2c483295A3e3E511F96a468. Aqua is audited but new; audits reduce, they do not eliminate, smart-contract risk.
Path A: write a custom AquaApp
1. Inherit from the AquaApp contract
Solidity
123456789101112
contract MyAMM is AquaApp {
constructor(IAqua aqua) AquaApp(aqua) {}
struct Strategy {
address maker; // required — makes strategyHash unique per maker
address token0;
address token1;
// ... strategy parameters (IMMUTABLE once shipped)
uint256 feeBps; // optional - strategy fees
bytes32 salt; // optional — allows multiple strategies per (maker, pair, fee)
}
}
The maker field in the strategy struct is required. Without it, two different makers using identical parameters would produce the same strategyHash, resulting in a collision in the registry.
2. Choose a swap pattern
Two patterns exist for handling the input token. The choice is a trade-off between gas efficiency and implementation complexity.
Pattern A: callback (recommended)
Uses the IXYCSwapCallback interface (from 1inch/aqua examples/apps/interfaces/). The taker implements the callback, which fires after pull(). Requires nonReentrantStrategy on the swap function.
Solidity
1234567891011121314151617181920212223242526272829303132333435
function swapExactIn(
Strategy calldata strategy,
bool isZeroForOne,
uint256 amountIn,
uint256 amountOutMin,
address to, // recipient of the output tokens
bytes calldata takerData // forwarded to the taker callback
)
external
nonReentrantStrategy(strategy.maker, keccak256(abi.encode(strategy)))
returns (uint256 amountOut)
{
bytes32 strategyHash = keccak256(abi.encode(strategy));
address tokenIn = isZeroForOne ? strategy.token0 : strategy.token1;
address tokenOut = isZeroForOne ? strategy.token1 : strategy.token0;
(uint256 balanceIn, uint256 balanceOut) = AQUA.safeBalances(strategy.maker, address(this), strategyHash, tokenIn, tokenOut);
amountOut = // ... compute output amount based on AMM logic
require(amountOut >= amountOutMin, "insufficient output");
uint256 expectedBalanceIn = balanceIn + amountIn;
// Pull output tokens to the recipient (SWAP EXECUTION)
AQUA.pull(strategy.maker, strategyHash, tokenOut, amountOut, to);
// Callback: the taker pushes tokenIn to the maker's Aqua balance
IXYCSwapCallback(msg.sender).xycSwapCallback(
tokenIn, tokenOut, amountIn, amountOut,
strategy.maker, address(this), strategyHash, takerData
);
// Verify input received (SWAP EXECUTION)
_safeCheckAquaPush(strategy.maker, strategyHash, tokenIn, expectedBalanceIn);
}
_safeCheckAquaPush reads the virtual balance after the callback and reverts if tokenIn balance did not reach expectedBalanceIn. The nonReentrantStrategy lock is what makes this check safe: it prevents a nested pull() from inflating the balance before the check.
Pattern B: direct transfer (simpler)
The taker transfers tokenIn to the app contract directly, which then calls push(). No reentrancy protection required, but costs one extra transferFrom.
Solidity
1234567891011121314151617181920212223
function swapExactIn(
Strategy calldata strategy,
bool isZeroForOne,
uint256 amountIn,
address to // recipient of the output tokens
) external returns (uint256 amountOut) {
bytes32 strategyHash = keccak256(abi.encode(strategy));
address tokenIn = isZeroForOne ? strategy.token0 : strategy.token1;
address tokenOut = isZeroForOne ? strategy.token1 : strategy.token0;
(uint256 balanceIn, uint256 balanceOut) = AQUA.safeBalances(strategy.maker, address(this), strategyHash, tokenIn, tokenOut);
amountOut = // ... compute output amount based on AMM logic
// Pull output tokens to the recipient (SWAP EXECUTION)
AQUA.pull(strategy.maker, strategyHash, tokenOut, amountOut, to);
// Transfer input tokens from taker and push to Aqua (SWAP EXECUTION)
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
IERC20(tokenIn).approve(address(AQUA), amountIn);
AQUA.push(strategy.maker, address(this), strategyHash, tokenIn, amountIn);
}
3. Implement the taker callback (Pattern A only)
Takers interacting with a callback-based app must implement the callback interface (IXYCSwapCallback) and call push() inside it:
Solidity
12345678910111213141516171819202122232425262728293031323334353637383940
contract SimpleTrader is IXYCSwapCallback {
IAqua public immutable AQUA;
constructor(IAqua _aqua, IERC20[] memory tokens) {
AQUA = _aqua;
for (uint256 i = 0; i < tokens.length; i++) {
tokens[i].approve(address(AQUA), type(uint256).max);
}
}
function swap(
MyAMM app,
MyAMM.Strategy calldata strategy,
bool zeroForOne,
uint256 amountIn
) external {
app.swapExactIn(
strategy,
zeroForOne,
amountIn,
0, // amountOutMin (calculate properly in production)
msg.sender, // recipient
"" // takerData
);
}
function xycSwapCallback(
address tokenIn,
address, // tokenOut
uint256 amountIn,
uint256, // amountOut
address maker,
address app,
bytes32 strategyHash,
bytes calldata
) external override {
// Transfer input tokens to complete swap (SWAP EXECUTION ONLY)
AQUA.push(maker, app, strategyHash, tokenIn, amountIn);
}
}
4. Query balances
Always use safeBalances() before computing swap output. It reverts if either token was not registered in the strategy at ship() time, preventing pricing errors on misconfigured or inactive strategies.
Solidity
12345678
(uint256 balanceIn, uint256 balanceOut) = AQUA.safeBalances(
strategy.maker,
address(this),
strategyHash,
tokenIn,
tokenOut
);
// Reverts if tokenIn or tokenOut is not part of the active strategy
Use rawBalances() only for debugging or when querying arbitrary storage slots.
Pattern comparison
| Callback (A) | Direct Transfer (B) | |
|---|---|---|
| Reentrancy protection needed | Yes (nonReentrantStrategy) |
No |
| Taker must implement callback | Yes | No |
| Gas | Lower (one transferFrom) |
Higher (two transferFrom) |
| Taker approval target | AQUA |
App contract |
Path B: compose SwapVM opcodes into a program
If your pricing fits an existing curve, you do not need to deploy a contract at all. Compose a strategy program from the opcodes already available on AquaSwapVMRouter (the deployed SwapVM router) and register it with ship(). The router owns pricing, and the type is set by the program bytes rather than by a separate contract address, so one router serves every strategy type.
The deployed Aqua opcode set is Controls, XYCSwap, XYCConcentrate, Decay, Fee, PeggedSwap, and Extruction. (Invalidators and SeriesEpochManager belong to the limit-order router, not to Aqua.) The strategy types are xyc (constant product), concentrated (concentrated liquidity), and pegged (stable or reward-bearing). v1.0 ships two-token strategies only.
Build programs with @1inch/swap-vm-sdk (SwapVM program builders and opcode encoding) and write strategies with @1inch/aqua-sdk (ship / dock and event decoders). Because a program encodes opcode numbers, each SDK version targets a specific router opcode table, so a strategy's bytecode is not portable across router versions.
Path C: embed proprietary pricing with Extruction
Use Extruction when your pricing formula is proprietary or otherwise not expressible with the built-in opcodes, but you still want to run inside the shared router instead of shipping a full AquaApp. The Extruction opcode calls out to an external contract that you supply through the IExtruction interface, which returns the quote for the swap.
Two requirements are mandatory:
- The external target must be non-upgradeable. A mutable pricing target would let the quote change out from under takers and would break the guarantees established by the audit.
quote()andswap()must return identical amounts. The staticquote()used for simulation has to match whatswap()actually executes. Any divergence between the two is a critical bug.
Note the spelling: the opcode and interface are Extruction / IExtruction (not "extraction").
Related
- Smart Contract —
pull(),push(),safeBalances()full reference - Liquidity provider & taker guide — how makers ship / dock and takers fill swaps in Solidity
- Strategy Lifecycle — ship / dock / pull / push state machine
- Patterns & decision tree — recipes for AMMs, limit orders, Dutch auctions
Recipe: a contract as the maker (a composed multi-strategy vault)
A maker does not have to be an EOA. A contract — a vault that holds pooled inventory — can be the maker and ship several strategies from one balance. Every registry entry point keys off either msg.sender (ship, dock) or an explicit maker argument (pull, push), so a contract becomes the maker simply by calling ship() itself. Its own on-chain token balance then backs everything it ships.
This is a recipe, not a full contract. It shows the wiring a custody vault needs; pricing still lives in the app (a custom AquaApp, or the deployed AquaSwapVMRouter when you compose opcodes).
How it fits together
- The vault holds inventory (ERC20) in its own address.
- The vault approves the Aqua registry (not the
app) for each token it will ship. - The vault calls
ship()once per strategy. Becauseshiprecordsmsg.senderas the maker, the vault is the maker of each one — for example anxycpair and apeggedpair, both against the sameapp, both drawing on the same balance. - On a fill, the
appcallspull(vault, strategyHash, token, amount, to), which runssafeTransferFrom(vault → taker); the taker side settles withpush(vault, app, strategyHash, token, amount), which runssafeTransferFrom(app → vault).
ship() moves no tokens. It only writes a virtual balance (an allowance) into the registry and emits Shipped / Pushed events. Real inventory never leaves the vault until a taker fills and pull() transfers it out. Shipping a strategy is therefore pure bookkeeping plus the one-time approval above.
One approval, one balance: strategies share the vault's inventory
Each strategy gets its own virtual-balance slot, keyed by (maker=vault, app, strategyHash, token). But those slots are accounting only. When pull() fires it runs IERC20(token).safeTransferFrom(vault, to, amount) against the vault's single real ERC20 balance, under the single approval the vault granted the registry. The per-strategy numbers are independent; the tokens behind them are not.
The consequence is the shared-liquidity behaviour of Aqua, applied inside one maker: a fill against one strategy reduces the real inventory available to all the others. If the vault ships an xyc pair holding 100k USDC of virtual balance and a pegged pair holding another 100k USDC of virtual balance, but only holds 120k USDC for real, the two strategies are competing for the same 120k. A large fill on the xyc strategy leaves less than the pegged strategy's virtual balance claims.
| Per strategy (virtual) | Across all strategies (real) | |
|---|---|---|
| Where it lives | Registry slot per strategyHash |
Vault's ERC20 balance + approval to the registry |
| Changed by | ship / pull / push arithmetic |
Actual token transfers at pull / push |
| Binding at fill time | safeBalances gates pricing |
safeTransferFrom reverts if inventory is short |
Do not let the sum of virtual balances silently exceed real holdings unless you intend the overlap. When it does, some fills will revert on safeTransferFrom (insufficient balance) even though safeBalances() still reports room on that strategy. Either keep total virtual balance at or below real inventory, or actively monitor the vault's token balance and re-price / dock as it drains.
strategyHash is per (maker, app, encoded strategy)
The registry computes strategyHash = keccak256(strategy) over the encoded strategy bytes, and stores balances at (maker, app, strategyHash, token). Within one vault the maker is fixed, so two strategies must encode to distinct bytes — otherwise they hash to the same slot and the second ship() reverts with StrategiesMustBeImmutable (the slot is already occupied and strategies are immutable once shipped). Give each strategy distinct parameters, or add a salt, so the xyc pair and the pegged pair land on separate slots.
What the vault must implement
- Approval, targeting the registry. The vault must
approvethe Aqua registry address for every token it ships, sized to cover its pulls.pull()transfers from the maker, so without this approval every fill reverts. Approve the registry, never theapp. - Receiving.
push()credits the vault viasafeTransferFrom(app → vault). The vault only needs to hold the ERC20 and must not block incoming transfers (no reverting transfer hook). Aqua settles in ERC20 viasafeTransferFromonly, so no payablereceive()is required for the swap path. - Maker actions run as the caller.
ship()anddock()recordmsg.senderas the maker, so the vault itself must call them — typically through owner-gated functions that forward toAQUA.ship/AQUA.dock.dock()must list every token of the strategy or it reverts withDockingShouldCloseAllTokens.
Solidity
12345678910111213141516171819202122232425
// Sketch, not a full contract. Custody + maker plumbing only; pricing lives in `app`.
contract InventoryVault {
IAqua public immutable AQUA; // the Aqua registry
address public immutable app; // AquaApp or AquaSwapVMRouter that prices the strategies
// One approval backs every strategy: lets pull() do transferFrom(vault -> taker).
function approveRegistry(IERC20 token, uint256 amount) external onlyOwner {
token.approve(address(AQUA), amount);
}
// Ship a strategy from this vault's balance; msg.sender == this vault == maker.
// Call once for the xyc pair and once for the pegged pair, with distinct strategy bytes.
function shipStrategy(bytes calldata strategy, address[] calldata tokens, uint256[] calldata amounts)
external onlyOwner returns (bytes32 strategyHash)
{
strategyHash = AQUA.ship(app, strategy, tokens, amounts);
}
// Revoke a strategy; must list all of its tokens.
function dockStrategy(bytes32 strategyHash, address[] calldata tokens) external onlyOwner {
AQUA.dock(app, strategyHash, tokens);
}
// push() credits this contract by ERC20 transfer; holding ERC20 needs no receiver hook.
}
Custody stays self-directed. The vault's tokens sit in the vault under a revocable approval; dock() zeroes a strategy's virtual balances and the vault can revoke the token approval at any time. Standard smart-contract and approval risk applies to the vault itself — it is now the maker of record for each strategy it ships.
Runnable Path A: custom AquaApp end-to-end
This section turns the Path A description above into a single, self-contained Foundry test. It deploys a custom AquaApp (MyAMM), has a maker ship() a strategy against it, and has a taker (SimpleTrader) fill through the IXYCSwapCallback path — asserting the registry events that prove the swap. MyAMM reuses the audited XYCSwap constant-product curve, so it already exposes swapExactIn, the nonReentrantStrategy lock, AQUA.safeBalances / AQUA.pull, the callback, and _safeCheckAquaPush — the exact surface documented in Path A.
In Path A the strategy is registered with APP = your AquaApp's own address. The maker calls AQUA.ship(address(myAMM), …), and every balance row is keyed by (maker, myAMM, strategyHash, token). This is the one structural difference from Path B, where APP is the shared AquaSwapVMRouter. Because pull() authorises against msg.sender, and MyAMM is the caller, the registry resolves the pull against that same MyAMM key. A custom app therefore never routes through the SwapVM router.
The custom AquaApp (MyAMM) and the taker
Both contracts are tiny. MyAMM inherits the audited example curve; SimpleTrader is the callback taker from section 3 — it approves the registry once, then pushes tokenIn from inside xycSwapCallback.
Solidity
12345678910111213141516171819202122232425262728293031323334353637383940414243
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IAqua } from "aqua/src/interfaces/IAqua.sol";
import { XYCSwap } from "aqua/examples/apps/XYCSwap.sol";
import { IXYCSwapCallback } from "aqua/examples/apps/interfaces/IXYCSwapCallback.sol";
// A custom AquaApp. It reuses the audited XYCSwap constant-product curve, so it
// exposes swapExactIn(...), nonReentrantStrategy, AQUA.safeBalances / AQUA.pull,
// the IXYCSwapCallback path and _safeCheckAquaPush out of the box.
contract MyAMM is XYCSwap {
constructor(IAqua aqua) XYCSwap(aqua) {}
}
// A taker that fills through the callback. It approves the Aqua registry (not the
// app) so push() can transferFrom it during the callback.
contract SimpleTrader is IXYCSwapCallback {
IAqua public immutable AQUA;
constructor(IAqua aqua, IERC20[] memory tokens) {
AQUA = aqua;
for (uint256 i = 0; i < tokens.length; i++) {
tokens[i].approve(address(aqua), type(uint256).max);
}
}
function swap(MyAMM app, XYCSwap.Strategy calldata s, bool zeroForOne, uint256 amountIn)
external
returns (uint256)
{
// recipient = msg.sender (the EOA calling this trader)
return app.swapExactIn(s, zeroForOne, amountIn, 0, msg.sender, "");
}
function xycSwapCallback(
address tokenIn, address /* tokenOut */, uint256 amountIn, uint256 /* amountOut */,
address maker, address app, bytes32 strategyHash, bytes calldata /* takerData */
) external override {
// Deliver the input leg into the maker's Aqua balance.
AQUA.push(maker, app, strategyHash, tokenIn, amountIn);
}
}
The end-to-end Foundry test
The test deploys a fresh Aqua registry locally (its constructor takes no arguments), funds the maker, and drives ship → fill. On mainnet the same flow targets the deployed registry at 0x1111113ccf1426a8e30e2bff5e005d929bf6a90a, identical across all 13 chains.
Solidity
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import { Test } from "forge-std/Test.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Aqua } from "aqua/src/Aqua.sol";
import { IAqua } from "aqua/src/interfaces/IAqua.sol";
import { XYCSwap } from "aqua/examples/apps/XYCSwap.sol";
// MyAMM and SimpleTrader from the block above.
contract TestToken is ERC20 {
constructor(string memory n, string memory s) ERC20(n, s) {}
function mint(address to, uint256 a) external { _mint(to, a); }
}
contract RunnablePathATest is Test {
Aqua aqua;
MyAMM myAMM;
TestToken token0;
TestToken token1;
SimpleTrader trader;
address maker = makeAddr("maker");
address taker = makeAddr("taker");
XYCSwap.Strategy strategy;
bytes32 strategyHash;
function setUp() public {
aqua = new Aqua();
myAMM = new MyAMM(IAqua(address(aqua)));
token0 = new TestToken("T0", "T0");
token1 = new TestToken("T1", "T1");
// Maker owns the liquidity and approves the Aqua REGISTRY (never the app).
// Tokens stay in the maker wallet; ship() only records a virtual balance.
token0.mint(maker, 1_000 ether);
token1.mint(maker, 1_000 ether);
vm.startPrank(maker);
token0.approve(address(aqua), type(uint256).max);
token1.approve(address(aqua), type(uint256).max);
vm.stopPrank();
// The taker contract approves the registry so push() can pull tokenIn from it.
IERC20[] memory toApprove = new IERC20[](2);
toApprove[0] = IERC20(address(token0));
toApprove[1] = IERC20(address(token1));
vm.prank(taker);
trader = new SimpleTrader(IAqua(address(aqua)), toApprove);
token0.mint(address(trader), 10 ether); // input inventory for the fill
// maker is part of the struct, so the strategyHash is unique per maker.
strategy = XYCSwap.Strategy({
maker: maker,
token0: address(token0),
token1: address(token1),
feeBps: 30, // 0.30%
salt: bytes32(0)
});
// MyAMM computes keccak256(abi.encode(strategy)); the registry computes
// keccak256(strategy-bytes). They match only if abi.encode(strategy) is shipped.
strategyHash = keccak256(abi.encode(strategy));
}
function test_pathA_end_to_end() public {
// 1) SHIP: maker registers the strategy against MyAMM. APP == address(myAMM).
address[] memory tokens = new address[](2);
tokens[0] = address(token0);
tokens[1] = address(token1);
uint256[] memory amounts = new uint256[](2);
amounts[0] = 1_000 ether; // initial virtual balance of token0
amounts[1] = 1_000 ether; // initial virtual balance of token1
// ship() emits Shipped + one Pushed per funded token.
vm.expectEmit(address(aqua));
emit IAqua.Shipped(maker, address(myAMM), strategyHash, abi.encode(strategy));
vm.expectEmit(address(aqua));
emit IAqua.Pushed(maker, address(myAMM), strategyHash, address(token0), 1_000 ether);
vm.expectEmit(address(aqua));
emit IAqua.Pushed(maker, address(myAMM), strategyHash, address(token1), 1_000 ether);
vm.prank(maker);
bytes32 shipped = aqua.ship(address(myAMM), abi.encode(strategy), tokens, amounts);
assertEq(shipped, strategyHash);
// 2) FILL: token0 -> token1 through the callback. Quote first for the assert.
uint256 amountIn = 10 ether;
uint256 expectedOut = myAMM.quoteExactIn(strategy, true, amountIn);
// There is no single "Swapped" event. The fill is the Pulled(tokenOut) +
// Pushed(tokenIn) pair: token1 leaves the maker, token0 returns.
vm.expectEmit(address(aqua));
emit IAqua.Pulled(maker, address(myAMM), strategyHash, address(token1), expectedOut);
vm.expectEmit(address(aqua));
emit IAqua.Pushed(maker, address(myAMM), strategyHash, address(token0), amountIn);
vm.prank(taker);
uint256 amountOut = trader.swap(myAMM, strategy, true, amountIn);
// 3) ASSERT: recipient received tokenOut; virtual balances moved by the legs.
assertEq(amountOut, expectedOut);
assertEq(token1.balanceOf(taker), expectedOut);
(uint248 bal0,) = aqua.rawBalances(maker, address(myAMM), strategyHash, address(token0));
(uint248 bal1,) = aqua.rawBalances(maker, address(myAMM), strategyHash, address(token1));
assertEq(bal0, uint248(1_000 ether + amountIn)); // pushed in
assertEq(bal1, uint248(1_000 ether - expectedOut)); // pulled out
}
}
Ship the exact bytes. The registry stores the strategy under keccak256(strategy) where strategy is the raw calldata you pass to ship(). MyAMM recomputes keccak256(abi.encode(strategy)) inside every call. The two hashes agree only when the maker ships precisely abi.encode(strategy). Ship any other encoding and safeBalances reads an unregistered row and reverts with SafeBalancesForTokenNotInActiveStrategy, so the fill fails before any transfer.
What the events prove
Aqua has no aggregate Swapped event; the registry records the maker's fund movements and the app returns the amounts. The table maps each lifecycle step to the events the test asserts. Every event is emitted by the Aqua registry.
| Step | Call | Registry event(s) |
|---|---|---|
| Register | maker → AQUA.ship(myAMM, …) |
Shipped + one Pushed per funded token (initial balance) |
| Fill, output leg | MyAMM → AQUA.pull(…, tokenOut, …, to) |
Pulled (tokenOut leaves the maker wallet to the recipient) |
| Fill, input leg | SimpleTrader → AQUA.push(…, tokenIn, …) in callback |
Pushed (tokenIn returns to the maker balance) |
| Settlement check | MyAMM._safeCheckAquaPush(…) |
none — reverts with MissingTakerAquaPush if the input never arrived |
The nonReentrantStrategy(maker, strategyHash) lock is what makes the post-callback _safeCheckAquaPush trustworthy: it blocks a nested pull() from inflating the input balance before the check reads it. That guarantee is why the callback pattern is safe with a single transferFrom.