From 22bf3359f51fc258d08c8653c4381d1f560067af Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:50:01 -0400 Subject: [PATCH] =?UTF-8?q?nostr:=20revert=20the=20money-path=20split=20?= =?UTF-8?q?=E2=80=94=20all=20nostr=20on=20the=20mixnet=20(as=20prior=20bui?= =?UTF-8?q?lds),=20keep=20confirm-before-sent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/nostr_split_measure.rs | 200 -------------------------------- src/nostr/client.rs | 80 +++---------- 2 files changed, 16 insertions(+), 264 deletions(-) delete mode 100644 examples/nostr_split_measure.rs diff --git a/examples/nostr_split_measure.rs b/examples/nostr_split_measure.rs deleted file mode 100644 index 97d0ac6..0000000 --- a/examples/nostr_split_measure.rs +++ /dev/null @@ -1,200 +0,0 @@ -// RUNTIME verification of the money-path narrowing (two-client split). -// -// Builds the SAME two nostr-sdk clients that `run_service` now builds: -// * MONEY client -> grim::nym::NymWebSocketTransport (mixnet; scoped exit for -// relay.floonet.dev) -> kind-1059 gift-wraps ONLY. -// * GENERAL client -> stock nostr-sdk transport (CLEARNET-direct) -> identity -// (0/10002/10050) + profile/DM-relay lookups. -// -// The transport routing under test (NymWebSocketTransport -> pool.exit_for -> -// streamexit) is 100% real grim code; only the per-op client assignment (the -// split itself) is mirrored here so we can control connection lifecycle and read -// the scratch exit's per-stream byte log cleanly. -// -// The pool is pointed at a SCRATCH scoped exit (env SCRATCH_EXIT). That exit is a -// plain floonet-mixexit whose stdout logs `stream closed (X B in, Y B out)` — our -// byte counter. Because the GENERAL client is clearnet, the scratch exit never -// even opens a stream for identity/lookup: that is the proof they are OFF the exit. -// -// HOME=/tmp/e2e-home SCRATCH_EXIT="" cargo run --example nostr_split_measure - -use std::time::Duration; - -use nostr_sdk::{ - Client, EventBuilder, Filter, Keys, Kind, Metadata, RelayUrl, SubscriptionId, Tag, TagKind, - Timestamp, -}; - -const RELAY: &str = "wss://relay.floonet.dev"; - -async fn connect(client: &Client, url: &str) { - let _ = client.add_relay(url).await; - client.connect().await; - // Give the handshake time (clearnet ~instant, exit ~2-6s over the mixnet). - for _ in 0..40 { - if client - .relays() - .await - .values() - .any(|r| r.status() == nostr_sdk::RelayStatus::Connected) - { - return; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } -} - -#[tokio::main] -async fn main() { - let _ = rustls::crypto::ring::default_provider().install_default(); - let exit = - std::env::var("SCRATCH_EXIT").expect("set SCRATCH_EXIT to the scratch exit nym addr"); - println!("[split] HOME={:?}", std::env::var("HOME").ok()); - println!("[split] scratch exit = {exit}"); - - // Bring up the wallet's REAL nym stack (tunnel + scoped-exit streamexit client). - println!("[split] warm_up(): starting nym…"); - grim::nym::warm_up(); - let mut ready = false; - for _ in 0..360 { - if grim::nym::is_ready() { - ready = true; - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - println!("[split] nym is_ready = {ready}"); - // Sanity: the pool we wrote must resolve relay.floonet.dev -> the scratch exit. - match grim::nostr::pool::load().exit_for(RELAY) { - Some(e) if e == exit => println!("[split] pool.exit_for({RELAY}) -> scratch exit OK"), - other => println!("[split] WARN pool.exit_for -> {other:?} (expected scratch exit)"), - } - - let keys = Keys::generate(); - let me = keys.public_key(); - println!("[split] ephemeral npub pubkey = {}", me.to_hex()); - - // ================= PHASE 1: GENERAL (clearnet) ================= - // identity publish (kinds 10050 + 10002 + 0) + a profile lookup. None of this - // must touch the scratch exit. - println!("\n[split] ===== PHASE 1: general/clearnet (identity + lookup) ====="); - let general = Client::builder().signer(keys.clone()).build(); - connect(&general, RELAY).await; - - let dm_tags = vec![Tag::custom(TagKind::custom("relay"), [RELAY.to_string()])]; - let inbox = EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags); - let relay_list = EventBuilder::relay_list(RelayUrl::parse(RELAY).ok().map(|u| (u, None))); - let meta = EventBuilder::metadata(&Metadata::new().name("split-e2e")); - for (label, b) in [ - ("kind10050", inbox), - ("kind10002", relay_list), - ("kind0", meta), - ] { - match general.sign_event_builder(b).await { - Ok(event) => match general.send_event_to([RELAY], &event).await { - Ok(o) => println!("[split] general publish {label} -> id {}", o.val.to_hex()), - Err(e) => println!("[split] general publish {label} FAILED: {e}"), - }, - Err(e) => println!("[split] general sign {label} FAILED: {e}"), - } - } - // A profile (kind-0) lookup of a random pubkey — the general-client read path. - let stranger = Keys::generate().public_key(); - let f = Filter::new().kind(Kind::Metadata).author(stranger).limit(1); - let _ = general - .fetch_events_from([RELAY], f, Duration::from_secs(8)) - .await; - println!("[split] general profile lookup done (clearnet)"); - general.disconnect().await; - tokio::time::sleep(Duration::from_secs(2)).await; - println!("[split] PHASE 1 complete — scratch exit should show ZERO streams so far."); - - // ================= PHASE 2: MONEY (scoped exit) ================= - println!("\n[split] ===== PHASE 2: money/exit (subscribe + catch-up + 1059) ====="); - let money = Client::builder() - .signer(keys.clone()) - .websocket_transport(grim::nym::NymWebSocketTransport) - .build(); - connect(&money, RELAY).await; - let connected = money - .relays() - .await - .values() - .any(|r| r.status() == nostr_sdk::RelayStatus::Connected); - println!("[split] money client connected over scratch exit = {connected}"); - - // Gift-wrap inbox subscription + 3-day catch-up (the receive money path). - let since = Timestamp::from_secs(Timestamp::now().as_u64().saturating_sub(3 * 86_400)); - let giftwrap = Filter::new().kind(Kind::GiftWrap).pubkey(me).since(since); - let _ = money - .subscribe_with_id_to( - [RELAY], - SubscriptionId::new("split-giftwrap"), - giftwrap.clone(), - None, - ) - .await; - let _ = money - .fetch_events_from([RELAY], giftwrap, Duration::from_secs(20)) - .await; - println!("[split] money subscribe + catch-up done (exit)"); - - // The kind-1059 PUBLISH — a NIP-59 gift-wrap, exactly dispatch_dm's v2 path. - // Synthetic ~30 KB slatepack so the 1059 is a clear, large stream on the exit. - let receiver = Keys::generate().public_key(); - let slatepack = format!("BEGINSLATEPACK.{}.ENDSLATEPACK", "A".repeat(30_000)); - let content = format!("goblin:pay\n{slatepack}"); - let sent = money - .send_private_msg_to([RELAY], receiver, content, Vec::::new()) - .await; - let wrap_id = match sent { - Ok(o) => { - println!( - "[split] money 1059 publish -> gift-wrap id {}", - o.val.to_hex() - ); - Some(o.val) - } - Err(e) => { - println!("[split] money 1059 publish FAILED: {e}"); - None - } - }; - - // Confirm read-back over the SAME exit (dispatch_dm's silent-loss guard). - let mut confirmed_via_exit = false; - if let Some(id) = wrap_id { - let cf = Filter::new().id(id).limit(1); - if let Ok(evs) = money - .fetch_events_from([RELAY], cf, Duration::from_secs(15)) - .await - { - confirmed_via_exit = !evs.is_empty(); - } - } - println!("[split] 1059 confirmed via exit read-back = {confirmed_via_exit}"); - money.disconnect().await; // forces the scratch exit to log `stream closed (X in, Y out)` - tokio::time::sleep(Duration::from_secs(2)).await; - - // ================= PHASE 3: CLEARNET ORACLE ================= - // Independently confirm the 1059 actually LANDED on relay.floonet.dev. - println!("\n[split] ===== PHASE 3: clearnet oracle (did the 1059 land?) ====="); - let mut landed = false; - if let Some(id) = wrap_id { - let oracle = Client::builder().signer(keys.clone()).build(); - connect(&oracle, RELAY).await; - let of = Filter::new().id(id).limit(1); - if let Ok(evs) = oracle - .fetch_events_from([RELAY], of, Duration::from_secs(15)) - .await - { - landed = !evs.is_empty(); - } - oracle.disconnect().await; - } - println!("[split] 1059 present on relay.floonet.dev via CLEARNET oracle = {landed}"); - - println!("\n[split] ===== DONE. Read the scratch exit log for the byte breakdown. ====="); - println!("[split] expectation: 0 streams during phase 1; 1 money stream in phase 2 whose"); - println!("[split] `X B in` is dominated by the ~30 KB 1059 publish (identity+lookup absent)."); -} diff --git a/src/nostr/client.rs b/src/nostr/client.rs index f3e7006..3125a80 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -101,22 +101,8 @@ pub struct NostrService { /// Directory holding identity.json. nostr_dir: PathBuf, - /// MONEY-PATH SDK client — carries ONLY kind-1059 gift-wraps (the slatepack - /// payment traffic: publishing a payment/control wrap, plus the gift-wrap - /// inbox subscribe + catch-up + send-confirm read-back). Its transport is the - /// Nym [`NymWebSocketTransport`], so every byte rides the mixnet — the scoped - /// exit for `relay.floonet.dev`, the public tunnel for any other relay. NEVER - /// clearnet: who-pays-whom and "I am listening for payments to this pubkey" - /// must stay on the mixnet. Present while the service loop runs. + /// SDK client, present while the service loop runs. client: RwLock>, - /// GENERAL SDK client — everything that is NOT a slatepack: our replaceable - /// identity events (kinds 0/3/10002/10050), the discovery-indexer fan-out, and - /// recipient profile / kind-10050 DM-relay lookups. Uses nostr-sdk's stock - /// transport = CLEARNET-direct, never the mixnet, so the metered scoped exit is - /// no longer starved by the wallet's whole relay session (identity + discovery - /// + catch-up firehose). These are all PUBLIC events/queries; keeping them off - /// the exit is the money-path narrowing. Present while the service loop runs. - general_client: RwLock>, /// Handle to the service's tokio runtime. One-shot fetches (e.g. profile /// lookups) from worker threads MUST run here, not on a throwaway runtime: /// the relay connections (incl. the custom Nym mixnet transport) are driven @@ -173,7 +159,6 @@ impl NostrService { store: Arc::new(store), nostr_dir, client: RwLock::new(None), - general_client: RwLock::new(None), rt_handle: RwLock::new(None), started: AtomicBool::new(false), shutdown: AtomicBool::new(false), @@ -233,8 +218,7 @@ impl NostrService { /// relays (NIP-65/gossip), which we won't otherwise be connected to. Blocking; /// call from a worker thread. pub fn fetch_profile_blocking(&self, hex: &str, hints: &[String]) -> Option { - // A profile (kind-0) lookup is general traffic — clearnet general client. - let client = self.general_client.read().clone()?; + let client = self.client.read().clone()?; let pk = PublicKey::from_hex(hex).ok()?; let hints: Vec = hints.to_vec(); // Run on the SERVICE runtime — the relay connections (and the custom Nym @@ -285,8 +269,7 @@ impl NostrService { /// field absent, or relays unreachable) = treat as accepting. Async — safe to /// call from the service runtime. Fail-open: only `Some(false)` blocks. pub async fn accepts_requests(&self, hex: &str) -> Option { - // Reading a peer's kind-0 preference is general traffic — clearnet client. - let client = self.general_client.read().clone()?; + let client = self.client.read().clone()?; let pk = PublicKey::from_hex(hex).ok()?; let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1); // First-event-wins, scoped to our own connected relays (cap 8s): return on @@ -311,8 +294,7 @@ impl NostrService { /// Republish our kind-0 profile + kind-10050 DM relays (e.g. after toggling /// the incoming-requests preference) so the change propagates immediately. pub async fn republish_identity(self: &Arc) { - // Identity events (kinds 0/10002/10050) are published clearnet, off the exit. - let client = { self.general_client.read().clone() }; + let client = { self.client.read().clone() }; if let Some(client) = client { publish_identity(self, &client).await; } @@ -567,7 +549,7 @@ impl NostrService { let content = protocol::build_payment_content(slatepack); let tags = protocol::build_rumor_tags(note); - let (urls, v3) = self.send_targets(&receiver, relay_hints).await; + let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await; // NIP-17 delivers to the RECIPIENT's relays, which may differ from ours; // dial any we don't already hold so the gift wrap actually reaches their @@ -596,7 +578,7 @@ impl NostrService { let content = protocol::build_control_content(); let tags = protocol::build_control_tags(slate_id); - let (urls, v3) = self.send_targets(&receiver, relay_hints).await; + let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await; connect_relays(&client, &urls).await; @@ -671,10 +653,11 @@ impl NostrService { /// advertises `nip44_v3`; no tag (or no 10050 at all) = v2 only. async fn send_targets( &self, + client: &Client, receiver: &PublicKey, relay_hints: &[String], ) -> (Vec, bool) { - let (urls, v3) = self.fetch_dm_relays(receiver).await; + let (urls, v3) = self.fetch_dm_relays(client, receiver).await; if !urls.is_empty() { return (urls, v3); } @@ -697,7 +680,7 @@ impl NostrService { /// our own relays AND the pool's discovery indexers — the recipient's /// 10050 lives on their relays and the indexers, not necessarily on /// anything we share. Both facts are cached on the contact together. - async fn fetch_dm_relays(&self, pk: &PublicKey) -> (Vec, bool) { + async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> (Vec, bool) { // Use cached relays (and the capability learned with them) first. if let Some(contact) = self.store.contact(&pk.to_hex()) && !contact.relays.is_empty() @@ -707,24 +690,13 @@ impl NostrService { contact.nip44_v3, ); } - // Resolving a recipient's kind-10050 inbox is part of resolving the PRIVATE - // payment target, so it rides the MONEY/mixnet client (scoped exit for - // relay.floonet.dev, tunnel for the discovery indexers) — never clearnet. It - // is a tiny, cheap lookup (one replaceable event, a handful of relay tags): - // unlike the fat kind-0 profile (which stays on the clearnet general client), - // leaking the recipient's inbox set to a clear relay would erode the very - // payment privacy the mixnet buys. If the money client isn't up yet, fall - // back to relay hints + our own set, exactly as an empty lookup would. - let Some(client) = self.client.read().clone() else { - return (vec![], false); - }; let mut from = self.relays(); for url in crate::nostr::pool::usable_discovery_relays().await { if !from.contains(&url) { from.push(url); } } - connect_relays(&client, &from).await; + connect_relays(client, &from).await; let filter = Filter::new().kind(Kind::InboxRelays).author(*pk).limit(1); let mut out = vec![]; let mut v3 = false; @@ -896,18 +868,10 @@ async fn run_service(svc: Arc, wallet: Wallet) { // Mirror the configured name authority so resolution + display follow it. crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain()); - // MONEY-PATH client: kind-1059 slatepack gift-wraps only, over the Nym mixnet - // (scoped exit for relay.floonet.dev, tunnel elsewhere). See `NostrService::client`. let client = Client::builder() .signer(svc.keys.clone()) .websocket_transport(NymWebSocketTransport) .build(); - // GENERAL client: identity (0/3/10002/10050), discovery fan-out and profile / - // DM-relay lookups — CLEARNET-direct (stock nostr-sdk transport), never the - // mixnet, so the metered scoped exit is no longer starved by the whole relay - // session. Only PUBLIC events/queries ride this; payment linkage stays on - // `client` above. See `NostrService::general_client`. - let general = Client::builder().signer(svc.keys.clone()).build(); // Wait for the in-process Nym mixnet tunnel before any network work // (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at // launch, but a fast wallet-open can beat the cold mixnet bootstrap — and @@ -990,13 +954,6 @@ async fn run_service(svc: Arc, wallet: Wallet) { if let Err(e) = client.add_relay(relay.clone()).await { warn!("nostr: add relay {relay} failed: {e}"); } - // The general (clearnet) client publishes our identity to the same - // advertised set — add them here too so kinds 0/10002/10050 go direct, - // off the metered exit. Discovery indexers are added later in the - // publish_identity fan-out (also on the general client). - if let Err(e) = general.add_relay(relay.clone()).await { - warn!("nostr: add general relay {relay} failed: {e}"); - } } // The tunnel generation these relays are being dialed on. If the exit is // later reselected (generation bumped by nymproc), the status loop drops @@ -1004,11 +961,9 @@ async fn run_service(svc: Arc, wallet: Wallet) { let mut dial_gen = crate::nym::tunnel_generation(); let connect_started = std::time::Instant::now(); client.connect().await; - // Bring up the clearnet general client too (direct, no tunnel wait needed). - general.connect().await; { - *svc.client.write() = Some(client.clone()); - *svc.general_client.write() = Some(general.clone()); + let mut w_client = svc.client.write(); + *w_client = Some(client.clone()); } // Log when the first relay reaches Connected over the mixnet, measured from @@ -1061,10 +1016,8 @@ async fn run_service(svc: Arc, wallet: Wallet) { }); } - // Publish identity events (kind 10050 DM relays; kind 0 only when named) on the - // general (clearnet) client — these are public replaceable events, kept off the - // metered money-path exit. - publish_identity(&svc, &general).await; + // Publish identity events (kind 10050 DM relays; kind 0 only when named). + publish_identity(&svc, &client).await; // Catch-up + live subscription for our gift wraps — targeted at our OWN // advertised set only. A pool-wide subscription would be inherited by @@ -1230,11 +1183,10 @@ async fn run_service(svc: Arc, wallet: Wallet) { // idle tunnel isn't condemned for "no relay" once we stop dialing. crate::nym::set_relay_consumer(false); { - *svc.client.write() = None; - *svc.general_client.write() = None; + let mut w_client = svc.client.write(); + *w_client = None; } client.disconnect().await; - general.disconnect().await; } /// Add + dial every relay in `urls` so a targeted send reaches relays we don't