NIP-3B: OpenTimestamps over Signed Events

Bitcoin-anchored time-attestation protocol for Nostr events. Binds an OpenTimestamps proof to the event signature, defining the leaf digest, two on-wire transports (ots/oldots sidecar field + kind:1041 beacon), verification rules, and NIP-44-encrypted private beacons.

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/oldots sidecar 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 is event.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 when encrypted_to is 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:

  1. 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.
  2. The proof’s .ots blob 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.)
  3. The leaf the proof commits to equals
    • sha256(event.id_bytes ‖ event.sig_bytes) for an ots proof, OR
    • sha256(event.id_bytes) for an oldots proof.

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 .ots blob 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
No comments yet.