Skip to content

Repository files navigation

Bebecita

An order book funded by a Uniswap position. The maker's inventory never sits idle in a wallet: it stays in a Uniswap v4 liquidity position and is unwound just in time, one instruction before the tokens leave, inside the same transaction as the fill.

Live app Ethereum Sepolia Etherscan Solidity License: MIT

Your Uniswap liquidity can quote an order book without leaving the pool.

Bebecita is a 1inch Aqua order book whose maker holds no inventory. The tokens it quotes on are inside a Uniswap v4 liquidity position, earning fees, and a custom SwapVM instruction reads that position on chain to decide how much the book may promise. When a taker fills, the same transaction unwinds exactly what the settlement needs through the Uniswap API, pays the taker, and puts the capital straight back to work.

Everything runs on public Ethereum Sepolia. The contracts are deployed and verified, the app is deployed with its proxies, and nineteen fills have settled on chain. Built for ETHGlobal Lisbon 2026 on two tracks, 1inch "Build an Aqua App" and Uniswap "Best Uniswap API Integration".

Table of contents

Why idle inventory is the problem

A maker quoting an order book has to keep the inventory it quotes on in its wallet, where it earns nothing. The Aqua whitepaper puts the same observation on the AMM side: 85 to 97 percent of liquidity sits idle. So the capital either quotes or it earns, and a maker picks one.

Both at once is possible here because of a property of Aqua rather than a trick of ours.

Aqua never custodies anything

Opening a strategy is not a deposit, it is writing a number into a mapping, and that number is backed by nothing: Aqua.ship performs no solvency check, and Aqua.pull decrements the virtual balance and only then calls safeTransferFrom on the maker's wallet. So Aqua does not require you to keep funds available, it requires that one transfer to succeed on the last line.

That is the whole opening. The money only has to be there for the width of two instructions, so it can work somewhere else until then.

There is no simultaneity here and the pitch should never claim any. The position is the collateral, and settlement converts it just in time, made safe by atomicity.

The removal test, both ways

This is the shortest description of what the project is, and it is the first thing to check.

Take the Uniswap API out and there is no calldata to place in either hook slice, the vault has no free float, and AQUA.pull reverts on the first fill on a safeTransferFrom out of an empty wallet. There is no degraded mode.

Take the custom SwapVM instruction out and Aqua quotes against the virtual balance the maker shipped, which is enormous by construction, the curve prices depth that does not exist, and the fill asks for more than the unwind can produce. That failure is asserted in the test suite, not described: see test_WithoutInstruction_QuotePassesAndSwapReverts.

Both legs break. Neither is decoration.

How it works

flowchart LR
    subgraph VM["SwapVM program, runs before any token moves"]
        OP["0x92 unwindPricedBalanceOut<br/>balanceOut = min(balanceOut, float + reachable)"]
        CURVE["swap curve<br/>prices against the clamped depth"]
        OP --> CURVE
    end

    HOOK1["preTransferOut<br/>vault executes /lp/decrease<br/>the position unwinds"]
    PULL["AQUA.pull<br/>the taker is paid"]
    PUSH["AQUA.push<br/>the vault is credited"]
    HOOK2["postTransferIn<br/>vault executes /lp/increase<br/>the inventory goes back to work"]

    CURVE --> HOOK1
    HOOK1 -->|"SwapVM.sol:310-314, then :321, the last maker controlled point"| PULL
    PULL --> PUSH
    PUSH --> HOOK2
Loading

The arrow between the unwind and the payment is the whole design. preTransferOut is the only point in SwapVM guaranteed to run immediately before tokens leave the maker, and it holds in both transfer orders the router supports. That is what makes just in time collateral possible at all.

One fill, end to end

Step Where What happens
1 runLoop Instruction 0x92 reads the vault's free float and the position's liquidity, and clamps balanceOut to what is genuinely reachable. Instruction 0x51 then prices against the truth, on the position's own range, and clamps its own output at the same number if the taker asked for more.
2 SwapVM.sol:310-314 preTransferOut fires. The vault executes the /lp/decrease calldata against the v4 PositionManager. The position unwinds, the vault receives the output token.
3 SwapVM.sol:321 to Aqua.sol:63-70 AQUA.pull pays the taker. The only thing that can run between the hook and this line is preTransferOutCallback, SwapVM.sol:316-319, which fires only when the taker asks for it and is the taker's own code.
4 SwapVM.sol:262 AQUA.push credits the vault with the taker's input.
5 SwapVM.sol:282-286 postTransferIn fires. The vault executes the /lp/increase calldata. The inventory goes back to work.

