mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-18 20:59:23 +00:00
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).");
|
||||
}
|
||||
Reference in New Issue
Block a user