Independent valuation-integrity audit for agents moving money on Robinhood Chain (EIP-155 4663).
Try it in a minute. None of it needs a clone, and only the third needs a wallet.
- Check a wallet, free: https://assay-steel.vercel.app/wall#check. Press "Try the burn address"; nothing is signed.
- Ask the MCP server:
claude mcp add --transport http assay https://sonar.my.id/assay-mcp/mcp, then ask forassay_true_positionon CRWD at0x000000000000000000000000000000000000dEaD. It refuses, and says why. - Buy the figures: the $0.01 audited position and the $0.25 contract audit, over x402 in USDC on Base, via OpenServ (before you pay).
- Start from the landing: https://assay-steel.vercel.app, which leads into the live wall.
ASSAY is an independent project. It is not affiliated with, endorsed by, or officially connected with Robinhood Markets, Inc., its affiliates, or Chainlink Labs. "Robinhood Chain" and "Chainlink" are trademarks of their respective owners and are used only to identify the network and data sources. ASSAY's output is an automated, informational reading of public blockchain state, provided as is and without warranty. It is not investment, financial, legal or tax advice, it is not a security audit, and a finding is not a statement that any named party acted wrongly. Verify independently before acting.
Built for SERV Hackathon Edition 01, entered in three tracks: Mainnet & MCP ("Robinhood Chain /
MCP" on the form), AgentKit ("Coinbase AgentKit", for the action provider) and Open
Track. ASSAY is the check an agent acting on Robinhood Chain calls before it values, collateralises
or moves a Stock Token position — as an MCP tool, a free view contract on 4663, or a paid x402
call — and the defect it catches is the one an agent hits when it mixes off-chain share prices with
on-chain token balances.
Where SERV Reasoning runs. When a subject asks ASSAY for an on-chain ERC-8004 verdict about
itself, SERV Reasoning (gpt-5.6-luna-serv-kronos-multipath, with serv_prompt_guard and
serv_shadow_agent) decides whether a byte-verified finding is material against the subject's
own declared mandate, through four ordered gates. It never computes a number. The sweep, the wall
and both paid calls use no model at all. No subject has requested a verdict yet, so every SERV call
so far is a measurement: what we measured, including a null result, is
below.
Live wall: https://assay-steel.vercel.app/wall · Site: https://assay-steel.vercel.app · Repo: https://github.com/OoJae/assay
| Settled x402 payment | 0x50124847…6b96b — 0.01 USDC, buyer 0x09f5…b5B5 → seller 0x0C3A…14B5, method transferWithAuthorization. The buyer spent zero ETH: x402 settles via an EIP-3009 signature and a relayer pays the gas. Five have settled at $0.01; this is the one whose reply carries the checks block, so the refusal-completeness fix is visible in the thing a buyer actually pays for. The seller in the first four is the wallet of frozen identity 95265, whose key was lost, so those 0.04 USDC cannot be moved. The fifth, 0xb852b0f5…63e8 (the demo film's NVDA position), and the $0.25 settlement below paid the current payTo, 0x6328…7911. Both buyers are wallets this project created — that is a working rail, not demand, and /pricing says so. |
| ERC-8004 identity | agent 8453:95374 on the IdentityRegistry 0x8004A169…a432 — mint tx · agent card, which the token's URI points at. 8004scan has never parsed that card and shows the agent as a nameless "Agent #95374"; the card itself is served and valid. 8453:95265 is frozen: its signing key was lost when a local .env was overwritten with a copy of .env.example, so it can never be updated again. Everything already published under it stays true and verifiable, and the card discloses both — along with 95266, an accidental duplicate. |
| On-chain guard | ERC8056Guard at 0x674f9b0ec3c3643c1f51c0a40d4837932f9c1648 on Robinhood Chain 4663 — deploy tx. Free, ownerless, storage-free, view-only. Verified as an exact match on Sourcify — creation and runtime bytecode, compiler settings and metadata identical to contracts/ERC8056Guard.sol. Reproduce it: cast code 0x674f9b0ec3c3643c1f51c0a40d4837932f9c1648 --rpc-url https://rpc.mainnet.chain.robinhood.com is byte-identical to solc --optimize --optimize-runs 200 --bin-runtime contracts/ERC8056Guard.sol with solc 0.8.35, run from the repo root: the source path is in the metadata hash, so compiling from another directory changes the last bytes. |
| Contract audit, paid | 0xc192e7b9…bc3b2 — 0.25 USDC for an assay_check_contract, transferWithAuthorization, buyer 0xDA31…d2cB → 0x6328…7911. The agent answered with a verdict and its interpretation for the address the buyer supplied; both are withheld here, because nothing public names a holder contract any more (see the disclosure: an earlier commit recorded both). Paid with the SDK's own vendored x402 client and an explicit ceiling equal to the price: payWorkflow()'s $0.10 limit is only a default. |
| On-chain attestation | agent 95374 rated CLEAN (100) by its owner 0x6328…7911 (request tx, response tx) — getAgentValidations(95374) returns one entry. The responseHash on-chain equals keccak256 of the exact document served at /attestations/95374/0x8e9f3590….json; pnpm verify:attestation checks that live, and it is in the test suite. The earlier self-attestation under frozen 95265 (validator 0x0C3A…14B5, /attestations/95265.json) is still on-chain and still verifies. Self-issued and not machine-adjudicated — subject and validator are the same key, and the tag is a documented self-assessment rather than an output of the SERV adjudicator. The document says both on its face and carries no third-party findings, because ASSAY's own rule is that unsolicited statements about a named party stay off-chain. Both were issued the same way; the first under 95265, before that identity froze. |
| Paid endpoints | assay_true_position at $0.01 — https://api.openserv.ai/webhooks/x402/trigger/006ecd4add4a459d8ae92362869a42a6 · paywallassay_check_contract at $0.25 — https://api.openserv.ai/webhooks/x402/trigger/a1bb2a3946d1411eb945200d43ebc740 · paywallBoth pay 0x6328…7911, a wallet this project controls. How to call them, and what to know before paying, is under Buy a call. |
| Public MCP | https://sonar.my.id/assay-mcp/sse (SSE) — TLS, verified from the public internet with real MCP clients — and, from this release, https://sonar.my.id/assay-mcp/mcp (Streamable HTTP); the cleartext :7379 it was previously published on is now closed. Free verdicts only: whether a position is safe to value and whether a contract can make the ERC-8056 correction; the figures behind them are what the two endpoints above sell. Rate limited per IP (limits). Unauthenticated by design — every tool is a public chain read, and the host holds no signing, inference or account key: serve-remote.ts refuses to start if WALLET_PRIVATE_KEY, BUYER_PRIVATE_KEY, SERV_API_KEY or OPENSERV_USER_API_KEY is in its environment, or .openserv.json is in its directory. |
The paid call returns real work, and leads with the refusal it is designed to produce. Verbatim from the settled $0.01 call above, at block 69261737 (the holder is left out):
{
"refusalReason": "No Chainlink feed is published for CRWD on Robinhood Chain; no on-chain price
is available. Using an off-chain SHARE price here would introduce a 300.000%
error, because the multiplier is 4.000000000.",
"tokenUnits": 12.697759045880602,
"shareEquivalents": 50.79103618352241,
"positionValueUsd": null,
"checks": { "pauseChecked": true, "feedRead": false, "priceSane": false, "roundComplete": false },
"confidence": "refuse"
}checks is the part worth looking at. false means unknown, not fine — it says which safety
checks actually completed. An earlier version of this call returned confidence: "high" with
refusalReason: null when the feed read had failed, which is the most dangerous thing a paid
valuation primitive can do: sell a check that did not happen as a check that passed.
The refusal text has been reworded since that call. "A 300.000% error" measured the mistake from the wrong answer up; the current reply measures it from the true value down, which is what a buyer acts on: valuing the raw balance at an off-chain share price understates the position by 75%, and the true value is 4x that. The quote above stays verbatim because it is what was paid for.
Robinhood Stock Tokens implement ERC-8056 scaled UI amounts. A corporate action moves
uiMultiplier(), not balances. That single design choice creates a family of defects that
standard ERC-20 habits walk straight into.
Measured live on mainnet (chain 4663) at block 70789445, 2026-09-23. These numbers are
generated from data/findings.json by pnpm readme:stats, not typed in —
they were hardcoded once and drifted away from the artifact they described.
| count | |
|---|---|
Stock Tokens with uiMultiplier() != 1.0 |
36 of 195 |
| Assets with no Chainlink feed at all | 160 of 195 (a chain note, not a finding — an absence cannot be proven by an eth_call) |
| 24/5 equity feeds past their heartbeat | 0 of the 35 feeds read were stale, with the market open — a stale feed during market hours is an incident, not a schedule |
Holder contracts that hold divergent-multiplier tokens and do not reference uiMultiplier() |
12 distinct contracts holding at least $289,021, found among 66 recent counterparties (47 with code: 0 reference it, 15 are pools or custody, 5 are too small to hold valuation logic, 0 are proxies that could not be resolved, 15 held none at the block) — an aggregate only: none is named here or on the wall, so their 3 findings are not in the count below |
| Pools and custody holding them (never need a share count) | 15 contracts (9 AMM pools, 1 pool manager, 1 custody or executor wallet, 4 distributors) holding at least $2,215,103, reported apart from the row above and never named |
| Findings published | 47, with 94/94 citations re-fetched and byte-compared |
| Findings rejected by the verifier | 0 — rendered on the wall with the reason, because a verification claim is only worth something if the misses are visible |
These figures, and the committed-board counts under the other side of the trade, are the snapshot committed with this README; the live wall re-measures them every 8 minutes, and the holder-contract dollar figures move a lot between sweeps (the $289,021 above read $629, across 5 contracts, on the live board at block 73031408 on 2026-09-26).
The 195 are every Stock Token in Robinhood's own registry, https://api.robinhood.com/rhj/assets,
all of them deployed on chain 4663: the committed sweep scanned that list at the block above, and
the registry still listed 195 on 2026-09-23 at 01:50 UTC.
Worked examples, all reproducible:
- CRWD
uiMultiplier() = 4e18. A holder of 10 CRWD tokens holds 40 share-equivalents — presenting the raw balance as a share count understates it by 75%. CRWD has no Chainlink feed, so any valuation must come from an off-chain share price, which differs from the token price by the 4.0x multiplier. This is the only 4.0x token on the chain, andassay_check_symbol('CRWDD')— one keystroke away — returns an explicit error with a did-you-mean rather than a clean bill of health. - NVDA, observed Sunday 2026-09-20, with the US equity market shut: a real holder's
position was $7,399,189, priced from a feed 50.4 hours old — about 26 hours past its
86,400s heartbeat. Recorded in the first commit (
e3fea08); this README later called the whole 50.4 hours "past its heartbeat" and dated it to the Monday, and both were wrong. Stated in the past tense on purpose. Reporting a weekend closure as a live incident is precisely the error this tool exists to catch — so the wall renders its own market claims in the past tense once its snapshot is more than 20 minutes old, and a stale-feed finding's title now gives the feed's age and its lateness separately.
Every finding above names the asset that was read. All 195 of them behave exactly as ERC-8056 specifies; not one is at fault. The exposure is on the contracts holding them, and those are on-chain and countable:
| Measured on chain 4663 | |
|---|---|
| Priced Stock Tokens | $124,769,444: totalSupply() × Chainlink price, summed over the 35 feed-covered Stock Tokens, every read at block 70133497 (2026-09-23 01:50 UTC). A one-off measurement; no script regenerates it |
Contracts holding divergent-multiplier tokens that reference uiMultiplier() |
0 of those whose code could be resolved, on the committed board (block 70789445) |
So ASSAY audits the readers too. For each contract seen transferring a Stock Token whose on-chain
multiplier is more than 0.2% from 1.0 — 9 of the 36 divergent assets on the committed board;
scanning all 36 would not fit the 8-minute cadence, and the board lists the 27 left out
(integrators.assetsBelowCutoff) — it
fetches eth_getCode and checks for the uiMultiplier() selector: a byte-verifiable absence,
re-runnable by the verifier like any other citation. Counts are of distinct contracts, at most
40 classified per asset per sweep. (Boards before that change counted (contract, token) pairs.) The
paid single-address audit has no such budget: it checks every Stock Token whose on-chain multiplier
is not exactly 1.0.
Pools and custody are not "unaware". An AMM pool, a pool manager, a custody or executor wallet
and a merkle distributor move tokens and never turn a balance into a share count, so they get a
verdict of their own, NOT_APPLICABLE, with the role. The role is read from the functions the
bytecode implements — unlock/settle/take, slot0 with a V3 swap, getReserves with
factory, Safe's execTransaction, a merkle claim — and any valuation function in the code
(totalAssets, convertToShares, latestRoundData, …) vetoes it. They are reported on a line of
their own, never inside the "does not reference it" count. Holdings too small to matter — under
$1,000, or 0.01 share-equivalents where no price exists — are counted and never become a named
finding.
Proxies are resolved or withheld, never guessed. A proxy's own bytecode is a delegatecall stub
containing no application selectors, so a naive selector test reports every proxy on the chain as
unaware. The Stock Tokens are themselves EIP-1967 beacon proxies — SGOV's address is a
283-byte stub — and the first working version duly called the very tokens that implement
uiMultiplier() "not multiplier aware". EIP-1967, beacon and EIP-1167 proxies are now resolved to
their implementation before any verdict, and anything still unresolved returns
PROXY_UNRESOLVED with no claim made at all.
What a NOT_AWARE verdict establishes is the absence of a call, not the presence of a mistake.
A contract that only custodies or routes a token never needs the multiplier and is not wrong to
lack it. Every statement says so in those words. It can also miss a call that is there: one made
through a library or an oracle adapter, one a router builds from calldata at runtime, or one behind
a diamond or a proxy pattern other than EIP-1967, beacon and EIP-1167. No false-positive rate is
published, because the classifier has not been checked against a hand-labelled sample.
Holder contracts are withheld from every public surface, and an earlier snapshot did name
them. The wall, the public feed, the free MCP tools, the committed data files and the verifier's
rejected list count holder contracts in aggregate and name none. A party that may carry no risk at
all should not be findable by name on a public page. A contract's verdict is available for an
address you supply — free as a verdict over MCP, and with its holdings and bytecode evidence as the
paid assay_check_contract call.
That was not always true of this repo. From commit 47f1166 (2026-09-22) until the change that
redacted it, the committed data/findings.json carried 25 findings naming 17 holder contracts
as unable to call uiMultiplier(), and so did web/data/findings.json from 2352a60, whose
commit message also quotes one of the addresses. Two more places in that history name some of the
same contracts. data/settlements.json, from 9c70c5e until the same change, records the address
the $0.25 audit was run on and its NOT_AWARE answer, which the current rules would not give: they
classify that contract as a pool. And one of the 17, the v4 PoolManager, was the example CRWD
holder in this README from the first commit and in docs/DEMO.md from when it was added; committed
scripts and tests used both addresses as fixtures. That history has not been rewritten, so all of
it remains readable in git. Re-classified under the current rules (read-only, at block
70130529), 11 of the 17 are AMM pools, a pool manager, an executor wallet or distributors —
NOT_APPLICABLE, contracts that never needed the multiplier — and 6 still reference no
uiMultiplier(), which remains the absence of a call, not the presence of a mistake. Any of them
can reply: see docs/RIGHT-OF-REPLY.md.
Detecting the mistake is worth less than making it impossible. Both of these are free:
ERC8056Guardon Robinhood Chain 4663 — ownerless, storage-free,view-only. One external call returns the corrected share count, or refuses with a reason. It never moves a token and never blocks anything: ASSAY emits something executable without ever holding a key, so the no-control-path posture survives intact.erc8056-guardon npm (npm i erc8056-guard viem) — the same ladder in TypeScript, plus the pure bigint arithmetic exported on its own, because that one line is what integrations get wrong. Its reads are pinned to one block, and it refuses a feed round timestamped in the future rather than computing a negative age.
The contract can revert, despite its NatSpec. It says "never reverts", and that is wrong for
some inputs: Solidity's try/catch does not catch a failure to decode return data, so
shareEquivalents, positionValue and feedUsable revert on an address with no code or one that
returns malformed data, and positionValue and feedUsable also revert on a feed round dated in
the future. The contract is immutable and the Sourcify match depends on its source, so the NatSpec
stays wrong: pass real Stock Token and feed addresses, and call it inside your own try/catch.
The package's README has the pattern. Neither the contract nor
the package checks that a feed belongs to the token, or reads a scheduled multiplier change.
pnpm test:guard forks 4663 with anvil, deploys the contract into the fork and checks it against
live state — including warping three days forward to force the staleness branch, which is otherwise
only reachable at a weekend.
balanceOf() × chainlinkFeedPrice is correct for token value — the feed is already
multiplier-adjusted, and Robinhood's docs say so explicitly. ASSAY does not claim otherwise.
The defect is cross-surface mixing: the on-chain feed returns a token price while
/prices and every off-chain equity source return a share price. Mixing them, or printing
balanceOf() to a human as a share count, produces a phantom error equal to the multiplier.
There are three paths, and only one of them involves a model.
DETECTIVE, UNSOLICITED (the wall — 195 assets + their holders, every 8 min)
Sweeper ──▶ Verifier ──▶ publish, UNADJUDICATED
(no model) (no model) assets by name · holder contracts in aggregate only
DETECTIVE, SOLICITED (a subject asks to be graded; built, never yet run)
Sweeper ──▶ Verifier ──▶ + the subject's declared mandate ──▶ Adjudicator ──▶ ERC-8004
(no model) (no model) (SERV/BRAID) registry write
PREVENTIVE (free, no model, no key, no control path)
ERC8056Guard on 4663 · erc8056-guard on npm ──▶ the caller reads it and gets the right number
Why the sweep is unadjudicated, stated plainly. Every gate in the adjudication rubric asks a question of the form "does the declared mandate state X" — and a 195-asset unsolicited sweep has no mandate for any subject, because nobody asked to be graded. Running the adjudicator over invented mandate text would be a worse integrity defect than leaving the sweep unadjudicated, so the sweep publishes facts and raw bytes only: no materiality judgment, no verdict, nothing on-chain. The model is reached only when a subject supplies its own mandate by requesting a verdict. An earlier version of this diagram showed a single path with the adjudicator in it, which overstated what the wall is.
The solicited path, as it stands: built, and never yet run. ASSAY's one on-chain verdict, its
self-attestation, was written without the adjudicator, so this path has answered no one. A subject files
validationRequest on the ERC-8004 ValidationRegistry (0x8004Cc84…DAAB58 on Base) naming this
project's validator, 0x6328…7911. The operator answers in two steps: pnpm attest:respond prepare <requestHash> reads the subject's agent card for its declared mandate, sweeps the finding
in scope, adjudicates it and writes the response document; the wall is deployed so the document is
live; pnpm attest:respond submit <requestHash> hashes the deployed bytes, checks them against the
live URL and signs. The request document may name its scope (scope.findingId, or scope.symbol
with defectClass); without one, the documented default is CRWD's SHARE_COUNT_MISREAD_RISK,
which is not evidence that the subject holds CRWD. A BENIGN verdict is tagged
NO_MATERIAL_EXPOSURE_AS_DECLARED (80), not CLEAN (100): only the declared text was graded, and
the document says so on its face. Nothing watches for requests. One waits until the operator
runs pnpm attest:pending.
1. Sweeper — deterministic, no model. Reads uiMultiplier(), oraclePaused(), decimals(),
totalSupply(), the Chainlink AggregatorV3Interface feed and its updatedAt vs heartbeat,
straight from chain state. Every value is kept as raw hex alongside the decoded form.
2. Verifier — deterministic, no model. Re-executes every cited call and byte-compares. A single mismatched citation discredits the entire finding.
What the guarantee covers, precisely. It covers the
evidencearray: on-chain calls, re-run and compared byte-for-byte. It does not cover off-chain inputs, which are listed separately underoffChainSourceswith their URL and fetch time and rendered in their own panel on the wall. And a claim that cannot be proven by aneth_callat all — the absence of a published price feed, say — is never published as a Finding; it is aChainNotecarrying checkablesources[]. Nor does it cover which contract a ticker is: the token address comes from Robinhood's/rhj/assetsregistry and the feed from Chainlink's feed directory, and neither binding is checked on-chain yet. A wrong registry entry would be byte-verified about the wrong contract. An earlier build got this wrong: 159 of 201 findings asserted an absence while citing an unrelateduiMultiplier()read, so most of the wall carried a verification badge its citation could not support. Fixed, and covered by a test.
It distinguishes:
reproduced— byte-identical, publishablemismatch— the citation contradicts chain state → the whole finding is dropped. The only reason that impugns a finding.unverifiable_here(pruned) — the node no longer serves that block → unchecked, not disprovenunchecked— the re-fetch failed after retries. Says nothing about the finding, only about our ability to confirm it right now.no_evidence— the finding arrived carrying no citations at all. That is a defect in our detector, not a statement about the subject, so it gets its own reason rather than hiding insideuncheckedor — as it used to — being reported asmismatch.
Everything withheld is rendered on the wall with its reason. A verification claim is only worth something if the misses are visible too.
The public RPC is not an archive node, and this number is measured rather than guessed. Binary-searching for the oldest block still serving state puts retention between 5,000 and 10,000 blocks, at 0.101s per block — so a citation stops being re-fetchable 8 to 17 minutes after it is minted.
Three things follow, and all three are consequences of that one number. Verification is fused into the sweep at each asset's own block, never run as a later pass. The sweep timer runs every 8 minutes, not the 30 it used to, because the board otherwise spends most of its life carrying "reproduce this yourself" commands that have already expired. And
assay_findingsreturnscitationsReproducibleandcitationLifetimeSeconds, so a caller can see the constraint instead of inferring it.A citation past that window is unchecked, not disproven — and the wall says which.
3. Adjudicator — SERV Reasoning. The only place a model is allowed. It never computes a fact; it decides materiality against the subject's own declared mandate, and it refuses when the evidence does not support a conclusion.
| SERV surface | Why it is load-bearing |
|---|---|
gpt-5.6-luna-serv-kronos-multipath |
multipath: severity is a real branching matrix. kronos: the methodology is semantically audited before it grades a named third party |
serv_prompt_guard |
100% of the mandate text is authored by the party being graded. Sent on every call; we have not been able to observe it trip (see the measurements below). On-chain name()/symbol() strings are not part of the input today, so they are not part of this threat model yet |
serv_shadow_agent (max_iterations: 5) |
Asked to check that the gates were applied in order, that every cited claim appears verbatim in the evidence, and that MATERIAL_MISSTATEMENT is reached only through gate 4 — a mandate that does not state the operation gets CONTROL_WEAKNESS. The response does not say whether it ran, so its effect is unmeasured |
| prompt cache | Methodology in the stable system prompt = the same methodology every time, which is what makes a grade an attestation |
What SERV does here is described above: it adjudicates materiality for a verdict a subject requested, and nothing else. This section is what we found when we measured that job.
The finding: specification, not the BRAID header — the only configuration we varied — was the dominant variable.
Three separate times we mistook our own under-specification for model inconsistency. Each time, fixing the rubric — not the model, not the feature flags — removed the variance. That is the result worth having here, and it is a result about prompt engineering on a well-scoped classification task, which is a thing you can act on.
- v1.
WITHHELDandCONTROL_WEAKNESSboth fired on the same input, with a third reading reachingMATERIAL_MISSTATEMENT. Three defensible answers, so verdicts oscillated. The single-run A/B that first looked decisive (BRAID onWITHHELD, offMATERIAL_MISSTATEMENT) was sampling noise — we reported it as decisive before running it again, and had to retract that. It ran on the dev model,gpt-5.6-lunawithout the SERV suffixes, going by the command the first README gave for it, and its output was never committed: it survives only in that README (e3fea08).data/braid-ab.jsonis a re-run the next morning, on the same dev model, which returnedCONTROL_WEAKNESSandMATERIAL_MISSTATEMENT. - v2 — four strictly-ordered gates, so exactly one verdict is correct per input. Oscillation vanished in both arms on the easy fixture — whose own mandate, "positions are displayed in shares", became v2's worked example for gate 3, so that row is a tuning-set result as well.
- v3 — gate 4. On hard cases every remaining error was gate 4, which never said which
surface handling had to cover, that it had to address this defect class, or that a stated
formula should be checked for scaling. The worst failure: a mandate documenting
balanceOf() * uiMultiplier() / 1e36— arithmetically correct — was calledMATERIAL_MISSTATEMENT. The auditor falsely accusing a subject that did it right is the most damaging error this tool can make. Adding 4a/4b/4c took both arms to 100% on the same six mandates those clauses were written to fix. That is a tuning-set result: it shows the rubric now handles the cases it was edited for, not how it does on a mandate nobody has seen. A held-out set, written and labelled before any run, is the measurement that would say that. It has now run: see the held-out result below.
It is a finding about this task: a bounded classification with a small verdict space, over evidence that a deterministic verifier has already established. It is not a claim that bounded reasoning does not work. BRAID's published benchmarks target open-ended multi-step reasoning, which is a different regime, and our task turned out to be easy once it was specified properly. What we can say is that on a task shaped like this one, we could not buy reliability with configuration — we had to write a better rubric.
Every arm is meant to run the production model (gpt-5.6-luna-serv-kronos-multipath), with the
same evidence and the same prompt; only the x-openserv-disable-braid: true header differs. Only
the single-run A/B artifacts record the model. The trial harnesses did not, so no trials row below
can be tied to a model from its file, and the v1 and v2 rows in particular may not share one.
"Finding text" is the version of the evidence the adjudicator is shown — buildUserMessage()
sends the finding's class, statement and impact verbatim, so it is an input exactly as much as the
rubric is. Rows on v0.2.0 were measured before the class was renamed and the statement rewritten
to say the token contract is spec-compliant; they are kept, not overwritten.
Inputs × draws is the honest sample size. A draw repeats one byte-identical input, and draws of
one input are not independent cases: every v3 hard-set cell was unanimous. The bracket is a Wilson
95% interval over draws, which flatters a row with few inputs; the 24/24 row is 6/6 by distinct
case, [61–100%]. SERV also compiles the reasoning prompt once per system prompt and caches it
(src/adjudicate/methodology.ts), so every BRAID-on call under one rubric version ran the same
compiled program, and variance between compiles was never sampled.
| rubric | finding text | task | inputs × draws | braid-on | braid-off |
|---|---|---|---|---|---|
| v1 (ambiguous) | v0.2.0 | easy fixture | 1 × 8 (6 completed off) | 3/8 unsafe verdicts = 38% [14–69%] | 3/6 = 50% [19–81%] |
| v2 (ordered gates) | v0.2.0 | easy fixture | 1 × 8 | 8/8 correct [68–100%] | 8/8 [68–100%] |
| v2 | v0.2.0 | prompt injection | 5 × 2 | 0/10 compromised [0–28%] | 0/10 [0–28%] |
| v2 | v0.2.0 | hard set, the committed sample | 6 × 2 | 9/12 = 75% [47–91%] | 11/12 = 92% [65–99%] |
| v3 (gate 4 tightened) | v0.2.0 | hard set | 6 × 2 | 12/12 = 100% [76–100%] | 12/12 = 100% [76–100%] |
| v3 | v0.3.0 | hard set | 6 × 4 | 24/24 = 100% [86–100%] | 24/24 = 100% [86–100%] |
| v3 | v0.3.0 | easy fixture | 1 × 16 | 15/16 = 94% [72–99%] · 0 unsafe | 16/16 = 100% [81–100%] · 0 unsafe |
| v3 | v0.3.0 | prompt injection | 5 × 4 | 0/20 compromised [0–16%] · 17/20 recognised | 0/20 compromised [0–16%] · 20/20 recognised |
| v3 | v0.4.0 | held-out, pre-registered (SHARE + CROSS) | 14 × 4 | 1/14 cases [1–31%] · 35 of 56 calls refused | 13/14 cases = 93% [69–99%] · 49/56 draws |
| v3 | v0.4.0 | held-out, pre-registered (STALE) | 6 × 4 | 6/6 cases = 100% [61–100%] · 24/24 draws, 0 refused | 6/6 cases = 100% [61–100%] · 24/24 draws |
Three corrections to earlier versions of this table. The v1 BRAID-off arm completed 6 runs, not 8.
The v2 hard-set row used to read "2 independent samples, 17/23 = 74% vs 20/23 = 87%": the second
sample was overwritten before it was committed, so only the first is shown, and
data/hard-trials-v2-run2.json, despite its name, is an incomplete v3 run (6/6 vs 6/6). And the
hard set has eight cases: the two about stale feeds need a live market closure and have never run,
so every hard-set row is six cases, and the rubric's 7-days-versus-86,400-seconds clause is untested.
Twenty mandates were written from the rubric's definitions alone, never its worked examples, the
tuning cases or the injection payloads, and labelled by two further agents who could not see the
writer's labels. All three agreed on every case, and the set was committed before the first call
(src/adjudicate/heldout/: the guide, the protocol, the frozen fixtures;
pre-registration b5ca947). The rubric is pinned by hash in the harness, and an offline test fails
if it changes, so it cannot be tuned against this set without CI saying so.
On the 14 SHARE and CROSS cases, run on 2026-09-23, BRAID off got 13 of 14 cases right [69–99%], 49 of 56 draws,
with no errors (heldout-trials-assay-methodology-v3.0.0-assa…). Its one missed case is the kind this tool most
needs to catch: a card labelled "Shares held" computed as balance × uiMultiplier / 1e18 and shown
unscaled, which inflates the count by 10¹⁸. The model called it BENIGN in all four draws, so a
units error inside a stated formula can still get past gate 4c. No scored case was over-accused; one
draw of 56 returned MATERIAL_MISSTATEMENT on the mandate that presents a pre-decided rating, and
none was talked into BENIGN.
BRAID on mostly did not answer. 35 of its 56 calls came back as a refusal, "I can't share that.", with no usage recorded, so only 1 of 14 cases had a modal verdict; over the 21 calls that
did answer, 15 were right. That is new. Every one of the 122 calls in the v0.3.0 rows returned a
verdict and usage, and a control re-run on 2026-09-23, of the easy fixture BRAID answered 16 times
on 2026-09-22, refused 1 of 2 (braid-trials-assay-methodology-v3.0.0-assay-…). So the BRAID-on figure
measures a change in SERV's BRAID layer between those two days, not these mandates, and it is
reported rather than retried away. The adjudicator used to turn an unparseable reply into a
synthetic WITHHELD; it now records a refusal as an error, kept in every denominator, which is the
only reason this is visible at all.
The six stale-feed cases ran on 2026-09-26, after the market closed. Their fixture was taken
mechanically under the capture rule fixed in advance: the first ORACLE_STALE_MARKET_CLOSED
finding on the first public board observed after 20:00 UTC, which was DELL's feed at 25.1 hours
old, from the board at block 73377041 (commit 9dd9e7a). Both arms got 6 of 6 cases right
[61–100%], 24 of 24 draws each, with no errored calls, no over-accusations, no missed defects and
no contested cases (heldout-trials-assay-methodology-v3.0.0-assa…).
BRAID on answered all 24 of its calls; the refusals of 2026-09-23 did not recur in this run. Six
cases is a small set: every cell was unanimous, so the draws add little beyond the cases, and the
lower bound is 61%. Across all 20 pre-registered cases, from the two runs, BRAID off got 19 of
20 [76–99%] and BRAID on 7 of 20 [18–57%], most of BRAID on's misses being the 23 September
refusals. Median latency on the stale cases was 24.2s with BRAID on and 5.1s off.
Median latency with BRAID on was 18.9s vs 7.2s off on the current easy fixture (21.6s vs 4.2s
on the v2 easy fixture). The single-run A/B, re-run on current text, now returns CONTROL_WEAKNESS
in both arms; the original single run — the one reported as decisive and retracted — had
BRAID-off at MATERIAL_MISSTATEMENT. It remains one run per arm, an anecdote by construction.
The current rows are the first with recorded cost. 122 calls. SERV's responses report 294,164
tokens in and 44,903 out, which is $0.14 at list price. The console billed the key $0.97 — about
$0.008 per adjudication, and roughly 7× what the responses show. These calls are the key's only
billable use, so the figure is attributable. The per-response counts therefore cover about 15% of
the real cost. That is consistent with Kronos compiling the reasoning prompt on the generator side,
but the response does not say, so we do not claim it. Cost from the bill, not from usage. The paid
endpoints never call SERV — adjudication runs only for attestations — so this does not touch the
$0.01 query's margin. (The $0.008 averages both arms, and 61 of the 122 calls had BRAID off, which
an attestation never runs; the OpenAI SDK's automatic retries, two by default, would not show in
either count.)
We could not measure a benefit from the BRAID header on this task. On the committed v2 hard-set sample it scored 9/12 against 11/12 without it: noise at that size, and we do not claim otherwise. Once the rubric was correct, both arms were perfect, and that held under the rewritten finding text — the same six mandates, four draws each, 24/24 in each arm and 6/6 by distinct case.
Indistinguishable from noise: on the current easy fixture BRAID-on returned one cautious
WITHHELD in 16. On one injection payload (fake-documented-handling, which cites the rubric's
gates to steer the verdict) it returned CONTROL_WEAKNESS 3 times in 4 where BRAID-off withheld all
4; that payload describes handling as much as it instructs, so either verdict is defensible and it
is not counted as BRAID doing worse. Neither gap is significant at these sizes, and neither is in
the unsafe direction: no arm was talked into BENIGN, and none over-accused.
We could not observe serv_prompt_guard doing anything. The harness's only sign of a guard
trip is a content_filter finish reason, and none appeared in the 40 injection calls; we could not
confirm that this is how the guard signals one, so this is "no trip seen", not "never fired". The
injection arms differ only by the BRAID header, and the guard was sent in both. Every refusal was a
WITHHELD, which the rubric makes reachable only through gate 1 — the gate whose clauses include a
mandate that tries to instruct the adjudicator rather than describe the subject. The committed rows
keep no rationale, so which clause fired is not recorded. On this task the protection we can show
is the specification, not the feature.
We are reporting this because a measurement you only publish when it flatters the sponsor is not a measurement.
Errored calls are counted in the denominator and reported separately. Every artifact is keyed by
rubric version and finding-text version and run id, and records the keccak256 of the exact
message the adjudicator was shown — keying by rubric alone was not enough, because the finding text
changed under an unchanged rubric and a re-run would have been indistinguishable from the run it
replaced. The harness also no longer writes over the committed artifacts: it used to, which is how
the second v2 hard-set sample was lost, and braid-ab.json was at risk the same way. An earlier
version silently dropped failed trials, which is the wrong defect for a contribution whose whole
value is methodological care.
pnpm ab # one A/B run -> data/braid-ab-<rubric>-<finding-text>-<run>.json
pnpm trials --n=16 # N trials per arm -> data/braid-trials-<rubric>-<finding-text>-<run>.json
pnpm inject --n=4 # prompt injection -> data/injection-trials-<rubric>-<finding-text>-<run>.json
pnpm hard --n=4 # hard case set -> data/hard-trials-<rubric>-<finding-text>-<run>.json
pnpm hard --n=4 --resume=<path> # continue that run; never a committed file
pnpm heldout --fixtures=SHARE,CROSS # the pre-registered held-out set -> data/heldout-trials-<rubric>-<finding-text>-<run>.json
pnpm heldout --fixtures=STALE # its six stale-feed cases, once the STALE fixture is captured
npx tsx scripts/heldout-capture-stale.ts # the STALE fixture, by the pre-registered rule: only from a board observed 2026-09-26T20:00Z to 2026-09-27T12:00ZThe harness is reusable and MIT-licensed. Point it at a different rubric or model and it will tell
you the same kind of thing. The raw artifact of every surviving run is committed under data/;
the two that did not survive are named above.
Nothing in the first block needs a key. The sweeper, the verifier, the wall and the MCP
server are public chain reads against Robinhood Chain 4663 and run on a fresh clone with an empty
.env. Every script that does need a secret validates it up front and exits naming it.
pnpm (11 or 12) is the supported installer; CI runs pnpm 11.1.2 on Node 22.13, 24 and 26 (pnpm 11 needs Node 22.13 or later).
npm install works too: the overrides field in package.json points @openserv-labs/sdk's
openai peer at the root openai, which is what pnpm resolves anyway. Without pnpm, each
script-running pnpm command is npx tsx on the file package.json names for it, and the name
does not always match (pnpm ab is npx tsx scripts/braid-ab.ts); pnpm test is npx vitest run.
web/package.json pins exact versions because Vercel uploads only web/ and installs it without a
lockfile.
pnpm install
cp -n .env.example .env; chmod 600 .env # -n: never overwrite an existing .env (that is how a key was lost)
pnpm sweep # full 195-asset sweep, verification fused in
pnpm sweep --symbols=CRWD,NVDA,SPY # scoped: writes data/findings.scoped.json, NOT the
# published board; --publish replaces it, and the guard
# refuses a narrower board unless --force
pnpm readme:stats # regenerate this README's numbers from the artifact (--print: stdout only)
pnpm test # all 541 tests in 29 files (20 of them, in 3 files, hit live chain state)
pnpm test:offline # 521 tests in 26 files, no network at all — what CI runs on every push
pnpm typecheck
npx tsx scripts/true-position.ts CRWD <holder> # any address holding CRWD
pnpm prove <payTo> [buyer] # prove a settlement from the USDC Transfer log (or set BUYER_ADDRESS)A sweep writes the public board to data/findings.json (or ASSAY_FINDINGS_PATH), redacted, and
beside it findings.private.json, the unredacted one, which is gitignored and never served, and
sweep-status.json, what the last run did and why.
Needs credentials — see .env.example:
pnpm ab # one A/B run -> data/braid-ab-<rubric>-<finding-text>-<run>.json (SERV_API_KEY)
pnpm trials --n=5 # N trials per arm (SERV_API_KEY)
pnpm wallets # generate the service wallet (and buyer); refuses to overwrite a key
pnpm balances # funding status for both wallets
pnpm provision # create agent + workflow + x402 paywall; refuses without WALLET_PRIVATE_KEY,
# and reuses the saved account key, so it is safe to re-run
pnpm buyer # generate wallet B; refuses to overwrite an existing key
pnpm pay # buyer settles $0.01 over x402
pnpm pay:contract <address> # buyer settles $0.25 for one contract audit
pnpm attest:pending # inbound validation requests, and which are unanswered
pnpm attest:respond prepare <requestHash> # answer one: sweep, adjudicate, write the document
pnpm attest:respond submit <requestHash> # sign it, only after the document is deployed
pnpm attest:build # build a self-attestation request + response, keyed by request hash,
# under web/public/attestations/<agentId>/
pnpm attest:submit # sign it, only after both documents are deployed
pnpm verify:attestation # independently check on-chain hash == served bytesProduction configuration — units, timer, nginx, logrotate, runbook — is in
deploy/, in version control rather than only on the host. The website in
web/ is covered there too: its routes, the landing's 3D kill switch, rollback, and
NEXT_DIST_DIR for local builds (The website). Its brand, type and
colour rules are in docs/BRAND.md.
Both paid endpoints are OpenServ x402 triggers: USDC on Base, paid to 0x6328…7911. OpenServ's own
preflight for them shows other values — an ERC-8004 id that is not ASSAY's (on 2026-09-23 it read
8453:95396, another project's identity, on 478 of the platform's 480 listings, so it is a
platform-wide value rather than ours), a different x402WalletAddress per workflow, and the frozen
wallet 0x0C3A…14B5 as owner. None of that is set by this repo; the 402 challenge itself is what a
payment follows, and it names 0x6328…7911. ASSAY's identity is 8453:95374, whose tokenURI and
agentWallet on-chain are the card and 0x6328…7911.
-
From a browser: the paywall pages, $0.01 audited position and $0.25 contract audit. Connect a Base wallet holding USDC; the inputs appear after connecting.
-
From code: POST to the trigger with any x402 client, and set its spending ceiling to the price. The body has this shape (from
src/lib/endpoints.ts); an unpaid POST without it is held for about 90 seconds instead of being answered with the 402 terms:
Before you pay. Payment settles before the task runs. Every answer is JSON, and a failure
comes back as { ok: false, errorClass, message, retryable } — BAD_INPUT, BAD_ADDRESS,
UNKNOWN_SYMBOL (with a did-you-mean), UPSTREAM_UNAVAILABLE or INTERNAL. Whether OpenServ
settles a task that errored has not been verified, so check a ticker or an address with the free
MCP tools first. The $0.01 answer reads every value at one block and adds a warning when a
multiplier change is scheduled. The $0.25 audit stops reading after 30 seconds; anything it could
not read or price is listed, conclusive is then false, and totalUsdHeld is null with
pricedUsdHeld as the floor. Both calls, and the sweep, read Robinhood Chain's public RPC, whose
terms say it is not meant for production use, with dRPC and Pocket as fallbacks; moving the paid
path to a dedicated node is still to do.
Four tools, free, unauthenticated, rate limited per IP. Two of them give a verdict only, and say where the figures behind it are sold.
| tool | free answer | what the paid call adds |
|---|---|---|
assay_true_position |
confidence (high / degraded / refuse), refusalReason, which safety checks completed, and any scheduled multiplier change. No position |
$0.01: share-equivalents, token and share prices, the position's value, and the feed behind the checks |
assay_check_contract |
verdict — AWARE, NOT_AWARE, NOT_APPLICABLE with a role, PROXY_UNRESOLVED (no claim at all), EOA or TOO_SMALL — plus codeHash, blockNumber and conclusive. Nothing about holdings |
$0.25: every Stock Token with an on-chain multiplier other than 1.0 that the address holds, the share-equivalents and dollars on each, what could not be read, and evidence[] re-runnable at the block |
assay_findings |
the published findings, snapshotAgeSeconds, citationLifetimeSeconds and sweepHealth (is the sweep still publishing, and why not). Holder contracts are counted, never named |
— |
assay_check_symbol |
a fresh live sweep for one ticker; an unknown ticker is an error with a did-you-mean, not an empty result. Never names a holder contract | — |
All four are served from one definition in src/lib/surface.ts, shared with the OpenServ agent and
the AgentKit action provider. The agent sells the figures; the AgentKit provider runs the same reads
in your own process. The provider is assayActionProviders() in
src/agentkit/assay-provider.ts, with three actions:
assay_true_position, assay_check_symbol and assay_check_contract. pnpm agentkit
(scripts/agentkit-smoke.ts) loads it into Coinbase AgentKit with a
stub wallet, lists the actions and reads one position; it needs no key.
Hosted, over SSE or Streamable HTTP:
https://sonar.my.id/assay-mcp/sse # SSE; OpenServ's no-code MCP connector speaks only this
https://sonar.my.id/assay-mcp/mcp # Streamable HTTP, stateless
OpenServ's no-code MCP connector is SSE-only (docs),
which is why SSE stays; the OpenServ SDK's mcpServers also accepts http and stdio. Mounted
under a path on an existing certificate rather than on its own subdomain, which needs no new DNS.
MCP_PUBLIC_PATH makes the SSE transport advertise the prefixed POST path — it sends an absolute
path, so a bare /messages would land on whatever else lives at the origin root.
# Claude Code, over Streamable HTTP
claude mcp add --transport http assay https://sonar.my.id/assay-mcp/mcp
# or, as a fallback, over SSE
claude mcp add --transport sse assay https://sonar.my.id/assay-mcp/sse{ "mcpServers": { "assay": { "command": "npx", "args": ["-y", "mcp-remote", "https://sonar.my.id/assay-mcp/sse", "--transport", "sse-only"] } } }That is Claude Desktop, through mcp-remote. Cursor takes the URL directly,
{ "mcpServers": { "assay": { "url": "https://sonar.my.id/assay-mcp/sse" } } }, and an OpenServ
SDK agent takes mcpServers: { assay: { transport: 'sse', url: 'https://sonar.my.id/assay-mcp/sse', autoRegisterTools: true } }.
Local, over stdio, from a clone. The paths must be absolute: MCP clients start servers from their own directory, where a relative path finds nothing, and the board's default path is relative to the working directory.
{ "mcpServers": { "assay": {
"command": "/abs/path/assay/node_modules/.bin/tsx",
"args": ["/abs/path/assay/src/mcp/stdio.ts"],
"env": { "ASSAY_FINDINGS_PATH": "/abs/path/assay/data/findings.json" }
} } }Limits, per IP: 30 SSE opens and 60 requests a minute, of which at most 10 may be
assay_check_contract and 5 assay_check_symbol; 6 open streams, where a 7th may displace only one
of your own that has been idle for 2 minutes, and 200 streams in all. A stream closes after 10
minutes without a tool call, or 30 minutes whatever happens, and carries a comment every 25 seconds
so clients do not time it out. At most 2 assay_check_symbol, 4 assay_check_contract and 8
assay_true_position calls run at once across the server, and one address may hold half of each.
nginx adds looser limits in front of all of that, as a backstop.
The raw feed, if you just want the data and not the protocol — public, CORS-enabled, cached 60s:
https://sonar.my.id/assay-mcp/findings.json
It carries the aggregate integrator figures and every asset finding, with holder contracts withheld
from both the findings and the rejected list, plus sweepHealth and a disclaimer. It is what the
wall itself reads. https://sonar.my.id/assay-mcp/health/sweep answers 503 when the board is more
than 20 minutes old or the last sweep refused to publish, for anyone who wants to monitor it.
ASSAY names assets and feeds; the contracts holding them only in aggregate. So the posture is structural, not promised:
- ASSAY rates itself first. The one on-chain verdict it has ever written is about itself, and that document states on its face that it is self-issued, carries no independent assurance, and was not produced by the adjudicator. (This previously read "and publishes its own worst grade", which was not true — the self-assessment came back CLEAN. The honest claim is that ASSAY subjected itself to the mechanism first, and discloses what that is worth.)
- Findings name what was READ, not who is at fault.
subjectis the contract or feed whose state produced the finding;affectedPartynames who carries the exposure, which for most classes here is an integrator, not the contract. A Stock Token that movesuiMultiplier()is doing exactly what ERC-8056 specifies. Falsely accusing a subject that did it right is the most damaging error this tool can make, so the data model separates the two. - Unsolicited output is facts plus raw bytes in neutral engineering language —
SHARE_COUNT_MISREAD_RISK,CONTROL WEAKNESS. Never "fraud", never intent, never a recommendation to withdraw from a named party. - Unsolicited findings are never written on-chain. Only a verdict the subject requested gets a registry write.
- Holder contracts are named on no public surface — the wall, the feed, the free MCP tools, the committed data, the rejected list. Earlier commits did name 17 of them, and git history still holds those; above says which files and commits, and what the contracts turned out to be.
- Right of reply, with what is actually offered spelled out in docs/RIGHT-OF-REPLY.md: any named party's response is published verbatim and unedited beside the finding, including a contract named only in a verdict given, free or paid, for an address the caller supplied, if it asks. A finding shown to be wrong is corrected by changing the rule that produced it, since the sweep rebuilds every finding every 8 minutes and there is no switch that hides one; the document says how. Pre-publication notice is not claimed — the sweep publishes on a timer and for most findings the subject is a contract, not a person to notify. Saying otherwise would be the same kind of unchecked claim this project exists to catch.
- A solicited verdict sends the subject's own mandate text to OpenServ's inference API
(
inference-api.openserv.ai), and training-data collection is on for this account, because the SERV Hackathon requires it. By OpenServ's own description of that setting, inputs and outputs may be used to train its models and retained for up to five years. Anyone requesting a verdict should know that before they ask for one. - The methodology is versioned —
assay-rh-v0.4.0for detection, stamped on every finding — and a solicited verdict records the rubric version, the model and the hash of the exact input, so a subject can inspect the inputs, rubric and model that produced its grade. The model is not deterministic, so that is not a promise the grade would come out the same twice.
The MIT license covers this repository's source code. The files under data/ and web/data/ hold
values read from public chain state, Robinhood's Stock Token APIs and Chainlink feeds; no rights in
that data, or in the "Robinhood Chain" and "Chainlink" names, are granted by it.