Building a Verifiable Exchange: Signed Orders, Replayable Execution, and On-Chain Anchors
An exchange can show a convincing order book while giving every observer a different history. The screen alone cannot tell you whether an order was omitted, whether a fill was invented, or whether yesterday's database was replaced before the page loaded.
That was the problem I wanted to study with Verifiable Exchange. It started as a one-hour matching-engine exercise and turned into a Rust research project about ordering, replay, evidence, and the limits of verification. The live deployment uses synthetic markets. It holds no funds and settles nothing.
The main question became: how much of an exchange's behavior can an outsider check from public data, without database access or an operator account?
My answer is a signed append-only order log, deterministic execution, independently signed state claims, and periodic roots on Base Sepolia. The result is not a decentralized exchange. It is a small exchange whose operator leaves evidence that can be checked after the fact.
The public repository contains the complete source, tests, protocol notes, and local demo.
The research questions
I did not begin with blockchain integration. I split the problem into questions that could fail independently:
- Can the service recover its exact state after a restart?
- Can a reader prove which bytes the sequencer published and in which order?
- Can a submission bypass the sequencer, so omission becomes observable?
- Can independent readers agree on one history without blocking trading?
- Can anyone replay that history and verify the resulting orders, positions, and trades?
- Can the operator rewrite a coherent history after everybody stops watching?
Each layer answers one question and leaves the next one open. That separation matters. A signed log proves that its signer said something; it does not prove the statement was honest. Three validators can make equivocation visible; they do not replace a dead sequencer. A blockchain anchor prevents a later rewrite; it cannot make a dishonest execution honest.
The running system
The local demo starts seven processes: a sequencer, a separate submission service, the matcher, three validators, and a traffic generator. The deployed version also runs a small Go sender that writes checked roots to Base Sepolia.
- Sequencer: accepts signed messages, assigns their order, stores the original bytes, and signs the log head.
- Separate submission service: records a user's order outside the sequencer's control and starts an inclusion deadline.
- Matcher: replays the ordered messages through a price-time-priority book and commits the resulting state.
- Validators: follow the log independently, recompute its chain, and sign the highest position they accept.
- Anchor sender: reads only public endpoints, checks that the log and state agree, then submits their roots to a contract.
- Browser: displays the book and verification state, and signs demo orders locally.
The sequencer decides order; the matcher decides execution. Keeping those roles separate made the trust boundary easier to inspect. The matcher does not get to reorder an inconvenient submission, and the sequencer does not decide whether an order fills.
A signed order is a statement, not a JSON object
Every order and cancel carries an Ed25519 public key and signature. The account's first accepted submission pins its key. A later request for that account must verify under the same key.
The signature covers a versioned, domain-separated statement:
exchange-account-order-v3
<session>
<account>
<symbol>
<side>
<price in integer cents>
<quantity in integer tenths>
<order type>
<time in force>
<post-only flag>
<nonce>
The session prevents an order captured from one log from being replayed after the operator starts a new one. The nonce prevents replay inside a session. Order type, time in force, and post-only status are covered because an unsigned trading term is a term an intermediary can change.
Prices and quantities cross the protocol as integers on explicit grids. Floating-point arithmetic has no place in matching or signatures: 10.10 must not become a machine-dependent approximation that one implementation rounds differently from another.
The browser creates a session-only key on first use. A passive visit does not write a secret to disk. A visitor can explicitly choose remember key, which stores the raw demo seed in the browser and is labeled as a convenience for a synthetic account, not secure custody.
The log preserves bytes, not reconstructed meaning
The sequencer writes every accepted message to SQLite before publishing it. It stores the exact bytes it received. A verifier hashes those same bytes instead of parsing JSON and serializing an equivalent object.
That distinction is easy to miss. Two JSON documents can mean the same thing while differing in whitespace, field order, or number spelling. If storage hashes one representation and a reader reconstructs another, both sides can be honest and still disagree.
The log has two commitments:
- a linear SHA-256 chain, useful for streaming readers that consume every message;
- a Merkle tree following RFC 9162, which gives compact inclusion and consistency proofs.
The sequencer signs the tree size, root, timestamp, and session. An inclusion proof shows that one stored message belongs to a signed root. A consistency proof shows that a later tree extends an earlier tree rather than replacing it. The tree uses RFC 9162's separate leaf and node prefixes, which prevent one kind of object from being interpreted as the other.
The linear chain still has value. A matcher already reading every message can update one hash per message. A browser asking about one trade should not download the whole window, so it uses the Merkle path instead. At the measured live tree size, that path was 18 hashes, or 576 bytes.
Deterministic matching is the real specification
The engine is a spot limit-order book with price-time priority. That sentence is not precise enough for two independent implementations to agree. The protocol also has to define:
- price and quantity grids;
- how ties are ordered;
- when self-trades are refused;
- whether an unfilled remainder rests or disappears;
- the behavior of post-only, immediate-or-cancel, and fill-or-kill orders;
- the worst executable price of a market order;
- how every refusal affects state.
Limit orders can rest. Market orders are bounded crossing orders: they may fill partly and never rest. Fill-or-kill is different; the whole quantity must be available inside the bound before the book changes, or nothing happens.
This distinction became visible in the UI. Asking to buy 9,999 units from a thin book did not mean 9,999 units had vanished. The ordinary market order consumed the quantity available inside its price collar and cancelled the remainder. The old screen reported only the partial fill, which made correct engine behavior look broken.
The revised ticket makes the bound explicit. A visitor chooses maximum slippage, the browser derives the worst price from the displayed midpoint, rounds it to the market's grid, shows it, and signs it. The server has its own two-percent protection collar, but it may only tighten the user's bound. It cannot widen it.
The server's reference is a 30-second time-weighted midpoint, not the last trade. One unusual fill can move the last price; moving the reference requires holding the book's midpoint away from its previous level over time.
The response now says how much filled, the range of execution prices, and why any remainder was cancelled. The chart also marks the user's latest fill so a real market move is distinguishable from the background traffic.
Execution leaves signed claims
The matcher commits its state after each batch. The state root covers the cursor, resting orders, and positions in one fixed encoding. It also writes a signed claim of the form:
history S, root A, plus messages m..n, produced root B and T trades
Claims form a chain: one claim's root_after is the next claim's root_before. They are written in the same database transaction as the state they describe. If a restored database does not hash to the last committed root, the matcher refuses to start.
The public audit command downloads the signed claims and trade record, fetches the sequencer's messages in bounded pages, reruns them, and compares every root and trade count. The important part is not the command's output. It is that the check needs one public URL and no private copy of the database.
The independent verifier does not import the matcher. Its matching rules are implemented separately and protected by an architectural test. Sharing the same implementation would test storage and transport while silently trusting the code under review.
What the validators add
Each validator follows the sequencer, verifies the signed history, computes the chain itself, and signs the position it accepts. With three validators, the UI reports the highest point accepted by a two-thirds quorum.
Trading does not wait for them. If validators fall behind, the verified position stops while the current log keeps moving, and that gap is visible. If a validator signs two histories for the same position, its signatures are evidence of equivocation.
This is the safety half of agreed ordering, not full Byzantine consensus. There is no view-change protocol to replace a stalled or censoring sequencer. If the sequencer stops, this market stops.
Why put only roots on a blockchain
Executing every toy order on-chain would answer a different question and make the demo needlessly expensive. The engine and public log remain off-chain. Every five minutes, the sender checks a tuple containing the history position, tree size, session, Merkle root, and state root, then writes it to an ExchangeRootAnchor contract on Base Sepolia.
Base Sepolia is a public test network. The anchor gives observers a dated commitment outside the operator's databases. Once written, the operator cannot replace yesterday's history with a different coherent history that produces new signatures and pretend it was always the answer.
The anchor does not prove that the committed execution was honest. A dishonest operator can anchor a dishonest history. It does not prevent censorship, guarantee data availability, bind the exchange's name to one contract forever, or protect messages created after the latest anchor. It removes one ability: revising an earlier commitment without leaving a contradiction.
That narrower claim is why I rejected a zero-knowledge proof for this version. A zkVM could shorten execution verification, but it would not solve sequencing, censorship, key discovery, or data availability. Before paying its build and operational cost, the system first needed a clear statement of what would actually be proved.
Failures that changed the project
The project improved most when a plausible design met an adversarial case.
Equivalent JSON was not equivalent evidence
An early path reconstructed messages before hashing them. The fix was to store and serve the original bytes and make every proof end at those bytes.
A valid-looking proof could identify the wrong user
The separate service initially treated a nonce as globally identifying an entry. Nonces are unique per account, not across every account. Identity now uses the (account, nonce) pair from the signed statement.
Empty account queries were the expensive case
An authorized external review found that asking for the trades of an unused account scanned the full trade history. A tiny unauthenticated response could consume seconds of CPU. The matcher now proves that empty result from its in-memory position map, caps the work done by history-backed queries, and applies read-path rate limits. The adjacent P&L path received the same treatment.
Startup had to be deterministic
The first rule and market listings open the engine's meaning. User, inbox, and generated traffic could not be allowed to race those opening messages. Startup now has an explicit gate: the sequencer publishes the rule plus every listing first, and returns a temporary refusal until that opening set is complete.
Browser convenience crossed the custody boundary
The first page generated and persisted a key before a visitor expressed any intent to trade. The external review also confirmed that the site could be framed and lacked a restrictive content policy. That combination turned one injected or clickjacked action into a persistent demo identity problem.
The hardened page does not persist on a passive visit. Remembering is explicit, pending orders trigger a reload warning, and the deployment applies CSP, frame denial, HSTS, MIME protections, referrer policy, permissions policy, and no-store rules where account-scoped responses require them. This still is not a wallet. A raw seed deliberately remembered in localStorage remains readable to same-origin script.
Correct market behavior needed a better explanation
The screenshots that looked like failed orders exposed three UX bugs: market partial fills were not explained, fill-or-kill refusals did not show the fillable quantity and price range, and post-only refusals were easy to mistake for missing orders. The order ticket now states the selected semantics before signing and reports the engine's result afterward.
Old chart requests could win a race
Changing MERKLE-USDC to ETH-USDC or BTC-USDC launched a new candle request, but an older response could arrive later and replace it. Interval changes had the same problem. Requests now carry a generation guard, in-flight reads are cancelled when the axis changes, cached series render immediately, and stale responses are discarded. Empty books no longer leave a stale chart pretending the previous symbol is current.
The slower problem was on the server. Each chart request rebuilt candles from the durable trade history, so the same five views became more expensive as the run grew. The matcher now maintains a bounded, continuous OHLCV projection for every symbol at 15 seconds, 5 minutes, 15 minutes, 1 hour, and 4 hours. Prices stay in integer cents and volume in integer tenths until the response is serialized. A chart read copies at most 1,000 buckets and never scans the trade table.
Security work before publication
I kept the operating repository private and created the public project from a reviewed snapshot with one parentless commit. That is deliberate: the public tree contains the code, tests, documentation, license, security policy, and community files needed to inspect the project, but none of the deployment history or private release checklist.
Before publishing it, I scanned the current tree and reachable Git objects for credentials, private keys, local paths, deployment identifiers, audit artifacts, and personal metadata. I also inspected the container build inputs and replaced the generic favicon with the same checked icon assets used by th3nolo.com.
This split does not claim the public commit is the project's full development history. It is a sanitized release snapshot. The private repository remains the record used to operate the deployment.
What this toy exchange proves
It proves less than a real exchange would need, but more than a normal demo screen shows:
- a user can sign the exact terms of an order;
- the sequencer can commit to the exact bytes and order it published;
- readers can prove inclusion and append-only extension;
- the engine's published state can be replayed from public inputs;
- contradictory histories or execution claims leave signed evidence;
- an on-chain root can bound how far back a later rewrite reaches.
It does not hold assets, settle trades, provide economic finality, guarantee liveness, prevent censorship, rotate compromised account keys, or prove execution in zero knowledge. The three validators are independent processes running the same reviewed system, not independent economic actors.
Those limitations are part of the result. Verifiability is not one feature called "blockchain." It is a chain of narrower claims, each with a witness, a failure mode, and a check someone outside the operator's machine can run.
Try it
- Open the live synthetic exchange.
- Read or run the single-commit public source.
- Inspect the Base Sepolia anchor history.
- Run the local stack with
./demo.sh, or audit the live history withservices --audit-url https://exchange.th3nolo.com.