nostr: money-path split — slatepacks over the mixnet exit, everything else clearnet
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).
This commit is contained in:
@@ -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="<addr>" 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::<Tag>::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).");
|
||||||
|
}
|
||||||
+109
-19
@@ -59,6 +59,19 @@ const LOOKBACK_SECS: i64 = 3 * 86_400;
|
|||||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
/// Send dispatch timeout.
|
/// Send dispatch timeout.
|
||||||
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
|
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).
|
/// Rate limit for incoming messages per known contact (events/hour).
|
||||||
const RATE_CONTACT_PER_HOUR: usize = 30;
|
const RATE_CONTACT_PER_HOUR: usize = 30;
|
||||||
/// Rate limit for incoming messages per unknown sender (events/hour).
|
/// Rate limit for incoming messages per unknown sender (events/hour).
|
||||||
@@ -88,8 +101,22 @@ pub struct NostrService {
|
|||||||
/// Directory holding identity.json.
|
/// Directory holding identity.json.
|
||||||
nostr_dir: PathBuf,
|
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<Option<Client>>,
|
client: RwLock<Option<Client>>,
|
||||||
|
/// 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<Option<Client>>,
|
||||||
/// Handle to the service's tokio runtime. One-shot fetches (e.g. profile
|
/// 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:
|
/// lookups) from worker threads MUST run here, not on a throwaway runtime:
|
||||||
/// the relay connections (incl. the custom Nym mixnet transport) are driven
|
/// the relay connections (incl. the custom Nym mixnet transport) are driven
|
||||||
@@ -146,6 +173,7 @@ impl NostrService {
|
|||||||
store: Arc::new(store),
|
store: Arc::new(store),
|
||||||
nostr_dir,
|
nostr_dir,
|
||||||
client: RwLock::new(None),
|
client: RwLock::new(None),
|
||||||
|
general_client: RwLock::new(None),
|
||||||
rt_handle: RwLock::new(None),
|
rt_handle: RwLock::new(None),
|
||||||
started: AtomicBool::new(false),
|
started: AtomicBool::new(false),
|
||||||
shutdown: 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;
|
/// relays (NIP-65/gossip), which we won't otherwise be connected to. Blocking;
|
||||||
/// call from a worker thread.
|
/// call from a worker thread.
|
||||||
pub fn fetch_profile_blocking(&self, hex: &str, hints: &[String]) -> Option<NostrProfile> {
|
pub fn fetch_profile_blocking(&self, hex: &str, hints: &[String]) -> Option<NostrProfile> {
|
||||||
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 pk = PublicKey::from_hex(hex).ok()?;
|
||||||
let hints: Vec<String> = hints.to_vec();
|
let hints: Vec<String> = hints.to_vec();
|
||||||
// Run on the SERVICE runtime — the relay connections (and the custom Nym
|
// 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
|
/// field absent, or relays unreachable) = treat as accepting. Async — safe to
|
||||||
/// call from the service runtime. Fail-open: only `Some(false)` blocks.
|
/// call from the service runtime. Fail-open: only `Some(false)` blocks.
|
||||||
pub async fn accepts_requests(&self, hex: &str) -> Option<bool> {
|
pub async fn accepts_requests(&self, hex: &str) -> Option<bool> {
|
||||||
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 pk = PublicKey::from_hex(hex).ok()?;
|
||||||
let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1);
|
let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1);
|
||||||
// First-event-wins, scoped to our own connected relays (cap 8s): return on
|
// 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
|
/// Republish our kind-0 profile + kind-10050 DM relays (e.g. after toggling
|
||||||
/// the incoming-requests preference) so the change propagates immediately.
|
/// the incoming-requests preference) so the change propagates immediately.
|
||||||
pub async fn republish_identity(self: &Arc<Self>) {
|
pub async fn republish_identity(self: &Arc<Self>) {
|
||||||
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 {
|
if let Some(client) = client {
|
||||||
publish_identity(self, &client).await;
|
publish_identity(self, &client).await;
|
||||||
}
|
}
|
||||||
@@ -536,7 +567,7 @@ impl NostrService {
|
|||||||
let content = protocol::build_payment_content(slatepack);
|
let content = protocol::build_payment_content(slatepack);
|
||||||
let tags = protocol::build_rumor_tags(note);
|
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;
|
// 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
|
// 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 content = protocol::build_control_content();
|
||||||
let tags = protocol::build_control_tags(slate_id);
|
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;
|
connect_relays(&client, &urls).await;
|
||||||
|
|
||||||
@@ -588,18 +619,47 @@ impl NostrService {
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let sent = if v3 {
|
let sent = if v3 {
|
||||||
let wrap = wrapv3::wrap(&self.keys, &receiver, content, tags)?;
|
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 {
|
} else {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
SEND_TIMEOUT,
|
SEND_TIMEOUT,
|
||||||
client.send_private_msg_to(urls, receiver, content, tags),
|
client.send_private_msg_to(urls.clone(), receiver, content, tags),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
let res = sent
|
let res = sent
|
||||||
.map_err(|_| "send timeout".to_string())?
|
.map_err(|_| "send timeout".to_string())?
|
||||||
.map_err(|e| format!("send failed: {e}"))?;
|
.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:
|
/// 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.
|
/// advertises `nip44_v3`; no tag (or no 10050 at all) = v2 only.
|
||||||
async fn send_targets(
|
async fn send_targets(
|
||||||
&self,
|
&self,
|
||||||
client: &Client,
|
|
||||||
receiver: &PublicKey,
|
receiver: &PublicKey,
|
||||||
relay_hints: &[String],
|
relay_hints: &[String],
|
||||||
) -> (Vec<String>, bool) {
|
) -> (Vec<String>, bool) {
|
||||||
let (urls, v3) = self.fetch_dm_relays(client, receiver).await;
|
let (urls, v3) = self.fetch_dm_relays(receiver).await;
|
||||||
if !urls.is_empty() {
|
if !urls.is_empty() {
|
||||||
return (urls, v3);
|
return (urls, v3);
|
||||||
}
|
}
|
||||||
@@ -638,7 +697,7 @@ impl NostrService {
|
|||||||
/// our own relays AND the pool's discovery indexers — the recipient's
|
/// our own relays AND the pool's discovery indexers — the recipient's
|
||||||
/// 10050 lives on their relays and the indexers, not necessarily on
|
/// 10050 lives on their relays and the indexers, not necessarily on
|
||||||
/// anything we share. Both facts are cached on the contact together.
|
/// anything we share. Both facts are cached on the contact together.
|
||||||
async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> (Vec<String>, bool) {
|
async fn fetch_dm_relays(&self, pk: &PublicKey) -> (Vec<String>, bool) {
|
||||||
// Use cached relays (and the capability learned with them) first.
|
// Use cached relays (and the capability learned with them) first.
|
||||||
if let Some(contact) = self.store.contact(&pk.to_hex())
|
if let Some(contact) = self.store.contact(&pk.to_hex())
|
||||||
&& !contact.relays.is_empty()
|
&& !contact.relays.is_empty()
|
||||||
@@ -648,13 +707,24 @@ impl NostrService {
|
|||||||
contact.nip44_v3,
|
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();
|
let mut from = self.relays();
|
||||||
for url in crate::nostr::pool::usable_discovery_relays().await {
|
for url in crate::nostr::pool::usable_discovery_relays().await {
|
||||||
if !from.contains(&url) {
|
if !from.contains(&url) {
|
||||||
from.push(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 filter = Filter::new().kind(Kind::InboxRelays).author(*pk).limit(1);
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
let mut v3 = false;
|
let mut v3 = false;
|
||||||
@@ -826,10 +896,18 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
// Mirror the configured name authority so resolution + display follow it.
|
// Mirror the configured name authority so resolution + display follow it.
|
||||||
crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain());
|
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()
|
let client = Client::builder()
|
||||||
.signer(svc.keys.clone())
|
.signer(svc.keys.clone())
|
||||||
.websocket_transport(NymWebSocketTransport)
|
.websocket_transport(NymWebSocketTransport)
|
||||||
.build();
|
.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
|
// Wait for the in-process Nym mixnet tunnel before any network work
|
||||||
// (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at
|
// (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
|
// launch, but a fast wallet-open can beat the cold mixnet bootstrap — and
|
||||||
@@ -912,6 +990,13 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
if let Err(e) = client.add_relay(relay.clone()).await {
|
if let Err(e) = client.add_relay(relay.clone()).await {
|
||||||
warn!("nostr: add relay {relay} failed: {e}");
|
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
|
// The tunnel generation these relays are being dialed on. If the exit is
|
||||||
// later reselected (generation bumped by nymproc), the status loop drops
|
// later reselected (generation bumped by nymproc), the status loop drops
|
||||||
@@ -919,9 +1004,11 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
let mut dial_gen = crate::nym::tunnel_generation();
|
let mut dial_gen = crate::nym::tunnel_generation();
|
||||||
let connect_started = std::time::Instant::now();
|
let connect_started = std::time::Instant::now();
|
||||||
client.connect().await;
|
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();
|
*svc.client.write() = Some(client.clone());
|
||||||
*w_client = Some(client.clone());
|
*svc.general_client.write() = Some(general.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log when the first relay reaches Connected over the mixnet, measured from
|
// Log when the first relay reaches Connected over the mixnet, measured from
|
||||||
@@ -974,8 +1061,10 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish identity events (kind 10050 DM relays; kind 0 only when named).
|
// Publish identity events (kind 10050 DM relays; kind 0 only when named) on the
|
||||||
publish_identity(&svc, &client).await;
|
// 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
|
// Catch-up + live subscription for our gift wraps — targeted at our OWN
|
||||||
// advertised set only. A pool-wide subscription would be inherited by
|
// advertised set only. A pool-wide subscription would be inherited by
|
||||||
@@ -1141,10 +1230,11 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
// idle tunnel isn't condemned for "no relay" once we stop dialing.
|
// idle tunnel isn't condemned for "no relay" once we stop dialing.
|
||||||
crate::nym::set_relay_consumer(false);
|
crate::nym::set_relay_consumer(false);
|
||||||
{
|
{
|
||||||
let mut w_client = svc.client.write();
|
*svc.client.write() = None;
|
||||||
*w_client = None;
|
*svc.general_client.write() = None;
|
||||||
}
|
}
|
||||||
client.disconnect().await;
|
client.disconnect().await;
|
||||||
|
general.disconnect().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add + dial every relay in `urls` so a targeted send reaches relays we don't
|
/// Add + dial every relay in `urls` so a targeted send reaches relays we don't
|
||||||
|
|||||||
@@ -266,4 +266,200 @@ mod tests {
|
|||||||
"unexpected relay reply: {txt}"
|
"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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+411
-2
@@ -27,13 +27,21 @@
|
|||||||
//! Ignored by default (real mainnet funds + a full recovery scan). Run:
|
//! Ignored by default (real mainnet funds + a full recovery scan). Run:
|
||||||
//! GOBLIN_E2E_SEED_A="word ..." GOBLIN_E2E_SEED_B="word ..." \
|
//! GOBLIN_E2E_SEED_A="word ..." GOBLIN_E2E_SEED_B="word ..." \
|
||||||
//! cargo test --lib wallet::e2e::tests::two_goblins_pay_over_floonet -- --ignored --nocapture
|
//! 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use grin_util::ToHex;
|
||||||
use grin_util::types::ZeroingString;
|
use grin_util::types::ZeroingString;
|
||||||
|
use grin_wallet_libwallet::TxLogEntryType;
|
||||||
|
|
||||||
use crate::nostr::{Contact, NostrConfig, NostrSendStatus};
|
use crate::nostr::{Contact, NostrConfig, NostrSendStatus};
|
||||||
use crate::wallet::types::{ConnectionMethod, PhraseMode, WalletTask};
|
use crate::wallet::types::{ConnectionMethod, PhraseMode, WalletTask};
|
||||||
@@ -65,14 +73,23 @@ mod tests {
|
|||||||
conn_id: i64,
|
conn_id: i64,
|
||||||
node_url: &str,
|
node_url: &str,
|
||||||
relay: &str,
|
relay: &str,
|
||||||
|
mode: PhraseMode,
|
||||||
) -> Wallet {
|
) -> 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();
|
let mut m = Mnemonic::default();
|
||||||
|
if mode == PhraseMode::Import {
|
||||||
m.set_mode(PhraseMode::Import);
|
m.set_mode(PhraseMode::Import);
|
||||||
m.import(&ZeroingString::from(phrase));
|
m.import(&ZeroingString::from(phrase));
|
||||||
assert!(
|
assert!(
|
||||||
m.valid(),
|
m.valid(),
|
||||||
"{name}: mnemonic did not validate (bad seed words?)"
|
"{name}: mnemonic did not validate (bad seed words?)"
|
||||||
);
|
);
|
||||||
|
}
|
||||||
let conn = ConnectionMethod::External(conn_id, node_url.to_string());
|
let conn = ConnectionMethod::External(conn_id, node_url.to_string());
|
||||||
let w = Wallet::create(&name.to_string(), pw, &m, &conn)
|
let w = Wallet::create(&name.to_string(), pw, &m, &conn)
|
||||||
.unwrap_or_else(|e| panic!("{name}: wallet create failed: {e}"));
|
.unwrap_or_else(|e| panic!("{name}: wallet create failed: {e}"));
|
||||||
@@ -192,11 +209,27 @@ mod tests {
|
|||||||
let pw = ZeroingString::from("e2e-test-pass");
|
let pw = ZeroingString::from("e2e-test-pass");
|
||||||
|
|
||||||
println!("[e2e] opening wallet A...");
|
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.
|
// Wallet id = unix seconds; two creates in the same second collide.
|
||||||
std::thread::sleep(Duration::from_millis(1500));
|
std::thread::sleep(Duration::from_millis(1500));
|
||||||
println!("[e2e] opening wallet B...");
|
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).
|
// Nostr services connect, each to its OWN relay (over the exit).
|
||||||
let a_svc = a.nostr_service().expect("A nostr service");
|
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");
|
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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user