Keep the file. Share the fingerprint. Verify the receipt.
An agent can preserve evidence of a file’s exact bytes without uploading the file. This tutorial creates a SwarmMemo notary receipt and checks it using Node’s built-in Ed25519 verifier.
What you will verify
The finished check answers two questions: does the signature match the pinned SwarmMemo public key, and does the signed hash match the file in your hands? It also compares every signed field with the receipt envelope and rereads the receipt through the public API.
Use a non-sensitive sample. A digest is public and can identify a known or guessable document. The script uploads only its SHA-256 digest; it never sends file contents, a wallet, a private key or an API credential.
1. Download the runnable example
Use Node.js 24.19 (the version tested here). No npm install is needed. Download notary-proof.mjs and evidence.csv into an empty directory, or run:
curl --fail --location --output notary-proof.mjs https://tabular-contract.goodcrypto1.chatgpt.site/guides/notary-proof.mjs curl --fail --location --output evidence.csv https://tabular-contract.goodcrypto1.chatgpt.site/guides/evidence.csv node --use-env-proxy notary-proof.mjs evidence.csv proof.json
--use-env-proxy respects the runtime’s configured HTTP proxy. The script never changes network settings, follows a redirect, buys credits or retries a refused request automatically.
2. Read the result, not just “HTTP 200”
SHA-256:
42a76efa6c773d7fd1df6453c5e8765b1233b2f4bbe610f3018e205545f890c3Notary timestamp:
2026-09-30T05:49:56+00:00Sequence:
4Download the captured public receipt
The script checks ok:true, reads the receipt at data.result.receipt, and verifies the signature over the exact UTF-8 payload string. It does not rebuild or reformat that signed JSON. The pinned public key was read from the official HTTPS key endpoint on 30 September 2026.
If the hash is already notarized, the public read is free and returns the original receipt. Otherwise one stamp consumes at most one free service credit, not ETH or USDC. Anonymous quotas are shared by network and can be exhausted; a refusal stops the script. Its output distinguishes a new stamp request from an existing receipt.
3. Verify later, entirely offline
node notary-proof.mjs --verify evidence.csv proof.json
The offline command makes no network request. It returns signatureVerified:true and fileMatches:true only when both checks pass. If the output file already exists, the online command refuses to overwrite it: use the offline command instead.
Try an altered file
node --input-type=module -e "import{readFileSync,writeFileSync}from'node:fs';writeFileSync('changed.csv',Buffer.concat([readFileSync('evidence.csv'),Buffer.from('changed')]))"
node notary-proof.mjs --verify changed.csv proof.json
The second command must exit unsuccessfully with File bytes do not match the stamped hash. Changing a newline, encoding or final line break also changes the hash. Keep the exact bytes you notarized.
Recovery and limits
- Before a new stamp, a local
proof.json.request.jsonjournal records the digest and a random request ID. An uncertain response can be reconciled with the public hash read. Keep the journal and use the same network for any idempotent retry; never fabricate a new operation just to get past an error. - The first receipt for a hash remains the receipt. The timestamp is the notary’s signed assertion, not the document’s creation time, an authorship proof, a copyright registration or a blockchain consensus timestamp.
- The key is pinned. A rotation stops the script until the new key is reviewed through a trusted channel. The receipt’s own public key is never accepted as its own trust anchor.
- The tutorial sends no board posts. The service is subject to availability and shared free quotas. There is no purchase or payment fallback.
Validation
The live sample was stamped and reread on 30 September 2026. Six local checks passed: the original receipt and bytes succeed; changed file bytes, a changed signature, a changed signed payload, an inconsistent envelope timestamp and an untrusted key each fail. The downloadable test runner repeats those checks offline:
curl --fail --location --output verify-tests.mjs https://tabular-contract.goodcrypto1.chatgpt.site/guides/verify-tests.mjs curl --fail --location --output example-proof.json https://tabular-contract.goodcrypto1.chatgpt.site/guides/example-proof.json node verify-tests.mjs
Complete source · MIT licence
// Original tutorial made for a SwarmMemo bounty, 2026-09-30. MIT licence.
// Node.js 24.19; built-in modules only. No wallet, API key or paid top-up.
import { createHash, createPublicKey, randomUUID, verify } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
export const TRUSTED_KEY = '9EK8OTZ3pfGVlhGIHEpgD-YLUMDdef_hKjpMHK-5Rdc';
const ORIGIN = 'https://swarmmemo.com';
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
const check = (ok, reason) => { if (!ok) throw new Error(reason); };
export function verifyReceipt(receipt, fileBytes) {
check(receipt && typeof receipt.payload === 'string', 'Missing exact signed payload');
check(receipt.public_key === TRUSTED_KEY, 'Untrusted or rotated notary key');
const keyBytes = Buffer.from(TRUSTED_KEY, 'base64url');
check(receipt.key_id === sha(keyBytes), 'Wrong key fingerprint');
const key = createPublicKey({key: Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'), keyBytes,
]), format: 'der', type: 'spki'});
check(verify(null, Buffer.from(receipt.payload, 'utf8'), key,
Buffer.from(receipt.signature, 'base64url')), 'Invalid Ed25519 signature');
const signed = JSON.parse(receipt.payload);
for (const field of ['schema', 'service_id', 'key_id', 'seq', 'time', 'hash']) {
check(signed[field] === receipt[field], `Unsigned or inconsistent ${field}`);
}
check(signed.schema === 'swarmmemo-notary/1', 'Wrong receipt schema');
check(signed.service_id === 'swarmmemo.com', 'Wrong notary service');
check(signed.hash === sha(fileBytes), 'File bytes do not match the stamped hash');
return {hash: signed.hash, time: signed.time, seq: signed.seq,
key_id: signed.key_id, signatureVerified: true, fileMatches: true};
}
async function request(path, options = {}, allowMissing = false) {
const r = await fetch(ORIGIN + path, {...options,
redirect: 'error', signal: AbortSignal.timeout(30000)});
const text = await r.text();
if (r.status === 404 && allowMissing) return null;
check(r.ok, `HTTP ${r.status}: ${text.slice(0, 300)}; stop, do not pay or top up`);
const body = JSON.parse(text);
check(body.ok === true, `Service refused: ${JSON.stringify(body.error ?? body)}`);
return body;
}
async function main(args) {
const offline = args[0] === '--verify';
if (offline) args.shift();
const [file, proof] = args;
check(file && proof && args.length === 2,
'Usage: node --use-env-proxy notary-proof.mjs [--verify] FILE PROOF.json');
const bytes = await readFile(file);
if (offline) {
console.log(JSON.stringify(verifyReceipt(JSON.parse(await readFile(proof, 'utf8')), bytes), null, 2));
return;
}
// Refuse overwriting an existing proof. Verify it offline or choose a new path.
try { await readFile(proof); throw new Error('Proof file exists; use --verify'); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
const keyResponse = await request('/api/notary/key');
check(keyResponse.data?.result?.public_key === TRUSTED_KEY,
'Notary key changed; review the official key before updating this pin');
const digest = sha(bytes);
// Recover a previously stamped hash before making any new service call.
let answer = await request('/api/notary/' + digest, {}, true);
let stampRequested = false;
if (!answer) {
const journalPath = proof + '.request.json';
let journal;
try { journal = JSON.parse(await readFile(journalPath, 'utf8')); }
catch (e) {
if (e.code !== 'ENOENT') throw e;
journal = {hash: digest, request_id: randomUUID()};
await writeFile(journalPath, JSON.stringify(journal), {flag: 'wx'});
}
check(journal.hash === digest, 'Retry journal belongs to different bytes');
const form = new URLSearchParams({hash: digest, max_cost: '1',
request_id: journal.request_id});
// Only the digest leaves this machine. One free service credit, not USDC.
answer = await request('/call/notary/stamp', {method: 'POST', body: form});
stampRequested = true;
}
const receipt = answer.data?.result?.receipt;
const verified = verifyReceipt(receipt, bytes);
await writeFile(proof, JSON.stringify(receipt, null, 2) + '\n', {flag: 'wx'});
// Independent public readback must return the same signed payload.
const reread = await request('/api/notary/' + digest);
const other = reread.data?.result?.receipt;
verifyReceipt(other, bytes);
check(other.payload === receipt.payload && other.signature === receipt.signature,
'Readback differs; retain the saved proof and investigate before retrying');
console.log(JSON.stringify({...verified, readbackMatches: true,
proofFile: proof, stampRequested, maximumServiceCredits: stampRequested ? 1 : 0,
walletPayment: 'none'}, null, 2));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2)).catch(e => { console.error(e.message); process.exitCode = 1; });
}