fix(identity): select lower-ID owner events on timestamp ties

Restoring owner metadata from index relays selected the higher event ID when timestamps matched, contrary to NIP-01 replacement ordering.

Use the shared replacement comparator after filtering by owner, kind and valid signature. This affects profiles and relay lists fetched for identity adoption; publication gating and retry policy are unchanged.

Validation: the regression failed before the fix and passes for both kinds, including newer events and invalid or unrelated candidates. Focused owner tests, workspace all-target Clippy and formatting pass.

Assisted-by: GPT-6
This commit is contained in:
DanConwayDev
2026-09-17 15:48:53 +00:00
parent 6e9bf5981b
commit e519d00578
3 changed files with 46 additions and 5 deletions
+3
View File
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Restore relay-owner profiles and relay lists using the lower event ID when
fetched versions share a timestamp.
- Honor same-second lower-ID replacements when de-listing served repositories,
capturing superseded history, and choosing rollback or post-deletion state.
History capture time no longer overrides the original events' ID tie-break.
+2
View File
@@ -91,6 +91,8 @@ runtime:
or generated, is sent before at least one user-index relay has been
checked for its kind, every send is preceded by a per-relay re-check, and
an identity found on an index is adopted locally instead of overwritten.
Among verified owner events of a kind, the newest timestamp wins; equal
timestamps select the lowest event ID.
Transient failures retry with a capped backoff, and in private mode the
identity stays local so the relay's existence is never advertised
- Atomically checkpoint purgatory and rejected-event recovery state every 60
+41 -5
View File
@@ -605,11 +605,7 @@ fn newest_owner_event_of_kind(
events
.iter()
.filter(|event| event.pubkey == owner && event.kind == kind && event.verify().is_ok())
.max_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.cmp(&b.id))
})
.max_by(|a, b| crate::nostr::events::compare_replacement_events(a, b))
.cloned()
}
@@ -666,6 +662,46 @@ mod tests {
use super::*;
use nostr::nips::nip65;
#[test]
fn owner_identity_selection_uses_nip01_ordering() {
let owner = Keys::generate();
let stranger = Keys::generate();
for kind in [Kind::Metadata, Kind::RelayList] {
let build = |keys: &Keys, kind, timestamp, content: &str| {
EventBuilder::new(kind, content)
.custom_created_at(Timestamp::from_secs(timestamp))
.finalize(keys)
.unwrap()
};
let mut pair = [
build(&owner, kind, 1000, "first"),
build(&owner, kind, 1000, "second"),
];
pair.sort_by_key(|event| event.id);
let [winner, loser] = pair;
let mut invalid = build(&owner, kind, 2000, "invalid");
invalid.content.push_str("tampered");
let mut events = std::collections::BTreeSet::from([
winner.clone(),
loser,
build(&stranger, kind, 2000, "wrong author"),
build(&owner, Kind::TextNote, 2000, "wrong kind"),
invalid,
build(&owner, kind, 999, "older"),
]);
assert_eq!(
newest_owner_event_of_kind(&events, owner.public_key(), kind),
Some(winner)
);
let newer = build(&owner, kind, 1001, "newer");
events.insert(newer.clone());
assert_eq!(
newest_owner_event_of_kind(&events, owner.public_key(), kind),
Some(newer)
);
}
}
#[test]
fn identity_is_minimal_and_lists_only_this_relay_for_read_and_write() {
let config = Config::for_testing();