preTransferOut is the last maker controlled point before the pull, and that holds in both transfer orders the router supports, which is the entire reason this design exists. It is not literally the statement before it: SwapVM.sol:316-319 sits in between and calls preTransferOutCallback on the taker, but only when the taker sets hasPreTransferOutCallback, and what runs there is the taker's own code. That changes nothing the vault has to verify, and it is exactly why the guards below check realised balances instead of trusting an ordering.

What a fill costs

gas
Bebecita fill, including both PositionManager calls 323,264 to 357,711
A bare Aqua fill on the same curve, for reference about 100,000

Measured on Sepolia, from the receipts of every fill this router has ever settled, enumerated from its own Swapped logs rather than remembered. The spread comes from whether the redeposit finds an existing position tick range warm, and the first fill, which paid to open one, sits near the top of it at 357,464.

cast logs --from-block 0 --to-block latest \
  --address 0x354422f6e4e3476b540E306A6DdFb4638d9EA5c3 \
  0x54bc5c027d15d7aa8ae083f994ab4411d2f223291672ecd3a344f3d92dcaf8b2 \
  --rpc-url <an archive Sepolia endpoint>

The overhead is the honest cost of the design: every fill carries a decrease and an increase against the v4 PositionManager, on top of the swap itself. It is worth naming rather than hiding, because it is also what sets the floor under a useful fill. Below a certain size a clip costs more in unwind gas than it earns in spread, and that boundary is a real property of the product rather than a defect.

For comparison, the reference figures published in the swap-vm gas snapshot put XYCConcentrate alone at 101,161 gas on a swap and 16,898 on a quote.

Architecture

The instruction

_unwindPricedBalanceOut1D, opcode 0x92, in the 0x90-0xaf balances tuning bank of OpcodeList.sol, whose rule reads: "New instruction MUST take the next free _Ix slot of its family bank." The claim is machine checked rather than asserted: test_InstructionTakesReservedSlotOfBalancesTuningBank asserts uint256(Opcode._92) equals the dispatched index.

It is fully view. It writes no storage, so quote/swap consistency, one of the two invariants of the SwapVM core suite that are never skipped, is satisfied structurally rather than by discipline. The other one is balance sufficiency, which CoreInvariants.t.sol asserts after every configurable check with no flag to turn it off, and which is precisely what this instruction enforces: the two invariants nobody may skip are this project's whole subject. It only ever lowers balanceOut, never raises it, and never touches balanceIn, because on a constant product curve lowering balanceIn would raise the payout, which is the opposite of the intent.

balanceOut = min(balanceOut, float + reachable)
reachable  = positionLiquidity x unitsPerLiquidity x maxUnwindPct x (1 - haircut)

The curve

The program prices on XYCConcentrateSwap, opcode 0x51, and not on XYCSwap, 0x50. Two reasons, and both are load bearing.

0x50 never clamps its own output. Once 0x92 has lowered balanceOut to what the position can genuinely release, a taker asking exact-out for more than that gets an arithmetic panic out of the VM: amountIn = ceilDiv(amountOut * balanceIn, balanceOut - amountOut) underflows above the balance and divides by zero at it. XYCConcentrate clamps on balanceOut and re-solves the other side for the clamped amount, in both directions. The same moment becomes an exact partial: the taker is paid exactly the reachable collateral and charged exactly what that costs. TakerTraitsLib.validate was written for this, it requires takerAmount >= amountOut, never equality, so a partial is a legal fill and not a tolerated accident. Both halves are asserted in test_ExactOut_AboveReachableCollateral_IsAnExactPartialInsteadOfARevert.

Its two arguments are the position. 0x51 takes sqrtPriceMin and sqrtPriceMax, and yarn aqua fills them from the position's tick bounds and the live sqrtRatioX96 returned by POST /lp/pool_info, then compiles them into the program bytes. The book's curve is the range of the Uniswap position backing it. Re-range the position and the book concentrates with it, which is test_ExactIn_ClampedFill_ReSolvesTheInputAndSettles: on a range of 0.25 to 4, a taker offering 3,000 is charged 1,693.61 and paid the whole 475 of reachable collateral, where 0x50 charges the full 3,000 and pays 109.62.

One honest detail. This position is full range, ticks -887220 to 887220, and full range does not survive the instruction's 1e18 fixed point: sqrt(1.0001^-887220) is 5.4e-20, which truncates to zero, and XYCConcentrateArgsBuilder.build2D rejects a zero lower bound with ConcentrateInvalidPriceBounds. The shipped bounds are therefore the range clamped to the widest window the format carries, a factor of 1e9 on the sqrt price either side of spot, which is 1e9 and 1e27 in the strategy record and ticks -414486 to 414486. At that width the virtual reserves add about a billionth of the real ones, so the curve is constant product to nine significant digits and it is the clamp that changed, not the price: 915.372435469721101398 out against 915.372435390345788254 on 0x50, a difference of 79 gwei of token on 915 tokens.

