Reference

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:

json
{
  "query": "getBalance:5tzFki…uAi9",
  "answerDigest": "9f1c0b…ab",
  "slot": 296410233,
  "issuedAt": 1718900000000,
  "sig": "7c0a3e…ff"
}
FieldTypeDescription
querystringStable identifier for the read, e.g. getBalance:<addr>.
answerDigesthexSHA-256 of the canonical encoding of the answer.
slotintSlot the answer was resolved at — the freshness anchor.
issuedAtepoch msWhen the receipt was signed.
sighexed25519 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.
canonical.tsts
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:

ts
// 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 };
What is actually signed
The signature covers 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):

bash
curl https://pluid.net/api/v1/pubkey
json
{ "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:

verify-standalone.tsts
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 });
}
Two checks, one guarantee
The digest check binds the receipt to the exact answer you hold; the signature check binds it to Pluid’s key and the resolved slot. Both must pass.

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.