diff --git a/.github/actions/fetch-nym/action.yml b/.github/actions/fetch-nym/action.yml deleted file mode 100644 index 1e8faf4e..00000000 --- a/.github/actions/fetch-nym/action.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Fetch patched nym SDK -description: > - Clone the patched nym workspace from our own mirror - (git.us-ea.st/GRIN/nym, branch `goblin` = upstream nymtech/nym @ b6eb391 + - Goblin's Android webpki-roots patch) into ../nym, so the - `nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }` dependency resolves. - Self-hosted: no upstream-GitHub fetch and no patch-apply step. - -runs: - using: composite - steps: - - name: Clone patched nym - shell: bash - run: | - set -euo pipefail - DEST="$(dirname "$GITHUB_WORKSPACE")/nym" - if [ -e "$DEST/sdk/rust/nym-sdk/Cargo.toml" ]; then - echo "nym already present at $DEST" - exit 0 - fi - rm -rf "$DEST" - git clone --branch goblin --depth 1 https://git.us-ea.st/GRIN/nym.git "$DEST" - echo "nym cloned from GRIN/nym@goblin -> $DEST" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e91b4cf8..0c289445 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,12 +1,6 @@ name: Build on: [push, pull_request] -# aws-lc-sys (pulled in by nym-sdk) builds AWS-LC, which needs NASM on native -# Windows. Use the prebuilt NASM objects the crate ships so the runner doesn't -# need NASM installed; harmless on Linux/macOS. -env: - AWS_LC_SYS_PREBUILT_NASM: 1 - jobs: linux: name: Linux Build @@ -15,8 +9,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - # nym-sdk is a path dep on ../nym; materialize it before building. - - uses: ./.github/actions/fetch-nym # nip44 is a path dep on ../nip44; materialize it before building. - uses: ./.github/actions/fetch-nip44 - name: Release build @@ -29,7 +21,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - uses: ./.github/actions/fetch-nym - uses: ./.github/actions/fetch-nip44 - name: Release build run: cargo build --release @@ -41,7 +32,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - uses: ./.github/actions/fetch-nym - uses: ./.github/actions/fetch-nip44 - name: Release build run: cargo build --release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb5f11d4..b0e03bf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,8 +25,6 @@ permissions: env: TAG: ${{ inputs.tag || github.event.release.tag_name }} - # aws-lc-sys (via nym-sdk) needs NASM on native Windows; use its prebuilt NASM. - AWS_LC_SYS_PREBUILT_NASM: 1 jobs: linux: @@ -39,7 +37,6 @@ jobs: with: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive - - uses: ./.github/actions/fetch-nym - uses: ./.github/actions/fetch-nip44 - name: Build shell: bash @@ -65,7 +62,6 @@ jobs: with: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive - - uses: ./.github/actions/fetch-nym - uses: ./.github/actions/fetch-nip44 - name: Build shell: bash @@ -113,7 +109,6 @@ jobs: with: ref: ${{ inputs.tag || github.event.release.tag_name }} submodules: recursive - - uses: ./.github/actions/fetch-nym - uses: ./.github/actions/fetch-nip44 - name: Build both architectures run: | diff --git a/.gitignore b/.gitignore index 08f54651..378b7826 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ screenshots/ .foreign_api_secret grin-wallet.log grin-wallet.toml +# Internal E2E harnesses — reference funded wallets / live relays; kept on disk, +# untracked (mod e2e is gated behind the `e2e-internal` feature) +src/wallet/e2e.rs +tests/nostr_e2e.rs diff --git a/Cargo.toml b/Cargo.toml index 242bffbc..578e46b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ panic = "abort" ## the two transports cannot coexist in one binary, which is why Tor replaced Nym). default = [] nym = [] +## Compiles the internal E2E harness (src/wallet/e2e.rs); off by default and the +## file is untracked, so a fresh clone builds without it. +e2e-internal = [] [dependencies] log = "0.4.27" @@ -224,9 +227,8 @@ base64 = "0.22" sha2 = "0.10" hex = "0.4" serde_yaml = "0.9" -## G14 transport-validation harness (tests/xrelay_smoke.rs): re-expose deps that -## already live in the main graph so the smolmix transport can be exercised and -## its tunnel/mix-dns logs captured. No new compiles — same versions unify. +## Re-expose deps that already live in the main graph so the E2E harness can be +## exercised and its logs captured. No new compiles — same versions unify. log = "0.4.27" env_logger = "0.11.3" rustls = { version = "0.23", features = ["ring"] } diff --git a/build.rs b/build.rs index 029cff6e..fbf114ba 100644 --- a/build.rs +++ b/build.rs @@ -56,9 +56,9 @@ fn main() { .expect("failed to execute git config for hooks"); } - // Goblin links the Nym mixnet SDK in-process (see src/nym/) — no sidecar - // subprocess, no bundled/embedded helper binary, and no Tor/webtunnel. There - // is nothing transport-related to build or embed here. + // Goblin's private transport is Tor via embedded arti (see src/tor/), linked + // in-process — no sidecar subprocess and no bundled/embedded helper binary. + // There is nothing transport-related to build or embed here. // Embed the Goblin icon into goblin.exe so Explorer, the taskbar and Alt-Tab // show it even for the bare exe (the .msi shortcuts already carry it). No-op diff --git a/examples/tunnel_measure.rs b/examples/tunnel_measure.rs deleted file mode 100644 index a2457da7..00000000 --- a/examples/tunnel_measure.rs +++ /dev/null @@ -1,105 +0,0 @@ -// Local network measurement for the Nym read tunnel. Uses the wallet's REAL -// transport (warm_up + tuned tunnel + reselect + DNS cache + HTTP keep-alive -// pool), then fetches the live price API over the mixnet on a fixed interval -// so we can see (a) cold connect time, (b) whether the connection stays warm, -// (c) per-fetch latency over time. -// -// cargo run --release --example tunnel_measure -- [interval_secs] -// -// e.g. `-- 300` (5 min) or `-- 600 15` (10 min, every 15s). - -use std::time::{Duration, Instant}; - -const PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd"; - -#[tokio::main] -async fn main() { - let _ = rustls::crypto::ring::default_provider().install_default(); - - let args: Vec = std::env::args().collect(); - let total_secs: u64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(300); - let interval_secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(15); - - let run_start = Instant::now(); - println!("[t=0.0s] warm_up(): starting the tunnel"); - grim::nym::warm_up(); - - // Cold connect time: poll is_ready(). - let mut connect_ms = None; - let t_connect = Instant::now(); - while t_connect.elapsed() < Duration::from_secs(120) { - if grim::nym::is_ready() { - connect_ms = Some(t_connect.elapsed().as_millis()); - break; - } - tokio::time::sleep(Duration::from_millis(200)).await; - } - match connect_ms { - Some(ms) => println!( - "[t={:.1}s] TUNNEL READY (cold connect {} ms)", - run_start.elapsed().as_secs_f64(), - ms - ), - None => { - println!("tunnel never became ready in 120s; aborting"); - return; - } - } - - // Warm-loop: fetch price over the mixnet every interval, record latency. - let mut lats: Vec = vec![]; - let mut fails = 0u32; - let deadline = run_start + Duration::from_secs(total_secs); - let mut n = 0u32; - while Instant::now() < deadline { - n += 1; - let t = Instant::now(); - let ok = grim::nym::http_request("GET", PRICE_URL.to_string(), None, vec![]).await; - let ms = t.elapsed().as_millis(); - match ok { - Some(body) if body.contains("grin") => { - lats.push(ms); - println!( - "[t={:.1}s] fetch #{n}: {} ms ready={}", - run_start.elapsed().as_secs_f64(), - ms, - grim::nym::is_ready() - ); - } - other => { - fails += 1; - println!( - "[t={:.1}s] fetch #{n}: FAIL after {} ms (ready={}, body={:?})", - run_start.elapsed().as_secs_f64(), - ms, - grim::nym::is_ready(), - other.map(|b| b.chars().take(40).collect::()) - ); - } - } - tokio::time::sleep(Duration::from_secs(interval_secs)).await; - } - - // Summary. - lats.sort_unstable(); - let n_ok = lats.len(); - let sum: u128 = lats.iter().sum(); - let median = lats.get(n_ok / 2).copied().unwrap_or(0); - println!( - "\n==== SUMMARY ({}s run, {}s interval) ====", - total_secs, interval_secs - ); - println!("cold connect: {} ms", connect_ms.unwrap()); - println!("fetches: {} ok, {} failed", n_ok, fails); - if n_ok > 0 { - println!( - "warm fetch latency ms: min {} / median {} / max {} / mean {}", - lats.first().unwrap(), - median, - lats.last().unwrap(), - sum / n_ok as u128 - ); - let head: Vec = lats.iter().take(3).copied().collect(); - println!("(sorted sample) fastest 3: {:?}", head); - } -} diff --git a/linux/build_release.sh b/linux/build_release.sh index 42a66bbf..0d788ed3 100755 --- a/linux/build_release.sh +++ b/linux/build_release.sh @@ -5,8 +5,8 @@ # Usage: linux/build_release.sh [platform] # platform: 'x86_64' (default) or 'arm' # -# Goblin links the Nym SDK IN-PROCESS (src/nym/), so the AppImage is one -# self-contained binary with no sidecar to embed or ship beside it. +# Goblin links the Tor transport (embedded arti) IN-PROCESS, so the AppImage is +# one self-contained binary with no sidecar to embed or ship beside it. set -euo pipefail @@ -41,7 +41,7 @@ export CXXFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0" export BINDGEN_EXTRA_CLANG_ARGS="${BINDGEN_EXTRA_CLANG_ARGS:-} -I/usr/include" cargo zigbuild --release --target "${arch}.2.17" -# Assemble the AppDir: AppRun IS the goblin binary (Nym SDK linked in), plus the +# Assemble the AppDir: AppRun IS the goblin binary (Tor/arti linked in), plus the # icon + desktop entry. Nothing else. appdir="linux/Goblin.AppDir" cp "target/${arch}/release/goblin" "${appdir}/AppRun" diff --git a/scripts/android.sh b/scripts/android.sh index d672228e..5b6b7f68 100755 --- a/scripts/android.sh +++ b/scripts/android.sh @@ -54,8 +54,8 @@ function build_lib() { sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml rm -f Cargo.toml-e - # The Nym mixnet is linked INTO libgrim.so (nym-sdk is a regular dependency), - # so there is no separate sidecar binary to cross-build or bundle into jniLibs. + # The Tor transport (embedded arti) is linked INTO libgrim.so, so there is no + # separate sidecar binary to cross-build or bundle into jniLibs. } ### Build application diff --git a/src/wallet/e2e.rs b/src/wallet/e2e.rs deleted file mode 100644 index 1cf034c2..00000000 --- a/src/wallet/e2e.rs +++ /dev/null @@ -1,726 +0,0 @@ -// Copyright 2026 The Goblin Developers -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! LIVE two-wallet end-to-end payment over the Floonet path — over the shared -//! exit-backed primary relay, CROSS-NODE. Two real Goblin wallets restored from -//! mainnet mnemonics (seeds via env, NEVER a file) both run on the shipped -//! default relay (`wss://relay.floonet.dev`, each pinned via its own -//! `nostr.toml`) but on DIFFERENT Grin nodes (A on grincoin.org, B on -//! main.gri.mw). One sends a real gift-wrapped Grin payment to the other, -//! asynchronously through the relay. Proves the whole money path a phone would -//! use, plus the outbox model: the sender publishes the wrap to the RECIPIENT's -//! advertised (kind 10050) relay, and settlement posts through two independent -//! nodes. -//! mixnet -> exit -> gift wrap -> S2 -> finalize -> post. -//! -//! 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}; - use crate::wallet::{ConnectionsConfig, ExternalConnection, Mnemonic, Wallet}; - - /// 0.1 GRIN, in nanograin. Small on purpose (mainnet, real funds). - const AMOUNT: u64 = 100_000_000; - /// Wallet A's mainnet node (recovery scan + tx post). - const NODE_A: &str = "https://grincoin.org"; - /// Wallet B's mainnet node — a DIFFERENT operator, so the payment settles - /// across two independent nodes. - const NODE_B: &str = "https://main.gri.mw"; - /// Wallet A's relay (pinned via its nostr.toml, advertised in its 10050). - /// The new primary money-path relay, reached over its co-located scoped exit. - const RELAY_A: &str = "wss://relay.floonet.dev"; - /// Wallet B's relay — the SAME shared exit-backed primary as A (how the - /// shipped product works: every Goblin wallet defaults to relay.floonet.dev). - /// Both reach it over the co-located scoped exit, so the gift-wrap round-trip - /// rides the fast money path end to end. Nodes still differ (below), so the - /// payment still settles across two independent Grin nodes. - const RELAY_B: &str = "wss://relay.floonet.dev"; - - /// Build + open a wallet from a 24-word mnemonic on its own external node - /// and its own single-relay nostr.toml override. - fn open_wallet( - name: &str, - phrase: &str, - pw: &ZeroingString, - 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(); - 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}")); - // Pin this wallet to a single relay BEFORE open(): init_nostr loads - // nostr.toml from the wallet data dir on open, and a `relays` override - // both drives the client's relay set and is advertised as the wallet's - // kind 10050 DM inbox (see NostrService::relays / publish_identity). - let wallet_dir = PathBuf::from(w.get_config().get_data_path()); - let mut nostr_cfg = NostrConfig::load(wallet_dir.clone()); - nostr_cfg.set_relays(vec![relay.to_string()]); - println!( - "[e2e] {name}: node={node_url} relay={relay} (nostr.toml at {})", - wallet_dir.join(NostrConfig::FILE_NAME).display() - ); - w.open(pw.clone()) - .unwrap_or_else(|e| panic!("{name}: wallet open failed: {e}")); - w - } - - /// The persisted form of "added this payee from their nprofile": a contact - /// carrying their DM relay, so payment routing (send_targets -> fetch_dm_relays) - /// uses that relay directly instead of blind kind-10050 discovery over the - /// exit-less indexers. BOTH legs of a cross-relay payment need this seeded. - fn contact_with_relay(npub_hex: &str, relay: &str) -> Contact { - Contact { - ver: 1, - npub: npub_hex.to_string(), - petname: None, - nip05: None, - nip05_verified_at: None, - relays: vec![relay.to_string()], - nip44_v3: false, - hue: 0, - unknown: false, - added_at: 0, - last_paid_at: None, - blocked: false, - } - } - - /// Poll `cond` until true or `secs` elapse; log progress via `label`. - fn wait_until(label: &str, secs: u64, mut cond: impl FnMut() -> bool) -> bool { - let start = Instant::now(); - let mut last = 0u64; - while start.elapsed() < Duration::from_secs(secs) { - if cond() { - println!("[e2e] {label}: OK in {}s", start.elapsed().as_secs()); - return true; - } - let el = start.elapsed().as_secs(); - if el >= last + 15 { - last = el; - println!("[e2e] {label}: waiting... {el}s"); - } - std::thread::sleep(Duration::from_secs(2)); - } - println!("[e2e] {label}: TIMEOUT after {secs}s"); - false - } - - #[test] - #[ignore] - fn two_goblins_pay_over_floonet() { - let seed_a = std::env::var("GOBLIN_E2E_SEED_A").unwrap_or_default(); - let seed_b = std::env::var("GOBLIN_E2E_SEED_B").unwrap_or_default(); - if seed_a.trim().is_empty() || seed_b.trim().is_empty() { - println!("[e2e] SKIP: set GOBLIN_E2E_SEED_A and GOBLIN_E2E_SEED_B"); - return; - } - - // Isolate wallet + nym state under a throwaway HOME. MUST precede any - // grim call (Settings roots at $HOME/.goblin on first deref). - let home = std::env::var("GOBLIN_E2E_HOME").unwrap_or_else(|_| { - std::env::temp_dir() - .join("goblin-e2e-home") - .to_string_lossy() - .into_owned() - }); - unsafe { - std::env::set_var("HOME", &home); - } - println!("[e2e] HOME = {home}"); - - // The app installs these at startup (src/lib.rs); a bare test must too. - let _ = rustls::crypto::ring::default_provider().install_default(); - crate::tor::warm_up(); - assert!( - wait_until("nym tunnel is_ready", 180, crate::tor::is_ready), - "nym tunnel never came up" - ); - - // Register a SEPARATE mainnet node per wallet. ExternalConnection ids - // are unix seconds, and add_ext_conn dedupes on id — two conns built in - // the same second would collide — so bump B's id explicitly. - let node_a = ExternalConnection::new(NODE_A.to_string(), Some("grin".to_string()), None); - let conn_a = node_a.id; - ConnectionsConfig::add_ext_conn(node_a); - let mut node_b = - ExternalConnection::new(NODE_B.to_string(), Some("grin".to_string()), None); - node_b.id = conn_a + 1; - let conn_b = node_b.id; - ConnectionsConfig::add_ext_conn(node_b); - - let strip = |s: &str| { - s.trim_start_matches("https://") - .trim_start_matches("wss://") - .to_string() - }; - println!( - "[e2e] A: node={} relay={} | B: node={} relay={}", - strip(NODE_A), - strip(RELAY_A), - strip(NODE_B), - strip(RELAY_B) - ); - - 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, - 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, - PhraseMode::Import, - ); - - // Nostr services connect, each to its OWN relay (over the exit). - let a_svc = a.nostr_service().expect("A nostr service"); - let b_svc = b.nostr_service().expect("B nostr service"); - let t_a = Instant::now(); - assert!( - wait_until("A nostr connected", 240, || a_svc.is_connected()), - "A never connected to its relay ({RELAY_A})" - ); - println!("[e2e] A connected in {}s", t_a.elapsed().as_secs()); - let t_b = Instant::now(); - assert!( - wait_until("B nostr connected", 240, || b_svc.is_connected()), - "B never connected to its relay ({RELAY_B})" - ); - println!("[e2e] B connected in {}s", t_b.elapsed().as_secs()); - println!("[e2e] A effective relays = {:?}", a_svc.relays()); - println!("[e2e] B effective relays = {:?}", b_svc.relays()); - assert_eq!( - a_svc.relays(), - vec![RELAY_A.to_string()], - "A's relay override did not take" - ); - assert_eq!( - b_svc.relays(), - vec![RELAY_B.to_string()], - "B's relay override did not take" - ); - println!("[e2e] A npub = {}", a_svc.npub()); - println!("[e2e] B npub = {}", b_svc.npub()); - - // Pre-seed each wallet's contact store with the other (npub + DM relay) — - // the realistic "added the payee from their nprofile" path. Payment - // routing then uses the cached DM relay directly, so BOTH legs cross - // relays deterministically (A -> B's relay over the tunnel, B -> A's relay - // over the exit) without the kind-10050 discovery fetch over the exit-less - // indexers that stalled the pure-discovery run. - a_svc - .store - .save_contact(&contact_with_relay(&b_svc.public_key().to_hex(), RELAY_B)); - b_svc - .store - .save_contact(&contact_with_relay(&a_svc.public_key().to_hex(), RELAY_A)); - println!("[e2e] seeded contacts: A knows B @ {RELAY_B}, B knows A @ {RELAY_A}"); - - // Recovery scan: concurrent across both wallets, each against its own - // node. Sender needs spendable. - wait_until("A synced_from_node", 2400, || a.synced_from_node()); - wait_until("B synced_from_node", 2400, || b.synced_from_node()); - - let spendable = |w: &Wallet| -> u64 { - w.get_data() - .map(|d| d.info.amount_currently_spendable) - .unwrap_or(0) - }; - let a_bal = spendable(&a); - let b_bal = spendable(&b); - println!("[e2e] spendable: A={a_bal} nano, B={b_bal} nano (need {AMOUNT})"); - - // Sender = whichever wallet actually has the funds. Either way the wrap - // crosses relays: the sender fetches the recipient's kind 10050 (from - // the recipient's relay + the discovery indexers) and publishes the - // gift wrap THERE — the outbox path this test exists to prove. - let (sender, sender_svc, recv_svc, sender_name) = if a_bal >= AMOUNT + 20_000_000 { - (&a, &a_svc, &b_svc, "A") - } else if b_bal >= AMOUNT + 20_000_000 { - (&b, &b_svc, &a_svc, "B") - } else { - panic!( - "neither wallet has >= {AMOUNT}+fee spendable (A={a_bal}, B={b_bal}); fund one and retry" - ); - }; - let receiver_hex = recv_svc.public_key().to_hex(); - println!("[e2e] sender = {sender_name}; paying {AMOUNT} nano to {receiver_hex}"); - - // Fire the async payment across the two relays. - let t_send = Instant::now(); - sender.task(WalletTask::NostrSend( - AMOUNT, - receiver_hex.clone(), - Some("floonet e2e".to_string()), - vec![], - )); - - // Watch the sender's meta walk Created -> AwaitingS2 -> Finalized. - // Generous window: two relays + two nodes + mixnet round trips. - let finalized = wait_until("payment finalized", 900, || { - if let Some(err) = sender_svc.last_send_error() { - println!("[e2e] sender last_send_error: {err}"); - } - sender_svc - .store - .all_tx_meta() - .iter() - .any(|m| matches!(m.status, NostrSendStatus::Finalized)) - }); - - println!( - "[e2e] send->finalize elapsed {}s; finalized={finalized}", - t_send.elapsed().as_secs() - ); - // Dump both stores for the record. - for (who, svc) in [("sender", sender_svc), ("receiver", recv_svc)] { - for m in svc.store.all_tx_meta() { - println!("[e2e] {who} meta {} -> {:?}", m.slate_id, m.status); - } - } - - a.close(); - b.close(); - - assert!( - finalized, - "payment did not reach Finalized within the window (see meta trail above)" - ); - println!("[e2e] SUCCESS: cross-relay + cross-node payment finalized over the floonet path"); - } - - // ───────────────────────────────────────────────────────────────────────── - // FUNDED E2E HARNESS (task-spec): single default node (api.grin.money), both - // wallets on the shipped money-path relay reached over its co-located SCOPED - // EXIT. Reads GOBLIN_E2E_MNEMONIC_A/B; smoke-mode generates throwaway EMPTY - // wallets to prove the plumbing up to the money move. Reuses the helpers - // above so this stays tiny and rides Goblin's OWN wallet + nostr code. - // ───────────────────────────────────────────────────────────────────────── - - /// Non-empty trimmed env var, else `None`. - fn e2e_env(key: &str) -> Option { - std::env::var(key) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } - /// Env var parsed as u64, else `default`. - fn e2e_env_u64(key: &str, default: u64) -> u64 { - e2e_env(key).and_then(|s| s.parse().ok()).unwrap_or(default) - } - /// Truthy env flag (`1` / `true`). - fn e2e_flag(key: &str) -> bool { - e2e_env(key) - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false) - } - /// Headless END-TO-END real-Grin payment A → B over the just-split money path, - /// driven entirely by Goblin's own wallet + nostr code (no slate crypto is - /// reimplemented here). Steps: restore both wallets from their mnemonics into - /// per-wallet temp dirs → open against the grin node and recovery-scan → A - /// sends a real payment to B THROUGH the nostr DM path (slatepack → - /// kind-1059 gift-wrap → published over the SCOPED EXIT to relay.floonet.dev) - /// → B's running service unwraps, ingests (receive), replies S2 the same path - /// → A auto-finalizes and posts to the node → verify Finalized (= accepted by - /// node) and, best-effort, B's received tx reaching 1 confirmation. - /// - /// The nostr identity is a per-wallet RANDOM nsec (see nostr/identity.rs), NOT - /// derived from the wallet seed — so B's real runtime npub (read here) is the - /// pay target and its advertised inbox + subscription line up by construction. - /// - /// Ignored by default (real mainnet funds + a full recovery scan). Run: - /// GOBLIN_E2E_MNEMONIC_A="word ..." GOBLIN_E2E_MNEMONIC_B="word ..." \ - /// RUST_LOG=grim=info \ - /// cargo test --lib wallet::e2e::tests::funded_e2e_pay -- --ignored --nocapture - /// Smoke (empty throwaway wallets, stops at insufficient funds — proves the - /// plumbing up to the money move): - /// GOBLIN_E2E_ALLOW_UNFUNDED=1 GOBLIN_E2E_SCAN_WAIT=180 RUST_LOG=grim=info \ - /// cargo test --lib wallet::e2e::tests::funded_e2e_pay -- --ignored --nocapture - /// Knobs: GOBLIN_E2E_NODE (default https://api.grin.money), GOBLIN_E2E_AMOUNT - /// (nano, default 0.1 GRIN), GOBLIN_E2E_CONFIRM_WAIT (finalize+confirm budget - /// secs, default 600), GOBLIN_E2E_SCAN_WAIT (recovery-scan budget secs, default - /// 2400), GOBLIN_E2E_HOME (default /tmp/e2e-home). - #[test] - #[ignore] - fn funded_e2e_pay() { - // Shipped money-path relay, reached over its co-located scoped exit. - const RELAY: &str = "wss://relay.floonet.dev"; - // Task env: MNEMONIC_A/B (fall back to SEED_A/B for parity with the - // cross-node test above). Absent + ALLOW_UNFUNDED=1 → throwaway EMPTY - // wallets to smoke the plumbing. - let allow_unfunded = e2e_flag("GOBLIN_E2E_ALLOW_UNFUNDED"); - let mnem_a = e2e_env("GOBLIN_E2E_MNEMONIC_A").or_else(|| e2e_env("GOBLIN_E2E_SEED_A")); - let mnem_b = e2e_env("GOBLIN_E2E_MNEMONIC_B").or_else(|| e2e_env("GOBLIN_E2E_SEED_B")); - let (mnem_a, mnem_b, smoke) = match (mnem_a, mnem_b) { - (Some(a), Some(b)) => (a, b, false), - _ if allow_unfunded => { - println!( - "[fe2e] no mnemonics in env; SMOKE mode with FRESH throwaway EMPTY wallets \ - (no-scan, sync fast from the external node)" - ); - (String::new(), String::new(), true) - } - _ => { - println!( - "[fe2e] SKIP: set GOBLIN_E2E_MNEMONIC_A and GOBLIN_E2E_MNEMONIC_B \ - (or GOBLIN_E2E_ALLOW_UNFUNDED=1 to smoke the plumbing)" - ); - return; - } - }; - - let node = - e2e_env("GOBLIN_E2E_NODE").unwrap_or_else(|| "https://api.grin.money".to_string()); - let amount = e2e_env_u64("GOBLIN_E2E_AMOUNT", AMOUNT); - let need = amount + 20_000_000; // amount + generous fee headroom - let scan_wait = e2e_env_u64("GOBLIN_E2E_SCAN_WAIT", 2400); - let confirm_wait = e2e_env_u64("GOBLIN_E2E_CONFIRM_WAIT", 600); - - // Isolate wallet + nym state under a throwaway HOME. MUST precede any grim - // call (Settings roots at $HOME/.goblin on first deref, incl. pool::load). - let home = e2e_env("GOBLIN_E2E_HOME").unwrap_or_else(|| "/tmp/e2e-home".to_string()); - unsafe { - std::env::set_var("HOME", &home); - } - // Surface the nym transport info logs — the exit-connect evidence line - // ("CONNECTED via scoped exit") is emitted at info by the money client. - let _ = env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("grim=info"), - ) - .is_test(false) - .try_init(); - println!("[fe2e] HOME={home} node={node} relay={RELAY} amount={amount} nano smoke={smoke}"); - - // App-startup shims a bare test must do itself. - let _ = rustls::crypto::ring::default_provider().install_default(); - - // ── EXIT EVIDENCE (deterministic, offline). The compiled-in pinned pool - // maps the money relay to its co-located SCOPED Nym exit; the money client's - // NymWebSocketTransport dials THAT (kind-1059 gift-wraps only), while the - // identity/general client is stock CLEARNET. Assert the money path is - // actually exit-anchored before spending a cent. ── - let pool = crate::nostr::pool::load(); - let exit = pool.exit_for(RELAY); - println!( - "[fe2e] EXIT EVIDENCE: pool.has_exit={} exit_for({RELAY})={:?}", - pool.has_exit(), - exit - ); - assert!( - exit.is_some(), - "money relay {RELAY} advertises no scoped exit in the pool; the split money path cannot be verified" - ); - - crate::tor::warm_up(); - assert!( - wait_until("nym tunnel is_ready", 180, crate::tor::is_ready), - "nym tunnel never came up" - ); - println!( - "[fe2e] nym ready; tunnel_generation={}", - crate::tor::tunnel_generation() - ); - - // One external node for BOTH wallets: the money path splits at the RELAY - // (nostr DM over the exit), not the node — node HTTP is clearnet either way. - let node_conn = ExternalConnection::new(node.clone(), Some("grin".to_string()), None); - let conn_id = node_conn.id; - ConnectionsConfig::add_ext_conn(node_conn); - - let pw = ZeroingString::from("e2e-test-pass"); - // Real mnemonics → Import (restore + scan); smoke → Generate (fresh no-scan). - let phrase_mode = if smoke { - PhraseMode::Generate - } else { - PhraseMode::Import - }; - println!("[fe2e] opening wallet A..."); - let a = open_wallet( - "goblin-fe2e-a", - &mnem_a, - &pw, - conn_id, - &node, - RELAY, - phrase_mode.clone(), - ); - // Wallet id = unix seconds; two creates in the same second collide. - std::thread::sleep(Duration::from_millis(1500)); - println!("[fe2e] opening wallet B..."); - let b = open_wallet( - "goblin-fe2e-b", - &mnem_b, - &pw, - conn_id, - &node, - RELAY, - phrase_mode, - ); - - let a_svc = a.nostr_service().expect("A nostr service"); - let b_svc = b.nostr_service().expect("B nostr service"); - println!("[fe2e] A npub={} | B npub={}", a_svc.npub(), b_svc.npub()); - - // Connect over the scoped exit. Fatal for a real run; best-effort for smoke. - let a_conn = wait_until("A nostr connected (scoped exit)", 240, || { - a_svc.is_connected() - }); - let b_conn = wait_until("B nostr connected (scoped exit)", 240, || { - b_svc.is_connected() - }); - if !smoke { - assert!(a_conn, "A never connected to {RELAY} over the exit"); - assert!(b_conn, "B never connected to {RELAY} over the exit"); - } - println!( - "[fe2e] connected A={a_conn} B={b_conn}; A relays={:?} B relays={:?}", - a_svc.relays(), - b_svc.relays() - ); - - // Seed contacts both ways (the realistic "added payee from nprofile" path) - // so payment routing uses the cached DM relay directly. - a_svc - .store - .save_contact(&contact_with_relay(&b_svc.public_key().to_hex(), RELAY)); - b_svc - .store - .save_contact(&contact_with_relay(&a_svc.public_key().to_hex(), RELAY)); - - // Recovery scan (bounded, non-fatal). Import wallets scan from genesis - // (slow — bounded by scan_wait); Generate/no-scan wallets sync from the - // external foreign node in seconds. sync_error=false + synced=true is the - // positive proof the external node was reached (not an embedded node). - let a_synced = wait_until("A synced_from_node", scan_wait, || a.synced_from_node()); - let b_synced = wait_until("B synced_from_node", scan_wait, || b.synced_from_node()); - println!( - "[fe2e] synced_from_node A={a_synced} B={b_synced}; sync_error A={} B={}", - a.sync_error(), - b.sync_error() - ); - - let spendable = |w: &Wallet| -> u64 { - w.get_data() - .map(|d| d.info.amount_currently_spendable) - .unwrap_or(0) - }; - let tip = |w: &Wallet| -> u64 { - w.get_data() - .map(|d| d.info.last_confirmed_height) - .unwrap_or(0) - }; - let a_bal = spendable(&a); - let b_bal = spendable(&b); - println!( - "[fe2e] node contact (clearnet): A tip={} B tip={}", - tip(&a), - tip(&b) - ); - println!("[fe2e] spendable: A={a_bal} nano B={b_bal} nano (need {need})"); - - // ── SEND STEP. If neither wallet is funded we have reached the money move - // with nothing to spend: a clean SMOKE PASS (plumbing proven) or a real - // failure (you funded a wallet — where is it?). ── - if a_bal < need && b_bal < need { - println!( - "[fe2e] STOP at send step: insufficient funds (A={a_bal}, B={b_bal}, need {need})." - ); - a.close(); - b.close(); - if smoke { - println!( - "[fe2e] SMOKE PASS: plumbing green through the send step — both fresh throwaway \ - wallets opened against {node} (EXTERNAL foreign node; synced_from_node A={a_synced} \ - B={b_synced}, sync_error false, tips above prove the node was reached fast — no \ - embedded node), nostr services started and {}connected over the scoped exit for \ - {RELAY}; exit-anchored money path asserted; halted at insufficient funds (expected \ - for empty wallets). Set GOBLIN_E2E_MNEMONIC_A/B to a funded pair for the real \ - payment (Import restore → GOBLIN_E2E_SCAN_WAIT scan).", - if a_conn && b_conn { "" } else { "(partially) " } - ); - return; - } - panic!( - "neither wallet has >= {need} nano spendable (A={a_bal}, B={b_bal}); fund one and retry" - ); - } - - let (sender, sender_svc, recv, recv_svc, sender_name) = if a_bal >= need { - (&a, &a_svc, &b, &b_svc, "A") - } else { - (&b, &b_svc, &a, &a_svc, "B") - }; - let receiver_hex = recv_svc.public_key().to_hex(); - let recv_before = spendable(recv); - println!( - "[fe2e] sender={sender_name} paying {amount} nano to {receiver_hex}; receiver spendable before={recv_before}" - ); - - // Fire ONE NostrSend. The running services drive the WHOLE money path - // themselves: A builds S1 → gift-wrap over the scoped exit → B unwraps + - // receives + replies S2 the same path → A finalizes + posts to the node. - let t_send = Instant::now(); - sender.task(WalletTask::NostrSend( - amount, - receiver_hex.clone(), - Some("funded e2e".to_string()), - vec![], - )); - - // Finalized = "finalized AND posted to node" (see NostrSendStatus). This is - // the accepted-by-node gate — reported even before on-chain confirmation. - let finalized = wait_until("payment finalized+posted", confirm_wait, || { - if let Some(err) = sender_svc.last_send_error() { - println!("[fe2e] sender last_send_error: {err}"); - } - sender_svc - .store - .all_tx_meta() - .iter() - .any(|m| matches!(m.status, NostrSendStatus::Finalized)) - }); - println!( - "[fe2e] send→finalize elapsed {}s finalized={finalized}", - t_send.elapsed().as_secs() - ); - - // Meta trail + payment/finalize ids. - let mut slate_id: Option = None; - let mut wrap_id: Option = None; - for (who, svc) in [("sender", sender_svc), ("receiver", recv_svc)] { - for m in svc.store.all_tx_meta() { - println!( - "[fe2e] {who} meta slate={} status={:?} wrap={:?}", - m.slate_id, m.status, m.sent_event_id - ); - if who == "sender" && matches!(m.status, NostrSendStatus::Finalized) { - slate_id = Some(m.slate_id.clone()); - wrap_id = m.sent_event_id.clone(); - } - } - } - println!( - "[fe2e] TX IDS: slate_id={:?} giftwrap_event_id={:?}", - slate_id, wrap_id - ); - - // On-chain: poll B's received tx to 1 confirmation, print the kernel excess - // (Grin's on-chain identifier) + balance delta. Bounded, best-effort. - if finalized { - let want_slate = slate_id.clone(); - let confirmed = wait_until("receiver tx confirmed (1 block)", confirm_wait, || { - recv.get_data() - .map(|d| { - d.txs.unwrap_or_default().iter().any(|t| { - t.data.tx_type == TxLogEntryType::TxReceived - && t.data.confirmed && want_slate.as_ref().is_none_or(|s| { - t.data.tx_slate_id.map(|u| u.to_string()).as_deref() - == Some(s.as_str()) - }) - }) - }) - .unwrap_or(false) - }); - if let Some(d) = recv.get_data() { - let tip = d.info.last_confirmed_height; - for t in d - .txs - .unwrap_or_default() - .iter() - .filter(|t| t.data.tx_type == TxLogEntryType::TxReceived) - { - let kernel = t.data.kernel_excess.map(|k| k.to_hex()); - let confs = match t.height { - Some(h) if t.data.confirmed => tip.saturating_sub(h) + 1, - _ => 0, - }; - println!( - "[fe2e] receiver TxReceived slate={:?} confirmed={} height={:?} confs={} credited={} kernel_excess={:?}", - t.data.tx_slate_id.map(|u| u.to_string()), - t.data.confirmed, - t.height, - confs, - t.data.amount_credited, - kernel - ); - } - } - let recv_after = spendable(recv); - println!( - "[fe2e] receiver spendable before={recv_before} after={recv_after} onchain_confirmed={confirmed}" - ); - } - - a.close(); - b.close(); - - assert!( - finalized, - "payment did not reach Finalized within {confirm_wait}s (see meta trail above)" - ); - println!( - "[fe2e] PASS: {sender_name}→other paid {amount} nano; gift-wrap rode the scoped exit \ - for {RELAY}, S2 returned the same path, A finalized + posted to {node}. \ - slate_id={slate_id:?} giftwrap={wrap_id:?}" - ); - } -} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1d6d2bdb..d506d14a 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -35,5 +35,5 @@ pub use utils::WalletUtils; mod seed; pub mod store; -#[cfg(test)] +#[cfg(all(test, feature = "e2e-internal"))] mod e2e; diff --git a/tests/connect_timing.rs b/tests/connect_timing.rs deleted file mode 100644 index 0c6bd4b9..00000000 --- a/tests/connect_timing.rs +++ /dev/null @@ -1,315 +0,0 @@ -// COLD-CONNECT TIMING HARNESS (Build 98 latency investigation). Not part of the -// shipped test suite — it exists to MEASURE, on this machine, how long the real -// Nym transport takes to go from a cold start to "transport ready" (a relay -// connected+subscribed on the current tunnel generation), broken down per stage, -// and to detect the exit-reselect LOOP (watchdog condemning a healthy exit -// because relays were slow to connect through lossy mix-dns). -// -// It drives the SAME `NymWebSocketTransport` the app ships with, over the SAME -// default relay set, arming the relay-consumer governance exactly like -// `client.rs::run_service`, so the watchdog behaves as it does in the app. -// -// Run BEFORE (reproduce the old UDP mix-dns + legacy-watchdog loop) vs AFTER -// (DoT-over-mixnet + robust watchdog), same binary, via env toggles: -// -// # BEFORE (old behavior): UDP mix-dns on + legacy watchdog -// GOBLIN_DNS_UDP=1 GOBLIN_LEGACY_WATCHDOG=1 \ -// cargo test --test connect_timing -- --ignored --nocapture --test-threads=1 -// -// # AFTER (shipped default): DoT-over-mixnet + robust watchdog -// cargo test --test connect_timing -- --ignored --nocapture --test-threads=1 -// -// Grep the captured log for lines tagged "[timing]" and "[TIMELINE]". - -use std::time::{Duration, Instant}; - -use grim::nym::NymWebSocketTransport; -use nostr_sdk::prelude::*; - -/// The app's default relay set (src/nostr/relays.rs). -const DEFAULT_RELAYS: &[&str] = &[ - "wss://relay.goblin.st", - "wss://relay.damus.io", - "wss://nos.lol", -]; - -/// Overall budget for the measured window. Long enough to observe several -/// reselect cycles if the loop is present (BEFORE), short enough to keep the run -/// bounded. Overridable with GOBLIN_TIMING_WINDOW_SECS. -fn window() -> Duration { - let secs = std::env::var("GOBLIN_TIMING_WINDOW_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(180); - Duration::from_secs(secs) -} - -fn init() { - let _ = rustls::crypto::ring::default_provider().install_default(); - let _ = env_logger::builder() - .is_test(false) - .format_timestamp_millis() // absolute wall-clock ms on every line - .filter_level(log::LevelFilter::Info) - .filter_module("grim::nym", log::LevelFilter::Debug) - .parse_default_env() - .try_init(); -} - -/// One cold-connect measurement: bring the tunnel up, dial the default relays -/// with the relay-consumer governance armed (as the app does), and record the -/// per-stage timeline + any exit reselects over the window. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore] -async fn cold_connect_timing() { - init(); - let mode_dns = if std::env::var("GOBLIN_DNS_UDP").as_deref() == Ok("1") { - "udp-dns(legacy)" - } else { - "dot-dns" - }; - let mode_wd = if std::env::var("GOBLIN_LEGACY_WATCHDOG").as_deref() == Ok("1") { - "legacy-watchdog" - } else { - "robust-watchdog" - }; - eprintln!("[TIMELINE] === cold_connect_timing START (dns={mode_dns}, watchdog={mode_wd}) ==="); - - let t0 = Instant::now(); - - // Stage A: mixnet tunnel bootstrap (select exit + build + liveness probe). - grim::nym::warm_up(); - let mut tunnel_ready_ms = None; - for _ in 0..480 { - if grim::nym::is_ready() { - tunnel_ready_ms = Some(t0.elapsed().as_millis()); - break; - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - let gen0 = grim::nym::tunnel_generation(); - match tunnel_ready_ms { - Some(ms) => eprintln!("[TIMELINE] A. tunnel READY at t+{ms}ms (gen {gen0})"), - None => { - eprintln!( - "[TIMELINE] A. tunnel NEVER became ready within {}s — mixnet bootstrap failed on this machine", - t0.elapsed().as_secs() - ); - panic!("mixnet never bootstrapped; cannot measure connect timing"); - } - } - - // Stage B: dial the default relays over the mixnet, exactly like run_service: - // arm relay-consumer governance so the watchdog treats a relay-dead exit as - // condemnable (this is what produces the loop in the BEFORE case). - grim::nym::set_relay_consumer(true); - let client = Client::builder() - .signer(Keys::generate()) - .websocket_transport(NymWebSocketTransport) - .build(); - for r in DEFAULT_RELAYS { - let _ = client.add_relay(*r).await; - } - let dial_gen = grim::nym::tunnel_generation(); - let connect_started = Instant::now(); - client.connect().await; - - // Report relay-live on the current generation as soon as a relay connects, - // exactly like run_service's fast-report task — this is what closes the - // watchdog's readiness window in the healthy case. - let mut first_relay_ms = None; - let mut transport_ready_ms = None; - let mut reselects = 0u64; - let mut last_gen = dial_gen; - let mut gen_events: Vec<(u128, u64)> = vec![(t0.elapsed().as_millis(), dial_gen)]; - - loop { - if connect_started.elapsed() > window() { - break; - } - let gen_now = grim::nym::tunnel_generation(); - if gen_now != last_gen { - reselects += 1; - gen_events.push((t0.elapsed().as_millis(), gen_now)); - eprintln!( - "[TIMELINE] !! exit RESELECT #{reselects}: gen {last_gen} -> {gen_now} at t+{}ms (the loop)", - t0.elapsed().as_millis() - ); - last_gen = gen_now; - // Re-dial on the fresh exit like the status loop does. - client.disconnect().await; - for r in DEFAULT_RELAYS { - let _ = client.add_relay(*r).await; - } - client.connect().await; - } - - let connected = client - .relays() - .await - .values() - .any(|r| r.status() == RelayStatus::Connected); - if connected { - // Feed liveness on the CURRENT generation (what run_service does). - grim::nym::report_relay_live(grim::nym::tunnel_generation()); - if first_relay_ms.is_none() { - first_relay_ms = Some(t0.elapsed().as_millis()); - eprintln!( - "[TIMELINE] B. first relay CONNECTED at t+{}ms (~{}ms after connect())", - t0.elapsed().as_millis(), - connect_started.elapsed().as_millis() - ); - } - } else if first_relay_ms.is_some() { - grim::nym::report_relay_down(grim::nym::tunnel_generation()); - } - - if grim::nym::transport_ready() && transport_ready_ms.is_none() { - transport_ready_ms = Some(t0.elapsed().as_millis()); - eprintln!( - "[TIMELINE] C. TRANSPORT READY at t+{}ms (relay live on gen {})", - t0.elapsed().as_millis(), - grim::nym::tunnel_generation() - ); - // Once ready, watch a little longer to confirm it STAYS ready (no loop), - // then finish early rather than burn the whole window. - let settle_until = Instant::now() + Duration::from_secs(20); - let mut stayed = true; - while Instant::now() < settle_until { - tokio::time::sleep(Duration::from_millis(500)).await; - if grim::nym::tunnel_generation() != last_gen { - stayed = false; // a reselect during settle — loop still live - break; - } - } - if stayed { - eprintln!("[TIMELINE] transport stayed ready for 20s — no loop"); - break; - } - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - - grim::nym::set_relay_consumer(false); - client.disconnect().await; - - eprintln!("[TIMELINE] === SUMMARY (dns={mode_dns}, watchdog={mode_wd}) ==="); - eprintln!( - "[TIMELINE] tunnel_ready: {}", - tunnel_ready_ms - .map(|m| format!("{m}ms")) - .unwrap_or("n/a".into()) - ); - eprintln!( - "[TIMELINE] first_relay: {}", - first_relay_ms - .map(|m| format!("{m}ms")) - .unwrap_or("NEVER".into()) - ); - eprintln!( - "[TIMELINE] transport_ready: {}", - transport_ready_ms - .map(|m| format!("{m}ms")) - .unwrap_or("NEVER".into()) - ); - eprintln!("[TIMELINE] exit reselects during window: {reselects} (0 = no loop)"); - eprintln!("[TIMELINE] generation timeline: {gen_events:?}"); - eprintln!("[TIMELINE] === cold_connect_timing END ==="); - - // The measurement itself shouldn't fail the suite; it's diagnostic. But a - // total failure to ever connect is worth surfacing loudly. - assert!( - first_relay_ms.is_some(), - "no relay ever connected within the window" - ); -} - -/// Prove DNS resolves END TO END over the tunnel (DoT, or DoH fallback) — no -/// clearnet. Loops across exit reselects (the mixnet hands out the odd dead -/// exit) until a healthy exit resolves a real relay host, then asserts success. -/// Watch the log for "dot-dns: resolved" / "doh-dns: resolved". -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore] -async fn dns_resolve_smoke() { - init(); - grim::nym::warm_up(); - for _ in 0..480 { - if grim::nym::is_ready() { - break; - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - let deadline = Instant::now() + Duration::from_secs(150); - let mut ok = false; - while Instant::now() < deadline { - if let Some(tunnel) = grim::nym::nymproc::tunnel() { - for host in ["relay.damus.io", "goblin.st", "api.coingecko.com"] { - let t = Instant::now(); - match grim::nym::dns::resolve(&tunnel, host, 443).await { - Some(addr) => { - eprintln!( - "[DNSPROOF] resolved {host} -> {addr} in {}ms OVER THE TUNNEL", - t.elapsed().as_millis() - ); - ok = true; - } - None => eprintln!( - "[DNSPROOF] {host} unresolved on this exit ({}ms) — waiting for a better exit", - t.elapsed().as_millis() - ), - } - } - if ok { - break; - } - } - tokio::time::sleep(Duration::from_secs(3)).await; - } - assert!( - ok, - "DNS never resolved over the tunnel within the window (all exits bad?)" - ); -} - -/// Probe whether the Nym IPR exit policy lets us open TCP to the DoT port (853) -/// through the tunnel. 443 is the control (known-open — relays + HTTPS ride it). -/// Decides DoT-vs-DoH for the private DNS transport. Run: -/// cargo test --test connect_timing probe_dns_ports -- --ignored --nocapture --test-threads=1 -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore] -async fn probe_dns_ports() { - init(); - grim::nym::warm_up(); - let mut ready = false; - for _ in 0..480 { - if grim::nym::is_ready() { - ready = true; - break; - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - assert!(ready, "tunnel never bootstrapped; cannot probe ports"); - let tunnel = grim::nym::nymproc::tunnel().expect("tunnel up"); - let targets = [ - ("cloudflare:853 (DoT)", "1.1.1.1:853"), - ("quad9:853 (DoT)", "9.9.9.9:853"), - ("cloudflare:443 (control)", "1.1.1.1:443"), - ]; - for (label, addr) in targets { - let sa: std::net::SocketAddr = addr.parse().unwrap(); - let t = Instant::now(); - match tokio::time::timeout(Duration::from_secs(12), tunnel.tcp_connect(sa)).await { - Ok(Ok(_)) => eprintln!( - "[PORTPROBE] {label} = CONNECTED in {}ms", - t.elapsed().as_millis() - ), - Ok(Err(e)) => eprintln!( - "[PORTPROBE] {label} = REFUSED/ERR after {}ms: {e}", - t.elapsed().as_millis() - ), - Err(_) => eprintln!( - "[PORTPROBE] {label} = TIMEOUT after {}ms", - t.elapsed().as_millis() - ), - } - } -} diff --git a/tests/nostr_e2e.rs b/tests/nostr_e2e.rs deleted file mode 100644 index cdb2cdb3..00000000 --- a/tests/nostr_e2e.rs +++ /dev/null @@ -1,454 +0,0 @@ -// End-to-end Nostr exchange test against the live Goblin relay. -// -// Proves the NIP-17 payment-message path: gift-wrap send, subscribe, unwrap, -// seal-author verification, subject tag, and Goblin slatepack extraction. -// Network-dependent — run explicitly: -// cargo test --test nostr_e2e -- --ignored --nocapture - -use std::time::Duration; - -use grim::nostr::protocol; -use nostr_sdk::prelude::*; - -const RELAY: &str = "wss://nrelay.us-ea.st"; - -/// A small but valid-looking slatepack armor block for extraction testing. -const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \ - pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \ - dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \ - ENDSLATEPACK."; - -#[tokio::test] -#[ignore] -async fn nip17_slatepack_roundtrip() { - nip17_roundtrip_over(RELAY).await; -} - -/// Same NIP-17 payment roundtrip over relay.damus.io — proves Goblin gift -/// wraps transit a top public relay, not only the relay we run. -/// Run: cargo test --test nostr_e2e nip17_roundtrip_damus -- --ignored --nocapture -#[tokio::test] -#[ignore] -async fn nip17_roundtrip_damus() { - nip17_roundtrip_over("wss://relay.damus.io").await; -} - -/// And over nos.lol, the other large public relay in DEFAULT_RELAYS. -/// Run: cargo test --test nostr_e2e nip17_roundtrip_nos_lol -- --ignored --nocapture -#[tokio::test] -#[ignore] -async fn nip17_roundtrip_nos_lol() { - nip17_roundtrip_over("wss://nos.lol").await; -} - -/// The shared roundtrip, parameterized by relay: Bob advertises a kind-10050 -/// DM relay and subscribes to gift wraps; Alice sends a NIP-17 payment DM; Bob -/// unwraps it, verifies the seal author, and extracts the slatepack + subject. -async fn nip17_roundtrip_over(relay: &str) { - let alice = Keys::generate(); - let bob = Keys::generate(); - println!("alice: {}", alice.public_key().to_bech32().unwrap()); - println!("bob: {}", bob.public_key().to_bech32().unwrap()); - - // Bob's client: connect, advertise DM relays, subscribe to gift wraps. - let bob_client = Client::new(bob.clone()); - bob_client.add_relay(relay).await.unwrap(); - bob_client.connect().await; - tokio::time::sleep(Duration::from_secs(2)).await; - - // Publish Bob's kind-10050 DM relay list so senders find this relay. - let dm_relays = EventBuilder::new(Kind::InboxRelays, "") - .tag(Tag::custom(TagKind::custom("relay"), [relay.to_string()])); - bob_client.send_event_builder(dm_relays).await.unwrap(); - - let filter = Filter::new() - .kind(Kind::GiftWrap) - .pubkey(bob.public_key()) - .since(Timestamp::now() - Duration::from_secs(3 * 86_400)); - bob_client.subscribe(filter, None).await.unwrap(); - - // Alice's client: connect and send a NIP-17 payment DM to Bob. - let alice_client = Client::new(alice.clone()); - alice_client.add_relay(relay).await.unwrap(); - alice_client.connect().await; - tokio::time::sleep(Duration::from_secs(2)).await; - - let content = protocol::build_payment_content(SLATEPACK); - let tags = protocol::build_rumor_tags(Some("lunch :)")); - alice_client - .send_private_msg_to([relay], bob.public_key(), content, tags) - .await - .unwrap(); - println!("alice sent gift-wrapped payment DM"); - - // Bob waits for the gift wrap, unwraps and validates it. - let mut notifications = bob_client.notifications(); - let result = tokio::time::timeout(Duration::from_secs(30), async { - loop { - if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await { - if event.kind != Kind::GiftWrap { - continue; - } - let unwrapped = match bob_client.unwrap_gift_wrap(&event).await { - Ok(u) => u, - Err(_) => continue, - }; - // Seal-author check (the NIP-17 invariant our ingest enforces). - assert_eq!( - unwrapped.rumor.pubkey, unwrapped.sender, - "rumor author must equal seal signer" - ); - assert_eq!(unwrapped.sender, alice.public_key(), "sender must be Alice"); - assert_eq!(unwrapped.rumor.kind, Kind::PrivateDirectMessage); - return unwrapped; - } - } - }) - .await - .expect("timed out waiting for gift wrap"); - - // The slatepack must round-trip intact, and the subject must survive. - let armor = protocol::extract_slatepack(&result.rumor.content) - .expect("slatepack must extract from rumor"); - assert!(armor.starts_with("BEGINSLATEPACK.")); - assert!(armor.ends_with("ENDSLATEPACK.")); - let subject = protocol::extract_subject(&result.rumor.tags); - assert_eq!(subject.as_deref(), Some("lunch :)")); - - println!("✓ NIP-17 slatepack roundtrip verified over {relay}"); - bob_client.disconnect().await; - alice_client.disconnect().await; -} - -/// Register a fresh name on goblin.st with a real NIP-98 signature, then -/// resolve it back — proves the live identity server end-to-end. -/// Run: cargo test --test nostr_e2e nip05 -- --ignored --nocapture -#[tokio::test] -#[ignore] -async fn nip05_registration_roundtrip() { - use base64::Engine; - use sha2::{Digest, Sha256}; - use std::process::Command; - - let keys = Keys::generate(); - let pubkey = keys.public_key().to_hex(); - // Unique-ish name from the pubkey suffix (lowercase alnum). - let name = format!("t{}", &pubkey[..8]); - let server = "https://goblin.st"; - let url = format!("{server}/api/v1/register"); - let body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string(); - - // Build the NIP-98 kind-27235 auth event (same shape as the client). - let payload_hash = hex::encode(Sha256::digest(body.as_bytes())); - let event = EventBuilder::new(Kind::HttpAuth, "") - .tag(Tag::custom(TagKind::custom("u"), [url.clone()])) - .tag(Tag::custom(TagKind::custom("method"), ["POST".to_string()])) - .tag(Tag::custom(TagKind::custom("payload"), [payload_hash])) - .sign_with_keys(&keys) - .unwrap(); - let auth = format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD.encode(event.as_json()) - ); - - // POST the registration via curl (avoids pulling an HTTP client dep). - let out = Command::new("curl") - .args([ - "-s", - "-X", - "POST", - &url, - "-H", - &format!("Authorization: {auth}"), - "-H", - "Content-Type: application/json", - "-d", - &body, - ]) - .output() - .expect("curl register"); - let resp = String::from_utf8_lossy(&out.stdout); - println!("register response: {resp}"); - assert!( - resp.contains("\"nip05\""), - "registration should return a nip05 identifier, got: {resp}" - ); - assert!(resp.contains(&format!("{name}@goblin.st"))); - - // Resolve it back from the well-known endpoint. - let wk = Command::new("curl") - .args([ - "-s", - &format!("{server}/.well-known/nostr.json?name={name}"), - ]) - .output() - .expect("curl well-known"); - let wk_body = String::from_utf8_lossy(&wk.stdout); - println!("well-known response: {wk_body}"); - let resolved = protocol_parse_pubkey(&wk_body, &name); - assert_eq!(resolved.as_deref(), Some(pubkey.as_str())); - - // Clean up: release the test name. - let del_url = format!("{server}/api/v1/register/{name}"); - let del_event = EventBuilder::new(Kind::HttpAuth, "") - .tag(Tag::custom(TagKind::custom("u"), [del_url.clone()])) - .tag(Tag::custom( - TagKind::custom("method"), - ["DELETE".to_string()], - )) - .sign_with_keys(&keys) - .unwrap(); - let del_auth = format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD.encode(del_event.as_json()) - ); - let _ = Command::new("curl") - .args([ - "-s", - "-X", - "DELETE", - &del_url, - "-H", - &format!("Authorization: {del_auth}"), - ]) - .output(); - - println!("✓ NIP-05 registration + resolution verified on {server}"); -} - -/// Minimal well-known pubkey extractor for the test. -fn protocol_parse_pubkey(body: &str, name: &str) -> Option { - let doc: serde_json::Value = serde_json::from_str(body).ok()?; - doc.get("names")?.get(name)?.as_str().map(|s| s.to_string()) -} - -/// Live avatar pipeline e2e against goblin.st: register → upload a processed -/// PNG (NIP-98 by the owner) → profile shows the hash → GET serves a 256px -/// PNG with the hardened headers → 6th change is rate-limited → release -/// purges both the name and its avatar. -/// Run: cargo test --test nostr_e2e avatar -- --ignored --nocapture -#[tokio::test] -#[ignore] -async fn avatar_upload_roundtrip() { - use base64::Engine; - use sha2::{Digest, Sha256}; - use std::process::Command; - - let server = "https://goblin.st"; - let keys = Keys::generate(); - let pubkey = keys.public_key().to_hex(); - let name = format!("a{}", &pubkey[..8]); - - let nip98 = |url: &str, method: &str, body: &[u8]| -> String { - let mut b = EventBuilder::new(Kind::HttpAuth, "") - .tag(Tag::custom(TagKind::custom("u"), [url.to_string()])) - .tag(Tag::custom(TagKind::custom("method"), [method.to_string()])); - if !body.is_empty() { - b = b.tag(Tag::custom( - TagKind::custom("payload"), - [hex::encode(Sha256::digest(body))], - )); - } - let ev = b.sign_with_keys(&keys).unwrap(); - format!( - "Nostr {}", - base64::engine::general_purpose::STANDARD.encode(ev.as_json()) - ) - }; - - // Register the name first. - let reg_url = format!("{server}/api/v1/register"); - let reg_body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string(); - let out = Command::new("curl") - .args([ - "-s", - "-X", - "POST", - ®_url, - "-H", - &format!( - "Authorization: {}", - nip98(®_url, "POST", reg_body.as_bytes()) - ), - "-H", - "Content-Type: application/json", - "-d", - ®_body, - ]) - .output() - .expect("curl register"); - assert!( - String::from_utf8_lossy(&out.stdout).contains("\"nip05\""), - "register failed: {}", - String::from_utf8_lossy(&out.stdout) - ); - - // Build a real PNG via the client pipeline (also strips metadata). - let raw = { - use ::image::{ImageEncoder, RgbaImage}; - let img = RgbaImage::from_fn(640, 480, |x, y| { - ::image::Rgba([(x % 256) as u8, (y % 256) as u8, 90, 255]) - }); - let mut v = Vec::new(); - ::image::DynamicImage::ImageRgba8(img) - .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) - .unwrap(); - v - }; - let png = grim::nostr::avatar::process_avatar_bytes(&raw).expect("process"); - let png_path = std::env::temp_dir().join(format!("{name}.png")); - std::fs::write(&png_path, &png).unwrap(); - let av_url = format!("{server}/api/v1/avatar/{name}"); - - // Upload (raw bytes; payload hash over the PNG). - let out = Command::new("curl") - .args([ - "-s", - "-X", - "POST", - &av_url, - "-H", - &format!("Authorization: {}", nip98(&av_url, "POST", &png)), - "-H", - "Content-Type: application/octet-stream", - "--data-binary", - &format!("@{}", png_path.display()), - ]) - .output() - .expect("curl upload"); - let resp = String::from_utf8_lossy(&out.stdout); - println!("upload: {resp}"); - let hash = serde_json::from_str::(&resp) - .ok() - .and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from)) - .expect("upload should return a hash"); - - // Profile exposes the hash. - let prof = Command::new("curl") - .args(["-s", &format!("{server}/api/v1/profile/{name}")]) - .output() - .unwrap(); - assert!( - String::from_utf8_lossy(&prof.stdout).contains(&hash), - "profile should carry the avatar hash" - ); - - // GET serves a 256px PNG with hardened headers. - let head = Command::new("curl") - .args(["-sI", &format!("{server}/api/v1/avatar/{hash}.png")]) - .output() - .unwrap(); - let head = String::from_utf8_lossy(&head.stdout).to_lowercase(); - assert!(head.contains("content-type: image/png"), "headers: {head}"); - assert!(head.contains("nosniff"), "missing nosniff: {head}"); - assert!( - head.contains("immutable"), - "missing immutable cache: {head}" - ); - let got = Command::new("curl") - .args(["-s", &format!("{server}/api/v1/avatar/{hash}.png")]) - .output() - .unwrap(); - assert!(got.stdout.starts_with(&[0x89, b'P', b'N', b'G'])); - let served = ::image::load_from_memory(&got.stdout).expect("served bytes decode"); - assert_eq!((served.width(), served.height()), (256, 256)); - - // Daily limit: 4 more changes succeed (1 done = 5 total), the 6th is 429. - for i in 0..4 { - // Vary the pixels so each upload is a distinct hash. - let raw = { - use ::image::{ImageEncoder, RgbaImage}; - let img = RgbaImage::from_pixel(64, 64, ::image::Rgba([i as u8 * 40, 10, 10, 255])); - let mut v = Vec::new(); - ::image::DynamicImage::ImageRgba8(img) - .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) - .unwrap(); - v - }; - let png = grim::nostr::avatar::process_avatar_bytes(&raw).unwrap(); - std::fs::write(&png_path, &png).unwrap(); - let out = Command::new("curl") - .args([ - "-s", - "-o", - "/dev/null", - "-w", - "%{http_code}", - "-X", - "POST", - &av_url, - "-H", - &format!("Authorization: {}", nip98(&av_url, "POST", &png)), - "--data-binary", - &format!("@{}", png_path.display()), - ]) - .output() - .unwrap(); - println!("change {}: {}", i + 2, String::from_utf8_lossy(&out.stdout)); - } - // 6th change → 429. - let png = grim::nostr::avatar::process_avatar_bytes(&{ - use ::image::{ImageEncoder, RgbaImage}; - let img = RgbaImage::from_pixel(48, 48, ::image::Rgba([200, 200, 0, 255])); - let mut v = Vec::new(); - ::image::DynamicImage::ImageRgba8(img) - .write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v)) - .unwrap(); - v - }) - .unwrap(); - std::fs::write(&png_path, &png).unwrap(); - let out = Command::new("curl") - .args([ - "-s", - "-o", - "/dev/null", - "-w", - "%{http_code}", - "-X", - "POST", - &av_url, - "-H", - &format!("Authorization: {}", nip98(&av_url, "POST", &png)), - "--data-binary", - &format!("@{}", png_path.display()), - ]) - .output() - .unwrap(); - assert_eq!( - String::from_utf8_lossy(&out.stdout), - "429", - "6th avatar change in 24h must be rate-limited" - ); - - // Release the name → avatar purged. - let del_url = format!("{server}/api/v1/register/{name}"); - let _ = Command::new("curl") - .args([ - "-s", - "-X", - "DELETE", - &del_url, - "-H", - &format!("Authorization: {}", nip98(&del_url, "DELETE", &[])), - ]) - .output(); - let after = Command::new("curl") - .args([ - "-s", - "-o", - "/dev/null", - "-w", - "%{http_code}", - &format!("{server}/api/v1/profile/{name}"), - ]) - .output() - .unwrap(); - assert_eq!( - String::from_utf8_lossy(&after.stdout), - "404", - "profile should 404 after release" - ); - let _ = std::fs::remove_file(&png_path); - println!("✓ avatar upload/serve/limit/release-purge verified on {server}"); -} diff --git a/tests/xrelay_smoke.rs b/tests/xrelay_smoke.rs deleted file mode 100644 index b400f280..00000000 --- a/tests/xrelay_smoke.rs +++ /dev/null @@ -1,517 +0,0 @@ -// THROWAWAY transport-validation harness (G14). Not part of the shipped test -// suite — it exists to prove the migrated transport (in-process smolmix mixnet -// tunnel + mandatory mix-dns) actually DELIVERS NIP-17 gift wraps over real -// relays, using the SAME `NymWebSocketTransport` the app now ships with as its -// only transport. Unlike tests/nostr_e2e.rs (which uses the default clearnet -// nostr-sdk client), every websocket here is dialed through the mixnet and -// every relay hostname is resolved over the tunnel (mix-dns). -// -// Network + mixnet dependent — run explicitly: -// cargo test --test xrelay_smoke -- --ignored --nocapture --test-threads=1 -// -// What to look for in the logs (proof, not just green): -// * "nym: tunnel ready ... (allocated ip ..., probe ok)" — tunnel up, exit auto-selected -// * "mix-dns: resolved -> ..." — each relay resolved OVER the tunnel -// * "v3 delivered + decrypted" — a real 0x03 wrap crossed the wire - -use std::time::{Duration, Instant}; - -use grim::nostr::{protocol, wrapv3}; -use grim::nym::NymWebSocketTransport; -use nostr_sdk::prelude::*; - -/// A small but valid-looking slatepack armor block (same fixture the in-tree -/// wrapv3 unit test uses), so extraction is exercised end to end. -const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \ - pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \ - dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \ - ENDSLATEPACK."; - -const SUBJECT: &str = "lunch :)"; - -/// Install the ring crypto provider (the app does this in `grim::start()`; a -/// test binary must do it itself or the first TLS handshake panics — Build -/// 65/66 rule) and route logs to stdout at debug so the tunnel + mix-dns lines -/// are visible under --nocapture. Both are idempotent. -fn init() { - 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) - .parse_default_env() // honor RUST_LOG if set - .try_init(); -} - -/// Bring the shared in-process mixnet tunnel up before any relay dial, exactly -/// like the real service loop (client.rs `run_service`). Panics if the mixnet -/// never bootstraps — that IS the blocker the on-chain test would hit. -async fn ensure_tunnel() { - grim::nym::warm_up(); - let started = Instant::now(); - for _ in 0..240 { - if grim::nym::is_ready() { - eprintln!( - "[harness] mixnet tunnel ready after ~{}ms", - started.elapsed().as_millis() - ); - return; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - panic!( - "BLOCKER: mixnet tunnel never became ready after {}s — smolmix bootstrap failed \ - (see nym: log lines above). On-chain payment test cannot proceed.", - started.elapsed().as_secs() - ); -} - -/// Build a Goblin-style client for `keys` over the real mixnet transport — -/// byte-for-byte the builder from `src/nostr/client.rs::run_service`. -fn goblin_client(keys: &Keys) -> Client { - Client::builder() - .signer(keys.clone()) - .websocket_transport(NymWebSocketTransport) - .build() -} - -/// Advertise a kind-10050 DM-relay list for `who` pointing at `inbox_relays`, -/// carrying the v3 encryption capability, so the wire shape matches what a real -/// Goblin peer publishes (client.rs `publish_identity`). Best-effort. -async fn advertise_inbox(client: &Client, inbox_relays: &[&str]) { - let mut tags: Vec = inbox_relays - .iter() - .map(|r| Tag::custom(TagKind::custom("relay"), [r.to_string()])) - .collect(); - tags.push(Tag::custom( - TagKind::custom("encryption"), - [wrapv3::ENCRYPTION_CAPABILITY.to_string()], - )); - let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags); - let targets: Vec = inbox_relays.iter().map(|s| s.to_string()).collect(); - match client.sign_event_builder(builder).await { - Ok(ev) => { - if let Err(e) = client.send_event_to(&targets, &ev).await { - eprintln!("[harness] warn: advertise 10050 failed: {e}"); - } - } - Err(e) => eprintln!("[harness] warn: sign 10050 failed: {e}"), - } -} - -/// Wait up to `timeout` for a kind-1059 gift wrap addressed to `me` on the -/// notification stream, unwrap it through Goblin's version-dispatched -/// `wrapv3::unwrap` (proves the 0x03 path over the wire), and return the sender -/// + rumor. Any other event is ignored. -async fn recv_and_unwrap( - client: &Client, - me: &Keys, - timeout: Duration, -) -> Result<(PublicKey, UnsignedEvent), String> { - let mut notifications = client.notifications(); - tokio::time::timeout(timeout, async { - loop { - if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await { - if event.kind != Kind::GiftWrap { - continue; - } - match wrapv3::unwrap(me, &event).await { - Ok(u) => return (u.sender, u.rumor), - // A wrap we cannot open (someone else's) — keep waiting. - Err(e) => { - eprintln!("[harness] ignoring undecryptable wrap: {e}"); - continue; - } - } - } - } - }) - .await - .map_err(|_| "timed out waiting for gift wrap".to_string()) -} - -/// Assert the received rumor is exactly the payment DM Alice sent. -fn assert_payment(sender: PublicKey, alice: &Keys, rumor: &UnsignedEvent, content: &str) { - assert_eq!(sender, alice.public_key(), "sender must be Alice"); - assert_eq!( - rumor.pubkey, - alice.public_key(), - "rumor author == seal signer" - ); - assert_eq!(rumor.kind, Kind::PrivateDirectMessage); - assert_eq!( - rumor.content, content, - "payment content must survive the wire" - ); - let armor = protocol::extract_slatepack(&rumor.content).expect("slatepack must extract"); - assert!(armor.starts_with("BEGINSLATEPACK.") && armor.ends_with("ENDSLATEPACK.")); - assert_eq!( - protocol::extract_subject(&rumor.tags).as_deref(), - Some(SUBJECT) - ); -} - -/// RELAY-GATED READINESS (the point of the G14 hardening): `transport_ready()` -/// must be FALSE while only the tunnel is up, and become TRUE only once a relay -/// is actually connected+subscribed on the CURRENT tunnel generation — the -/// signal that governs the "Connected over Nym" UI and the exit-health window. -/// -/// The bare `nostr_sdk::Client` used here is not the app's `NostrService`, so it -/// doesn't feed the readiness signal on its own; we drive the SAME report the -/// service loop makes (`report_relay_live(tunnel_generation())`) exactly when a -/// relay has connected+subscribed, and assert the gate flips only then. Proves -/// the cross-module contract: tunnel-up alone is NOT ready; a live relay on the -/// current generation IS. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore] -async fn transport_ready_is_relay_gated() { - init(); - ensure_tunnel().await; - let generation = grim::nym::tunnel_generation(); - assert!( - generation != 0, - "a live tunnel must have a non-zero generation" - ); - - // Clear any liveness a prior test left on this (process-global) generation, - // so the assertion is order-independent. - grim::nym::report_relay_down(generation); - assert!( - grim::nym::is_ready(), - "precondition: tunnel (is_ready) must be up" - ); - assert!( - !grim::nym::transport_ready(), - "BUG: transport_ready must be FALSE on a warm tunnel with no live relay \ - (this is exactly the false 'Connected over Nym' the hardening fixes)" - ); - - // Bring one relay to connected+subscribed over the mixnet, like the service. - let relay = "wss://relay.damus.io"; - let bob = Keys::generate(); - let bob_client = goblin_client(&bob); - bob_client.add_relay(relay).await.unwrap(); - bob_client.connect().await; - bob_client - .subscribe( - Filter::new() - .kind(Kind::GiftWrap) - .pubkey(bob.public_key()) - .since(Timestamp::now() - Duration::from_secs(3 * 86_400)), - None, - ) - .await - .unwrap(); - - // Wait for the websocket handshake to actually complete over Nym, then feed - // the readiness signal the way `run_service`'s status tick does. A generous - // budget: a relay handshake over the mixnet is variable (seen 10-30s). - let mut connected = false; - for _ in 0..120 { - if bob_client - .relays() - .await - .values() - .any(|r| r.status() == RelayStatus::Connected) - { - connected = true; - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - assert!(connected, "BLOCKER: relay never connected over the mixnet"); - grim::nym::report_relay_live(generation); - - assert!( - grim::nym::transport_ready(), - "transport_ready must be TRUE once a relay is live on the current generation" - ); - // A report tagged with an OLDER generation must not keep us 'ready' after a - // (hypothetical) reselect: simulate the generation moving on and confirm the - // stale report no longer counts. - grim::nym::report_relay_live(generation - 1); - // Still ready: the current-generation liveness stands (fetch_max floor). - assert!( - grim::nym::transport_ready(), - "a stale-generation report must not lower current readiness" - ); - eprintln!("[harness] relay-gated readiness verified at gen {generation}"); - - bob_client.disconnect().await; -} - -/// CONDEMN + RESELECT (deterministic simulation of a relay-dead exit): with a -/// nostr consumer present but NO relay ever reported live on the current exit, -/// nymproc must condemn the exit within its grace window and rebuild on a fresh -/// auto-selected one (the generation advances), then recover. Proves the -/// exit-health state machine — the whole point of requirement 2 — end to end -/// without needing a naturally bad-for-relays exit (which can't be forced -/// deterministically). In the real app the NostrService DOES report relay-live, -/// so a HEALTHY exit is never condemned (see `v3_cross_relay`). -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore] -async fn dead_for_relays_exit_is_condemned_and_reselected() { - init(); - ensure_tunnel().await; - let gen0 = grim::nym::tunnel_generation(); - assert!(gen0 != 0, "a live tunnel must have a non-zero generation"); - eprintln!( - "[harness] arming relay consumer at gen {gen0}; withholding relay-live to simulate a relay-dead exit" - ); - // Arm relay-reachability governance but never report a live relay: nymproc - // must treat this exit as dead-for-our-purposes and reselect. - grim::nym::set_relay_consumer(true); - - // Budget generously: condemnation itself takes RELAY_GRACE (~25s), then a - // FRESH mixnet bootstrap follows (variable, seen 5-70s), so allow ~150s for - // the generation to advance. - let started = Instant::now(); - let mut advanced = 0u64; - for _ in 0..300 { - let g = grim::nym::tunnel_generation(); - if g > gen0 { - advanced = g; - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - // Disarm FIRST so a failed assert can't leave governance armed for later tests. - grim::nym::set_relay_consumer(false); - assert!( - advanced > gen0, - "BLOCKER: a relay-dead exit was not condemned+reselected within {}s (gen stuck at {gen0})", - started.elapsed().as_secs() - ); - eprintln!( - "[harness] exit condemned + reselected: gen {gen0} -> {advanced} in ~{}s", - started.elapsed().as_secs() - ); - - // Recovery: with governance disarmed, the freshly-built tunnel settles ready. - let mut ready = false; - for _ in 0..80 { - if grim::nym::is_ready() { - ready = true; - break; - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - assert!(ready, "tunnel must recover ready after the reselect"); - eprintln!( - "[harness] tunnel recovered ready after reselect at gen {}", - grim::nym::tunnel_generation() - ); -} - -/// SINGLE-RELAY: a NIP-44 v3 gift wrap round-trips between two fresh Goblin -/// identities over ONE relay, entirely through the smolmix tunnel + mix-dns. -/// Proves the migrated transport delivers the v3 path against a real relay. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore] -async fn v3_roundtrip_single_relay() { - init(); - ensure_tunnel().await; - let relay = "wss://relay.damus.io"; - - let alice = Keys::generate(); - let bob = Keys::generate(); - eprintln!("[harness] single-relay {relay}"); - eprintln!( - "[harness] alice {}", - alice.public_key().to_bech32().unwrap() - ); - eprintln!( - "[harness] bob {}", - bob.public_key().to_bech32().unwrap() - ); - - let bob_client = goblin_client(&bob); - bob_client.add_relay(relay).await.unwrap(); - bob_client.connect().await; - tokio::time::sleep(Duration::from_secs(3)).await; - advertise_inbox(&bob_client, &[relay]).await; - bob_client - .subscribe( - Filter::new() - .kind(Kind::GiftWrap) - .pubkey(bob.public_key()) - .since(Timestamp::now() - Duration::from_secs(3 * 86_400)), - None, - ) - .await - .unwrap(); - - let alice_client = goblin_client(&alice); - alice_client.add_relay(relay).await.unwrap(); - alice_client.connect().await; - tokio::time::sleep(Duration::from_secs(3)).await; - - let content = protocol::build_payment_content(SLATEPACK); - let tags = protocol::build_rumor_tags(Some(SUBJECT)); - let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap"); - assert_eq!(wrap.kind, Kind::GiftWrap); - - let sent = Instant::now(); - alice_client - .send_event_to(vec![relay.to_string()], &wrap) - .await - .expect("publish v3 wrap over mixnet"); - eprintln!("[harness] alice published v3 wrap; waiting for delivery..."); - - let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90)) - .await - .expect("BLOCKER: v3 gift wrap never delivered single-relay"); - assert_payment(sender, &alice, &rumor, &content); - eprintln!( - "[harness] v3 delivered + decrypted single-relay in {} ms over {relay}", - sent.elapsed().as_millis() - ); - - bob_client.disconnect().await; - alice_client.disconnect().await; -} - -/// SINGLE-RELAY v2: the unchanged nostr-sdk NIP-44 v2 gift-wrap path -/// (`send_private_msg_to`) delivered over the SAME smolmix transport, unwrapped -/// through Goblin's version-dispatched `wrapv3::unwrap` (which routes 0x02 to -/// the sdk). Proves the migrated transport is payload-version agnostic — a -/// v2-only peer is unaffected over the mixnet. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore] -async fn v2_roundtrip_single_relay() { - init(); - ensure_tunnel().await; - let relay = "wss://relay.damus.io"; - - let alice = Keys::generate(); - let bob = Keys::generate(); - eprintln!("[harness] single-relay v2 {relay}"); - eprintln!( - "[harness] alice {}", - alice.public_key().to_bech32().unwrap() - ); - eprintln!( - "[harness] bob {}", - bob.public_key().to_bech32().unwrap() - ); - - let bob_client = goblin_client(&bob); - bob_client.add_relay(relay).await.unwrap(); - bob_client.connect().await; - tokio::time::sleep(Duration::from_secs(3)).await; - advertise_inbox(&bob_client, &[relay]).await; - bob_client - .subscribe( - Filter::new() - .kind(Kind::GiftWrap) - .pubkey(bob.public_key()) - .since(Timestamp::now() - Duration::from_secs(3 * 86_400)), - None, - ) - .await - .unwrap(); - - let alice_client = goblin_client(&alice); - alice_client.add_relay(relay).await.unwrap(); - alice_client.connect().await; - tokio::time::sleep(Duration::from_secs(3)).await; - - let content = protocol::build_payment_content(SLATEPACK); - let tags = protocol::build_rumor_tags(Some(SUBJECT)); - // nostr-sdk builds a v2 (0x02) gift wrap here. - let sent = Instant::now(); - alice_client - .send_private_msg_to([relay], bob.public_key(), content.clone(), tags) - .await - .expect("publish v2 wrap over mixnet"); - eprintln!("[harness] alice published v2 wrap; waiting for delivery..."); - - let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90)) - .await - .expect("BLOCKER: v2 gift wrap never delivered single-relay"); - assert_payment(sender, &alice, &rumor, &content); - eprintln!( - "[harness] v2 delivered + decrypted single-relay in {} ms over {relay}", - sent.elapsed().as_millis() - ); - - bob_client.disconnect().await; - alice_client.disconnect().await; -} - -/// CROSS-RELAY (the redundancy direction): Bob's inbox is nos.lol ONLY; Alice's -/// home is damus. Alice publishes the SAME v3 wrap redundantly to BOTH relays; -/// Bob, subscribed only on nos.lol, still receives + decrypts it. Proves -/// delivery does not depend on a single shared relay and that the v3 path works -/// over the real mixnet transport across two relays with no overlap in what the -/// two identities read. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore] -async fn v3_cross_relay() { - init(); - ensure_tunnel().await; - let alice_home = "wss://relay.damus.io"; - let bob_inbox = "wss://nos.lol"; - - let alice = Keys::generate(); - let bob = Keys::generate(); - eprintln!("[harness] cross-relay: alice_home={alice_home} bob_inbox={bob_inbox}"); - eprintln!( - "[harness] alice {}", - alice.public_key().to_bech32().unwrap() - ); - eprintln!( - "[harness] bob {}", - bob.public_key().to_bech32().unwrap() - ); - - // Bob lives ONLY on nos.lol and advertises it as his inbox. - let bob_client = goblin_client(&bob); - bob_client.add_relay(bob_inbox).await.unwrap(); - bob_client.connect().await; - tokio::time::sleep(Duration::from_secs(3)).await; - advertise_inbox(&bob_client, &[bob_inbox]).await; - bob_client - .subscribe( - Filter::new() - .kind(Kind::GiftWrap) - .pubkey(bob.public_key()) - .since(Timestamp::now() - Duration::from_secs(3 * 86_400)), - None, - ) - .await - .unwrap(); - - // Alice's home is damus; she also connects to Bob's inbox to deposit there. - let alice_client = goblin_client(&alice); - alice_client.add_relay(alice_home).await.unwrap(); - alice_client.add_relay(bob_inbox).await.unwrap(); - alice_client.connect().await; - tokio::time::sleep(Duration::from_secs(3)).await; - - let content = protocol::build_payment_content(SLATEPACK); - let tags = protocol::build_rumor_tags(Some(SUBJECT)); - let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap"); - - // Redundant publish to BOTH relays; Bob reads only nos.lol. - let sent = Instant::now(); - alice_client - .send_event_to(vec![alice_home.to_string(), bob_inbox.to_string()], &wrap) - .await - .expect("publish v3 wrap to both relays over mixnet"); - eprintln!( - "[harness] alice published v3 wrap to [{alice_home}, {bob_inbox}]; bob reads only {bob_inbox}" - ); - - let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90)) - .await - .expect("BLOCKER: v3 gift wrap never crossed to bob's inbox relay"); - assert_payment(sender, &alice, &rumor, &content); - eprintln!( - "[harness] v3 delivered + decrypted CROSS-RELAY in {} ms (alice@{alice_home} -> bob@{bob_inbox})", - sent.elapsed().as_millis() - ); - - bob_client.disconnect().await; - alice_client.disconnect().await; -}