Verify AI receipts

Every AI request is hashed into a Merkle tree whose root is written on Solana.

Every AI request a coin pays for gets a receipt. Finished requests are hashed into a Merkle tree, and the tree's root is written on Solana in a memo transaction. Anyone can take one request and prove that it is in a root that was published on-chain, so the record cannot be quietly changed or removed later.

Sealing happens in the background about once a minute. It never delays an answer.

What is recorded#

FieldMeaning
idThe request number (the request_id you see in chat and in the API).
mintThe coin whose AI vault paid.
actorWho caused it: holder, creator_key, mind, x402 or tool.
provider, modelThe AI provider and the model used.
inputTokens, outputTokensTokens reported by the provider.
costMicroUsdcWhat was paid, in millionths of a USDC (1000 = $0.001).
paymentSigThe Solana transaction of the USDC payment, when the provider returned one.
requestHashSHA-256 of the request (coin, actor, model, messages, time and a secret nonce).
responseHashSHA-256 of the answer (model, text, tool calls, token usage).
errorWhy it failed, if it did. Failed requests are sealed too.
createdAtWhen it started (ISO time).

A receipt does not contain your wallet, your API key or your messages. The request hash includes a nonce derived from a server secret that is not published, so nobody can guess a short prompt by hashing candidates.

How the tree is built#

These are the exact rules in src/lib/ai/merkle.ts, which both the server and the /verify page use.

Canonical JSON

Object keys are sorted (at every level), there is no whitespace, missing fields are dropped and big numbers are written as strings. The leaf object always has exactly these 14 keys:

Leaf object
{ v: 1, id, mint, actor, provider, model, inputTokens, outputTokens,
  costMicroUsdc (string), paymentSig (or null), requestHash,
  responseHash (or null), error (or null), createdAt }

Hashing

text
leaf = sha256( 0x00 || utf8(canonicalJson(leafObject)) )
node = sha256( 0x01 || left || right )
  • The 0x00 / 0x01 prefix keeps a leaf from ever being mistaken for a node.
  • Leaves are ordered by request id. Pairs are not sorted: left is always the lower position.
  • If a level has an odd number of nodes, the last one moves up to the next level unchanged.
  • A tree with one leaf has that leaf as its root.
  • A proof is the list of sibling hashes from the leaf up, each marked with the side it sits on.

The memo on Solana#

About every 60 seconds a background job takes up to 1,000 finished requests that are not sealed yet (from all coins, lowest id first), builds the tree and sends one Memo-program transaction, paid by the platform's fee wallet. The memo text is:

text
pairpad:ai:v1:<rootHex>:<fromId>-<toId>:<count>
  • rootHex: the Merkle root, 64 hex characters.
  • fromId / toId: the lowest and highest request id in the batch. Ids in between that were still running are not in it; count says how many requests are.
  • A request is sealed once it has an answer or an error, or after 10 minutes if it never finished.
  • A memo that fails or is not confirmed is sent again on a later run. A sealed request is never changed.

Verify a request in your browser#

  1. Find the request id

    Every chat answer has a receipt link, and every API response has pairpad.request_id and pairpad.receipt_url.
  2. Open /verify

    Go to /verify and enter the id (or open /verify?id=1234).
  3. Read the checks

    Your browser recomputes the leaf from the public fields, folds the proof, and checks that it reaches the root and that the memo text carries that root and covers this id.
  4. On-chain check

    The page then reads the memo transaction straight from a Solana RPC (not from this site) and checks that it succeeded, carries exactly this memo and was paid by the platform's fee wallet. You can type another RPC address and check again.
  5. Your own request (optional)

    If you are signed in on this site with the wallet that made the request (for API key requests: the creator wallet), the page also shows the request's time and nonce, and recomputes the request hash from the messages you paste.

Note

A request that is not sealed yet shows “Not in a batch yet”. Try again a minute later.

Verify it yourself#

The receipt API is public (60 requests per minute per IP). It returns the request's public fields, its leaf, its proof, the batch root, the memo text, the memo transaction and the address that pays for and signs every memo (memoSigner, the platform's fee wallet).

