NIP-3B — OpenTimestamps over Signed Events
- NIP-3B
NIP-3B
OpenTimestamps over Signed Events
draft optional
This NIP defines a Bitcoin time-attestation scheme for Nostr events that binds the attestation to the signed event — i.e. the event id together with the Schnorr signature — instead of the canonical event id alone.
It also specifies a non-canonical, in-event JSON transport for the proof (“side-car”), so a single network roundtrip can carry both the signed event and its time-attestation. As a courtesy to legacy consumers it includes a demo profile that re-uses the same transport for NIP-03 proofs.
This NIP is a companion to NIP-03, not a replacement: NIP-03 proofs remain interoperable; clients that wish to keep them MAY still produce or consume kind:1040 events. NIP-3B specifies what to do when you want a proof that actually attests the signature.
Why a new scheme
NIP-03 specifies an OpenTimestamps proof over event.id:
The OpenTimestamps proof MUST prove the referenced
eevent id as its digest.
event.id is sha256(canonical_serialization) — i.e. the canonical fields
[0, pubkey, created_at, kind, tags, content] hashed. Since the signature is
not part of that hash, an OTS proof of event.id says nothing about when the
signature was produced. An attacker holding a pre-existing canonical event
serialization can forge a Schnorr signature over the same id at any later
moment; 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.
To bind time-attestation to the signed event itself, NIP-3B uses a leaf digest that commits to both the canonical id and the signature:
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. The OTS proof is built over those 96
bytes hashed.
Because Schnorr signatures over secp256k1 cannot be forged without the
private key, an OTS proof of sha256(id || sig) proves that BOTH the id and
a valid signature over it existed before the attested Bitcoin block.
Transport
NIP-3B specifies two transports for the same proof. They are interoperable and either may be used; relays / clients SHOULD support both on read.
A. Kind 1041 — “OpenTimestamps Attestation over Signed Event”
A regular event whose content carries the base64-encoded .ots blob:
{
"kind": 1041,
"tags": [
["e", "<target-event-id>", "<relay-url-hint>"],
["k", "<target-event-kind>"],
["p", "<target-event-pubkey>"],
["digest_alg", "id+sig"]
],
"content": "<base64-encoded .ots file>"
}
The .ots blob’s leaf digest MUST equal sha256(target.id_bytes || target.sig_bytes).
Tag rules:
e(REQUIRED) — the target event id (64 hex chars). The third element is an optional relay-url hint per NIP-01.k(REQUIRED) — the target event’s kind, as a decimal string.p(REQUIRED) — the target event’s pubkey (64 hex chars).digest_alg(RECOMMENDED) —"id+sig". Reserved for future schemes.
The content field MUST be the base64-encoded full bytes of an .ots file.
The file SHOULD contain a single Bitcoin attestation; pending calendars
SHOULD be elided in published copies (clients that produce calendar-pending
proofs SHOULD upgrade and re-publish, or keep them in private storage until
upgraded).
This is parallel to NIP-03’s kind:1040, with three differences:
- The leaf digest is
id || siginstead ofidalone. - The
digest_algtag is REQUIRED to distinguish the schemes when both appear (a relay holding both 1040 and 1041 attestations for the same target is normal during migration). - The
ptag is REQUIRED so consumers can resolve the target pubkey without looking up the original event.
B. In-event side-car (ots_proof)
When the publisher of an event holds a proof for that same event, the proof MAY ride along inside the event’s JSON as a top-level field:
{
"id": "<event-id>",
"pubkey": "<pubkey>",
"created_at": 1234567890,
"kind": 1,
"tags": [],
"content": "the post",
"sig": "<sig>",
"ots_proof": "<base64-encoded .ots file>",
"ots_alg": "id+sig"
}
ots_proof and ots_alg are NOT part of the canonical signing input and
are NOT inside the tags array. The signature is computed over only
[0, pubkey, created_at, kind, tags, content] exactly as in NIP-01.
The .ots file’s leaf digest MUST equal sha256(id || sig) of the same
event. ots_alg MUST be "id+sig" for the scheme defined in this NIP;
the field is reserved so future schemes can carry the proof in the same
slot without ambiguity.
The side-car MAY be stripped by any relay or client that does not wish to carry it. Stripping leaves the canonical signed event intact; the signature continues to verify; only the time-attestation is lost. This makes the side-car opportunistic by design: a publisher SHOULD also produce and broadcast a kind:1041 event so the proof can be obtained out of band when the side-car is not available.
A relay or client that wishes to retain side-cars across writes SHOULD
emit them on outbound EVENT frames whenever the consumer has signalled
support — for example via a connection-time hint (?ots_sidecar=1 is a
reasonable convention). When no such signal is present, the relay SHOULD
emit canonical events unchanged so unaware Nostr clients keep working.
Verification
A NIP-3B proof is valid when ALL of the following hold:
- The signed event verifies under NIP-01 (canonical id + valid Schnorr signature).
- The proof’s
.otsfile deserialises and walks to a Bitcoin block header against the verifier’s local chain (no external trust required; any standards-compliant OpenTimestamps verifier suffices). - The leaf digest the proof commits to equals
sha256(event.id_bytes || event.sig_bytes)for the kind-1041 form, orsha256(id || sig)of the carrying event for the side-car form.
Each consumer SHOULD verify independently with its own miniots / OTS verifier and its own Bitcoin headers store. Consumers MUST NOT rely on a relay’s accept-rules as a substitute for verification.
The earliest Bitcoin block height across a verifying proof’s attestations is the canonical “this signed event existed by block N” claim. When more than one valid NIP-3B proof exists for the same event, consumers SHOULD take the earliest block height as the canonical attested time.
Demo profile: NIP-03 over the side-car transport (oldnostrots)
To exercise the in-event side-car transport with the existing fleet of NIP-03 kind:1040 attestations, this NIP optionally specifies a second, non-canonical field on the event JSON:
{
"id": "...", "pubkey": "...", ..., "sig": "...",
"oldnostrots": "<base64-encoded .ots file>"
}
oldnostrots carries an OTS proof whose leaf digest commits to
event.id ALONE (the legacy NIP-03 scheme). A consumer that already
holds a kind:1040 event referencing some target event MAY repackage the
proof into the side-car format by emitting the canonical target event with
the oldnostrots field added.
This is explicitly a demo of the transport, not of the digest scheme.
A proof in oldnostrots does not bind the signature and inherits the
NIP-03 weakness described above. It exists so:
- Existing NIP-03 attestations can be transported alongside their signed events without introducing kind:1041 events that would re-attest under a stronger scheme they cannot satisfy.
- Implementers can validate the in-JSON side-car transport end to end — publishing, relay storage, on-the-wire reattach, downstream verification — using the existing corpus of Bitcoin-confirmed NIP-03 proofs.
A consumer MAY treat oldnostrots as a “this event id existed by block N”
hint, but MUST NOT treat it as a binding time-attestation of the
signature. New deployments SHOULD always use ots_proof (id+sig) and not
oldnostrots.
ots_proof and oldnostrots MAY appear on the same event. When they do,
each is independently verifiable; verifiers SHOULD prefer ots_proof.
Redundant-attestation cleanup
Because OTS proofs accumulate calendar-pending stubs and may carry multiple Bitcoin attestations, an in-event side-car blob can be considerably larger than the minimal proof needed to verify the attested time. A side-car holder MAY prune the proof by:
- Re-running the OTS proof through any pruner that retains the earliest verifying Bitcoin attestation and discards pending calendars and later attestations.
- Replacing the
ots_proof(oroldnostrots) value with the pruned bytes.
The event.id, pubkey, tags, content, and sig MUST remain
unchanged. The pruned proof MUST still verify against the same leaf
digest. Consumers receiving a pruned proof for an event whose unpruned
form they previously held SHOULD prefer the smaller blob (the pruned form
is strictly more compact while semantically equivalent).
Comparison with NIP-03
| Property | NIP-03 (kind:1040) | NIP-3B (kind:1041) | NIP-3B (side-car) |
|---|---|---|---|
| Leaf digest binds event id | yes | yes | yes |
| Leaf digest binds Schnorr signature | no | yes | yes |
| Separate Nostr event for the proof | yes | yes | no |
| Carried inside the target event JSON | n/a | n/a | yes |
| Removable per relay / per client | yes (own event) | yes (own event) | yes (just strip) |
| Verification needs external lookup | yes (target event) | yes (target event) | no |
NIP-03 and NIP-3B are not in conflict; both kinds may coexist on the same relay for the same target event. Consumers SHOULD prefer NIP-3B when both schemes claim the same target.
Migration
For a target event already carrying a NIP-03 attestation, the publisher MAY:
- Continue to publish NIP-03 kind:1040 events.
- ADDITIONALLY publish a NIP-3B kind:1041 event whose
.otsprovessha256(id || sig)of the same target. - ADDITIONALLY include
ots_proof(NIP-3B) and/oroldnostrots(the NIP-03 demo profile) on the target event when re-broadcasting it.
There is no requirement to retract NIP-03 attestations. They remain valid as id-only timestamps; they simply do not provide the stronger signature-binding guarantee NIP-3B does.
Examples
Kind 1041 attesting a kind 1 note
{
"kind": 1041,
"id": "53a3a26844ac13ceb677c73bf3463650bd9681c080eb4bee574d252ff33be298",
"pubkey": "6d7124d023574010cd5d3aea61b262a2b96e9dff41889102d2c34a058a859ec2",
"created_at": 1778100210,
"tags": [
["e", "7e0c8cbe361180827fe9ee2f4b7658218ded36838e9009216ebd793e92532e29",
"ws://127.0.0.1:4849"],
["k", "1"],
["p", "6d7124d023574010cd5d3aea61b262a2b96e9dff41889102d2c34a058a859ec2"],
["digest_alg", "id+sig"]
],
"content": "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/...",
"sig": "..."
}
The content is the base64-encoded .ots. Its leaf digest equals
sha256("7e0c8cbe...".bytes || "<sig of 7e0c...>".bytes).
Kind 1 with ots_proof side-car
{
"id": "97369303c3119d0f06fe0b11043ccd24db6a4910c8c1c44f8c332ce7a9735210",
"pubkey": "6d7124d023574010cd5d3aea61b262a2b96e9dff41889102d2c34a058a859ec2",
"created_at": 1778115277,
"kind": 1,
"tags": [],
"content": "OTSuite end-to-end via supervisor",
"sig": "...",
"ots_proof": "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/...",
"ots_alg": "id+sig"
}
The signature is over [0, pubkey, created_at, kind, tags, content] per
NIP-01; ots_proof and ots_alg are outside that scope. The .ots
file’s leaf digest equals sha256(id || sig).
Kind 1 with the demo oldnostrots field (legacy id-only)
{
"id": "430bf2069503bae5af8bc0514d73a8aa0ed6d8fd616e2048599a82b88e818530",
"pubkey": "...",
"created_at": 1778097096,
"kind": 1,
"tags": [],
"content": "...",
"sig": "...",
"oldnostrots": "AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/..."
}
The proof commits to event.id alone — see the warning in the demo
section above. New deployments SHOULD use ots_proof instead.
Implementation notes
- A relay that strips unknown top-level fields will discard side-cars; the signed event remains intact. Side-cars are opportunistic.
- A relay that wishes to preserve side-cars across writes SHOULD store the
proof bytes by event id in its own storage and reattach them on outbound
EVENT frames when the subscriber has signalled support. A simple HTTP
side-channel (e.g.
GET /sidecar/<event-id>returning the raw.ots) is also a reasonable transport for consumers that don’t want to opt in on the WS layer. - The proof in the side-car SHOULD be identical to the proof 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 a third party. Each verifying component in a pipeline SHOULD perform its own check.
References
- NIP-01 Basic protocol flow description
- NIP-03 OpenTimestamps Attestations for Events
- OpenTimestamps Bitcoin-anchored time attestations
Write a comment