Goblin: retire Nym from the public toolchains + drop internal test harnesses

- CI (release.yml/build.yml): remove fetch-nym + AWS_LC_SYS_PREBUILT_NASM;
  the default cargo build --release is Tor/arti only now (nip44 fetch kept).
- Cargo.toml: nym-sdk is optional behind the `nym` feature so the default
  build never pulls it.
- Delete the dead Nym-era probes (xrelay_smoke, connect_timing,
  tunnel_measure) that referenced the removed Nym API and broke `cargo test`.
- Untrack the internal E2E harnesses (e2e.rs, nostr_e2e.rs) via .gitignore;
  gate `mod e2e` behind the `e2e-internal` feature so clones still compile.
- Refresh stale Nym comments in build.rs and the build scripts.
This commit is contained in:
2ro
2026-07-04 05:59:56 -04:00
parent 1e55ef5dfb
commit 1f36631777
14 changed files with 18 additions and 2167 deletions
-23
View File
@@ -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"
-10
View File
@@ -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
-5
View File
@@ -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: |
+4
View File
@@ -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
+5 -3
View File
@@ -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"] }
+3 -3
View File
@@ -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
-105
View File
@@ -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 -- <seconds> [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<String> = 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<u128> = 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::<String>())
);
}
}
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<u128> = lats.iter().take(3).copied().collect();
println!("(sorted sample) fastest 3: {:?}", head);
}
}
+3 -3
View File
@@ -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"
+2 -2
View File
@@ -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
-726
View File
@@ -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<String> {
std::env::var(key)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Env var parsed as u64, else `default`.
fn e2e_env_u64(key: &str, default: u64) -> u64 {
e2e_env(key).and_then(|s| s.parse().ok()).unwrap_or(default)
}
/// Truthy env flag (`1` / `true`).
fn e2e_flag(key: &str) -> bool {
e2e_env(key)
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
/// Headless END-TO-END real-Grin payment A → B over the just-split money path,
/// driven entirely by Goblin's own wallet + nostr code (no slate crypto is
/// reimplemented here). Steps: restore both wallets from their mnemonics into
/// per-wallet temp dirs → open against the grin node and recovery-scan → A
/// sends a real payment to B THROUGH the nostr DM path (slatepack →
/// kind-1059 gift-wrap → published over the SCOPED EXIT to relay.floonet.dev)
/// → B's running service unwraps, ingests (receive), replies S2 the same path
/// → A auto-finalizes and posts to the node → verify Finalized (= accepted by
/// node) and, best-effort, B's received tx reaching 1 confirmation.
///
/// The nostr identity is a per-wallet RANDOM nsec (see nostr/identity.rs), NOT
/// derived from the wallet seed — so B's real runtime npub (read here) is the
/// pay target and its advertised inbox + subscription line up by construction.
///
/// Ignored by default (real mainnet funds + a full recovery scan). Run:
/// GOBLIN_E2E_MNEMONIC_A="word ..." GOBLIN_E2E_MNEMONIC_B="word ..." \
/// RUST_LOG=grim=info \
/// cargo test --lib wallet::e2e::tests::funded_e2e_pay -- --ignored --nocapture
/// Smoke (empty throwaway wallets, stops at insufficient funds — proves the
/// plumbing up to the money move):
/// GOBLIN_E2E_ALLOW_UNFUNDED=1 GOBLIN_E2E_SCAN_WAIT=180 RUST_LOG=grim=info \
/// cargo test --lib wallet::e2e::tests::funded_e2e_pay -- --ignored --nocapture
/// Knobs: GOBLIN_E2E_NODE (default https://api.grin.money), GOBLIN_E2E_AMOUNT
/// (nano, default 0.1 GRIN), GOBLIN_E2E_CONFIRM_WAIT (finalize+confirm budget
/// secs, default 600), GOBLIN_E2E_SCAN_WAIT (recovery-scan budget secs, default
/// 2400), GOBLIN_E2E_HOME (default /tmp/e2e-home).
#[test]
#[ignore]
fn funded_e2e_pay() {
// Shipped money-path relay, reached over its co-located scoped exit.
const RELAY: &str = "wss://relay.floonet.dev";
// Task env: MNEMONIC_A/B (fall back to SEED_A/B for parity with the
// cross-node test above). Absent + ALLOW_UNFUNDED=1 → throwaway EMPTY
// wallets to smoke the plumbing.
let allow_unfunded = e2e_flag("GOBLIN_E2E_ALLOW_UNFUNDED");
let mnem_a = e2e_env("GOBLIN_E2E_MNEMONIC_A").or_else(|| e2e_env("GOBLIN_E2E_SEED_A"));
let mnem_b = e2e_env("GOBLIN_E2E_MNEMONIC_B").or_else(|| e2e_env("GOBLIN_E2E_SEED_B"));
let (mnem_a, mnem_b, smoke) = match (mnem_a, mnem_b) {
(Some(a), Some(b)) => (a, b, false),
_ if allow_unfunded => {
println!(
"[fe2e] no mnemonics in env; SMOKE mode with FRESH throwaway EMPTY wallets \
(no-scan, sync fast from the external node)"
);
(String::new(), String::new(), true)
}
_ => {
println!(
"[fe2e] SKIP: set GOBLIN_E2E_MNEMONIC_A and GOBLIN_E2E_MNEMONIC_B \
(or GOBLIN_E2E_ALLOW_UNFUNDED=1 to smoke the plumbing)"
);
return;
}
};
let node =
e2e_env("GOBLIN_E2E_NODE").unwrap_or_else(|| "https://api.grin.money".to_string());
let amount = e2e_env_u64("GOBLIN_E2E_AMOUNT", AMOUNT);
let need = amount + 20_000_000; // amount + generous fee headroom
let scan_wait = e2e_env_u64("GOBLIN_E2E_SCAN_WAIT", 2400);
let confirm_wait = e2e_env_u64("GOBLIN_E2E_CONFIRM_WAIT", 600);
// Isolate wallet + nym state under a throwaway HOME. MUST precede any grim
// call (Settings roots at $HOME/.goblin on first deref, incl. pool::load).
let home = e2e_env("GOBLIN_E2E_HOME").unwrap_or_else(|| "/tmp/e2e-home".to_string());
unsafe {
std::env::set_var("HOME", &home);
}
// Surface the nym transport info logs — the exit-connect evidence line
// ("CONNECTED via scoped exit") is emitted at info by the money client.
let _ = env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("grim=info"),
)
.is_test(false)
.try_init();
println!("[fe2e] HOME={home} node={node} relay={RELAY} amount={amount} nano smoke={smoke}");
// App-startup shims a bare test must do itself.
let _ = rustls::crypto::ring::default_provider().install_default();
// ── EXIT EVIDENCE (deterministic, offline). The compiled-in pinned pool
// maps the money relay to its co-located SCOPED Nym exit; the money client's
// NymWebSocketTransport dials THAT (kind-1059 gift-wraps only), while the
// identity/general client is stock CLEARNET. Assert the money path is
// actually exit-anchored before spending a cent. ──
let pool = crate::nostr::pool::load();
let exit = pool.exit_for(RELAY);
println!(
"[fe2e] EXIT EVIDENCE: pool.has_exit={} exit_for({RELAY})={:?}",
pool.has_exit(),
exit
);
assert!(
exit.is_some(),
"money relay {RELAY} advertises no scoped exit in the pool; the split money path cannot be verified"
);
crate::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<String> = None;
let mut wrap_id: Option<String> = None;
for (who, svc) in [("sender", sender_svc), ("receiver", recv_svc)] {
for m in svc.store.all_tx_meta() {
println!(
"[fe2e] {who} meta slate={} status={:?} wrap={:?}",
m.slate_id, m.status, m.sent_event_id
);
if who == "sender" && matches!(m.status, NostrSendStatus::Finalized) {
slate_id = Some(m.slate_id.clone());
wrap_id = m.sent_event_id.clone();
}
}
}
println!(
"[fe2e] TX IDS: slate_id={:?} giftwrap_event_id={:?}",
slate_id, wrap_id
);
// On-chain: poll B's received tx to 1 confirmation, print the kernel excess
// (Grin's on-chain identifier) + balance delta. Bounded, best-effort.
if finalized {
let want_slate = slate_id.clone();
let confirmed = wait_until("receiver tx confirmed (1 block)", confirm_wait, || {
recv.get_data()
.map(|d| {
d.txs.unwrap_or_default().iter().any(|t| {
t.data.tx_type == TxLogEntryType::TxReceived
&& t.data.confirmed && want_slate.as_ref().is_none_or(|s| {
t.data.tx_slate_id.map(|u| u.to_string()).as_deref()
== Some(s.as_str())
})
})
})
.unwrap_or(false)
});
if let Some(d) = recv.get_data() {
let tip = d.info.last_confirmed_height;
for t in d
.txs
.unwrap_or_default()
.iter()
.filter(|t| t.data.tx_type == TxLogEntryType::TxReceived)
{
let kernel = t.data.kernel_excess.map(|k| k.to_hex());
let confs = match t.height {
Some(h) if t.data.confirmed => tip.saturating_sub(h) + 1,
_ => 0,
};
println!(
"[fe2e] receiver TxReceived slate={:?} confirmed={} height={:?} confs={} credited={} kernel_excess={:?}",
t.data.tx_slate_id.map(|u| u.to_string()),
t.data.confirmed,
t.height,
confs,
t.data.amount_credited,
kernel
);
}
}
let recv_after = spendable(recv);
println!(
"[fe2e] receiver spendable before={recv_before} after={recv_after} onchain_confirmed={confirmed}"
);
}
a.close();
b.close();
assert!(
finalized,
"payment did not reach Finalized within {confirm_wait}s (see meta trail above)"
);
println!(
"[fe2e] PASS: {sender_name}→other paid {amount} nano; gift-wrap rode the scoped exit \
for {RELAY}, S2 returned the same path, A finalized + posted to {node}. \
slate_id={slate_id:?} giftwrap={wrap_id:?}"
);
}
}
+1 -1
View File
@@ -35,5 +35,5 @@ pub use utils::WalletUtils;
mod seed;
pub mod store;
#[cfg(test)]
#[cfg(all(test, feature = "e2e-internal"))]
mod e2e;
-315
View File
@@ -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()
),
}
}
}
-454
View File
@@ -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<String> {
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",
&reg_url,
"-H",
&format!(
"Authorization: {}",
nip98(&reg_url, "POST", reg_body.as_bytes())
),
"-H",
"Content-Type: application/json",
"-d",
&reg_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::<serde_json::Value>(&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}");
}
-517
View File
@@ -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 <host> -> <ip> ..." — 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<Tag> = 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<String> = 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;
}