Receipts & Proof-of-Serve
— Every answer is signed. Here is the exact scheme so you can verify it yourself.
A proof-of-serve receipt is an ed25519 signature over a canonical digest of your query and its answer, bound to the slot it was resolved at. It lets anyone confirm — offline, without trusting Pluid — that a specific answer was served at a specific slot by a key the network publishes.
The Receipt
Every receipt-bearing response includes a receipt object:
{ "query": "getBalance:5tzFki…uAi9", "answerDigest": "9f1c0b…ab", "slot": 296410233, "issuedAt": 1718900000000, "sig": "7c0a3e…ff" }
| Field | Type | Description |
|---|---|---|
| query | string | Stable identifier for the read, e.g. getBalance:<addr>. |
| answerDigest | hex | SHA-256 of the canonical encoding of the answer. |
| slot | int | Slot the answer was resolved at — the freshness anchor. |
| issuedAt | epoch ms | When the receipt was signed. |
| sig | hex | ed25519 signature over the canonical signed payload (below). |
Canonical JSON
The signer and verifier must produce identical bytes, so both use a deterministic encoding with sorted object keys. Key order is the only footgun — get it right and the rest is mechanical.
- Primitives encode as standard JSON (
JSON.stringify). - Arrays encode element-wise, in order.
- Objects encode with keys sorted ascending, each value encoded recursively.
function canonical(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return "[" + value.map(canonical).join(",") + "]";
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(obj[k])).join(",") + "}";
}How a Receipt Is Built
On the server, for an answer at a given slot:
// 1. Digest the answer with canonical JSON.
const answerDigest = hex(sha256(utf8(canonical(answer))));
// 2. Assemble the signed payload (no signature yet).
const payload = { query, answerDigest, slot, issuedAt: Date.now() };
// 3. Sign the canonical bytes of the payload.
const sig = hex(ed25519.sign(utf8(canonical(payload)), SECRET_KEY));
// 4. The receipt is the payload plus the signature.
const receipt = { ...payload, sig };canonical({ query, answerDigest, slot, issuedAt }) — the answer enters via its digest, not inline. To verify, you recompute the digest from the answer you received, rebuild the payload, and check the signature.The Public Key
Fetch the network signing key from /api/v1/pubkey. The response is bespoke (no envelope):
curl https://pluid.net/api/v1/pubkey{ "publicKey": "7c0a…ff", "algorithm": "ed25519" }
Pin this value in production to make verification a pure offline computation.
Verifying Independently
Any stack with SHA-256 and ed25519 can verify a receipt. Here it is in TypeScript with no Pluid dependency:
import * as ed from "@noble/ed25519";
import { sha256, sha512 } from "@noble/hashes/sha2.js";
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
ed.hashes.sha512 = sha512; // enable sync verify
export function verifyReceipt(
receipt: { query: string; answerDigest: string; slot: number; issuedAt: number; sig: string },
answer: unknown,
publicKeyHex: string
): boolean {
// 1. Recompute the answer digest and confirm it matches the receipt.
const digest = bytesToHex(sha256(utf8ToBytes(canonical(answer))));
if (digest !== receipt.answerDigest) return false;
// 2. Rebuild the signed payload and check the signature.
const { sig, ...payload } = receipt;
const msg = utf8ToBytes(canonical(payload));
return ed.verify(hexToBytes(sig), msg, hexToBytes(publicKeyHex), { zip215: false });
}What a Valid Receipt Proves
- The answer was not altered after signing (digest match).
- A Pluid network key signed it (signature against the published public key).
- It was resolved at
receipt.slot— freshness is explicit, not assumed.
