MARKET
Nirium← Home
Back to blog
Audit Trail5 min read

Anchor a receipt signed with your own Stellar key

Oct 13, 2026 Stellar testnet and mainnet Free during the beta

An agent acts. An agent pays. Later, someone asks who did what, and when. If the only answer lives in your own database, you are asking people to trust you.

Audit Trail gives you another answer. You anchor a fingerprint of the evidence to IPFS and sign it with a Stellar key. Anyone can check it without asking us.

How it works

Three steps.

1. Hash your evidence

Take the bytes you want to be able to prove later and compute their sha-256. The evidence itself stays with you.

2. Sign with your G key

Sign the exact text nirium-audit-v1:<sha-256> with your Stellar key. A Stellar G address is an ed25519 public key, so any Stellar key pair works. The prefix stops a signature made for something else from being reused here.

3. POST and get the CID

Send the hash and the signature to POST /api/audit/log. The box checks the signature. If it is wrong, it answers 400 and anchors nothing. If it is right, it pins a small document to IPFS and returns its CID.

A real run on testnet

We ran this on the testnet box with a throwaway key. The receipt was this JSON: {"action":"approved","ref":"demo-receipt-001"}. Steps one and two:

import { Keypair } from '@stellar/stellar-sdk';
import { createHash } from 'node:crypto';

// 1. The evidence: any bytes you can keep. Here, a tiny receipt, no personal data.
const evidence = JSON.stringify({ action: 'approved', ref: 'demo-receipt-001' });
const sha = createHash('sha256').update(evidence).digest('hex');

// 2. Sign the exact statement with your Stellar key (throwaway, never printed).
const kp = Keypair.random();
const statement = `nirium-audit-v1:${sha}`;
// Keypair.sign() can return a Uint8Array depending on the installed
// @stellar/stellar-sdk version, and Uint8Array.toString() ignores the
// 'base64' argument and joins the bytes with commas instead. Wrap it in
// Buffer.from() first so .toString('base64') always does what you expect.
const signature = Buffer.from(kp.sign(Buffer.from(statement, 'utf8'))).toString('base64');

Then the request, exactly as it was sent:

curl -s -X POST https://nirium-agent.fly.dev/api/audit/log \
  -H 'content-type: application/json' \
  -d '{"hash":"sha-256:10df12bb9011ab438020c980494e79ed3b7120f91327a508969192399d41e32b","tag":"blog-demo","agent":{"id":"throwaway-demo-key","key":"GCDOCELS3ESLR34W5IGO5QWOLSIGZV5R5EBPDMI4KSXW4DEFWEKH6KPD","signature":"2Lmym4Pi7BG4OU36yOesc96HxAOQcPjxiE+ryJwIC3nCc0JY26hRl2pz4nWx3vamZ4fGa0K3LuFmTmkWgl36Ag=="}}'

The box answered 201 with a CID:

{
  "cid": "QmVuYgNZ3doLayyoWve2gRy8rSUuNE2asCWozpNZvXPeDF",
  "gatewayUrl": "https://gateway.pinata.cloud/ipfs/QmVuYgNZ3doLayyoWve2gRy8rSUuNE2asCWozpNZvXPeDF",
  "content_sha256": "10df12bb9011ab438020c980494e79ed3b7120f91327a508969192399d41e32b",
  "attestedBy": "GCDOCELS3ESLR34W5IGO5QWOLSIGZV5R5EBPDMI4KSXW4DEFWEKH6KPD",
  "anchoredAt": "2026-10-13T22:01:08.430Z",
  "verify": "fetch the gatewayUrl, recompute sha-256 over your original evidence, then verify agent.signature over agent.statement against agent.key"
}

The document it pinned is public. This is what the CID returns:

{
  "version": "1.1.0",
  "node": "audit-external",
  "content_sha256": "10df12bb9011ab438020c980494e79ed3b7120f91327a508969192399d41e32b",
  "agent": {
    "id": "throwaway-demo-key",
    "key": "GCDOCELS3ESLR34W5IGO5QWOLSIGZV5R5EBPDMI4KSXW4DEFWEKH6KPD",
    "alg": "ed25519",
    "statement": "nirium-audit-v1:10df12bb9011ab438020c980494e79ed3b7120f91327a508969192399d41e32b",
    "signature": "2Lmym4Pi7BG4OU36yOesc96HxAOQcPjxiE+ryJwIC3nCc0JY26hRl2pz4nWx3vamZ4fGa0K3LuFmTmkWgl36Ag=="
  },
  "network": "stellar:testnet",
  "tag": "blog-demo",
  "anchoredAt": "2026-10-13T22:01:08.430Z"
}

We also sent a request signed by a different key than the one it claimed. The box answered 400, with an error that names the exact text to sign, and anchored nothing.

