Consume NIP-19 relay hints for event fetching

Extract and use relay hints from bech32 entities (nevent, nprofile, naddr)
and event tag references (e, q tags) to fetch events from hinted relays
not in the user's relay pool.

Changes:
- Parse relay hints from bech32 TLV data in URLHandler
- Pass relay hints through SearchType and NoteReference enums
- Add ensureConnected() to RelayPool for ephemeral relay connections
- Implement ephemeral relay lease management with race condition protection
- Add repostTarget() helper to extract relay hints from repost e tags
- Add QuoteRef struct to preserve relay hints from q tags (NIP-10/NIP-18)
- Support relay hints in replies with author pubkey in e-tags (NIP-10)
- Implement fallback broadcast when hinted relays don't respond
- Add comprehensive test coverage for relay hint functionality
- Add DEBUG logging for relay hint tracing during development

Implementation details:
- Connect to hinted relays as ephemeral, returning early when first connects
- Use total deadline to prevent timeout accumulation across hint attempts
- Decrement lease count before suspension points to ensure atomicity
- Fall back to broadcast if hints don't resolve or respond

Closes: https://github.com/damus-io/damus/issues/1147
Changelog-Added: Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies)
Signed-off-by: alltheseas
Signed-off-by: Daniel D'Aquino <daniel@daquino.me>
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Daniel D'Aquino <daniel@daquino.me
This commit is contained in:
alltheseas
2026-02-02 18:52:41 -08:00
committed by GitHub
co-authored by alltheseas Claude Opus 4.5 Daniel D'Aquino <daniel@daquino.me
parent 6f8e2d3064
commit 9a1ae6f9b5
27 changed files with 1522 additions and 128 deletions
@@ -101,18 +101,18 @@ struct InnerSearchResults: View {
case .nip05(let addr):
SearchingEventView(state: damus_state, search_type: .nip05(addr))
case .profile(let pubkey):
SearchingEventView(state: damus_state, search_type: .profile(pubkey))
SearchingEventView(state: damus_state, search_type: .profile(pubkey, relays: []))
case .hex(let h):
VStack(spacing: 10) {
SearchingEventView(state: damus_state, search_type: .event(NoteId(h)))
SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h)))
}
SearchingEventView(state: damus_state, search_type: .event(NoteId(h), relays: []))
SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h), relays: []))
}
case .note(let nid):
SearchingEventView(state: damus_state, search_type: .event(nid))
SearchingEventView(state: damus_state, search_type: .event(nid, relays: []))
case .nevent(let nevent):
SearchingEventView(state: damus_state, search_type: .event(nevent.noteid))
SearchingEventView(state: damus_state, search_type: .event(nevent.noteid, relays: nevent.relays))
case .nprofile(let nprofile):
SearchingEventView(state: damus_state, search_type: .profile(nprofile.author))
SearchingEventView(state: damus_state, search_type: .profile(nprofile.author, relays: nprofile.relays))
case .naddr(let naddr):
SearchingEventView(state: damus_state, search_type: .naddr(naddr))
case .multi(let multi):
@@ -199,13 +199,12 @@ struct SearchResultsView: View {
}
}
/*
struct SearchResultsView_Previews: PreviewProvider {
static var previews: some View {
SearchResultsView(damus_state: test_damus_state(), s)
}
}
*/
/// Interprets a raw search string and maps it to an appropriate `Search` case.
/// - Parameters:
/// - profiles: Profile index used when resolving profile-lookups from the query.
/// - contacts: Contact list used to prioritize or resolve profile-lookups.
/// - search new: The raw user-provided search string to interpret.
/// - Returns: A `Search` value representing the parsed query (e.g., `.nip05`, `.hashtag`, `.hex`, `.profile`, `.note`, `.nevent`, `.nprofile`, `.naddr`, or `.multi`), or `nil` if the input string is empty.
@MainActor
func search_for_string(profiles: Profiles, contacts: Contacts, search new: String) -> Search? {
@@ -240,6 +239,10 @@ func search_for_string(profiles: Profiles, contacts: Contacts, search new: Strin
}
if searchQuery.starts(with: "nevent"), case let .nevent(nevent) = Bech32Object.parse(searchQuery) {
#if DEBUG
print("[nevent] Parsed note ID: \(nevent.noteid.hex())")
print("[nevent] Parsed \(nevent.relays.count) relay hints: \(nevent.relays.map { $0.absoluteString })")
#endif
return .nevent(nevent)
}
@@ -15,8 +15,8 @@ enum SearchState {
}
enum SearchType: Equatable {
case event(NoteId)
case profile(Pubkey)
case event(NoteId, relays: [RelayURL])
case profile(Pubkey, relays: [RelayURL])
case nip05(String)
case naddr(NAddr)
}
@@ -32,15 +32,23 @@ struct SearchingEventView: View {
switch search_type {
case .nip05:
return "Nostr Address"
case .profile:
case .profile(_, _):
return "Profile"
case .event:
case .event(_, _):
return "Note"
case .naddr:
return "Naddr"
}
}
/// Performs the search described by `search` and updates `search_state` with the outcome.
///
/// Starts the lookup for the provided SearchType and transitions `search_state` from `.searching` to one of:
/// - `.found(event)` when the target event is located,
/// - `.found_profile(pubkey)` when a profile pubkey is located (including nip05 resolution),
/// - `.not_found` when the lookup fails or yields no result.
/// - Parameters:
/// - search: The SearchType to perform (nip05, event with optional relays, profile with optional relays, or naddr).
func handle_search(search: SearchType) {
self.search_state = .searching
@@ -76,18 +84,20 @@ struct SearchingEventView: View {
}
}
case .event(let note_id):
case .event(let note_id, let relays):
Task {
let res = await state.nostrNetwork.reader.findEvent(query: .event(evid: note_id))
let targetRelays = relays.isEmpty ? nil : relays
let res = await state.nostrNetwork.reader.findEvent(query: .event(evid: note_id, find_from: targetRelays))
guard case .event(let ev) = res else {
self.search_state = .not_found
return
}
self.search_state = .found(ev)
}
case .profile(let pubkey):
case .profile(let pubkey, let relays):
Task {
let res = await state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey))
let targetRelays = relays.isEmpty ? nil : relays
let res = await state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey, find_from: targetRelays))
guard case .profile(let pubkey) = res else {
self.search_state = .not_found
return
@@ -96,7 +106,8 @@ struct SearchingEventView: View {
}
case .naddr(let naddr):
Task {
let res = await state.nostrNetwork.reader.lookup(naddr: naddr)
let targetRelays = naddr.relays.isEmpty ? nil : naddr.relays
let res = await state.nostrNetwork.reader.lookup(naddr: naddr, to: targetRelays)
guard let res = res else {
self.search_state = .not_found
return
@@ -141,6 +152,6 @@ struct SearchingEventView: View {
struct SearchingEventView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
SearchingEventView(state: state, search_type: .event(test_note.id))
SearchingEventView(state: state, search_type: .event(test_note.id, relays: []))
}
}
}