Search projects, hackathons, sponsors and tech

    ← All projects

    payOrRefuse

    x402 payment gate: refuse before signing, judged on live data from The Graph.

    Demo

    About

    payOrRefuse is an x402 payment gate that refuses before a signature exists. It holds the signer until the evidence is there: the seller's settled history from vet402's /decision and, as a second source, the x402 Base subgraph on The Graph. Anything short of a pass is refused before a signature exists (no payment module is loaded) and returns machine-readable reason codes; a pass signs the x402 payment and attests it. It has moved real money once, and that is the run this submission documents: on 2026-09-05 it signed 0.01 USDC to The Graph's own x402 endpoint — Base block 50898704, tx 0xf12093fba9314b1d3a514e7b667969201be8d021a6f4d6bdeb8d6c7f2de469ad. CONTINUITY. What existed before: vet402 already bought from x402 endpoints with real USDC on Base and answered "should my agent pay this?" via /decision, an SDK and an MCP tool. None of that is submitted. The boundary is tag pre-ethonline-2026 = commit c42daca, 2026-09-04 00:05:36 UTC, pushed. Hacking Begins was 2026-09-04 16:00 UTC, so the tag sits 15 h 54 min before. Three claimed commits predate that start: 37c56db and e668957 touch only WINDOW_PLAN.md, and ac6ec2e adds 16 lines under packages/sdk (6 in src/index.ts, 10 in generated dist/index.d.ts). I had pushed 214 commits since my 2026-08-23 application (git rev-list --count 26a7c66..pre-ethonline-2026), disclosed in writing to ETHGlobal at 2026-09-04 23:49 UTC. The message says 207 because it counted only to 2026-09-03; docs/ethonline-2026/DISCLOSURE_2026-09-05.md holds it as sent, with the reconciliation. What is new: everything else in the claimed range is after 16:00 UTC. payOrRefuse (packages/sdk), pay_if_trusted (packages/mcp-server), the demo, the A/B harness, SKILL.md, AI_USAGE.md. I am not moving the tag; both disclosures link to it. main is also production, so the range holds work I do not claim. At the submission tag ethonline-2026-submission: git log pre-ethonline-2026..ethonline-2026-submission -- packages/sdk packages/mcp-server examples/ethonline-2026-demo examples/ethonline-2026-ab SKILL.md AI_USAGE.md docs/ethonline-2026 # my claim: 260 commits git log pre-ethonline-2026..ethonline-2026-submission # same days, everything: 411 commits Commits and code comments are mostly Japanese. The English route is SKILL.md (what to run), AI_USAGE.md (who wrote what) and docs/ethonline-2026/CHANGED_FILES.md (pre-existing files I touched). PROMPTS/ in that folder is a day-by-day excerpt, in Japanese, of the decisions and instructions I gave. The Graph. With evidence source "subgraph" or "both", the gate reads the x402 Base subgraph live via the Gateway with the caller's key against a pinned deployment; the evidence row carries _meta.block.number. A failed read or a pin mismatch refuses, and nothing falls back to vet402's ledger. On Bazantic I ran a pre-registered A/B (examples/ethonline-2026-ab), Recipe vs no Recipe. Success did not move: 5/10 and 5/10 — I had been expecting to write a different sentence here. Vocabulary did: real vet402 identifiers among reason codes went from 20/32 (63%) to 29/32 (91%), labelled exploratory. 88 calls settled, 88 zero-USDC tx on chain. Re-measured 2026-09-09: those free reads no longer post a transaction. A second pre-registered run, after a product fix and a Recipe update, went 7/10 without the Recipe and 5/10 with it. The whole gap came from one fixture. A third pre-registered run on 2026-09-12 closed it after I rewrote one sentence of the Recipe: 7/10 in both arms, with the vocabulary rate higher under the Recipe for the third time in three runs. The Bazantic comment goes through all of it. npm run judge-check from the repo root unsets every key and runs 11 steps — sdk, mcp-server, demo, a root install for CI parity, and the A/B harness with mutations — then prints one table ending in judge-check: all 11 steps exit 0 in <n>s. It does not cover the SDK's own mutations; SKILL.md points at that one separately, and it takes a judge from git clone to a live read of The Graph and a dry-run payment, no funded payer key needed. The two MCP blocks want a throwaway key and still sign nothing, and the page flags them.

    How it's made

    The stack is TypeScript. packages/sdk (payOrRefuse, x402-pay, subgraph-evidence) and packages/mcp-server (pay_if_trusted, stdio) are plain Node packages, Node >= 20; the demo under examples/ethonline-2026-demo runs .ts directly on Node >= 22.18. The service behind vet402.com is Next.js on Postgres and is pre-existing. THE GATE. payOrRefuse runs in this order and stops at the first failure (the header of packages/sdk/src/pay-or-refuse.ts). (1) Caller errors are rejected before any request goes out: a payee that is not a 0x address, or a policy that waives ALLOW without declaring a floor (invalid_policy). (2) The caller's own price ceiling is applied before the decision is fetched. price_above_ceiling, zero requests. (3) GET /decision for the resource, role=payer. Unreadable or degraded refuses. A 404 means "not catalogued" and hands the judgement to the 402's payTo plus the payee score for that address. The evidence floors the caller declared are read and checked here, and vet402's recommendation is applied. ALLOW passes. WARN passes only if the caller set requireVet402Allow: false and every floor is met. BLOCK and degraded are never waived. (4) The real 402 challenge is fetched, and its payTo, network, asset, scheme and amount are matched against what the caller named. (5) The payment module is dynamically imported. Refusals carry reason codes from a closed vocabulary (l1_not_attempted, l1_inconclusive, resource_uncatalogued, price_above_ceiling, price_above_declared, payee_recommendation_block, evidence_unavailable, ...). How The Graph decides the outcome With policy.evidence.source "subgraph" or "both", the SDK queries the x402 Base subgraph (Cb56epg3EvQ6JRpPfknbkM54QxpzTvLa7mwKNQQfUyoj) at gateway.thegraph.com with the caller's Subgraph Studio key: x402AddressSummaries filtered by the payee address with role RECIPIENT (totalPayments, totalVolumeDecimal), plus _meta { block { number } deployment }. The same address can also hold a PAYER row — it must not be added in. The Gateway answers a keyless request with HTTP 200 and an errors array where the data would be, so the SDK checks for that array before it reads any field and a missing key can never arrive as a receipt count of zero. The evidence row on the decision carries subgraphId, block number, deployment and queriedAt, plus the key-less publicUrl, so the record never contains the key. minSubgraphReceipts is a floor against that count. A failed read refuses, with no fallback to vet402's own ledger. This is what let a caller pay The Graph on The Graph's own data while vet402's engine still said WARN. The payment path is x402 v2, scheme "exact", eip155:8453, USDC 0x8335...2913, EIP-3009. The SDK builds the transferWithAuthorization (from, to = accept.payTo, value = accept.amount exactly, validBefore rounded to at most 120 s, nonce = 32 random bytes it generates), signs it as EIP-712 with the USDC domain pinned to on-chain measured constants (name "USD Coin", version "2") and never taken from the seller's accept.extra, persists the nonce immediately after signing, re-sends the original request with the PAYMENT-SIGNATURE header (v1: X-PAYMENT), and reads the settlement claim from the PAYMENT-RESPONSE header. The SDK reports it as settle_claimed, not settled, because only a chain re-read may say settled. The buyer never calls a facilitator and pays no gas. Whether a refuse path can reach the signer is checked rather than asserted. The payer account is a Proxy that counts property reads: 0 on every refuse path, and a negative control that flips --live sees exactly 1. The built dist/index.js module graph is walked, and the payment module must appear only behind a dynamic import. packages/sdk/test-mutations.mjs then edits the source one mutation at a time (pass a BLOCK under requireVet402Allow: false, flip the subgraph floor comparison, treat a /decision 404 as ALLOW, drop the payTo check, move the dynamic import outside the allow branch, read data past a GraphQL error) and requires every edit to turn the suite red. A companion suite feeds a table of malformed shapes to every external input: the decision body, the 402 challenge, the subgraph answer and the caller's own policy each get null, a wrong type, "0x10", "1e4", Infinity and a string "true". Each one has to stop before a signature exists. The MCP server does not re-judge anything. pay_if_trusted takes the same policy object and forwards it to payOrRefuse unchanged. GRAPH_API_KEY comes from the server's env block. If the policy asks for the subgraph and the key is missing, the tool refuses with graph_key_not_configured before reading anything. VOUCH_API_KEY is optional for resources in vet402's catalogue, because /decision answers key-less at 10 requests per minute per IP. A resource outside the catalogue, such as The Graph's gateway, falls back to a keyed payee-score read, so without the key that verdict is not read and the tool refuses with evidence_unavailable. On Bazantic, a Bazantic-built x402 gateway fronts vet402's REST API as 57 MCP tools (https://2vjhqfgvw5dt5lja2zpjsjwrem.bazgateway.com/mcp), and a public Recipe explains when and how to use it. The A/B harness (examples/ethonline-2026-ab) gives the same model the same prompt and the same tools twice, with the Recipe as the only difference (a test pins stripRecipe(B) === A byte for byte), grades every trial against what vet402's API actually returned, and writes raw trials.jsonl that npm run metrics regrades from scratch. Because the gateway answered 402 even on $0 routes during the v1 run and MCP tools/call cannot carry a payment, the harness includes a bridge — I had not known about that gap when I planned the run — that signs only when the quoted amount is exactly "0" and re-sends over REST. Claude (Opus / Fable, via Claude Code) wrote most of the code under my direction, and the failing tests for the gate were written before the implementation. AI_USAGE.md lists the areas and files and what I did. docs/ethonline-2026/PROMPTS/ holds, day by day, an excerpt of the decisions and instructions I gave and the commits they landed in. No AI voice is used in the video.