Verify it yourself

You do not need to trust the box. You need the CID and your original evidence. This script downloads the document, recomputes the sha-256, rebuilds the signed text and checks the ed25519 signature against the G key.

// Verify an anchored receipt from its CID and your original evidence.
// usage: node verify.mjs <cid> '<original evidence string>'
import { Keypair } from '@stellar/stellar-sdk';
import { createHash } from 'node:crypto';

const [cid, evidence] = process.argv.slice(2);

// 1. Download the anchored document by CID (any IPFS gateway works).
const gateways = ['https://gateway.pinata.cloud/ipfs/', 'https://ipfs.io/ipfs/'];
let doc;
for (const g of gateways) {
  try {
    const r = await fetch(g + cid, { signal: AbortSignal.timeout(20000) });
    if (r.ok) { doc = await r.json(); console.log('fetched from', g); break; }
  } catch {}
}
if (!doc) { console.log('FAIL: could not fetch the CID'); process.exit(1); }

// 2. Recompute the sha-256 of YOUR evidence and compare.
const sha = createHash('sha256').update(evidence).digest('hex');
const hashOk = sha === doc.content_sha256;
console.log('sha-256 of your evidence matches the anchored one:', hashOk);

// 3. The signed statement must be exactly domain + hash.
const stmtOk = doc.agent?.statement === `nirium-audit-v1:${doc.content_sha256}`;
console.log('statement is nirium-audit-v1:<that hash>         :', stmtOk);

// 4. Verify the ed25519 signature against the Stellar G key.
let sigOk = false;
try {
  sigOk = Keypair.fromPublicKey(doc.agent.key)
    .verify(Buffer.from(doc.agent.statement, 'utf8'), Buffer.from(doc.agent.signature, 'base64'));
} catch {}
console.log('signature verifies against', doc.agent?.key, ':', sigOk);

console.log('anchoredAt (written by the box):', doc.anchoredAt, '| network:', doc.network);
process.exit(hashOk && stmtOk && sigOk ? 0 : 2);

This is the output of the real run, with the right evidence:

fetched from https://gateway.pinata.cloud/ipfs/
sha-256 of your evidence matches the anchored one: true
statement is nirium-audit-v1:<that hash>         : true
signature verifies against GCDOCELS3ESLR34W5IGO5QWOLSIGZV5R5EBPDMI4KSXW4DEFWEKH6KPD : true
anchoredAt (written by the box): 2026-10-13T22:01:08.430Z | network: stellar:testnet

We also changed one word of the evidence, from approved to rejected. The hash check printed false and the script exited with an error. Run it with your own CID and your own evidence.

The same run, on mainnet

We ran the exact flow above against the mainnet box too, with a separate throwaway key and this evidence: {"action":"approved","ref":"demo-receipt-mainnet-001"}. It anchored the same way: 201, a CID, a document that verifies.

The anchored document, fetched from IPFS:

{
  "version": "1.1.0",
  "node": "audit-external",
  "content_sha256": "3f15aaabd2177199e01e442b67a573d7eef74ad259121768e0195049a75d7f0e",
  "agent": {
    "id": "throwaway-demo-key",
    "key": "GDSA3QS7KWRLYGSKTKSMGTRPHKOIR7WRW6RCIOYKDLBK4GAHDDFKBQNF",
    "alg": "ed25519",
    "statement": "nirium-audit-v1:3f15aaabd2177199e01e442b67a573d7eef74ad259121768e0195049a75d7f0e",
    "signature": "P0Qon/Ko7gv5mLRutone62QRQQ9RKuOXusubTnFWB6XF1x5kSq5wQ1n5xvx9zEOOeuAqpFvY/7+vJnZGQglsBw=="
  },
  "network": "stellar:pubnet",
  "tag": "x-demo",
  "anchoredAt": "2026-10-13T20:45:56.631Z"
}

What it proves and what it does not

What it proves

The document has not changed since it was pinned, because its CID comes from its content. The hash matches your evidence. The holder of the G key signed that hash.

What it does not prove

It does not prove that the content is true. It does not say who is behind a G key in real life. The anchoredAt field is the time the box wrote into the document, not an independent timestamp.

Anchor hashes, not personal data

IPFS records cannot be deleted. Anchor the hash of your evidence. The optional record field is capped at 8KB, and you are responsible for what you put in it.

Free, with limits

Free during the beta and rate-limited. The box sets the network, not the request: it anchors on whichever network it runs on, mainnet or testnet, never the one the client asks for.

Try it

Run the two scripts with a key of your own. Start with GET /api/audit/info, which lists the current rules.

Where to start

GET /api/audit/info lists the rules, the limits and the two endpoints. POST /api/audit/log anchors a hash.