NIP-3C: OTS Handling Policy for Nostr
- NIP3C
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):
- Sidecar —
ots(or legacyoldots) 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
.otsblob, with NIP-40expirationset 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 onnow − 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 declaredcreated_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— default7 * 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:
- Compute
leaf = sha256(id‖sig)for the candidate event. - 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. - Query the configured relay set for an existing kind:1041
referencing the target event id (
#efilter,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_wallmax_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_wallmax_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:
- Decode and parse the pending
.otsfrom the 1041 content. - Run
ots upgradeagainst the calendar. - Verify the upgraded proof locally per NIP3B § 4.
- Fetch the target event referenced by the
etag. - 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
expirationtag passes (NIP-40 lifecycle). - The party’s own
max_time_skew_seconds_wallfor 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_proofwould 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’screated_atalready 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.comfor relay-specific overrides).encrypted_fields(OPTIONAL) — comma-separated list of top-level keys incontentwhose 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 declaredcreated_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