Event lists show duplicate cards when a d-tag exists under more than one pubkey #175
Labels
No labels
app:activities
app:chat
app:chatelet
app:events
app:forum
app:libra
app:market
app:restaurant
app:tasks
app:wallet
app:webapp
bug
enhancement
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
aiolabs/webapp#175
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The events store keys by coordinate, and list views iterate the map directly. When the same calendar event has been published under two different author pubkeys, both land in the map and the feed renders it twice — once correct, once stale.
getEventById(:183) already handles this correctly — it scans for matching d-tags and keeps the newest bycreatedAt— so the detail page is right while the list is wrong.upsertEvent's reconciliation doesn't help either: it only fires for the draft↔published pair (empty pubkey vs real), and two genuinely published copies are both non-drafts.Live on aio-demo
Of 24 distinct d-tags on the relay, 13 exist under two pubkeys — 37 calendar events for 24 logical ones:
Note the second one: the orphaned copy still advertises no
tickets_available, which this app renders as "Unlimited tickets". That's precisely the lieaiolabs/events#62was released to stop — and it is still being told on the feed by the stale copy, because the fix only reaches events the current identity republishes.Why there are two
The old copies all share
created_at 1780492940— a single bulk publish around early June — and their pubkeys recur across events, so specific organizer accounts were re-keyed at some point. NIP-52 addressable replacement is scoped to(kind, pubkey, d), so a new key means a new coordinate and the old event is orphaned rather than replaced. The relay is behaving correctly.Fix
Dedupe by d-tag in the list computeds with the same newest-wins rule
getEventByIdalready applies:upcomingEventsandpastEventsboth derive fromevents, so they inherit it. Worth checking any other directeventsMap.values()consumer at the same time (:185is insidegetEventById, which is already correct).Keeping
eventsMapkeyed by coordinate is right — it is the addressable identity, and collapsing the key would lose the ability to tell the copies apart. The dedupe belongs at the read side.Related, not fixed here
The orphaned relay events cannot be cleaned up from our side: a NIP-09 delete must be signed by the key that published them, and those accounts no longer hold it (the bunker may, which is worth checking before assuming they are permanent). Raising that separately against the backend — it is the residue of an identity change, and the same thing will happen again on the next re-key unless something deletes or re-signs on transition.
Independent of #143, which is about the value of the total on a single correct event rather than about duplicates.
Retracting the proposed fix — it would be a security regression
The dedupe I suggested (by d-tag, newest
createdAtwins) is wrong.stores/events.spec.ts:69already tests the property it would break:Keeping both coordinates is deliberate. My fix would have collapsed them and handed the win to whoever published most recently — which is exactly the impostor. I'd have traded a cosmetic duplicate for an event-hijack vector.
The underlying reason is protocol-level: in Nostr the addressable identity is
(kind, pubkey, d). The d-tag alone is not an identity. Two same-d-tag events from different pubkeys are genuinely different events, and no client can tell "the organizer rotated keys" from "someone is impersonating them" without an attestation. NIP-41 is the answer, and it doesn't exist yet — the migration script that caused this cited it as what would close the gap in a later phase.getEventById's newest-wins has the same weakness, incidentally, which its own comment half-acknowledges ("UsegetByCoordinatewhen the author is known"). Not making it worse by spreading that rule to the lists.The actual fix is at the relay, not the client
These 13 orphans are our own junk on our own relay, not foreign events we have to tolerate.
nostrrelaysoft-deletes, and its query builder excludes deleted rows from every read:So marking the 13 orphaned event ids
deleted=trueon demo's relay removes them at source. No protocol change, no client change, no security property weakened, and it's reversible with oneUPDATE.That also scopes the problem correctly: this is a one-off artifact of the June local→bunker migration on a single staging instance. The other hosts never ran it and have no orphans. Building client-side machinery for it would be solving a recurring problem we don't have.
Proposed close
Prune demo's relay, leave the store semantics alone, and close this as not-a-client-bug. The genuinely useful residue is on
aiolabs/lnbits#57: repairs must preserve identity, becauseprovision()re-keying an account is what creates orphans in the first place.Closing — resolved at the relay, no client change warranted.
Pruned demo's relay: 13 superseded events marked
deleted=true, so 37 → 24 calendar events, 24 distinct d-tags, zero orphans. Each event now resolves to exactly one copy under its current identity, and "Alpaca Therapy" publishesavail=0rather than an absent tag.The prune only touched an event when its pubkey was not a current account and the same d-tag existed under one that was — so genuine third-party events with unrecognised pubkeys were left alone. DB backed up to
/root/ext_nostrrelay.sqlite3.pre-prune, ids in/root/relay-prune-backup.txt, reversible with a singleUPDATE.Store semantics deliberately unchanged: keeping both coordinates is the anti-hijack property tested at
stores/events.spec.ts:69, and the dedupe I originally proposed would have handed the win to the later publisher.The durable lesson is recorded on
aiolabs/lnbits#57— repairs must preserve identity, becauseprovision()re-keying an account is what produces orphans, and that is the live path now that the one-off migration is done.