@sanning/anchor
v0.4.0
Published
Anchor-at-write-time for the Sanning evidence plane. Hash your data locally, sign a Verifiable Event Envelope (sanning.events/v1), and anchor it permanently on Arweave — permanent, tamper-evident provenance with Merkle-batched checkpoints and per-event in
Downloads
199
Readme
@sanning/anchor
Anchor-at-write-time for the Sanning evidence plane: hash your data locally, sign a Verifiable Event Envelope under the sanning.events/v1 profile, and anchor it — no relay, no SDK bloat, raw bytes never leave your system.
Write through the Sanning anchor front — https://console.sanning.io/anchor, the SDK's default upload endpoint (metered, API-keyed via controlPlane). Read it back through the Sanning read front — https://console.sanning.io/read, the default gateway base — or from any public Arweave gateway (https://arweave.net/raw/<txId>); anchored records stay independently fetchable and verifiable, so you're never locked to one provider. Underneath, everything is stored permanently on Arweave.
npm install @sanning/anchorimport { createAnchorer } from "@sanning/anchor";
// Dev mode: zero config. Auto identity + wallet; small uploads ride the free tier.
// Proofs are permanently marked environment:"dev" inside the signed bytes.
const anchorer = createAnchorer();
const receipt = await anchorer.anchor({
type: "event",
data: fileBytes, // or a string, or an AsyncIterable (streams),
// contentHash: "...", // or a pre-computed sha256 — exactly one
ref: "s3://bucket/key", // optional locator
metadata: { approver: "alice" },
chain: "orders", // optional per-key hash chain
});
receipt.txId; // Arweave transaction id (resolved once the anchor front accepts the upload)
receipt.envelope; // the signed, Minimal-disclosure envelope
receipt.recordBytes; // RETAIN THESE — the committed event record
receipt.gatewayUrl; // the Sanning read front by default; any public Arweave gateway also serves itBatching
High-frequency events (LLM steps, pipeline records): one anchor write per window, while every event keeps its own offline-verifiable inclusion proof.
const batch = anchorer.batch({
maxEvents: 100, // flush when full…
maxAge: 60_000, // …or 60s after the first buffered event…
flushOnIdle: 5_000, // …or 5s after the last add. First trigger wins.
});
const receipt = await batch.add({ data: JSON.stringify(step) }).receipt();
// → { checkpointTxId, root, leafHash, leafIndex, auditPath, envelope, recordBytes, ... }
await anchorer.close(); // explicit flush — call it on shutdown; nothing is flushed for youadd() is synchronous (bytes/string/pre-computed hash — no streams here). A batch of one is valid. With the default in-memory buffer a crash loses buffered proofs, never data — every event is re-anchorable from your system.
Bundle a whole trace (one portable file)
Collect a set of receipts and serialize them into ONE signed, self-verifying sanning.evidence/v1 bundle (body_type: sanning.anchor.trace/v1) with anchorer.bundle(). The wrapper is signed by your anchorer's own key — the same key the receipts' envelopes carry, so the call site needs zero signer handling; the bundle carries every event's signed envelope + committed record + inclusion proof, de-duplicating the shared checkpoint(s).
const receipts = await Promise.all(handles.map((h) => h.receipt()));
const bundle = await anchorer.bundle(receipts); // signed with the anchorer's own key — zero ceremony
await fs.writeFile("trace-bundle.json", JSON.stringify(bundle, null, 2));Need an explicit key, or to override the issuer/gateway? The standalone advanced form is toEvidenceBundle(receipts, { signer, issuer }) (also from @sanning/anchor) — anchorer.bundle() is the same call wired to your anchorer's own signer with a sensible default issuer.
Optionally include the raw logs (opt-in, default off)
By default a bundle discloses nothing semantic — it carries hashes and on-chain locations, never your raw bytes, and that's the whole point of minimal disclosure. Sometimes, though, you want one self-contained file an auditor can open and read: the raw logs and their on-chain proofs together. Pass disclose, keyed by eventId, with the original bytes (the receipt never retained them, so you supply them back):
const bundle = await anchorer.bundle(receipts, {
disclose: { [receipts[0].eventId]: rawLogBytes }, // Uint8Array | string (UTF-8)
});Each disclosed event embeds its bytes (lowercase hex) inside the signed body, so the disclosure is covered by body_hash and tamper-evident like everything else; sha256(bytes) is asserted equal to the event's committed content_hash at assembly time, and a mismatch throws. This is purely a property of the file you hand out — your on-chain footprint is unchanged (the envelope still carries only the hash), and events you don't list stay minimal. Disclose all, some, or none, per event.
With a logStore configured (see retention below), skip the map entirely — disclose: true reads every retained event's bytes back from the store for you:
const bundle = await anchorer.bundle(receipts, { disclose: true }); // from the logStoreHash-only adds (the SDK never saw those bytes) stay minimal; true without a retrieving logStore throws rather than silently disclosing nothing.
Verifying
Hand the trace bundle to an auditor; they verify the whole thing — every event's signature + payload binding + Merkle inclusion, offline — with one command and the read-only @sanning/proof (no write SDK in the trust path):
npx @sanning/proof verify trace-bundle.json
# optionally re-fetch each checkpoint on-chain to confirm it's anchored:
npx @sanning/proof verify trace-bundle.json https://arweave.net,https://permagate.ioIt prints a per-event + rollup verdict and exits on a pinned code (0 verified · 1 failed · 2 malformed · 3 gateway-unavailable); the producer's asserted verdict is shown but never trusted (the verdict is recomputed from the body). A withheld record surfaces as semantics-undetermined (~), not a failure. Any raw bytes you disclosed (above) are checked too — the verifier recomputes their hash, confirms it equals the event's committed content_hash, and marks the event logs ✓ (--logs <file> does the same for logs that travel alongside a minimal bundle). The same CLI also verifies sanning.agent.proof/v1 inclusion bundles (it sniffs spec_version).
Live: the bundle emit (
anchorer.bundle()/toEvidenceBundle) ships in@sanning/anchor≥ 0.4.0 and thenpx @sanning/proof verifyCLI in@sanning/proof≥ 0.4.0 (the first releases under these names). The@sanning/proofprimitives below remain available for manual/advanced verification.
A single envelope still verifies by hand — fetch it from the Sanning read front (https://console.sanning.io/read/<txId>) or any public Arweave gateway (https://<gateway>/raw/<txId>, e.g. arweave.net) — with your retained recordBytes:
import { ed25519Verify, jcs, sha256Hex, utf8, verifyInclusion, hexToBytes } from "@sanning/proof";
const { signature, ...preSignature } = receipt.envelope;
await ed25519Verify(signature, utf8(jcs(preSignature)), receipt.envelope.public_key); // true
(await sha256Hex(receipt.recordBytes)) === receipt.envelope.payload_hash; // trueSee the runnable examples.
Production
Production structurally refuses auto-generated secrets — it throws unless you pass all three:
import { createAnchorer, LocalEd25519Signer, SolanaWalletSigner } from "@sanning/anchor";
const anchorer = createAnchorer({
environment: "production",
// Identity key (signs envelopes). Any { publicKey(), sign() } works —
// file seed shown; Vault/KMS adapters implement the same interface.
signer: LocalEd25519Signer.fromSeedHex(process.env.ANCHOR_IDENTITY_SEED!),
// Data-item wallet (signs the ANS-104 item) — a different key, Solana ed25519 default.
wallet: new SolanaWalletSigner(LocalEd25519Signer.fromSeedHex(process.env.ANCHOR_WALLET_SEED!)),
subject: { type: "producer", producer_id: "acme-app" },
// Route through your Sanning workspace: uploads carry the API key as a Bearer
// token, and reads default to the same workspace's /read front.
controlPlane: { baseUrl: "https://console.sanning.io", apiKey: process.env.SANNING_API_KEY! },
});The two keys are deliberately separate: identity (who signed the envelope) vs the data-item wallet (who signed the write). Anchoring through the Sanning front is metered against your API key (scope anchor:write).
Errors
All errors carry a machine-checkable code:
| Class (code) | When | Do |
|---|---|---|
| FundingExhaustedError (FUNDING_EXHAUSTED) | 402 from the anchor front — funding or quota exhausted | Top up the account; the message includes instructions. Not retryable as-is. |
| UploadFailedError (UPLOAD_FAILED) | 5xx/429/network, retries exhausted | Transient — safe to retry the same anchor() call. |
| UploadRejectedError (UPLOAD_REJECTED) | Terminal 4xx from the anchor front | Inspect the detail; retrying the same bytes won't help. |
| HttpTimeoutError (HTTP_TIMEOUT) | An upload or enrolment call went silent past its bound (default 30s per attempt, covering the response body) | The outcome is unknown — a timed-out request may still have been accepted, so this is not evidence that it was rejected. Look the event up before re-sending (your sink rows carry its eventId; the read front, the TX), because re-running anchor() builds a new envelope and would anchor the same event twice. Re-running enrolment is safe as-is: same-key registration is a server-side no-op. Raise arweave.timeoutMs / controlPlane.timeoutMs if your front is legitimately slower; never leave the call unbounded. |
| InvalidRefError (INVALID_REF) | A ref / payload_ref is not a URI, or is longer than 2048 chars | Percent-encode spaces and non-ASCII, or shorten the locator. Checked before anything is signed, because payload_ref becomes permanent public bytes. |
| TxIdMismatchError (TXID_MISMATCH) | Upstream returned a TX ID that doesn't match the signature-derived one | Should never happen with an honest upstream — treat the upload as suspect. |
| ProductionConfigError (PRODUCTION_CONFIG) | Production mode without explicit signer/wallet/subject | Supply the missing credentials; dev secrets cannot reach production. |
A failed batch window rejects only that window's receipt() promises — the chain head is untouched and the next window proceeds; re-add the events to re-anchor them.
Guarantees
- Local hashing only.
datais hashed in-process (streams supported); only the signed envelope (a few hundred bytes) is uploaded. - Minimal disclosure. The on-chain envelope carries no event type, no subject, no chain pointer — those live in the hash-committed record you retain.
- Dev proofs are cryptographically dev.
environmentsits inside the signed scope; a dev proof can never be presented as production evidence. - Verification is a separate, read-only package —
@sanning/proof. This package contains the write path only.
Conformance
Byte-for-byte against the family corpus (sanning-io/proof test-vectors-v2.0); ANS-104 output byte-pinned vs arbundles and re-verified by an independent Python parser in CI. See the profile spec.
