OrdoFi Documentation
The execution layer for Robinhood Chain. A protected JSON-RPC gateway, a sealed-bid second-price backrun auction with signed and anchored outcomes, trust-minimised on-chain settlement, a sequencer feed relay, and the measurement that makes the numbers checkable.
Overview#
Robinhood Chain is an Arbitrum Nitro rollup with a single, first-come-first-served sequencer and no public mempool. Blocks arrive every ~100 ms. That combination changes what MEV looks like: nobody can front-run a pending transaction, because there is no pending set to read. What exists instead is a latency race to backrun: the sequencer feed reveals each transaction the instant it is ordered, and bots compete to trade immediately after anything that moves a pool.
OrdoFi sits between the user and the sequencer and does three things with that fact:
Protect the submission
Every raw transaction is simulated from its recovered sender before it is forwarded. A transaction that would revert is refused and never pays gas. Nothing is broadcast to a third-party provider on the way; sends go to the sequencer operator's endpoint.
Auction the backrun
If a transaction touches a pool, the right to trade right after it is sold in a sealed-bid, second-price auction that closes in 200 ms. The user's transaction is dispatched first, then the winner's. The clearing price is charged on-chain and 90% returns as rebates.
Make it checkable
Bids are acknowledged and rounds are receipted under EIP-712 signatures from the auctioneer; receipts are Merkle-anchored to OrdoReceiptLog. An independent watcher measures backrun extraction across every venue and publishes it.
Architecture#
wallet / app ──eth_sendRawTransaction──▶ gateway rpc.ordofi.network :8547
│ simulate as sender (eth_call) · reject reverts
│ key in auction mode → POST /submit
▼
auction auction.ordofi.network :8548
│ hold ≤ 200 ms · hints via eth_simulateV1
│ ws /searcher ──▶ searchers (sealed bids, EIP-712)
│ second price · dispatch user tx, then backrun
│ settle(Settlement, sig) · sign receipt
▼
OrdoSettlement bond debit → user / app / protocol split
OrdoReceiptLog Merkle root of receipts, every 25 rounds
sequencer feed wss://feed.mainnet.chain.robinhood.com ──▶ relay ws /feed ──▶ searchers
chain ──▶ watcher (arb attribution, OHLC tape) ──▶ SQLite ──▶ app.ordofi.network/api
Three processes matter operationally: the gateway (stateless, keyed), the auction (holds the auction window, WebSocket fan-out, settlement and receipts) and the watcher (indexes every block into SQLite). The web app is a read layer over the index plus a small set of on-chain reads.
Network & public endpoints#
| Parameter | Value |
|---|---|
| Chain | Robinhood Chain — Arbitrum Nitro (Orbit) L2 settling to Ethereum mainnet |
| Chain id | 4663 · 0x1237 |
| Block time | ~0.1 s · single centralized sequencer, FCFS ordering, no public mempool |
| Gas token | ETH (18 decimals) |
| Sequencer RPC | https://rpc.mainnet.chain.robinhood.com |
| Sequencer feed | wss://feed.mainnet.chain.robinhood.com |
| Explorer | https://robinhoodchain.blockscout.com |
| WETH | 0x0bd7d308f8e1639fab988df18a8011f41eacad73 |
| USDG | 0x5fc5360d0400a0fd4f2af552add042d716f1d168 (6 decimals) |
| Uniswap V3 factory / QuoterV2 / SwapRouter02 | 0x1f7d7550b1b028f7571e69a784071f0205fd2efa · 0x33e885ed0ec9bf04ecfb19341582aadcb4c8a9e7 · 0xcaf681a66d020601342297493863e78c959e5cb2 |
| Uniswap V4 PoolManager / PositionManager / StateView | 0x8366a39cc670b4001a1121b8f6a443a643e40951 · 0x58daec3116aae6d93017baaea7749052e8a04fa7 · 0xf3334192d15450cdd385c8b70e03f9a6bd9e673b — every V4 pool lives inside the singleton and is keyed by its 32-byte PoolId; native ETH is currency 0x0 |
| OrdoFi host | Service | Transport |
|---|---|---|
rpc.ordofi.network | Gateway (JSON-RPC) | HTTPS POST · GET / /health /metrics |
auction.ordofi.network | Auction, receipts, feed relay | HTTPS · WSS /searcher · WSS /feed |
app.ordofi.network | Terminal, explorer, portal, this reference, public API | HTTPS · /api/* is CORS-open |
Deployed contracts (chain 4663)#
| Contract | Address | Role |
|---|---|---|
| OrdoSettlement | 0xbC680922DaF2F65a8B957e5238857f8c68BeDabb | Searcher bonds, second-price debit against a signed maximum, rebate accounting |
| OrdoBundler | 0xc0bccFb3aA4ad9160d272645376a1797a32f3c4a | Per-owner CREATE2 executors for atomic multi-call bundles |
| OrdoReceiptLog | 0x89926c06cad403fDDD481C599b2ce709EBC936B9 | Merkle roots over all auction receipts ever issued |
| Auctioneer | 0x894255564bBb71585AebDf9337888b4b6E72C649 | Signs acks and receipts; the only address allowed to settle() and commit() |
| Protocol treasury | 0xf5ba571781533aaab9fc155311c93f5b1100affd | Receives the protocol share of each settlement |
Deployment block 51544378. Split constants at deploy: appBps = 500, protocolBps = 500; the user share is the remainder (90%).
Quickstart#
Wallet user#
Add the network with rpc.ordofi.network as its RPC URL. No key is needed: the anonymous tier covers every method a wallet uses, including a revert-protected eth_sendRawTransaction. One click at rpc.ordofi.network, or programmatically:
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [{
chainId: "0x1237",
chainName: "Robinhood Chain · OrdoFi protected",
rpcUrls: ["https://rpc.ordofi.network"],
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
blockExplorerUrls: ["https://robinhoodchain.blockscout.com"],
}],
});
App or wallet developer#
Mint a key with a rebate address so your users' flow goes through the auction and the app share accrues to you. Then send exactly as before, with one header.
# 1. a key (returned once; only its SHA-256 is stored)
curl -X POST https://app.ordofi.network/api/keys \
-H 'content-type: application/json' \
-d '{"label":"my-wallet","rebateAddress":"0xYourTreasury"}'
# 2. route order flow through the auction
curl https://rpc.ordofi.network \
-H 'x-api-key: ordo_…' -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_sendRawTransaction","params":["0x02f8…"]}'
Searcher#
import { OrdoSearcher, OrdoBond } from "@ordofi/sdk";
const SETTLEMENT = "0xbC680922DaF2F65a8B957e5238857f8c68BeDabb";
await new OrdoBond(process.env.SEARCHER_KEY, SETTLEMENT).deposit("0.05"); // bond ≥ your largest bid
new OrdoSearcher({
auctionWsUrl: "wss://auction.ordofi.network/searcher",
privateKey: process.env.SEARCHER_KEY,
settlementAddress: SETTLEMENT,
onOpportunity: async (opp, { account }) => {
if (!opp.hint.poolsTouched.length) return null; // nothing to rebalance
const backrunRawTx = await buildBackrun(opp, account); // your strategy
return { maxBidWei: 2_000_000_000_000_000n, backrunRawTx }; // 0.002 ETH ceiling
},
}).connect();
Gateway — rpc.ordofi.network#
A JSON-RPC 2.0 endpoint on HTTPS POST /. Batches (a JSON array of requests) are accepted and answered as an array. Standard eth_* methods pass through to the chain; the methods below are intercepted. Non-POST requests to paths other than /, /health and /metrics* return a JSON 404. CORS is open (access-control-allow-origin: *, headers content-type, x-api-key, authorization).
Authentication & rate limits#
Keys travel in x-api-key or Authorization: Bearer <key>. A key carries a mode and an optional rebate address:
| Tier | Who | Methods | Limit | eth_sendRawTransaction behaviour |
|---|---|---|---|---|
| Anonymous | Wallets, anyone | Full wallet set (see below) + ordo_simulate | 600 req/min per source IP | Protected direct send |
Key · direct | Apps without a rebate address | Everything | Per key, default 600/min | Protected direct send |
Key · auction | Apps with a rebate address | Everything | Per key, default 600/min | Auction via POST /submit; falls back to protected direct send if the auction is unreachable, and the fallback is counted |
Anonymous methods: eth_chainId net_version web3_clientVersion eth_blockNumber eth_syncing eth_gasPrice eth_maxPriorityFeePerGas eth_feeHistory eth_getBalance eth_getCode eth_getStorageAt eth_getTransactionCount eth_call eth_estimateGas eth_getLogs eth_getBlockByNumber eth_getBlockByHash eth_getBlockReceipts eth_getTransactionReceipt eth_getTransactionByHash eth_getTransactionByBlockNumberAndIndex eth_sendRawTransaction ordo_simulate. Anything else without a key returns -32001.
Limits are fixed 60-second windows. Exceeding one returns -32005 with retry in Ns in the message. Keys minted at the portal are stored as SHA-256 hashes; a lost key is re-issued, never recovered.
Methods#
- RPCeth_sendRawTransaction
[rawTx]. The sender is recovered from the signature and the call is simulated as that sender witheth_callagainst latest state (the transaction's own gas limit is not forwarded into the simulation). A revert yields-32000ordo: transaction would revert, not submitted: <reason>withdata.ordoProtected = true. Otherwise the raw transaction is sent to the sequencer operator's endpoint and the hash is returned. Keys inauctionmode route through the auction instead and receiveresult.userTxHash.- RPCordo_sendPrivateTransaction
[rawTx]. Always the auction path regardless of key mode. Returns the user transaction hash.- RPCordo_simulate
[rawTx]. Dry run without sending. Returns{ ok, returnData?, revertReason?, from, to }.- RPCordo_sendBundle
[{ txs: rawTx[], allowRevert?: boolean | number[] }]. Up to 10 transactions. See Bundles.- RPCordo_bundlerInfo
[ownerAddress]. Returns{ bundler, executor, deployed, note }: the owner's deterministicOrdoExecutoraddress and whether it exists yet.- RPCeverything else
- Forwarded verbatim. Reads fan out across a failover list of upstreams (rotating on transport failures, HTTP 429/403 and archive-plan refusals); a genuine JSON-RPC error from a healthy upstream is returned with its code intact and does not rotate.
Bundles#
Every transaction in the bundle is simulated first; if any would revert the whole bundle is refused (-32000, data: { failedIndex, hint }) unless that index is allowed by allowRevert. Accepted bundles are dispatched in the same tick.
// response
{
"bundleId": "0x…", // = txHashes[0]
"txHashes": ["0x…", "0x…"],
"atomic": false, // true only when txs.length === 1
"note": "…",
"atomicAlternative": "…" // present when !atomic
}
Ordering on a FCFS chain
Robinhood Chain's sequencer orders by arrival. Firing a bundle in one tick minimises the gap between its transactions but cannot make them adjacent or all-or-nothing. For enforced atomicity, deploy anOrdoExecutor through OrdoBundler and execute the legs as one transaction with on-chain preconditions and a minGainWei guard.Error codes#
| Code | Meaning | Notes |
|---|---|---|
-32700 | parse error | Body is not JSON |
-32001 | unauthorized: valid x-api-key required | Method outside the anonymous tier and no valid key |
-32005 | rate limit exceeded, retry in Ns | Per key, or per IP for anonymous callers |
-32000 | ordo: transaction would revert, not submitted | data.ordoProtected = true; also used for bundle refusals with data.failedIndex |
-32602 | bundle.txs required · max 10 txs per bundle | Invalid bundle params |
-32000 | all RPC upstreams refused the request | Every upstream failed at the transport level; retry |
| HTTP 404 / 405 | — | GET on unknown path / non-POST RPC; JSON body points at the landing page |
Operational endpoints#
| Endpoint | Returns |
|---|---|
GET/ | Landing page with live probe and one-click wallet setup |
GET/health | { status, upstream, sequencer, uptimeSeconds } |
GET/metrics | Prometheus text: ordo_uptime_seconds, counters (rpc_requests_total, tx_submitted_total, orderflow_auctioned_total, orderflow_fallback_total, send_fallback_total, rpc_unauthorized_total, rpc_rate_limited_total, upstream_challenge_total…), latency p50/p95/p99 |
GET/metrics.json | Same, as { uptimeSeconds, counters, latency } |
Order-flow auction — auction.ordofi.network#
The auction turns a backrun from something taken to something sold. Lifecycle of one round:
- A raw transaction arrives via
POST /submit(from the gateway for auction-mode keys, or directly from an app). It is parsed, its sender recovered, and it is simulated witheth_simulateV1to derive a hint. - An
opportunityis broadcast to every connected searcher. The window isORDO_AUCTION_WINDOW_MS= 200 ms. - Searchers reply with sealed bids: an amount in wei, their signed backrun transaction, and an EIP-712 Bid signature authorising a maximum charge. Each accepted bid is acknowledged with a signed
BidAck. - At close, the highest bid wins and pays the second-highest (or its own bid if it stood alone). Bids without sufficient bond are rejected at submission.
- The user's transaction is dispatched to the sequencer, then the winner's backrun. Both legs are fault-isolated: a failure of one is reported, never allowed to discard the other.
- The auctioneer calls
OrdoSettlement.settle()with the searcher's signature, debiting the bond and crediting user, app and protocol shares. A signedReceiptfor the round is published and later Merkle-anchored.
POST /submit#
// request
{ "rawTx": "0x02f8…", "originLabel": "my-wallet", "rebateAddress": "0xApp…" }
// response (200)
{
"result": {
"opportunityId": "uuid", "winner": "0x…" | null, "clearingPriceWei": "…",
"bidCount": 2, "userTxHash": "0x…", "backrunTxHash": "0x…" | null, "dispatchedAt": 1756…
},
"rebate": { … } | null, // ledger entry: user / app / protocol amounts
"settlement": { … } | null, // what was submitted to OrdoSettlement
"settlementTxHash": "0x…" | null,
"receipt": Receipt | null, // signed, see Verifiability
"settlementContract": "0xbC68…",
"userError": null | "…", // the user leg's dispatch error, if any
"auctionDelayMs": 203, // how long the hold added
"hint": { "level": "pools", "simulated": true, "pools": 1 }
}
A malformed request returns 400 { error }. auctionDelayMs is reported rather than hidden: it is the latency cost of the auction, typically the window plus a few milliseconds.
Searcher WebSocket — wss://auction.ordofi.network/searcher#
All frames are JSON with a type. Server → client:
// on connect
{ "type": "welcome", "swapTopics": { "0xd78a…": "univ2", "0xc420…": "univ3", "0x40e9…": "univ4" },
"auctionWindowMs": 200, "hintLevel": "pools" }
// one per held transaction
{ "type": "opportunity", "opportunity": {
"id": "uuid", "createdAt": 1756…,
"hint": { "poolsTouched": ["0x…"], "swaps": [ { "kind": "univ3", "pool": "0x…", "direction": "0for1" } ],
"to": "0x…", "selector": "0x…", "value": "0x0", "simulated": true, "level": "pools" },
"originLabel": "my-wallet", "originRebateAddress": "0x…" } }
// reply to a bid
{ "type": "bid_ack", "opportunityId": "uuid", "accepted": true,
"ack": { "opportunityId": "0x…32 bytes", "searcher": "0x…", "bidWei": "…", "receivedAt": 1756…, "signature": "0x…" } }
{ "type": "bid_ack", "accepted": false, "reason": "insufficient bond: X wei bonded, Y wei bid — deposit into OrdoSettlement first" }
// malformed frame
{ "type": "error", "error": "invalid json" }
Client → server:
{ "type": "bid", "opportunityId": "uuid", "searcher": "0xYou",
"bidWei": "1000000000000000", // decimal string
"backrunRawTx": "0x02f8…", // signed, ready to dispatch
"bidSig": "0x…" } // EIP-712 Bid over (searcher, opportunityId, maxAmountWei = bidWei)
Rejection reasons: unknown or closed auction, auction closed, invalid bidWei, bid must be positive, missing backrunRawTx, searcher must be an address when bonding is enabled, insufficient bond…. There is no per-round result frame over the socket; the outcome is the signed receipt at GET /receipts/:opportunityId, and the winner learns it dispatched by watching the chain.
Hints#
Searchers never see the raw transaction. The hint is derived by simulating it with eth_simulateV1 and decoding Swap logs against the venue topic map. ORDO_HINT_LEVEL selects how much is shared:
| Level | Fields |
|---|---|
minimal | poolsTouched, to, selector, value |
pools (default) | + swaps[] with kind (univ2/univ3/univ4), pool, poolId for V4, direction (0for1/1for0) |
full | + amount0, amount1 per swap |
If simulation fails or the upstream degrades, simulated is false and poolsTouched falls back to [to]. This is exactly the information that becomes public on the sequencer feed the moment the transaction is ordered; the auction reveals it a few hundred milliseconds early to whoever pays for the privilege.
Clearing rule & bonds#
- Sealed bids; the highest
bidWeiwins. - Clearing price = second-highest bid; a lone bidder pays its own bid; no bids → no winner, price 0.
- When
ORDO_SETTLEMENT_ADDRESSis set, a bid is only accepted ifOrdoSettlement.bond(searcher) ≥ bidWei(bond reads cachedORDO_BOND_CACHE_MS= 15 s). - On-chain, the charge can never exceed the searcher's signed
maxAmountWei; the auctioneer proves the second price by charging less than or equal to what the searcher authorised. - Opportunity ids are UUIDs; on-chain they become
bytes32by stripping dashes and right-padding with zeros.
Rebate split#
| Recipient | Share | Where it lands | Env |
|---|---|---|---|
| User (transaction sender) | 90% | claimable[user] in OrdoSettlement | ORDO_REBATE_USER=0.9 |
| App (order-flow originator) | 5% | claimable[rebateAddress] | ORDO_REBATE_APP=0.05 |
| Protocol | 5% | claimable[treasury] | ORDO_REBATE_PROTOCOL=0.05 |
The off-chain ledger (data/rebates.ndjson) mirrors every entry and tracks app balances owed; the on-chain split is fixed by appBps/protocolBps in the contract and claimed with claim().
HTTP endpoints#
| Endpoint | Returns |
|---|---|
POST/submit | Auction result, see above |
GET/health | { status, searchers, stats } · stats = { opportunities, bids, rejectedBids, dispatched, backruns, settled } |
GET/stats | { stats, connectedSearchers, rebateSplit, owedRebates } |
GET/receipts?n=20 | { auctioneer, receipts[] } |
GET/receipts/:opportunityId | { auctioneer, receipt } by uuid or bytes32 · 404 if unknown |
GET/receipts/root | { root, count, anchoredCount, auctioneer } |
GET/feed/stats | { upstream, clients, sequencerMessages, txsRelayed, lastSequenceNumber, reconnects } |
WSS/searcher · /feed | See searcher protocol and feed relay |
OrdoSettlement#
Trust-minimised payment for auction outcomes. Searchers post ETH bonds; the auctioneer can debit a bond only with the searcher's own EIP-712 signature over a maximum for that specific opportunity, at most once per opportunity.
| Function | Access | Semantics |
|---|---|---|
payabledeposit() · receive() | anyone | Adds msg.value to bond[msg.sender]. A plain ETH transfer to the contract bonds the sender. |
withdrawBond(uint256 amount) | bond owner | Returns bond to the searcher |
settle(Settlement s, bytes searcherSig) | auctioneer only | Verifies searcherSig over Bid(searcher, opportunityId, maxAmountWei); requires chargeWei ≤ maxAmountWei, !settled[opportunityId], EIP-2 low-s. Debits bond[searcher] by chargeWei and credits claimable for user (remainder), app (appBps) and treasury (protocolBps). Marks the opportunity settled. |
claim() | anyone | Pays out claimable[msg.sender] |
bond(address) · claimable(address) · settled(bytes32) | view | Balances and replay state |
bidDigest(address, bytes32, uint256) | view | The exact EIP-712 digest a searcher signs; useful for wallets and audits |
DOMAIN_SEPARATOR() · BID_TYPEHASH() · auctioneer() · protocolTreasury() · appBps() · protocolBps() · owner() | view | Parameters |
setAuctioneer · setSplit · setProtocolTreasury · transferOwnership | owner only | Administration. The owner is the deployer; see trust model. |
struct Settlement {
address searcher; // whose bond is debited
bytes32 opportunityId; // uuid → bytes32 (dashes stripped, right-padded)
uint256 maxAmountWei; // what the searcher signed
uint256 chargeWei; // what the auction cleared at (≤ maxAmountWei)
address user; // receives the user share
address app; // receives appBps
}
EIP-712 bid#
domain = { name: "OrdoSettlement", version: "1", chainId: 4663, verifyingContract: 0xbC680922DaF2F65a8B957e5238857f8c68BeDabb }
Bid = Bid(address searcher, bytes32 opportunityId, uint256 maxAmountWei)
digest = keccak256(0x1901 ‖ DOMAIN_SEPARATOR ‖ keccak256(abi.encode(BID_TYPEHASH, searcher, opportunityId, maxAmountWei)))
The SDK signs this with signTypedData; maxAmountWei equals the bid, so a searcher can never be charged more than it bid, and the contract makes the second-price claim falsifiable: any chargeWei > maxAmountWei reverts.
OrdoBundler & OrdoExecutor#
Enforced atomicity on a chain whose sequencer offers none. OrdoBundler deploys one OrdoExecutor per owner at a deterministic CREATE2 address; the executor runs a list of calls as a single transaction, with preconditions and a minimum-gain guard, so a stale or unprofitable bundle reverts as a whole.
| Function | Semantics |
|---|---|
OrdoBundler.executorOf(address owner) → address | Deterministic executor address, whether or not deployed |
OrdoBundler.isDeployed(address owner) → bool · deploy(address owner) · deploy() | Deploys the owner's executor; emits ExecutorDeployed |
OrdoExecutor.execute(Call[] calls, Check[] checks, uint64 maxBlock, uint256 minGainWei) payable → bytes[] | Owner only. Reverts if block.number > maxBlock, if any Check fails before execution, or if the executor's ETH balance did not grow by at least minGainWei. |
OrdoExecutor.checksPass(Check[]) → bool | Evaluate preconditions off-chain via eth_call |
withdraw · withdrawToken · receive() | Owner recovers ETH and ERC-20 balances |
Use ordo_bundlerInfo on the gateway to find your executor address; then a "bundle" is one execute() transaction sent like any other.
OrdoReceiptLog#
| Function | Semantics |
|---|---|
commit(bytes32 root, uint64 count) | Auctioneer only (settlement.auctioneer()). Appends a Merkle root over the first count receipts. Rejects an empty root or a count smaller than the previous commitment. |
latest() → (root, count) · total() · commitments(uint256 i) | Read the anchor history |
verify(bytes32 leaf, bytes32[] proof, uint256 index) → bool | Inclusion proof against the latest root |
Signed receipts#
A sealed-bid auction asks you to trust the operator about the bids you did not see. OrdoFi removes that: the moment a bid lands it is acknowledged under the auctioneer's EIP-712 signature, and when the round closes a receipt listing every bid is signed too. A discrepancy between an ack and a receipt is cryptographic evidence of misconduct, not a complaint.
domain = { name: "OrdoAuction", version: "1", chainId: 4663 } // no verifyingContract; these are off-chain attestations
BidAck = BidAck(bytes32 opportunityId, address searcher, uint256 bidWei, uint64 receivedAt)
Receipt = Receipt(bytes32 opportunityId, bytes32 bidsHash, address winner, uint256 clearingPriceWei, uint64 closedAt)
bidsHash = keccak256(abi.encode(address[] searchers, uint256[] bidsWei, uint64[] receivedAts)) // keccak256("") when empty
receiptHash = keccak256(abi.encode(opportunityId, bidsHash, winner, clearingPriceWei, closedAt)) // the Merkle leaf
// GET /receipts/:opportunityId
{ "auctioneer": "0x8942…C649",
"receipt": {
"opportunityId": "0x…", "winner": "0x…", "clearingPriceWei": "…",
"bids": [ { "searcher": "0x…", "bidWei": "…", "receivedAt": 1756…, "bidSig": "0x…" } ],
"closedAt": 1756…, "signature": "0x…" } }
Each listed bid carries the searcher's own settlement signature (bidSig), so the auctioneer cannot invent bids to inflate a clearing price: a fabricated bid would need a signature it cannot forge.
Merkle anchoring#
Signatures make a receipt unforgeable but not un-retractable. To stop a receipt from being quietly replaced after you read it, the auctioneer commits a Merkle root over every receipt it has ever issued to OrdoReceiptLog every ORDO_RECEIPT_COMMIT_EVERY = 25 new receipts. Leaves are receiptHash in issue order; parents are keccak256(sorted(left, right)); an odd node is carried up unpaired. GET /receipts/root reports count (issued) and anchoredCount (immutable): the gap is the only part still retractable, and it is bounded.
Receipts persist in data/receipts.ndjson and are rehydrated on restart; the anchor state is re-read from the chain at boot so a restart cannot reset the log.
Auditing a round#
| Claim | Check |
|---|---|
| My bid was counted | The acknowledged (searcher, bidWei, receivedAt) appears in the receipt's bids |
| No bids were invented | Every bidSig recovers to its searcher under the OrdoSettlement domain |
| Second price was honoured | winner is the highest bidder; clearingPriceWei equals the second-highest bid (or the sole bid) |
| The receipt is the auctioneer's | signature recovers to the published auctioneer under the OrdoAuction domain |
| It cannot be swapped later | receiptHash is included under an anchored root (verify()) |
import { auditReceipt } from "@ordofi/core/receipt";
const { receipt, auctioneer } = await fetch(`https://auction.ordofi.network/receipts/${id}`).then(r => r.json());
const finding = await auditReceipt(receipt, myAck, 4663, auctioneer, "0xbC680922DaF2F65a8B957e5238857f8c68BeDabb");
if (!finding.ok) console.error(finding.reason); // signed proof of what went wrong
// also: auditClearingPrice(receipt), auditBidAuthenticity(receipt, 4663, settlement)
Sequencer feed relay — wss://auction.ordofi.network/feed#
MEV here starts on the sequencer feed. OrdoFi holds one upstream connection to wss://feed.mainnet.chain.robinhood.com, decodes the Nitro framing, and fans the transactions out so a searcher does not need its own decoder or connection.
// on connect
{ "type": "welcome", "feed": "wss://feed.mainnet.chain.robinhood.com", "note": "…" }
// per sequencer message
{ "type": "feed_txs", "sequenceNumber": 51709518, "blockNumber": 51654343, "timestamp": 1756…,
"receivedAt": 1756…, // ms, stamped by the relay on receipt
"txs": [ { "hash": "0x…", "raw": "0x02f8…" } ] }
Decoding: L1 message kind 3 carries an L2 message; L2 kind 4 is a single signed transaction, kind 3 is a batch of sub-messages each prefixed by a big-endian uint64 length. receivedAt lets you measure the relay's own added latency instead of assuming it. The upstream reconnects with backoff 1 s → 30 s; GET /feed/stats exposes reconnects.
What the feed is not
The feed carries transactions the sequencer has already ordered. It is a head start on decoding, not a preview of pending flow. Pending flow, with hints and the right to trade after it, is what the auction sells.Apps & wallets#
Routing your users' transactions through OrdoFi costs one header and returns a share of the backrun value that would otherwise leave through a bot.
- POSTapp.ordofi.network/api/keys
- Body
{ label?, rebateAddress? }. Returns201 { key, label, mode, rebateAddress, rateLimitPerMin, note, rpc }. With a rebate address the key isauctionmode; without,direct(protected passthrough only). Limited to 3 keys per hour per IP. Keys look likeordo_+ 36 hex characters and are shown exactly once. - GETapp.ordofi.network/api/account?address=0x…
{ address, settlement, bondEth, claimableEth, settlementsAsSearcher, settlementsAsApp, claimHint }. Bond and claimable are read live from the contract; no key required.- on-chainOrdoSettlement.claim()
- Withdraws everything credited to
msg.sender: user rebates, app shares, or protocol shares. The portal at /portal wraps this.
Use the key as x-api-key against https://rpc.ordofi.network, or call POST auction.ordofi.network/submit directly with rebateAddress in the body if you prefer to see the auction result inline.
Searcher SDK — @ordofi/sdk#
TypeScript, built on viem and ws. Until the npm release lands, install from the monorepo (packages/sdk).
new OrdoSearcher(config)config = { auctionWsUrl, privateKey, settlementAddress, onOpportunity, chainId? }.connect()opens the socket and reconnects on close with exponential backoff (1 s doubling to a 30 s cap, reset on a successful open);close()stops it. Exposesaddress.onOpportunity(opp, { account }) → Promise<{ maxBidWei: bigint, backrunRawTx: Hex } | null>- Called per opportunity with
opp = { id, hint: { poolsTouched, to, selector, value }, originLabel }. Returnnullto pass. A returned decision is signed (signBid) and sent as abidframe withbidWei = maxBidWei. searcher.signBid(opportunityId, maxAmountWei) → Promise<Hex>- EIP-712
Bidsignature under the OrdoSettlement domain; also usable to build bids by hand. opportunityIdToBytes32(uuid) → Hex- Strip dashes, right-pad to 32 bytes.
new OrdoBond(privateKey, settlementAddress, rpcUrl?)deposit(amountEth),claim(),bondOf(address),claimableOf(address).submitOrderFlow(url, { rawTx, originLabel, rebateAddress? })- Typed wrapper for
POST /submit. SETTLEMENT_ABI- The subset of the settlement ABI the SDK uses.
Bidding economics
Pay at most what the backrun is worth to you net of gas; you will be charged the second price, so shading is unnecessary. Bond at least your largest intended bid, since bids above your bond are rejected before the auction. The backrun transaction must be valid at dispatch time: correct nonce, sufficient gas for a state-changing call (a 21,000-gas placeholder is refused by the chain), and a fee at or above the current base fee.Web API — app.ordofi.network/api#
Read-only JSON over HTTPS, CORS-open, default cache-control: public, max-age=15 unless noted. Large responses are gzipped when accepted.
| Endpoint | Returns |
|---|---|
GET/api/stats | Embed-friendly headline numbers: { chain, arbs, searchers, pools, swaps, arbsPerDay, settlement: { deployed, address, settlements, totalSettledEth, rebatesToUsersEth }, updatedAt }. arbsPerDay is null until the sample spans one hour. |
GET/api/report | The watcher's full MEV report: venues, pools, searchers, USD totals |
GET/api/arbs/recent?n=40 | Latest attributed backruns (max 200): block, tx, sender, pools, profit token and amount |
GET/api/onchain?address= | Settlement contract statistics from the index, with an on-chain log scan as fallback |
GET/api/auction | Proxy of the auction's /stats |
GET/api/explorer | { report, recentArbs, auction, onchain, generatedAt } — everything the explorer page renders |
POST/api/keys · GET/api/account | See Apps & wallets |
Trade endpoints#
The terminal at /trade is a thin client over these. The server never holds keys or sends transactions; it prepares Uniswap V3 calldata that the user's wallet signs and submits through the protected RPC. Prices come from the pools' own Swap events, never from an oracle or a price API.
| Endpoint | Params | Returns |
|---|---|---|
/api/trade/chain | — | { id: 4663, idHex: "0x1237", name, rpc, explorer } for wallet_addEthereumChain |
/api/trade/tokens | — | Tradable assets: { address, symbol, name, decimals, usdPerToken, icon, holders, active, tradable, swaps24h, tiers: { eth: [fees], usdg: [fees] } }[]. Sources: the explorer's most-held ERC-20s, every factory pool that traded in the last 24 h, and user imports. tradable is true when a Uniswap V3 pool exists against ETH or USDG, null while the check is queued. Stale-while-revalidate, rebuilt every 5 min. |
/api/trade/markets | — | Every routable pair that traded today, from the recorder's tape alone (no RPC on the request path): { markets: [{ pool, fee, base, quote, price, change24, change1h, high24, low24, volumeQuote, volumeUsd, swaps, lastTrade }], pairsTotal, coverage, at }. Money (USDG over ETH) is always the quote; top 800 by volume. |
/api/trade/token | address | Import any ERC-20 by address: resolves metadata and checks ETH/USDG routability. Tradable imports join the list. |
/api/trade/quote | tokenIn, tokenOut, amountIn (raw units; eth for native), slippageBps?=50, from? | Best of direct and one-intermediate (WETH/USDG) V3 routes via QuoterV2: { amountIn, amountOut, minOut, route: { tokens, fees, hops }, priceImpactBps, gasEstimate, tokenIn, tokenOut, for, guard, tx: { to, data, value } | null, approval?, account?, quotedAt }. tx is a SwapRouter02 multicall with exactInput paying MSG_SENDER; for native output the swap pays the router and a trailing unwrapWETH9(amountMinimum) pays the sender in ETH. 10-minute deadline. Proof of delivery: tx is only present when from is given and the call has been executed with eth_simulateV1 from that address, with from receiving at least minOut of the output, paying at most amountIn, and nothing landing on 0x00…0a/0xdead or staying in the router. guard reports the result: { ok, reason, received[], paid[], leaks[], retained[], via }. Never send calldata from a quote without guard.ok, and only from the for address. no-store. |
/api/trade/candles | base, quote, bucketSec?=60, spanBlocks?=72000 | OHLCV of base in quote from the deepest direct pool; the watcher's recorded tape first, an adaptive eth_getLogs walk to backfill. { pool, fee, base, quote, source: "recorder" | "logs" | "recorder+logs", truncated, swaps, volumeQuote, last, candles[] } |
/api/trade/pair | base, quote | { pool, fee, tvlUsd, reserves, base, quote } for the deepest direct pool by in-range liquidity; cached 60 s |
/api/trade/trades | base, quote, limit?=40 | Most recent individual swaps: { block, time, tx, logIndex, price, sizeBase, sizeQuote, side }; cached 5 s |
/api/trade/balances | address | Native ETH and every listed token with a non-zero balance, USD-valued where a price is known; cached 30 s |
/api/trade/resolvers | — | Health of the persistent lookup caches: { pools, tokens, routes } each { known, pending } |
MEV measurement#
The watcher follows the head with concurrent block fetches (0.1 s blocks demand it) and classifies every successful transaction whose receipts contain Swap logs from the venue topic map (univ2, univ3, univ4, which also covers forks sharing those event signatures).
- Candidate. Two or more distinct pools touched in one transaction.
- Net flows. ERC-20
Transfers are netted for the transaction'sfromandtoonly — not arbitrary recipients — and nativevaluecounts as WETH out. This is what stopped a user's full swap output from being booked as a third party's profit. - Disqualifier. If any token nets negative, the sender paid something: it is a trade, not an arbitrage.
- Profit. Positive net in a quote asset (USDG, WETH) is booked as
profitWeiin that token; a pure positive position in another token is booked as a weaker, unpriced arbitrage. - Valuation. Per token, with decimals and price from
getTokenInfo; ETH/USD is derived from the on-chain WETH/USDG pool, not a constant. - Extrapolation. Daily rates are not published until the sample spans at least one hour.
Alongside attribution the watcher records a one-minute OHLC tape per pool — Uniswap V3 pools by address, V4 pools by PoolId, with their keys from the PoolManager's Initialize events in v4_pools — from sqrtPriceX96 and swap amounts (candles table, pruned after three days). The index lives in SQLite (arbs, arb_pools, auctions, settlements, candles, api_keys, meta) with NDJSON as the raw record.
Self-hosting#
git clone https://github.com/OrdoFi/ordofi && cd ordofi && npm install npm run watcher # index the chain → data/ordo.db (:—) npm run gateway # protected RPC (:8547) npm run auction # auction, receipts, feed relay (:8548) npm run web # terminal, explorer, portal, API (:3000) npm test && npm run typecheck # production: Caddy in front of the services, one shared data volume docker compose -f deploy/docker-compose.prod.yml --env-file .env up -d --build
| Variable | Default | Purpose |
|---|---|---|
ORDO_RPC_URL · ORDO_RPC_URLS | sequencer RPC | Read upstream(s); comma-separated list rotates on transport failures |
ORDO_SEQUENCER_URL | https://rpc.mainnet.chain.robinhood.com | Where signed transactions are sent; third-party providers are reads only |
ORDO_API_KEYS | ephemeral dev key | key:label:rateLimit:rebateAddress:mode records |
ORDO_ALLOW_ANON · ORDO_ANON_RATE_LIMIT | 1 in production · 600 | Anonymous wallet tier and its per-IP limit |
ORDO_AUCTION_URL · ORDO_AUCTION_TIMEOUT_MS | http://localhost:8548 · 3000 | Gateway → auction |
ORDO_AUCTION_WINDOW_MS · ORDO_HINT_LEVEL | 200 · pools | Auction window and hint disclosure |
ORDO_SETTLEMENT_ADDRESS · ORDO_AUCTIONEER_KEY | — | Enables bond checks, on-chain settlement and signed receipts |
ORDO_RECEIPT_LOG_ADDRESS · ORDO_RECEIPT_COMMIT_EVERY | — · 25 | Merkle anchoring |
ORDO_REBATE_USER/APP/PROTOCOL | 0.9 / 0.05 / 0.05 | Ledger split (on-chain split is in the contract) |
ORDO_FEED_URL | sequencer feed | Upstream for the relay |
ORDO_DB · ORDO_DATA_DIR | data/ordo.db · data/ | Index and persisted caches |
Compose services: caddy, gateway, auction, web, watcher, searcher (the house bidder that keeps the market non-empty), arb (the house arbitrage bot). Contracts are Foundry projects under contracts/; a Nitro full-node deployment for a bare-metal host is under deploy/nitro-node/.
Trust model & limitations#
- The sequencer sees everything
- Unavoidable on an L2 with a centralized sequencer. OrdoFi removes third parties from the path; it cannot remove the operator. Direct sequencer integration is the end of the roadmap for that reason.
- Bundles are best-effort
- Same-tick dispatch on a FCFS sequencer narrows the gap but does not guarantee adjacency. Use
OrdoExecutorfor atomicity. - Receipts have a bounded retraction window
- Everything up to
anchoredCountis immutable; the gap tocount(at most 25 receipts) could still be altered by a malicious operator. Anchoring cadence is a parameter. - The auctioneer key is hot
- It signs acks and receipts and calls
settle(). Its powers are bounded by searcher signatures (it cannot charge above a signed maximum, nor twice) and by the contract owner, who can rotate it withsetAuctioneer. - Contract owner
- The deployer owns OrdoSettlement and can change the auctioneer, the split and the treasury. Bonds and claimables cannot be seized by the owner; there is no owner withdrawal path.
- Hints leak early, by design
- Searchers learn pools and direction ~200 ms before the feed would show them. Amounts are withheld below the
fullhint level. This is the price of selling the backrun instead of giving it away. - Not audited
- The contracts are small and tested but have not had a third-party audit. Bond what you can afford to lose until they have.
- Not affiliated
- OrdoFi Labs is independent of Robinhood Markets, Inc.