The vault, and taker supplied calldata

The withdrawal calldata comes from the taker, per fill, because the Uniswap API builds it against live chain state and it cannot be baked into an immutable order. The vault does not trust it. It pins the callee, pins the selector, and judges the payload by its effects:

  1. after the unwind, the output balance must have grown by at least the amount the VM computed, which the hook receives as a parameter, so this is a floor and not a sign check;
  2. the other side of the book may not shrink;
  3. the position may not lose more liquidity than the maker authorised for one fill;
  4. a redeposit may only grow the position;
  5. the value the unwind actually released has to land in this vault.

The fifth is the one that makes the other four safe, and it exists because they were not. A real modifyLiquidities payload composes v4 Actions freely: DECREASE_LIQUIDITY on the token id, then TAKE or TAKE_PAIR naming any recipient at all. So a taker could unwind the whole of maxUnwindPct, route exactly amountOut to the vault and keep the entire remainder of both tokens. Guard 1 is a floor and was met exactly; guard 2 held with equality, because a balance that never moves cannot shrink; guard 3 caps liquidity and not value. Repeat with dust sized fills and the position drains at maxUnwindPct a time.

Guard 5 closes it by pricing the liquidity actually removed into both tokens at the live pool price, read from the v4 StateView and the position's own tick bounds, and requiring the vault's combined gain to cover it. The tolerance is haircutBps, reused rather than invented, because it is already the maker's statement of how much slack it accepts between what the position is worth and what it will count on. test_Attack_WouldHavePassedTheFirstThreeGuards runs the attacker's payload straight at the position manager and asserts that each of the first three guards holds, before the same payload is put through a fill and rejected with UnwindValueDiverted.

A taker can therefore choose how to unwind, but never whether the maker ends up short, and never where the released collateral lands.

Where the fill runs, and why there is no solver process

A solver is off-chain by nature in every RFQ system, including 1inch's own Fusion: the maker signs an intent, resolvers compete off chain, and the chain only ever sees the settlement. So the question is never whether a solver runs off chain, it is whether the maker has to trust it. Here it does not. The taker is whoever presses the button, the order and the vault are immutable, and everything the taker supplies per fill is judged on chain by the five guards above. An adversarial taker therefore chooses how the position is unwound and never whether the maker ends up short. That is why deleting the backend cost this project nothing: there was no trust in it to remove. What kept the fill in a process was one detail, TakerTraitsLib.build being internal pure Solidity, and porting it removed the process.

The port is not asserted, it is proved: contracts/test/TakerTraits.t.sol builds twelve argument shapes with the sponsor's own library and asserts byte equality against what solver/src/takerTraits.ts wrote for the same arguments, then round trips the live fill shape back through the library's own slice readers. contracts/script/Fill.s.sol stays in the repository as the Solidity reference that diff is taken against, and it makes the same assertion a second time on payloads nobody chose: every yarn fill writes its request to deployments/fill.local.json, and replaying it through the script checks the TypeScript blob against TakerTraitsLib.build on the 676 and 708 bytes of v4 Actions the Uniswap API built for that fill.

forge script contracts/script/Fill.s.sol --rpc-url sepolia --skip test
  taker traits      TypeScript port matches TakerTraitsLib.build byte for byte

solver/src/fillPlan.ts holds every decision a fill makes and reaches the chain and the API through two injected interfaces, so it does not know where it is running. The command line supplies a private key and an x-api-key header, the browser supplies the connected wallet and the dev server proxy, and there is no second copy of the sizing arithmetic between them. See docs/ARCHITECTURE.md for the settlement window line by line, and docs/DECISIONS.md for what was decided and what was refused.

1inch Aqua and SwapVM

The official Aqua contracts do the work. BebecitaRouter is a redeployed SwapVM, which the track allows in so many words, and it points at the canonical Aqua: AQUA() returns 0x499943E74FB0cE105688beeE8Ef2ABec5D936d31, which is the one line that proves it. Not a line of the sponsor's source is modified. BebecitaOpcodes subclasses AquaOpcodes and overrides _runOpcode with a super fallthrough, exactly as AquaOpcodesDebug does.

