8.9 KiB
G56-02 Implementation Plan: Non-Replaceable Proof Carrier with Append-Only Upgrades
Date: 2026-07-24
Finding: G56-02 — Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event
Approach: Switch the proof carrier from replaceable kind 11112 to non-replaceable kind 9999, with append-only upgrade events and earliest-anchor selection
No migration needed: the project has not been publicly released
Problem summary
Kind 11112 is in the 10000–19999 range, which Nostr relays treat as replaceable — they keep only the latest event per pubkey. This means:
- An attacker who breaks secp256k1 can publish a new kind 11112 that replaces the original on relays
- The code queries with
limit: 1and selects the newestcreated_at, which is the opposite of the proposal's "earliest valid Bitcoin anchor wins" rule - The original event — with its earlier Bitcoin anchor — can be hidden
Design
Two-event structure (unchanged conceptually, changed kind)
Event 1: Kind 1 Announcement (non-replaceable, already correct)
- Contains the human-readable attestation text and PQ keys/signatures in
algorithmtags - Signed by the user's existing secp256k1 identity
- Its full signed JSON hash is what gets submitted to OpenTimestamps
- No changes needed — kind 1 is already in the 0–9999 non-replaceable range
Event 2: Kind 9999 Proof Carrier (non-replaceable, changed from 11112)
- References Event 1 by
etag - Carries the
sha256tag (hash of Event 1's full signed JSON) - Carries the
otstag (base64 .ots proof) - New
ots_statustag:"pending"or"confirmed" - New
upgrade_oftag: references the previous proof carrier event ID (only on upgrade events) - Published as append-only — upgrades publish a NEW event, never replace
Append-only upgrade flow
1. User signs in → generates seed → derives PQ keys → signs kind 1 announcement
2. Submit kind 1 hash to OTS calendars → get pending .ots proof
3. Publish kind 1 announcement (permanent, non-replaceable)
4. Publish kind 9999 proof carrier with ots_status="pending" (permanent, non-replaceable)
5. Wait 10-30 min for Bitcoin confirmation
6. Upgrade the .ots proof via /ots-upgrade helper
7. Publish a NEW kind 9999 proof carrier with ots_status="confirmed" and upgrade_of="<pending event id>"
8. Both proof carriers remain on relays permanently — client picks the one with the earliest valid Bitcoin anchor
Re-migration flow (user lost seed, runs process again years later)
1. User signs in with same Nostr identity → generates new seed → derives new PQ keys
2. Publishes a new kind 1 announcement (new PQ keys, new block height)
3. Publishes new kind 9999 proof carriers (pending, then confirmed)
4. Client sees multiple kind 1 announcements + multiple proof carriers
5. Earliest valid Bitcoin anchor across all of them is the canonical root
6. Later migrations are valid if published while secp256k1 is still trustworthy
Earliest-anchor selection logic
A new pure function selectCanonicalProofCarrier(candidates, kind1Event):
- Input: array of kind 9999 events for a pubkey, plus the kind 1 announcement they reference
- Filter: only candidates whose
etag matches the kind 1 event ID - Filter: only candidates whose
sha256tag matcheshashFullEvent(kind1Event) - Filter: only candidates with a valid secp256k1 signature
- For each remaining candidate, parse and verify its OTS proof:
- If
ots_statusis "confirmed" and the OTS proof has a valid Bitcoin attestation → record the Bitcoin block height - If
ots_statusis "pending" or the OTS proof has no Bitcoin attestation → skip (not yet anchored)
- If
- Sort valid candidates by Bitcoin block height (ascending)
- Return the one with the earliest block height
- If tie, sort by event ID lexicographically (deterministic tie-break)
- If no candidate has a confirmed Bitcoin anchor, return the best pending candidate with a note
Files to change
1. www/js/pq-crypto.mjs
Change NIP_QR_KIND:
- Line 430: change
11112to9999 - Update comment: "Kind 9999 is a non-replaceable event (0–9999 range). Each publication is permanent. Upgrades publish a new event with
upgrade_ofreferencing the previous one."
Update buildKind11112Wrapper() → rename to buildProofCarrier():
- Add
['ots_status', 'pending']tag (or 'confirmed' when applicable) - Add optional
['upgrade_of', previousEventId]tag when upgrading - Keep existing
e,sha256,otstags
Update buildUpgradedEvent():
- Instead of copying tags and replacing the
otstag, build a completely new proof carrier event - Include
ots_status: 'confirmed' - Include
upgrade_of: <original proof carrier event id> - Keep the same
eandsha256tags (they reference the same kind 1 event)
Add selectCanonicalProofCarrier(candidates, kind1Event):
- Pure function, no network calls
- Takes array of proof carrier events and the kind 1 announcement
- Returns
{ canonical, allCandidates, errors }wherecanonicalis the event with the earliest valid Bitcoin anchor - OTS verification is async, so this function should be async and call
verifyOtsProof()for each candidate's OTS proof
2. www/verify.html
Update queryRelayForEvent():
- Change kind from
NIP_QR_KIND(which will be 9999) — already uses the constant, so this is automatic - Remove
limit: 1— fetch all events - Return an array of events instead of a single event
Update verifyEvent():
- Accept an array of proof carrier candidates
- Call
selectCanonicalProofCarrier()to pick the canonical one - Display all candidates in the UI with their OTS status and Bitcoin anchor height
- Highlight the canonical (earliest-anchor) one
- Still verify the kind 1 announcement and PQ signatures as before
Update the relay query tab:
- Query for both kind 1 and kind 9999 events for the pubkey
- Match proof carriers to announcements by
etag - Display the full migration history
3. www/index.html
Update queryRelayForKind11112() → rename to queryRelayForProofCarriers():
- Remove
limit: 1 - Return an array
Update the sign/publish flow:
- After signing the kind 1 announcement and getting the pending OTS proof, publish the kind 9999 proof carrier with
ots_status: 'pending'
Update publishUpgradedEvent():
- Build a NEW proof carrier event (not a replacement)
- Include
ots_status: 'confirmed'andupgrade_of: <pending event id> - Publish it as a new event
- Do NOT try to replace the old event
Update the resume workflow:
- Fetch all kind 9999 proof carriers for the pubkey
- Find the one that matches the persisted kind 1 event hash
- If a confirmed proof carrier already exists, the workflow is complete
- If only a pending proof carrier exists, continue the upgrade polling
Update savePendingOts():
- Store both the pending proof carrier event ID and the kind 1 event ID
- Store the
sha256target digest explicitly - On resume, validate that fetched events match persisted IDs
4. test/pq-crypto.test.mjs
Update existing tests:
- Any test referencing kind 11112 → change to 9999
Add new tests:
buildProofCarrierincludesots_statustagbuildUpgradedEventincludesots_status: 'confirmed'andupgrade_oftagselectCanonicalProofCarrierreturns the event with the earliest Bitcoin anchorselectCanonicalProofCarrierhandles ties deterministicallyselectCanonicalProofCarrierskips pending-only candidates when confirmed ones existselectCanonicalProofCarrierrejects candidates with wrongetag orsha256tagselectCanonicalProofCarrierrejects candidates with invalid secp signatures- Multiple migration attempts (re-migration) — earliest anchor wins across all
5. nip_proposal.md
- Change kind 11112 to kind 9999
- Update the description: "non-replaceable event (0–9999 range)"
- Add
ots_statusandupgrade_oftags to the tag specification - Update the verification rule: "fetch all kind 9999 events, select earliest valid Bitcoin anchor"
- Update the proof states section: upgrades publish new events, not replacements
6. README.md
- Update any reference to kind 11112 → kind 9999
- Update the migration flow description
- Update the implementation status table
Acceptance criteria
- No event in the system is replaceable — both kind 1 and kind 9999 are in the 0–9999 range
- Upgrades publish new events with
upgrade_ofreferencing the previous one — no event is ever replaced - Relay queries fetch all candidates, not
limit: 1 selectCanonicalProofCarrierdeterministically picks the earliest valid Bitcoin anchor- A later forged proof carrier cannot hide or supersede an earlier valid one
- Re-migration (publishing a second migration attempt) works naturally — all events are permanent
- All existing tests pass (updated for kind 9999)
- New tests for append-only upgrades and earliest-anchor selection pass
- The verify page displays the full candidate history and highlights the canonical one