Access, resolvers & Pathfinder

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 TxOriginTokenBalanceIsZero unless balanceOf(tx.origin) > 0 of the per-chain KycNFT.

  • 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 which safeBalances reverts SafeBalancesForTokenNotInActiveStrategy. 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, so balanceIn / balanceOut are different when you settle than when you quoted.

  • The curve is state- and time-dependent. Price-shaping opcodes make the divergence intrinsic, not incidental: Decay moves the rate as a function of time, and Extruction shapes it from the current balances. Even with no competing fills, quoting early and settling late gives a different amountOut.

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 (symbol RES).

  • The swap() transaction is sent directly from that EOA. The strategy program embeds the Controls check _onlyTxOriginTokenBalanceNonZero(KycNFT), which reverts TxOriginTokenBalanceIsZero unless balanceOf(tx.origin) > 0. Because it reads tx.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 tokenIn it 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
1
2
3
4
5
6
7
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

  1. Hash & lock. Computes orderHash = hash(order) and takes a per-order reentrancy lock. For Aqua strategies hash is keccak256(abi.encode(order)) (no EIP-712 signature branch).

  2. Parse taker traits.TakerTraitsLib.parse yields takerTraits and takerData; isExactIn is read from the header.

  3. Load maker balances. Because the strategy sets useAquaInsteadOfSignature, the router reads the maker's virtual balances with AQUA.safeBalances(maker, router, orderHash, tokenIn, tokenOut) instead of verifying a signature.

  4. Run the program.runLoop() executes the strategy's opcodes (the AMM curve, any Fee opcode, the tx.origin access check, etc.) and computes (amountIn, amountOut).

  5. Validate.order.traits.validate(...) then takerTraits.validate(...): amountOut > 0; for exact-in the passed amount must equal amountIn and amountOut >= minWethOut (or exact if isStrictThresholdAmount); the deadline, if set, must not have passed.

  6. Settle, ordered by isFirstTransferFromTaker. With the flag false the router runs _transferOut then _transferIn:

    • Maker output (pull):_transferOut calls AQUA.pull(maker, orderHash, tokenOut, amountOut, to), moving amountOut WETH out of the maker's Aqua balance to the recipient (to, defaulting to the taker EOA).

    • Taker input (push): because useTransferFromAndAquaPush is set, _transferIn does IERC20(tokenIn).safeTransferFrom(taker, router, amountIn), forceApprove(AQUA, amountIn), then AQUA.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.)

  7. 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 Fee opcode (basis points, denominator 1e9). 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 amountIn of tokenIn and receives amountOut of tokenOut at the strategy price. Its profit is the difference between the value it realizes for the pulled tokenOut (an end user, its own book, or another venue) and the cost of the tokenIn it sourced and pushed — minus the gas it pays as msg.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 the threshold (min output) and deadline exist 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 SafeERC20not 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
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
// 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.

Did you find what you need?