1
0
forked from GRIN/grim

Goblin Build 132 - payments never fail silently

Adds confirm-before-sent: a payment is not shown as "sent" until the relay
actually confirms receipt. If the path is slow or a send doesn't land, you now
see a clear "not confirmed — retry" instead of a false "sent" that quietly loses
the payment.

Routing is unchanged from prior builds: all wallet nostr traffic — slatepacks,
payment requests, identity, discovery, goblin.st name + relay lookups — rides
the mixnet; the grin node and price/fiat lookups stay direct.
This commit is contained in:
2ro
2026-07-03 22:50:02 -04:00
parent d05e015f98
commit 1c8d848e18
3 changed files with 658 additions and 11 deletions
+45 -3
View File
@@ -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).
@@ -588,18 +601,47 @@ impl NostrService {
) -> Result<String, String> {
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:
+196
View File
@@ -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=<addr> 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::<u64>());
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,
}
}
}
}
+417 -8
View File
@@ -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<String> {
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<String> = None;
let mut wrap_id: Option<String> = 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:?}"
);
}
}