Courier: deposit in parallel when the only route is a send queue (#1368)

* Deposit with couriers in parallel when the only route is a send queue

The courier path was nearly unreachable: NostrTransport claims any
favorite with a known npub as "reachable" regardless of connectivity,
and the mesh favorite exchange shares npubs, so for essentially every
courier-eligible recipient the router picked Nostr's reachable branch.
With no internet the message just sat in the relay send queue — in the
flagship scenario (internet shutdown, mutual friend standing right
there) the courier walked away carrying nothing.

Add Transport.canDeliverPromptly(to:), defaulting to reachability for
radio-backed transports; NostrTransport answers honestly by mirroring
the relay manager's connection state (fail-closed behind Tor). When the
chosen transport can't hand the message off promptly, the router now
also deposits a sealed copy with connected couriers. Double delivery is
harmless: receivers dedup by message ID, and delivered/read acks never
downgrade the carried status. When relays are up, sends are trusted and
no courier quota is spent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Track DM-relay connectivity, not any-relay, for prompt delivery

Codex review: NostrRelayManager.isConnected is true when any relay is
up, including geohash/custom relays — but private messages target the
default (gift-wrap-capable) relay set and queue when none of those are
connected. A lone geohash relay would have suppressed the parallel
courier deposit while the DM sat in the queue. Publish a DM-scoped
connectivity flag and drive canDeliverPromptly from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 17:45:57 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 75da63c9d7
commit a66c591f8e
6 changed files with 182 additions and 9 deletions
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
import Testing
import BitFoundation
@@ -85,6 +86,51 @@ struct NostrTransportTests {
#expect(didRefresh)
}
@Test("Prompt delivery requires both a known npub and a relay connection")
@MainActor
func canDeliverPromptlyTracksRelayConnectivity() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let recipient = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Alice"
)
let connectivity = CurrentValueSubject<Bool, Never>(false)
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
loadFavorites: { [noiseKey: relationship] },
relayConnectivity: { connectivity.eraseToAnyPublisher() }
)
)
// Reachable (npub known) but relays down: the peer must not be
// treated as promptly deliverable, or the router would skip the
// courier and let the message rot in the Nostr send queue.
#expect(transport.isPeerReachable(peerID))
#expect(!transport.canDeliverPromptly(to: peerID))
connectivity.send(true)
let deliverable = await TestHelpers.waitUntil(
{ transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(deliverable)
connectivity.send(false)
let undeliverable = await TestHelpers.waitUntil(
{ !transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(undeliverable)
}
@Test("Private message resolves short peer ID and emits decryptable packet")
@MainActor
func sendPrivateMessageResolvesShortPeerID() async throws {
@@ -375,7 +421,8 @@ struct NostrTransportTests {
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
) -> NostrTransport.Dependencies {
NostrTransport.Dependencies(
notificationCenter: notificationCenter,
@@ -385,7 +432,8 @@ struct NostrTransportTests {
currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent,
scheduleAfter: scheduleAfter
scheduleAfter: scheduleAfter,
relayConnectivity: relayConnectivity
)
}