bash
curl -s https://pairity.wtf/api/ai/receipts/1234
Response (example, shortened)
{
  "request": {
    "id": 1234, "mint": "<MINT>", "actor": "creator_key", "provider": "blockrun",
    "model": "deepseek/deepseek-chat", "inputTokens": 14, "outputTokens": 42,
    "costMicroUsdc": "1000", "paymentSig": "5Kq…", "requestHash": "9f2c…",
    "responseHash": "77ab…", "error": null, "createdAt": "2026-10-19T12:00:00.000Z"
  },
  "leaf": "c41e…",
  "batchId": 88,
  "proof": [{ "hash": "a1b2…", "side": "right" }, { "hash": "d4e5…", "side": "left" }],
  "root": "e3b0…",
  "memo": "pairpad:ai:v1:e3b0…:1201-1290:85",
  "memoSig": "3xT…",
  "memoSigner": "<platform fee wallet>",
  "status": "confirmed",
  "explorer": "https://solscan.io/tx/3xT…"
}

status is unbatched before sealing, then the batch's pending, sent, confirmed or failed. mismatch would mean the stored requests no longer produce the stored root.

verify.mjs (Node 18+)
import { createHash } from 'node:crypto'

const id = process.argv[2]
const r = await (await fetch('https://pairity.wtf/api/ai/receipts/' + id)).json()

// canonical JSON: sorted keys, no whitespace, undefined dropped
const canon = (v) =>
  v === null ? 'null'
  : Array.isArray(v) ? '[' + v.map(canon).join(',') + ']'
  : typeof v === 'object'
    ? '{' + Object.keys(v).filter((k) => v[k] !== undefined).sort()
        .map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',') + '}'
  : JSON.stringify(v)
const sha = (...parts) => { const h = createHash('sha256'); for (const p of parts) h.update(p); return h.digest() }

const q = r.request
const leafObj = {
  v: 1, id: q.id, mint: q.mint, actor: q.actor, provider: q.provider, model: q.model,
  inputTokens: q.inputTokens, outputTokens: q.outputTokens, costMicroUsdc: String(q.costMicroUsdc),
  paymentSig: q.paymentSig ?? null, requestHash: q.requestHash, responseHash: q.responseHash ?? null,
  error: q.error ?? null, createdAt: q.createdAt,
}
let h = sha(Buffer.from([0x00]), Buffer.from(canon(leafObj), 'utf8'))
console.log('leaf matches:', h.toString('hex') === r.leaf)

for (const s of r.proof) {
  const sib = Buffer.from(s.hash, 'hex')
  h = s.side === 'left' ? sha(Buffer.from([0x01]), sib, h) : sha(Buffer.from([0x01]), h, sib)
}
console.log('reaches root:', h.toString('hex') === r.root)
console.log('memo to find on-chain:', r.memo, r.explorer)

Then open the memo transaction on an explorer (or fetch it from any Solana RPC) and check three things yourself: it succeeded and is confirmed, its fee payer is memoSigner, and its memo text is exactly the one above (same root, and an id range that includes your request). That step does not rely on the platform at all.

Check your own request hash

If you made the request, call the receipt API with your session cookie (the wallet that made it; for API key requests, the creator wallet) or with the same API key (Authorization: Bearer …). The answer then also has a private object with ts and nonce, the inputs of requestHash besides your messages. Nobody else gets them.

text
requestHash = sha256( canonicalJson({ mint, actor, model, messages, ts, nonce }) )

messages is the list the server sent to the model, in its own shape: { role, content }, assistant tool calls as toolCalls: [{ id, name, arguments }] and tool answers with toolCallId. For an API request that is your own messages (a developer message becomes system, text parts are joined). In the coin page chat the server adds its own system prompt, so you can check the API case, not the chat case.

Check the answer you got (API users)

For an API request you can also recompute responseHash from the response: SHA-256 (no prefix) of the canonical JSON of { model, text, toolCalls, usage }, where text is the message content, toolCalls is a list of { id, name, arguments } and usage is { inputTokens, outputTokens }. Chat answers on the coin page are cleaned before they are shown (unsafe links removed), so they may not match.

What a receipt proves, and what it does not#

  • It proves that the request's record (cost, model, tokens, payment, hashes) existed when its root was published, and has not changed since.
  • Removing or changing any request of a published batch would make that batch's proofs stop reaching its on-chain root.
  • Check the payment separately: paymentSig is a normal Solana transaction; open it to see the USDC transfer from the coin's AI vault to the provider.
  • It does not prove what the model really computed. The provider's answer and token counts are recorded as received.