VaultRadar
VaultRadar is a machine-payable, cross-protocol vault-risk service. One standardized query template per schema family runs across every Messari-schema deployment in its registry, alongside a new ERC-4626 Substreams module composed from Pinax's erc4626 package. Risk reports are sold per request over x402 on Hedera and on Arc, and consumed by a risk-monitor agent. Requests and responses are sealed with a hybrid post-quantum KEM, so no intermediary learns the portfolio. A privacy tier lets the vendor learn only the protocol. Every receipt and per-vault attestation is signed with ML-DSA-65, so it stays verifiable long after ECDSA is gone.
Built for ETHOnline 2026. Partner tracks targeted below.
| Track | What to look at |
|---|---|
| The Graph, Best Use of Composable or Standardized Graph Products | One template per schema family in packages/core/src/standardized/templates.ts, run across the 15 registered deployments (14 with a pinned deploymentId) in packages/core/src/standardized/deployments.json. The ERC-4626 module in substreams/erc4626-vault-metrics/ imports Pinax erc4626 and ships to two chains from one WASM binary. |
| The Graph, Best AI Tooling or AI Use Case (From Scratch) | The agent in packages/agent/src/client.ts discovers, seals, pays, opens and verifies before it reasons. It refuses stale data on its own clock, not on the service's word. Package: https://substreams.dev/packages/erc4626-vault-metrics/v0.1.0 |
| Hedera, AI & Agentic Payments on Hedera | The x402 rail in packages/service/src/rails/hedera.ts prices per vault and settles HTS USDC through Blocky402. A real settled request: HashScan, 0.0015 USDC for one sealed scan, receipt verified |
| Arc, Best Agentic Economy Application with Circle Agent Stack | Bucketed Arc routes on the agent card, the Circle Gateway rail, and the dashboard at https://vaultradar-dashboard-production.up.railway.app showing the same receipts and decisions. A real settled payment: the Gateway batcher's reference cbc2021d-d57e-4e05-b43f-56707a532f33 for a 0.003 USDC scan whose receipt verified. On chain: the ERC-8004 registration and the Gateway deposit that funded the payment. |
| Arc, Launch on Arc Testnet & Push to Mainnet | Arc testnet config lives in packages/service/src/config.ts under arc, network eip155:5042002. The mainnet path is a config swap, documented in the run section below. |
Live service: https://vaultradar-service-production.up.railway.app Dashboard: https://vaultradar-dashboard-production.up.railway.app Demo video: vaultradar-demo.mp4, 3 minutes 28, in this repository. It walks one paid request end to end: the ranking from a real hundred-vault purchase, the run it produced, a receipt verified in the browser against both chains, and the x402 round trip step by step.
How this was built (AI attribution)
VaultRadar was built with Claude Code under human direction, using a spec-driven subagent workflow: a written spec, a written plan, one implementer per task working from a written brief, an independent reviewer for each task's diff, and a whole-branch review with a fix wave at the end. Practically all of the code, tests, documentation and diagrams were generated by the tool; the human contribution was direction, decisions and credentials, the project and track selection, the design approval, the scope cuts, every account and key, the deployment and key-management decisions, and the interactive steps that needed a browser.
Every artifact of that process ships in the repository, as the rules require:
docs/ai-workflow/ holds the prompts in sequence, the decision ledger, the per-task
briefs and reports, and the review reports, alongside the
spec and
plan. Start with the ledger: it records
every ruling made when the plan met reality, with the reasoning and what each would cost if
wrong, and it does not flatter the outcome.
Architecture


