NIP-3B: OpenTimestamps over Signed Events
- NIP3B
NIP3B
OpenTimestamps over Signed Events
draft optional
NIP3B specifies a Bitcoin-anchored time-attestation protocol for Nostr events. It binds an OpenTimestamps proof to the signature of a Nostr event — not just to its canonical bytes — so the proof is evidence that BOTH the event id AND a valid Schnorr signature over it existed before the attested Bitcoin block.
NIP3B is purely a wire-protocol specification. It defines:
- The leaf digest a proof commits to.
- Two on-wire transports for the same proof: the
ots/oldotssidecar field on the original event, and the kind:1041 calendar-pending beacon. - The verification rules a consumer applies before honouring an attestation as a time anchor.
- The carriage of NIP-44-encrypted kind:1041 content for private proofs.
Operator behaviour — when to stamp, what to store, what to strip, what to serve, when to cooperatively upgrade other people’s beacons — is out of scope for NIP3B. Those concerns are the subject of the companion NIP3C (“OTS Handling Policy for Nostr”). The two NIPs are independent: a verifier needs only NIP3B; a relay or client that creates, stores, or services proofs needs both.
NIP3B is a companion to NIP-03, not a replacement. Pre-cutoff NIP-03
proofs remain interoperable as a degraded transport (carried in the
oldots sidecar field). New deployments produce only ots proofs.
1. Why a new digest scheme
NIP-03 specifies an OpenTimestamps proof over event.id. The
canonical event id is
event.id = sha256(canonical_serialization)
canonical_serialization = JSON [0, pubkey, created_at, kind, tags, content]
The Schnorr signature is not part of that hash. An OTS proof of
event.id therefore says nothing about when the signature was
produced. An adversary in possession of a pre-existing canonical
event serialization can forge a Schnorr signature over the same id
at any later moment, and the original NIP-03 proof would still
verify — even though the signed event “did not exist” in any
meaningful Nostr sense at the attested time.
NIP3B closes this gap by hashing the signature into the leaf:
leaf = sha256(event.id_bytes ‖ event.sig_bytes)
event.id_bytes is the 32-byte hex-decoded id; event.sig_bytes is
the 64-byte hex-decoded Schnorr signature; ‖ denotes byte
concatenation. The OTS proof is built over those 96 bytes hashed.
Schnorr signatures over secp256k1 cannot be forged without the
private key. An OTS proof of sha256(id‖sig) therefore proves that
both the id AND a valid signature over it existed before the attested
Bitcoin block. This is the property the NIP-03 leaf scheme failed to
provide.
2. Transports
NIP3B specifies two on-wire transports for the same proof bytes (an
OpenTimestamps .ots blob). They serve different phases of the
proof’s lifecycle.
2.1 Sidecar field on the original event
The canonical post-confirmation home of a NIP3B proof is a top-level
field on the event JSON named ots:
{
"id": "<event-id>",
"pubkey": "<pubkey>",
"created_at": 1234567890,
"kind": 1,
"tags": [],
"content": "the post",
"sig": "<schnorr-sig>",
"ots": "<base64-encoded .ots blob>"
}
The ots field is not part of the canonical signing input. The
NIP-01 signature is computed over [0, pubkey, created_at, kind, tags, content] exactly as before. Stripping ots from any event JSON
leaves the canonical signed event intact and still verifying; only
the time-attestation is lost.
The .ots blob carried in the ots field MUST commit to a leaf
equal to sha256(event.id_bytes ‖ event.sig_bytes) of the same
event, and MUST contain at least one Bitcoin attestation that
verifies against the consumer’s local headers store (see § 4).
The sidecar is opportunistic by design: relays and clients are free to drop it. Whether a given relay or client preserves, strips, extracts, or reattaches sidecars is governed by NIP3C, not NIP3B.
Legacy oldots field
For backward-compatible carriage of NIP-03 (id-only) proofs, NIP3B
defines a second top-level field named oldots:
{ ..., "oldots": "<base64-encoded .ots blob>" }
oldots carries an OTS proof whose leaf commits to event.id
alone (the legacy NIP-03 leaf). Such a proof MAY be honoured as
a NIP3B time anchor only if its earliest verifying Bitcoin block
height is strictly less than the cutoff height (currently
1,000,000). At and past the cutoff, NIP-03 proofs MUST be treated as
untrusted regardless of whether they verify against the chain.
Rationale: post-cutoff, the digest scheme cannot bind a signature
and there is no excuse for relying on a pre-image-only attestation.
The cutoff height is a fixed protocol constant. Implementations MUST NOT make it configurable.
ots and oldots MAY appear on the same event. When both verify, a
consumer SHOULD prefer the proof reporting the earliest verified
Bitcoin block height. At equal height, prefer ots (the
signature-binding scheme is strictly stronger).
New deployments MUST always produce ots and never oldots. The
oldots slot exists only to surface pre-cutoff legacy attestations
that already exist in the wild.
2.2 Kind 1041 — calendar-pending beacon
A regular Nostr event carrying a calendar-pending OTS proof, used during the gap between calendar stamping and Bitcoin confirmation:
{
"kind": 1041,
"tags": [
["e", "<target-event-id>", "<relay-url-hint>"],
["k", "<target-event-kind>"],
["p", "<target-event-pubkey>"],
["digest_alg", "id+sig"],
["expiration", "<unix-ts>"]
],
"content": "<base64-encoded .ots blob>"
}
Required and optional tags:
| Tag | Required | Meaning |
|---|---|---|
e |
yes | Target event id (64 hex chars). Third element is an optional relay-url hint per NIP-01. |
k |
yes | Target event kind, decimal string. |
p |
yes | Target event author pubkey (64 hex chars). |
digest_alg |
yes | "id+sig" for NIP3B. The value "id" MAY appear in ingested legacy 1040 fixtures but MUST NOT be used in newly produced 1041 events. |
expiration |
SHOULD | NIP-40 expiration in unix seconds. Default now + 7*86400 (seven days). |
The content field MUST be the base64 of a .ots file whose leaf
equals sha256(target.id_bytes ‖ target.sig_bytes).
2.2.1 Single-shot semantics
Each kind:1041 beacon is a single-shot external data store for
the calendar-pending proof. Its NIP-40 expiration IS the upgrade
budget. Publishers MUST NOT emit a fresh kind:1041 referencing the
same target to extend the window. If the calendar fails to anchor
in the seven-day default window, the proof is dropped; a publisher
who needs a longer budget sets a longer expiration at first
publication.
This rule has two consequences. First, an upgrade-watcher (NIP3C) that picks up a beacon knows it has a finite window in which the calendar might commit. Second, a relay or client cannot game the scheme by republishing pending stamps: the calendar’s commitment is independent of Nostr, and re-emitting the same blob under a new event id buys nothing.
2.2.2 Pending calendars belong on-wire
A kind:1041 beacon MUST carry the pending form of the .ots
blob — i.e. the form returned by the OTS calendar before any upgrade
attempt, with calendar URLs intact. Eliding pending calendars defeats
the purpose of the beacon: any upgrade-watcher needs the calendar
URLs to fetch the upgrade.
2.2.3 Encrypted kind:1041 (private mode)
A publisher who does not want third parties to read or complete the
proof MAY encrypt the content using NIP-44. The encrypted form is
signalled by an ["encrypted", "nip44"] tag, and the NIP-44
conversation-key counterparty is identified by an
["encrypted_to", "<recipient-pubkey>"] tag:
{
"kind": 1041,
"tags": [
["e", "<target-event-id>"],
["k", "<target-event-kind>"],
["p", "<target-event-pubkey>"],
["digest_alg", "id+sig"],
["encrypted", "nip44"],
["encrypted_to", "<recipient-pubkey>"],
["expiration", "<unix-ts>"]
],
"content": "<NIP-44 ciphertext of base64(.ots)>"
}
The p tag retains its NIP3B meaning of “the target’s pubkey.”
encrypted_to is a separate field that names the encryption
recipient — typically the publisher’s own pubkey for self-encryption,
or any chosen recipient for peer-encrypted proofs. The two values
may coincide by accident but are not interchangeable, which is why
they live in separate tags.
A decoder picks the conversation-key counterparty as follows:
- If the decoder’s own pubkey equals
encrypted_to, the peer isevent.pubkey(the publisher). - Otherwise the peer is
encrypted_to(this branch covers the publisher reading their own self-encrypted emission, and the publisher’s recipient whenencrypted_tois somebody else).
The wire format is what NIP3B specifies. The behavioural rule that cooperative upgraders MUST silently skip kind:1041 events they cannot decrypt belongs to NIP3C.
For metadata privacy beyond just the proof bytes — concealing even which event is being timestamped — implementations MAY use NIP-17 gift-wrap (kind:1059 wrapping a kind:1041 rumor). Gift-wrap usage is reserved for future spec extension and is not required for NIP3B v1 compliance.
3. Stamping
A NIP3B implementation produces a calendar-pending proof by
submitting the leaf digest to one or more OpenTimestamps calendar
servers. The leaf MUST be the 32-byte sha256(id‖sig) value passed
verbatim — not hashed again by the calendar tooling.
3.1 Subprocess (Tier A)
When invoking the reference ots CLI as a subprocess, the
implementation MUST use digest mode:
ots stamp -d <hex-of-leaf> [-c <calendar-url>]...
The -d (or --hash) flag is REQUIRED. Invocations like
ots stamp <file> apply sha256 to the file before submitting,
producing a leaf of sha256(<32 bytes>) = sha256(sha256(id‖sig)).
Such a leaf does not match the NIP3B leaf and any proof produced
this way will fail verification.
3.2 Pure-language stamping (Tier B)
Direct HTTP submission to calendar servers and local OTS proof
encoding is permitted and is required for browser / mobile
environments that cannot fork a subprocess. A Tier-B implementation
MUST produce a .ots blob whose leaf equals the NIP3B leaf and
whose calendar attestations match what the OTS calendars actually
returned.
4. Verification
A NIP3B proof on event E is valid when ALL of the following
hold:
- The event itself verifies under NIP-01: the canonical id is
correctly computed over
[0, pubkey, created_at, kind, tags, content], and the Schnorr signature is valid over that id. - The proof’s
.otsblob deserialises and walks to a Bitcoin block header against the verifier’s local chain. (External trust is not permitted; any standards-compliant OpenTimestamps verifier suffices, e.g. MiniOTS9000.) - The leaf the proof commits to equals
sha256(event.id_bytes ‖ event.sig_bytes)for anotsproof, ORsha256(event.id_bytes)for anoldotsproof.
For oldots only, an additional cutoff check applies: the earliest
verified Bitcoin block height MUST be strictly less than 1,000,000.
At or past that height, an oldots proof MUST NOT be honoured as a
NIP3B time anchor regardless of whether it verifies against the
chain.
When more than one valid NIP3B proof exists for the same event, a
consumer SHOULD adopt the proof reporting the earliest verified
Bitcoin block height. At equal height, prefer ots over oldots.
Each consumer SHOULD verify independently using its own OTS verifier and its own Bitcoin headers store. A consumer MUST NOT rely on a relay’s accept-rules as a substitute for verification.
5. Lifecycle
The end-to-end lifecycle of a NIP3B proof is:
┌───────────────────────────────────────────────────────────────┐
│ 1. PUBLISHER signs an event E. Computes leaf = sha256(id‖sig).│
│ Submits leaf to one or more OTS calendars, getting back a │
│ calendar-pending .ots blob. │
└────────────────────────┬──────────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 2. PUBLISHER (or anyone authorised) emits a kind:1041 beacon │
│ carrying base64(pending .ots) in `content`, with the e/k/p │
│ tags pointing at E and a NIP-40 `expiration` (default 7d). │
└────────────────────────┬──────────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 3. The OTS calendars batch the leaf into a Bitcoin tx that is │
│ eventually mined. From this point the proof can be │
│ "upgraded" — fetched in its Bitcoin-anchored form. │
└────────────────────────┬──────────────────────────────────────┘
▼
┌───────────────────────────────────────────────────────────────┐
│ 4. AN UPGRADE-WATCHER (publisher itself, a cooperating relay, │
│ or any third party) decodes the pending .ots, runs `ots │
│ upgrade`, verifies the result locally, fetches E by its │
│ e-tag, and republishes E with `ots` set to the upgraded │
│ base64. The kind:1041 beacon expires harmlessly. │
└───────────────────────────────────────────────────────────────┘
The upgrade step (4) is a behavioural concern governed by NIP3C. NIP3B specifies what bytes go on the wire at each step but is silent on who performs each step and when.
If the calendar fails to anchor before the kind:1041 beacon’s
expiration, the proof is dropped. NIP3B MUST NOT re-emit the same
proof under a fresh kind:1041 to extend the window — see § 2.2.1.
6. Comparison with NIP-03
| Property | NIP-03 (kind:1040) | NIP3B ots sidecar |
NIP3B kind:1041 |
|---|---|---|---|
| Leaf binds event id | yes | yes | yes |
| Leaf binds Schnorr signature | no | yes | yes |
| Separate Nostr event for the proof | yes | no | yes |
| Carried inside the target event JSON | n/a | yes | n/a |
| Removable per relay / per client | yes (own event) | yes (just strip) | yes (own event) |
| Expires by default | no | n/a | yes (7 days) |
| Long-term canonical home of the proof | yes (the 1040) | yes | no (transient) |
| Verification needs external lookup | yes (target event) | no | yes (target) |
NIP-03 and NIP3B are not in conflict. Pre-cutoff legacy NIP-03 proofs
remain interoperable as oldots sidecars. New deployments produce
only ots (and kind:1041 beacons during the calendar-pending
window).
7. Examples
7.1 Kind 1041 beacon, calendar-pending, plain
{
"kind": 1041,
"id": "53a3a26844ac13ceb677c73bf3463650bd9681c080eb4bee574d252ff33be298",
"pubkey": "6d7124d023574010cd5d3aea61b262a2b96e9dff41889102d2c34a058a859ec2",
"created_at": 1778100210,
"tags": [
["e", "7e0c8cbe361180827fe9ee2f4b7658218ded36838e9009216ebd793e92532e29",
"wss://relay.example"],
["k", "1"],
["p", "6d7124d023574010cd5d3aea61b262a2b96e9dff41889102d2c34a058a859ec2"],
["digest_alg", "id+sig"],
["expiration", "1778705010"]
],
"content": "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/...",
"sig": "..."
}
7.2 Kind 1 with ots sidecar, Bitcoin-anchored
{
"id": "97369303c3119d0f06fe0b11043ccd24db6a4910c8c1c44f8c332ce7a9735210",
"pubkey": "6d7124d023574010cd5d3aea61b262a2b96e9dff41889102d2c34a058a859ec2",
"created_at": 1778115277,
"kind": 1,
"tags": [],
"content": "the canonical post",
"sig": "...",
"ots": "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/..."
}
The .ots blob’s leaf equals sha256(id ‖ sig) of this event; the
earliest Bitcoin attestation may be at any height.
7.3 Kind 1 with legacy oldots, pre-cutoff only
{
"id": "430bf2069503bae5af8bc0514d73a8aa0ed6d8fd616e2048599a82b88e818530",
"pubkey": "...",
"created_at": 1778097096,
"kind": 1,
"tags": [],
"content": "...",
"sig": "...",
"oldots": "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/..."
}
The oldots blob’s earliest verifying Bitcoin block height MUST be
strictly less than 1,000,000 for this proof to be honoured.
8. Implementation notes
- A relay that strips unknown top-level fields will discard sidecars; the signed event remains intact. Sidecars are opportunistic.
- A relay that wants to preserve sidecars across writes SHOULD store
the proof bytes by event id in its own storage and reattach them
on outbound EVENT frames when subscribers signal support. A simple
HTTP side-channel (e.g.
GET /sidecar/<event-id>returning the raw.ots) is a reasonable transport for consumers that don’t want to opt in over the WebSocket layer. See NIP3C for how a relay decides which events get this treatment. - The
.otsblob in a sidecar SHOULD be byte-identical to the blob in the matching kind:1041 event when both exist. A consumer that finds two proofs for the same target SHOULD reject any inconsistency (different leaf digests, different earliest Bitcoin heights) and prefer the verified one. - For verification, consumers SHOULD use a local Bitcoin headers store rather than trusting any third party. Each verifying component in a pipeline SHOULD perform its own check.
- NIP3B implementations MUST use
ots stamp -d <hex>(digest mode), not file mode. File mode applies an extra sha256 to the input and produces a leaf that does not match the NIP3B specification.
9. References
- NIP-01 — Basic protocol flow.
- NIP-03 — OpenTimestamps Attestations for Events (the predecessor
scheme; pre-cutoff NIP-03 proofs are interoperable as
oldots). - NIP-40 — Expiration Timestamp.
- NIP-44 — Encrypted Payloads (Versioned).
- NIP-17 — Private Direct Messages (gift wrap; reserved for future metadata-hiding extension).
- NIP3C — OTS Handling Policy for Nostr (the companion behavioural NIP — when to stamp, what to store, how cooperative upgrade works).
- OpenTimestamps — Bitcoin-anchored time attestations (https://opentimestamps.org/).
Write a comment