Docs
How Taper works, exactly
Measured Sep 23, 2026 at block 70,382,026.
Every number on this page is computed from a file in data/ by
tools/figures.mjs; none is typed in.
One Seaport order
An auction is a single order sent to Seaport 1.6 at 0x0000000000000068F116a894984e2DB1123eB395. Seaport was already on this chain. We did not deploy it, we have no privilege over it, and it — not us — holds the approval and moves both sides.
| offer | the shares, from your wallet |
|---|---|
| consideration | USDG to you, descending from the opening ask to the floor |
| orderType | PARTIAL_RESTRICTED (3) |
| zone | 0xCc75…C7Bd — the gate |
| zoneHash | the packed terms: feed, max discount, staleness, hard floor |
| startTime / endTime | now, and now plus your window |
The taper is Seaport's own: when a consideration item's
startAmount and endAmount differ,
_locateCurrentAmount interpolates linearly between them over the window, rounding
the ask up so the buyer never gains from the rounding. That is read out of the source
verified at Seaport's own address on this chain, not out of documentation.
Why a hundred slices
Seaport applies a filler's fraction to every amount and reverts
InexactFraction unless it divides each exactly. A descending order has three —
the shares, the opening ask and the closing ask — and three arbitrary integers share no useful
divisor, so an auction advertised as partially fillable would be takeable only in full. All
three are rounded down to a multiple of 100, and a buyer may
then take any 1%. The rounding is downward on every side:
the most it can cost on the dollar side is 99 millionths of a dollar.
Publishing, and taking down
Publishing calls Seaport's own validate, which writes the whole
order into its OrderValidated log. That log is the board — there is no
server anywhere in this product. Cancelling is a transaction to Seaport, and until it confirms
the auction is still takeable. Bumping your Seaport counter cancels everything you have ever
signed, in one transaction.
The gate, and why it exists
A plain Seaport auction's floor is a number of dollars fixed when you signed. The stock keeps moving. Measured over 30 days across 27 Robinhood feeds, the median move between one print of a feed and the next is 53.0 basis points — 0.53% — and the per-feed medians span only 50.6 to 55.4 bp while the print frequency spans a factor of 11. That is a deviation threshold, not a clock: a stock that has not moved does not print, and one that has moved half a percent does.
So a half-hour auction on a stock that rallies is an auction whose whole
remaining schedule is priced off a market that has gone. The gate is the fix. Seaport calls it
twice on every fill — authorizeOrder before any token moves and
validateOrder after they all have — and it refuses unless the buyer is paying at
least what Robinhood's current answer says the shares are worth, less the discount you
chose.
The terms, in 32 bytes
Seaport already signs 32 bytes of zone data with every order. The whole
condition fits in zoneHash with nothing to spare, which is why the gate needs no
storage, no owner and no registry — and why a fulfiller who edits a term changes the order
hash and the signature stops verifying.
| bits 255–96 | the price feed's address (160) |
|---|---|
| bits 95–80 | the most the sale may be below that feed, in bp (16) |
| bits 79–48 | the oldest answer that may be acted on, in seconds (32) |
| bits 47–0 | an absolute floor in USDG per whole share; zero for none (48) |
What it is
| address | 0xCc75…C7Bd |
|---|---|
| runtime | 5,450 bytes (22.2% of EIP-170) |
| init code hash | 0x8f4061ca2535b11b55e0e03fb37cbd98f9fd7e46a507441f6984ab635c57e929 |
| deployed | not yet |
The address is a CREATE2 address derived from the bytecode itself through Arachnid's factory, so anybody can check that the gate an order names is the gate in this repository. Taper deploys nothing: whoever wants the first auction sends that transaction, and nobody owns the result. It has not been audited.
Why the feed and not the pool
A pool's spot price can be pushed within the same transaction that fills the order, so a floor read from one would be a floor the buyer sets. Nobody filling an order can move Robinhood's feed. The pool is still what the default floor is measured from — but that measurement happens off chain, at signing time, where a wrong answer costs nothing.
What was measured
What a pool charges
Every one of the 45 tokenized stocks with a Uniswap pool on this chain, quoted at one pinned block against both v3 and v4, for $1,000, $5,000, $25,000, $100,000 — each against that same venue's own price for $100, which isolates depth from the pool's standing disagreement with the feed.
| median at $25,000 | 78 bp |
|---|---|
| 25th percentile | 15 bp |
| 75th percentile | 171 bp |
| 90th percentile | 438 bp |
| over 100 bp | 21 of 44 |
| under 10 bp | 8 |
Reported as a median with both ends named. Ranking by the cheapest would put a flattering number at the top of a page about a product for the dear ones.
The feeds
113 aggregator proxies were read — every
contract this chain's feed deployer created — and
35 distinct stock feeds found, by each one's own
description() rather than from an address list copied from another project.
35 of those tickers have two proxy addresses, and
every one of the pairs proved to share a single underlying aggregator(): two
addresses, one feed. The test is the aggregator, not the address.
The exception that shows the rule is SGOV: it moves 1.0 bp between prints and falls back on a heartbeat of about 24 hours. It never travels far enough to trip the threshold — which is the honest warning for anyone auctioning something that does not move.
What was checked, and what the checks missed
Properties, on the live chain
29 of 29 properties,
758 assertions, at block 68,309,287.
Each is one eth_call with no to: the harness's creation code executes
against the real Seaport, the real tokenized stock and Robinhood's real feeds. The gate is
installed at its own CREATE2 address by a state override, so the address an order names in a
property is the address it will name in production. Nothing is deployed and nothing is spent.
Every refusal is classified by the error the contract actually returned. A refusal whose selector nobody declared fails the property — "something said no" is how a guard takes credit for a failure that had nothing to do with it.
Deliberate breaks
45 of 46 breaks were caught by the property named for that break, against a baseline of 29/29. Credit by name matters: a sibling's sweep read 8 of 8 when the truth was 1 of 8, because one wrong check fired on every seed and took credit for everything.
1 escaped, and 1 of those is declared with a reason:
- the token's decimals and the feed's are swapped in the valuation — Division is commutative over two separate divisors: `x / 10^a / 10^b` and `x / 10^b / 10^a` are the same integer for every input, so this is not a behaviour change at all. It is in the list because it LOOKS like the classic decimals bug and a sweep that reported it as caught would be taking credit for noticing nothing. The real version is seen by properties 10 and 13.
And the sweep's own blind spot
A sweep that has never reported a miss has not shown that it can. So 9 further breaks were written to be genuinely subtle and replayed against the suite as it was before the properties for them existed. 2 were invisible to all 23 of them:
- the feed's scale is assumed to be 8 rather than read — nothing in the suite that existed then noticed. Property 10 exists because of it.
- a token that will not answer decimals() is assumed to have 18 — nothing in the suite that existed then noticed. Property 14 exists because of it.
Random sequences
6 seeds of about 64 operations each — move the price, age the price, take a slice, take another — with 10 invariants checked after every single one, then every seed replayed against 10 broken builds. 6 were caught. 86 fills landed in total; a seed that filled nothing is a failure, because a break that makes filling impossible would otherwise make every invariant vacuously true.
The 4 the fuzz did not catch are named here rather than rounded away, each with the check that does see it:
- the gate values raw units as if they were shares — AAPL's uiMultiplier is 1.000566, so valuing raw units as shares moves the floor by 5.7 basis points — well inside the 25 bp of slack the gate is deliberately given so an ordinary fill at the floor is not refused by rounding. The break is real and smaller than the product's own tolerance, which no balance-watching invariant on this token can resolve. Property 13 catches it with a mock token whose multiplier is 2.0, where the same error doubles the answer. Caught instead by property 13.
- the gate counts dollars paid to anybody as paying the seller — Every order in this book pays the seller and nobody else, because that is the only shape js/order.js can build — so counting dollars paid to a third party changes nothing here. It is a coverage limit of the BOOK, not of the invariants. Caught instead by property 16, which constructs an order paying a stranger.
- authorizeOrder waves everything through (validateOrder still checks) — Seaport is atomic: if the second hook still refuses, the whole fill reverts and no balance anywhere differs from a fill the first hook had also refused. A fuzz that watches what MOVED cannot see the difference. It is caught by property 15, which calls each hook directly. Caught instead by property 15.
- the page builds an ask that does not descend — An ask that never descends is always HIGHER than the tapered one, so every fill pays the seller more. Invariants 1 and 5 are lower bounds and a break that moves the amount UP is invisible to them by construction. Nobody would fill such an order, but the fuzz's fillers are not economic actors. Caught instead by property 18, which requires each fill to move EXACTLY the ask the page drew.
What Taper cannot do
- It cannot make anybody bid. An auction nobody takes sells nothing. When the window closes you still hold the shares.
- It does not run when the market is shut. Robinhood's stock feeds stop printing at the close, and the gate refuses a fill priced off a dead feed. Right now the feeds are 3.4 hours old.
- It cannot serve 18 of this chain's tokenized stocks, because no Robinhood feed exists for them — NU (3,191 bp), RIVN (482 bp), MRNA (439 bp), QUBT (437 bp), NET (434 bp), AVGO (346 bp), NFLX (200 bp), TTWO (176 bp) and more. Those include 13 of the names that cost over 100 bp to sell. Taper cannot serve the ones that need it most.
- For 4 stocks it would make things worse. The pool already pays at or above what Robinhood says the shares are worth: AAPL (10 bp above), GME (2 bp below), GOOGL (10 bp below), NVDA (10 bp below). The app refuses to build an auction for them.
- The seller can pull the order out from under a buyer. Nothing is escrowed — the shares stay in your wallet — so a cancel that lands first makes a buyer's fill revert and costs them their gas. That is the price of not escrowing, and the alternative trade-off is worth reading: a sibling project, EBB, holds the shares in its own contract instead and knows nothing but the clock.
- Robinhood can still pause, block or burn the token. Every tokenized stock here carries those powers for its issuer. Taper changes none of them and could not.
- The gate is not audited. It is 5,450 bytes reviewed by nobody but its author.