NIP-3C: OTS Handling Policy for Nostr

Behavioural NIP that complements NIP-3B. Defines kind:30041 — an addressable replaceable event whose content is a JSON document declaring how the publisher creates, stores, strips, keeps, and serves OTS proofs. Five orthogonal rules + retention bounds + cooperative-upgrade flags + NIP-44 encrypted fields.

NIP3C

OTS Handling Policy for Nostr

draft optional

NIP3C specifies how a Nostr client or relay decides — declaratively and verifiably — to create, store, and service OpenTimestamps proofs of the form defined by NIP3B. Where NIP3B is the wire protocol (leaf scheme, kind:1041 beacon, ots/oldots sidecar fields, verification rules), NIP3C is the behaviour layer.

NIP3C requires NIP3B as a normative dependency. A consumer that only verifies proofs needs only NIP3B. A consumer that creates, stores, strips, keeps, serves, or cooperatively upgrades proofs MUST additionally implement NIP3C — or document explicitly which subset of the rules they honour.

The two NIPs are versioned independently.

1. Concepts

NIP3B defines two on-wire transports for an OpenTimestamps proof (see NIP3B § 2):

  • Sidecarots (or legacy oldots) top-level field on the target event JSON. The canonical post-confirmation home of the proof.
  • Kind:1041 beacon — a regular Nostr event whose content is a base64 calendar-pending .ots blob, with NIP-40 expiration set by the publisher (default seven days). Single-shot data store bridging the gap between calendar stamping and Bitcoin confirmation.

NIP3C uses the term otsdb to denote a consumer’s local store of proof bytes, indexed at minimum by target event id and by leaf digest. The otsdb may carry both calendar-pending blobs (waiting on Bitcoin) and Bitcoin-anchored proofs (final). NIP3C does not prescribe an on-disk format; reference implementations use bbolt or equivalent KV stores.

A NIP3C-compliant party publishes a single policy event of kind:30041 declaring its handling rules. The policy is declarative, not enforcing: receivers MAY use it to predict behaviour; absence MUST NOT be interpreted as denial.

2. The five decision rules

A NIP3C party makes five orthogonal decisions per event. Each is scoped by event kind, event publisher (pubkey), and one or both of two time-window predicates:

  • max_time_skew_seconds_wall — bound on now − event.created_at. This is the event’s age as seen by the policy holder. Useful for filters of the form “I only care about events younger than N seconds.” A value of 0 disables the wall check.
  • max_time_skew_seconds_proof — bound on |attestation_time − event.created_at|. This is the absolute skew between the OTS earliest-attested block time and the event’s declared created_at. Useful for “the proof’s anchored time is plausibly close to the event’s claimed time.” A value of 0 disables the proof-skew check.

Both windows are absolute deltas. Negative deltas (created_at after the proof block time) are physically impossible for a valid proof but are still expressible and bounded by the same threshold. Rules that operate on events without a verified proof at hand can only apply the wall window.

The five rules are:

2.1 Stamp

Should the policy holder create a calendar-pending stamp + kind:1041 beacon for an observed event?

Predicates:

  • kinds (whitelist; null = any)
  • exclude_kinds (blacklist; always implicitly includes 1040, 1041, 30041)
  • pubkeys, exclude_pubkeys (whitelist / blacklist of authors)
  • max_time_skew_seconds_wall — default 7 * 86400 (seven days)
  • calendars — list of OTS calendar URLs to submit through. Empty list delegates to the implementation’s default.

Default seven-day wall window: a Bitcoin-anchored proof of sha256(id‖sig) for an event whose created_at claims a date the publisher could not have honestly signed at adds no information. The proof binds the signature, not the claim, and the OTS attestation is post-block-time. The default refuses to stamp anything older than seven days unless the operator explicitly opts in to a larger window.

Pre-stamp obligations (MUST)

