diff --git a/README.md b/README.md index b4ac3f2..71d3a1c 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ Opting in costs the user nothing in calldata: the ABI is identical, so only the address changes, and on Base the L1 data fee is unchanged. The whole cost is L2 execution gas, about $0.0003 to $0.0005 per swap at August 2026 prices. -**[contracts/README.md](contracts/README.md)** is the detailed document: the mechanism, the -compression format, break-even economics with live prices, the trust model, and how to run the -tests. Start there. +**[crates/fynd-jit-solver/README.md](crates/fynd-jit-solver/README.md)** is the detailed +document: the mechanism, the solve loop, what authorizes an emission, the compression format, +break-even economics with live prices, the trust model, and how to run the tests. Start there. +**[contracts/README.md](contracts/README.md)** covers the three contracts on their own. ## Intent backrunning @@ -52,15 +53,13 @@ and the smoke test. | path | what it is | | --- | --- | | [`contracts/`](contracts) | Foundry project: `FyndJITRouter`, `RouteDictionary`, `RouteDecompressor`, and the `BackrunResolver` the intent mode settles through | +| [`crates/fynd-jit-solver`](crates/fynd-jit-solver) | The JIT solver: open book, improved-route quoting, the profitability gate, and the `storeBatch` it emits | | [`crates/route-compressor`](crates/route-compressor) | Rust mirror of the on-chain compression format, plus the builder's calldata encoders | | [`crates/intent-backrunner`](crates/intent-backrunner) | The Fusion intent engine | -| [`crates/builder-types`](crates/builder-types) | The types that cross the builder boundary: `BuildEvent` in, `BackrunCandidate` out | +| [`crates/solver-core`](crates/solver-core) | What both solvers share: the tycho quote client, build-iteration tracking, pending-state replay, and the fee conventions | +| [`crates/builder-types`](crates/builder-types) | The types that cross the builder boundary: `BuildEvent` in, `JitFrontrun` and `BackrunCandidate` out | | [`crates/uniswap-v2-core`](crates/uniswap-v2-core), [`v3`](crates/uniswap-v3-core), [`v4`](crates/uniswap-v4-core) | Log-driven pending-state indexers for those three protocols | -The JIT solver itself, `crates/fynd-jit-solver`, is under review in -[PR #19](https://github.com/propeller-heads/builder-integration/pull/19) and is not yet on the -main line. - ## Deployments `FyndJITRouter` is not deployed yet. On Base it will front the live TychoRouterV3 at @@ -71,8 +70,9 @@ main line. ```sh cd contracts && forge test # 107 tests, ~2 min from a clean build -cargo test --workspace # 102 tests +cargo test --workspace # 256 tests, 3 ignored ``` -Both READMEs document the fork suites, the cross-language fixtures, and the environment -variables they need. +`cargo test --workspace` rewrites `contracts/test/fixtures/route_selector/*.json`, so check +`git status` afterwards. The crate READMEs document the fork suites, the cross-language +fixtures, and the environment variables they need. diff --git a/contracts/README.md b/contracts/README.md index 6a113eb..502715f 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,112 +1,84 @@ -# FyndJITRouter: JIT route improvement +# Contracts -A user sends a swap to `FyndJITRouter` instead of sending it to `TychoRouterV3`. A block -builder that sees the pending order solves a better route against pending state, stores it, -and places the store transaction immediately before the user's swap in the same block. The -router substitutes the improved route if it matches that exact order, and forwards the -user's original route untouched if it does not. +Foundry project holding the three contracts of the JIT route-improvement system plus the +`BackrunResolver` the intent mode settles through. -Nothing about the user's transaction changes except its destination address. The six -entrypoints carry the same names and parameter lists as the TychoRouterV3 entrypoints they -front, so the calldata is byte-identical and the value is the same. `minAmountOut` is -enforced identically on both paths, and every failure mode ends in the user's own route -executing. +For what the JIT system is, why a builder would run it, the economics and the trust model, read +**[`crates/fynd-jit-solver/README.md`](../crates/fynd-jit-solver/README.md)**. This document +covers the on-chain surface only. -## Contents - -- [How a swap flows](#how-a-swap-flows) -- [Architecture](#architecture) -- [Economics](#economics) -- [What opting in costs the user](#what-opting-in-costs-the-user) -- [Trust model and limits](#trust-model-and-limits) -- [Running the tests](#running-the-tests) - -## How a swap flows - -The user calls one of the six entrypoints on `FyndJITRouter` -([`src/FyndJITRouter.sol`](src/FyndJITRouter.sol)): - -| entrypoint | selector | +| contract | role | | --- | --- | -| `singleSwap` | `0x0c1a0ee7` | -| `sequentialSwap` | `0x3c226834` | -| `splitSwap` | `0xfe745a0f` | -| `singleSwapPermit2` | `0xca931073` | -| `sequentialSwapPermit2` | `0x631eecea` | -| `splitSwapPermit2` | `0x9b676069` | - -`FyndJITRouter` pulls the input token (or takes native ETH as call value), then computes the -order key: - -```solidity -keccak256(abi.encode(sender, tokenIn, tokenOut, amountIn, expectedAmountOut, minAmountOut, receiver, block.number)) -``` - -The key binds every routing-relevant argument plus the block, so a stored route applies to -exactly one order in exactly one block. `expectedAmountOut` is part of the key because the -quote decides how much of a substituted route's output reaches the receiver: two orders -quoted differently are different orders. - -The key's low bits pick a ring slot (`uint256(key) % ROUTE_CAPACITY`) and its top 32 bits are -the fingerprint. `FyndJITRouter` reads the slot and compares the fingerprint the stored -route's header carries against the fingerprint of the key it just computed. On a match it -decompresses the route and calls the router with it. On a mismatch, an empty slot, a failed -decompression, or a revert inside the substituted route, it forwards the user's original -call verbatim. - -Both branches call the same live `TychoRouterV3`, which enforces `minAmountOut` and validates -it against `expectedAmountOut` (`minAmountOut` may not exceed the quote, nor sit more than -`MAX_SLIPPAGE_TOLERANCE_BPS` below it). `FyndJITRouter` does not duplicate that check, so an -order the router would refuse is refused identically whichever address it was sent to. - -Three details a client integrating this needs to know: - -- **Permit2 variants name `FyndJITRouter` as the spender.** The user signs - `permitSingle.spender` as `FyndJITRouter`, not the router. `FyndJITRouter` pulls the funds - and holds a standing ERC-20 approval to the router. Users who already hold the canonical - `approve(Permit2, max)` need no new on-chain approval, only a different signed permit. -- **A `receiver` equal to the router address is rejected** with `InvalidReceiver(address)`. - That receiver selects the router's vault-rebalance mode, where the output is credited to - the caller's ERC-6909 balance inside the router rather than transferred. The caller here is - `FyndJITRouter`, which has no withdrawal path for such a balance. This is the one order the - two destinations do not treat alike; every other receiver, `address(0)` included, is - forwarded for the router to accept or reject. -- **Native ETH follows the router's convention.** The three plain entrypoints are `payable`, - `tokenIn` is the `ETH_ADDRESS` marker `0xEeee…EEeE` (not `address(0)`, which the router - rejects), and `msg.value` must equal `amountIn`. Value sent with an ERC-20 `tokenIn` is - rejected rather than stranded. The `…Permit2` variants are non-payable, as on the router. - -A substituted route runs with client-fee parameters zeroed. The router's `clientSignature` -covers the swaps blob, so a caller's signature cannot authorize a fee on a route the builder -chose. The caller's fee applies unchanged whenever their own route runs. - -## Architecture - -**[`src/FyndJITRouter.sol`](src/FyndJITRouter.sol)** holds the user entrypoints, the route -ring, and the builder entrypoints `storeRoute(key, words)` and -`storeBatch(entries, expectedFirstIndex, keys, words)`, both behind `BUILDER_ROLE`. -`storeBatch` appends dictionary entries and stores several routes in one transaction, which -amortizes the 21,000 intrinsic gas charge and makes `expectedFirstIndex` safe: routes -referencing new dictionary entries land in the same transaction as the append, so no foreign -append can interleave. - -The ring is a fixed `mapping(uint256 => bytes32)` of `ROUTE_CAPACITY` slots (65,536 in -[`script/DeployFyndJITRouter.s.sol`](script/DeployFyndJITRouter.s.sol)). Routes occupy up to -five contiguous slots, wrapping. Slots are never cleared, so once the ring is warm every -store overwrites a non-zero slot instead of paying for a fresh one, and state stays bounded. -Stale content is harmless because the fingerprint check rejects it. Avoiding ring collisions -within one block is the builder's job, and the keys are known in advance. - -**[`src/RouteDictionary.sol`](src/RouteDictionary.sol)** is an append-only list of byte -sequences. Entries are 1 to 31 bytes, because 31 bytes packs data and length into a single -storage slot under Solidity's short-bytes layout, keeping an append at one cold `SSTORE`. -Indices are assigned sequentially and entries are immutable once written. -`append(entries, expectedFirstIndex)` reverts unless `expectedFirstIndex == count()`, so a -compressed route that references a not-yet-appended index can never bind to a different -entry. `read(start, maxCount)` serves off-chain mirrors. - -**[`src/RouteDecompressor.sol`](src/RouteDecompressor.sol)** is stateless and knows nothing -about protocols. It reads an MSB-first bit stream over the concatenated words: +| [`src/FyndJITRouter.sol`](src/FyndJITRouter.sol) | User entrypoints, the route ring, and the builder's store entrypoints | +| [`src/RouteDictionary.sol`](src/RouteDictionary.sol) | Append-only list of byte sequences that compressed routes reference by index | +| [`src/RouteDecompressor.sol`](src/RouteDecompressor.sol) | Stateless expansion of a compressed route back into a router `swaps` blob | +| [`src/BackrunResolver.sol`](src/BackrunResolver.sol) | 1inch Fusion settlement target for the intent-backrunning mode | + +## FyndJITRouter + +Six user entrypoints (`singleSwap`, `sequentialSwap`, `splitSwap` and their `…Permit2` +variants) carry the same names and parameter lists as the `TychoRouterV3` entrypoints they +front. Both the substitution path and the fallback call the live router, which is where +`minAmountOut` is enforced. + +Builder entrypoints, both behind `BUILDER_ROLE`: + +- `storeRoute(bytes32 key, bytes32[] words)` writes one compressed route. `words = [bytes32(0)]` + disables the route in that key's slot. +- `storeBatch(bytes[] entries, uint256 expectedFirstIndex, bytes32[] keys, bytes32[][] words)` + appends dictionary entries and stores several routes in one transaction. Either array may be + empty. It requires `FyndJITRouter` to hold `BUILDER_ROLE` on the dictionary, since it appends + on the builder's behalf. + +Views: `orderKey(sender, tokenIn, tokenOut, amountIn, expectedAmountOut, minAmountOut, receiver)` +folds in `block.number` itself, `slotIndex(key)` is `uint256(key) % ROUTE_CAPACITY`, and +`keyFingerprint(key)` is the key's top 32 bits. Admin holds `setDecompressor` and `rescueToken`; +there is deliberately no ETH counterpart to the latter and no `receive()`. + +### Invariants + +- **A stored route binds one order in one block.** The key covers every routing-relevant + argument plus `block.number`. The slot comes from the key's low bits and the header + fingerprint from its top 32 bits, so the two are independent by construction and a foreign + route landing on the same slot is rejected rather than executed. +- **The header's word count must equal `words.length`** (`_storeRoute`). Slot contents from + earlier, longer routes persist, so this is what stops a route decompressing against another + store's leftovers. +- **Every failure ends in the user's own route running.** A mismatch, an empty slot, a failed + decompression and a revert inside the substituted route all fall through to + `_forwardOriginal`, which passes the same `expectedAmountOut`. +- **Ring slots are never cleared.** State stays bounded at `ROUTE_CAPACITY` slots and a warm + store is a non-zero overwrite. Stale content is harmless because of the fingerprint check. + Avoiding collisions between two orders in one block is the builder's job; the contract does + not help. +- **`receiver == address(ROUTER)` reverts** with `InvalidReceiver`. That receiver selects the + router's vault-rebalance mode, which would credit the output to this contract's ERC-6909 + balance with no withdrawal path. `address(0)` is left to the router, which rejects it. +- **Native ETH uses the `NATIVE_TOKEN` marker** `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE`, + not `address(0)`, and `msg.value` must equal `amountIn`. Value sent with an ERC-20 `tokenIn` + reverts with `UnexpectedValue` rather than being stranded. +- **A substituted route runs with client-fee parameters zeroed.** The router's + `clientSignature` covers the swaps blob, so a caller's signature cannot authorize a fee on a + route the builder chose. +- **`ROUTE_CAPACITY >= MAX_WORDS`**, checked in the constructor (`InvalidCapacity`). + +## RouteDictionary + +An append-only list of byte sequences, indices assigned sequentially and entries immutable once +written. Entries are 1 to 31 bytes (`MIN_ENTRY_LENGTH`, `MAX_ENTRY_LENGTH`), because 31 bytes +packs data and length into a single storage slot under Solidity's short-bytes layout, which +keeps an append at one cold `SSTORE`. + +`append(entries, expectedFirstIndex)` reverts with `UnexpectedFirstIndex` unless +`expectedFirstIndex == count()`, so a compressed route referencing a not-yet-appended index can +never bind to a different entry. `read(start, maxCount)` serves off-chain mirrors and `get(index)` +one entry. + +## RouteDecompressor + +Stateless, and knows nothing about protocols: it is a concatenation machine over dictionary +entries and literals whose output is the exact `TychoRouter` `swaps` blob. It reads an MSB-first +bit stream over the concatenated words: ``` [0..3) version = 1 @@ -117,318 +89,67 @@ about protocols. It reads an MSB-first bit stream over the concatenated words: then ops: 00 DICT (+ 20-bit index) | 01 LIT (+ 6-bit len-1, + payload) | 10 END ``` -The 43-bit header and the closing END tag leave 1,235 payload bits in a five-word route. A -route is capped at -`MAX_WORDS = 5`, `MAX_OPS = 64` and `MAX_OUTPUT = 2048` bytes. The output is the exact -TychoRouter `swaps` blob: the decompressor is a concatenation machine over dictionary entries -and literals. - -**[`../crates/route-compressor`](../crates/route-compressor)** is the Rust mirror of that -format. `codec::compress` / `codec::decompress` are the round trip, `dictionary::Dictionary` -holds the off-chain mirror (`apply_page`, `lookup`), `order::order_key` and -`order::key_fingerprint` mirror the Solidity views, `segment::proposals` picks dictionary -candidates out of a blob, and `contracts::{encode_store_route, encode_store_batch, -encode_append, decode_swap_calldata}` build and read the builder's calldata. The two -implementations are pinned together by the cross-language fixtures described under -[Running the tests](#running-the-tests). - -**The builder-side solver lives in `crates/fynd-jit-solver`**, which is under review in -[PR #19](https://github.com/propeller-heads/builder-integration/pull/19) (head -`feat/jit-solver-engine`, base `feat/jit-solver-book`) and is not on this branch. It watches -for orders addressed to `FyndJITRouter`, quotes an improved route against pending state, -runs a profitability gate over L2 gas plus the OP-stack L1 data fee, and emits the -`storeBatch` transaction. - -Routes compress to a median of one 256-bit word, measured over a week of Base routes in the -solver's hindsight run. The three cross-language fixtures in this repo come out at 1, 1 and 2 -words for blobs of 86, 168 and 204 bytes. - -### Deployment - -`FyndJITRouter` is not deployed on any chain yet. -[`script/DeployFyndJITRouter.s.sol`](script/DeployFyndJITRouter.s.sol) deploys the -dictionary, the decompressor and the router, and prints the role grants that follow. It reads -`TYCHO_ROUTER`, `ADMIN` and `DEPLOYER_PRIVATE_KEY`. The addresses it points at on Base are the -live TychoRouterV3 at `0x9bA632d83e9eF57571256Cf4cc951b8aF1158e9C` and Permit2 at -`0x000000000022D473030F116dDEE9F6B43aC78BA3`. - -Note the extra grant the script prints last: `storeBatch` appends to the dictionary on the -builder's behalf, so `FyndJITRouter` itself needs `BUILDER_ROLE` on the dictionary. - -## Economics +The 43-bit header (`HEADER_BITS`) and the closing END tag leave 1,235 payload bits in a +five-word route. `MAX_WORDS = 5`, `MAX_OPS = 64`, `MAX_OUTPUT = 2048` bytes. `wordCount(word0)` +and `fingerprint(word0)` read the header without decompressing, which is what `_storeRoute` and +the substitution path use. -Two different questions, with two different numbers. Conflating them gives the wrong answer -to both. - -1. **Is this one frontrun worth sending?** Compare the cost of the store transaction against - the improvement, given that an improvement exists. -2. **Is running the integration worth it at all?** Compare the same cost against the average - improvement over all quotes, including the majority with no improvement. - -Both numbers price **one route per frontrun transaction**, which is the common case. A -builder that batches several routes into one `storeBatch` amortizes the 21,000-gas intrinsic -charge across them and pays less per route, but nothing below assumes it does. - -### Prices and measured gas - -Live inputs, fetched 2026-08-14 11:56 UTC (Base block 49959633, Ethereum block 25753117): - -| quantity | value | source | -| --- | --- | --- | -| Base gas price | 0.0060 gwei (base fee 0.0050) | `cast gas-price --rpc-url https://mainnet.base.org` | -| Ethereum gas price | 0.1012 gwei (base fee 0.1011) | `cast gas-price --rpc-url https://ethereum-rpc.publicnode.com` | -| ETH/USD | $1,874.23 | Chainlink `0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419`, `latestRoundData()` | -| builder tip | 0.001 gwei | `DEFAULT_BUILDER_TIP_WEI`, configurable | -| Base L1 data fee, 1-route `storeBatch` | 1,033,000,310 wei (356 calldata bytes) | `GasPriceOracle.getL1Fee` at `0x420…000F` | - -Both gas prices are unusually low. Ethereum at 0.1 gwei is near multi-year lows, which -matters below. - -The builder tip is Fynd's money. The frontrun pays the builder nothing through the swap, so -the tip is what compensates the builder for including it, and Fynd funds the wallet it comes -from. The gate charges base fee and tip together for that reason. It lives in the solver -crate on [PR #19](https://github.com/propeller-heads/builder-integration/pull/19), which is -not merged. - -Execution gas, measured on the same day against Base head with -`forge test --match-test test_gas_overhead_comparison -vv`: - -| path | gas | -| --- | --- | -| direct TychoRouterV3 `sequentialSwap` | 89,448 | -| via `FyndJITRouter`, no stored route (miss) | 119,724 | -| via `FyndJITRouter`, stored route executed (hit) | 132,109 | -| `storeRoute`, one word, reused ring slot | 3,300 | -| `storeRoute`, one word, fresh ring slot | 31,803 | -| `dict.append`, 3 chunks | 100,716 | +[`crates/route-compressor`](../crates/route-compressor) is the Rust mirror of this format. The +two are pinned together by `test/RouteFixtures.t.sol`. -The fork suite forks the RPC head, so absolute numbers drift by tens of thousands of gas -between runs as vault slots go cold or warm. The deltas hold to a few hundred gas, so the -model uses those: 30,276 for the miss overhead and 12,385 more for a substitution. +## Deployment -The steady state is the reused-slot store, 3,300 gas. Over a week of Base routes (252,489 of -them), 99.5% to 99.8% of stores needed no dictionary append from day three onward, and the -ring never clears a slot, so the fresh slot (31,803) and the append (100,716) are the rare -cases rather than the typical ones. - -### Break-even +`FyndJITRouter` is not deployed on any chain yet. +[`script/DeployFyndJITRouter.s.sol`](script/DeployFyndJITRouter.s.sol) deploys the dictionary, +the decompressor and the router with `ROUTE_CAPACITY = 65536`, then prints the role grants that +follow. It reads `TYCHO_ROUTER`, `ADMIN` and `DEPLOYER_PRIVATE_KEY`: +```sh +forge script script/DeployFyndJITRouter.s.sol --rpc-url $BASE_RPC_URL \ + --private-key $DEPLOYER_PRIVATE_KEY --broadcast ``` -frontrun_cost = L2_gas x (base_fee + builder_tip) + L1_data_fee (L1 term is zero off OP-stack) -V* = frontrun_cost_usd / (improvement_bps / 10_000) -``` -One route means 21,000 intrinsic plus 2,048 calldata gas for the 356-byte `storeBatch` plus -3,300 to write the ring slot, which is **26,348 gas**. On Base at 0.006 gwei that is -158,088,000,000 wei, and the L1 data fee adds 1,033,000,310 wei, for **$0.000298**. The same -transaction on Ethereum, where the whole charge is execution gas, costs 26,348 x 0.1021 gwei, -or **$0.005044**. - -With those costs: - -| question | improvement | Base | Ethereum | -| --- | --- | --- | --- | -| one frontrun pays for itself above | conditional on an improvement: 0.76 bps Base, 2.8 bps Ethereum | $3.92 | $18.02 | -| a stream of orders pays for itself above | averaged over all quotes: 0.09 bps Base, 0.24 bps Ethereum | $33.14 | $210.18 | - -The formula, worked: $0.000298 / (0.76 / 10,000) = $3.92 on Base, and -$0.005044 / (0.24 / 10,000) = $210.18 on Ethereum. - -Read the second row as the one that decides whether to run the integration: at any average -trade size above roughly $33 on Base, the flow pays for the transactions it costs. The first -row is the per-transaction decision the solver's gate makes, and it clears on almost any -trade worth routing. - -The second row is also conservative. It charges a store against every order, while the -builder only stores when it finds an improvement, so the real per-order cost scales down with -the hit rate. - -### Gas-price sensitivity - -Today's gas is cheap enough that a single break-even figure would mislead. Both tables hold -the trade improvement and the L1 data fee fixed and vary the chain's own gas price. The price -column is what the transaction pays per gas, base fee plus tip. - -Base: - -| Base price (gwei) | cost | V\* at 0.76 bps | V\* at 0.09 bps | -| --- | --- | --- | --- | -| 0.006 (now) | $0.000298 | $3.92 | $33 | -| 0.02 | $0.000990 | $13.02 | $110 | -| 0.10 | $0.004940 | $65.00 | $549 | -| 0.50 | $0.024693 | $324.91 | $2,744 | - -Ethereum: - -| Ethereum price (gwei) | cost | V\* at 2.8 bps | V\* at 0.24 bps | -| --- | --- | --- | --- | -| 0.102 (now) | $0.005044 | $18.02 | $210 | -| 1 | $0.049382 | $176.36 | $2,058 | -| 5 | $0.246910 | $881.82 | $10,288 | -| 20 | $0.987642 | $3,527.29 | $41,152 | - -### The L1 data fee - -On an OP-stack chain the store transaction pays an L1 data fee for its calldata on top of L2 -execution. The solver measures it per pass through `GasPriceOracle.getL1Fee` rather than -assuming a value, because its weight swings by orders of magnitude with L1 conditions. - -That fee is affine in the payload, not proportional, and for a payload this small the fixed -part is the whole of it. The one-route `storeBatch` pays 1,033,000,310 wei, the same fee to -the wei that a 100-byte payload of zeros pays: Base charges on the compressed size against a -minimum, and 356 bytes of mostly ABI padding compress below that minimum. Ten routes carry -4.24 times the payload for 1.66 times the fee (1,711,711,472 wei). So dividing a batch's L1 -fee by its route count understates the single-route charge badly. One route really pays 60% -of what ten routes pay, not a tenth of it, and that is the main reason single-route economics -come out worse than a per-route split of a batch suggests. - -At this snapshot the L1 term is small either way: 1,033,000,310 wei against 158,088,000,000 -wei of L2 execution, so L2 execution is 153 times the L1 data fee and the L1 term moves the -break-even by under a percent. That is a statement about today's L1, not about the design. -Base's L1 base fee is 0.104 gwei and its blob base fee 0.0062 gwei in this snapshot, both -near their historical floor. The L1 term scales with those two while the L2 term scales with -Base's own base fee, so at L1 conditions two orders of magnitude above today's, with Base gas -unchanged, the L1 data fee matches and then exceeds L2 execution. -[PR #19's gate](https://github.com/propeller-heads/builder-integration/pull/19) documents it -as routinely dominating for exactly that reason. Keep it in the model. - -### Hit rates - -The two improvement figures per chain imply a hit rate: 0.09 / 0.76 is 11.8% on Base and -0.24 / 2.8 is 8.6% on Ethereum. A separate measurement over 28,720 settled trades put the win -rate at 17.3%, with a median win of 14.11 bps conditional on winning. These are three -different samples and three different quantities, so they should not be averaged together. -The point is only that they land in the same range, which is a weak but real consistency -check on the improvement figures. None of this measurement tooling is in this repo; the -figures were confirmed on 2026-08-13 and the hindsight replay lives with the solver on -PR #19. - -## What opting in costs the user - -The calldata a user sends to `FyndJITRouter` is byte-identical to the calldata they would have -sent to `TychoRouterV3`. Only the destination address differs. On Base that matters more than -it sounds: **opting in does not change the L1 data fee at all**, because the L1 data fee is a -function of the calldata. The entire cost of opting in is L2 execution gas. - -Against a direct router call, measured above: - -| outcome | extra gas | cost on Base at 0.006 gwei | -| --- | --- | --- | -| miss, original route forwarded | +30,276 | $0.00034 | -| hit, improved route substituted | +42,661 | $0.00048 | - -The miss overhead buys the input hop into `FyndJITRouter`, the key computation and the ring -lookup. The extra 12,385 gas on a hit is almost all decompression. Included in the miss -figure is the receiver guard, one comparison against an immutable. - -In exchange, the user keeps their quote and their `minAmountOut` protection on both paths, and -on a hit they get a route that could not have been found when they signed. - -## Trust model and limits - -**Builders are whitelisted.** Storing a route requires `BUILDER_ROLE`, granted by -`DEFAULT_ADMIN_ROLE`. There is no permissionless store. - -**A compromised builder key can substitute a worse route, bounded by `minAmountOut`.** The -router enforces the user's `minAmountOut` on the substituted path exactly as on the original, -so the worst a malicious stored route can do is deliver `minAmountOut` on a swap that would -have delivered more. It cannot exceed the slippage the user already accepted, and it cannot -redirect output: `receiver` is part of the order key, so a route stored against a different -receiver does not match. - -**The improvement funds the sponsorship through positive-slippage capture.** A substituted -route is passed the user's own `expectedAmountOut`. Output above that quote is positive -slippage, which the router's FeeCalculator credits to the router fee receiver when -positive-slippage capture is enabled. It is enabled on the live Base deployment: the -FeeCalculator at `0xb6e39a6ce7224fbc73eb24b10a1d3443ff7fbfc1` answers -`getPositiveSlippageEnabled() == true`, with the fee receiver at -`0xA5503D92EE16a78E602996AD362674Dc49034A14`. A JIT improvement beyond the quote therefore -accrues protocol-side, where it pays for the builder sponsorship, and the user keeps their -quote either way. Note that the positive-slippage flag alone decides this, not the zeroed -client fee. - -**"Improvement" means captured surplus, which is not the frontrun's marginal contribution.** -The solver measures `improved_quote - expectedAmountOut`, the surplus the protocol captures -over the quote. Because positive slippage is captured on the forwarded path too, the user's -own route would have produced a capture of its own against the same quote, and the frontrun's -marginal contribution is the difference between the two. Frontend quotes are systematically -conservative, so a large part of a typical surplus is market drift between signing and -inclusion that the protocol would have captured with no transaction sent at all. PR #19's -`gate.rs` documents this and is worth reading before tuning a margin from the decision log. - -**Ring collisions are the builder's problem.** Two orders in one block whose keys land on -overlapping slots will clobber each other. The keys are known in advance, so the solver can -see it coming; the contract does not. - -**Not audited, not deployed.** 88 of the 107 tests in this directory cover this system, 14 of -them against live Base state, and that is the extent of the assurance today. - -## Running the tests - -Every command below was run at this commit. - -### Unit and fixture tests +Note the extra grant it prints last: `storeBatch` appends to the dictionary on the builder's +behalf, so `FyndJITRouter` itself needs `BUILDER_ROLE` on the dictionary. On Base the addresses +it points at are the live TychoRouterV3 at `0x9bA632d83e9eF57571256Cf4cc951b8aF1158e9C` and +Permit2 at `0x000000000022D473030F116dDEE9F6B43aC78BA3`. + +## Tests ```sh cd contracts && forge test ``` -107 tests, about 2 minutes from a clean build. The compile dominates: solc 0.8.25 with -`via_ir`, which the widest entrypoint's eleven parameters require (building an `Order` from -them overflows the stack without the IR pipeline, and upstream compiles the same way). -Re-runs against a warm cache take about 8 seconds. +107 tests, about 2 minutes from a clean build. The compile dominates: solc 0.8.25 with `via_ir`, +which the widest entrypoint's eleven parameters require (building an `Order` from them overflows +the stack without the IR pipeline, and upstream compiles the same way). Re-runs against a warm +cache take 20 to 30 seconds, nearly all of it the fork suite waiting on an RPC. + +88 of the 107 cover the JIT system: `FyndJITRouter.t.sol` (52), `FyndJITRouterFork.t.sol` (14), +`RouteDecompressor.t.sol` (12), `RouteDictionary.t.sol` (6) and `RouteFixtures.t.sol` (4). ### Base fork suite ```sh -cd contracts && BASE_RPC_URL=https://your-base-rpc forge test --match-contract FyndJITRouterForkTest -vv +BASE_RPC_URL=https://your-base-rpc forge test --match-contract FyndJITRouterForkTest -vv ``` 14 tests against the TychoRouterV3 deployed on Base at `0x9bA632d83e9eF57571256Cf4cc951b8aF1158e9C`, overridable with `TYCHO_ROUTER`. The router, its -executors, its FeeCalculator, the pools and Permit2 are all live deployments; the suite -deploys only the three contracts in `src/`. It reads the fee split off the live FeeCalculator -rather than assuming one, and `setUp` asserts the preconditions a swap needs (router present -and unpaused, executors past their 3-day activation delay) so a deployment change surfaces as -a named failure instead of a revert deep inside a swap. - -`BASE_RPC_URL` falls back to the public `https://mainnet.base.org`, which is what the plain -`forge test` above uses. That works, and it is rate-limited; use your own endpoint for -repeated runs. +executors, its FeeCalculator, the pools and Permit2 are all live deployments; the suite deploys +only the three contracts in `src/`. It reads the fee split off the live FeeCalculator rather than +assuming one, and `setUp` asserts the preconditions a swap needs (router present and unpaused, +executors past their 3-day activation delay) so a deployment change surfaces as a named failure +instead of a revert deep inside a swap. Two of the 14 print the gas tables the solver README +quotes under Economics: `test_gas_overhead_comparison` and `test_gas_native_hit_vs_erc20_hit`. -Two tests in the suite print the gas tables quoted under [Economics](#economics): -`test_gas_overhead_comparison` and `test_gas_native_hit_vs_erc20_hit`, both with `-vv`. +`BASE_RPC_URL` falls back to the public `https://mainnet.base.org`, which is what a plain +`forge test` uses. That works, and it is rate-limited; use your own endpoint for repeated runs. ### Cross-language fixtures `test/RouteFixtures.t.sol` decompresses fixtures generated by the Rust compressor through the -Solidity decompressor and asserts the blobs match byte for byte. That test is the contract -between the two implementations. Regenerate the fixtures from the Rust side with: - -```sh -cargo test -p route-compressor --test fixtures -``` - -It rewrites `contracts/test/fixtures/route_selector/*.json`. On this branch the output is -byte-identical to what is committed, so `git status` stays clean; check it after running, and -`git checkout -- contracts/` if anything moved. - -### Rust workspace - -```sh -cargo test --workspace -``` - -102 passed, 2 ignored. This includes the fixture generator above, so it also rewrites those -JSON files. - -### Solver integration test against anvil - -`crates/fynd-jit-solver/tests/store_batch_onchain.rs` deploys `RouteDictionary`, -`RouteDecompressor` and `FyndJITRouter` to a local anvil node, sends the solver's emitted -`storeBatch` transaction as a builder would, and reads the routes back out of the ring -decompressed by the contract. It skips loudly without failing when `anvil` is not on `PATH` -or when the contracts have not been built, so run `cd contracts && forge build` first. - -This test is on the PR #19 branch, not on `feat/route-selector`. Check out -`feat/jit-solver-engine` to run it. +Solidity decompressor and asserts the blobs match byte for byte. `cargo test --workspace` (and +`cargo test -p route-compressor --test fixtures`) rewrites +`test/fixtures/route_selector/*.json`. The values come out identical and the JSON key order does +not, so check `git status` afterwards and `git checkout -- contracts/` to drop the churn. diff --git a/crates/fynd-jit-solver/README.md b/crates/fynd-jit-solver/README.md new file mode 100644 index 0000000..2f01583 --- /dev/null +++ b/crates/fynd-jit-solver/README.md @@ -0,0 +1,559 @@ +# fynd-jit-solver: JIT route improvement + +A user sends a swap to `FyndJITRouter` instead of sending it to `TychoRouterV3`. A block +builder that sees the pending order solves a better route against pending state, stores it, +and places the store transaction immediately before the user's swap in the same block. The +router substitutes the improved route if it matches that exact order, and forwards the +user's original route untouched if it does not. + +Nothing about the user's transaction changes except its destination address. The six +entrypoints carry the same names and parameter lists as the TychoRouterV3 entrypoints they +front, so the calldata is byte-identical and the value is the same. `minAmountOut` is +enforced identically on both paths, and every failure mode ends in the user's own route +executing. + +This crate is the builder-side half: it keeps the open orders, quotes improvements, decides +which ones pay for themselves, and hands the builder one transaction to include. The three +contracts it writes to live in [`contracts/`](../../contracts), and +[`contracts/README.md`](../../contracts/README.md) documents their on-chain interfaces and +invariants. + +## Contents + +- [How a swap flows](#how-a-swap-flows) +- [The solve loop](#the-solve-loop) +- [What authorizes an emission](#what-authorizes-an-emission) +- [Compression](#compression) +- [Running the solver](#running-the-solver) +- [Economics](#economics) +- [What opting in costs the user](#what-opting-in-costs-the-user) +- [Trust model and limits](#trust-model-and-limits) +- [Running the tests](#running-the-tests) + +## How a swap flows + +The user calls one of the six entrypoints on `FyndJITRouter` +([`contracts/src/FyndJITRouter.sol`](../../contracts/src/FyndJITRouter.sol)): + +| entrypoint | selector | +| --- | --- | +| `singleSwap` | `0x0c1a0ee7` | +| `sequentialSwap` | `0x3c226834` | +| `splitSwap` | `0xfe745a0f` | +| `singleSwapPermit2` | `0xca931073` | +| `sequentialSwapPermit2` | `0x631eecea` | +| `splitSwapPermit2` | `0x9b676069` | + +`FyndJITRouter` pulls the input token (or takes native ETH as call value), then computes the +order key: + +```solidity +keccak256(abi.encode(sender, tokenIn, tokenOut, amountIn, expectedAmountOut, minAmountOut, receiver, block.number)) +``` + +The key binds every routing-relevant argument plus the block, so a stored route applies to +exactly one order in exactly one block. `expectedAmountOut` is part of the key because the +quote decides how much of a substituted route's output reaches the receiver: two orders +quoted differently are different orders. +[`route_compressor::order`](../route-compressor/src/order.rs) mirrors that derivation off +chain, as `order_key` and as `order_key_for` straight from a detected order. + +The key's low bits pick a ring slot (`uint256(key) % ROUTE_CAPACITY`) and its top 32 bits are +the fingerprint. `FyndJITRouter` reads the slot and compares the fingerprint the stored +route's header carries against the fingerprint of the key it just computed. On a match it +decompresses the route and calls the router with it. On a mismatch, an empty slot, a failed +decompression, or a revert inside the substituted route, it forwards the user's original +call verbatim. + +Both branches call the same live `TychoRouterV3`, which enforces `minAmountOut` and validates +it against `expectedAmountOut` (`minAmountOut` may not exceed the quote, nor sit more than +`MAX_SLIPPAGE_TOLERANCE_BPS` below it). `FyndJITRouter` does not duplicate that check, so an +order the router would refuse is refused identically whichever address it was sent to. + +Three details a client integrating this needs to know: + +- **Permit2 variants name `FyndJITRouter` as the spender.** The user signs + `permitSingle.spender` as `FyndJITRouter`, not the router. `FyndJITRouter` pulls the funds + and holds a standing ERC-20 approval to the router. Users who already hold the canonical + `approve(Permit2, max)` need no new on-chain approval, only a different signed permit. +- **A `receiver` equal to the router address is rejected** with `InvalidReceiver(address)`. + That receiver selects the router's vault-rebalance mode, where the output is credited to + the caller's ERC-6909 balance inside the router rather than transferred. The caller here is + `FyndJITRouter`, which has no withdrawal path for such a balance. This is the one order the + two destinations do not treat alike; every other receiver, `address(0)` included, is + forwarded for the router to accept or reject. +- **Native ETH follows the router's convention.** The three plain entrypoints are `payable`, + `tokenIn` is the `ETH_ADDRESS` marker `0xEeee…EEeE` (not `address(0)`, which the router + rejects), and `msg.value` must equal `amountIn`. Value sent with an ERC-20 `tokenIn` is + rejected rather than stranded. The `…Permit2` variants are non-payable, as on the router. + +A substituted route runs with client-fee parameters zeroed. The router's `clientSignature` +covers the swaps blob, so a caller's signature cannot authorize a fee on a route the builder +chose. The caller's fee applies unchanged whenever their own route runs. + +## The solve loop + +The solver makes no assumption about how a builder finds `FyndJITRouter` calls. Detection is +the builder's job; this crate defines the interface. The builder pushes `JitOrderEvent`s in, +the solver keeps a book of everything still live, and every completed builder iteration +re-attempts improvement of every open order. + +| module | responsibility | +| --- | --- | +| [`src/book.rs`](src/book.rs) | `OpenBook`: the orders the builder has detected and not yet closed | +| [`src/decode.rs`](src/decode.rs) | `decode_fynd_router_tx`: a raw signed transaction to a detected order, with the canonical `order_ref` | +| [`src/engine.rs`](src/engine.rs) | One solve pass: measure, rank, gate, assemble | +| [`src/quoter.rs`](src/quoter.rs) | `RouteQuoter`: improved routes and surplus valuation from tycho | +| [`src/route_delta.rs`](src/route_delta.rs) | Whether an improved route is a different execution from the one the order already carries | +| [`src/gate.rs`](src/gate.rs) | What an emission costs, what the improvement is worth, and whether the first justifies the second | +| [`src/l1fee.rs`](src/l1fee.rs) | The OP-stack L1 data fee, measured rather than modelled | +| [`src/compress.rs`](src/compress.rs) | An improved route to the words `storeBatch` takes, for one order in one block | +| [`src/dictionary.rs`](src/dictionary.rs) | `ChainDictionary`: reading the deployed `RouteDictionary` into the local mirror | +| [`src/decisions.rs`](src/decisions.rs) | One JSONL row per order per block, emitted or refused | +| [`src/state.rs`](src/state.rs) | The little that persists between passes: where each order's last route was stored | + +`solve_loop` in [`src/lib.rs`](src/lib.rs) drives the two channels. Order intake and solving +share one task on purpose, and intake is `biased` first in the `select!`: a close already in +the queue has to be applied before the pass that would otherwise solve for an order the +builder has let go. + +One pass is five stages, all of them in `Engine::solve_registered` +([`src/engine.rs`](src/engine.rs)): + +1. **Measure.** Quote an improved route for every open order in one tycho solve, and compare + it against `expectedAmountOut` from the user's own calldata. +2. **Rank.** Value each surplus in ETH, compress each route against the synced dictionary + mirror, and sort by improvement per unit of gas. Ordering happens before anything is + gated, because the book is a `HashMap`: without a sort, the sequence a pass walks (and so + which of two orders sharing a hop pays for the dictionary append inside its own gate) + changes between runs on identical input. That sequence is the dataset the margin gets + tuned from. +3. **Probe the L1 rate.** One `GasPriceOracle.getL1Fee` call per pass, turned into a rate per + unit of calldata gas so the per-order gate can charge each candidate its share. No call at + all when nothing survived ranking. +4. **Gate.** Apply the emission rule to each candidate in turn. Sequential by necessity: an + accepted candidate's dictionary appends shift the indices the next one compresses against. +5. **Assemble.** Encode one `storeBatch` over everything that cleared, price the real + transaction, and check it against the aggregate margin. + +The result is a single `JitFrontrun` carrying unsigned calldata, the orders it covers, and +the gas and improvement the gate weighed. The builder signs it and places it immediately +before the covered user transactions. + +Every failure short of the whole pass is per-order and swallowed. An order that cannot be +quoted, valued or compressed this iteration is retried next iteration, because the cost of +skipping one is a missed improvement and the cost of aborting the pass is all of them. A +missing base fee is the exception: `Engine::solve` refuses the whole pass rather than pricing +gas at zero, since a zero cost clears any margin against any surplus, and that is a gate +switched off rather than passed. + +## What authorizes an emission + +**The baseline is the order's own `expectedAmountOut`.** Every `TychoRouterV3` entrypoint +carries it, so it sits on the user's calldata and `decode_swap_calldata` reads it out. Nothing +is simulated, no `eth_call` is made, and the original route is never re-quoted: the number the +user was promised is the number to beat. `minAmountOut` is only the revert floor and sits far +below it. + +**The surplus rule is the only emission rule.** A substituted call passes the original +`expectedAmountOut` through, so the router's fee calculator captures everything the improved +route returns above that quote. `gate::decide` refuses anything that does not beat the quote +(`no_surplus`), then requires the captured surplus to cover the transaction by `--margin-bps` +(`below_margin`), then to clear the `--min-surplus-wei` floor (`below_min_surplus`). A route +that lands *below* the quote emits nothing: charging slippage on one would mean sending a +modified `expectedAmountOut` with the substituted route, which changes the compressed format, +the router call path and the security model at once. + +**The same-route rule refuses substituting a route for itself.** Market movement alone makes +the order's own route quote better, and the router captures that on the forwarded path whether +or not anything is stored, so storing identical bytes buys nothing and costs a transaction. +[`src/route_delta.rs`](src/route_delta.rs) normalises both sides through +`compress::substituted_route` first and compares the routes as they would execute: a +single-hop improvement quoted as `Single` and the same hop carried as a one-element sequential +route are the same execution. A byte-identical match is `RouteDelta::Identical`, refused as +`same_route` before it costs a valuation quote. That is a proof rather than a heuristic, which +is why anything the module cannot normalise or walk comes back `Different` and goes on to the +gate. Same pools with new split fractions is a different route and can be worth real money, so +`RouteDelta::SamePoolsNewSplit` only sets the `same_pools_new_split` flag on the decision row +and the candidate is still gated on its economics. + +**The builder tip is charged as a cost.** The frontrun pays the builder nothing through the +swap, so `--builder-tip-wei` (default `DEFAULT_BUILDER_TIP_WEI`, 0.001 gwei) is what +compensates the builder for including it, and Fynd funds the wallet it is paid from. That +makes it Fynd's money, so `gate::GasPrice` pairs it with the block's base fee and `cost_wei` +charges both against the improvement. Raising the tip makes marginal candidates stop clearing, +which is the intended behaviour. The emitted transaction's `max_priority_fee_per_gas` is the +same number the gate weighed. + +**The L1 data fee is detected, not configured.** `OpStackL1Fee::detect` +([`src/l1fee.rs`](src/l1fee.rs)) makes one `eth_getCode` call against the OP-stack +`GasPriceOracle` predeploy at `0x420…000F` at startup, which is exactly the question "is this +an OP-stack chain". On a chain without it the source holds no provider at all, so the fee is +structurally zero rather than a flag someone can regress past. Where the chain does charge +one, it is measured once per pass and charged at both gates through `gate::cost_with_l1`. + +**Native tokens are quoted as the zero address.** `0xEeee…EEeE` is the router-boundary +convention, on the user's calldata and on the call `FyndJITRouter` makes. Tycho names the same +thing with the zero address, so [`src/quoter.rs`](src/quoter.rs) translates a native position +to zero before asking for a route. Native is not translated into wrapped: quoting WETH would +ask for a different swap than the user signed, and would route a native order through wrapped +pools while reporting it as an improvement on the native one. + +Every decision, emitted or refused, appends one row to `--decision-log`. The `Outcome` labels +in [`src/decisions.rs`](src/decisions.rs) are the ones above plus `no_quote`, `unpriceable`, +`route_too_large`, `compress_failed` and `batch_below_margin`. Read that module's header +before tuning a margin from the file. + +## Compression + +**[`../route-compressor`](../route-compressor)** is the Rust mirror of the on-chain bit +format, whose layout [`contracts/README.md`](../../contracts/README.md) documents: + +| module | responsibility | +| --- | --- | +| [`route-compressor/src/codec.rs`](../route-compressor/src/codec.rs) | `compress` and `decompress`: the round trip, and the `MAX_WORDS` budget | +| [`route-compressor/src/bitstream.rs`](../route-compressor/src/bitstream.rs) | MSB-first bit reader and writer over 256-bit words | +| [`route-compressor/src/segment.rs`](../route-compressor/src/segment.rs) | `hops` and `proposals`: walking the PLE blob and picking dictionary candidates out of it | +| [`route-compressor/src/order.rs`](../route-compressor/src/order.rs) | `order_key`, `order_key_for` and `key_fingerprint`, mirroring the Solidity views | +| [`route-compressor/src/dictionary.rs`](../route-compressor/src/dictionary.rs) | `Dictionary`: the off-chain mirror (`sync`, `apply_page`, `lookup`) and the `DictionaryReader` seam | +| [`route-compressor/src/contracts.rs`](../route-compressor/src/contracts.rs) | `sol!` bindings, `encode_store_route`, `encode_store_batch`, `encode_append` and `decode_swap_calldata` | + +The two implementations are pinned together by the cross-language fixtures described under +[Running the tests](#running-the-tests). + +Two things about compression are easy to miss, both in [`src/compress.rs`](src/compress.rs). +The order key binds the target block, so the same route for the same order yields different +words in block N and N+1, and only word zero changes: the payload words are a deterministic +function of the blob. And the on-chain substitution path dispatches `sequentialSwap` or +`splitSwap` only, so a single-hop improvement has to be expressed as a one-element sequential +route. + +Routes compress to a median of one 256-bit word, measured over a week of Base routes in the +solver's hindsight run. The three cross-language fixtures in this repo come out at 1, 1 and 2 +words for blobs of 86, 168 and 204 bytes. + +## Running the solver + +`cargo run -p fynd-jit-solver` starts the standalone binary ([`src/main.rs`](src/main.rs)). +Every flag also reads an environment variable, named after the flag in upper snake case +except for the three marked below. + +| flag | default | what it is | +| --- | --- | --- | +| `--tycho-url`, `--tycho-api-key` | required, none | Market-data endpoint | +| `--rpc-url` (`ETH_RPC_URL`) | required | JSON-RPC endpoint, for the dictionary and the L1 fee oracle | +| `--chain`, `--chain-id` | `base`, `8453` | Chain to solve for | +| `--protocols` | `uniswap_v2,uniswap_v3,uniswap_v4` | Protocols to index | +| `--min-tvl` | `100` | TVL floor in USD on the pools tycho will quote, and the operator's dial for quote trust (see [Trust model and limits](#trust-model-and-limits)) | +| `--fynd-jit-router` (`FYND_JIT_ROUTER_ADDRESS`) | required | Deployed `FyndJITRouter`, whose calls the builder detects and whose ring the solver writes | +| `--dictionary` (`ROUTE_DICTIONARY_ADDRESS`) | required | Deployed `RouteDictionary` | +| `--route-capacity` | `4096` | Must match the deployed `FyndJITRouter.ROUTE_CAPACITY`. Wrong here prices the wrong ring slots; the contract derives the slot itself | +| `--margin-bps` | `15000` | Multiple of its own cost a surplus must be worth. 1.5x | +| `--min-surplus-wei` | `0` | Floor on the surplus, independent of what the transaction costs | +| `--builder-tip-wei` | `1000000` | Tip per unit of gas, charged in the gate because Fynd pays it | +| `--slippage` | `0.005` | Slippage on improved-route quotes | +| `--decision-log` | none | Append every gate decision here as JSONL | +| `--dictionary-page-size` | `256` | Entries per `read` call when syncing the mirror | +| `--order-channel-capacity` | `1024` | Buffer on the builder's order intake | +| `--ready-timeout-mins` | `10` | How long startup waits for the first market-data snapshot | + +The binary is the entry point for a future message-queue integration. It drops its own channel +senders today, so it connects, logs and exits. The working integration is in-process: build a +`JitSolver`, take its `order_channel` and `book` handles, and spawn `JitSolver::run` alongside +the builder loop. The crate docs in [`src/lib.rs`](src/lib.rs) carry a worked example. + +Either way, a builder has to supply three things: + +- **Order detections.** Push `JitOrderEvent::Detected` when a `FyndJITRouter` call appears and + `JitOrderEvent::Closed` when it is `Included`, `Dropped` or `Expired`. The solver keeps no + TTL of its own: + an order the builder never closes stays open, which makes the builder the single source of + truth. Builders that see router calls as top-level transactions can use + `decode_fynd_router_tx` rather than writing their own ABI handling, and get the canonical + `order_ref` with it. +- **Build events.** Stream `BuildEvent`s as each iteration progresses. A completed iteration + is what triggers a solve pass, and its `BlockEnv` is where the base fee comes from. +- **A signer.** `JitFrontrun` arrives built but unsigned. The builder signs it with an EOA + holding `BUILDER_ROLE`, pays for it, and places it immediately before the covered user + transactions. Fynd funds that wallet, which is why the gate charges the tip. + +## Economics + +Two different questions, with two different numbers. Conflating them gives the wrong answer +to both. + +1. **Is this one frontrun worth sending?** Compare the cost of the store transaction against + the improvement, given that an improvement exists. +2. **Is running the integration worth it at all?** Compare the same cost against the average + improvement over all quotes, including the majority with no improvement. + +Both numbers price **one route per frontrun transaction**, which is the common case. A +builder that batches several routes into one `storeBatch` amortizes the 21,000-gas intrinsic +charge across them and pays less per route, but nothing below assumes it does. + +### Prices and measured gas + +Live inputs, fetched 2026-08-14 11:56 UTC (Base block 49959633, Ethereum block 25753117): + +| quantity | value | source | +| --- | --- | --- | +| Base gas price | 0.0060 gwei (base fee 0.0050) | `cast gas-price --rpc-url https://mainnet.base.org` | +| Ethereum gas price | 0.1012 gwei (base fee 0.1011) | `cast gas-price --rpc-url https://ethereum-rpc.publicnode.com` | +| ETH/USD | $1,874.23 | Chainlink `0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419`, `latestRoundData()` | +| builder tip | 0.001 gwei | `DEFAULT_BUILDER_TIP_WEI`, configurable | +| Base L1 data fee, 1-route `storeBatch` | 1,033,000,310 wei (356 calldata bytes) | `GasPriceOracle.getL1Fee` at `0x420…000F` | + +Both gas prices are unusually low. Ethereum at 0.1 gwei is near multi-year lows, which +matters below. + +The builder tip is Fynd's money. The frontrun pays the builder nothing through the swap, so +the tip is what compensates the builder for including it, and Fynd funds the wallet it comes +from. The gate charges base fee and tip together for that reason, in `gate::GasPrice` +([`src/gate.rs`](src/gate.rs)). + +Execution gas, measured on the same day against Base head with +`forge test --match-test test_gas_overhead_comparison -vv`: + +| path | gas | +| --- | --- | +| direct TychoRouterV3 `sequentialSwap` | 89,448 | +| via `FyndJITRouter`, no stored route (miss) | 119,724 | +| via `FyndJITRouter`, stored route executed (hit) | 132,109 | +| `storeRoute`, one word, reused ring slot | 3,300 | +| `storeRoute`, one word, fresh ring slot | 31,803 | +| `dict.append`, 3 chunks | 100,716 | + +The fork suite forks the RPC head, so absolute numbers drift by tens of thousands of gas +between runs as vault slots go cold or warm. The deltas hold to a few hundred gas, so the +model uses those: 30,276 for the miss overhead and 12,385 more for a substitution. + +The steady state is the reused-slot store, 3,300 gas. Over a week of Base routes (252,489 of +them), 99.5% to 99.8% of stores needed no dictionary append from day three onward, and the +ring never clears a slot, so the fresh slot (31,803) and the append (100,716) are the rare +cases rather than the typical ones. `gate::RingSlots` prices each slot by its warmth, tracked +from the solver's own stores rather than read from chain, which errs toward over-estimating +cost. + +### Break-even + +``` +frontrun_cost = L2_gas x (base_fee + builder_tip) + L1_data_fee (L1 term is zero off OP-stack) +V* = frontrun_cost_usd / (improvement_bps / 10_000) +``` + +One route means 21,000 intrinsic plus 2,048 calldata gas for the 356-byte `storeBatch` plus +3,300 to write the ring slot, which is **26,348 gas**. On Base at 0.006 gwei that is +158,088,000,000 wei, and the L1 data fee adds 1,033,000,310 wei, for **$0.000298**. The same +transaction on Ethereum, where the whole charge is execution gas, costs 26,348 x 0.1021 gwei, +or **$0.005044**. + +With those costs: + +| question | improvement | Base | Ethereum | +| --- | --- | --- | --- | +| one frontrun pays for itself above | conditional on an improvement: 0.76 bps Base, 2.8 bps Ethereum | $3.92 | $18.02 | +| a stream of orders pays for itself above | averaged over all quotes: 0.09 bps Base, 0.24 bps Ethereum | $33.14 | $210.18 | + +The formula, worked: $0.000298 / (0.76 / 10,000) = $3.92 on Base, and +$0.005044 / (0.24 / 10,000) = $210.18 on Ethereum. + +Read the second row as the one that decides whether to run the integration: at any average +trade size above roughly $33 on Base, the flow pays for the transactions it costs. The first +row is the per-transaction decision the solver's gate makes, and it clears on almost any +trade worth routing. + +The second row is also conservative. It charges a store against every order, while the +builder only stores when it finds an improvement, so the real per-order cost scales down with +the hit rate. + +### Gas-price sensitivity + +Today's gas is cheap enough that a single break-even figure would mislead. Both tables hold +the trade improvement and the L1 data fee fixed and vary the chain's own gas price. The price +column is what the transaction pays per gas, base fee plus tip. + +Base: + +| Base price (gwei) | cost | V\* at 0.76 bps | V\* at 0.09 bps | +| --- | --- | --- | --- | +| 0.006 (now) | $0.000298 | $3.92 | $33 | +| 0.02 | $0.000990 | $13.02 | $110 | +| 0.10 | $0.004940 | $65.00 | $549 | +| 0.50 | $0.024693 | $324.91 | $2,744 | + +Ethereum: + +| Ethereum price (gwei) | cost | V\* at 2.8 bps | V\* at 0.24 bps | +| --- | --- | --- | --- | +| 0.102 (now) | $0.005044 | $18.02 | $210 | +| 1 | $0.049382 | $176.36 | $2,058 | +| 5 | $0.246910 | $881.82 | $10,288 | +| 20 | $0.987642 | $3,527.29 | $41,152 | + +### The L1 data fee + +On an OP-stack chain the store transaction pays an L1 data fee for its calldata on top of L2 +execution. The solver measures it per pass through `GasPriceOracle.getL1Fee` rather than +assuming a value, because its weight swings by orders of magnitude with L1 conditions. + +That fee is affine in the payload, not proportional, and for a payload this small the fixed +part is the whole of it. The one-route `storeBatch` pays 1,033,000,310 wei, the same fee to +the wei that a 100-byte payload of zeros pays: Base charges on the compressed size against a +minimum, and 356 bytes of mostly ABI padding compress below that minimum. Ten routes carry +4.24 times the payload for 1.66 times the fee (1,711,711,472 wei). So dividing a batch's L1 +fee by its route count understates the single-route charge badly. One route really pays 60% +of what ten routes pay, not a tenth of it, and that is the main reason single-route economics +come out worse than a per-route split of a batch suggests. + +At this snapshot the L1 term is small either way: 1,033,000,310 wei against 158,088,000,000 +wei of L2 execution, so L2 execution is 153 times the L1 data fee and the L1 term moves the +break-even by under a percent. That is a statement about today's L1, not about the design. +Base's L1 base fee is 0.104 gwei and its blob base fee 0.0062 gwei in this snapshot, both +near their historical floor, and cheap blobspace is what keeps the term small. Before +EIP-4844 it was usually the larger of the two. The L1 term scales with Ethereum's base fee +and blob base fee while the L2 term scales with Base's own base fee, and the two move +independently across orders of magnitude, so at L1 conditions two orders of magnitude above +today's, with Base gas unchanged, the L1 data fee matches and then exceeds L2 execution. That +is why the gate keeps it in the model and [`src/l1fee.rs`](src/l1fee.rs) measures it rather +than assuming a ratio. + +### Hit rates + +The two improvement figures per chain imply a hit rate: 0.09 / 0.76 is 11.8% on Base and +0.24 / 2.8 is 8.6% on Ethereum. A separate measurement over 28,720 settled trades put the win +rate at 17.3%, with a median win of 14.11 bps conditional on winning. These are three +different samples and three different quantities, so they should not be averaged together. +The point is only that they land in the same range, which is a weak but real consistency +check on the improvement figures. None of this measurement tooling is in this repo; the +figures were confirmed on 2026-08-13. + +## What opting in costs the user + +The calldata a user sends to `FyndJITRouter` is byte-identical to the calldata they would have +sent to `TychoRouterV3`. Only the destination address differs. On Base that matters more than +it sounds: **opting in does not change the L1 data fee at all**, because the L1 data fee is a +function of the calldata. The entire cost of opting in is L2 execution gas. + +Against a direct router call, measured above: + +| outcome | extra gas | cost on Base at 0.006 gwei | +| --- | --- | --- | +| miss, original route forwarded | +30,276 | $0.00034 | +| hit, improved route substituted | +42,661 | $0.00048 | + +The miss overhead buys the input hop into `FyndJITRouter`, the key computation and the ring +lookup. The extra 12,385 gas on a hit is almost all decompression. Included in the miss +figure is the receiver guard, one comparison against an immutable. + +In exchange, the user keeps their quote and their `minAmountOut` protection on both paths, and +on a hit they get a route that could not have been found when they signed. + +## Trust model and limits + +**Builders are whitelisted.** Storing a route requires `BUILDER_ROLE`, granted by +`DEFAULT_ADMIN_ROLE`. There is no permissionless store. + +**A compromised builder key can substitute a worse route, bounded by `minAmountOut`.** The +router enforces the user's `minAmountOut` on the substituted path exactly as on the original, +so the worst a malicious stored route can do is deliver `minAmountOut` on a swap that would +have delivered more. It cannot exceed the slippage the user already accepted, and it cannot +redirect output: `receiver` is part of the order key, so a route stored against a different +receiver does not match. + +**The improvement funds the sponsorship through positive-slippage capture.** A substituted +route is passed the user's own `expectedAmountOut`. Output above that quote is positive +slippage, which the router's FeeCalculator credits to the router fee receiver when +positive-slippage capture is enabled. It is enabled on the live Base deployment: the +FeeCalculator at `0xb6e39a6ce7224fbc73eb24b10a1d3443ff7fbfc1` answers +`getPositiveSlippageEnabled() == true`, with the fee receiver at +`0xA5503D92EE16a78E602996AD362674Dc49034A14`. A JIT improvement beyond the quote therefore +accrues protocol-side, where it pays for the builder sponsorship, and the user keeps their +quote either way. Note that the positive-slippage flag alone decides this, not the zeroed +client fee. + +**"Improvement" means captured surplus, which is not the frontrun's marginal contribution.** +The solver measures `improved_quote - expectedAmountOut`, the surplus the protocol captures +over the quote. Because positive slippage is captured on the forwarded path too, the user's +own route would have produced a capture of its own against the same quote, and the frontrun's +marginal contribution is the difference between the two. Frontend quotes are systematically +conservative, so a large part of a typical surplus is market drift between signing and +inclusion that the protocol would have captured with no transaction sent at all. That +counterfactual is deliberately not computed. The same-route rule closes the one subset where +the movement is provably all there is, and nothing more: a decision row saying the improvement +was worth ten times its gas does not say the frontrun was worth ten times its gas. Read +[`src/gate.rs`](src/gate.rs)'s header before tuning a margin from the decision log. + +**Both numbers the quoter produces are taken on trust, and `--min-tvl` is the dial.** The +improved route and the ETH value of the surplus it promises are tycho quotes against the +iteration's pending overlay. Neither is bounded, neither is re-simulated, and the substituted +call is not simulated before it is stored. The trust assumption is therefore tycho's pool set +and the pending overlay, and at the default `--min-tvl` of 100 USD that set is wide enough for +an unprivileged actor to add to. Two consequences follow, both reachable by anyone who can get +a thin pool indexed and who keeps an order of their own open. A thin `tokenOut`/WETH pool can +quote a dust surplus as worth a large amount of WETH; the gate clears on it, and the builder +pays for a `storeBatch` whose real capture is near zero, so the exposure is the sponsored gas. +And a pool that quotes above `expectedAmountOut` but delivers just above `minAmountOut` when +executed lets the difference be taken from the user's output, because the contract's fallback +protects against a revert rather than against a route that executes and pays the floor. +Raising `--min-tvl` well above the default is the mitigation available today. +[`src/quoter.rs`](src/quoter.rs) records what is still open: bounding `improvement_wei` per +order, restricting the valuation to curated reference pools, a daily sponsorship cap, and +re-simulating the substituted call before storing it. + +**Ring collisions are the builder's problem.** Two orders in one block whose keys land on +overlapping slots will clobber each other. The keys are known in advance, so the solver can +see it coming; the contract does not. + +**Not audited, not deployed.** 88 of the 107 contract tests cover this system, 14 of them +against live Base state, and that is the extent of the assurance today. + +## Running the tests + +Every command below was run at this commit. + +### Contracts + +```sh +cd contracts && forge test +``` + +107 tests, about 2 minutes from a clean build. The compile dominates: solc 0.8.25 with +`via_ir`, which the widest entrypoint's eleven parameters require. Re-runs against a warm +cache take 20 to 30 seconds, nearly all of it the 14 fork tests waiting on an RPC. +[`contracts/README.md`](../../contracts/README.md) covers the fork suite and the environment +variables it reads. + +Two tests in the fork suite print the gas tables quoted under [Economics](#economics): +`test_gas_overhead_comparison` and `test_gas_native_hit_vs_erc20_hit`, both with `-vv`. + +### Rust workspace + +```sh +cargo test --workspace +``` + +256 passed, 3 ignored, in under ten seconds against a warm build. + +This includes the fixture generator, so it rewrites +`contracts/test/fixtures/route_selector/*.json`. The values come out identical and the JSON +key order does not, so the tree goes dirty either way: check `git status` after running, and +`git checkout -- contracts/` to drop the churn. + +### Cross-language fixtures + +`contracts/test/RouteFixtures.t.sol` decompresses fixtures generated by the Rust compressor +through the Solidity decompressor and asserts the blobs match byte for byte. That test is the +contract between the two implementations. Regenerate the fixtures from the Rust side with: + +```sh +cargo test -p route-compressor --test fixtures +``` + +### Solver integration test against anvil + +[`tests/store_batch_onchain.rs`](tests/store_batch_onchain.rs) deploys `RouteDictionary`, +`RouteDecompressor` and `FyndJITRouter` to a local anvil node, sends the solver's emitted +`storeBatch` transaction as a builder would, and reads the routes back out of the ring +decompressed by the contract. That is the one assertion the mocked suite structurally cannot +make. It skips loudly without failing when `anvil` is not on `PATH` or when the contracts have +not been built, so run `cd contracts && forge build` first.