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 20:52:41 -06:00
committed by GitHub
parent 6f8e2d3064
commit 9a1ae6f9b5
27 changed files with 1522 additions and 128 deletions

View File

@@ -147,6 +147,12 @@ final class NIP10Tests: XCTestCase {
XCTAssertEqual(tr.is_reply_to_root, true)
}
/// Tests NIP-10 relay hint behavior and pubkey propagation in reply tags.
///
/// Validates that when building a reply:
/// - Root "e" tag lacks pubkey (since parent's e-tag didn't include one)
/// - Reply "e" tag includes the `replying_to.pubkey` at position 4
/// - Corresponding "p" tags are present for propagated pubkeys
func test_marker_reply() async {
let note_json = """
{
@@ -184,10 +190,13 @@ final class NIP10Tests: XCTestCase {
let reply = await build_post(state: test_damus_state, post: .init(string: "hello"), action: .replying_to(note), uploadedMedias: [], pubkeys: [pk] + note.referenced_pubkeys.map({pk in pk}))
let root_hex = "00152d2945459fb394fed2ea95af879c903c4ec42d96327a739fa27c023f20e0"
// NIP-10 format: ["e", <event-id>, <relay-url>, <marker>, <pubkey>]
// Root tag doesn't have pubkey since parent event's e-tag didn't include one
// Reply tag includes replying_to.pubkey
XCTAssertEqual(reply.tags,
[
["e", root_hex, "wss://nostr.mutinywallet.com/", "root"],
["e", replying_to_hex, "", "reply"],
["e", replying_to_hex, "", "reply", "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e"],
["p", "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e"],
["p", "6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e"],
])
@@ -254,4 +263,175 @@ final class NIP10Tests: XCTestCase {
}
}
// MARK: - Relay Hints Tests
/// Tests that NoteRef correctly parses pubkey from position 4 of e-tags per NIP-10.
func test_noteref_parses_pubkey_from_etag() {
// NIP-10 format: ["e", <event-id>, <relay-url>, <marker>, <pubkey>]
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let pubkeyHex = "f7dac46aa270f7287606a22beb4d7725573afa0e028cdfac39a4cb2331537f66"
let relayUrl = "wss://relay.example.com"
let tags = [
["e", eventIdHex, relayUrl, "reply", pubkeyHex]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
let noteRef = noteRefs.first!
XCTAssertEqual(noteRef.note_id.hex(), eventIdHex)
XCTAssertEqual(noteRef.relay, relayUrl)
XCTAssertEqual(noteRef.marker, .reply)
XCTAssertEqual(noteRef.pubkey?.hex(), pubkeyHex)
}
/// Tests that NoteRef handles e-tags without pubkey (backwards compatibility).
func test_noteref_parses_without_pubkey() {
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let relayUrl = "wss://relay.example.com"
let tags = [
["e", eventIdHex, relayUrl, "root"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
let noteRef = noteRefs.first!
XCTAssertEqual(noteRef.note_id.hex(), eventIdHex)
XCTAssertEqual(noteRef.relay, relayUrl)
XCTAssertEqual(noteRef.marker, .root)
XCTAssertNil(noteRef.pubkey)
}
/// Tests that NoteRef.tag includes pubkey when present.
func test_noteref_tag_includes_pubkey() {
let noteId = NoteId(hex: "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb")!
let pubkey = Pubkey(hex: "f7dac46aa270f7287606a22beb4d7725573afa0e028cdfac39a4cb2331537f66")!
let relayUrl = "wss://relay.example.com"
let noteRef = NoteRef(note_id: noteId, relay: relayUrl, marker: .reply, pubkey: pubkey)
let tag = noteRef.tag
XCTAssertEqual(tag.count, 5)
XCTAssertEqual(tag[0], "e")
XCTAssertEqual(tag[1], noteId.hex())
XCTAssertEqual(tag[2], relayUrl)
XCTAssertEqual(tag[3], "reply")
XCTAssertEqual(tag[4], pubkey.hex())
}
/// Tests that NoteRef.tag omits pubkey when nil.
func test_noteref_tag_omits_pubkey_when_nil() {
let noteId = NoteId(hex: "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb")!
let relayUrl = "wss://relay.example.com"
let noteRef = NoteRef(note_id: noteId, relay: relayUrl, marker: .root, pubkey: nil)
let tag = noteRef.tag
XCTAssertEqual(tag.count, 4)
XCTAssertEqual(tag[0], "e")
XCTAssertEqual(tag[1], noteId.hex())
XCTAssertEqual(tag[2], relayUrl)
XCTAssertEqual(tag[3], "root")
}
/// Tests nip10_reply_tags includes pubkey when replying directly to a note.
func test_nip10_reply_tags_direct_reply_includes_pubkey() {
let parentNote = NostrEvent(
content: "parent note",
keypair: test_keypair,
kind: 1,
tags: []
)!
let relayUrl = RelayURL("wss://relay.example.com")
let tags = nip10_reply_tags(replying_to: parentNote, keypair: test_keypair, relayURL: relayUrl)
XCTAssertEqual(tags.count, 1)
let rootTag = tags[0]
// Should be: ["e", <id>, <relay>, "root", <pubkey>]
XCTAssertEqual(rootTag.count, 5)
XCTAssertEqual(rootTag[0], "e")
XCTAssertEqual(rootTag[1], parentNote.id.hex())
XCTAssertEqual(rootTag[2], "wss://relay.example.com")
XCTAssertEqual(rootTag[3], "root")
XCTAssertEqual(rootTag[4], parentNote.pubkey.hex())
}
/// Tests nip10_reply_tags preserves root pubkey from parent's e-tag.
func test_nip10_reply_tags_preserves_root_pubkey() {
let rootId = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let rootPubkey = "f7dac46aa270f7287606a22beb4d7725573afa0e028cdfac39a4cb2331537f66"
// Parent note is a reply to some root
let parentNote = NdbNote(
content: "reply to root",
keypair: test_keypair,
kind: 1,
tags: [
["e", rootId, "wss://root-relay.com", "root", rootPubkey]
]
)!
let relayUrl = RelayURL("wss://reply-relay.com")
let tags = nip10_reply_tags(replying_to: parentNote, keypair: test_keypair, relayURL: relayUrl)
XCTAssertEqual(tags.count, 2)
// Root tag should preserve pubkey from parent's e-tag
let rootTag = tags[0]
XCTAssertEqual(rootTag.count, 5)
XCTAssertEqual(rootTag[0], "e")
XCTAssertEqual(rootTag[1], rootId)
XCTAssertEqual(rootTag[3], "root")
XCTAssertEqual(rootTag[4], rootPubkey)
// Reply tag should include parent's pubkey
let replyTag = tags[1]
XCTAssertEqual(replyTag.count, 5)
XCTAssertEqual(replyTag[0], "e")
XCTAssertEqual(replyTag[1], parentNote.id.hex())
XCTAssertEqual(replyTag[3], "reply")
XCTAssertEqual(replyTag[4], parentNote.pubkey.hex())
}
/// Tests relay hint extraction from e-tags.
func test_relay_hint_extraction_from_etag() {
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let relayUrl = "wss://relay.example.com"
let tags = [
["e", eventIdHex, relayUrl, "reply"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
XCTAssertEqual(noteRefs.first?.relay, relayUrl)
}
/// Tests that empty relay hint is preserved as empty string.
func test_empty_relay_hint_preserved() {
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let tags = [
["e", eventIdHex, "", "reply"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
XCTAssertEqual(noteRefs.first?.relay, "")
}
}