Before invoking the calendar, a NIP3C party MUST:

  1. Compute leaf = sha256(id‖sig) for the candidate event.
  2. Check the local otsdb for an existing entry keyed by leaf (or by the target event id, whichever the implementation indexes on). If found, use the stored proof and skip stamping.
  3. Query the configured relay set for an existing kind:1041 referencing the target event id (#e filter, Limit: 1, short timeout). If found, the calendar-pending proof already exists somewhere and the party SHOULD ingest it (subject to the Extract rule below) instead of producing a duplicate.

These obligations exist to prevent two NIP3C parties watching the same relays from duplicating calendar work for the same target.

2.2 Extract

When an event arrives carrying an ots (or oldots) sidecar field, should the policy holder pull the proof bytes into the local otsdb?

Predicates:

  • kinds (whitelist; null = any)
  • pubkeys (whitelist; null = any)
  • max_time_skew_seconds_wall
  • max_time_skew_seconds_proof (applied after verification)

Extraction stores the proof bytes (and the leaf-match / verification verdict) in the otsdb, keyed by leaf and by target event id. The proof bytes pulled from the sidecar are NOT modified.

Extraction is independent of whether the sidecar field is left on the stored event; that decision is rules § 2.3 and § 2.4 below.

2.3 Strip-on-store

When persisting an event into local relay storage, should the policy holder drop the ots / oldots field from the event JSON before persisting?

Predicates:

  • kinds, pubkeys, max_time_skew_seconds_wall

A storage layer that strips sidecars to save bytes typically pairs this with rule § 2.2 (Extract) so the proof is not lost — pulled into the otsdb, dropped from the event. Stripping without extracting is permitted but represents an explicit choice to discard the proof.

2.4 Keep-on-store

The complement of Strip: events whose sidecar is retained on the stored event JSON.

Predicates:

  • kinds, pubkeys, max_time_skew_seconds_wall

When Strip and Keep both match the same event, Strip wins (explicit space-savings trump preservation). When neither matches, the implementation default applies (see § 4 Default behaviour).

Strip and Keep operate independently of Extract. A relay can Extract+Keep (proof in both places, full redundancy), Extract+Strip (otsdb-only canonical, slim event JSON on disk), Keep-only (no otsdb), or Strip-only (proof discarded entirely).

2.5 Serve

When emitting an event on an outbound EVENT frame to a subscriber, should the policy holder attach an ots sidecar assembled from the otsdb (or kept inline)?

Predicates:

  • kinds, pubkeys, max_time_skew_seconds_wall
  • max_time_skew_seconds_proof (when a candidate proof’s attestation time is known)

Service is best-effort: the rule says “attempt to attach if a proof exists.” When no proof is available — neither in the otsdb nor inline on the event — the event ships without a sidecar. The absence is not an error; sidecars are opportunistic by design (NIP3B § 2.1).

A relay that wants subscribers to opt into receiving sidecars MAY gate the attach behind a connection-time hint (e.g. ?ots_sidecar=1 on the WebSocket URL). A relay that always attaches when a proof exists is also conformant. The choice is implementation-defined and SHOULD be reflected in the published policy via a free-form note; there is no required field — the hint is a delivery convention, not a policy dimension.

3. Cooperative upgrade

A cooperating consumer of kind:1041 events MAY, on behalf of the original publisher:

  1. Decode and parse the pending .ots from the 1041 content.
  2. Run ots upgrade against the calendar.
  3. Verify the upgraded proof locally per NIP3B § 4.
  4. Fetch the target event referenced by the e tag.
  5. Construct the sidecar form per NIP3B § 2.1 and publish it to the same relay set the 1041 was published on, to a configured publish set, or to the local relay’s own outbound subscribers — implementation choice.

Cooperative upgrade is opt-in. A NIP3C party MUST publish cooperates_on_1041: true in its kind:30041 policy event before performing cooperative upgrade on third-party beacons. Acting on one’s own beacons does not require cooperative-mode opt-in.

Encrypted kind:1041 events (those carrying the ["encrypted", "nip44"] tag — see NIP3B § 2.2.3) MUST be skipped by cooperative upgraders that do not hold a usable conversation key. “Skip” means do nothing; do not attempt and fail. Logging and counters are implementation-defined.

3.1 Bounded retry on the upgrade attempt

A cooperative party MAY retry ots upgrade against the calendar on a periodic tick for a given pending beacon. The retry MUST terminate when ANY of the following holds:

  • The upgrade succeeds and the sidecar is delivered.
  • The kind:1041’s expiration tag passes (NIP-40 lifecycle).
  • The party’s own max_time_skew_seconds_wall for the target event has been exceeded (the event is now too old for any of the party’s active rules to want a proof for it).
  • The party’s own max_time_skew_seconds_proof would be exceeded by any verifiable upgrade (this branch can usually only be evaluated after a successful upgrade — when the calendar’s earliest Bitcoin block time minus the event’s created_at already exceeds the bound, future retries cannot improve the situation).

A cooperative party MUST NOT publish a fresh kind:1041 event for the same target to extend the window: re-emission is explicitly prohibited by NIP3B § 2.2.1. Each kind:1041 carries its own seven-day budget; if the calendar fails to anchor in that window the proof is dropped and the party stops.

4. Default behaviour

Absent a kind:30041 policy event, a NIP3C party SHOULD apply a conservative default: do nothing that costs other people bandwidth or CPU.

Rule Default
Stamp disabled
Extract disabled
Strip-on-store disabled
Keep-on-store enabled (preserve sidecars as-received)
Serve enabled (forward whatever is inline)
Cooperate on 1041 disabled
Republish decorated disabled

This default produces a passive party: it does not stamp, does not maintain an otsdb, does not strip or modify event JSON, and does not act on third-party beacons. It DOES preserve and forward whatever sidecars were already on events it stored. The operator must explicitly opt in to anything more active by publishing a policy event.

5. Policy event — kind 30041

A pubkey publishes its NIP3C handling rules as an addressable replaceable event of kind 30041.

The kind number itself IS the schema version. A future incompatible policy schema is published under a new addressable kind, never as a version field inside this one. Implementations MUST NOT emit a version field in the content; readers MAY ignore one if a non-conforming publisher inserts it.

5.1 Event shape

{
  "kind": 30041,
  "created_at": 1778100000,
  "pubkey": "<author>",
  "tags": [
    ["d", ""],
    ["encrypted_fields", "stamp,retention"]
  ],
  "content": "<JSON document, see § 5.3>",
  "sig": "..."
}

Tags:

  • d (REQUIRED) — addressable replaceable scope per NIP-01. The empty string "" is the global default policy for this pubkey. Other scope identifiers are reserved for future spec extension (e.g. relay:wss://example.com for relay-specific overrides).
  • encrypted_fields (OPTIONAL) — comma-separated list of top-level keys in content whose value is NIP-44 ciphertext rather than plaintext JSON. See § 6 Encryption.

5.2 Discovery

To find a pubkey’s policy:

filter = { kinds: [30041], authors: [<pubkey>], "#d": [""] }

The latest event matching that filter is the active default policy. Replaceable semantics ensure only one is current per (pubkey, d) pair.

5.3 Content schema

{
  "stamp": {
    "enabled": true,
    "kinds": [1, 30023],
    "exclude_kinds": [1040, 1041, 30041],
    "pubkeys": null,
    "exclude_pubkeys": ["<spammer-pubkey>"],
    "max_time_skew_seconds_wall": 604800,
    "calendars": [
      "https://alice.btc.calendar.opentimestamps.org",
      "https://bob.btc.calendar.opentimestamps.org"
    ]
  },

  "extract": {
    "enabled": true,
    "kinds": [1, 30023],
    "pubkeys": null,
    "max_time_skew_seconds_wall": 31536000,
    "max_time_skew_seconds_proof": 86400
  },

  "strip": {
    "enabled": false,
    "kinds": null,
    "pubkeys": null,
    "max_time_skew_seconds_wall": 0
  },

  "keep": {
    "enabled": true,
    "kinds": null,
    "pubkeys": null,
    "max_time_skew_seconds_wall": 0
  },

  "serve": {
    "enabled": true,
    "kinds": null,
    "pubkeys": null,
    "max_time_skew_seconds_wall": 0,
    "max_time_skew_seconds_proof": 0
  },

  "retention": {
    "max_proofs": 100000,
    "max_age_days": 365,
    "per_kind": { "1": 50000, "30023": 10000 },
    "per_pubkey": null,
    "max_total_bytes": 1073741824
  },

  "cooperates_on_1041": false,
  "republishes_decorated_events": false
}

5.4 Common rule shape

Each of the five rule blocks (stamp, extract, strip, keep, serve) shares a common skeleton:

Field Type Meaning
enabled bool Master switch for this rule.
kinds int[] Whitelist; null = any kind.
exclude_kinds int[] Blacklist (always implicitly includes 1040, 1041, 30041 for stamp).
pubkeys string[] Whitelist of authors; null = any.
exclude_pubkeys string[] Blacklist of authors.
max_time_skew_seconds_wall int Bound on now − event.created_at. 0 disables the check. Defaults vary per rule.
max_time_skew_seconds_proof int Bound on `

Rule-specific extras follow.

5.5 Stamp-specific fields

  • calendars — list of OTS calendar URLs to submit through. Empty list delegates to the implementation default.

Default max_time_skew_seconds_wall: 604800 (seven days). See § 2.1 for rationale.

5.6 Extract-specific fields

  • max_time_skew_seconds_proof — anti-fraud bound. A relay may refuse to ingest proofs whose attestation time is too far from the event’s declared created_at.

Default max_time_skew_seconds_wall: 0 (no age cap on extraction; operators typically want to ingest historical proofs).

5.7 Retention

Storage caps for the otsdb. Per-kind and per-pubkey budgets are maps keyed by kind/pubkey hex string. null or absent = no cap on that axis.

Field Type Meaning
max_proofs uint64 Upper bound on stored proof count.
max_age_days int Drop proofs older than N days.
per_kind map[string]uint64 Per-target-kind quota.
per_pubkey map[string]uint64 Per-target-pubkey quota.
max_total_bytes uint64 Upper bound on otsdb size on disk.

5.8 cooperates_on_1041

Cooperative-upgrade gate. When true, this declarer’s upgrade-watcher will complete pending kind:1041 events authored by other pubkeys — running ots upgrade, verifying locally, and publishing the sidecar-decorated original. When false, the watcher acts only on the declarer’s own events.

Per § 3 Cooperative upgrade, this MUST be true before a party performs upgrade work on third-party beacons. Encrypted kind:1041 events are skipped silently when the cooperator does not hold a usable conversation key.

5.9 republishes_decorated_events

After a successful upgrade-and-decorate, controls whether this declarer re-broadcasts the decorated event to its publish set. true for relays that want to maximise sidecar reach; false for clients that prefer to let the original publisher propagate.

6. Encryption

Any value in content whose top-level key is listed in the encrypted_fields tag is NIP-44 ciphertext. The conversation-key counterparty is the publisher itself (self-encryption) unless specified otherwise via a separate addressing tag.

If encrypted_fields is "stamp,retention", the content may look like:

{
  "stamp":     "AjsLk1...",
  "extract":   { ... },
  "strip":     { ... },
  "keep":      { ... },
  "serve":     { ... },
  "retention": "AjsLk1...",
  "cooperates_on_1041": false
}

Decrypted ciphertext is itself a JSON value (object, array, or scalar) matching the schema position it occupies. A consumer that cannot decrypt a field MUST treat it as opaque and fall back to whatever default is sensible for the consumer’s role (typically: ignore the field; do not assume permissive or restrictive).

7. Examples

7.1 Active relay — extract proofs, serve sidecars, cooperate

{
  "stamp": {
    "enabled": true, "kinds": null,
    "exclude_kinds": [1040, 1041, 30041],
    "max_time_skew_seconds_wall": 604800,
    "calendars": []
  },
  "extract": {
    "enabled": true, "kinds": null,
    "max_time_skew_seconds_wall": 0,
    "max_time_skew_seconds_proof": 86400
  },
  "strip":  { "enabled": false },
  "keep":   { "enabled": true },
  "serve":  { "enabled": true,
              "max_time_skew_seconds_wall": 0,
              "max_time_skew_seconds_proof": 0 },
  "retention": { "max_proofs": 1000000 },
  "cooperates_on_1041": true,
  "republishes_decorated_events": true
}

7.2 Strict-archive client

{
  "stamp": {
    "enabled": true, "kinds": [1, 30023],
    "max_time_skew_seconds_wall": 604800,
    "calendars": ["https://alice.btc.calendar.opentimestamps.org"]
  },
  "extract": {
    "enabled": true, "kinds": [1, 30023],
    "max_time_skew_seconds_wall": 0,
    "max_time_skew_seconds_proof": 3600
  },
  "strip":  { "enabled": false },
  "keep":   { "enabled": true,  "kinds": [1, 30023] },
  "serve":  { "enabled": true,  "kinds": [1, 30023] },
  "retention": { "max_proofs": 100000, "max_age_days": 1825,
                 "per_kind": { "30023": 5000 } },
  "cooperates_on_1041": false,
  "republishes_decorated_events": false
}

7.3 Minimal passive relay — default, no policy event needed

A relay that publishes no kind:30041 inherits the conservative default specified in § 4. It preserves sidecars on inbound events, forwards them on outbound EVENT frames, and does nothing else — no stamping, no extraction, no cooperation. Operators who want any of those behaviours must publish a policy event explicitly.

8. Comparison with NIP3B

Concern NIP3B NIP3C
Leaf-digest scheme yes
Kind:1041 wire format yes
ots/oldots sidecar fmt yes
Verification rules yes
Stamping mechanics (-d) yes
When to stamp yes
Pre-stamp existence checks yes
Extract / strip / keep / serve yes
Cooperative-upgrade gate yes
Retry semantics yes
Policy event schema yes
Default behaviour yes

A library or tool that exposes NIP3B primitives without NIP3C behaviour is a verifier or stamper-as-tool. A library or tool that runs autonomously — making decisions about what to stamp, store, or forward — implements both.

9. References

  • NIP3B — Wire-protocol companion. NIP3C is a normative dependent of NIP3B; the wire format being decided about is specified there.
  • NIP-01 — Basic protocol flow.
  • NIP-40 — Expiration Timestamp.
  • NIP-44 — Encrypted Payloads (Versioned).

Write a comment
No comments yet.