How OC Stamp works
Two steps. First you sign. Then you anchor.
1. Sign
import { stamp } from '@orangecheck/stamp-core';
const env = await stamp({
content: new TextEncoder().encode('release: v1.2.3 signed by Alice'),
mime: 'text/plain',
signer: {
address: 'bc1qalice...',
signMessage: async (m) => walletSignBIP322(m),
},
// Optional — reference an OC Attest attestation for "stake at signing"
attest_ref:
'a3f5b8c2d1e4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1',
});
stamp() builds a canonical message with header
oc-stamp, the SHA-256 of content, the MIME type, the signer's address, and —
if provided — the Attest reference. The wallet signs the canonical bytes via
BIP-322.
The envelope is a JSON blob:
{
"id": "<sha256 of canonical message, base64url>",
"version": "oc-stamp/v0",
"signer": {
"address": "bc1qalice...",
"scheme": "bip322"
},
"content_hash": "<sha256 of content bytes, hex>",
"mime": "text/plain",
"attest_ref": "a3f5b8c2...",
"issued_at": "2026-04-24T06:47:29.977Z",
"sig": "<BIP-322 signature, base64>",
"ots": null
}
The content itself is NOT included in the envelope — only its hash. You ship the
envelope alongside the content (archive, filesystem, HTTP, whatever). A verifier
re-hashes the content and checks against content_hash to prove the envelope
commits to the same bytes.
This is intentional: envelopes stay small (~500 bytes) regardless of content size. A 4 GB video gets the same envelope a 40-byte string does.
2. Anchor
import { submitToCalendars, upgradeProof } from '@orangecheck/stamp-ots';
// Submit the envelope id to OpenTimestamps calendars
env.ots = await submitToCalendars(env.id);
// env.ots is now a "pending" OTS proof
submitToCalendars() POSTs the envelope's id bytes to one or more OTS
calendars (alice.btc.calendar.opentimestamps.org,
bob.btc.calendar.opentimestamps.org, etc.). The calendars batch many digests
into a Merkle tree and publish the tree root to Bitcoin. At this point the proof
is pending — you have a receipt that says "your digest is in a batch" but
the batch hasn't hit the chain yet.
Once a Bitcoin block confirms, the proof can be upgraded:
// Minutes to hours later:
env.ots = await upgradeProof(env.ots, env.id);
// env.ots is now a "confirmed" proof chained to a specific Bitcoin block
The upgraded proof contains the Merkle path from your digest to the batch root, and the block header that included the batch root. Anyone holding the envelope + the proof + any honest Bitcoin node can verify:
- The envelope was signed by the holder of the Bitcoin address (via BIP-322).
- The envelope's
idwas included in a specific Bitcoin block at a specific height, so it cannot have been fabricated after that block.
Verify
import { verify } from '@orangecheck/stamp-core';
const result = await verify({
envelope: env,
content: contentBytes, // the raw bytes the envelope commits to
verifyBip322: async (msg, sig, addr) => walletVerifyBIP322(msg, sig, addr),
verifyOtsAnchor: myAnchorVerifier, // see /stamp/ots-anchor
});
if (result.ok) {
console.log('Author:', result.signer.address);
console.log('Content hash matched:', result.content_hash_ok);
console.log('Anchored in Bitcoin block:', result.anchor?.block_height);
console.log('Attest reference:', result.attest_ref);
}
verify() is pure — it does the crypto and delegates network calls (BIP-322
verification, OTS anchor resolution) to plug-in functions you provide. This
keeps the core verifier offline-capable and lets you choose any block-header
source.
Without the anchor
A stamp without ots is still useful — you have a signed statement binding
content to an address. You just don't have a time-anchor. Anchor later, or skip
it entirely if your use case doesn't need priority.
Publishing optional
Envelopes can be published to Nostr kind-30078
with d tag oc-stamp:<id> for durable public discovery, but it's optional. A
stamp is a self-contained JSON blob; email it, embed it in a git commit message,
put it in S3 — it verifies the same way.
See also
- OpenTimestamps anchor — the anchor step in detail
- Use cases — concrete patterns with code