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, "")
}
}

View File

@@ -0,0 +1,549 @@
//
// RelayHintsTests.swift
// damus
//
// Created by Daniel DAquino on 2026-02-02.
//
import XCTest
import NostrSDK
@testable import damus
/// Tests for relay hints functionality, ensuring relay hints are correctly extracted and used
/// for ephemeral relay connections per NIP-01 and NIP-10.
///
/// These tests verify that:
/// - Relay hints are correctly extracted from tags
/// - Ephemeral relays can be added and managed by RelayPool
/// - Relay hint lease management prevents premature cleanup
final class RelayHintsTests: XCTestCase {
// MARK: - Helper Functions
/// Creates and runs a local relay on a random available port.
/// - Returns: The running LocalRelay instance
private func setupRelay() async throws -> LocalRelay {
let builder = RelayBuilder()
let relay = LocalRelay(builder: builder)
try await relay.run()
print("Relay url: \(await relay.url())")
return relay
}
// MARK: - Test Cases
/// Test that TagSequence correctly extracts relay hints from e-tags per NIP-10.
/// This verifies the basic relay hint extraction functionality.
func testTagSequenceExtractsRelayHints() {
// Given: An e-tag with a relay hint at position 2
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)!
// When: Accessing the first tag
let firstTag = note.tags[0]
// Then: Relay hint should be extracted correctly
XCTAssertEqual(firstTag.relayHint?.absoluteString, relayUrl)
XCTAssertEqual(firstTag.relayHints.count, 1)
XCTAssertEqual(firstTag.relayHints.first?.absoluteString, relayUrl)
}
/// Test that TagSequence handles tags without relay hints gracefully.
func testTagSequenceHandlesEmptyRelayHint() {
// Given: An e-tag with an empty relay hint
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let tags = [
["e", eventIdHex, "", "reply"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
// When: Accessing the first tag
let firstTag = note.tags[0]
// Then: Relay hint should be nil for empty string
XCTAssertNil(firstTag.relayHint)
XCTAssertEqual(firstTag.relayHints.count, 0)
}
/// Test that TagSequence handles tags with fewer than 3 elements (no relay hint position).
func testTagSequenceHandlesShortTags() {
// Given: An e-tag with only 2 elements (no relay hint position)
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let tags = [
["e", eventIdHex]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
// When: Accessing the first tag
let firstTag = note.tags[0]
// Then: Relay hint should be nil
XCTAssertNil(firstTag.relayHint)
XCTAssertEqual(firstTag.relayHints.count, 0)
}
/// Test that RelayPool can add ephemeral relays.
/// This verifies the basic ephemeral relay management functionality.
func testRelayPoolAddsEphemeralRelay() async throws {
// Given: A relay pool and a relay descriptor marked as ephemeral
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
// When: Adding the ephemeral relay
try await pool.add_relay(descriptor)
// Then: The relay should be in the pool and marked as ephemeral
let descriptors = await pool.all_descriptors
let ephemeralRelays = descriptors.filter { $0.ephemeral }
XCTAssertEqual(ephemeralRelays.count, 1, "Should have exactly one ephemeral relay")
XCTAssertEqual(ephemeralRelays.first?.url, testRelayUrl)
XCTAssertTrue(ephemeralRelays.first?.ephemeral ?? false)
// Cleanup
await pool.close()
}
/// Test that ephemeral relay lease management works correctly.
/// This ensures ephemeral relays track leases and can be released.
func testEphemeralRelayLeaseManagement() async throws {
// Given: A relay pool with an ephemeral relay
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
try await pool.add_relay(descriptor)
// When: Acquiring a lease on the ephemeral relay
await pool.acquireEphemeralRelays([testRelayUrl])
// Then: The relay should still be in the pool
var descriptors = await pool.all_descriptors
var ephemeralRelays = descriptors.filter { $0.ephemeral }
XCTAssertEqual(ephemeralRelays.count, 1, "Should have ephemeral relay after acquiring lease")
// When: Releasing the lease
await pool.releaseEphemeralRelays([testRelayUrl])
// Give some time for cleanup
try await Task.sleep(for: .seconds(1))
// Then: The relay should be removed after releasing the lease
descriptors = await pool.all_descriptors
ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
XCTAssertEqual(ephemeralRelays.count, 0, "Ephemeral relay should be removed after releasing lease")
// Cleanup
await pool.close()
}
/// Test that multiple leases prevent premature cleanup of ephemeral relays.
/// This ensures the reference counting mechanism works correctly.
func testMultipleLeasesPreventsCleanup() async throws {
// Given: A relay pool with an ephemeral relay
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
try await pool.add_relay(descriptor)
// When: Acquiring two leases on the same ephemeral relay
await pool.acquireEphemeralRelays([testRelayUrl])
await pool.acquireEphemeralRelays([testRelayUrl])
// Then: Releasing one lease should not remove the relay
await pool.releaseEphemeralRelays([testRelayUrl])
try await Task.sleep(for: .milliseconds(500))
var descriptors = await pool.all_descriptors
var ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
XCTAssertEqual(ephemeralRelays.count, 1, "Should still have ephemeral relay after releasing one of two leases")
// When: Releasing the second lease
await pool.releaseEphemeralRelays([testRelayUrl])
try await Task.sleep(for: .seconds(1))
// Then: The relay should be removed after all leases are released
descriptors = await pool.all_descriptors
ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
XCTAssertEqual(ephemeralRelays.count, 0, "Ephemeral relay should be removed after releasing all leases")
// Cleanup
await pool.close()
}
/// Test that ensureConnected adds missing relays as ephemeral.
/// This verifies the automatic ephemeral relay addition when using relay hints.
func testEnsureConnectedAddsEphemeralRelays() async throws {
// Given: A relay pool without any relays
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
// Initially no relays
var descriptors = await pool.all_descriptors
XCTAssertEqual(descriptors.count, 0, "Should have no relays initially")
// When: Ensuring connection to a relay not in the pool
let connectedRelays = await pool.ensureConnected(to: [testRelayUrl], timeout: .seconds(3))
// Then: The relay should be added as ephemeral
descriptors = await pool.all_descriptors
let ephemeralRelays = descriptors.filter { $0.ephemeral }
XCTAssertGreaterThan(descriptors.count, 0, "Should have added the relay")
XCTAssertEqual(ephemeralRelays.count, 1, "Should have one ephemeral relay")
XCTAssertEqual(ephemeralRelays.first?.url, testRelayUrl)
print("Connected relays: \(connectedRelays.map { $0.absoluteString })")
// Cleanup
await pool.close()
}
/// Test that relay hints enable fetching events from relays not in the user's pool.
/// This is an end-to-end integration test that verifies:
/// - A note exists on relayA (with the note)
/// - User is connected to relayB (empty, no notes)
/// - Using a relay hint to relayA allows fetching the note successfully
func testRelayHintFetchesEventFromCorrectRelay() async throws {
// Given: Two relays - one with a note, one empty
let relayWithNote = try await setupRelay()
let emptyRelay = try await setupRelay()
let relayWithNoteUrl = RelayURL(await relayWithNote.url().description)!
let emptyRelayUrl = RelayURL(await emptyRelay.url().description)!
// Create a test note
let testNote = NostrEvent(content: "Test note on specific relay", keypair: test_keypair)!
// Send the note to relayWithNote only
let connectionToRelayWithNote = await connectToRelay(url: relayWithNoteUrl, label: "RelayWithNote")
sendEvents([testNote], to: connectionToRelayWithNote)
// Wait for the event to be received by the relay
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is connected ONLY to the empty relay (not relayWithNote)
let ndb = await test_damus_state.ndb
let networkManager = try await setupNetworkManager(with: [emptyRelayUrl], ndb: ndb)
// Verify the note is NOT in local NDB yet
let localNote = try? ndb.lookup_note_and_copy(testNote.id)
XCTAssertNil(localNote, "Note should not be in local NDB yet")
// Try to fetch WITHOUT relay hint (should fail since note is not on emptyRelay)
let lenderWithoutHint = try? await networkManager.reader.lookup(noteId: testNote.id, to: nil, timeout: .seconds(2))
XCTAssertNil(lenderWithoutHint, "Should not find note without relay hint (note is not on emptyRelay)")
// Then: Fetch WITH relay hint to relayWithNote (should succeed)
let lenderWithHint = try? await networkManager.reader.lookup(noteId: testNote.id, to: [relayWithNoteUrl], timeout: .seconds(5))
XCTAssertNotNil(lenderWithHint, "Should find note using relay hint")
// Verify the found note matches the original
var foundNote: NostrEvent?
lenderWithHint?.justUseACopy({ foundNote = $0 })
XCTAssertNotNil(foundNote, "Should be able to extract note from lender")
XCTAssertEqual(foundNote?.id, testNote.id, "Found note should match original")
XCTAssertEqual(foundNote?.content, testNote.content, "Note content should match")
// Cleanup
await networkManager.close()
}
/// Test that relay hints fall back to broadcasting when hinted relays don't respond.
/// This verifies the critical fallback mechanism that ensures notes can still be fetched
/// even when relay hints point to slow or unavailable relays.
func testRelayHintFallsBackToBroadcastWhenHintsDontRespond() async throws {
// Given: User has a relay with the note, but relay hint points to a relay WITHOUT the note
let userRelay = try await setupRelay()
let slowHintRelay = try await setupRelay()
let userRelayUrl = RelayURL(await userRelay.url().description)!
let slowHintRelayUrl = RelayURL(await slowHintRelay.url().description)!
// Create a test note
let testNote = NostrEvent(content: "Note for fallback test", keypair: test_keypair)!
// Send the note ONLY to user's relay (not to the hinted relay)
let userConnection = await connectToRelay(url: userRelayUrl, label: "UserRelay")
sendEvents([testNote], to: userConnection)
// Wait for the event to be received by the relay
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is connected to user's relay
let ndb = await test_damus_state.ndb
let networkManager = try await setupNetworkManager(with: [userRelayUrl], ndb: ndb)
// Try to fetch WITH relay hint to slowHintRelay (which doesn't have the note)
// This should:
// 1. Try slowHintRelay first (will timeout/fail)
// 2. Fall back to broadcasting to userRelay
// 3. Successfully find the note
let lender = try? await networkManager.reader.lookup(noteId: testNote.id, to: [slowHintRelayUrl], timeout: .seconds(5))
// Then: Note should be found via fallback broadcast to user's relay
XCTAssertNotNil(lender, "Should find note via fallback broadcast despite bad relay hint")
var foundNote: NostrEvent?
lender?.justUseACopy({ foundNote = $0 })
XCTAssertNotNil(foundNote, "Should be able to extract note from lender")
XCTAssertEqual(foundNote?.id, testNote.id, "Found note should match original")
XCTAssertEqual(foundNote?.content, testNote.content, "Note content should match")
// Cleanup
await networkManager.close()
}
/// Test that relay hints from NIP-19 nevent entities are correctly used for lookups.
/// This verifies that nevent-style relay hints (common in nostr: URLs) work correctly.
func testRelayHintsFromNEventEntity() async throws {
// Given: A relay with a note, and nevent with relay hints
let hintedRelay = try await setupRelay()
let emptyRelay = try await setupRelay()
let hintedRelayUrl = RelayURL(await hintedRelay.url().description)!
let emptyRelayUrl = RelayURL(await emptyRelay.url().description)!
// Create a test note and send it to the hinted relay
let testNote = NostrEvent(content: "Note for nevent test", keypair: test_keypair)!
let hintedConnection = await connectToRelay(url: hintedRelayUrl, label: "HintedRelay")
sendEvents([testNote], to: hintedConnection)
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is connected ONLY to empty relay
let ndb = await test_damus_state.ndb
let networkManager = try await setupNetworkManager(with: [emptyRelayUrl], ndb: ndb)
// Create an NEvent with relay hints (simulating NIP-19 parsing)
let nevent = NEvent(noteid: testNote.id, relays: [hintedRelayUrl])
// Verify nevent has relay hints
XCTAssertEqual(nevent.relays.count, 1, "NEvent should have one relay hint")
XCTAssertEqual(nevent.relays.first, hintedRelayUrl, "NEvent relay hint should match")
// Then: Use findEvent with nevent's relay hints (as it would be used in real code)
let targetRelays = nevent.relays.isEmpty ? nil : nevent.relays
let result = await networkManager.reader.findEvent(query: .event(evid: nevent.noteid, find_from: targetRelays))
// Verify we got the event back
guard case .event(let foundNote) = result else {
XCTFail("Should find note using nevent relay hints via findEvent")
return
}
XCTAssertEqual(foundNote.id, testNote.id, "Found note should match nevent note ID")
XCTAssertEqual(foundNote.content, testNote.content, "Found note content should match")
// Cleanup
await networkManager.close()
}
/// Test that relay hints work correctly when some events are cached in NDB.
/// This verifies that cached events are returned from NDB and relay hints are only
/// used for non-cached events, avoiding unnecessary network calls.
func testRelayHintsWithNDBCachedEvents() async throws {
// Given: Some notes cached in NDB, one note on a relay
let relay = try await setupRelay()
let relayUrl = RelayURL(await relay.url().description)!
// Create three notes
let cachedNoteA = NostrEvent(content: "Cached note A", keypair: test_keypair)!
let cachedNoteB = NostrEvent(content: "Cached note B", keypair: test_keypair)!
let uncachedNoteC = NostrEvent(content: "Uncached note C", keypair: test_keypair)!
// Store A and B in NDB (cached)
let ndb = await test_damus_state.ndb
storeEventsInNdb([cachedNoteA, cachedNoteB], ndb: ndb)
// Send only C to the relay
let connection = await connectToRelay(url: relayUrl, label: "Relay")
sendEvents([uncachedNoteC], to: connection)
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is set up
let networkManager = try await setupNetworkManager(with: [relayUrl], ndb: ndb)
// Then: Fetch all three notes with relay hints
// Fetch cached note A (should come from NDB, not network)
let lenderA = try? await networkManager.reader.lookup(noteId: cachedNoteA.id, to: [relayUrl], timeout: .seconds(3))
XCTAssertNotNil(lenderA, "Should find cached note A")
var foundA: NostrEvent?
lenderA?.justUseACopy({ foundA = $0 })
XCTAssertEqual(foundA?.id, cachedNoteA.id, "Cached note A should match")
XCTAssertEqual(foundA?.content, "Cached note A", "Cached note A content should match")
// Fetch cached note B (should come from NDB, not network)
let lenderB = try? await networkManager.reader.lookup(noteId: cachedNoteB.id, to: [relayUrl], timeout: .seconds(3))
XCTAssertNotNil(lenderB, "Should find cached note B")
var foundB: NostrEvent?
lenderB?.justUseACopy({ foundB = $0 })
XCTAssertEqual(foundB?.id, cachedNoteB.id, "Cached note B should match")
XCTAssertEqual(foundB?.content, "Cached note B", "Cached note B content should match")
// Fetch uncached note C (should use relay hints to fetch from network)
let lenderC = try? await networkManager.reader.lookup(noteId: uncachedNoteC.id, to: [relayUrl], timeout: .seconds(3))
XCTAssertNotNil(lenderC, "Should find uncached note C via relay hints")
var foundC: NostrEvent?
lenderC?.justUseACopy({ foundC = $0 })
XCTAssertEqual(foundC?.id, uncachedNoteC.id, "Uncached note C should match")
XCTAssertEqual(foundC?.content, "Uncached note C", "Uncached note C content should match")
// Verify all notes were found correctly
XCTAssertNotNil(foundA, "Note A should be found from cache")
XCTAssertNotNil(foundB, "Note B should be found from cache")
XCTAssertNotNil(foundC, "Note C should be found from network")
// Cleanup
await networkManager.close()
}
// MARK: - Helper Functions for Integration Test
/// Connects to a relay and waits for the connection to be established.
/// - Parameters:
/// - url: The relay URL to connect to
/// - label: Optional label for logging
/// - Returns: The connected RelayConnection instance
private func connectToRelay(url: RelayURL, label: String = "") async -> RelayConnection {
var connectionContinuation: CheckedContinuation<Void, Never>?
let relayConnection = RelayConnection(url: url, handleEvent: { _ in }, processUnverifiedWSEvent: { wsEvent in
let prefix = label.isEmpty ? "" : "(\(label)) "
switch wsEvent {
case .connected:
connectionContinuation?.resume()
case .message(let message):
print("RELAY_HINTS_TEST \(prefix): Received: \(message)")
case .disconnected(let closeCode, let string):
print("RELAY_HINTS_TEST \(prefix): Disconnected: \(closeCode); \(String(describing: string))")
case .error(let error):
print("RELAY_HINTS_TEST \(prefix): Received error: \(error)")
}
})
relayConnection.connect()
// Wait for connection to be established
await withCheckedContinuation { continuation in
connectionContinuation = continuation
}
return relayConnection
}
/// Sends events to a relay connection.
/// - Parameters:
/// - events: Array of NostrEvent to send
/// - connection: The RelayConnection to send events through
private func sendEvents(_ events: [NostrEvent], to connection: RelayConnection) {
for event in events {
connection.send(.typical(.event(event)))
}
}
/// Stores events in NostrDB for testing purposes.
/// - Parameters:
/// - events: Array of NostrEvent to store in NDB
/// - ndb: The Ndb instance to store events in
private func storeEventsInNdb(_ events: [NostrEvent], ndb: Ndb) {
for event in events {
do {
try ndb.add(event: event)
} catch {
XCTFail("Failed to store event in NDB: \(error)")
}
}
}
/// Sets up a NostrNetworkManager with the specified relay URLs.
/// - Parameters:
/// - urls: Array of RelayURL to add to the manager
/// - ndb: The Ndb instance to use
/// - Returns: Configured and connected NostrNetworkManager
private func setupNetworkManager(with urls: [RelayURL], ndb: Ndb) async throws -> NostrNetworkManager {
let delegate = TestNetworkDelegate(ndb: ndb, keypair: test_keypair, bootstrapRelays: urls)
let networkManager = NostrNetworkManager(delegate: delegate, addNdbToRelayPool: true)
// Manually add relays to the pool
for url in urls {
do {
try await networkManager.userRelayList.insert(relay: .init(url: url, rwConfiguration: .readWrite), force: true)
}
catch {
switch error {
case .relayAlreadyExists: continue
default: throw error
}
}
}
// Only connect and wait if we have relays to connect to
if !urls.isEmpty {
await networkManager.userRelayList.connect()
// Wait for relay pool to be ready
try await Task.sleep(for: .seconds(2))
}
return networkManager
}
}
// MARK: - Test Doubles
/// Test delegate for NostrNetworkManager that provides minimal configuration for testing
private final class TestNetworkDelegate: NostrNetworkManager.Delegate {
var ndb: Ndb
var keypair: Keypair
var latestRelayListEventIdHex: String?
var latestContactListEvent: NostrEvent?
var bootstrapRelays: [RelayURL]
var developerMode: Bool = false
var experimentalLocalRelayModelSupport: Bool = false
var relayModelCache: RelayModelCache
var relayFilters: RelayFilters
var nwcWallet: WalletConnectURL?
init(ndb: Ndb, keypair: Keypair, bootstrapRelays: [RelayURL]) {
self.ndb = ndb
self.keypair = keypair
self.bootstrapRelays = bootstrapRelays
self.relayModelCache = RelayModelCache()
self.relayFilters = RelayFilters(our_pubkey: keypair.pubkey)
}
}