Access in 1inch Aqua is enforced at four surfaces with two independent levers: makers are open by design everywhere, and takers are allow-listed by design at launch. This page consolidates the model; see Controls and Conditional Access for the opcode-level detail.
The four-surface access model
| Surface | Makers (liquidity providers) | Takers (swappers) |
|---|---|---|
| Smart contracts | Permissionless: ship()/dock(), no pause |
Default-deny via _onlyTxOriginTokenBalanceNonZero on router v1.0.1; per-order _whitelistSingleTaker/_whitelistMultipleTakers exist in source but are not registered on the deployed Aqua router |
| Pathfinder (routing) | Off-chain maker blacklist (routing exclusion, never custodial) | KYC-routing gate: routes only KYC-opcode Aqua strategies |
| dApp | Open behind the per-chain enable-aqua flag |
Assembler attaches withTxOriginAccessToken(aquaKycToken) to every strategy; sanctioned-address screening |
| Compliance / onboarding | Never KYC'd | KYB-gated KycNFT minting; sanctioned-wallet deny-list |
The contract gate
The deployed launch gate is the Controls opcode _onlyTxOriginTokenBalanceNonZero(token), which reverts TxOriginTokenBalanceIsZero unless balanceOf(tx.origin) > 0. Because it reads tx.origin, only a swap sent directly from a credential-holding EOA passes — smart-contract wallets, multisigs and ERC-4337 bundlers cannot satisfy it. It is evaluated at swap time, not ship time: a strategy can be live yet untradeable until a permitted taker holds the credential.
Resolvers are the permitted takers
At launch the permitted takers are 1inch Resolvers: KYB-verified firms who accept the taker role. A KycNFT (symbol RES, also called the Aqua resolver NFT, reusing the Fusion KycNFT.sol design) is minted to each resolver's operator EOA. The credential is one-per-address, soulbound, per chain, and must sit on the tx.origin EOA that originates the swap. A chain's enable-aqua flag flips only once at least two verified resolvers hold credentials there. Revocation is by burn.
Launch status. Aqua is live and routable: permitted takers hold KycNFT credentials and fills execute on-chain. A KycNFT must currently be requested from 1inch by emailing csm@1inch.com — see How to request the Aqua resolver NFT for what to include.
Pathfinder routing
Pathfinder is the 1inch routing engine that discovers Aqua liquidity off-chain and executes it on-chain. Two access controls live here: an off-chain maker blacklist (routing exclusion, non-custodial, fail-closed) and a KYC-routing gate that routes only KYC-opcode Aqua strategies and blocks Classic-Swap routing through Aqua. This routing is part of the 1inch routing engine, separate from the Aqua contracts.
Can I take? Becoming a resolver
At launch, external solvers and takers cannot fill Aqua strategies directly: every dApp strategy is gated to holders of the KycNFT credential, checked on tx.origin. Permitted takers are KYB-verified 1inch Resolvers, and the credential must sit on the operator EOA that originates the swap (a smart-contract/settlement wallet cannot pass a tx.origin check).
On-chain reality: the swap reverts
TxOriginTokenBalanceIsZerounlessbalanceOf(tx.origin) > 0of the per-chainKycNFT.Who qualifies: the taker role is offered to KYB-verified 1inch Resolvers; the credential is one-per-EOA, soulbound, per chain, and revocable by burn.
Onboarding: Aqua taker access is not yet part of the self-service Resolver onboarding flow (which covers Limit Order, Fusion, and Fusion+ credentials). Request it by email as described below.
How to request the Aqua resolver NFT
Aqua taker credentials are granted case by case.
Step 1 — become a 1inch resolver. If your firm is not yet onboarded, register a 1inch Business Portal account, select Resolver among your business segments, and complete the self-service Resolver onboarding flow — it covers your company details, KYB verification, and the resolver compliance survey.
Step 2 — request Aqua access. Email csm@1inch.com with the subject "Aqua resolver NFT request" and include:
Years in operation and company website
Contact person — name, role, and business email
Track record — the trading or solver activity you run today: venues, roles (e.g. resolver/solver/filler on 1inch Fusion, CoW Protocol, UniswapX, Across), and indicative monthly volumes
Chains and inventory — which of the 13 Aqua chains you plan to fill on and the indicative inventory you will quote with
Operator EOA address(es) the credential should be minted to — one NFT per address, per chain. The address must be an EOA that is the transaction origin (
tx.origin) of your fills; a smart-contract wallet, multisig, or 4337 bundler cannot pass the gate.Key management — how the operator EOA keys are secured (e.g. HSM, MPC, custody provider)
After manual review, wallet addresses are screened and — once approved — the Aqua resolver NFT is minted to your operator EOA on the agreed chains. It is soulbound, one per address, per chain, and revocable by burn.
Already a 1inch resolver? Skip step 1 and go straight to the email — Aqua is enabled per organization only after manual review. The self-service Resolver profile covers additional Limit Order, Fusion, and Fusion+ contracts, not Aqua; once Aqua is enabled for your organization, the Aqua section appears there as well.
Concurrency & inventory-safety
Aqua does not custody funds. A shipped strategy is virtual accounting, not an escrow, so both makers and takers must reason about the state of the world between the moment they read a strategy and the moment a swap actually settles. This section covers the two races that follow from that: makers over-committing shared inventory, and takers acting on a stale quote.
Shared inventory & double-commitment (maker side)
When a maker calls ship(), Aqua records per-token amounts in the mapping _balances[maker][app][strategyHash][token] and moves no tokens — the source comment calls these entries “makers’ allowances”. The inventory stays in the maker’s own wallet. It only leaves the wallet at swap time, when the router calls pull(), which executes IERC20(token).safeTransferFrom(maker, to, amount) and decrements the virtual balance.
Consequence. The virtual balance is a bookkeeping ceiling, not a set-aside reserve. The same tokens sitting in the maker wallet simultaneously back every strategyHash they were shipped under, as well as any Fusion or classic limit orders the maker has open against that wallet. Nothing on-chain prevents the sum of these commitments from exceeding the wallet’s actual balance or allowance.
The result is a first-fill-wins race. Each virtual balance decrements independently, but they all draw from one pool of real tokens. Whichever swap reaches safeTransferFrom first succeeds; a competing fill that arrives after the wallet has been drained reverts inside the ERC-20 transfer (insufficient balance or allowance), even though its Aqua virtual balance still looked sufficient a block earlier.
| State | What it reflects | Where it lives |
|---|---|---|
| Virtual balance | What a strategy is allowed to swap | _balances[...][strategyHash][token] in Aqua |
| Real inventory | What can actually settle right now | balanceOf(maker) + allowance to Aqua, in the maker wallet |
Operators must reserve or segregate inventory themselves. If you run multiple Aqua strategies, or mix Aqua with Fusion/limit orders, out of one wallet, treat the wallet balance as the true budget and keep the sum of live commitments below it. Options: hold distinct inventory per strategy in separate wallets, cap the shipped amounts so overlapping strategies cannot collectively overshoot, or actively re-ship / dock() as real inventory moves. Aqua will not do this for you.
Quote-to-fill safety (taker side)
A taker’s quote() and the eventual swap() are separate transactions, and the strategy’s output can change between them. Both entry points seed the VM from the same call — AQUA.safeBalances(order.maker, address(this), orderHash, tokenIn, tokenOut) — so anything that alters that state between your quote and your fill alters the price you get:
The maker can walk away. A maker may call
dock()on the strategy at any time; it stamps the tokens_DOCKED, after whichsafeBalancesrevertsSafeBalancesForTokenNotInActiveStrategy. A strategy you quoted can be gone by the time you submit.Other takers move the balances. Concurrent fills push and pull against the same
strategyHash, sobalanceIn/balanceOutare different when you settle than when you quoted.The curve is state- and time-dependent. Price-shaping opcodes make the divergence intrinsic, not incidental:
Decaymoves the rate as a function of time, andExtructionshapes it from the current balances. Even with no competing fills, quoting early and settling late gives a differentamountOut.
The protocol gives takers two enforcement knobs in TakerTraits, both checked in TakerTraitsLib.validate() after the VM runs:
| Knob | Field | On-chain check |
|---|---|---|
| Slippage bound | threshold (32 bytes) |
exact-in: reverts TakerTraitsInsufficientMinOutputAmount unless amountOut >= threshold; exact-out: reverts TakerTraitsExceedingMaxInputAmount unless amountIn <= threshold |
| Time bound | deadline (uint40) |
reverts TakerTraitsDeadlineExpired when deadline != 0 && block.timestamp > deadline |
Do not commit on a stale quote. Set a conservative threshold (a genuine min-out / max-in, not the raw quoted number) so a moved curve reverts instead of filling at a worse rate; set a tight deadline so a delayed transaction expires rather than settling against a decayed price; and re-run quote() (static) immediately before submitting to confirm the strategy is still active and the numbers still hold. An empty threshold or a zero deadline disables that check entirely.
Resolver settlement worked example
This section walks a single fill end to end from the resolver (taker) perspective against the deployed AquaSwapVMRouter v1.0.1. It is written for a solver calling the contracts directly; the same on-chain path is what the 1inch resolver network executes under the hood. Everything below is the deployed v1.0.1 behaviour of SwapVM.swap(...) and TakerTraitsLib — no off-chain service is involved in settlement.
Scope: Aqua does no discovery or matching. The protocol prices and settles one maker strategy against one taker per swap() call. It performs no off-chain price discovery, no CoW/order matching, and no multi-order aggregation. Finding which strategy to fill, at what size, and against what counter-flow is the job of the resolver network (when swapping through the 1inch dApp) or your own solver (when calling the contracts directly). Hosted resolver/discovery APIs are a 1inch Business product and are out of scope here — see 1inch Business.
Preconditions the resolver must satisfy
Take a shipped Aqua strategy that quotes WETH out for USDC in (the maker holds WETH inventory in Aqua; tokenIn = USDC flows to the maker, tokenOut = WETH flows to the taker). Before the fill can succeed:
The resolver's operator EOA holds the per-chain
KycNFT(symbolRES).The
swap()transaction is sent directly from that EOA. The strategy program embeds the Controls check_onlyTxOriginTokenBalanceNonZero(KycNFT), which revertsTxOriginTokenBalanceIsZerounlessbalanceOf(tx.origin) > 0. Because it readstx.origin, a smart-contract settlement wallet, multisig or 4337 bundler in the call path does not satisfy the gate — the credentialed EOA must be the transaction origin.The resolver has (or sources) the
tokenInit will push, and approves the router to move it.
The call: the 5-arg swap
On v1.0.1 both quote and swap take five arguments. Pricing is done first with a static call to quote (same signature, no state change), then the fill is sent to swap:
Solidity
1234567
function swap(
ISwapVM.Order calldata order, // maker strategy: { maker, traits, data }; data = hooks || program
address tokenIn, // token the taker pays (USDC)
address tokenOut, // token the taker receives (WETH)
uint256 amount, // exact-in: amount of tokenIn; exact-out: amount of tokenOut
bytes calldata takerTraitsAndData
) external returns (uint256 amountIn, uint256 amountOut, bytes32 orderHash);
The resolver builds takerTraitsAndData with TakerTraitsLib.build(Args). For an Aqua fill the resolver sets, at minimum:
Solidity
1234567891011121314
TakerTraitsLib.Args({
taker: resolverEOA,
isExactIn: true, // sell an exact amount of USDC
shouldUnwrapWeth: false, // MUST be false: unwrap is incompatible with Aqua
isStrictThresholdAmount: false, // treat threshold as a minimum, not an exact match
isFirstTransferFromTaker: false, // pull maker output first, then push taker input
useTransferFromAndAquaPush: true, // router does transferFrom(taker) then AQUA.push(maker)
threshold: abi.encode(minWethOut), // 32-byte min amountOut (slippage floor)
to: address(0), // 0 => proceeds go to the taker EOA
deadline: ..., // 0 = no deadline, else unix ts
// hook / callback fields empty for a plain fill
instructionsArgs: "",
signature: "" // empty: Aqua strategies use useAquaInsteadOfSignature
})
The first 22 bytes of the packed blob are the traits header (TakerTraitsLib.parse splits header from tail); the USE_TRANSFER_FROM_AND_AQUA_PUSH_FLAG is bit 0x0040.
What the router does, in order
Hash & lock. Computes
orderHash = hash(order)and takes a per-order reentrancy lock. For Aqua strategieshashiskeccak256(abi.encode(order))(no EIP-712 signature branch).Parse taker traits.
TakerTraitsLib.parseyieldstakerTraitsandtakerData;isExactInis read from the header.Load maker balances. Because the strategy sets
useAquaInsteadOfSignature, the router reads the maker's virtual balances withAQUA.safeBalances(maker, router, orderHash, tokenIn, tokenOut)instead of verifying a signature.Run the program.
runLoop()executes the strategy's opcodes (the AMM curve, any Fee opcode, thetx.originaccess check, etc.) and computes(amountIn, amountOut).Validate.
order.traits.validate(...)thentakerTraits.validate(...):amountOut > 0; for exact-in the passedamountmust equalamountInandamountOut >= minWethOut(or exact ifisStrictThresholdAmount); the deadline, if set, must not have passed.Settle, ordered by
isFirstTransferFromTaker. With the flagfalsethe router runs_transferOutthen_transferIn:Maker output (pull):
_transferOutcallsAQUA.pull(maker, orderHash, tokenOut, amountOut, to), movingamountOutWETH out of the maker's Aqua balance to the recipient (to, defaulting to the taker EOA).Taker input (push): because
useTransferFromAndAquaPushis set,_transferIndoesIERC20(tokenIn).safeTransferFrom(taker, router, amountIn),forceApprove(AQUA, amountIn), thenAQUA.push(maker, router, orderHash, tokenIn, amountIn), crediting the maker's USDC balance. (With the flag off, the router instead only checks that the maker's raw Aqua balance already grew by the required amount — i.e. the taker pushed inventory itself.)
Unlock & emit. Releases the lock and emits
Swapped(orderHash, maker, taker, tokenIn, tokenOut, amountIn, amountOut).
Aqua strategy constraints enforced at settlement. For useAquaInsteadOfSignature orders the router requires shouldUnwrapWeth == false (reverts MakerTraitsUnwrapIsIncompatibleWithAqua) and the maker to be its own receiver (reverts MakerTraitsCustomReceiverIsIncompatibleWithAqua). Set shouldUnwrapWeth: false and do not attempt a custom maker receiver.
Token flow (exact-in, illustrative amounts)
| Leg | Mechanism | From → To | Amount |
|---|---|---|---|
| Maker output | AQUA.pull |
Maker Aqua balance → resolver EOA | amountOut = 0.994 WETH |
| Taker input | transferFrom + AQUA.push |
Resolver EOA → router → maker Aqua balance | amountIn = 3,000 USDC |
Amounts are illustrative; the real numbers come from the strategy's on-chain program (AMM curve plus any Fee opcode) as returned by quote.
Economics: who earns what
The maker earns the LP swap fee. The strategy's realized price already embeds the AMM curve and, if present, a
Feeopcode (basis points, denominator1e9). The maker swaps inventory (WETH out, USDC in) at that price and keeps the fee/curve spread as compensation for providing liquidity. The maker never pays gas and is never the transaction origin.The resolver captures spread minus gas. The resolver pays
amountInoftokenInand receivesamountOutoftokenOutat the strategy price. Its profit is the difference between the value it realizes for the pulledtokenOut(an end user, its own book, or another venue) and the cost of thetokenInit sourced and pushed — minus the gas it pays asmsg.sender/tx.origin. The resolver bears execution risk and gas; the maker does not. A fill is only worth sending when that spread clears gas and sourcing cost, which is why thethreshold(min output) anddeadlineexist as the resolver's on-chain guardrails.
Because the protocol settles exactly one maker–taker pair per call, any batching, netting, or cross-venue routing is the solver's own logic wrapped around these calls — Aqua neither provides nor requires it.
Inventory-coverage health check (maker/keeper side)
The double-commitment race above has no on-chain guard, so a maker or keeper running shared inventory needs its own pollable health signal: for every token, the sum of what has been committed across all live strategies must stay at or below what the wallet can actually deliver. This section gives the formula, the exact primitives to read, and a Foundry test that reproduces the revert.
The coverage formula (per token)
For a given maker wallet and the app its strategies were shipped under, the committed amount of a token is the sum of that token's virtual balance across every active strategyHash. The real backing is the wallet's balance capped by its allowance to the Aqua contract, because pull() settles with IERC20(token).safeTransferFrom(maker, to, amount) where the spender is Aqua itself — a fill needs both real balance and allowance.
# Inputs the keeper already holds off-chain. The _balances mapping is NOT
# enumerable, so you must track the strategyHashes you have shipped yourself.
# maker - wallet that called ship()
# app - AquaApp address the strategies were shipped under
# strategies - strategyHashes you have shipped and not docked
# tokens - tokens those strategies touch
for token in tokens:
committed = 0
for h in strategies:
(bal, tokensCount) = AQUA.rawBalances(maker, app, h, token)
if tokensCount == 0 or tokensCount == 0xff: # never-shipped or DOCKED
continue # skip: contributes nothing
committed += bal
real = min(
IERC20(token).balanceOf(maker),
IERC20(token).allowance(maker, address(AQUA))
)
coverage = real / committed # >= 1.0 is healthy
if committed > real:
ALERT(token, committed, real) # double-commitment danger zone
Read rawBalances, not safeBalances, for the sweep. rawBalances(maker, app, strategyHash, token) returns (uint248 balance, uint8 tokensCount) for any slot and never reverts, so you can loop it. safeBalances reverts SafeBalancesForTokenNotInActiveStrategy the moment it hits a never-shipped or _DOCKED (0xff) token, which would abort your loop. Use the tokensCount field from rawBalances to skip inactive slots (docked entries store 0 anyway). If the same wallet also backs open Fusion or classic limit orders, fold their live commitments into committed as well — they draw from the identical real balance.
safeBalances can report room the wallet no longer backs. safeBalances reads only the one strategy's virtual entry; it knows nothing about sibling strategies sharing the wallet. So a strategy can return a full, non-zero balance from safeBalances at quote time and still have its pull() revert at swap time, because another fill already drained the shared wallet below the amount. That revert surfaces as a bare ERC-20 insufficient-balance (or insufficient-allowance) revert from inside SafeERC20 — not an Aqua named error and not a virtual-balance underflow (each strategy's own balance decrements cleanly to zero). Poll the formula above rather than trusting a per-strategy read.
Reproducing the double-commitment revert (Foundry)
This minimal test ships two strategies over one wallet, each committing 100 tokens while the wallet holds only 120 of real inventory. The first fill drains the wallet; the second reverts inside safeTransferFrom even though its virtual balance still reads a full 100. Drop it in the aqua repo's test/ directory (it reuses that repo's remappings and mock ERC20) and run forge test.
Solidity
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import { Test } from "forge-std/Test.sol";
import { IERC20 } from "@1inch/solidity-utils/contracts/interfaces/IERC20.sol";
import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { Aqua } from "src/Aqua.sol";
contract Mock is ERC20 {
constructor() ERC20("Mock", "MOCK") {}
function mint(address to, uint256 a) external { _mint(to, a); }
}
// This test contract plays the `app` that calls pull() at swap time,
// exactly as AquaRouter would.
contract DoubleCommitmentTest is Test {
Aqua aqua;
Mock token;
address maker = makeAddr("maker");
address taker = makeAddr("taker");
function setUp() public {
aqua = new Aqua();
token = new Mock();
token.mint(maker, 120e18); // only 120 REAL inventory
vm.prank(maker);
token.approve(address(aqua), type(uint256).max); // allowance is not the bottleneck
}
function _ship(bytes memory strategy) internal returns (bytes32 h) {
address[] memory tks = new address[](1);
tks[0] = address(token);
uint256[] memory amt = new uint256[](1);
amt[0] = 100e18; // commit 100 to EACH strategy
vm.prank(maker);
h = aqua.ship(address(this), strategy, tks, amt);
}
function test_secondFillReverts_onSharedInventory() public {
bytes32 hA = _ship("STRATEGY_A");
bytes32 hB = _ship("STRATEGY_B");
// Both virtual balances advertise a full 100 -> 200 committed vs 120 real.
(uint256 a,) = aqua.safeBalances(maker, address(this), hA, address(token), address(token));
(uint256 b,) = aqua.safeBalances(maker, address(this), hB, address(token), address(token));
assertEq(a, 100e18);
assertEq(b, 100e18);
assertGt(a + b, token.balanceOf(maker)); // the danger zone
// First fill wins: pull() drains the wallet from 120 to 20.
aqua.pull(maker, hA, address(token), 100e18, taker);
assertEq(token.balanceOf(maker), 20e18);
// Strategy B's virtual balance is UNTOUCHED and still reports room ...
(uint256 stillB,) = aqua.safeBalances(maker, address(this), hB, address(token), address(token));
assertEq(stillB, 100e18);
assertLt(token.balanceOf(maker), 100e18); // ... that the wallet no longer backs
// ... so the second pull reverts inside SafeERC20's safeTransferFrom
// (ERC20 insufficient balance) -- not an Aqua error, not an underflow.
vm.expectRevert();
aqua.pull(maker, hB, address(token), 100e18, taker);
}
}
The test proves the asymmetry the formula is built to catch: safeBalances(hB) returns 100e18 right up until the failing call, while balanceOf(maker) has already fallen to 20e18. Only the wallet-level sum — not any single strategy read — exposes the shortfall before a taker hits it. Keep coverage >= 1.0 by segregating inventory per wallet, capping shipped amounts so overlapping strategies cannot collectively overshoot, or re-shipping / dock()-ing as real inventory moves.