Proof
Receipt Verification
Verify a receipt's hash, signature, Merkle inclusion and content fingerprints without trusting the platform.
What verification proves
- Integrity. The payload hashes to
receipt_hash, so no field has been changed. - Authenticity. The signature recovers to the platform's receipt signer, so the platform issued it.
- Existence in time (when anchored). The hash is included in a Merkle root recorded on Robinhood Chain at a known block time, so the receipt could not have been created or altered afterwards. Beta
- Content (with the salt). A specific input or output is exactly what the operation processed.
Steps 1 and 2 need only the public payload. Do them yourself rather than relying on the verification result the platform reports.
Public proof page
Every receipt has a public page at https://www.hushcompute.xyz/proofs/{receipt_id}, linked from receipt.url in API responses. It shows the payload, re-verifies the hash, signature and signer on each view, and shows the anchoring batch and inclusion proof when one exists. No account is needed.
Proof JSON API
Served from the web application at https://www.hushcompute.xyz, not the API host. No authentication is required, any origin may call it, and responses are cacheable for 15 seconds. Lookups are rate limited to 240 per minute per client; excess requests receive HTTP 429 with retry-after: 30. Unknown or malformed receipt IDs return HTTP 404 not_found.
curl https://www.hushcompute.xyz/api/proofs/rcpt_01K58ZQ4W6N3T2B9XRC7D1M5HJ{
"object": "receipt",
"id": "rcpt_01K58ZQ4W6N3T2B9XRC7D1M5HJ",
"receipt_hash": "0x6f0d…a3c9",
"signature": "0x9b1f…c07e1b",
"signer_address": "0x3A4c…91De",
"payload": { "version": "1", "receipt_id": "rcpt_01K58ZQ4W6N3T2B9XRC7D1M5HJ", "…": "…" },
"created_at": "2026-09-14T12:00:02.141Z",
"verification": {
"valid": true,
"checks": {
"payload_well_formed": true,
"hash_matches": true,
"signature_valid": true,
"signer_trusted": true
},
"recovered_address": "0x3A4c…91De",
"reason": null
},
"anchor": {
"batch_id": "batch_01K5902A7C9E2G4J6L8N1Q3S5U",
"status": "anchored",
"merkle_root": "0xd2e8…17af",
"leaf_index": 41,
"proof": ["0x0b93…e2c4", "0x7a15…40d9", "0xf3c0…8b61"],
"proof_verified": true,
"receipt_count": 318,
"chain_id": 4663,
"contract_address": "0x5B1d…A7e0",
"tx_hash": "0x4e77…c9b2",
"block_number": "1849302",
"onchain_batch_id": "57",
"anchored_at": "2026-09-14T12:10:05.000Z"
},
"trusted_signers": ["0x3A4c…91De"]
}| Field | Type | Description |
|---|---|---|
| receipt_hash | string | keccak256 of the canonical payload, as stored. |
| signature | string | 65-byte EIP-191 signature, 0x-hex. |
| signer_address | string | The address that claims to have signed. |
| payload | object | The full signed payload. See Receipts for every field. |
| created_at | string | When the receipt was stored. |
| verification | object | The server's re-verification: valid, checks (payload_well_formed, hash_matches, signature_valid, signer_trusted), recovered_address and a reason when invalid. |
| anchor | object | null | null until the receipt is assigned to a batch. Otherwise batch_id, status (built, submitted, anchored, failed), merkle_root, leaf_index, proof (sibling hashes), proof_verified, receipt_count, chain_id, contract_address, tx_hash, block_number, onchain_batch_id and anchored_at. |
| trusted_signers | string[] | The signer addresses this deployment trusts. |
Verify locally
Local verification has three steps:
- Serialize
payloadas canonical JSON: keys sorted, no whitespace, integers only. - Compute
keccak256of its UTF-8 bytes and compare withreceipt_hash. - Recover the EIP-191 signer from the signature over the raw 32 hash bytes and compare with a trusted signer address.
TypeScript
Uses viem (opens in a new tab). The canonicalization function is complete; it matches the platform's implementation, including rejection of floats and undefined values.
/** Canonical JSON exactly as the platform produces it (a strict subset of RFC 8785). */
export function canonicalize(value: unknown): string {
if (value === null) return "null";
switch (typeof value) {
case "string":
return JSON.stringify(value);
case "boolean":
return value ? "true" : "false";
case "number":
if (!Number.isSafeInteger(value)) throw new Error("numbers must be safe integers");
return Object.is(value, -0) ? "0" : String(value);
case "object": {
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
const record = value as Record<string, unknown>;
const members = Object.keys(record)
.sort() // UTF-16 code unit order
.map((key) => {
if (record[key] === undefined) throw new Error(`undefined value at ${key}`);
return `${JSON.stringify(key)}:${canonicalize(record[key])}`;
});
return `{${members.join(",")}}`;
}
default:
throw new Error(`unsupported type ${typeof value}`);
}
}Python
Uses eth_account (which installs eth_utils). json.dumps(..., ensure_ascii=False) escapes strings the same way as JavaScript's JSON.stringify, and sorting keys by UTF-16 bytes reproduces JavaScript's key order.
# pip install eth-account
import json
import urllib.request
from eth_account import Account
from eth_account.messages import encode_defunct
from eth_utils import keccak
MAX_SAFE_INTEGER = 2**53 - 1
# Pin the platform's published receipt signer address(es) in your own configuration.
TRUSTED_SIGNERS = {
# "0x…".lower(),
}
def canonicalize(value) -> str:
"""Canonical JSON exactly as the platform produces it."""
if value is None:
return "null"
if isinstance(value, bool): # check before int: bool is a subclass of int
return "true" if value else "false"
if isinstance(value, int):
if abs(value) > MAX_SAFE_INTEGER:
raise ValueError("numbers must be safe integers")
return str(value)
if isinstance(value, float):
raise ValueError("floats are not allowed")
if isinstance(value, str):
# Matches JSON.stringify: literal non-ASCII, lowercase \u00xx control escapes.
return json.dumps(value, ensure_ascii=False)
if isinstance(value, list):
return "[" + ",".join(canonicalize(item) for item in value) + "]"
if isinstance(value, dict):
# Sort by UTF-16 code units, as JavaScript does.
keys = sorted(value.keys(), key=lambda k: k.encode("utf-16-be"))
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonicalize(value[k]) for k in keys) + "}"
raise TypeError(f"unsupported type {type(value).__name__}")
def verify_receipt(receipt_id: str) -> dict:
with urllib.request.urlopen(f"https://www.hushcompute.xyz/api/proofs/{receipt_id}") as response:
proof = json.load(response)
# 1. Recompute the receipt hash from the canonical payload.
computed_hash = "0x" + keccak(canonicalize(proof["payload"]).encode("utf-8")).hex()
if computed_hash != proof["receipt_hash"].lower():
raise ValueError("receipt hash does not match the payload")
# 2. Recover the signer of the EIP-191 signature over the raw 32-byte hash.
message = encode_defunct(primitive=bytes.fromhex(computed_hash[2:]))
recovered = Account.recover_message(message, signature=proof["signature"])
if recovered.lower() != proof["signer_address"].lower():
raise ValueError("signature was not produced by signer_address")
# 3. Check the signer against addresses you trust independently.
if recovered.lower() not in TRUSTED_SIGNERS:
raise ValueError(f"signer {recovered} is not a trusted receipt signer")
return proof["payload"]Merkle inclusion
When anchor is present, prove that the receipt hash is a leaf of the batch root. Leaves and nodes are domain-separated with a 0x00 or 0x01 prefix, pairs are sorted before hashing, and odd nodes are promoted without a sibling. The proof therefore lists only the siblings that exist on the path.
import { concat, keccak256, type Hex } from "viem";
const lower = (value: string) => value.toLowerCase() as Hex;
/** leaf = keccak256(0x00 ‖ receipt_hash) */
function merkleLeaf(receiptHash: Hex): Hex {
return keccak256(concat(["0x00", lower(receiptHash)]));
}
/** node = keccak256(0x01 ‖ min(a, b) ‖ max(a, b)) */
function merkleNode(a: Hex, b: Hex): Hex {
const [left, right] = lower(a) <= lower(b) ? [lower(a), lower(b)] : [lower(b), lower(a)];
return keccak256(concat(["0x01", left, right]));
}
/** Folds the sibling hashes from anchor.proof into the root. Sorted pairs need no left/right flags. */
export function verifyInclusion(root: Hex, receiptHash: Hex, proof: readonly Hex[]): boolean {
let computed = merkleLeaf(receiptHash);
for (const sibling of proof) computed = merkleNode(computed, sibling);
return computed === lower(root);
}
// With the proof JSON from /api/proofs/{receipt_id}:
// verifyInclusion(proof.anchor.merkle_root, proof.receipt_hash, proof.anchor.proof) === trueOn-chain anchor
An inclusion proof is meaningful only if the root was published. When anchor.status is anchored, read the anchor timestamp from the ReceiptAnchor contract on Robinhood Chain. You can also open anchor.tx_hash in the block explorer (opens in a new tab) and inspect the ReceiptBatchAnchored event.
import { createPublicClient, defineChain, http, parseAbi, type Address, type Hex } from "viem";
const robinhoodChain = defineChain({
id: 4663,
name: "Robinhood Chain",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: ["https://rpc.mainnet.chain.robinhood.com"] } },
});
const receiptAnchorAbi = parseAbi(["function anchoredAt(bytes32 root) view returns (uint64)"]);
export async function anchoredAt(anchor: { chain_id: number; contract_address: Address; merkle_root: Hex }) {
if (anchor.chain_id !== robinhoodChain.id) throw new Error(`unexpected chain ${anchor.chain_id}`);
const client = createPublicClient({ chain: robinhoodChain, transport: http() });
const timestamp = await client.readContract({
address: anchor.contract_address,
abi: receiptAnchorAbi,
functionName: "anchoredAt",
args: [anchor.merkle_root],
});
if (timestamp === 0n) throw new Error("this root has not been anchored");
return new Date(Number(timestamp) * 1000);
}Proving content with the salt
To prove what an operation processed, rebuild the commitment exactly as described in Receipts and hash it with the salt from billing.fingerprint_salt:
import { concat, hexToBytes, sha256, stringToBytes, type Hex } from "viem";
import { canonicalize } from "./canonicalize";
function fingerprint(salt: Hex, content: string): Hex {
return sha256(concat([hexToBytes(salt), stringToBytes(content)]));
}
// Values you kept from the original request and response.
const salt = billing.fingerprint_salt as Hex;
const input = canonicalize({
messages: [
{ role: "system", content: "You are a concise assistant." },
{ role: "user", content: "Explain what a signed receipt proves in two sentences." },
],
tools: [],
});
const output = canonicalize({
content: completion.choices[0].message.content ?? "",
tool_calls: [],
});
console.log("input matches:", fingerprint(salt, input) === payload.input_fingerprint);
console.log("output matches:", fingerprint(salt, output) === payload.output_fingerprint);- Normalize messages the way the gateway does:
developerbecomessystem, the usernameis dropped, content parts are joined, and assistant messages always carry atool_callsarray. - Tools are committed as
{"name":…,"description":…}only. Withtool_choice: "none"the list is empty. - Share the salt only with parties who should be able to check the content. Anyone holding the salt can test guesses against the public fingerprint.
Verifying a run chain
For agent runs, verify each receipt individually, then check that every previous_receipt_hash points at the preceding receipt and recompute the chain head:
import { concat, keccak256, type Hex } from "viem";
interface RunReceipt {
receipt_hash: Hex;
payload: { run: { run_id: string; sequence: number; previous_receipt_hash: Hex | null } | null };
}
/** Checks the links between a run's verified receipts and returns the chain head. */
export function verifyRunChain(receipts: RunReceipt[]): Hex {
const ordered = [...receipts].sort((a, b) => a.payload.run!.sequence - b.payload.run!.sequence);
let previous: Hex | null = null;
let head: Hex = `0x${"00".repeat(32)}`;
for (const receipt of ordered) {
const run = receipt.payload.run;
if (!run || run.run_id !== ordered[0]!.payload.run!.run_id) throw new Error("receipt is not part of this run");
if (run.previous_receipt_hash !== previous) throw new Error(`broken link at sequence ${run.sequence}`);
head = keccak256(concat([head, receipt.receipt_hash]));
previous = receipt.receipt_hash;
}
return head; // compare with the run's receipt head shown in the console
}