Skip to content

Commit 2f5f21e

Browse files
alltheseasclaudeJSKitty
authored
feat: Route DM gift wraps to recipient's inbox relays (NIP-17 kind 10050) (#44)
* feat: Route DM gift wraps to recipient's inbox relays (NIP-17 kind 10050) Add inbox_relays module that fetches, caches, and publishes kind 10050 relay lists so DM gift wraps are delivered to each recipient's preferred inbox relays instead of broadcasting to all pool relays. - Fetch recipient's 10050 relay list with 1h cache (60s on error) - Route gift_wrap_to() inbox relays, fall back to pool on failure - Publish own 10050 on connect advertising read-capable relays - Republish 10050 (debounced) on every relay config change Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add unit tests for inbox_relays tag parsing, cache, and debounce 9 tests covering: - Tag parsing: extracts URLs, ignores non-relay tags, handles empty/missing - Cache: store/retrieve, TTL expiry, empty results, error short TTL - Debounce: generation counter supersedes earlier calls Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Prevent cache stampede in inbox relay fetches with double-checked locking When multiple messages are sent rapidly to the same recipient with a cold cache, each would spawn duplicate fetches for the same kind 10050 event. This wasted bandwidth and put unnecessary pressure on relays. Implementation: - Extracted get_or_fetch_with_lock: generic function with injectable fetch - Production get_or_fetch_inbox_relays wraps it with fetch_inbox_relays - Tests use same function with mock fetch, eliminating code duplication - Double-checked locking: fast path (cache hit) → per-key lock → double-check - Only first concurrent caller fetches; others wait ~5s then hit cache - Different pubkeys never block each other (per-key async Mutex) Memory management (strictly bounded, even on cancellation/panic): - FETCH_LOCKS stores Weak<Mutex> references, not Arc - Mutex allocation freed when Arc refcount drops (immediate) - FetchLockEntryCleanup drop guard ensures cleanup on normal return, task cancellation, and panic unwind - Drop implementation checks Arc::strong_count == 2 (guard + upgrade temp) to detect last holder, then removes map entry - Periodic retain() every 100 misses as fallback safety net - True bounded growth: map size = in-flight fetches only, no idle leak Performance: - Periodic pruning: O(n/100) amortized cost vs O(n) per miss - Drop-guard cleanup: O(1) per call, runs unconditionally - Avoids global critical section bottleneck under heavy miss fan-out Testing (production code path, zero duplication): - concurrent_fetches_for_same_pubkey_serialize: 10 tasks → 1 fetch, verifies lock entry removed after all waiters complete - fetch_locks_do_not_accumulate_after_calls_complete: verifies drop-guard removes entries immediately after single-caller fetches - cancelled_fetch_cleans_up_lock_entry: spawns task with long sleep, aborts it, verifies entry still removed via drop guard - TEST_GLOBALS_LOCK serializes ALL tests touching global statics - All 12 unit tests pass with --test-threads=16 Addresses PR feedback from reviewer testing between Vector and 0xChat. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: alltheseas <alltheseas@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: JSKitty <mail@jskitty.cat>
1 parent 925f0e8 commit 2f5f21e

6 files changed

Lines changed: 740 additions & 13 deletions

File tree

src-tauri/src/commands/relays.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,7 @@ pub async fn toggle_default_relay<R: Runtime>(handle: AppHandle<R>, url: String,
410410
println!("[Relay] Disabled default relay: {}", normalized_url);
411411
}
412412
}
413+
crate::inbox_relays::republish_inbox_relays_debounced();
413414
}
414415

415416
Ok(true)
@@ -452,6 +453,7 @@ pub async fn add_custom_relay<R: Runtime>(handle: AppHandle<R>, url: String, mod
452453
if let Err(e) = client.pool().connect_relay(&new_relay.url).await {
453454
eprintln!("[Relay] Failed to connect to new relay: {}", e);
454455
}
456+
crate::inbox_relays::republish_inbox_relays_debounced();
455457
}
456458
Err(e) => eprintln!("[Relay] Failed to add relay to pool: {}", e),
457459
}
@@ -483,6 +485,9 @@ pub async fn remove_custom_relay<R: Runtime>(handle: AppHandle<R>, url: String)
483485
}
484486
}
485487

488+
// Republish regardless of pool removal result — config changed either way
489+
crate::inbox_relays::republish_inbox_relays_debounced();
490+
486491
Ok(true)
487492
}
488493

@@ -525,6 +530,7 @@ pub async fn toggle_custom_relay<R: Runtime>(handle: AppHandle<R>, url: String,
525530
println!("[Relay] Disabled custom relay: {}", url);
526531
}
527532
}
533+
crate::inbox_relays::republish_inbox_relays_debounced();
528534
}
529535

530536
Ok(true)
@@ -568,6 +574,8 @@ pub async fn update_relay_mode<R: Runtime>(handle: AppHandle<R>, url: String, mo
568574
Err(e) => eprintln!("[Relay] Failed to update relay mode: {}", e),
569575
}
570576
}
577+
// Republish regardless — relay was removed and mode config changed either way
578+
crate::inbox_relays::republish_inbox_relays_debounced();
571579
}
572580

573581
Ok(true)
@@ -830,6 +838,20 @@ pub async fn connect<R: Runtime>(handle: AppHandle<R>) -> bool {
830838
// Connect to all added relays
831839
client.connect().await;
832840

841+
// Post-connect: publish our kind 10050 (DM Relay List) so other clients know
842+
// where to send gift-wrapped DMs to us.
843+
tokio::spawn(async {
844+
// Small delay to let relay connections stabilise
845+
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
846+
let client = match NOSTR_CLIENT.get() {
847+
Some(c) => c,
848+
None => return,
849+
};
850+
if let Err(e) = crate::inbox_relays::publish_inbox_relays(client).await {
851+
eprintln!("[Relay] Failed to publish inbox relays: {}", e);
852+
}
853+
});
854+
833855
// Post-connect: force-regenerate device KeyPackage if flagged by migration 13
834856
// (v0.3.0 upgrade: old keypackages used incompatible MLS engine format)
835857
tokio::spawn(async {

0 commit comments

Comments
 (0)