Requirement of the track How this repository meets it
Official Aqua and SwapVM contracts used AQUA() on the router returns the canonical address, whose Sepolia bytecode is identical to Base
Redeployment of a modified SwapVM BebecitaRouter is Simulator, SwapVM, BebecitaOpcodes, one added instruction and three rewired ones
On-chain execution of token transfers nineteen real fills on public Sepolia, from three distinct taker addresses
Uses SwapVM, which scores higher custom instruction 0x92 in the reserved slot of the balances tuning bank, asserted by test rather than claimed

The shipped program is [0x92 unwind][0x51 concentrate][0x02 salt], 142 bytes, the curve bounded by the position's own range. OPCODE_UNWIND_PRICED_BALANCE_OUT() on the router returns 146, that is 0x92.

Rewired instructions

AquaOpcodes dispatches 19 opcodes where the full Opcodes table dispatches 46. Three of them, Stop, Revert and JumpIfDirection, were added to Controls.sol on 2026-07-05 and are unreachable from any Aqua program: a program using them reverts with UnknownOpcode. They are wired back in BebecitaOpcodes because a program that cannot halt early cannot express a conditional strategy. That is three else if and a test, and the diff is in one file.

Two fills of one position, in one transaction

Aqua reads a maker's balances per order hash. Two strategies against one position are therefore two promises with nothing relating them, and instruction 0x92 is the only thing in the transaction that does. contracts/src/takers/BebecitaTaker.sol is the taker that puts that under load, and it does two things because they are two different experiments. fillAll runs fills back to back. The preTransferOutCallback path runs the second fill inside the first, in the window SwapVM.sol:316-319 opens between the maker's hook and AQUA.pull, which is the shape that actually needs the reentrancy guard to be keyed by order hash.

Back to back, the second fill lands on the reachable share of the position the first one left, to the wei, and the per fill cap measures the position as it stands rather than as it was, so iterating inside one transaction never reaches the whole of it. Nested, both fills settle and the log order proves the nesting.

The composition that only nesting creates is the reason it was worth building. 0x92 counts the maker's free float towards the quotable balance, and inside the settlement window that float is the outer fill's own unwind, sitting in the vault because AQUA.pull has not run yet, so the inner fill is quoted more than its own share of the position is worth. Neither the guard keying nor the cap stops a taker asking for all of it. Guard 1 does, because it is a delta and not a level, and it stays that way. Five tests in contracts/test/Bebecita.t.sol cover the whole of it. This shipped as contracts and tests against MockPositionManager, and nothing new was sent to Sepolia for it.

One warning about the official Sepolia router

AquaSwapVMRouter at 0x8fdd04dbf6111437b44bbca99c28882434e0958f does not execute orders built against the swap-vm commit this project pins: quote() reverts with empty return data, including for an order that was never shipped, where a router built from the published source names its error. It is recognisably an AquaSwapVMRouter otherwise. It cost an afternoon, and it is reported with the repro in FEEDBACK.md.

Uniswap API integration

The API is the funding mechanism, not a data source. Qualifying function claimed: liquidity provision, literally, with two PositionManager calls built by the API at fill time and executed on-chain inside every fill.

Endpoint Called from Role
POST /lp/pool_info solver/src/aqua.ts Once per strategy. Its sqrtRatioX96 and the position's ticks become the two arguments of instruction 0x51, compiled into the program bytes. The response is a parameter, not a display value
POST /lp/check_approval solver/src/setup.ts With generatePermitAsTransaction: true, returns permits as executable transactions instead of EIP-712 typed data. This is what makes a contract owned position possible: the vault cannot sign, only execute
POST /lp/create solver/src/setup.ts Opens the pool and the position through newPool, so the demo owns its own pool and depends on no pre-existing testnet liquidity
POST /lp/decrease the browser, or solver/src/uniswap.ts Once per fill. Its calldata goes verbatim into the preTransferOutHookData slice of TakerTraits and is executed by BebecitaVault.preTransferOut
POST /lp/increase the browser, or solver/src/uniswap.ts Same fill. Its calldata goes into postTransferInHookData and is executed by BebecitaVault.postTransferIn
POST /lp/claim_fees the browser, or solver/src/uniswap.ts Closing panel: the fees the same capital earned while it was quoting

