Search projects, hackathons, sponsors and tech

    ← All projects

    Riptide

    An AMM that charges a volatility-indexed fee and auctions stale-price arb back to LPs.

    Demo

    About

    ## RIPTIDE — an AMM on 1inch Aqua + SwapVM that prices and reclaims the money LPs lose to arbitrage ### The problem A normal AMM quotes a price from its reserves and collects a swap fee. That quote lags the rest of the market. When ETH moves on a CEX, the pool still quotes the old price until someone trades against it. The trader who does that is usually an arbitrageur, and the profit comes from the LP. This is called **Loss-Versus-Rebalancing (LVR)**. Since 2022 there has been a closed-form expression for it. For a constant-product pool it is `σ²/8` of pool value per unit time. At 100% annualised volatility that is one eighth of the pool per year, paid to arbitrageurs, before fees. Two consequences: 1. **A fixed fee does not match the cost.** LVR scales with `σ²`. A flat 30 bps overcharges on quiet days and undercharges on volatile ones. 2. **The arb profit can be auctioned instead of left to the mempool.** When the pool is stale, the size of that profit is known. Today it goes to whoever pays the highest priority fee. It can instead go to whoever pays the LP the most for the right to take the trade. ### What RIPTIDE is A RIPTIDE strategy is a constant-product pool (`x · y = k`) with two extra layers: a fee that moves with volatility, and an auction for the right to reprice the pool when it is stale. The curve itself is unchanged. **Mechanism 1 — a fee that tracks volatility.** An on-chain controller reads a live volatility estimate and moves the fee toward the break-even point where expected fee revenue covers expected LVR: `φ* = (σ̂²/8)/λ`, clamped into the maker's `[feeMin, feeMax]` band, approached by a clamped PI controller with anti-windup. Higher volatility, higher fee; lower volatility, lower fee. To a trader this is an ordinary protocol fee. It is delivered through 1inch's existing fee instruction — **no new fee opcode**. SwapVM already staticcalls an `IProtocolFeeProvider`; RIPTIDE answers that call with a contract that reads an on-chain EWMA volatility estimate. Because it is a staticcall, the fee a quote shows and the fee a swap charges are the same number. **Mechanism 2 — a Dutch auction for the rebalance.** When the external price moves, the strategy opens a declining-price auction for the right to rebalance the pool. Resolvers compete on time priority. On settlement the contract measures surplus `S` on-chain, pays the resolver `⌊(1−β)·S⌋`, and leaves at least `β·S` with the LP. With `β = 0.95`, at most about 5% of the arb value leaves. A reverse swap immediately afterwards is penalised, so the rebalance cannot be sandwiched. If `S` is not positive the trade reverts, which also stops a resolver from filling at a loss. **The loop between them.** A resolver only settles when the rebalance is profitable, so the price they pay is a traded price, not just an oracle print. That price is written into the volatility oracle and moves the next fee target. This is a design property, not a theorem. ### The research this is built on Nothing in the economics is invented. Three papers, all included in the GitHub Repo - **arXiv:2208.06046** (Milionis, Moallemi, Roughgarden, Zhang) — the definition and closed form of LVR, and the identity that decides everything: *LP value − rebalancing value = fees − accumulated LVR*. - **arXiv:2210.10601** ("Diamond", McMenamin, Daza, Mazorra) — the recapture result: auction the rebalancing right and retain `β`, and the LVR escaping to arbitrageurs is bounded by `E[LVR] ≤ (1−β)·L`. Their simulations use `β = 0.95`. - **arXiv:2305.14604** (Milionis, Moallemi, Roughgarden) — the fee is the instrument that offsets LVR, with an interior optimum. We take the principle, not a closed-form constant. EWMA/Garman–Klass volatility estimation and the PI controller come from outside that set and are labelled as engineering mechanics, not economic claims. Papers we deliberately did *not* use are listed with a written reason for each. ### How it uses 1inch This is built *on* Aqua and SwapVM, not merely alongside them. **Aqua is the custody layer.** Makers keep tokens in their own wallets. Aqua holds an allowance record, not the money — so there is no vault to trust and no LP share token. RIPTIDE's routers are registered as Aqua **apps** that `pull`/`push` against a maker's ledger entry. **No RIPTIDE contract ever holds inventory.** All five Aqua primitives (`ship`/`dock`/`pull`/`push`/`safeBalances`) are load-bearing. **SwapVM is the execution layer.** A strategy *is* a bytecode program. RIPTIDE emits two: a **41-byte swap program built entirely from upstream opcodes** (`Deadline → aquaDynamicProtocolFee → XYCSwap → Salt`), and a **66-byte rebalance program** that adds two RIPTIDE instructions — the declining-price schedule and the β-split. A 226-byte policy payload (fee band, auction policy, oracle config, salt) rides in the same order via the MakerTraits program slice, which is what lets a strategy's full policy be committed in Aqua's immutable strategy hash. Everything compiles against **`swap-vm@v1.0.2` and `aqua@v1.0.0`** — the releases 1inch has actually deployed to mainnet, never `main` — and **`@1inch/aqua-sdk` builds all Aqua calldata in production**. We deploy two slimmed routers rather than the all-opcodes `SwapVMRouter` (which does not fit EIP-170) because RIPTIDE needs a custom instruction; the resulting swap program is still a plain Aqua-AMM program, and a fork test ships it to a **stock, unmodified `AquaSwapVMRouter`** and asserts it prices identically to ours, to the wei. Integration against the *real* 1inch mainnet Aqua registry is proven by a separate mainnet fork test. ### Who gets what - **Liquidity providers** — tokens stay in your wallet; no vault, no LP token. Your fee rises when volatility rises, so you charge more precisely when you are losing more, and when the price gaps, most of the re-pricing profit comes back to you instead of going to a searcher. The dashboard shows the number that matters: cumulative value recaptured. - **Traders** — a quote that shows the applied fee **and the σ that produced it**, before you sign. Orders are split across makers by a solver and settle atomically: if any fill fails the whole route reverts and you keep your funds. - **Resolvers / searchers** — a legible source of flow instead of a mempool race. See open auctions with their live declining price, preview your exact take `⌊(1−β)·S⌋` before committing, and settle through a permissionless entry point from any wallet. - **Analysts** — every headline number links to the on-chain event that produced it, with the indexed block stated and an honesty panel marking which claims are verified on-chain versus validated by simulation. ### What is actually deployed Fourteen contracts live on Base Sepolia with three seeded strategies running different fee bands and β values (0.90 / 0.95 / 0.97), a six-page web app (one per persona: explainer, maker studio, swap terminal, resolver board, positions, analytics), a subgraph on Graph Studio indexing six contracts into thirteen entities, and four off-chain services (solver API, resolver bot, volatility indexer, and an MCP server exposing executable-liquidity tools). The off-chain layer is **untrusted and replaceable** — none of it signs for a user, and none of it can move maker inventory, settle a loss-making rebalance, or make the settled fee differ from the quoted fee. The contracts recompute everything. ### How we verified it 120 Solidity test functions across 45 files. Five named protocol invariants, each shipping a **negative control** — a deliberately broken variant that must make the test fail, so a green suite is evidence the tests *can* fail. Plus 10 fuzz tests at 10,000 runs, 12 differential tests against a committed Python oracle, atomic-rollback and reentrancy tests, and fork tests against the canonical 1inch mainnet Aqua deployment. On top of that, a scripted end-to-end run against the **live deployed app** — it generates two wallets, funds them from the faucet, and walks every persona (ship → swap exact-in → swap exact-out → skew the oracle → preview → settle → dock → read analytics) through the same HTTP endpoints the browser uses. Nothing pre-approved, no fixtures. The most recent run: **20 transactions, 41/41 assertions**, every hash linked to BaseScan in `docs/E2E_RUN.md`. in our Github repo. It observed σ move from 0 to 0.1124 after a settlement, with the fee target shifting to 15954 against a reported 10892 — the loop closing, measured rather than asserted.

    How it's made

    RIPTIDE is a constant-product AMM that runs as SwapVM programs over 1inch Aqua. The curve is ordinary `x · y = k`. Around it sit two control layers: a fee that tracks realized volatility, and an auction that sells the right to reprice a stale pool and keeps most of the surplus with the maker. The repo is a pnpm monorepo. Contracts are Foundry (Solidity 0.8.30, `via_ir`, 700 optimizer runs, Cancun — the same settings as pinned SwapVM, because opcode-table layout is build-dependent). Everything else is all-ESM TypeScript. The subgraph is AssemblyScript. The app is Next.js 15. Python is used only as an independent math oracle. Aqua holds allowance records. Maker tokens stay in maker wallets until a trade pulls them. Two slim SwapVM routers execute the programs — one for taker swaps, one for rebalances. Behind them sit a volatility oracle, a fee provider the VM staticcalls mid-swap, and a stateless kernel that does the surplus and β split. Periphery is a quoter that prices through the real VM, a lens, a permissionless auction settler, and a batch executor for atomic multi-maker routes. Off-chain (solver, resolver bot, vol indexer, MCP server, subgraph, web app) is untrusted: none of it signs for a user, and none of it can move maker inventory, settle a loss-making rebalance, or make the settled fee differ from the quoted fee. The contracts recompute from live state. We compile against SwapVM v1.0.2 and Aqua v1.0.0 — the releases 1inch has actually deployed, never `main`. GitHub `main` has a different fee-provider signature that does not exist in any published release; we implement the v1.0.2 one, `getFeeBpsAndRecipient → (uint32, address)`, which `Fee.sol` decodes as exactly 64 bytes. `@1inch/aqua-sdk` 0.3.4 builds all ship/dock calldata in production. `@1inch/swap-vm-sdk` is installed and unused. OpenZeppelin 5.4.0 and Solady 0.1.26 (ln/exp/pow/sqrt, wrapped with explicit domains) fill the rest. 1inch has no Aqua on Base Sepolia, so the demo deploys an unmodified official Aqua v1.0.0 instance; integration against the real registry is a mainnet fork test that ships WETH/USDC into the canonical Aqua address. ### Aqua Aqua is a shared-liquidity registry. A maker approves it once and distributes virtual balances across strategies. RIPTIDE's routers register as Aqua apps, which is what lets them pull and push against a maker's ledger. `ship` is called from the maker's own wallet — no RIPTIDE contract ever calls it — and commits `strategyHash = keccak256(abi.encode(order))`. `safeBalances` is read on every quote and every swap; SwapVM seeds `balanceIn`/`balanceOut` from it before running the program, never from a cache or an indexer. `pull` sends swap output to the taker, the β rebate to whoever is settling a rebalance, and the protocol fee (that last one pulled by SwapVM itself). `push` credits taker input into the maker's strategy. `dock` cancels and releases balances. There is no RIPTIDE vault and no LP share token. The same tokens can back several strategies at once because Aqua distributes allowance, not capital. A docked strategy cannot execute even if router runtime is stale. Fee collection on the swap path is best-effort by upstream design: SwapVM wraps the fee pull and emits a skip rather than reverting, so a one-sided maker position stays tradable. ### SwapVM A strategy is a bytecode program: `[opcode:1][argsLength:1][args:N]`. RIPTIDE emits two. Mechanism 1 uses only upstream opcodes. Mechanism 2 adds two RIPTIDE instructions. Reserves are not loaded by a balance opcode — the Aqua trait makes the router seed registers from `safeBalances` before `runLoop`. Emitting `DynamicBalances` would overwrite those Aqua-seeded registers, so it is absent. Aqua stores no policy, and neither does either router. The full policy has to be immutable at ship time, so it goes in the order: `order.data = [226-byte payload][program bytes]`, `order.traits = (1 << 254) | (226 << 208)`. Bit 254 tells SwapVM to authorise via Aqua and seed from `safeBalances`. `226 << 208` writes the program start into MakerTraits slice index 3 (bits 208–223), so the VM sees `data[226:]` and never the payload. On-chain resolution honours this packing; the SwapVM SDK normalises it away. Re-encoding a live order through that SDK returns 224 bytes instead of 448, drops the payload, and orphans the Aqua position — which is why order construction stays with our codec. The Aqua SDK produced byte-identical calldata to what we had hand-rolled, so it replaced that code. The SDK's ESM build is also broken (a deep import with no `exports` map), so it cannot be imported from this workspace anyway. Because the Aqua trait is set, SwapVM's order hash is the same value Aqua computes as `strategyHash`. Any change to payload, program, or auction start is a different Aqua position. The payload is packed big-endian, magic `RPT1`: tokens, salt, reserve snapshots, fee band, PI gains, auction parameters, oracle config, fee-provider address. `maker` is deliberately not in it. Decoding is never authorisation — every runtime path re-reads live Aqua balances. The codec exists in Solidity, TypeScript, and Python, held identical by committed vectors. Four identifiers are never interchangeable: `policyHash = keccak256(payload)`, `strategyHash = keccak256(abi.encode(order))`, `strategyKey = keccak256(abi.encode(maker, salt))`, and `marketId` of the ordered pair. Aqua events speak in hashes; router and fee-provider events speak in keys. The subgraph is what reconciles them. SwapVM dispatches on an index into a function-pointer array. Aqua's opcode set builds a fixed array and converts it to a dynamic one by writing the length into slot 0 with assembly, which overwrites element 0. Every runtime index is therefore one less than its source position — `XYCSwap` dispatches at 17, not 18. A test resolves the indices from the live table so a silent upstream renumber fails the build instead of swapping in the wrong instruction. We do not repeat that assembly write when extending the table; we allocate a new array and append at 34–37. Opcode 37 (`OraclePriceAdjuster`) is reserved and unused: it rewrites swap amounts toward a Chainlink price, which would fight Mechanism 1 rather than observe volatility. 1inch's all-opcodes router does not fit EIP-170, and the stock Aqua AMM router has no slot for a custom instruction. We deploy two slimmed routers on the same SwapVM base, each with a reduced opcode table at the canonical Aqua indices. Mechanism 2 is further split into a router and a module. The swap router is 24,548 bytes against the 24,576-byte cap — 28 bytes of headroom, asserted in CI. To check it is still a real Aqua AMM program, the Mechanism 1 program is shipped to a stock unmodified `AquaSwapVMRouter` and asserted to price identically, to the wei. ### Mechanism 1 The swap program is 41 bytes: `Deadline → aquaDynamicProtocolFee → XYCSwap → Salt`. Every opcode is upstream. The dynamic fee needed no new opcode — SwapVM already staticcalls a provider; we answer with a contract that returns a committed, volatility-indexed rate. The fee instruction wraps the rest: save `amountIn`, subtract the fee, run the swap and salt on the net input, restore, then try to pull the fee. Volatility is estimated on-chain (EWMA of log returns, optional Garman–Klass range term, clamped, stale-frozen). Only the rebalance router and a governed indexer key may `observe`; quoting is static and writes nothing. The controller targets `φ* = (σ²/8)/λ`, clamps into the maker's `[feeMin, feeMax]`, and steps with a PI loop and anti-windup. The maker sets the band and the gains, not the fee. The provider is `view` and returns the already committed `feeReported`. The controller advances only after a swap or a rebalance, so within a block the quoted fee and the charged fee are the same number. RIPTIDE denominates fees at `1e7 = 100%`; SwapVM v1.0.2 uses `1e9`. We initially returned the wrong scale, so the VM charged 100× too little, silently, with every test still green because they shared the bug. The conversion now lives at the one provider return (`×100`). Setting the scale back to 1 breaks five tests. ### Mechanism 2 The rebalance program is 66 bytes: Deadline, then a declining-price instruction that scales `balanceIn` by `decay^(now − start)`, then wrapping `Decay` (anti-sandwich), `XYCSwap`, the custom rebalance instruction, and Salt. Deadline is first so an expired auction reverts before any balance is touched. SwapVM's `DutchAuction` lives in the limit-order opcode group, not the AMM group. Rather than borrow across that boundary, the schedule is a RIPTIDE instruction that only scales a reserve register. `XYCSwap` still prices from the constant-product curve. Shrinking `balanceIn` reduces what the resolver must pay over time, so surplus decays toward zero and they race to be first at a still-profitable price. On settlement the module decodes `(beta, staleInWad)` from 24 bytes of args, takes the rebate recipient from the VM taker (not from the args), measures `S = executedIn − staleIn`, reverts if negative, and pays `⌊(1−β)·S⌋` via Aqua `pull`. The retained remainder stays in the maker's Aqua balance. The split also runs in static context, so a quote of a loss-making rebalance reverts the same way settlement would. The price written back to the oracle is the auction-clearing price, which moves σ and therefore the next fee target. That loop is a design property, not a theorem. The rebate recipient used to be encoded in the program args. The program is part of `order.data`, which is part of the strategy hash, so an address in the args pinned the order to one resolver. On a live deployment only the pre-designated wallet could settle, which also breaks the auction: a monopolist waits until the price decays to wherever their margin is fattest. Args are now `beta ‖ staleInWad`, and the recipient is `ctx.query.taker`. That immediately showed a second bug: the settler is the VM taker, so the bought base landed on the settler and was never forwarded. It now sweeps both legs — unspent quote plus rebate, and the base — back to `msg.sender`. Anyone can call it; protection is the surplus check, not an allow-list. `RebalanceSettled` names the VM taker, which on this path is the settler contract. `AuctionSettled` names `msg.sender`. The subgraph joins them in the same transaction so attribution is per wallet rather than one contract address forever. ### Math WAD fixed point, explicit rounding, revert on zero denominator or overflow. `amountIn` ceils, `amountOut` floors, the β rebate floors — all three favour the maker. SwapVM registers are output-per-input; the UI shows quote-per-base; conversion happens only at the market boundary. ### The Graph RIPTIDE is single-maker micro-pools: each maker ships their own strategy, and there is no on-chain registry to enumerate them. Aqua is keyed by hash and exposes balance getters only. So the taker path is discover off-chain, verify on-chain. The subgraph answers what exists; the contracts re-derive every number that matters. A stale snapshot can make a route revert. It cannot authorise a bad fill. It also gives the time series (fees vs σ, recapture vs payout) that an RPC node does not. Six datasources, including 1inch Aqua. Lifecycle and inventory are Aqua's `Shipped`/`Docked`/`Pushed`/`Pulled`, filtered to the RIPTIDE app. The rest are our router, fee provider, batch executor, rebalance router, and settler. A fill on our router resolving to a strategy created by an Aqua `Shipped` is a cross-contract join, which is why this is one subgraph. The load-bearing field is `orderBytes`. Policy lives only in the 226-byte payload inside the Aqua order. Aqua has no getter for it; neither router stores it. The only place those bytes appear is Aqua's `Shipped` event. The mapping keeps them; the solver decodes them and rebuilds a priceable strategy. Without that field, anything a real maker ships through the UI is invisible to takers. No RPC call recovers it. Aqua events carry `strategyHash`; our events carry `strategyKey`. A secondary index maps key → strategy. The fee-provider handler returns early when the index is missing rather than inventing a dangling row. For settlement, the rebalance handler writes a join keyed by `txHash-strategyKey`; the later `AuctionSettled` in the same transaction rewrites `settledBy` and rolls amounts into a per-wallet Resolver aggregate. That is why the Rebalance entity cannot be immutable. Every quote carries subgraph lag (`_meta.block` vs chain head). The UI shows the indexed block and a stale badge. If no subgraph is configured, reads fall back to `getLogs` from the deploy block: the app still works, but maker-shipped strategies stop being routable, freshness becomes unknown, and aggregates are rescanned on every request. ### Off-chain and the app The solver discovers candidates, draft-optimises, refreshes live Aqua balances over RPC, re-optimises, stamps each fill with a version token, builds orders, `eth_call`-simulates the whole batch, and returns unsigned calldata targeting the batch executor only. Simulation is the gate. It originally priced against reserves committed in the order bytes — those are the strategy's initial inventory, frozen for the life of the order — while the VM prices against live Aqua balances. Multi-fill routes overshot slippage and reverted. It now prices against live balances; quoted output matches realised output to the wei. The resolver bot previews on-chain and settles when the rebate clears a threshold; the no-surplus guard is the real protection. The vol indexer pushes prices and computes no volatility — EWMA and clamps are on-chain. The MCP server exposes executable size (quoter as ground truth) and recapture stats. The web app is Next.js 15, viem/wagmi, Reown AppKit. Six routes (landing, make, swap, resolve, positions, analytics). The browser holds no RPC secrets; addresses come from one deployment manifest, and CI fails on hardcoded ones. Writes are server-built transaction plans walked by a stepper, so approve-then-execute is one click. Quotes show the fee and the σ that produced it. There is no in-place strategy edit: change means dock and ship a new salt. ### Verification A Python `Decimal` oracle (120 digits, no Solidity imports) emits committed JSON vectors. Solidity and TypeScript consume the same files. 120 test functions; five named invariants each with a negative control so a green suite is evidence the tests can fail. Fork tests cover the canonical 1inch Aqua registry, the stock AquaSwapVMRouter, and permissionless settlement. A live-app script walks every persona through the same HTTP endpoints the browser uses: 20 transactions, 41/41 assertions. After one settlement σ moved from 0 to 0.1124 and the fee target shifted with it. Base Sepolia, deployed at block 46742718, with a Graph Studio subgraph and the web app on Railway building from committed ABIs (no Foundry in the container).