The shape is: The Graph supplies data two ways, the service turns it into signed risk reports behind an x402 paywall, and an agent buys it under a policy.
Payment flow
Hedera rail
- The agent fetches
/.well-known/agent.json, verifies the card's ML-DSA-65 signature, and comparespq.sig.pub_hashagainst the ERC-8004 registry'sgetMetadata(agentId, "pq.sig.pubhash")on chain 296. - The agent generates an ephemeral KEM keypair, seals
{ request, reply_pk, payer, ts, req_nonce }to the service's KEM public key, and POSTs it to/hedera/v1/scanwith the clear headerX-VR-Count. - The price function reads
X-VR-Count, validates the envelope's shape, and the middleware answers 402 with aPAYMENT-REQUIREDheader. The amount is $0.001 plus $0.0005 per vault, in HTS USDC0.0.429274onhedera:testnet. - The agent checks the demanded amount against its own quote, and only then signs a Hedera
TransferTransactionnaming the Blocky402 facilitator as fee payer and retries the POST with aPAYMENT-SIGNATUREheader. That quote is computed locally from the pricing table shared with the service rather than probed over the wire, so the check is what binds the two: a 402 demanding more than one percent over the quote is refused before anything is signed, on either rail, and the receipt's stated price is checked against the same band afterwards. - The middleware calls Blocky402
/verify. Only on success does the handler run. - The handler opens the envelope, checks
tsagainst a 120 second window, checksreq_nonceagainst the replay store, checkspayeragainst the payer the facilitator verified, and checksX-VR-Countagainst the request's own vault count. It then runs the standardized queries and the Postgres read under a 60 second cap, unifies, computes freshness and risk, signs one attestation per vault, seals the body toreply_pk, signs the receipt, and answers 200. - The middleware settles via Blocky402
/settleand setsPAYMENT-RESPONSEwith the transaction id. - The service enqueues an HCS commitment of the receipt hash. The agent opens the response, verifies the receipt and every attestation, applies its own age check, and decides.
A settled request. The rail has been exercised end to end against the deployed service: one sealed scan of one vault cost 0.0015 USDC (0.001 + 0.0005 × 1), settled as 1500 atomic units of HTS USDC 0.0.429274 from the agent's account 0.0.10463726 to the service's 0.0.10463666.
HashScan renders it for a person; the same transaction is machine-verifiable at the mirror node's JSON API, which reports result: SUCCESS and the two token transfers. HashScan is a client-rendered app, so its deep links answer 404 to curl, the mirror link is the one to check if you are scripting this.
Arc rail
Steps 1, 2, 6 and 8 are identical. Steps 3 to 7 differ:
- Step 3. Circle's Gateway middleware answers 402 on one of the bucketed routes,
/arc/v1/scan/s|m|lfor counts 1 to 5, 6 to 20, and 21 to 100. Circle's middleware is static per route, so the bucket carries the price rather than a price function. - Step 4. The agent signs an EIP-3009 transfer authorization instead of a Hedera transaction.
- Step 5. The Gateway facilitator verifies the authorization.
- Step 7. Settlement is batched by Gateway. Unlike the Hedera rail, Circle's middleware verifies and settles before the request handler ever runs, the receipt records the authorization's transaction reference, but the money has already moved by the time the handler produces that receipt, not after.
Because settlement happens before the handler, every check that can reject a request outright (the X-VR-Count/bucket match, and for a sealed request the timestamp window, nonce replay, and vault-count-vs-header agreement) runs ahead of payment, in a pre-payment middleware. The one check that cannot run that early is whether a sealed request's claimed payer matches the payer Circle's Gateway actually settled with: that payer is only known once the payment has already gone through. A payer_mismatch on the Arc rail is therefore detected after settlement and is not refunded, a real limitation of Circle Gateway's settle-before-handler design, not a gap in this service's own validation.
A settled request. The rail has been exercised end to end against the deployed service: the agent deposited 2 USDC into Circle Gateway (deposit, after an approval), then paid 0.003 USDC for a sealed scan. The batcher's reference for that payment is cbc2021d-d57e-4e05-b43f-56707a532f33; the receipt verified and the sealed reply opened. Circle settles batches asynchronously, so a Gateway payment's own reference is a batch id rather than an Arc transaction hash, the on-chain evidence for the rail is the funding deposit above, plus the registration that anchors the service's key.
What the standards made easier
Full write-up for judges: docs/standards-leverage.md.
One template, many protocols. packages/core/src/standardized/templates.ts holds exactly two GraphQL queries, one per Messari schema family. They are parameterized only by pagination. Those two queries cover 15 registered deployments across 10 protocols and 5 chains, listed in deployments.json:
| Schema family | Protocols |
|---|---|
| lending 3.1.0 | aave-v3 (Ethereum, Base), compound-v3, spark, morpho-aave-v3, euler |
| yield-aggregator 1.3.1 | yearn-v2 (Ethereum, Arbitrum), convex-finance, aura-finance, arrakis-finance (Ethereum, Optimism, Polygon), gamma-strategies (Ethereum, Polygon) |
The verification gate has run: 11 of the 15 registrations are live, 1 is stale, and 3 are down (convex-finance and aura-finance on Ethereum stopped indexing years ago, and aave-v3 on Base resolves to no allocations). Fourteen of the fifteen deployment ids are pinned in deployments.json; the fifteenth could not be pinned because there is nothing there to pin, and it resolves by subgraph id. Every query on a pinned deployment goes to that exact deploymentId, so a re-point cannot silently change the data underneath a risk verdict. A dead registration is not hidden: its sources never classify as fresh, so the vaults under it are reported unavailable.
One module, two chains. substreams/erc4626-vault-metrics/ imports Pinax's public erc4626 package as a dependency and never scans logs itself. chain_id is a Substreams runtime parameter, so the same compiled WASM runs on Ethereum mainnet and on Base. Only the manifest differs: substreams.yaml versus substreams.base.yaml. Both sinks write to one Postgres, because every table's primary key includes chain_id.
Published package: https://substreams.dev/packages/erc4626-vault-metrics/v0.1.0
The one-prompt challenge, honestly. The one-prompt Substreams generation was not run. Installing the Substreams Skills plugin was out of scope for this build, so the module was written by hand. The exact prompt is recorded in docs/one-prompt.md along with a finding that matters to anyone who tries it: the substreams-sink-sql protodefs release fails substreams protogen under CLI 1.22.0, so the SQL half of that prompt hits a wall regardless of wording. Nothing in this repo claims the module came from one prompt.
Freshness and the refusal rule
A risk verdict is only as good as the block it was computed from, so freshness is a first-class field rather than a footnote.
| Source kind | Reference block | Fresh within |
|---|---|---|
| Messari subgraph | _meta.block |
60 minutes of chain head |
| Substreams sink | the sink's cursor block | 30 minutes of chain head (the sink indexes finalized blocks, so it trails the head by Ethereum's finality lag) |
Anything past the threshold is stale. A failed query, or _meta.hasIndexingErrors, is unavailable. Chain head comes from a per-chain JSON-RPC provider, cached 15 seconds. If that RPC call fails, every source on that chain is marked stale rather than assumed current.
The refusal rule has two independent halves.
The service refuses first. In packages/core/src/risk.ts, any vault whose sources are not all fresh gets a stale_data flag, a score of zero, and the verdict unavailable. No partial verdict is ever inferred from the data that did arrive. The evidence array still names the source, its block, and its age, so the refusal is auditable.
The agent refuses independently. It checks each attestation's timestamp against its own max_age_seconds policy value on its own clock, regardless of what the service said about freshness. A rejected attestation becomes insufficient data, never a guess. The shipped policies set that bar to 2400s rather than something tighter, because an attestation from the Substreams source is stamped with the block the sink had reached, and a sink that indexes finalized blocks only is never fresher than Ethereum's finality lag, a 15-minute bar refuses every one of them, and the agent would pay for data it then could not act on.
Privacy and post-quantum
Sealed requests. The request body is { v: 1, kem: "ml-kem768-x25519", kid, ct, nonce, body }. The encrypted plaintext is { request, reply_pk, payer, ts, req_nonce }. The hybrid KEM is ML-KEM-768 combined with X25519, from @noble/post-quantum 0.7.1. The shared secret goes through HKDF-SHA-256 with info vaultradar/seal/v1 into AES-256-GCM. Only the vault count leaks, in the clear X-VR-Count header, because the price depends on it.
Sealed responses. The reply is sealed to the reply_pk the client generated for that one request. A third party who captures the envelope and pays for it again cannot read the answer, because they do not hold the reply secret.
Replay defence. The service rejects an envelope whose ts is more than 120 seconds from server time, whose req_nonce has been seen in the last 10 minutes, whose payer does not match the payer the payment layer verified, or whose count does not match. All four checks return 422 before settlement, so a rejected request costs nothing.
Privacy tiers. A clear scan hides nothing. A sealed scan hides the portfolio from every intermediary, but the vendor still decrypts it. The table tier hides holdings from the vendor too, by buying every vault of one protocol and filtering locally. table costs a flat $0.06 on both rails, which is deliberately never cheaper than a sealed scan: a scan of the maximum 100 vaults costs $0.051.
Receipts and attestations. Every response carries an ML-DSA-65 signed receipt binding request_hash, response_hash, the sources with their blocks, the price, the payment transaction id, the tier, and whether the exchange was sealed. Each vault also gets its own signed attestation, so a single vault's data stays portable and verifiable after the sealed response is opened and discarded.
HCS commitments. After settlement the service enqueues { v: 1, receipt_hash, sig, issued_at } to a Hedera Consensus Service topic. Only the hash and the signature go on the public log, never the content, so the audit trail cannot leak what anyone asked about. Look a receipt up by hash at /v1/receipts/:hash, which returns the sequence number once consensus confirms it.
Topic: 0.0.10483981
Mirror node: https://testnet.mirrornode.hedera.com/api/v1/topics/0.0.10483981/messages
On-chain key anchor. The service's ML-DSA public-key hash is written as ERC-8004 metadata under pq.sig.pubhash, at registry 0x8004A818BFB912233c491871b3d84c89A494BD9e on both testnets. Discovery reads it from the chain, so a forged agent card served from a compromised host still fails the check.
ERC-8004 agent ids: Hedera chain 296 112 (registration tx 0x0f23d2a0c2c3a820e69e4304027f5d442c6ae4a8cff1147a6ea8b4e5bda9ca3a); Arc chain 5042002 894342 (registration tx 0x0ebaa26fbf5c6db6f99eee116deccdef0aa766b7fad4d24bcb819e087b82ffc2). Both were verified live: each registry returns exactly the key hash the agent card publishes.
What the anchor does not prove, and the pin that closes it. The registry is permissionless (anyone can register an agent id), so an impostor who controls the service URL can register an id of their own, serve a card signed by their own key naming it, and satisfy every anchor check. The anchor binds a key to an agent id; only an expectation set outside the exchange binds an agent id to this service. So both consumers take one: EXPECTED_ERC8004=296:112,5042002:894342 on the dashboard (read at runtime by /verify and by the paid-scan route, which refuses before paying) and expected_erc8004 in the agent's policy. Unset, the checks still run and still prove the service is internally consistent, they just cannot prove it is the one you meant to call.
The boundary, stated plainly. Quoting the design spec:
The ML-DSA key is anchored on-chain by an ECDSA-controlled account. A verifier that has fetched and pinned the key once can verify receipts indefinitely without trusting ECDSA again. The anchor protects discovery today; it does not stop a future quantum adversary from re-pointing the registry. x402 payment signatures and chain consensus remain classical. There is no forward secrecy against later compromise of the service's KEM seed.
Run it
Prerequisites
- Bun 1.3 or newer.
- A Subgraph Studio API key.
- Two Hedera testnet ECDSA accounts, one for the service
payToand one for the agent. Both must associate HTS USDC0.0.429274. Fund the agent account with testnet USDC from Circle's faucet and with HBAR for its own signature. - A Circle developer account, Arc testnet USDC deposited to Gateway for the agent, and separate native Arc testnet USDC for ERC-8004 registration gas.
- Hashio JSON-RPC access for the Hedera ECDSA-alias deployer, with the gas limit set explicitly.
- A publicly reachable Postgres for the Substreams sink. Neon works.
- A substreams.dev login and a The Graph Market API key, for publishing and for the Substreams endpoint.
- An Anthropic API key for the agent, and a host for the service. It runs on Railway today
(
packages/service/Dockerfile,packages/dashboard/Dockerfile, andsubstreams/erc4626-vault-metrics/Dockerfilefor the sink); nothing in the code is specific to it.
Setup
cp .env.example .env # then fill it in; every variable is commented
bun install
bun test
Both at once
bun run dev
Service on :8787, dashboard on :3000, with the dashboard pointed at the local service
rather than at any deployment you have configured. Ctrl-C stops both.
Service
bun run service # or: bun run packages/service/src/main.ts
curl -s localhost:8787/.well-known/agent.json | jq '{endpoints, prices, pq}'
Agent
bun run agent watch --vaults 1:0xVAULT_ADDRESS,8453:0xVAULT_ADDRESS --policy packages/agent/policy.example.json
The watch command and its policy file are built and covered by bun test: the loop lives in packages/agent/src/watch.ts, and two ready-made policies ship as packages/agent/policy.example.json (balanced, sealed scan) and packages/agent/policy.strict.json (strict, whole-protocol table). scripts/demo.sh drives both, step 3 runs watch under the balanced policy and step 4 runs it again under the strict one.
Dashboard
cd packages/dashboard
SERVICE_URL=http://localhost:8787 NEXT_PUBLIC_SERVICE_URL=http://localhost:8787 bun run dev
Server components read SERVICE_URL. The in-browser receipt verifier at /verify needs the URL inlined at build time, so it reads NEXT_PUBLIC_SERVICE_URL. Set both.
Substreams sink
Build and pack both manifests first, per substreams/erc4626-vault-metrics/README.md. Then set up the database once per chain, and run one sink per chain. The --cursors-table value matters: packages/core/src/substreams/reader.ts reads sink progress from cursors_<chainId>, so a shared default cursors table would let one chain's cursor answer for another.
export DATABASE_URL='postgres://USER:PASS@HOST/DB?sslmode=require'
export SUBSTREAMS_API_TOKEN='...' # from thegraph.market
substreams-sink-sql setup "$DATABASE_URL" ./erc4626-vault-metrics-v0.1.0.spkg \
--cursors-table cursors_1
substreams-sink-sql setup "$DATABASE_URL" ./erc4626-vault-metrics-base-v0.1.0.spkg \
--cursors-table cursors_8453
substreams-sink-sql run "$DATABASE_URL" ./erc4626-vault-metrics-v0.1.0.spkg \
-e mainnet.eth.streamingfast.io:443 --params db_out=1 \
--cursors-table cursors_1 --final-blocks-only
substreams-sink-sql run "$DATABASE_URL" ./erc4626-vault-metrics-base-v0.1.0.spkg \
-e base-mainnet.streamingfast.io:443 --params db_out=8453 \
--cursors-table cursors_8453 --final-blocks-only
The substreams CLI 1.22.0 ships the same sink built in, if you would rather not install a second binary. It takes the connection string as a flag instead of a positional argument, and the same --cursors-table:
substreams sink postgres ./erc4626-vault-metrics-v0.1.0.spkg \
--dsn "$DATABASE_URL" -e mainnet.eth.streamingfast.io:443 \
--params db_out=1 --cursors-table cursors_1 --final-blocks-only
Check the deployment registry before a demo:
bun run verify-deployments
Arc mainnet path
Arc mainnet is a config change, not a code change. Point ARC_FACILITATOR_URL at the mainnet Gateway facilitator and change arc.network in packages/service/src/config.ts from eip155:5042002 to the Arc mainnet chain id. The seller address, the bucketed routes, and the shared handler are unchanged.
Prize mapping and scope
| Track | Where it lives | Evidence |
|---|---|---|
| The Graph, Composable / Standardized | packages/core/src/standardized/, substreams/erc4626-vault-metrics/ |
Two templates over the 15 registrations (14 pinned); one WASM module on two chains, published at https://substreams.dev/packages/erc4626-vault-metrics/v0.1.0 |
| The Graph, AI Use Case (From Scratch) | packages/agent/, packages/core/src/risk.ts |
Independent age check and insufficient data refusal; per-request x402 payment |
| Hedera, AI & Agentic Payments | packages/service/src/rails/hedera.ts, packages/service/src/keys.ts |
Metered pricing, HTS USDC via Blocky402, ERC-8004 identity with on-chain PQ key hash, UCP profile, HCS commitments; settled scan on HashScan |
| Arc, Agentic Economy | Arc rail and packages/dashboard/ |
Bucketed Gateway nanopayments, a settled 0.003 USDC scan through the batcher, and on-chain Arc evidence in the section above |
| Arc, Launch and Push to Mainnet | packages/service/src/config.ts |
Documented mainnet config path, above |
Honest scope notes, so nothing here is read as more than it is:
The Hedera Harness PR was cut. No PR to
hedera-dev/hedera-harnesswas opened. ThePOST /hedera/v1/scan-hbarroute that the proposed Tier 3.5x402Probevalidator would have exercised does exist and is mounted, but the validator itself does not.HCS-14 UAID is not implemented. The agent card has no
uaidfield. Identity is ERC-8004 plus the signed card, nothing more.Falcon signatures are not implemented. Signatures are ML-DSA-65 only. Falcon was considered as a smaller-signature option and dropped.
The upstream x402 payment to The Graph gateway was cut. Standardized queries use a Studio API key. The service does not pay the gateway per query.
Both rails have settled real payments. Hedera: 0.0015 USDC for one sealed scan, verified on chain. Arc: 0.003 USDC through Circle Gateway's batcher, receipt verified, sealed reply opened. The service is registered on both chains (HCS topic
0.0.10483981; ERC-8004 agent ids112on Hedera and894342on Arc), the agent's own discovery resolves both anchors,/v1/receipts/:hashreturns the HCS sequence for a committed receipt, and the Substreams sink is indexing into the same Postgres the service reads.What is still missing. The published package is up. What remains is the demo video, which is recorded and cut but not yet hosted; its link will be added here when it is.
The identity rule applies to every purchase. A card with no ERC-8004 identity, or an identity whose registry read fails, is refused by both the agent and the dashboard before they pay, the pin in the section above is what makes that a check on a specific service rather than on any service.
KEM key rotation and forward secrecy are out of scope. So are on-chain PQ payment signatures and zero-knowledge proofs of the risk computation.
License
MIT.