Note for the reviewer. The /lp/* operations belong to the same OpenAPI document as trade-api.gateway.uniswap.org/v1 and use the same x-api-key scheme, but the document carries a per-path server override and they are served from https://liquidity.api.uniswap.org with no version prefix. Grepping our request logs for trade-api will find nothing, which is why this is stated here rather than left to be discovered.

FEEDBACK.md is what we found while integrating: sixteen reproducible findings, eleven on Uniswap and five on 1inch, each with a repro and a suggestion. grep -c '^### ' FEEDBACK.md counts them.

URC-3

The vault implements IHookStats, the Uniswap Labs standard for reporting TVL and immediately swappable liquidity, created 2026-06-11. Its normative invariant is that getEffectiveLiquidity should not exceed getReserves, which is exactly the shape of what instruction 0x92 enforces inside the VM. URC-3's own motivating cases list, verbatim, "deploy liquidity in external protocols", "rehypothecate assets" and "maintain reserves outside the PoolManager".

Both accessors report the real per token content of the position, valued at the live pool price, plus the vault's free float on the side it actually sits on. They used to credit liquidity * unitsPerLiquidityE18 to both sides, which is right at parity and wrong everywhere else: a full range unit of liquidity is worth sqrt(price) of token1 and 1/sqrt(price) of token0, so at a price of four the two sides differ by a factor of four and the old report said they were equal. Both also refuse a PoolKey that is not the pool backing the position, because the standard is written around a named pool and answering for a different one is a wrong answer rather than a missing one.

The standard is scoped to v4 hooks and makes hook() mandatory, which does not fit a contract that holds reserves without being a hook. The vault returns the zero address and the gap is reported upstream rather than papered over.

What is live, and what is not

Live on Ethereum Sepolia, today, with nothing mocked:

  • the instruction, 0x92, in a redeployed SwapVM pointing at the official Aqua, quoting a book that is clamped on chain to what the position can release;
  • the funding path, /lp/decrease and /lp/increase calldata executed inside the settlement, with the five guards judging it by its effects;
  • nineteen settled fills from three distinct taker addresses, and one rebalance that traded the maker's accumulated float home and put both sides back into the position;
  • the app, deployed with both proxies as serverless functions, where a visitor mints, approves and fills from their own wallet;
  • URC-3 reporting, per token and priced at the live pool.

Deliberately not built, with the reasoning in docs/DECISIONS.md rather than here:

Not built Why
The instruction as a wrapper around a nested runLoop it would compute the unwind percentage instead of taking a maker parameter, on the only component of the critical path, for a security argument guard 1 already pays
A second instruction at 0x93 for minimum fill size the answer to a thin contribution is a composed program, not a second opcode
Decay in the program stack its own NatSpec announces a quote versus swap divergence, and that invariant is the one claimed structurally
A rebalance inside the fill it would add a router, a slippage bound and a deadline to the one path that must not grow failure modes, and a maker that hedges into the pool it quotes against can never beat that pool
A mainnet fork /lp/decrease derives position state server side, so a forked position is invisible to the API

What is genuinely unfinished is the demo video and the Uniswap Developer Feedback form, neither of which was submitted. docs/SUBMISSION.md records that rather than rounding it up.

Tech stack

  • Contracts. Solidity 0.8.30, Foundry with via_ir and 700 optimizer runs. @1inch/swap-vm pinned to the exact commit the lockfile resolves, b5e0e4d72242ec44ac636d5e6ce0c5686619b00d, rather than to #main, because it is the one dependency whose behaviour this project modifies and a moving target there is a moving target under the instruction table. OpenZeppelin and forge-std are held at 5.4.0 and v1.11.0, the versions swap-vm itself pins: upgrading either past the sponsor would compile our contracts against one tree and their instructions against another.
  • Protocols. 1inch Aqua and SwapVM, Uniswap v4 through the PositionManager, StateView and the Liquidity API, Permit2 for the v4 approval legs.
  • Solver and scripts. TypeScript and viem, run with tsx. No framework and no backend.
  • Frontend. Vite, React, TypeScript, viem, wagmi over it for the wallet. One CSS file, no component library, no chart library. Deployed on Vercel with two serverless proxies.

Repository structure

contracts/src/instructions/   UnwindPricedBalances, the 0x92 instruction and its args builder
contracts/src/opcodes/        BebecitaOpcodes, the Aqua table plus 0x92 and three rewired Controls
contracts/src/routers/        BebecitaRouter, the redeployed SwapVM pointing at the official Aqua
contracts/src/vault/          BebecitaVault, the maker: position custody, hooks, URC-3 reporting
contracts/src/takers/         BebecitaTaker, two fills of one position, back to back and nested
contracts/src/interfaces/     IHookStats (URC-3)
contracts/src/libraries/      LiquidityAmounts and TickMath, liquidity to per token amounts
contracts/src/mocks/          MockPositionManager and MockStateView for the suite, TestERC20 for the pair
contracts/test/               53 tests: the negative moment, the partial fills, the five guards, the
                              diversion attack, the redeposit, the rewired controls, the composed fills,
                              the traits port
contracts/script/             Sepolia deployment, and the Solidity reference for the taker traits
solver/src/                   the fill plan and the taker traits, shared with the browser, plus the LP API
                              client, gate zero, setup and the Aqua strategy
app/                          landing page and dashboard, Vite plus React plus viem, where the connected
                              wallet is the taker
api/                          the two serverless proxies the deployed app runs on
deployments/sepolia.json      every address and hash, written by the scripts and read at runtime
docs/                         architecture, onboarding, demo script, decisions, plan, submission record

Getting started

yarn install
cp .env.example .env          # fill UNISWAP_API_KEY and DEPLOYER_PRIVATE_KEY
yarn gate0                    # six checks that decide whether the project exists
yarn test                     # 53 tests in three suites, no network needed
forge script contracts/script/Deploy.s.sol --rpc-url sepolia --broadcast --verify
yarn setup                    # pool, position, vault custody, all through the LP API
yarn aqua                     # opens the strategy the book quotes
yarn fill                     # one fill on Sepolia, end to end
yarn inventory                # what those fills did to the maker's inventory
yarn rebalance                # trade the inventory home, between fills

yarn setup is resumable and reads deployments/sepolia.json as its state: a position already recorded there is reused rather than duplicated, and --recreate opens a second one. yarn aqua picks a fresh salt on every run, because a strategy hash can be shipped exactly once and the second run would otherwise revert with StrategiesMustBeImmutable.

docs/ONBOARDING.md is the fifteen minute version for somebody who has to change the code, including the nine things that will bite you. docs/DEMO.md is the walkthrough, in the order that makes the instruction explain itself.

The fill

yarn fill                     # quote, size, unwind, swap, redeposit, one transaction
yarn fill --amount=250        # a smaller clip
yarn fill --dry               # simulate against live state and broadcast nothing
yarn demo:reset               # re-salt the order and re-ship it, ready for a fresh fill

yarn fill reads the quote through quote() on a staticcall, which is what asView() exists for, sizes the withdrawal from the amountOut that came back, rounds the percentage up because liquidityPercentageToDecrease is an integer, fetches /lp/decrease and /lp/increase, encodes the taker traits and sends swap() from the taker's own key. Then it reads the receipt back and checks the two ModifyLiquidity events, so the claim is verified from the chain and not from the console.

yarn demo:reset exists because a strategy hash can be shipped exactly once, ever. It walks the salt space for a program Aqua has never seen, ships that one from the vault with the same balances and the same risk parameters, and leaves the taker approved. Nothing else moves: same pool, same position, same vault, same router, one byte of difference in a no-op instruction.

The last fill on Sepolia 0xba4722e2…
Block 11354243
Program 0x92 unwind, 0x51 concentrate bounded by the position, 0x02 salt
Swapped 1,000 bBRAVO in, 864.399981979604296262 bALPHA out
Gas 340,611
Taker 0xdae8992a…, which is not the deployer key yarn fill signs with, so it came from the dashboard and a connected wallet. That is also why deployments/sepolia.json does not record it: only yarn fill writes lastFill

The fill deployments/sepolia.json does record, 0xfa8e60eb…, is the one worth reading for what the fifth guard costs. It swapped 1,000 bBRAVO for 912.676854023977327256 bALPHA on a 2% unwind that released 1,806.65 bALPHA and 2,234.54 bBRAVO against 912.68 owed, all of it into the vault, and put the position's liquidity back from 98,452.54 to 99,446.80 in the same transaction. It cost 340,599 gas, and it is the first fill to have run against all five guards. The fill immediately before it, 0xad08b75c…, took the same 2% unwind under four guards and cost 326,914. So the two extra reads the conservation guard makes, getPoolAndPositionInfo and getSlot0, cost 13,685 gas, about 4% on a fill that already carries two PositionManager calls. That is the price of the hole it closes, and it is worth naming rather than rounding to nothing.

The first fill this project ever ran is 0xe0a395b7…, 1,000 bBRAVO for 23.72 bALPHA. The price is not a market move, it is the shipped book: that fill quoted before the input side was shipped from reachableFromPosition instead of generously, which is what brought the book back to something a human can read.

Inventory, and trading it home

yarn inventory                # what every fill did to the maker's inventory, read off its own receipt
yarn rebalance                # sell the surplus, put both sides back into the position
yarn rebalance --dry          # size it against live state, call the API for real, broadcast nothing

A fill is not balance neutral for the maker and it never was. /lp/decrease returns both tokens pro rata, the fill pays the taker in one of them and is paid in the other, and the redeposit is two sided and therefore capped by whichever token the maker keeps selling. Counted off the receipts, the first fourteen fills put 42.50% of the removed liquidity back, left 21,859.61 bBRAVO of free float in the vault, and took the position from 100,000 of liquidity to 92,140.39. That is the state yarn rebalance was run against, and deployments/sepolia.json records it under rebalance.before. That is what one directional flow does to any market maker rather than something this design does to itself, and the answer has always been the same one: trade the inventory home, and charge a spread that covers doing so.

One rebalance on Sepolia 0x6c8f9009…
Float 21,859.61 bBRAVO and 2.07 bALPHA, down to 35.73 and 0.93
Position 92,140.39 of liquidity back to 102,473.62, which is 102.47% of what it opened with
Sold 10,363.05 bBRAVO for 9,290.22 bALPHA, POST /quote then POST /swap on the trade host, x-permit2-disabled: true so an approve and an execute replace any signature
Where at the owner, on the sweep the vault already had, between fills and never in the settlement path

The size is a single sided zap rather than a dump: selling the whole surplus would leave the vault holding one token against a pool priced where that sale left it, which is the same trap one level down. See docs/ARCHITECTURE.md, section Inventory, for what the rebalance costs and for the one maker parameter it moves, and docs/DECISIONS.md for why this is not inside the fill.

The dashboard

Deployed and public: https://bebecita-aqua.vercel.app

cd app && yarn install && yarn dev     # http://localhost:5173, and nothing else

A landing page and a dashboard. The dashboard reads the position, the vault and the Aqua balances live from Sepolia, calls quote() through a staticcall, calls the Uniswap LP API for real, and shows every API request and response with its response headers. Addresses are read at runtime from deployments/sepolia.json and solver/src/config.ts, never copied. See app/README.md.

The connect button offers the injected wallets the browser announces, Coinbase Wallet, and WalletConnect when VITE_WALLETCONNECT_PROJECT_ID is set. It disconnects, it follows an account or chain switch made inside the wallet without a reload, and it offers to add Ethereum Sepolia to a wallet that does not know it.

Run a fill does the whole thing in the tab, signed by the connected wallet. It quotes, sizes the unwind, calls /lp/decrease and /lp/increase, encodes the taker traits and sends swap(). The transaction is then read back from its own receipt: the Swapped amounts, and the two PositionManager calls with the position's liquidity before and after each. The connected wallet is the taker, so it needs the input token and an allowance to the router. Both are one button away, Mint and Approve the router, because TestERC20.mint is public. A judge can drive the demo from their own wallet without asking anyone for anything, and three of the nineteen settled fills came from a wallet that is not the deployer key yarn fill signs with, which is what that path leaves behind on chain.

The dashboard leads with SLAC, the Shared Liquidity Amplification Coefficient of the Aqua whitepaper, page 4: the total liquidity provisioned across every strategy this vault has shipped, over the wallet equity backing it. It is shown twice, because the denominator is the whole argument. Against the vault's plain ERC20 balances it is in the hundreds, and undefined when the float is zero, which is what a wallet balance check sees. Against free float plus reachableFromPosition(), the figure instruction 0x92 clamps to on chain, it is finite. The strategy hashes come from the vault's own Shipped events, scanned back over about 45 000 blocks on a minute long loop of its own, merged with the one deployments/sepolia.json names so that a strategy shipped before that window is still counted. The record only ever names the live one, which is why the scan is what the sum is built from.

There is one port and no backend. Two proxies exist and neither runs any of this project's logic: one attaches the Uniswap key to /api/uniswap so the key never reaches the bundle, the other forwards JSON-RPC on /api/rpc so a private endpoint can stay out of it. Locally they are Vite middleware; in production they are the two serverless functions in api/, so the deployed site is the same app rather than a crippled build. The Uniswap gateway answers browser preflights on the /lp/* paths, so a build that carries its own key can call it with no proxy at all. See docs/ARCHITECTURE.md, section Deployment.

Deployed addresses

Everything runs on Ethereum Sepolia, chainId 11155111, and that is not a fallback.

It is the only chain outside mainnet carrying both halves of this project. The official Aqua is deployed there at its canonical address 0x499943E74FB0cE105688beeE8Ef2ABec5D936d31, with bytecode byte for byte identical to the Base deployment, sha256 e30d2eab49ae15c876b4d75131185c78bb28e88ac8a21faab06336139b84a1af on both. No 1inch README lists a testnet, so this was established by probing the chain. Base Sepolia and Unichain Sepolia carry no Aqua at either known address. The Uniswap API accepts chainId 11155111, and Uniswap's own FAQ states there is no sandbox and that testing is done against the supported testnets through the production endpoints.

Ours, all four verified on Etherscan:

Contract Address
BebecitaRouter, our modified SwapVM 0x354422f6e4e3476b540E306A6DdFb4638d9EA5c3
BebecitaVault, the maker 0x6A64a5BB9704119bb651b4f08D09b065F48902CD
bALPHA 0xdB41CB0A2EEFF8Ed53Ef019D4C9826744f500B7F
bBRAVO 0x0128Ac6B5E3364b022e55A0cf9c0cb4987B3B20f

Theirs:

Contract Address
Aqua, official 0x499943E74FB0cE105688beeE8Ef2ABec5D936d31
AquaSwapVMRouter, official 0x8fdd04dbf6111437b44bbca99c28882434e0958f
v4 PoolManager 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543
v4 PositionManager 0x429ba70129df741B2Ca2a85BC3A2a3328e5c09b4
v4 StateView 0xe1dd9c3fa50edb962e442f60dfbc432e24537e4c

The maker's inventory, opened through the Uniswap API and owned by the vault:

Position v4 NFT #37804, full range, opened with 100,000 of each token
Pool 0x25f7dd131e5548b22a4bf9b95587514d69960261c1defff0ec465f9f90d54489, fee 3000, spacing 60, no hook
Reachable per fill 25% of the position after a 5% haircut. Read on chain at block 11351549: 21,237.22 bALPHA and 26,267.04 bBRAVO, which differ because the pool no longer sits at parity. getEffectiveLiquidity reports each of those plus the vault's free float on that side
Aqua strategy 0x5558177a9c2fafbf360d32c575576bac9cd7603b1d52c91821b0a08fe9015207

Every address and hash above is read out of deployments/sepolia.json, which the deploy, setup and fill scripts write and which the app and the solver read at runtime. That file is the source of truth; anywhere else in these documents, the file is pointed at rather than copied.

The vault has been deployed three times, and both redeployments are for the same reason: what a taker needs to be able to read off an immutable maker cannot live behind a setter. The first was constructed before the position existed, so TOKEN_ID was zero. The second, 0xE703F509ba1bF70BcFa4957a7090e73B627dE76a, could not read the pool price, which the conservation guard needs to value what a withdrawal released, so STATE_VIEW joined the immutables. The position NFT moved across in 0xa2516a9a…, the float followed, and the book was re-shipped from the new maker.

Security and testing

yarn test    # 53 tests in three suites, no network needed
yarn gate0   # six live checks against Sepolia and the Uniswap API

The tests that carry weight are the ones that assert a failure. test_WithoutInstruction_QuotePassesAndSwapReverts is the removal test as an executable assertion. test_Attack_WouldHavePassedTheFirstThreeGuards runs the diversion payload straight at the position manager, asserts the first three guards each hold, then puts the same payload through a fill and watches guard 5 reject it. test_InstructionTakesReservedSlotOfBalancesTuningBank machine checks the opcode claim. contracts/test/TakerTraits.t.sol diffs the TypeScript traits builder against the sponsor's own library byte for byte across twelve shapes, because the failure mode of that port is silent: a slice index one byte off decodes a different slice rather than failing. contracts/test/LiquidityAmounts.t.sol is sixteen cases against published constants and the range boundaries, which is the price of vendoring two Uniswap core routines rather than depending on v4.

The trust model is written down rather than implied. The taker is untrusted, supplies the withdrawal calldata, and is judged by the five guards. What a taker reads off the maker cannot move: AQUA, ROUTER, POSITION_MANAGER, STATE_VIEW, TOKEN_ID and OWNER are all immutable, which is why fixing the conservation guard cost a redeployment rather than a setter. The owner can move the risk parameters through setRiskParams and can sweep, and both are owner only and live outside the settlement path.

Iteration discipline, measured on the build machine: a full forge build costs 3 min 49 because of via_ir, forge build --skip test costs 17 s, and forge test --match-path <one file> costs 13 s. Never run the bare commands, and yarn test never does.

Built by

Two people, at ETHGlobal Lisbon 2026.

Sofiane Ben Taleb built the contracts, the custom instruction, the solver and the settlement path.

Sankara Wigneswaran built the visual direction of the app, the landing page and the live console.

Sanka's work landed in d1c9f94 under a local machine identity rather than a GitHub account, so it does not appear in this repository's contributor graph. The .mailmap in the root fixes his attribution for git log and git shortlog, and this section is here because that is the part GitHub cannot infer.

License

MIT

Built for ETHGlobal Lisbon 2026 · 1inch Aqua and Uniswap v4

About

A 1inch Aqua order book whose maker inventory never leaves a Uniswap v4 LP position: a custom SwapVM instruction (0x92) clamps every quote to what the position can actually release, and each fill unwinds and redeposits it through the Uniswap API inside the same transaction. Built for ETHGlobal Lisbon 2026.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages