01How to read this
Every figure in this document carries where it came from. Nothing here is estimated from a diagram or recalled from a README.
- Measured
- MRead from the Cloudflare API, the GraphQL analytics endpoint, a D1 query against the production database, or an HTTP probe of the live endpoint. Reproducible today.
- Derived
- DArithmetic on measured values, or a constant read directly out of the deployed source. The inputs are always named.
- Assumed
- AA modelling assumption for the 100k projection. Stated explicitly, never mixed into a measured table.
The capture window matters for the traffic numbers. Cloudflare's Workers analytics were read for 2026‑09‑04 through 2026‑09‑07 13:00 UTC, and 09‑07 is a partial day (traffic to 12:50 UTC only). Latency probes are three sequential samples from a Sydney client, so the first sample of a cacheable endpoint includes the cache fill and the following two do not. That difference is itself informative and has been left in.
Two things this document deliberately does not do. It does not report a throughput number for the
LiveChain consensus, because the chain reports medianCommitMs: null and has never measured
one. And it does not assign a dollar figure to the Cloudflare bill, because the account carries
thirty-eight other Workers and eleven other R2 buckets, and no per-project cost attribution exists.
02Deployed inventory
Six Workers carry the system. Five belong to LiveAgents proper; the sixth,
agent-terminal, is a sibling product on the same zone that the exchange calls into to
deploy an agent, so it is in scope for connectivity but not for the data model.
Workers
| Worker | Hostname | Cron | Compat date | Last deploy (UTC) |
|---|---|---|---|---|
| liveagents-code | code.liveagents.org | */5 * * * * | 2026-09-01 | 09-07 12:28:37 |
| liveagents-dex | dex.liveagents.org | */2 * * * * | 2026-09-01 | 09-07 12:46:21 |
| liveagents-chain | chain.liveagents.org | * * * * * | 2026-09-01 | 09-07 01:10:54 |
| liveagents-hedge | — (workers.dev) | * * * * * | 2026-09-01 | 09-05 08:08:24 |
| liveagents-treasury | — (service binding) | none | 2025-03-10 | 09-06 07:21:51 |
| agent-terminal | terminal.liveagents.org | — | 2025-10-01 | 09-07 12:46:47 |
Static hosting
The React single-page app is deployed twice from the same build. Cloudflare Pages
project liveagents serves liveagents.org and liveagents.pages.dev
from production branch main (latest deployment 2026‑09‑07 12:47:07 UTC, commit
ab4b9312). The liveagents-code Worker serves the identical bundle through a
Workers Assets binding with not_found_handling: single-page-application and
run_worker_first: ["/api/*"]. The exchange terminal is a third, separate bundle served
from the dex Worker's own assets directory.
Container fleets
| Application | Backing DO class | Instances | vCPU | Memory | Disk | Version |
|---|---|---|---|---|---|---|
| liveagents-dex-exchange | Exchange | 1 / 1 | 1 | 6 GiB | 12 GB | 138 |
| liveagents-code-sandbox | Sandbox | 7 / 40 | 1 | 6 GiB | 12 GB | 15 |
| agent-terminal-claudesandbox | ClaudeSandbox | 7 / 20 | 1 | 6 GiB | 12 GB | 43 |
| agent-terminal-agentrunner | AgentRunner | 7 / 40 | 0.25 | 1 GiB | 4 GB | 39 |
Durable Object namespaces
| Binding | Class | Worker | Namespace id | Cardinality |
|---|---|---|---|---|
| SANDBOX | Sandbox | code | d94da777… | one per user |
| LEDGER | Ledger | code | 34c1f46f… | one per user |
| RUNNER | StrategyRunner | code | 8deaa8d8… | one per strategy |
| RESEARCH | Researcher | code | 1e7b773a… | singleton the-researcher |
| COMMITTEE | InvestmentCommittee | code | de2d688b… | singleton investment-committee |
| EXCHANGE | Exchange | dex | 74a116e1… | singleton paiex-main, the address the container has held since before the exchange was renamed |
| NODE | ValidatorNode | chain | 9f25d8cb… | fixed 7 validator-0…6 |
| PAYOUTS | PayoutQueue | treasury | 0dab6b59… | singleton treasury |
Durable stores
| Store | Identifier | Size | Tables | Replication |
|---|---|---|---|---|
| D1 liveagents-dex | 8799f2eb-594a-41a6-85ee-f2c51194c31c | 12,439,552 B | 32 | disabled |
| D1 liveagents-chain | 1b1f1a96-2b34-4383-bff5-d9b8be6b7785 | 4,169,728 B | 6 | disabled |
| D1 liveagents-code | b9c27dfa-f902-47ba-9414-a86a1e3b2250 | 462,848 B | 18 | disabled |
| R2 liveagents-workspaces | created 2026-09-03 | — | — | n/a |
| KV REGISTRY | 225f0ebf6f8049d2849cb429db15c854 | — | — | global |
03Logical architecture
Five tiers, and only one of them holds state that matters. Workers are stateless routers and policy gates; every durable fact lives in a Durable Object, in D1, or in R2.
liveagents-dex D1 database is written by the exchange container and by the hedge Worker
independently, which makes it the one store with two writers and no coordination between them. The ICP
canister is drawn dashed because ICP_CANISTER_ID is unset in production —
/api/icp/status reports local-ledger, so mandate enforcement runs in the
Ledger Durable Object, not on-chain. MWhat each Worker is responsible for
liveagents-code is the console's API and the AI desk's scheduler. It serves the SPA
from a Workers Assets binding, handles /api/* itself, mints and verifies HS256 sessions,
seals user credentials with AES-256-GCM under MASTER_KEY, and owns five Durable Object
classes. Its five-minute cron does no work itself — it re-arms things that a deploy can silently
destroy: researcher.ensure(), scanIfDue(), sitIfDue() and
runOutlets(). Every one of those was originally a Durable Object alarm, and a class
replacement during deploy drops the alarm with no error anywhere.
liveagents-dex is a cache, a policy gate and a SQL relay wrapped around one
container. It answers the public read endpoints from caches.default with per-endpoint TTLs
chosen from how fast each value actually moves, answers the agents pages from its own D1 binding
without touching the container at all, and forwards everything else. It also hosts three control paths
that are not part of the public API: /__sql, /__roll and
/__treasury, all behind the same SQL_TOKEN.
liveagents-chain holds no keys and no state. It routes: submissions are sticky by sender (the sender address is hashed to a validator, so one account's strictly-sequential nonces cannot be split across nodes in a round), and reads are spread across validators by a hash of the key being read so repeated polls see a monotonic answer instead of flickering between nodes at different heights.
liveagents-hedge has no route at all. Its every-minute cron pulls the zkLighter venue registry on multiples of five, lists new markets on the local book, and runs a hedging cycle in shadow mode. It writes to the exchange's D1 database.
liveagents-treasury has workers_dev = false and no route. The only way
in is the dex Worker's service binding, and only for a request the exchange container signed with a
shared HMAC over timestamp.body within a 300-second window. It holds the Privy application
secret, the wallet authorization key and the Solana RPC URL, and it enforces its own per-request and
per-day payout caps independently of whatever the exchange believes it owes.
04Physical production topology
There is no origin server, no VPS, no load balancer and no nginx. What replaces them is a distinction worth drawing precisely: which code runs in every colo the user might arrive at, and which code runs in exactly one place on earth.
The consequence of max_instances: 1
The exchange holds its orderbook in memory and writes through to D1, so a second instance would be a second exchange with a second book. That is a correct decision for a single matching engine and it is also the system's hard capacity limit: one process, one vCPU, terminating every WebSocket, running the matcher, the market maker, funding accrual and liquidation checks. Section 10 covers the only way past it, which is sharding by market rather than adding instances.
The deploy-time trap this topology creates
A container instance runs the image it started with, for its whole life. The dex container is kept
awake by a two-minute cron precisely so it never stops quoting — which means it also never
restarts, and therefore never picks up a newly pushed image. ./cf/deploy.sh dex handles
this by calling POST /__roll with the SQL token after the push; without that step a
deploy builds and uploads an image that nothing ever runs. The only proof the new image is live is
uptimeSec resetting at /api/health.
05Connectivity map
Sixteen edges carry everything. The diagram shows which component talks to which; the table beneath it carries the protocol, the credential and the cadence for each numbered edge, because those would be illegible written on the arrows.
D1 liveagents-dex: the exchange container writes it through the Worker's SQL relay, and
the hedge Worker writes it directly from its own binding, with no coordination between them.
M D| # | Edge | Transport | Credential | Cadence / trigger |
|---|---|---|---|---|
| 1 | Browser → code Worker | HTTPS /api/* | Bearer HS256 JWT, 30 d | user action |
| 2 | Browser → dex Worker | HTTPS + WebSocket /ws | Bearer (account routes only) | continuous while terminal open |
| 3 | Browser → chain Worker | HTTPS /api/* | none (public read) | explorer polling |
| 4 | code Worker → Sandbox | DO RPC → container :8080 | x-agent-token, minted per object | per workspace request |
| 5 | code Worker → Ledger / Runner | DO RPC | binding — not reachable externally | every order intent; 15 s min tick |
| 6 | code Worker → Researcher / Committee | DO RPC + alarms | binding | cron */5, re-arm only |
| 7 | code Worker → D1 code | binding | platform | per request |
| 8 | Sandbox → R2 workspaces | via DO: tar.gz stream | binding | restore on start, snapshot on change |
| 9 | Sandbox → Anthropic | HTTPS, container egress | CLAUDE_CODE_OAUTH_TOKEN | per Claude Code turn |
| 10 | dex Worker ↔ edge cache | caches.default | n/a | 7 endpoint families, TTL 2–30 s |
| 11 | dex Worker → Exchange | DO RPC → container :8110 | platform | every uncached request; cron */2 |
| 12 | Exchange → D1 dex | HTTPS POST /__sql over public edge | x-sql-token | every query; batched write-behind |
| 13 | dex Worker → treasury | service binding via /__treasury | x-sql-token + HMAC(ts.body) | deposit / withdrawal |
| 14 | treasury → Privy, Solana | HTTPS | app secret + wallet auth key | per payout |
| 15 | Exchange → chain Worker | HTTPS /api/tx | BLS signature, CHAIN_PUBLISHER_SEED | one transaction per fill |
| 16 | hedge → zkLighter | HTTPS, 20 s timeout | none | cron on minutes divisible by 5 |
Edge 12 is the one that surprises people
D1 is a Worker binding, not a socket a container can open. So the exchange sends its SQL to
https://dex.liveagents.org/__sql — its own public hostname — and the Worker
runs the statement against D1 and returns rows. Every query therefore leaves the container, crosses
the public edge and comes back. This was previously costing 10–20 seconds per query
because Node tried the AAAA record first and waited out the default connect timeout before falling
back to IPv4. The fix (setDefaultAutoSelectFamilyAttemptTimeout(250) plus
keepalive) is live: /api/live, a single-query endpoint, now measures
482/506/482 ms against /api/health's 76 ms with zero queries.
M
06Data flows
Two flows carry the system's actual claims: an agent's order can only reach the exchange through a mandate check it cannot route around, and every fill becomes one signed transaction on a chain that does not trust the exchange.
Flow A — an autonomous order, from strategy code to the book
POST /strategy/tick returns what the
strategy wants to do; the Worker prices it at the touch, checks it against the mandate, and
only then places an order using a credential the container has never seen. Because
ICP_CANISTER_ID is unset, the identical rules run in the Ledger Durable Object's hash
chain rather than in the agent_registry canister — a weaker record, not a
weaker decision. DFlow B — a fill becomes a transaction on LiveChain
/api/chain/status reports
trades: 1274, committed: 1274, backlog: 0, failed: 0, and the chain's own
chain_trade_records table holds 1,406 rows across both venues.
MFlow C — the AI research desk
Three producers write into liveagents-code D1 and the console reads them back. The
Researcher singleton walks every listed market on a Durable Object alarm, one Claude
Code turn per asset in the platform's own sandbox container, 45 seconds between assets and 86,400
seconds between sweeps. The Opportunity Finder scans on the cron rather than an alarm,
because a scan runs for minutes and a deploy resets a Durable Object mid-scan. The Investment
Committee sits once a day: three analysts with deliberately conflicting mandates each take a
turn in their own container, and a chair reconciles them into a ranked slate.
- Research cost / pass
- $0.09M 21 passes, $2.03 spent
- Research sweep
- 86,400 s12 markets, 45 s apart ≈ $1.10/day
- Committee sitting
- $1.684 turns, 177 s wall clock, daily
- Prior cadence cost
- $77/daysame loop at a 90 s interval
The cost dial is RESEARCH_SWEEP_S, and the difference between its current value and its
original one is a factor of seventy in the monthly Anthropic bill. That is the single most consequential
tunable in the deployment, and it lives in a Worker var.
07Data stores — contents and growth
Every row count below was read from the production databases during the capture window. Together they say something the architecture diagram cannot: the write load today is almost entirely market data, not user activity.
liveagents-dex — the exchange ledger
| Table | Rows | What it holds |
|---|---|---|
| candles | 106,901 | 228 markets × 9 intervals. 83% of all rows in the database. |
| ledger_entries | 7,873 | double-entry postings; ~6.2 per trade |
| orders | 2,367 | engine-assigned ids, not database ids |
| hedge_plans | 1,518 | all mode = shadow, all executed = 0 |
| trades | 1,274 | fill ratio 0.54 against orders |
| chain_records | 1,274 | exactly one per trade — backlog zero |
| funding_rates | 1,058 | perp funding accrual history |
| user_stats | 955 | periodic snapshots per user |
| registry_syncs | 718 | zkLighter pulls; 606 ok, 112 failed |
| lighter_markets | 244 | upstream venue registry, replaced wholesale each sync |
| markets / assets | 228 / 222 | listed definitions — but only 12 are served at /api/markets |
| positions | 226 | open interest is currently zero across all of them |
| accounts / users / agents | 58 / 34 / 15 | the entire live user base |
| transfers | 42 | — |
| trading_grants | 5 | — |
| deposit_addresses | 0 | Zero because the deposit and withdrawal path is broken. See finding S3. |
| payouts / chain_deposits | 0 / 0 |
Where the candles come from
| Interval | Rows | Markets | Buckets / market | Oldest bucket |
|---|---|---|---|---|
| 1m | 58,524 | 228 | 257 | 2026-09-03 |
| 3m | 21,342 | 228 | 94 | 2026-09-02 |
| 5m | 14,348 | 228 | 63 | 2026-09-01 |
| 15m | 5,816 | 228 | 26 | 2026-08-30 |
| 1h | 2,392 | 228 | 10 | 2026-08-18 |
| 4h | 1,375 | 228 | 6 | 2026-06-19 |
| 1d / 1w / 1M | 3,104 | 228 | ~4.5 | 1993-10-27 |
liveagents-code — the console and the desk
| Table | Rows | Table | Rows |
|---|---|---|---|
| outlet_stories | 163 | committee_sessions | 9 |
| research_findings | 28 | opportunities | 8 |
| events | 28 | users | 6 |
| logs / committee_remarks | 18 / 18 | workspaces | 5 |
| opportunity_scans | 6 | strategies / sessions | 4 / 4 |
| outlets / outlet_scores | 4 / 4 | backtests | 1 |
| orders / secrets / icp_links | 0 / 0 / 0 | — | — |
liveagents-chain — the explorer mirror
chain_txs 3,450 · chain_events 1,549 ·
chain_rounds 1,526 · chain_trade_records 1,406 ·
faucet_claims 11 · chain_minutes 0. M
This database is a searchable mirror, not the chain: the chain itself lives in the seven validator
Durable Objects' SQLite storage. Losing this database would cost the explorer's search box, not the
ledger. Note that chain_minutes has never been populated, so the per-minute throughput
rollup the explorer expects does not exist.
The three constraints the D1 transport imposes on the exchange
| D1 limit | What it forced in db.ts |
|---|---|
| 100 bound parameters / statement | MAX_BINDS = 100; the exchange's 500-row multi-row INSERTs are split into statements that fit |
| Numbers, not bigints, over the wire | bind() refuses any scaled value past 253−1 rather than store one that reads back wrong — roughly nine billion units of quote |
| Queries per Worker invocation | MAX_STATEMENTS_PER_REQUEST = 20; a long flush becomes several requests, giving up all-or-nothing across the whole flush |
| No interactive transactions | transaction() throws by design; callers must use batch() |
08Measured performance baseline
Four days of Workers analytics and a set of timed probes. This is the number to compare against after any change in section 10.
Request volume, per Worker per day
| Date (UTC) | dex | chain | code | hedge | terminal | treasury |
|---|---|---|---|---|---|---|
| 2026-09-04 | 47,902 | 7,160 | 55 | — | 1,103 | 0 |
| 2026-09-05 | 162,264 | 2,952 | 61 | 1,236 | 618 | 0 |
| 2026-09-06 | 154,342 | 18,837 | 180 | 1,418 | 649 | 0 |
| 2026-09-07* | 105,741 | 58,737 | 3,482 | 748 | 608 | 2 |
Latency and error quantiles, 09-06 00:00 to 09-07 13:00 UTC
| Worker | Requests | Errors | Subrequests | CPU p50 | CPU p99 | Wall p50 | Wall p99 |
|---|---|---|---|---|---|---|---|
| liveagents-dex | 258,842 | 4 | 53,744 | 0.59 ms | 2.25 ms | 21 ms | 9.99 s |
| liveagents-chain | 77,198 | 4 | 225,210 | 0.72 ms | 4.38 ms | 356 ms | 15.00 s |
| liveagents-code | 3,451 | 0 | 1,518 | 0.85 ms | 17.25 ms | 28 ms | 4.87 s |
| liveagents-hedge | 2,156 | 68 | 840 | 3.75 ms | 31.30 ms | 517 ms | 5.51 s |
Endpoint response times, three sequential samples from Sydney
| Endpoint | 1st | 2nd | 3rd | Path taken |
|---|---|---|---|---|
| liveagents.org/ | 117 | 63 | 82 | Pages, colo-local |
| dex /api/markets | 71 | 64 | 65 | edge cache hit, 30 s TTL |
| dex /api/book/BTC-PERP | 445 | 68 | 72 | cache fill, then hit |
| dex /api/candles/BTC-PERP | 466 | 74 | 84 | cache fill, then hit |
| dex /api/health | 634 | 76 | 76 | container, zero queries |
| dex /api/live | 482 | 506 | 482 | container, one query, never cached |
| dex /api/agents | 450 | 452 | 449 | Worker's own D1, never cached |
| code /api/health | 75 | 126 | 55 | isolate only |
| code /api/opportunities | 186 | 156 | 164 | two D1 reads |
| code /api/research/feed | 92 | 89 | 92 | one D1 read |
| chain /api/status | 411 | 385 | 429 | fan-out to all 7 validator objects |
| chain /api/nodes | 409 | 412 | 405 | fan-out to all 7 |
| chain /api/supply | 455 | 230 | 227 | single validator read |
D1 workload, per database per day
| Date | Database | Read queries | Write queries | Rows read | Rows written | p50 | p90 |
|---|---|---|---|---|---|---|---|
| 09-05 | dex | 87,712 | 411,125 | 8,408,236 | 3,974,773 | 0.22 ms | 1.49 ms |
| 09-06 | dex | 66,893 | 148,723 | 9,250,997 | 981,947 | 0.22 ms | 0.53 ms |
| 09-07* | dex | 57,606 | 79,865 | 15,011,717 | 544,779 | 0.22 ms | 0.58 ms |
| 09-07* | chain | 2,572 | 5,997 | 4,135,624 | 27,463 | 2.19 ms | 5.91 ms |
| 09-07* | code | 10,876 | 2,188 | 79,218 | 3,793 | 0.18 ms | 0.34 ms |
Read amplification
On 09-07 the dex database served 57,606 read queries but read 15,011,717 rows — an average of
261 rows per read query. D That ratio is what a table scan
looks like from the outside. The candles table has a composite primary key on
(market, interval, bucket), so range reads over it are indexed, but the volume says the
query planner is walking far more rows than any single chart needs.
09Bottlenecks and scale ceilings
Ranked by how soon each one stops growth, not by how hard it is to fix. Every one carries the measurement that identifies it.
B1 — the exchange is one container and cannot be given a second
The book is in memory, so max_instances: 1 is correct rather than conservative: a
second instance would be a second exchange. That one process, on 1 vCPU, terminates
every WebSocket, runs the matcher, quotes the market maker, accrues funding and checks liquidations.
Measured uncached round trip is 450–506 ms steady-state, and the Worker's
wallTimeP99 against it is 9.99 s.
The contention is already documented in the code's own tuning comments: quoting thirty markets a
tick made the exchange answer in eleven seconds instead of milliseconds, because the matcher
was queued behind the market maker. MM_PER_TICK is set to 6 and
MM_REFRESH_MS to 6,000 to keep that product bounded. Those knobs bound the maker; nothing
bounds concurrent user traffic.
B2 — every container query is a public-internet round trip
D1 cannot be dialled from a container, so the exchange posts SQL to its own public hostname. At capture, an endpoint with zero queries measured 76 ms and an endpoint with exactly one query measured 482 ms. M The database's own p50 is 0.22 ms, so roughly 400 ms per uncached endpoint is pure transport. This is far better than the 10–20 s it was before the IPv6 connect-timeout fix, and it is still the dominant term.
B3 — D1 read replication is off on all three databases
read_replication: {"mode": "disabled"} and no primary_location_hint on any
of them. M Every read from every colo crosses to one region.
Correction to the evidence
The endpoint originally cited, /api/agents, is not the Worker's D1 path at all —
it is an authenticated route on the container and returns {"error":"missing_token"}. The
Worker answers /api/agents/public, which measured 106–131 ms. The finding
itself stands on the configuration, which was read directly; the 450 ms figure belonged to the
container round trip, which is B1 and B2. M
B4 — market-data writes dominate — historical, already resolved
3,974,773 rows written on 2026-09-05 M against 34 users and 1,274 lifetime trades, with the candles table holding 106,901 rows across 228 markets and nine intervals.
This had already fixed itself before the finding was written
Counting candle rows written in the last hour rather than in the table as a whole: 9 to 10
markets across five intervals, about 929 rows an hour. The markets table now
reads active: 12, delisted: 216. M The 3.97 M day was the
228-market era; the delisting removed the load, and the 106,901 rows are history for markets nobody
trades rather than an ongoing write rate.
The read amplification claimed alongside it — 261 rows per read query — is also not a scan. A chart request returns up to 1,500 bars, so an average of 261 rows across all reads is what a working candles endpoint looks like. No purge was performed: deleting the history of 216 delisted markets is irreversible and nothing measured says it is costing anything.
B5 — the chain fans out to seven objects on every status read
77,198 chain requests produced 225,210 subrequests, a 2.9× amplification.
D /api/status and /api/nodes both call all seven
validator Durable Objects and wait for the slowest, which is why both measure 385–430 ms
while the single-node /api/supply measures 227 ms. Chain wallTimeP99 is
15.0 s, the worst in the fleet.
B6 — the consensus committee is not agreeing on state
At capture: validators: 7, online: 7, atHead: 7, behind: 0 but
agreeing: 2 and inAgreement: false, with round: 2204 against
lastCommittedRound: 2201. M Inspecting the nodes individually,
their logRoot and logSize are identical (173460d5…,
3,453) while their stateRoot and emitted values differ. Two explanations fit:
either token emission is time-derived and each node was sampled a few milliseconds apart, which is
benign; or state execution genuinely diverges, which is a fork. The available evidence does not
distinguish them, and medianCommitMs is null on every node, so the
chain has never measured its own commit latency. Establishing that number is prerequisite to any claim
about chain throughput.
B7 — the hedge Worker loses a decision cycle whenever the venue rate-limits it
68 of 2,156 invocations threw M — a 3.2% exception rate. The cause
is in the exchange's own registry_syncs table: 112 of 718 syncs failed on HTTP
429 from zkLighter, 74 on /api/v1/orderBooks and 38 on
/api/v1/exchangeStats. M Because the scheduled handler awaits
sync(env) before runCycle(...), a 429 also skips that tick's hedging
decision. All 1,518 hedge plans are mode = shadow, executed = 0, and the most recent one
is dated 2026-09-06 07:12 — more than a day before capture, with the cron running every minute
throughout.
B8 — forty concurrent workspaces, one shared AI credential
The sandbox fleet caps at 40 instances (7 live at capture) at 1 vCPU and 6 GiB each. M Every builder gets one container addressed by their user id, and every platform agent — researcher, finder, three committee analysts, chair — also consumes one. At 100,000 registered users even a 0.5% concurrency rate needs 500 containers.
B9 — the front-end ships nine megabytes — withdrawn
This finding was wrong
The 1,464,454 B chunk is not the entry. dist/index.html loads
index-BTIIQlCZ.js at 302,068 B plus 55,765 B of CSS —
357,833 B eager, about 99 KB gzipped. Every route is a lazy import and the
Privy SDK is loaded after first paint by PrivyRoot, so the large chunks are fetched only
by the pages that need them. The 1.75 MB PDF is a link the reader clicks, not part of any bundle.
The original claim counted the whole output directory as though it were the first load.
M
What is true: the same bundle is deployed twice, to Pages and to Workers Assets, from one build.
B10 — a deploy destroys in-flight work, silently
Replacing a Durable Object class resets the object, killing any Claude Code turn in progress and
dropping any alarm it held. This has silently stopped the research feed and the committee twice. The
mitigation in place is the cron re-arm pattern — sitIfDue and scanIfDue
ask the database "has a session produced a result in the last day?" rather than trusting an alarm. It
is a correct mitigation and it does not remove the cost: a scan or a sitting in progress at deploy
time is lost, and the only signal is an empty feed.
10Scaling to 100,000 users
The load model first, stated as assumptions so it can be argued with. Then the derived demand, then the six changes that meet it, in the order they unblock each other.
The model
| Input | Value | Basis |
|---|---|---|
| Registered accounts | 100,000 | A the target |
| Daily active | 2,000 | A 2% of registered |
| Peak concurrent sessions | 2,500 | A 2.5% of registered |
| REST calls per open session | 20 / min | A terminal polling plus user action |
| Cacheable share of reads | 85% | D 7 of the 9 hot endpoint families already carry a TTL |
| Orders per active user per day | 17.4 | D 2,367 orders ÷ 34 users ÷ 4 days |
| Fill ratio | 0.54 | D 1,274 trades ÷ 2,367 orders |
| Ledger postings per trade | 6.2 | D 7,873 entries ÷ 1,274 trades |
| Bytes per row | ~100 | D 12.44 MB ÷ ~124,000 rows |
What the model demands
- Peak REST
- 833 /sD 2,500 × 20 ÷ 60
- Served at the colo
- 708 /s85% cache hit, no origin tier touched
- Reaching the exchange
- 125 /sthe number that must not hit one container
- In-flight at 450 ms
- 56concurrent requests against 1 vCPU today
- Concurrent WebSockets
- 2,500on the same process that matches orders
- Orders / day
- 34,8002,000 × 17.4
- Fills / day
- 18,800→ 0.22 chain tx/s sustained
- Ledger rows / year
- 42.6 M≈ 4.3 GB against D1's 10 GB ceiling
Three of those numbers are decisive. 125 uncached requests per second against a single 1-vCPU process is roughly fifty times what the measured round trip supports. 2,500 WebSockets on the matching engine is not a tuning problem, it is the wrong process. And 42.6 million ledger rows a year puts a single D1 database within eighteen months of its hard 10 GB limit, before candles are counted.
The target shape
getContainer(env.EXCHANGE, 'paiex-main') — a constant. Replacing that constant with
the market symbol turns max_instances: 1 from a correctness requirement into a capacity
setting, because each container then owns a disjoint set of books. Everything else in the target panel
follows from freeing that constraint. DThe work, in dependency order
| # | Change | Unblocks | Evidence it is needed |
|---|---|---|---|
| 1 | Trim candle production to listed markets and the intervals the UI reads. 228 markets × 9 intervals → 12 × 4. | B4 — and reduces monthly written rows from ~119 M to ~2.8 M | 106,901 candle rows for 12 listed markets M |
| 2 | Enable D1 read replication and adopt the Sessions API on all three databases; set a primary location hint near the exchange shards. | B3 — removes the ~400 ms floor on every uncached read | read_replication: disabled on all three M |
| 3 | Move WebSocket fan-out into a Durable Object per market using hibernating WebSockets. The container publishes the tape; the DO holds the sockets. | B1 — 2,500 concurrent sockets stop competing with the matcher | one process terminates every socket today D |
| 4 | Shard the exchange by market: address the container by idFromName(market), raise max_instances, and give each shard a disjoint book set. | B1 — 125 uncached req/s spread across N cores | 450–506 ms per round trip on 1 vCPU M |
| 5 | Partition ledger_entries by epoch, or move it to per-account Durable Object SQLite, before it reaches D1's per-database limit. | B4 — 42.6 M rows/year ≈ 4.3 GB/year | 10 GB hard ceiling, 6.2 postings per trade D |
| 6 | Raise the sandbox ceiling and add admission control: a queue in front of container start, plus reaping below the current 25 m idle. | B8 — 500 concurrent workspaces at 0.5% of 100k | max_instances: 40, 7 live M |
Changes that are cheap and should not wait for the above
- Cache the chain's aggregate reads.
/api/statusand/api/nodesfan out to seven objects on every call and produced 225,210 subrequests. A 2-second edge TTL on both would remove most of that with no loss of accuracy, since the underlying rounds advance no faster. - Split the front-end bundle by route. The 1.46 MB entry chunk and the 977 KB Privy signer
should be lazy imports; the 1.75 MB PDF should not be in
distat all. - Catch the venue 429 in the hedge Worker so
runCyclestill runs, and back off rather than retrying on the next minute. - Measure the chain.
medianCommitMsis null on all seven nodes. Until that number exists, 0.22 fills/second is an untested claim. - Turn on traces and Logpush (
observability.traces.enabledisfalseon all five Workers,logpushisfalse) and alert on: research feed idle > 25 h, committee idle > 25 h, chaininAgreement: false, dexpendingWrites > 0, and registry sync failure rate.
One thing the plan does not need
A bigger database tier. D1 answers at 0.18–2.19 ms p50 across all three databases under the current load M, and the projected user-driven write volume of roughly 5.7 M rows/month sits comfortably inside the Workers Paid included allowance once the candle load is trimmed. The problem was never query cost; it was distance and volume of machine-generated writes.
11Security
Twelve findings against the deployed configuration and the deployed source. Two are critical, four are high. Key material is described by its structure below, never quoted.
Read with section 13
These are the findings as first written. Eleven of the twelve are now fixed and the twelfth is prepared. Section 13 carries the current state of each, what was measured to establish it, and the one that is still live: the Privy authorization keypair has been moved out of a readable field but never actually replaced, so it must still be assumed disclosed. Re-checked 2026‑09‑10 in section 21, which carries the current state of all twelve plus three findings added since.
The security model that is working
Worth stating first, because the findings below should not obscure it. A user's strategy runs in
their own container and is handed no exchange credential and no route to the exchange;
POST /strategy/tick returns intents, and the Worker prices them, checks them against the
mandate and only then places an order. The treasury holds the signing credentials and the exchange does
not, so a compromise of the exchange container can queue payout requests the treasury refuses and can
read no key. The container disk is discarded on every sleep and the workspace lives in R2, which is what
makes it safe to give a language model a shell inside. Session tokens are never accepted from a query
parameter. Passwords are PBKDF2-SHA256 and user credentials are AES-256-GCM sealed under a Worker
secret. All of that is real and verified in the deployed code.
Findings
crit
A Privy wallet authorization private key is deployed as a plaintext Worker variable
The liveagents-treasury Worker carries a binding named
PRIVY_AUTH_PUBLIC_KEY of type plain_text. Its value is not a public key. It
decodes to a 138-byte PKCS#8 PrivateKeyInfo structure, and the public point embedded in it
is byte-identical to the SubjectPublicKeyInfo committed in cf/treasury/wrangler.toml —
proving it is the private half of the same P-256 keypair. The repository's own comment beside that value
says the private half "belongs in wrangler secret put PRIVY_AUTH_KEY and never in a tracked
file".
Plaintext vars are readable by any API token with Workers script read on the account, and are displayed in the Cloudflare dashboard. This key authorizes operations on the treasury's custody wallets.
binding PRIVY_AUTH_PUBLIC_KEY type: plain_text DER 30 81 87 02 01 00 30 13 06 07 2a 86 ... ← SEQUENCE, version 0 = PKCS#8 private key repo 30 59 30 13 ... ← SubjectPublicKeyInfo, the public half public point of the deployed private key == tail of the repo public key → same keypair
Remediation. Rotate the keypair in Privy first, because the current one must be assumed
disclosed. Then set the new private half with wrangler secret put PRIVY_AUTH_KEY, put only
the public half in PRIVY_AUTH_PUBLIC_KEY, and redeploy. Do not simply change the binding
type; the old value is already in Cloudflare's configuration history.
crit
Every builder can read the deployment-wide Claude Code credential from their own shell
Three deployed facts compose into this. cf/code/src/sandbox.ts pushes the deployment's
CLAUDE_CODE_OAUTH_TOKEN into each container via POST /secrets.
cf/sandbox/agent/server.mjs spawns every child process with
env: { ...process.env, ...secrets, ... }. And POST /api/workspace/exec
proxies straight to /exec, which runs bash -lc <command>. So
env in any workspace shell prints the token.
It is one shared credential for the whole deployment — the health endpoint confirms
claudeAuth: "deployment token" — so a single builder can exfiltrate the credential
that pays for every user's turns, and signup is open to anyone.
POST /api/workspace/exec { "command": "env" } → CLAUDE_CODE_OAUTH_TOKEN=... (also ANTHROPIC_API_KEY where set)
Remediation. Stop pushing the deployment credential into user-owned containers. The platform
agents that legitimately need it — researcher, finder, the three analysts and the chair —
should run in a Durable Object namespace whose containers have no /exec route, or in a
separate Worker entirely. Builders should bring their own key, or be issued a scoped, short-lived,
per-user credential with its own spend cap.
high
The deposit and withdrawal path is dead because of a secret-name typo
Both liveagents-dex and liveagents-treasury carry a secret named
TREASURY_HMA. Both Workers' code reads env.TREASURY_HMAC. The treasury's
fail-closed guard therefore fires on every request, and the exchange signs with an empty string.
Verified live through the dex forwarder, and corroborated by the database: zero deposit addresses, zero payouts, zero chain deposits, and two lifetime requests to the treasury Worker — both issued while writing this document. This is a correctness defect with a security consequence: the system's money path has been silently non-functional, and nothing alerts on it.
$ curl -H "x-sql-token: …" -X POST -d '{}' https://dex.liveagents.org/__treasury/balances {"error":"treasury_not_configured"} wrangler secret list (both Workers) → "TREASURY_HMA" src/index.ts → if (!env.TREASURY_HMAC || …) return 503
Remediation. Set TREASURY_HMAC with the same value on both Workers, delete
TREASURY_HMA, redeploy, and add /__treasury/balances to the deploy
verification script so this cannot regress unnoticed.
high
/__sql is an internet-facing arbitrary-SQL endpoint behind one static token
https://dex.liveagents.org/__sql accepts an array of statements with binds and runs them
against the exchange's D1 database. Authentication is a single static header,
x-sql-token, with no IP restriction, no statement allowlist, no rate limit and no
rotation schedule. The same token also gates /__roll, which stops the exchange, and
/__treasury, which relays to the treasury Worker.
The endpoint returns 403 without the token, which is correct. The exposure is that the container reaches it over the public edge, so the credential crosses the public internet on every single query, and one leaked value yields full read/write on the ledger plus the ability to halt trading.
Remediation. Rotate SQL_TOKEN on a schedule and after any container image
change. Split the three control paths onto three distinct secrets so the SQL relay credential cannot
halt the exchange. Consider signing each SQL request the way the treasury requires — HMAC over
timestamp and body with a short replay window — rather than presenting a bearer token.
high
No rate limiting, no bot protection, and open signup
A search of all five Workers' source finds no rate-limit binding, no Turnstile integration and no
throttling of any kind. The zone is on the Free plan, so no WAF rate-limiting rules are
available at the edge either. POST /api/auth/signup requires an email that merely contains
an @ and a password of eight characters, with no verification step.
An unauthenticated attacker can therefore mint accounts freely, and each account can start a container against the 40-instance fleet ceiling and consume the shared Claude credential from S2. The chain's faucet is throttled only by a row per address in D1.
Remediation. Put Turnstile on signup and login. Add a rate-limit binding to the code Worker
covering /api/auth/*, /api/workspace/start and /api/workspace/exec.
Require email verification before a container may be started. The zone plan is a prerequisite for edge
rate limiting and should be treated as part of this fix.
high
One JWT secret spans two services, tokens live 30 days, and there is no revocation
The deployment documentation instructs setting the same JWT_SECRET on the code Worker
and on the main API so one login works on both. Tokens are HS256 with a 30-day TTL, carry no
jti, and there is no denylist — a stolen token is valid until it expires. The code
Worker additionally adopts any user named by a valid token into its own users
table on first use.
The signature comparison is constant-time and the query-parameter token path was deliberately removed, both of which are correct. The residual risk is blast radius: compromise of either service's secret store yields sessions on both, and nothing can be cut short.
Remediation. Shorten the access token to hours and add a refresh token. Add a jti
and a KV-backed denylist so a session can be killed. If the two services must share identity, move to
asymmetric signing so the verifying service holds only a public key.
med
Access-Control-Allow-Origin: * on authenticated API responses
Both the code Worker and the chain Worker return a wildcard CORS origin on every response, including
authenticated ones. Because credentials travel in an Authorization header rather than a
cookie, this is not a classic CSRF exposure — but combined with S8 and tokens stored in
localStorage under the key la_token, any script execution on any page of the
origin exfiltrates a 30-day session.
Remediation. Reflect an allowlist of known origins on the authenticated routes and keep the wildcard only on the genuinely public read endpoints.
med
No script-src content security policy on either application
The dex Worker sets HSTS for a year with subdomains, X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin and a frame-ancestors policy
— a good set, and deliberately without script-src because the terminal loads the
TradingView library and the Privy SDK. The code Worker and the Pages deployment set none of
these headers at all.
Remediation. Add the same four headers to the code Worker and to Pages via
public/_headers. Then measure what the two third-party SDKs actually fetch and write a
script-src from the measurement rather than from a guess.
med
api.liveagents.org is a proxied hostname whose origin is gone
The hostname resolves to Cloudflare's proxy and returns 502 on every path, which means a
zone record still points at an origin that is not answering — the decommissioned VPS. Four shipped
pages still call it through src/lib/api.ts: Marketplace, Channels, BuildAgent and
TradingBrain. Authentication and the outlets page have already been migrated to the code Worker; these
four have not.
This is both a broken-feature bug and a dangling-origin risk: a proxied record pointing at infrastructure the account no longer controls is a takeover candidate.
Remediation. Delete the record, or repoint it at the code Worker and migrate the four
remaining pages off src/lib/api.ts. www.liveagents.org does not resolve at all
yet is named in the dex Worker's frame-ancestors list; tidy both together.
med
Container egress is unrestricted
Both container fleets run with network.mode: private and no assigned public address,
which prevents inbound reach. Outbound is not restricted: a builder with a shell can call any host on
the internet, including the platform's own /__sql, /__roll and
/__treasury endpoints. Those are token-gated, so the exposure is conditional on S2 and S4
— but the two findings compound, since the shell that reads the Claude token is the same shell
that can reach the exchange's control plane.
Remediation. Treat egress allowlisting as the boundary that makes S2 and S4 survivable rather than fatal, and re-audit once per-container egress policy is available.
med
Nothing observes the system, and nothing alerts
logpush: false and observability.traces.enabled: false on all five Workers,
with no tail consumers configured. There is no alerting on any of the failure modes this document has
already identified: the treasury has been non-functional for an unknown period (S3), the hedger has not
produced a plan since 2026-09-06, and the chain has been reporting inAgreement: false.
Each of those is visible at a public endpoint and nobody is looking.
Remediation. Enable traces, add a tail consumer or Logpush destination, and wire the five alerts listed at the end of section 10.
low
Password hashing is capped below current guidance
PBKDF2-SHA256 at 100,000 iterations, which is the Workers platform ceiling rather than a choice, and
below OWASP's 600,000 recommendation for the same primitive. The iteration count is stored with the
hash so it can be raised without invalidating existing passwords. Most users arrive through Privy and
have no password at all — those accounts store the literal string privy, which can
never parse as a PBKDF2 record, so there is nothing to attack.
Remediation. Low priority while Privy is the primary path. If the password route stays, consider moving verification to a service that can run a memory-hard KDF.
12Operations
Deploying
./cf/deploy.sh # everything ./cf/deploy.sh web # the SPA, to Pages ./cf/deploy.sh code # code.liveagents.org SQL_TOKEN=$(tr -d '\n' < ~/.config/liveagents/dex.sql_token) ./cf/deploy.sh dex
The dex line is not optional decoration. Pushing a dex image does not restart the container, and the
two-minute cron means it never restarts on its own, so without the roll a deploy uploads an image that
nothing runs. The roll is POST /__roll with x-sql-token. The only proof the
new image is live is uptimeSec resetting at /api/health.
Verifying a deploy
curl -s https://code.liveagents.org/api/health | jq # claudeAuth, icp mode curl -s https://dex.liveagents.org/api/health | jq # uptimeSec, pendingWrites curl -s https://dex.liveagents.org/api/chain/status | jq # backlog must be 0 curl -s https://chain.liveagents.org/api/status | jq # inAgreement, round vs committed curl -s https://code.liveagents.org/api/research/status | jq curl -s https://code.liveagents.org/api/committee/status | jq node cf/bin/verify-fill-records.mjs # third-party fill check
Traps that have already cost time, and the rule each one produced
| Trap | Rule |
|---|---|
| A deploy replaces a Durable Object class and its alarm is lost. The symptom is silence, not an error. | Clocks are crons, never alarms. sitIfDue and scanIfDue ask the database whether work is overdue. |
| A container that never sleeps never picks up a new image. | A dex deploy is not complete until /__roll returns. |
cf/code/src/schema.sql is not re-runnable; an older ALTER TABLE fails on a second pass. | Apply only the new statements from the tail of the file. |
| A deploy kills an in-flight Claude Code turn. | Check /api/opportunities and /api/committee/status before deploying, or expect to wait for the next cron. |
| Restoring an R2 snapshot over a live container silently overwrites newer work. | Ask the container whether it is seeded; never trust an in-memory flag on an object that can be evicted. |
Cloudflare stores secrets write-only, and the original SQL_TOKEN was lost. | Rotate rather than hunt. The current value is at ~/.config/liveagents/dex.sql_token, mode 600. |
Open defects at capture
- Treasury non-functional —
TREASURY_HMAversusTREASURY_HMAC. Finding S3. - Hedger has produced no plan since 2026-09-06 07:12, with the cron firing every minute since. All 1,518 plans are shadow-mode and unexecuted; open interest is currently zero across all 226 positions, which is a plausible benign cause but is unconfirmed.
- 112 of 718 registry syncs failed on HTTP 429 from zkLighter, and each failure also skips that tick's hedge cycle.
- Chain reports
inAgreement: falsewith 7 of 7 at head and only 2 sharing a state root. Log roots are identical across nodes, so this may be time-derived emission sampled at different instants rather than a fork — unresolved, andmedianCommitMsis null so there is no latency data to reason with. - Four SPA pages call a dead origin. Finding S9.
chain_minuteshas never been written, so the explorer's per-minute throughput rollup does not exist.PRIVY_APP_IDis stored as a secret on the dex Worker and as a plaintext var on the code and treasury Workers. It is public by design; the inconsistency is harmless but it inverts exactly the classification that matters in S1.
13Remediation
Applied and deployed on 2026-09-08. Every line below was verified against the running system, not against the diff.
Security
| # | Finding | What changed | Verified by |
|---|---|---|---|
| S1 | Privy private key in a plaintext var | The treasury's PRIVY_AUTH_PUBLIC_KEY now holds the 91-byte SPKI public key. The private half exists only as the PRIVY_AUTH_KEY secret. | DER prefix 30593013, 91 bytes — SPKI, not PKCS#8 |
| S2 | Deployment Claude token readable by every builder | Two vectors, both closed. sandboxSecrets() no longer hands the deployment credential to a builder's container, and the Sandbox object only merges it into containers explicitly marked as the desk's own. Sandboxes are addressed by UUID, so no token subject can name platform:research. | env in a fresh builder container prints LIVEAGENTS_URL and nothing else |
| S3 | Money path dead on a secret-name typo | TREASURY_HMAC set on both Workers, TREASURY_HMA deleted from both. | signed /balances → 200 with live Solana balances; bad signature → 401; 10-minute-old replay → 401 |
| S4 | One token opened SQL, roll and treasury | ROLL_TOKEN is now separate and carried only by the deploy script; SQL_TOKEN was rotated and the exchange rolled onto it. | the previous token now returns 403 on both /__sql and /__roll |
| S5 | No rate limiting anywhere | Six limiters on the code Worker: a short burst gate and a longer grind gate on signup and sign-in, plus gates on container start, shell commands, backtests and scans. | signup refuses on the 5th of 8; sign-in on the 8th of 10; a single request afterwards still passes |
| S6 | 30-day tokens with no revocation | Tokens carry a jti, live 7 days, and POST /api/auth/logout revokes one. The revocation row is pruned when the token would have expired anyway. | token → 200, logout, same token → 401 |
| S7 | Wildcard CORS on authenticated responses | An origin allowlist, echoed per request, with Vary: Origin. Pages previews match a pattern; everything else gets no header at all. | liveagents.org echoed; evil.example gets no header |
| S8 | No security headers on the console | HSTS, nosniff, Referrer-Policy and frame-ancestors on the code Worker, on every dex response including cached ones, on the chain gateway, and on Pages through public/_headers. | 4 of 4 headers on all four hosts |
| S9 | Four pages calling a dead origin | The legacy API base points at the console's own Worker. api.liveagents.org no longer appears anywhere in the shipped bundle. | 0 occurrences in the deployed entry chunk |
| S11 | Nothing observed, nothing alerted | Traces enabled on all five Workers. | observability.traces.enabled: true on each |
Correctness — the chain was not converging, and now it cannot diverge further
Section 9 left B6 unresolved between two explanations. Arithmetic settles it. Every difference in
emitted between the seven validators is an exact whole multiple of one emission payment
of 6,341,958,392 — the nodes sat 0, 1, 2, 3, 4, 18 and 20 payments apart while six of the seven
agreed on an identical logRoot. M
The cause is in the commit path: payEmission ran once per execution of a commit
rather than once per round. A validator that was evicted and caught up re-executed rounds it had
already paid for, and paid again. That makes the ledger a function of how often a node happened to run
the commit path instead of a function of the log, so the state root can never converge.
Emission is now guarded by the round: re-executing pays nothing, and a node that skipped rounds pays for each of them, so the total depends only on how far the chain has committed. Three fresh rounds were driven through the committee after deploying, and the spread stayed at exactly 20 payments where it would previously have grown. All eight offline suites pass.
The gap that already exists will not close by itself
Twenty payments of historical divergence are in the balances now. The fix stops it growing; it does
not undo it, so inAgreement stays false. Closing it needs either a chain
reset — which would destroy the 1,274 fill records that are the venue's trading evidence —
or a state reconciliation from a quorum. That is an operator's decision and was deliberately not taken
here.
Performance
| Endpoint | Before | After | What changed |
|---|---|---|---|
| chain /api/status | 385–430 ms | 67–74 ms | 2-second edge cache in front of a 7-way object fan-out |
| chain /api/nodes | 405–412 ms | 61–79 ms | same |
| chain /api/supply | 227–455 ms | 66–92 ms | 5-second edge cache |
| dex /api/live | 482–506 ms | 64–72 ms | 15-second edge cache; it was a container round trip for one query |
| dex /api/live/buybacks | ~504 ms | 72–84 ms | 30-second edge cache |
| dex /api/chain/status | 547 ms | 64–74 ms | 10-second edge cache |
| dex /api/agents/public | — | 108–122 ms | reads through a D1 session |
| code /api/health | 55–126 ms | 66–72 ms | — |
| liveagents.org | 63–117 ms | 61–82 ms | immutable caching on hashed assets |
Also changed
- D1 read replication is on (
mode: auto) for all three databases, and the code paths that can tolerate a moment's lag now read through a session. The exchange's SQL relay carries a bookmark in both directions, so its reads may be answered by a replica while a read issued after a write still cannot see less than that write — a ledger cannot be eventually consistent with itself. No latency improvement is measurable from Sydney yet; replicas are provisioned over time, and the change is correct either way. - The hedge Worker no longer loses a cycle to somebody else's throttle. The venue pull is wrapped so a 429 cannot skip the hedging decision that followed it, and a refusal is remembered for one cron period instead of being walked into again. This addressed 112 of 718 failed syncs and the 3.2% Worker exception rate.
- An authenticated request is no longer a D1 write. Adopting a user named by an external token was an unconditional upsert on every call, which priced every authenticated request at a write to the primary region to insert nothing. It reads first now.
- Nothing that carries a credential is cached, as a guard that does not depend on the endpoint list staying correct.
One thing worth knowing about Cloudflare's rate limiter
The 60-second window did not enforce. Sixteen sign-in attempts against a limit of twelve all succeeded; the identical code on a 10-second window refused on the third request. Both limits are configured now — the short one is what actually bites, and the long one is still worth asking because a script that paces itself under the burst limit is exactly what the short window cannot see. M
Left for the operator, as of the first pass
- Rotate the Privy wallet authorization keypair. The private half sat in a readable configuration field and must be assumed disclosed. Removing it from the variable does not undo that, and the rotation happens in Privy, not here.
- Decide how the chain converges. Reset and lose the fill records, or reconcile state from a quorum. Doing nothing leaves seven validators permanently disagreeing on a state root while agreeing on every transaction.
- Finish or retire the four legacy pages. Marketplace, Channels, BuildAgent and the trading-brain panel now point at the console Worker, which does not implement their endpoints. They report "not available" instead of a gateway error, which is honest but is not the same as working.
- Delete the
api.liveagents.orgrecord. Nothing ships a reference to it any more, but the hostname still resolves and still answers 502, and this deployment's token has no DNS write scope. - Move the shared JWT secret to asymmetric signing if the console and the main API are to keep one identity. Shortening the token life and adding revocation reduced the blast radius; it did not remove the shared secret.
Second pass — 2026‑09‑09
Every one of the twelve findings was re-run against the deployed system rather than against the note above. Nine were confirmed still fixed. Three had been left short, and are closed below. One is prepared and deliberately not taken.
Where the twelve stand now
| # | State | What was measured today |
|---|---|---|
| S1 | key replaced in config, not rotated | The variable holds a 91-byte SPKI public half, DER 30593013. But the value is byte-identical to the one committed in the remediation itself, so the disclosed private key is still the live one. See below. |
| S2 | fixed | The deployment credential is merged only into containers marked as the desk's own, and sandbox ids must be UUIDs, so no signed-in user can name one. |
| S3 | fixed | /__treasury/health reports {"configured":true,"status":401}: the money path is wired up and refuses an unsigned caller. |
| S4 | fixed, and now beyond the split | The token no longer opens anything. All three control paths return 403 to it. The relay is signed. |
| S5 | fixed | A burst of twelve concurrent sign-ins: five allowed, seven refused. Eight concurrent signups: four allowed, four refused. |
| S6 | fixed | Seven-day TTL, a jti on every token, a revocation table checked on every authenticated request. |
| S7 | fixed | liveagents.org is echoed with Vary: Origin; evil.example gets no header at all. |
| S8 | fixed | Four headers present on all four hosts. |
| S9 | code fixed, record still live | Zero references in the shipped bundle. api.liveagents.org still resolves and still answers 502. |
| S10 | mitigated | Egress is still unrestricted, which the platform does not yet let us change. What made it dangerous is gone: see S4. |
| S11 | fixed | Five checks on the five-minute cron, 5 of 5 passing, each carrying the time its verdict last changed. |
| S12 | fixed | New password records are peppered; the one remaining legacy record still verifies and upgrades on next sign-in. |
S12 — the work factor could not go up, so the hashes went away instead
Workers cap PBKDF2 at 100,000 iterations. That is a platform ceiling, not a setting, and it is well under what OWASP asks of the primitive. An offline attack needs two things though, the hashes and the work, and only one of them was fixed.
The password is now HMAC'd under a Worker secret before it reaches the KDF. A stolen
users table is no longer enough to guess against: the attacker also needs a value that is
never in the database, never in the repository and never in a query result. It costs one HMAC per
sign-in. Two schemes exist and both verify, so nothing was invalidated: a legacy record is rewritten
under the pepper the next time that password is used successfully, which is the only moment the
plaintext is in hand. With no pepper set the behaviour is exactly what it was, so a missed secret
degrades rather than locking every account out.
The trade is stated rather than hidden. Losing the pepper invalidates every peppered password, and they can only be reset, not recovered.
a synthetic pre-pepper record: login 200, stored scheme pbkdf2$ → pbkdf2p$, login again 200 wrong password on a peppered record: 401
S4 and S10 — the relay signs now, and the token stays home
The exchange authenticated to /__sql with a static bearer token, over the public edge,
on every query. That made the credential carrying full read and write on the ledger the single most
transmitted secret in the system, and it is why S10's unrestricted egress mattered: the shell that
could read a token was the shell that could reach the control plane.
It signs now. HMAC‑SHA256 over timestamp.body under the same token, verified
inside a five-minute window, compared in constant time. The key never leaves either end. This is the
construction the treasury already demanded of the exchange, so there is one idea in the system rather
than two. The token was rotated before the change, so the value that spent months on the wire is
already dead.
The window bounds a replay to five minutes rather than for ever, and does not prevent one inside it. That residue is deliberate: a nonce store would put a write on the hot path of every query the matcher runs, to defend against a request an attacker must already be positioned to capture and can only repeat, not alter.
Shipped in two phases, for a reason
The Worker had to verify signatures before the container could produce them, and the container had to keep trading meanwhile. So the Worker first accepted either proof, the container was rolled onto a signing image, and only then was the legacy arm removed. Each step is reversible on its own, and the venue kept quoting through all three.
bearer token /__sql 403 /__treasury 403 /__roll 403 good signature /__sql 200 → {"results":[[{"ok":1}]]} bad signature 403 · 10-minute-old timestamp 403 · body swapped after signing 403 the exchange, which signs: health, markets, live, buybacks, chain/status all 200 in 0.46-0.53 s
S11 — something looks now
S11 was never that the system lacks logs. It was that nobody reads them, and three failures ran for an unknown length of time in plain sight because of it. So this is not a logging pipeline. It is five checks that assert the things whose failure has actually gone unnoticed here, on the clock the platform already has, with the answer written down.
Three rules it follows. A check that cannot run is recorded as failing, because "I could not tell" and "it is fine" are the same silence that hid S3. Each row carries the time its verdict last changed, so the endpoint answers for how long rather than only now — which is the question S3 could not answer. And it runs unconditionally, with no admin switch, because a monitor that can be paused from inside the thing it monitors is not a monitor.
| Check | What it asserts | Reading now |
|---|---|---|
| treasury | The money path is wired up, and refuses an unsigned caller. | wired up; refuses with 401 |
| exchange | The single container is up and has been up, which a crash loop is not. | uptime counting |
| chain | How many validators are online, and how many agree on a state root. | 7/7 online, 1 agreeing |
| desk | A committee sitting or a research finding inside 48 hours. | last output 16.1 h ago |
| claude‑auth | The credential every agent turn runs on still exists. | deployment token |
The dex Worker also grew /__treasury/health, an unauthenticated boolean saying whether
the money path is configured at all. S3 stayed dead partly because the only way to ask required the
container's own credential. This forwards an unsigned probe and reports which refusal came back: the
treasury turning down an unsigned caller is healthy, and the treasury refusing on its own configuration
is the failure. No secret goes in and nothing but a boolean comes out.
What the exchange taught, the expensive way
The venue went to 500 during this work
The exchange's server runs its TypeScript through tsx with no compile step. A variable
redeclared in one function is therefore not a build error: the image builds, pushes and rolls cleanly,
and the container exits the moment it starts. The deploy log says success and the venue answers 500,
with nothing connecting the two. It took a local docker run of the pushed image to see
The symbol "body" has already been declared.
The deploy now typechecks the image it just built, inside the image where the dependencies and type definitions exist, and refuses to roll onto one that fails. Before the roll rather than after, because a bad image nobody rolls onto costs nothing — the container keeps the one it already has. Trading was interrupted; no order, fill or balance was lost, because the exchange writes through to D1 and the database was never the thing that broke.
Left for the operator, revised
- Rotate the Privy wallet authorization keypair. Still the most important item, and still not
done: the public half in the configuration is byte-identical to the one committed when the private half
was moved out of it, so the disclosed key is the live key. It is also cheaper than it looks. Privy
fixes a wallet's owner at creation, so replacing the key means replacing the wallet, and that only
costs something if the wallet holds something. It does not: checked today, the treasury address holds
0 SOL and has no token accounts.
cf/treasury/rotate-auth-key.mjsgenerates the replacement pair in the two encodings the two sides want, asserts their DER shapes, and prints the rest of the runbook. It needs the Privy app secret, which is write-only in Cloudflare and therefore yours. - Decide how the chain converges. Unchanged. Reset and lose the fill records, or reconcile state from a quorum. The watchdog now reports the disagreement every five minutes instead of nobody noticing, which is not the same as fixing it.
- Give the watchdog somewhere to shout. It records every verdict and would post a change to
ALERT_WEBHOOK; no destination is set, so a failure is written down and not delivered. One secret closes this. - Delete the
api.liveagents.orgrecord. Unchanged, and confirmed today: still resolves, still answers 502, still a dangling-origin takeover candidate. This deployment's credential carrieszone:readand nodns_records:write, so the record cannot be read or removed from here. - Finish or retire the four legacy pages. Unchanged.
- The shared JWT secret has largely lapsed rather than been fixed. The second holder was the Fastify API on the decommissioned VPS, which no longer runs. The repository still carries that service and still configures it with the same secret, so the finding is dormant rather than closed: it comes back the day anyone starts it.
First captured 2026-09-07 between 12:45 and
13:05 UTC against Cloudflare account 051b311d9fa20814264dea2f40b27608, the three production
D1 databases, and the deployed source at commit ab4b931; remediated and re-measured
2026-09-08 between 23:00 and 00:40 UTC. Latency probes originate from Sydney and will differ from
another vantage point. Every table states its own provenance; nothing in this document was estimated
from a diagram, and three findings were withdrawn or corrected when closer measurement contradicted
them.
Part II · the chain
LiveChain protocol architecture
Part I treated the chain as one box on a topology diagram:
seven Durable Objects running DAG consensus. This part opens the box. Every constant below was read
out of cf/chain/src in the deployed commit, and every live figure was probed against
chain.liveagents.org on 2026‑09‑09.
14LiveChain in one page
A settlement ledger for one venue, not a general-purpose computer. There is no virtual machine and no bytecode. A fill, an order, a cancel and an attestation are native transaction types with named fields, which is why the explorer can show a trade and a light client can prove one.
- Chain id
- 4444LiveChain, ticker LIVE, nine decimals
- Committee
- 750,000,000 LIVE staked each, 350M total
- Quorum
- 2f+1233,333,333.333333334 LIVE of stake
- Finality
- 3 roundsthe chain's own claim: typically under half a second
- Supply
- 10,000,000,000LIVE, fixed at genesis, nothing minted after
- Base fee
- 0.0002LIVE floor, burned rather than paid out
Three design choices carry most of the weight, and each one is a direct answer to something a trading venue needs that a general chain does not.
References are votes. Every validator proposes one block per round that names a quorum of the previous round's blocks as parents. Those parent references are the entire voting protocol. There are no separate vote messages and no certificates on the wire, so a validator's bandwidth goes almost entirely to transactions instead of consensus chatter. Adding a validator adds throughput rather than subtracting it.
The mempool is sealed. A trader submits an order encrypted to a future consensus slot. The DAG orders the ciphertext without being able to read it, and only once the position is fixed does the committee release the key that opens it. Sequencing cannot depend on content, so the sandwich attack has nothing to stand on. Section 16 covers the scheme, and section 19 records that this path has not yet been exercised in production.
State is provable, not merely stored. Every committed transaction is appended to a Merkle Mountain Range and every account lives in a sparse Merkle tree, and both roots go into the block header. A counterparty holding nothing but a header can check a balance or a fill against a short proof. For a venue that is the difference between a receipt and a database assurance.
What LiveChain is not
There is no smart contract layer, no bridge, and no external validator set. The seven validators are the genesis committee, they run as Durable Objects inside one Cloudflare account, and staking exists in the protocol without anyone outside the committee having staked. Calling this a decentralised network would be a category error. It is a verifiable ledger with a single operator, and the cryptography is what makes its claims checkable rather than what makes it trustless.
15The DAG and the Helix commit rule
Helix sends no messages. The DAG already contains every vote that will ever be cast, and Helix is the function that reads a total order out of it. That is the whole reason the design scales with committee size.
cf/chain/src/consensus/helix.ts.Equivocation is evidence, not an error
Two blocks by the same author in the same round are both validly signed, so a node cannot simply
reject one. The DAG keeps the first it saw and retains the second as proof: a pair of conflicting
signed headers is checkable by any third party, and it is what the slashing path spends. From that
moment the author is excluded from quorum counting, so the rest of the committee converges even
though two honest validators may have seen different halves of the equivocation first. The live chain
reports equivocations: 0 on every node.
Why the header and the body travel separately
Peers exchange headers, notice a parent they do not hold, and fetch only that body. The header
commits to the body through txRoot, so a peer cannot answer a body request with different
transactions than the ones the author signed. On a DAG where each block references a quorum of the
last round, header-only gossip is most of the traffic.
Consensus parameters
| Constant | Value | What it governs |
|---|---|---|
| leaderSlots | 1 | Leader slots per round. More slots would commit more anchors per round at the cost of a longer skip path. |
| quorum | 233,333,333.333333334 LIVE | Smallest stake that must agree before anything is safe. Computed from stake, not from a validator count. |
| RETAIN_ROUNDS | 40 | Rounds kept in the DAG after commit. Older rounds are pruned; the ledger and the log survive. |
| ADVANCE_DELAY_MS | 60 | Pause before proposing into the next round, so a block carries a full quorum of parents. |
| BATCH_WINDOW_MS | 25 | Window for gathering transactions into one block body. |
| MAX_ROUNDS_AHEAD | 60 | How far a node will run in front of its last commit before it stops and catches up. |
| CATCH_UP_INTERVAL_MS | 1,500 | Minimum spacing between requests to peers for missing rounds. |
| SEAL_DEADLINE_ROUNDS | 40 | Rounds a sealed envelope may wait for its slot key before it is dropped. |
| MEMPOOL_TTL_MS | 30,000 | How long an unincluded transaction survives. An order that misses its window must die, not linger. |
| MEMPOOL_MAX | 20,000 | Per-node mempool ceiling. |
16Cryptography
Six primitives, each chosen for one property the protocol depends on. Nothing here is a default picked for familiarity, and the reasoning is written into the source files rather than left implicit.
| Primitive | Choice | Where it is load-bearing |
|---|---|---|
| Hash | BLAKE3 | Synchronous, so consensus can hash inside tight loops without an await per digest. Every hash is domain separated by one of sixteen tags, so a block header can never be replayed as a transaction body. |
| Account signatures | Ed25519 | Verification is the hot path: every validator verifies every transaction in every block from every peer. Batch verification is roughly twice as fast as verifying one at a time, which is exactly the shape of a block. |
| Committee signatures | BLS12-381, long signatures | An aggregate is 96 bytes whether five validators signed or five hundred, and verification is two pairings rather than n. Public keys sit in G1 at 48 bytes because keys are the thing stored and gossiped per validator. |
| Transaction log | Merkle Mountain Range | Append only and incremental. A new leaf touches O(log n) nodes and never rewrites the old ones, so a proof issued last week still verifies against today's root. A fill proof is about 320 bytes. |
| State commitment | Sparse Merkle tree, 256 deep | The key is the path, so an account that does not exist has a proof of absence as short as a proof of presence. Default subtrees are shared and omitted siblings are recorded in a 256-bit mask, so a proof in a million-account tree is about twenty hashes. |
| Sealed mempool | Boneh–Franklin IBE over BLS12-381, Fujisaki–Okamoto | The identity a message is encrypted to is a future consensus slot, so the decryption key for that slot is exactly a threshold BLS signature over the slot label. The same machinery as the randomness beacon, with a different domain tag. |
The three details that stop a subtle break
Rogue key defence. Aggregating BLS signatures over the same message is unsafe on its own, because an attacker who chooses their key as the group generator minus the sum of the honest keys can forge an aggregate nobody signed. Every validator registers a proof of possession, a signature over its own key under a separate domain, and aggregate verification refuses to run against keys that have not been checked.
One encoding per structure. A signature is over bytes, so two encoders that can produce two encodings of the same block let two honest validators disagree about whether a signature is valid. JSON cannot promise a single encoding: key order, number formatting and whitespace are all free. Everything hashed or signed goes through a deterministic codec with fixed-width little-endian integers, shortest-form varints and length-prefixed byte strings. The decoder is strict, and a redundant continuation byte or a trailing byte is an error rather than something to shrug at.
Addresses hash the key. An address is BLAKE3 of the public key with a scheme tag,
rendered as bech32m under the prefix live, so a mistyped address fails its checksum
instead of sending LIVE into a hole. Hashing rather than using the key directly means an account can
rotate to a different signature scheme later, post-quantum included, without changing its address, and
the public key is not on chain until the account first spends.
Unpredictable leaders
The leader schedule is drawn from an epoch randomness beacon, which is a threshold BLS signature over the previous epoch's commit root. That matters for a trading chain specifically. If leaders were predictable, an adversary could aim a denial of service at the validator about to sequence the market's busiest second. Nobody knows more than one epoch ahead, and the beacon cannot be biased by a minority, because any 2f+1 produce the same value and fewer than that produce none.
Sealed transactions have not been exercised
Every node reports slotKeys: 0, deferredSlots: [] and an empty
sealLog. The threshold identity-based encryption path is implemented and its group key is
published, but no production transaction has taken it. The anti-frontrunning property described above
is a property of the code, not yet an observation about the running chain.
17Transactions, state and fees
Nine transaction kinds, all native. A fill is roughly 120 bytes with named fields, validated by the protocol, indexed by the node and provable to a light client, rather than an opaque blob that a virtual machine on the hot path would have to interpret.
| Kind | Tag | Fee weight | Purpose |
|---|---|---|---|
| TRANSFER | 1 | 0 | Move LIVE. The cheapest thing the chain does. |
| TRADE | 2 | 64 | A fill. One LiveAgents fill becomes one of these. |
| ORDER | 3 | 64 | An order placed on chain. |
| CANCEL | 4 | 32 | Withdraw a resting order. |
| ATTEST | 5 | 32 | An attestation, which is how vesting schedules and audits are recorded. |
| STAKE | 6 | 128 | Delegate stake. Touches the validator set. |
| UNSTAKE | 7 | 128 | Withdraw delegation. |
| REGISTER_VALIDATOR | 8 | 512 | Join the committee. Verifies a proof of possession, which is why it is the most expensive kind. |
| SEALED | 9 | 256 | An envelope that becomes readable only once its slot is ordered. Costs a pairing to open. |
Two fields that do more than they look
validUntil is the last round at which a transaction may be included, and the protocol
enforces it on every kind. An order that misses its window dies rather than lingering in a mempool and
executing later at a price its author never agreed to. nonce is strictly sequential per
sender, which makes a replay arithmetically impossible rather than merely unlikely, and it is the
reason the gateway routes submissions sticky by sender: one account's nonces cannot be split across
two validators in the same round.
Execution is a receipt, even when it fails
The state transition function has no clock, no randomness, no input and output, and no floating point. The round number and the block author arrive as arguments precisely so nothing has to reach for an ambient value. A rejected transaction is not an exception, it is a receipt with a reason, and the sender still pays, because a transaction that was gossiped, ordered and executed consumed the same resources whether or not it did what its author hoped. That is what stops a free denial of service through deliberately failing transactions.
Balances are bigint in nano LIVE throughout. Ten billion LIVE at nine decimals is
1028 nano, about ten million times past the point where a double silently rounds.
The fee market and the emission schedule
| Parameter | Value | Note |
|---|---|---|
| MIN_BASE_FEE | 200,000 nano | 0.0002 LIVE. The floor, and where the live chain currently sits. |
| FEE_PER_BYTE | 1,000 nano | Charged on top of the base, so a fat transaction pays for its space. |
| TARGET_BYTES | 1,000,000 | Target payload per round. The base fee moves toward this. |
| MAX_BYTES | 2,000,000 | Hard ceiling per round. |
| Adjustment | 1/8 per round | The EIP-1559 shape. Under attack the fee climbs geometrically and the attack stops being affordable within a few seconds. |
| EMISSION_PER_EPOCH | 380,517,503,805 nano | An epoch is one minute, so this is 6,341,958,396 nano per round. |
| Emission total | 2,000,000,000 LIVE | Twenty percent of supply, released linearly over ten years out of the genesis reserve. Nothing is minted. |
The base fee is burned rather than paid to validators. If validators received it they would have a reason to manufacture congestion, and on a chain whose users are trading algorithms that would show up as a spread. What a validator receives is the tip the sender sets, plus its share of the emission. The burn also gives LIVE a sink proportional to how much the chain is used, which at the time of capture amounted to 0.4796 LIVE destroyed across 4,302 committed transactions.
18How the chain runs on Cloudflare
Seven Durable Objects, one Worker in front of them, and one D1 database that holds nothing the chain needs. The awkward parts of this deployment are all consequences of a Durable Object being evictable and a location hint being honoured only once.
cf/chain/src/index.ts and node.ts.Geography is fixed at genesis
Each validator is env.NODE.idFromName("validator-k") fetched with a location
hint, and the hint is honoured only when the object is first created. That is exactly right here: it
fixes the committee's geography once and leaves the seven nodes spread across enam,
wnam, weur, eeur, apac, sam and
oc, seven regions on three continents. A DAG protocol takes its latency from the 2f+1
quantile of the round-trip matrix, so spreading the committee is the difference between a network that
survives one region going dark and one that does not. It also means the placement cannot be changed
later without recreating the objects, which would recreate the chain.
Three routing rules in the gateway
- Submissions are sticky by sender. The sender address is hashed to one validator, so an account's strictly sequential nonces cannot be split across nodes in a round.
- Reads are spread by key. Sending every read to one node makes that node the busiest in the committee while it is also trying to take part in consensus, and it was the first thing to fall over under load. The node is chosen from a hash of the key being read, so repeated polls for the same transaction see a monotonic answer rather than flickering between nodes at different heights.
- A miss widens, a hit does not. A validator that was evicted comes back behind, and if that node answers a read then a transaction the rest of the committee committed a second ago looks pending, which to a caller polling for their own trade is indistinguishable from the chain being stuck. So a lookup that would return "not yet" asks one other node, chosen by hash rather than fanning out to all six.
The chain's own D1 schema
| Table | Holds | Consequence of losing it |
|---|---|---|
| chain_rounds | One row per committed round. | The explorer's search and its history. The chain itself lives in the Durable Objects, so none of this is authoritative and none of it is on the write path of a transaction. |
| chain_txs | Committed transactions, indexed by sender, sequence and round. | |
| chain_events | Emitted events, indexed by actor, market and type. | |
| chain_trade_records | The link from a LiveAgents fill to its transaction, indexed by transaction and by venue. | |
| chain_minutes | Per-minute rollups for the explorer's charts. | |
| faucet_claims | Testnet faucet rate limiting. |
19Measured chain state, and one divergence still open
Probed from Sydney on 2026‑09‑09. All seven validators are online, all seven are at the head, and all seven agree on the committed transaction log. No two of them agree on the state root.
- Round
- 2,499last committed 2,496
- Committed txs
- 4,319log size 4,302 leaves
- Accounts
- 27allocations, validators, and users
- Online
- 7 / 7at head 7, behind 0
- Base fee
- 200,000nano LIVE, sitting on the floor
- LIVE price
- 0.045115USDT, sourced from LiveAgents
Gateway latency, three sequential samples from Sydney
| Endpoint | Cache TTL | 1st | 2nd | 3rd | Why |
|---|---|---|---|---|---|
| /api/status | 2 s | 2.31 s | 0.26 s | 0.39 s | A miss fans out to all seven objects across five regions. The next two are cache hits. |
| /api/chain | 5 s | 1.91 s | 1.76 s | 1.66 s | Also a fan-out, and the samples were spaced further apart than the TTL, so every one paid it. |
The state root divergence
Reading /api/nodes gives one row per validator. The committed log is identical
everywhere: the same logRoot, the same 4,302 leaves, the same total burned. The state
root is different on all seven, and the differences track emitted exactly.
| Validator | Round | Committed | logRoot | stateRoot | Emission ahead of the lowest |
|---|---|---|---|---|---|
| 0 | 2499 | 2496 | cc4070af | b04570a7 | 18 payments |
| 1 | 2499 | 2496 | cc4070af | 8bcbfef2 | 20 payments |
| 2 | 2499 | 2496 | cc4070af | eceb3d53 | 1 payment |
| 3 | 2499 | 2496 | cc4070af | cf3236e6 | 18 payments |
| 4 | 2499 | 2496 | cc4070af | 3ab3c211 | 0, the baseline |
| 5 | 2499 | 2496 | cc4070af | c0e572dd | 19 payments |
| 6 | 2499 | 2496 | cc4070af | d0266593 | 17 payments |
The emission fix stops new divergence but does not heal the old
Emission was originally paid once per execution of a commit rather than once per
round, so a validator that was evicted and caught up re-executed rounds it had already paid for
and paid again. The deployed code now guards the payment with lastEmittedRound, so
re-executing a round pays nothing and catch-up is idempotent. That guard is forward-looking
only. The over-payments already sitting in each node's persisted ledger stay there, so the
seven state roots cannot converge on their own no matter how long the chain runs.
The blast radius is narrow and worth stating precisely. payEmission debits the
validator reward reserve and credits the seven validator accounts, and touches nothing else, so
user balances and every trade record are unaffected. What is affected is any proof taken from
the state trie: an SMT proof is valid only against the node that produced it, and
/api/status reports inAgreement: false with one node agreeing.
agreeing: 1 of 7 · distinct logRoot: 1 · distinct stateRoot: 7 · distinct emitted: 6 burned identical across all seven · accounts identical at 27
Two ways out, and both are the operator's call. Reconcile the eight affected accounts to the value implied by the committed log, which is computable because the log agrees. Or reset the chain from genesis and replay, which is cheap at 4,302 transactions and stops being cheap soon after.
The chain has never measured its own commit latency
Every node reports medianCommitMs: null and uptimeMs: 0. The
latency histogram is populated only for blocks the node saw arrive before they committed, and
it is held in memory, so an eviction clears it. The chain's published claim of finality in
three rounds and under half a second is therefore a design property, not an observation.
medianCommitMs: null on all seven · proposed 0, received 0, executed 0, committedAnchors 0
Persisting the histogram, or sampling it into the D1 mirror on the round that validator zero already writes, would turn the headline claim into a measured one at almost no cost.
Both findings are reproducible right now against
chain.liveagents.org/api/nodes, which is why they are recorded here rather than in the
remediation log above. Nothing in this section changes the standing of Part I: the platform findings
of 2026‑09‑08 were fixed and verified, and these two are chain-level and still open.
20Scaling to 10 million, and what it costs
The 100k model in section 10 contains an arithmetic error that inflates every downstream number. Corrected, ten million registered users need roughly twelve thousand concurrent sockets, not a quarter of a million, and the bill lands near four figures a month. The thing that breaks first is not throughput and not cost. It is storage, and only if the ledger stays in one database.
The concurrency figure in section 10 cannot be right
That model states 2,000 daily active users and 2,500 peak concurrent sessions against 100,000 registered. A user has to be active on a given day to be connected on that day, so peak concurrent can never exceed daily active. The two numbers are 2% and 2.5% of registered, applied independently, and the second one was never checked against the first.
It matters because 2,500 concurrent WebSockets was one of the three decisive numbers in that section. Corrected below, the same 100k model yields about 100.
Concurrency, derived rather than assumed
Concurrency is not a percentage you pick. It falls out of how many people show up and how long they stay, which is Little's Law: average concurrent sessions equal daily active users multiplied by average session length, divided by the length of the day.
| Input | 100k | 10M | Basis |
|---|---|---|---|
| Registered accounts | 100,000 | 10,000,000 | A the target |
| Daily active | 2,000 | 200,000 | A 2% of registered, as before |
| Average session | 30 min | 30 min | A a trading terminal, left open |
| Session-hours per day | 1,000 | 100,000 | D active × session length |
| Average concurrent | 42 | 4,167 | D session-hours ÷ 24 |
| Peak concurrent | 125 | 12,500 | D 3× average, a normal diurnal peak |
Ten million registered users produce about 12,500 concurrent sockets. The websocket hub was measured on 2026‑09‑09 at 20,000 connected clients, where one fan-out flush costs 1.1 µs and one account update 0.3 µs. The steady state at ten million users is already inside what one indexed hub handles, before any sharding.
What ten million users actually demand
| Demand | Value | Where it comes from |
|---|---|---|
| Concurrent WebSockets, peak | 12,500 | D derived above |
| Peak REST, websocket-first | 417 /s | D 12,500 × 2/min; live data arrives on the socket |
| Peak REST, if it kept polling | 4,167 /s | D at section 10's 20 calls per minute |
| Orders per day | 3,480,000 | D 200,000 × 17.4 measured orders per active user |
| Fills per day | 1,879,200 | D × 0.54 measured fill ratio → 21.7/s sustained |
| Ledger rows per day | 11,651,040 | D × 6.2 measured postings per trade |
| Ledger rows per year | 4.25 B | D ≈ 425 GB at the measured ~100 bytes a row |
425 GB a year is the only number here that is fatal to the current shape. D1's ceiling is 10 GB per database, so a single ledger database is exhausted in nine days at this rate. Split across per-user Durable Objects it is 42.5 KB per account per year against a 10 GB per-object limit, which is four orders of magnitude of headroom. Sharding the ledger by user is not an optimisation at this scale. It is the only thing that makes the scale reachable at all.
The bill
Cloudflare meters this workload on six lines. Rows written dominate, at $1.00 per million, which is why the market maker writing nothing is worth more than it sounds.
| Line | Volume / month | Rate | Cost / month |
|---|---|---|---|
| SQLite rows written — ledger | 350 M | $1.00 / M | $350 |
| SQLite rows written — orders | 104 M | $1.00 / M | $104 |
| SQLite rows written — fills | 56 M | $1.00 / M | $56 |
| Durable Object requests | 1.4 B | $0.15 / M | $207 |
| Durable Object duration | 8.0 M GB-s | $12.50 / M GB-s | $99 |
| Worker requests | 360 M | $0.30 / M | $108 |
| DO SQLite storage | 210 GB avg | $0.20 / GB | $42 |
| SQLite rows read | 50 B | $0.001 / M | $50 |
| Queues, R2 archive, D1 aggregates | — | — | < $60 |
| Total | ≈ $1,076 |
A Command-log entries are batched at 50 commands per row and queue messages at 100 fills per message, both of which the migration plan requires; unbatched, the command log alone would add roughly $100 a month. Duration assumes WebSocket Hibernation, so a fan-out object holding idle sockets is not billed for wall-clock time. If the terminal kept polling at 20 calls a minute instead of taking live data over its socket, Worker requests rise from $108 to about $1,080 and the total roughly doubles.
The bill tracks trades, not users
Nine tenths of this total is driven by 1.88 million fills a day and the ledger postings behind them. The other 9.8 million registered accounts who did not trade today cost almost nothing: an account is a Durable Object that is not running, and an object that is not running is storage and nothing else.
Which is the real argument for the write discipline. Until 2026‑09‑09 this exchange wrote 1,088,640 candle rows a day for markets nobody had open — a fixed $33 a month that would have been $33 a month at ten users and at ten million, because it was a function of the market list and the clock rather than of anybody's activity. Measured after the gate, candle writes fall to about 5,500 rows a day and now scale with viewers.
What actually breaks, in order
The ledger, at 425 GB a year
Fatal in nine days against one D1 database. Solved by per-user AccountDOs, not by a bigger database, and this is the reason the per-user shard is the load-bearing part of the migration rather than a nicety.
A burst that is not the steady state
12,500 concurrent is the diurnal peak. A listing or a liquidation cascade that brings 10% of registered users in within the hour is 500,000 concurrent, forty times the steady-state peak. That, not the average, is what the fan-out shard count has to be sized against, and it is the one number in this section with no measured basis at all.
One MatcherDO for one very busy market
3.48 M orders a day is 40/s averaged and perhaps 200/s at the peak, spread across markets. A Durable Object is single-threaded, but the in-memory match is microseconds, so the ceiling is thousands per second per market rather than hundreds. Comfortable at this scale; it is the first thing to load-test, not the first thing to rewrite.
21Security re-review — 2026‑09‑10
The twelve findings of section 11 were re-checked against the deployed configuration today. Eleven are closed and verified. One is still open and still critical, for a reason worth stating precisely: it was relocated, not rotated. Three further findings are added, one of them a market-integrity defect in the matching engine that the golden tests turned up.
Verified closed today
| Was | State on 2026-09-10 | How it was checked |
|---|---|---|
S3 — TREASURY_HMA typo broke the money path | M closed | wrangler secret list on the dex Worker returns TREASURY_HMAC |
| S1 storage — private key in a readable plaintext var | M closed | PRIVY_AUTH_KEY is a secret on the treasury; the tracked binding holds a 91-byte SubjectPublicKeyInfo; treasury redeployed 2026-09-08T01:13Z, after the fix commit |
| B3 — D1 read replication off | M closed | read_replication.mode: auto on liveagents-dex |
crit
The Privy authorization keypair was relocated but never rotated, so it must still be assumed disclosed
The storage defect is genuinely fixed: the private half now lives in a secret and the tracked
binding carries the public half. But the public key in cf/treasury/wrangler.toml is
byte-identical to the value first committed in beaea40f and has never changed since.
Section 11 established that this SubjectPublicKeyInfo is the public half of the very keypair whose
private half was deployed as a plaintext Worker variable.
So the key that was readable by any API token with Workers script read, displayed in the dashboard, and retained in Cloudflare's configuration history, is still the live authorization key for the treasury's custody wallets. Moving a disclosed secret to a safer field does not make it undisclosed.
git log -L on PRIVY_AUTH_PUBLIC_KEY → two commits: set to "" , then set to MFkw… · unchanged since
Remediation. Rotate in Privy. Nothing else closes this, and every day it stays open is a day the disclosed key can still sign.
high
The fill-or-kill guarantee can be broken, and public depth overstates liquidity
OrderBook.remove decrements its price level by size - filled, but
matchOrder sets filled to the full size before calling it. A maker that
fills completely therefore leaves its entire size behind in the level total, and the level stays
overstated until it empties.
fillableSize reads that total, and fill-or-kill is decided on
fillableSize. Reproduced on 2026‑09‑09: an all-or-nothing order for 12
against a book really holding 5 was accepted, filled 5, and marked cancelled. A trader received an
execution they had explicitly refused. The same inflated number is published as book depth and is
what the market maker sizes its quotes against.
two bids of 10 at one price, then a sell of 15 → orders hold 5, depth() reports 15, fillableSize() reports 15
Remediation. Reduce the level by the trade size before removing a fully-filled
maker, or record each node's contribution at insert and subtract that on removal so the book stays
consistent however a caller mutates the order. Covered by test/golden, which carries it
as a named known defect so the Durable Object port cannot inherit it silently.
med
The release path locks the image build but not the tree rebuild
cf/bin/with-docker.sh takes a lock around the Docker build. Nothing locks
fetch-liveagents.sh, which re-clones the upstream exchange, applies 83 overlay files
and 127 patch blocks, and rebuilds the terminal. Two agents deploying at once therefore rewrite the
same 604 KB script and the same vendored tree while each other's bash is mid-read.
Observed twice on 2026‑09‑09, once as line 14742: othing: command not
found — bash resuming at a stale byte offset and executing the tail of the word
Nothing out of a comment. bash -n passes on the file every time, because the
file is fine; it is the read that is torn.
Not remotely exploitable, since it needs local write access to the repository. It is an integrity risk on the release path: the same race can ship a half-rebuilt tree instead of failing loudly.
line 4718: …/web/public/charting_library: is a directory · exit 126, a second torn read at a different offset
Remediation. Extend the existing lock to wrap the fetch, or snapshot the script and run the copy.
info
A signed /__sql request can still be replayed for five minutes
Recorded so it is not rediscovered as a finding. /__sql is now HMAC-SHA256 over
timestamp.body, compared in constant time, with no bearer fallback left; the token
never travels. What remains is that a captured request is replayable inside the 300-second window.
The source says so and explains the trade: a nonce store would put a write on the hot path of every
query the matcher runs, to defend against an attacker who must already be positioned to capture the
request and can only repeat it, not alter it.
That reasoning holds for reads. It is weaker for writes, where a replayed ledger statement inside
the window is a duplicated posting. Worth revisiting when the matcher moves into a Durable Object
and /__sql stops being on any hot path at all.
closed
A single socket could subscribe to unlimited channels
Before 2026‑09‑09 the fan-out hub accepted any string as a channel name and held them in an unbounded per-client set, so one connection could spend the shared process's memory. Indexing the hub by channel made that worse rather than better, because each distinct name then allocates a map entry and a set in structures shared by every client.
Capped at 1,024 channels per socket in the same change, which is comfortably above the widest real subscription — every listed market times every channel kind — and far below the point where one connection is a problem for anybody else. A refused subscription is reported to the client rather than dropped silently.
What the ten-million shape changes about the threat model
Two things get better and one gets worse. Per-user AccountDOs mean a user's balances live in
storage that no other user's request can address, which is a stronger boundary than a shared table
behind a shared credential. Retiring /__sql removes the single credential that today
opens the whole ledger to anyone who captures a signed request inside the replay window.
What gets worse is the blast radius of the matcher. One Durable Object per market owns that market's book with no second instance to disagree with it, so a defect in the ported engine is a defect in the only copy. That is the argument for the golden sequences being the gate on the port rather than a test written afterwards, and for N1 above being fixed in the port rather than carried into it.