Before your agent pays an x402 API, check if vet402 bought it with its own money and what came back.
Runs on your machine from its package; the steps are below.
Not assessed yet.
Odel charges nothing per call. The publisher may charge for its own service.
Runs locally over stdio
This server isn't hosted — your MCP client launches it from a package registry. Use one of the commands below, or drop the config into your client (e.g. Claude Desktop).
Run
npx -y @vet402/check
MCP client config
{
"mcpServers": {
"vet402-check": {
"command": "npx",
"args": [
"-y",
"@vet402/check"
]
}
}
}Before an AI agent pays for an API, vet402 has already bought it with its own money and shows what came back, with the payment on chain.
On Solana, Tempo and Base since 2026-09-28 (UTC). The current counts (purchases, sellers, payments settled, answers that came back, and payments that settled with nothing usable back) are at the top of the site's first page and in site/rank.json: the groups[] entry with id main, its totals (the top-level totals include Algorand).
npx tsx scripts/verify-receipt.ts https://kzmttkc.github.io/vet402-delivery/records/obs_2026-09-28_000001.jsonpackages/check/skills/vet402-check/Who it is for:
"Came back with an answer" means the payment settled and the seller answered 2xx with a non-empty body (for the Tempo census purchases, whose runner kept no body: 2xx; the Tempo re-purchases are tested for an empty body). On Solana, Tempo and Base the runner checked each payment on chain before calling it settled. vet402 did not check that the answer is what the listing promised; whether its keys matched what the seller declared is a separate column.
How vet402 pays for this: Grades come only from vet402's own purchases. vet402 also sells paid checks (paid in USDC on Algorand or Base); a paid check is a separate report and never moves a grade.
Where Solana comes in: vet402 pays Solana sellers in USDC on Solana, and writes the Merkle root of each day's signed delivery records into one Solana memo, so a record of that day cannot be added or dropped later without the root changing.
This README carries no counts: the daily publish commits only data/ and site/, so counts written here would fall behind. The site is rebuilt from data/ on every push to main, so it shows the purchases whose results have been committed to data/, not ones still waiting to be copied there.
This repository is the multi-chain part of vet402. It buys the sellers it can on Solana (from the PayAI, CDP Bazaar and Pay.sh catalogs) and Tempo (from Tempo's Mercator directory), and on Base the sellers registered in ERC-8004 (8 purchases from 8 sellers on 2026-09-28, each bought once). Rebuy plan from UTC day 2026-09-29, with no end date: every day, twice on Solana and once on Tempo, one purchase per payTo per slot per UTC day among the Solana and Tempo sellers whose earlier payment settled (which ones: the rows in data/remeasure/), within a cap of 70 USDC per calendar month on Solana and 30 USDC.e on Tempo. It keeps every result with its payment, and grades sellers by what was actually delivered, not by how popular a listing is.
Node 22 or newer. Nothing to sign up for, no key, no payment: the check reads rank.json, the signed records and the records index, and nothing else.
npx -y @vet402/check https://api.xona-agent.com/token/pumpfun-trending
It prints what vet402's record holds about that seller: purchases tried, settled and answered, the failures counted against the seller and the ones that are not, the grade (measuring while there are too few purchases), the newest purchase with its tx, and the signed records. For a seller vet402 never bought from, it prints "vet402 has no record of this seller". The first run installs from GitHub (about 30 seconds on an empty npm cache).
Add --verify to re-check the newest signed record (signature, Merkle proof, payment on chain, root in vet402's Solana memo). As an MCP server (tools check_before_paying and verify_record):
claude mcp add vet402-check -- npx -y @vet402/check --mcp
The x402 fetch hook, the output fields and the options: packages/check/README.md.
| Part | What it does | Code |
|---|---|---|
| Solana census | Joins the PayAI, CDP Bazaar and Pay.sh catalogs and buys each seller once (one purchase per payTo), then records settled / delivered with the tx signature | scripts/census.ts, src/census.ts |
| Tempo census | Buys the MPP services listed in Tempo's Mercator directory on Tempo mainnet (USDC.e) and records the result | scripts/tempo-census.ts, src/tempo/ |
| Base | Buys ERC-8004-registered sellers and writes the delivered result to the ERC-8004 ReputationRegistry with proofOfPayment, from the same address that paid | scripts/base-buy.ts, scripts/base-feedback.ts, src/evm/ |
| Check before paying | Read-only lookup of one seller in rank.json and the signed records, with optional verification of a record down to the Solana memo anchor: a CLI (npx), an MCP server and a hook for the fetch an x402 client pays through | packages/check/ |
| Check skill | A check to run between "search" and "pay" in an agent's x402 flow | skills/vet402-check/ |
| Delivery ranking | Grades sellers by independent purchases, per page: Solana, Tempo and Base together, Algorand on its own (method v3); failures caused by vet402 or the facilitator are not counted against the seller | src/rank/ (method: src/rank/README.md) |
| Public site | Static pages (Solana, Tempo and Base results first, an Algorand page, one page per seller, the method) and rank.json, built from the inputs in data/ and served by GitHub Pages | scripts/build-site.ts, site/, data/ |
| First-buyer mode | Buys once, for life, from each Solana seller (payTo) that no one has paid yet, and publishes whether it settled and delivered, or why it cannot be paid | scripts/first-buyer.ts, src/first-buyer/ |
| Delivery records | One signed record per purchase (x402-observation/v0): what vet402 paid, on which chain, and what came back, with a Merkle proof into a daily root that is written into a Solana memo | src/receipt/, scripts/build-receipts.ts, scripts/publish-records.ts, scripts/anchor-receipts.ts, data/records/, site/records/ |
| Remeasure | Buys again from sellers vet402 already paid (payment settled), one purchase per payTo per slot per UTC day, so the ranking gets purchases on more than one day. From UTC day 2026-09-29, with no end date: every day, twice on Solana and once on Tempo, within the month caps | scripts/remeasure.ts, src/remeasure/ |
| Solana feedback | Writes the outcome of a paid Solana purchase to the 8004-solana reputation registry (the Solana port of ERC-8004), from the wallet that paid, with the published delivery record as the feedback file | scripts/solana-feedback.ts, src/solana-feedback/ |
| Robinhood Chain | Buys, in USDG, every seller whose live 402 lists Robinhood Chain (one purchase per payTo), and checks stock prices sold over x402 against the Chainlink feed of the Stock Token: age against the heartbeat, oraclePaused(), and the ERC-8056 multiplier between share price and token price | scripts/evm-lane.ts, src/robinhood/, src/evm/, site/robinhood.html |
| Arbitrum One | Buys each seller that lists Arbitrum One with the same payTo as its Base accept, once on Arbitrum and once on Base, and shows per seller whether each chain settled and came back, and why not | scripts/evm-lane.ts, src/evm/, site/arbitrum.html |
| Daily root on EVM chains | After each UTC day with purchases on the Robinhood Chain or Arbitrum lane, that day's Merkle root goes into contracts/DeliveryRoots.sol on that chain: written once per day, never changed, only by its own key (not the payer wallet). Each leaf is a public record of one purchase (no seller answer, only its sha256), published in data/evm/roots/<lane>.json with its proof, so anyone, or another contract, can call verify(day, digest, proof). Once deployed, the contract is at 0x84DB4733f8F9e6803Af811fD9E438aEBc0b90145 on both chains (the key's first transaction); the days written so far are the ones in data/evm/roots/ | scripts/evm-roots-deploy.ts, scripts/evm-anchor.ts, scripts/evm-roots-publish.ts, src/evm/roots.ts, contracts/ |
| Proxy buy | An agent pays vet402 the seller's price + 0.005 (x402 on Solana, MPP on Tempo); after that payment settles, vet402 pays a seller it already paid before and returns the answer with both transactions and a record, or refunds the agent when it did not pay the seller. State in Postgres; runs on Vercel Functions. Running since 2026-10-01 at https://vet402-delivery.vercel.app/v1/buy, Solana only | src/proxy-buy/, api/, scripts/proxy-buy-serve.ts |
Every paying script signs exactly one transfer per purchase, to the payTo locked from the seller's own 402, within a per-purchase cap and a persistent total cap, and refuses anything else before signing. Keys live in .keys/ (git-ignored) and never appear in logs or results. The Tempo, Base, delivery-record and first-buyer paths were reviewed independently (the fixes are in the commit log). The first Solana runs (gate 1 and the census) ran before an independent review; they stayed within the caps above. Proxy buy (below) reads its keys from environment variables instead, uses wallets of its own, and went through several rounds of independent review before it was switched on (each round's fixes are in the commit log).
Some x402 sellers are listed but have never been paid by anyone. vet402 buys from each of them once, with its own money, so the seller learns whether a real payment settles and what comes back. Every record says: "One test purchase by vet402. Not organic demand."
Rules:
results/first-buyer-ledger.json: the attempt is written before anything is signed; an attempt that never finished counts as paid. A failure where no signed payment left vet402 may be tried again at +7 and +30 days, then never.--since; no USDC ever received (read on chain); the payTo's USDC account exists; not a tunnel host (trycloudflare and similar); a usable example input; not vet402's own host or payTo.src/first-buyer/first-buyer-optout.json is never bought. A request to be bought does not change who is chosen.--since exists in results/first-buyer-seen.json, the only date is the catalogs' lastUpdated, which is a last-update time, not a first-listing time. Pay.sh listings carry no date and are skipped until then.Published: the ledger (payTo, tx, time, settled, delivered, failure reason), results/first-buyer/purchases.json, and the buyer wallets in results/first-buyer/wallets.json, so analysts can subtract these payments.
Reciprocity: each ledger entry has reciprocal.value, false until checked. To check it, list USDC transfers into vet402's own Solana payTo addresses within 90 days after the purchase; if one comes from the seller's payTo (or a wallet the seller names as theirs), set it to true. Such a purchase is not counted as vet402 usage.
Files (under results/, not committed; keep them where --pay runs, and back them up):
first-buyer-ledger.json: the lifetime ledger. --pay stops when it is missing; create it once with --init-ledger before the very first --pay, never to replace a lost one.first-buyer-budget-YYYY-MM.json: the month budget. --pay stops when the ledger shows more spent this month than this file.first-buyer.lock: exists while a --pay run is going. A second run stops. A run that was killed leaves it; the next run stops and says so. Check the ledger for pending attempts, then delete it.first-buyer-seen.json: hosts seen per run, for "new since".npm run first-buyer -- --dry-run --since 2026-09-21 # catalogs, unpaid 402s, chain reads, transaction build; pays nothing
npm run first-buyer -- --init-ledger # once, before the very first --pay
Each purchase can become a record signed by vet402's observation key (listed in src/receipt/observers.ts; it is not a payment key and holds no funds). A record holds the payment tx, the HTTP status that came back and salted hashes of the request. It never holds the response body. All records of one UTC day form one Merkle root, and that root is written once into a Solana memo.
What is published (data/records/, served at https://kzmttkc.github.io/vet402-delivery/records/):
data/records/notified.json. A seller is the ranking's seller: the host, or host#service when one host fronts several services that each pay their own recipient. An entry matches only that exact seller, so listing a bare host never publishes the failures of another service behind the same host. On 2026-09-30 it listed one seller, api.xona-agent.com, told in issue xona-labs/creative-ai-agent#1, and that seller's 3 NOT_DELIVERED records of 2026-09-28 and 2026-09-29 were published the same day. On 2026-10-01 api.syraa.fun was told by email (to the developer contact in its docs), and its 5 NOT_DELIVERED records of 2026-09-28 to 2026-09-30 were published the same day. The counts per day are in data/records/index.json. The daily root still covers them, so no record can be added or dropped later without changing the root.scripts/build-site.ts stops if data/records/ holds a record this policy does not allow, a record that does not verify, or a file that data/records/index.json does not list.Check a published record, with no account and no payment:
npx tsx scripts/verify-receipt.ts https://kzmttkc.github.io/vet402-delivery/records/obs_2026-09-28_000001.json
It checks the signer against vet402's key, the signature, that the verdict follows from the recorded checks, the Merkle proof and the payment on chain. Change any signed field, even by one character, and it fails.
When the records index (--index, else data/records/index.json next to a local record, else the published one) names the day's account in the observation-roots program (days[].programRoot), it also asks the program itself: a simulated verify of the record on Solana mainnet (simulateTransaction with signature checks off and the blockhash replaced; nothing is signed or sent). The line program verify: DELIVERED (2026-09-30, sequence 599); digest 0x… is the record's appears only when the simulation succeeds and the program's return data names the record's day, verdict, sequence and digest. A changed record makes the program refuse it, and the run fails.
The anchor counts only when vet402's anchor wallet (VET402_ANCHOR_SIGNERS in src/receipt/observers.ts) paid for and signed the memo; a memo from any other wallet is ignored, whatever root it names. Until the day's root is on chain the result reads RESULT: OK (not yet anchored), and the script says what that leaves unproven: the Merkle line then only shows that the proof and the root inside the record agree, not that the record belongs to the day vet402 committed to.
npm run records:publish -- --from results/receipts # copy the publishable records into data/records/
npm run receipts:anchor -- --day 2026-09-28 # simulate the day's memo on mainnet; signs and sends nothing
Daily records: from 2026-09-29 on, each day's remeasure purchases (Solana and Tempo) become that day's records and root.
npm run receipts:build -- --key .keys/attest.json --out ~/vet402-solana-receipt/results/receipts --day 2026-09-29 --simulate-anchor
npm run records:publish -- --from ~/vet402-solana-receipt/results/receipts --day 2026-09-29
--allow-open-day is given.--allow-open-day is for a local look only: records:publish and receipts:anchor refuse it. Both also hash again each input the day was signed from (listed in its local sources.json) and stop if one changed, for example when a second run the same day added purchases. The day is then built again from the current files.records:publish --day adds that day only; the days already in data/records/ stay byte for byte.Writing the root on chain needs --send and the Solana payer key. The records are read from ~/vet402-solana-receipt/results/receipts; --send and --resume refuse any other folder unless --other-receipts-dir is added. The network fee is the only thing that leaves the wallet. One root per day, checked twice before signing:
results/receipts/<day>/anchor-sent.json does not exist (it is written before the transaction leaves, with the signed bytes);If a send was interrupted (anchor-sent.json says "sending"):
npm run receipts:anchor -- --day 2026-09-28 --resume # reads the chain; changes nothing on chain
npm run receipts:anchor -- --day 2026-09-28 --resume --send # only if the line above says to
"sent", then run records:publish again.--resume again, or --resume --send to rebroadcast the same signed bytes (same signature, so it cannot land twice).anchor-sent.failed-* or anchor-sent.expired-*, and --resume --send (or --send) writes the root afresh.anchor-sent.json is missing: --resume records it.The ranking gives a rank number only after 10 counted purchases on 2 or more UTC days. Remeasure buys again only from sellers vet402 already paid, so each seller's record grows day by day. It never buys from a new seller.
Rebuy plan from UTC day 2026-09-29, with no end date: every day, twice on Solana and once on Tempo. Spending is capped per calendar month (70 USDC on Solana, 30 USDC.e on Tempo, src/remeasure/constants.ts); a chain that reaches its cap buys nothing more until the next month. The ledger allows one purchase per recipient per slot per UTC day, so where several sellers share one payTo (on Tempo many services do), one of them is bought. Which sellers were bought again is decided by the rows in data/remeasure/, and a correction can change them; a seller page says what those rows hold for that seller (UTC days and purchases per chain), counting only rows whose payment settled on chain (settled true, with a tx); a seller with no such row (refused, not settled, or an outcome not on record) gets no such line. The runs of 2026-09-29 were started by hand; from 2026-09-30 the daily runner below starts them. The days it ran are the files in data/remeasure/.
data/ (Solana census and gate1 results, the Tempo ledger). The payTo is locked to that earlier payment and the price can be no higher. A live 402 that names another payTo is not paid and is recorded as pay_to_changed; a higher price is not paid either.--per-payto up to 5), at least 60 s apart for the same payTo. A payTo with several resources gets them in a fixed order, cheapest first, so the same resource adds up.<date>|<payTo>|<slot> on both chains).src/pay.ts, src/tempo/pay.ts), which write the ledger before anything is signed.--pay run (census and remeasure) checks the same thing before signing: USDC.e out of the key since the census start block must not exceed its own ledger plus what the key's other ledgers record as paid (src/tempo/key-ledgers.ts). A deleted or lost ledger leaves its payments unaccounted, so nothing more is signed until someone looks. Every day ledger is also listed in tempo-ledger-index.json before its first purchase; a listed day ledger that is missing stops every Tempo run by name. The census --pay accepts only the key's census ledger (~/vet402-solana-tempo/results/tempo-ledger.json) and exits otherwise. The Tempo month cap also counts what left the key on chain since the month's first block, census purchases included.--pay run as unknown_after_sign. It is never bought again that day, and the rank does not count it against the seller.~/vet402-solana/results/remeasure. Ledgers, locks and results always go there, whichever checkout runs the script, and --pay refuses --out. npm run rank reads the same folder.<chain>-YYYY-MM-DD.json, read by npm run rank as purchases on that day. Ledgers: budget-solana-YYYY-MM.json and tempo-ledger-YYYY-MM-DD.json. Back them up; never delete them.npm run remeasure -- --chain solana # dry run (the default): targets and estimate; pays nothing
npm run remeasure -- --chain tempo --dry-run
An agent asks vet402 to buy a seller's answer for it. vet402 pays the seller from its own wallet only after the agent's payment has settled, and returns the seller's answer together with both transactions and a public record of the purchase.
Status on 2026-10-01: it runs at https://vet402-delivery.vercel.app/v1/buy, for Solana only (Tempo is switched off on that deployment, so no Tempo price is offered and a Tempo payment is refused). It went through several rounds of independent review before it was switched on; each round's fixes are in the commit log. The first purchase through it settled on 2026-10-01 at 04:50 UTC. Purchases per UTC day stay within the cap the deployment is configured with.
Use:
curl -i 'https://vet402-delivery.vercel.app/v1/buy?url=<seller endpoint>' # free: shows the price, charges nothing
The answer is a 402 with the price on every chain vet402 can pay that seller on:
PAYMENT-REQUIRED header (scheme exact, USDC). Pay it with any x402 v2 client and send the same GET with PAYMENT-SIGNATURE.WWW-Authenticate: Payment challenge (tempo/charge, USDC.e on chain 4217). Send the same GET with Authorization: Payment ... carrying a signed transaction for vet402 to broadcast (pull mode). A transaction already broadcast (push mode) is refused.vet402 pays the seller on the chain the agent paid on.
Price: the seller's price plus 0.005 (USDC on Solana, USDC.e on Tempo). The 402 body shows the seller's price, the fee, the total, the seller's payTo, the refund rule below, the rule for large answers (an answer above 1,000,000 bytes is not forwarded, and since the seller was paid there is no refund), and how many earlier vet402 purchases from that seller settled and delivered. On Tempo the agent pays its own network fee.
Refunds:
validBefore with no receipt for its own hash, or, for a payment the seller sponsors, no transfer carrying its challenge-bound memo). The refund transaction is in the answer (x-vet402-refund-tx) and in the record.buy.refund says which rule applies): the refund for an undelivered answer (Solana), when switched on, refunds the full payment for a seller-side failure (402 again, 5xx, a 1xx or 3xx status, a 2xx with an empty body, or a connection the seller refused or closed), at most once per paying address per UTC day and only while a cap per UTC day and per UTC month, shared by all agents, has room; a refund refused by these limits is not tried again. Whether the seller was paid is read from vet402's own signed payment on chain (Solana: by its message; Tempo: by its hash, or its memo when sponsored), never from the transaction the seller names in its answer, and one on-chain transaction counts for one purchase only (a database key).seller_payment_pending and the reconciler (below) refunds once that payment is proven dead. If it lands, the reconciler closes the purchase with no refund, or, with the refund for an undelivered answer on, decides that refund as described below.validBefore (Tempo) are written to the database before it is sent; another attempt is made only after the previous one is proven dead on chain, at most three sent attempts, and at most six attempts that fail before sending (a read error). A refund that cannot be made (after those attempts, or at once when no transfer can reach the address) is "stuck": it stays open and owed, and the reconciler reports it (ALERT) for a human; it is never closed as done.VET402_PROXY_SOLANA_NOT_DELIVERED_REFUND_DAILY_CAP and VET402_PROXY_SOLANA_NOT_DELIVERED_REFUND_MONTHLY_CAP are both set; one without the other stops the service from starting). When vet402's own payment to the seller is found landed on chain (by its message, as above) and the seller then answered 402 again, 5xx, a 1xx or 3xx status, a 2xx with an empty body, or refused or closed the connection with no answer (ECONNREFUSED, ECONNRESET, EPIPE, a socket closed by the other side; not a client vet402 itself closed), the agent's full payment (seller price + fee) is refunded from the payer wallet, to the same address and in the same way as above, unless one of the checks below refuses it. These are the ranking's seller-side failures (settled_not_delivered and paid_not_delivered in src/rank/classify.ts, the NOT_DELIVERED line of the delivery records), less what cannot be told apart from a slow seller or vet402's own side. Not refunded: any other 4xx (vet402 or the agent built the request), no answer within the wait (a timeout of any kind), any other connection error (a name that did not resolve, for example), a 2xx whose body could not be read, an answer above 1,000,000 bytes, and a payment to the seller not found on chain yet (the reconciler decides that one the same way once it is found landed; proven dead, it is the refund above). The decision is made once per purchase, in one database transaction under one lock (pb_nd_refund, one row per purchase), so two requests or a request and the reconciler never both decide it:
refund.status is refused and refund.reason starts with cap_daily or cap_monthly, with the amounts. A refusal by a cap or by a check below is final for that purchase: it is not tried again on a later day.buyer_daily_limit).payto_is_buyer).seller_on_hold, nothing charged) and gets no second refund of this kind (seller_on_hold) until a vet402 purchase from it that delivered is in data/ with a later time than the refund (the next daily run that delivers, once it is deployed), or until 7 days after the refund, whichever comes first, so a seller the daily runs do not buy again is not held for good.seller_on_hold for the Solana offer, before the agent signs, when the instance answering it knows of the hold. It reads the holds after each paid request and, for a quote, only in the minutes after a cron run, when the database is awake anyway, so a quote never wakes the database. A hold the quote did not show is still refused by the paid request, before any charge.seller_unsettled), which makes the decision the same way at its next run. The answer is then the 502 not_delivered with refund pending_reconcile (unknown when nothing could be written).refund_pending, outcome not_delivered) for the reconciler, which tries again and reports (ALERT) until it is sent or a person settles it, and a purchase is refunded at most once (a database key).not_delivered with refund: its status, amountAtomic, to, tx, basis (what it is for, such as not_delivered: http_500) and, when it was not sent, reason; x-vet402-refund-tx when sent. The record says the same.isBlockhashValid, processed); otherwise the agent's payment is refused before any charge (blockhash_not_known, sign again) and a seller payment is not handed over (the agent is refunded). Its last valid block height is then recorded as the newest blockhash's, read from a state at least as recent as the one that knew it (getLatestBlockhash with minContextSlot): never below the real one by more than the block a transaction can still be included in. A refund records its own blockhash's.getEpochInfo, finalized: height and slot of one bank). isBlockhashValid is not used for this: it also answers false for a blockhash the node does not know yet.getSignatureStatuses with searchTransactionHistory): found decides; not found proves nothing (a node answers null when its long-term storage fails), so the search below decides. Any other transaction is searched by its message hash among the signatures of the account. Transactions of every version are read (maxSupportedTransactionVersion 1) and compared by the hash of their raw message bytes (legacy and v0: the bytes after the signatures; v1: the message comes first, its second byte is the number of signatures, and the signatures follow it); one that cannot be read is never taken for someone else's: the answer stays "cannot tell yet" and the reason is reported.minContextSlot). After expiry, the window ends at the finalized slot of the first answer past the margin; the transaction, if it ever landed, is in a block below it. The search reads finalized signatures down from that slot; those above it are skipped unread, and how far the walk above it got is kept, so the next look starts there however much traffic came after. The walk ends at the anchor (or, with no anchor on record, at the slot read before the hand-over). For the agent's payment it always reaches the anchor, then goes on past it, reading it too, down to ten minutes before vet402 took the payment (on a quiet account, where the anchor is older than that, the walk ends at the anchor): the agent may have sent the same signed payment to the facilitator itself before vet402 read the anchor, and a transaction with a blockhash vet402's node knew then cannot be older than that.getFirstAvailableBlock); otherwise the answer stays "cannot tell yet" and is reported. Reaching the anchor shows that the listing reached back to before the hand-over; it cannot show that nothing between the anchor and the window's end was left out by the node that listed it. To keep that small, every page of one walk asks for the same minContextSlot, the anchor is read with minContextSlot at the slot read just before it, and all reads go to the one endpoint in SOLANA_RPC_URL (use a provider that serves full history from one consistent source).rpc_rate_limited), no recorded last valid height or anchor, a history that does not reach back far enough, or a look cut short leaves the answer at "cannot tell yet", never "dead", with the reason. The reconciler reports it (ALERT) and looks at that purchase again after 15 minutes, at its next run after that (the cron every 30 minutes, or a reconcile turn a paid request starts).Who vet402 buys from: only a seller host and payTo pair that vet402 already paid with a settled payment and that delivered at least once, read from data/ (npm run proxy-buy -- --check prints how many pairs there are). Any other host is refused without being contacted; a seller whose payTo moved, or whose every settled purchase came back empty, is refused. So is a seller whose 402 names the blockhash to sign with (extra.recentBlockhash or recentSlot): the x402 SVM client would sign vet402's payment with it, and the seller would choose when that payment expires. The same check runs again just before vet402 signs its payment to the seller; a seller that starts naming one after the agent paid gets nothing signed, and the agent is refunded.
What comes back after a paid request:
x-vet402-customer-tx, x-vet402-seller-status, x-vet402-record and x-vet402-seller-settled (true, false or unknown: whether vet402's own payment to the seller was found on chain when the answer was handed over). x-vet402-seller-tx is present only when it was found; the transaction a seller names in its own answer is never used. A purchase closed with unknown is settled later by the reconciler.seller_not_paid (with the refund), not_delivered (vet402's payment to the seller settled; no refund, unless the refund for an undelivered answer is on and applies, and then the refund field has it) or answer_too_large (vet402's payment to the seller settled; no refund), seller_payment_pending or customer_payment_unconfirmed (not known yet; the reconciler decides, and the refund field says pending_reconcile). Reasons are fixed codes; error messages from RPCs, facilitators and the database are never shown.GET /v1/buy/records/<id> returns the record: the seller endpoint without its query string, seller payTo and price, fee, total, both transactions, the seller's HTTP status, the sha256 and size of the answer, the outcome and the refund. The record exists from the moment the agent's payment is confirmed (outcome in_progress).Order of a paid request, and where it stops. Every stop before the agent's payment settles charges nothing ("charged": false):
reconcile_pending, nothing charged. Everyone else is served.validBefore at most 600 seconds ahead (mppx signs 25 seconds ahead), so that one never mined can be shown dead; otherwise 400 valid_before_required.accepted field (the target, the seller's price and payTo are in extra); MPP matches the challenge's HMAC, amount and externalId (a hash of the target).src/pay.ts, src/tempo/pay.ts): the seller's 402 is read once more and a raised price or a changed payTo is refused before signing; the signed transaction is read back and written to the database before it is sent.State and hosting: all state is in Postgres (tables pb_purchase, pb_customer_tx, pb_chain_tx, pb_day, pb_wallet, pb_refund, pb_nd_refund, pb_counter, pb_alert, pb_state, created on first use). The per-client request limit (30 a minute) for requests that carry a payment is counted there too, under a hash of the client address; for free quotes it is counted in each function instance's memory (at most 50,000 clients a minute per instance; past that a new client's quote is refused), so a quote never writes to the database. Everything that must not happen twice is a unique key or a conditional update in the database, so any number of serverless instances (Vercel Functions, api/buy.ts) can serve at once. Tables are created once, under a Postgres advisory lock, by whichever instance starts first. api/reconcile.ts runs the reconciler from Vercel Cron every 30 minutes (at :00 and :30), least recently looked-at purchase first, so purchases that cannot be decided yet never hide one that can (it logs ALERT lines for a human, including every purchase still open two hours after it started). Each cron run also reads the payer wallets and reports, as ALERT, anything that refuses every new purchase: a balance below the floor the books explain (chain_spend_exceeds_ledger, with both numbers), a floor below one purchase's worst case (insufficient_balance), or too little SOL for the refund fees and account rent of the open purchases and one more (refund_fee_unavailable, with what is held and what is needed); npm run proxy-buy:reconcile runs it by hand (-- --list only lists).
Before deploying (none of this is done yet):
SOLANA_RPC_URL must be a paid RPC endpoint whose history reaches back at least as far as the oldest purchase still open (days, not hours): refunds depend on getSignatureStatuses with searchTransactionHistory, getSignaturesForAddress at finalized commitment, getFirstAvailableBlock and getTransaction for version 1 transactions. The public endpoint (api.mainnet-beta.solana.com) is rate-limited and not meant for production traffic: under its limits reads answer 429, the answers stay "cannot tell yet" (rpc_rate_limited) and the reconciler reports them. With too little history nothing is called dead either, and that is reported too.*/30 in vercel.json), and Hobby runs a cron at most daily; Hobby is also for non-commercial use only.DATABASE_URL), for example Neon through the Vercel Marketplace.proxy-alerts read comes 2 minutes after each run, so both share one wake-up of about 7 minutes per half hour: about 170 hours awake a month, about 42 CU-hours at the smallest size (0.25 CU). A free quote (GET /v1/buy with no payment) does not wake the database: its rate limit is kept in memory, and with the refund for an undelivered answer on it reads the held sellers only in the minutes after a cron run, when the database is awake anyway. Usage counting (pc_usage, for /v1/check and free quotes) keeps its counts in memory and writes them only while the database is awake anyway (up to 6 minutes after a cron run; never because of its own earlier write, which would keep the database awake by itself), one write at a time, so it never wakes it; counts of an instance that stops before then are lost, so the table can only undercount. What still wakes the database between cron runs: a paid request, a record read (/v1/buy/records/<id>), and the first request of a new function instance (it checks the tables once). Each such wake-up costs about 5 minutes, about 0.02 CU-hours. These are estimates at 0.25 CU. Neon's own count (compute time per project, from the Neon API with a key the operator issues) would also see the short wake-ups; it is not read yet.Wallets and caps:
ALERT tempo_base_fee_high when Tempo's base fee passes 3 gwei (0.6 gwei on 2026-10-01): refunds are signed and bounded at a 12 gwei cap, and the 0.002 seller-payment reserve covers a payer's first transaction up to about 7 gwei. A Tempo payment from one of vet402's own wallets (the receive wallet, the proxy payer, the census payer) is refused before any charge (payer_is_vet402).insufficient_balance says that figure.VET402_PROXY_BUY_ENABLED=1. Tempo is off unless VET402_PROXY_TEMPO_ENABLED=1 as well: without it no Tempo price is offered and a Tempo payment gets 503 tempo_disabled (Solana first).Run locally:
npm run proxy-buy -- --check # prints the configuration (addresses and caps; never keys or the database URL) and the allowlist size
npm run proxy-buy # serves on PORT (default 8402), 127.0.0.1 unless HOST is set
| Variable | What |
|---|---|
VET402_PROXY_BUY_ENABLED | 1 to buy; anything else answers 503 and does nothing |
VET402_PROXY_PUBLIC_ORIGIN | public origin, for record links |
DATABASE_URL | Postgres connection string (set by the Neon integration on Vercel; POSTGRES_URL is also read) |
VET402_PROXY_SOLANA_RECEIVE, VET402_PROXY_SOLANA_PAYER, VET402_PROXY_SOLANA_PAYER_KEY | Solana receive address, payer address, payer key (64-byte JSON array) |
SOLANA_RPC_URL, VET402_PROXY_FACILITATOR_URL | Solana RPC; facilitator for the agent's payment (default PayAI) |
VET402_PROXY_TEMPO_RECEIVE, VET402_PROXY_TEMPO_PAYER, VET402_PROXY_TEMPO_PAYER_KEY | Tempo receive address, payer address, payer key (0x, 32 bytes) |
VET402_PROXY_MPP_SECRET, TEMPO_RPC_URL | secret that binds MPP challenges (32+ characters); Tempo RPC |
VET402_PROXY_SOLANA_DAILY_CAP, VET402_PROXY_TEMPO_DAILY_CAP | daily caps, e.g. 2.00 |
VET402_PROXY_SOLANA_REFUND_DAILY_CAP, VET402_PROXY_TEMPO_REFUND_DAILY_CAP | refund room caps: the most the day's open purchases may hold for refunds at once (default: the daily cap) |
VET402_PROXY_TEMPO_ENABLED | 1 to take Tempo payments; default off |
VET402_PROXY_SOLANA_NOT_DELIVERED_REFUND_DAILY_CAP, VET402_PROXY_SOLANA_NOT_DELIVERED_REFUND_MONTHLY_CAP | the refund for an undelivered answer on Solana: most refunded this way per UTC day and per UTC month, e.g. 2.00 and 20.00 (at most those). Both unset: off (the default). One without the other: refused at start |
CRON_SECRET | Vercel Cron's bearer secret for api/reconcile.ts, 16+ characters: a shorter one is refused, every cron call gets 403, the reconciler does not run and the Mac's runner reports it late |
VET402_PROXY_ALERTS_SECRET | a separate, read-only bearer secret for api/alerts.ts (16+ characters); the Mac's runner holds this one, never the cron's. The same value as CRON_SECRET is refused (503) |
VET402_PROXY_TRUST_PROXY | local server (npm run proxy-buy) only, default off: the client address is then the socket's. Set 1 only behind a reverse proxy that sets x-real-ip. Not read on Vercel, where the platform sets x-real-ip itself |
Keys are read from the environment only and never printed; an error about a key never quotes it.
ALERTs and clearing them up:
ALERT the reconciler says is also kept in Postgres (pb_alert: one row per purchase and kind, with the reason, when it was first and last seen and how often); one that no longer holds on the next full look is resolved (a look cut short by a run's own read budget leaves it open). GET /api/alerts with Authorization: Bearer $VET402_PROXY_ALERTS_SECRET (compared in constant time) returns the open ones and when the reconciler last ran in full (reconcilerLastRunAt, from pb_state), with no keys and no RPC URLs.proxy-alerts job (below) reads them at :02 and :32 every hour, two minutes after each cron run, and writes each new one, and each one still open a day later, as one line to the alert file with a macOS notification. It also says so, once and then daily, when this month's database time, projected to the month's end, passes 80 CU-hours (each read records when the database last woke up, pg_postmaster_start_time() from api/alerts; a wake-up that starts and ends between two reads is not seen, so the estimate can only undercount, but a database that never stops is seen at once), when the database does not say when it woke up, when the reconciler has not run in full for 95 minutes (three cron intervals and one run's time, so two missed runs in a row do not raise it; the cron stopped) and when the read fails (no answer, not 200, not JSON, no alerts list); and once when its settings are missing, when its script is missing, when a previous run still holds its lock, and when it reaches its end day. It never stops quietly.npm run proxy-buy:resolve -- list shows the open alerts and the purchases behind them. Then, with a reason every time:
recheck <id>: look again now (one reconcile run), for example after the RPC came back;reopen-refund <id> --reason "..." [--to <address>]: a refund that is stuck (every sent attempt proven dead, or it could never be sent) may be sent again, to another address if the first cannot receive it;settle <id> --reason "..." --spent <atomic> [--refund-tx <signature>]: close it with what a person found out (for example a refund sent by hand), giving back what the purchase reserved minus --spent and minus the Tempo network fees already recorded with it (facts.fees, shown by list: leave them out of --spent); for a delivered purchase whose seller payment stays unseen, it marks that payment as seen. Refused while a sent refund can still land (sending, unknown: recheck until the reconciler decides it), when the refund was sent (the reconciler closes it), and when the purchase or its refund changed in the last ten minutes. The purchase (locked first) and the refund row change in one transaction and only as they were read; with no refund row yet, settle takes the purchase's single refund row itself, and the reconciler claims a refund only after reading the purchase again under its lock. A reconciler that starts a refund meanwhile wins, or finds the purchase closed; nothing is sent twice. A look by the reconciler that changes nothing (a refund still refused or still failing) does not move the purchase's last-change time, so the ten minutes can pass. Without --refund-tx the refund row is closed with the purchase (closed), not left open;note <id> --reason "...": only a note on its alerts.Code: src/proxy-buy/, api/, scripts/proxy-buy-serve.ts, scripts/proxy-buy-reconcile.ts, scripts/proxy-buy-resolve.ts, scripts/daily/proxy-alerts.ts. Tests: test/proxy-buy.test.ts (mock sellers, a fake facilitator, fake chains, the real mppx server and client, and an in-process Postgres: success, no delivery, a seller that never settles vet402's payment, price raised, payTo moved, the same payment ten times at once or re-encoded or on another instance, a reused settlement, caps, balance and a wallet drained from outside, a process stopped half way and while refunding, refunds and their caps, error text kept out of answers), test/proxy-buy-review2.test.ts to test/proxy-buy-review10.test.ts (what reviews found: a flood of signatures, a seller naming an earlier settlement, concurrent purchases at one price, a transaction with no expiry, undecidable rows, an off-curve refund owner, an RPC node behind the others, a seller payment landing after its purchase closed, a blockhash the node does not know yet, a seller naming the blockhash, a last valid height a few blocks off, version 1 transactions including a real mainnet one, a pruned history, durable nonces, a listing that skips the window, the ALERT flow and the runner, settling by hand, an agent that settles its own payment first), test/proxy-buy-not-delivered.test.ts (the refund for an undelivered answer: which answers are refunded and which are not, the amount, the caps per day and month, one per paying address per day, the seller held until it delivers again, never to the seller's own payTo, one refund per purchase under concurrency, a refund that fails to send staying owed for the reconciler, the wallet books, and nothing changed with the caps unset), test/proxy-buy-pg.test.ts (a real Postgres server with concurrent connections, when PROXY_BUY_TEST_PG_URL is set) and test/proxy-buy-e2e.test.ts (the same over real sockets on 127.0.0.1).
scripts/daily/run.sh runs the remeasure purchases, the daily records and their publishing in one fixed order, from launchd on the machine that holds the keys. Every step that can stop does stop: the run pays nothing more, writes one line to the alert file and shows a notification. A stop after money could have moved also leaves HALT-<lane> in the state folder, and that lane does nothing until a person has looked and removed it.
| Job | Time (JST) | What |
|---|---|---|
am | 10:17 | Solana, then Tempo: dry run, pay only within the run cap, the month and the balance (slots already in the spend ledger left out), then publish |
pm | 22:17 | Solana, the second purchase per payTo, then publish |
records | 09:05 | every closed UTC day with purchases whose root is not anchored or not published, oldest first, at most 3: build, verify each record, publish-records, anchor (once), publish. A dry run until ~/.config/vet402-daily/records-enabled exists |
board | 19:05 | kzmttkc/vet402-algorand: when today's board/<UTC day>.json on main has no completedAt and no board run is queued or running, starts board.yml (mode=daily) once |
proxy-alerts | :02 and :32 every hour | reads the deployed proxy buy's open ALERTs (VET402_PROXY_ALERTS_URL and VET402_PROXY_ALERTS_SECRET in the env file; unset: says so once) and writes each new one, or one still open a day later, to the alert file with a notification. Its own lock: it never waits on or blocks a paying run. Pays nothing |
publish | by hand | publishes today's results again without paying, after a stop that a person resolved |
catchup | :12 and :42 every hour | runs once a scheduled am, pm, records or board run that stopped for lack of network, when the network answers again on the same UTC day (am and pm only inside the pay window), that mode has not ended well that day, and no other daily run holds the lock. With nothing missed it does nothing. Its own lock; pays nothing itself |
Both purchase times are inside one UTC day (01:17 and 13:17 UTC). The jobs have no end date: am, pm and publish go on until VET402_DAILY_END=YYYY-MM-DD is set in ~/.config/vet402-daily/env (nothing runs from that JST day); set VET402_RECORDS_END to the day after it, so the last purchase day is still recorded, published and anchored the next morning; VET402_BOARD_END stops board; proxy-alerts stops from 2026-12-31 (VET402_PROXY_ALERTS_END). An end that is not a date stops the job with an alert line. What bounds the money is the caps, not a date: per run, per day and per calendar month (src/remeasure/constants.ts). A run that reaches the month cap buys what fits, publishes it, and writes one notice line (not a stop, no HALT); after that the dry runs find nothing that fits and nothing is bought until the first of the next month (UTC). If a records run fails, nothing retries that day on its own: run ~/vet402-solana/scripts/daily/run.sh records once by hand after fixing the cause. The one exception is a missing network, below.
No network: before git fetch, am, pm, records, board and publish check that github.com and the RPC hosts the mode reads (Solana: the host of SOLANA_RPC_URL or api.mainnet-beta.solana.com; Tempo: rpc.tempo.xyz for am, and for records with the Tempo anchor on) resolve and answer over HTTPS. Only the host is asked, never a path or a query. Without an answer the run waits, with one log line, checking every VET402_NET_WAIT_STEP seconds (default 60) for up to VET402_NET_WAIT_MAX seconds (default 1800, wall clock: time the Mac spends asleep counts). If the network comes back, the run goes on; am and pm first check the pay window again, and a wait that ends outside it pays nothing. If it does not, the run stops with one alert line starting no network: (nothing ran, no HALT), and a scheduled mode leaves missed-<mode>-<UTC day> in the state folder for the catchup job. A run that ends well leaves done-<mode>-<UTC day> and removes its marker. A marker whose UTC day passed without a catch-up is removed with one notice line. proxy-alerts does not wait.
Publishing: each result file is copied into data/ through the secret gate (scripts/daily/secret-gate.ts), data/manifest.json is updated, rank and site are rebuilt, the tree is scanned again, typecheck and npm test run, and one commit with data/ and site/ only is checked against the pre-push review gate and pushed. A dry run (--dry-run) does the same with a made-up copy of the newest day dated today, and never pushes.
After the runner is on main:
git -C ~/vet402-solana pull --ff-only origin main
mkdir -p ~/.config/vet402-daily
printf 'VET402_ALERTS_FILE=%s\n' /path/to/ALERTS.md > ~/.config/vet402-daily/env # required: without it nothing runs
cp ~/vet402-solana/scripts/daily/launchd/launch.sh ~/.config/vet402-daily/launch.sh
env -i HOME="$HOME" PATH=/usr/bin:/bin ~/vet402-solana/scripts/daily/run.sh am --dry-run # read the log in ~/Library/Logs/vet402-daily/
cp ~/vet402-solana/scripts/daily/launchd/com.vet402.daily.*.plist ~/Library/LaunchAgents/
for j in am pm records board proxy-alerts catchup; do launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.vet402.daily.$j.plist; done
launchctl list | grep com.vet402.daily
The plists start ~/.config/vet402-daily/launch.sh, which lives outside the checkout: if run.sh is missing it still writes the alert line and the notification. The records job sends anchors only after touch ~/.config/vet402-daily/records-enabled, once a person has run run.sh records by hand and read its log.
anchor-receipts --day <day> --post-root --send, signed and paid by the posting key .keys/mainnet/roots-poster.json, never by the purchase wallet; see solana-program/README.md). A day already posted is not sent again. The posting key's balance is read first: below one day's rent and fee plus the empty account's rent-exempt minimum, nothing is signed and the alert file gets one line. Any failure is one alert line starting records: post_root failed, publish continues: (a failed Tempo anchor l