From c701f0f48087ac1e7e9d1853ee6a295c9d927a39 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:17:59 -0400 Subject: [PATCH] Floonet scoped Nym exit + NIP-44 v3 + wallet polish Money path: - Scoped, unbonded Nym exit for the money-path relay: the wallet dials a relay operator's co-located exit over a MixnetStream (src/nym/streamexit.rs) which pipes to its one relay; hostname-validated TLS end to end, no public DNS. Anchor + fallback (never pin-only): any exit failure degrades to the smolmix tunnel. relay.goblin.st's exit address is pinned in the relay pool (src/nostr/pool.rs) and the maintainer gist so it bootstraps offline. - STREAM_SETTLE bridges the open-before-accept gap so the first TLS byte is not dropped into a stalled handshake. - Verified end to end: two wallets complete a real gift-wrapped Grin payment through relay.goblin.st over the exit, finalized + posted on mainnet (src/wallet/e2e.rs, ignored live test). Encryption: - Adopt NIP-44 v3 for the NIP-17 gift-wrap path (G4): src/nostr/wrapv3.rs, nip44 path dep; v3<->v3 and v3->v2 interop. Also: mix-DNS (src/nym/dns.rs), full localization pass, GUI polish, avatar-ring example, Android icon/script updates, GRIM deviation notes, xrelay + connect-timing tests. --- .gitignore | 5 + Cargo.lock | 192 ++++- Cargo.toml | 47 +- .../mw/gri/android/BackgroundService.java | 38 + .../java/mw/gri/android/MainActivity.java | 13 +- .../main/res/drawable-hdpi/ic_stat_name.png | Bin 984 -> 1404 bytes .../main/res/drawable-mdpi/ic_stat_name.png | Bin 615 -> 965 bytes .../main/res/drawable-xhdpi/ic_stat_name.png | Bin 1398 -> 1870 bytes .../main/res/drawable-xxhdpi/ic_stat_name.png | Bin 2185 -> 2804 bytes .../res/drawable-xxxhdpi/ic_stat_name.png | Bin 2978 -> 3832 bytes docs/GRIM-DEVIATIONS.md | 300 ++++++++ examples/avatar_ring.rs | 128 ++++ locales/de.yml | 12 +- locales/en.yml | 12 +- locales/fr.yml | 12 +- locales/ru.yml | 12 +- locales/tr.yml | 12 +- locales/zh-CN.yml | 12 +- scripts/android.sh | 7 +- scripts/gen_icons.sh | 12 + src/gui/app.rs | 38 +- src/gui/platform/android/mod.rs | 48 ++ src/gui/theme.rs | 6 - src/gui/views/content.rs | 3 + src/gui/views/goblin/data.rs | 76 +- src/gui/views/goblin/identicon.rs | 33 +- src/gui/views/goblin/mod.rs | 446 +++++++---- src/gui/views/goblin/onboarding.rs | 35 +- src/gui/views/goblin/send.rs | 66 +- src/gui/views/goblin/widgets.rs | 276 +++---- src/gui/views/network/content.rs | 2 +- src/gui/views/wallets/content.rs | 10 +- src/gui/views/wallets/wallet/content.rs | 13 +- src/lib.rs | 19 +- src/node/node.rs | 19 +- src/nostr/client.rs | 498 +++++++++--- src/nostr/config.rs | 10 +- src/nostr/identity.rs | 77 +- src/nostr/mod.rs | 3 + src/nostr/nip05.rs | 53 +- src/nostr/pool.rs | 535 +++++++++++++ src/nostr/types.rs | 5 + src/nostr/wrapv3.rs | 296 ++++++++ src/nym/dns.rs | 612 +++++++++++++++ src/nym/mod.rs | 287 +++++-- src/nym/nymproc.rs | 706 ++++++++++++++++++ src/nym/sidecar.rs | 154 ---- src/nym/streamexit.rs | 229 ++++++ src/nym/transport.rs | 142 +++- src/wallet/connections/external.rs | 15 +- src/wallet/e2e.rs | 209 ++++++ src/wallet/mod.rs | 3 + src/wallet/wallet.rs | 119 ++- tests/connect_timing.rs | 315 ++++++++ tests/xrelay_smoke.rs | 517 +++++++++++++ wallet | 2 +- 56 files changed, 5741 insertions(+), 950 deletions(-) create mode 100644 docs/GRIM-DEVIATIONS.md create mode 100644 examples/avatar_ring.rs create mode 100644 src/nostr/pool.rs create mode 100644 src/nostr/wrapv3.rs create mode 100644 src/nym/dns.rs create mode 100644 src/nym/nymproc.rs delete mode 100644 src/nym/sidecar.rs create mode 100644 src/nym/streamexit.rs create mode 100644 src/wallet/e2e.rs create mode 100644 tests/connect_timing.rs create mode 100644 tests/xrelay_smoke.rs diff --git a/.gitignore b/.gitignore index ff217dc..08f5465 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ Cargo.toml-e screenshots/ # GRIM-canonical build toolchains fetched by scripts/toolchain.sh .toolchains/ +# Runtime wallet/node artifacts + secrets generated by running locally — NEVER commit +.owner_api_secret +.foreign_api_secret +grin-wallet.log +grin-wallet.toml diff --git a/Cargo.lock b/Cargo.lock index ddd180e..1e6fd37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2813,6 +2813,47 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.0", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.114", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" @@ -4496,6 +4537,7 @@ dependencies = [ "grin_wallet_libwallet", "grin_wallet_util", "hex", + "hickory-proto", "http-body-util", "hyper 1.8.1", "hyper-proxy2", @@ -4509,6 +4551,7 @@ dependencies = [ "local-ip-address", "log", "log4rs", + "nip44", "nokhwa", "nostr-relay-pool", "nostr-sdk", @@ -4521,23 +4564,24 @@ dependencies = [ "qrcodegen", "rand 0.9.2", "regex", - "reqwest 0.12.28", "rfd", "ring 0.16.20", "rkv", "rqrr", "rust-i18n", "rustls 0.23.40", + "secp256k1 0.31.1", "serde", "serde_derive", "serde_json", "serde_yaml", "sha2 0.10.9", + "smolmix", "sys-locale", "thiserror 2.0.18", "tokio 0.2.25", "tokio 1.49.0", - "tokio-socks 0.5.3", + "tokio-rustls 0.26.4", "tokio-tungstenite 0.26.2", "tokio-util 0.2.0", "toml 0.9.11+spec-1.1.0", @@ -4545,6 +4589,7 @@ dependencies = [ "url", "usvg", "uuid 0.8.2", + "webpki-roots 1.0.7", "wgpu", "winit", "winresource", @@ -5045,6 +5090,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -5162,6 +5216,16 @@ dependencies = [ "http 1.4.0", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.3.3" @@ -5660,7 +5724,6 @@ dependencies = [ "tokio 1.49.0", "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.7", ] [[package]] @@ -6983,6 +7046,12 @@ dependencies = [ "libc", ] +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "matchers" version = "0.2.0" @@ -7408,6 +7477,21 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nip44" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chacha20 0.9.1", + "constant_time_eq 0.4.2", + "hkdf 0.12.4", + "hmac 0.12.1", + "rand_core 0.9.5", + "secp256k1 0.31.1", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -11017,44 +11101,6 @@ dependencies = [ "winreg 0.50.0", ] -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes 1.11.1", - "futures-core", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.9", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite 0.2.16", - "quinn", - "rustls 0.23.40", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio 1.49.0", - "tokio-rustls 0.26.4", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.7", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -11780,6 +11826,17 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes 0.14.100", + "rand 0.9.2", + "secp256k1-sys 0.11.0", +] + [[package]] name = "secp256k1-sys" version = "0.8.2" @@ -11798,6 +11855,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "secrecy" version = "0.6.0" @@ -12306,6 +12372,36 @@ dependencies = [ "serde", ] +[[package]] +name = "smolmix" +version = "1.21.1" +dependencies = [ + "futures 0.3.31", + "nym-ip-packet-requests", + "nym-sdk", + "smoltcp", + "thiserror 2.0.18", + "tokio 1.49.0", + "tokio-smoltcp", + "tracing", +] + +[[package]] +name = "smoltcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if 1.0.4", + "defmt 0.3.100", + "heapless", + "libc", + "log", + "managed", +] + [[package]] name = "snow" version = "0.9.6" @@ -13362,6 +13458,20 @@ dependencies = [ "tokio 1.49.0", ] +[[package]] +name = "tokio-smoltcp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f5d53da1c3095663a8900d86c2abb0ffe02d3f6aa86527b066148fcb33e65e" +dependencies = [ + "futures 0.3.31", + "parking_lot 0.12.5", + "pin-project-lite 0.2.16", + "smoltcp", + "tokio 1.49.0", + "tokio-util 0.7.18", +] + [[package]] name = "tokio-socks" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 264dd6b..3c2cd63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,7 @@ rkv = "0.20.0" usvg = "0.45.1" ring = "0.16.20" hyper = { version = "1.6.0", features = ["full"], package = "hyper" } -hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy"] } +hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy", "tokio"] } http-body-util = "0.1.3" bytes = "1.11.0" hyper-socks2 = "0.9.1" @@ -100,29 +100,42 @@ num-bigint = "0.4.6" ## nostr nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] } nostr-relay-pool = "0.44" +## NIP-44 v3 (+ v2) encryption for the NIP-17 backward-compat extension (G4). +## Path dep on the local crate's `v3` working tree — the M0 deliverable, all +## upstream test vectors green. Do NOT float this to crates.io until the crate +## is published there. +nip44 = { path = "../nip44" } +## Only to construct the key types the `nip44` crate takes: nostr-sdk pins +## secp256k1 0.29, the nip44 crate 0.31 — bridged via byte arrays in wrapv3.rs. +secp256k1 = "0.31" async-wsocket = "0.13" tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } regex = "1" base64 = "0.22" hex = "0.4" -## HTTP client routed through the local Nym SOCKS5 sidecar (rustls, no native -## TLS so it cross-compiles to Android; `socks` so every request — NIP-05, -## price, avatars — goes over the mixnet, never clearnet). -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks"] } -## SOCKS5 TCP dialer for the nostr relay WebSocket transport over the mixnet. -tokio-socks = "0.5" - -## rustls is pulled by both our TLS (tungstenite/reqwest, ring) and nym-sdk +## rustls is pulled by both our TLS (tungstenite, ring) and nym-sdk ## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default, ## so we install ring explicitly at startup (see lib.rs). Direct dep just to make -## `rustls::crypto::ring::default_provider()` reachable. +## `rustls::crypto::ring::default_provider()` reachable. NOTHING here may pull +## rustls-platform-verifier — it panics on Android outside a full app context. rustls = { version = "0.23", features = ["ring"] } +## TLS-over-tunnel for the mixnet HTTP client (webpki roots, never the platform +## verifier — see the rustls note above). +tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } +webpki-roots = "1" -## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary). We -## run the SDK's SOCKS5 client on an internal tokio task exposing 127.0.0.1:1080, -## the same loopback seam the transport already dials. Path dep: the local nym -## checkout carries our Android webpki-roots patch. +## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary). +## Path deps into the local nym checkout, PINNED at rev +## f6ed17d949cc19fee0fb51db3cb65771fd510d5b: it carries the load-bearing local +## commit "http-api-client: preconfigured webpki roots on Android". Do not +## float the checkout past that rev without re-verifying the Android build. nym-sdk = { path = "../nym/sdk/rust/nym-sdk" } +## smolmix: TCP/UDP tunnel over the mixnet with an AUTO-SELECTED IPR exit — +## the single-network-requester SPOF is structurally gone (plan G14). +smolmix = { path = "../nym/smolmix/core" } +## mix-dns wire codec. Already in the dependency graph via nym-http-api-client +## (Cargo.lock), so we reuse it instead of vendoring a DNS encode/parse. +hickory-proto = { version = "0.26", default-features = false, features = ["std"] } ## NIP-98 payload hashing sha2 = "0.10.8" @@ -184,3 +197,9 @@ 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. +log = "0.4.27" +env_logger = "0.11.3" +rustls = { version = "0.23", features = ["ring"] } diff --git a/android/app/src/main/java/mw/gri/android/BackgroundService.java b/android/app/src/main/java/mw/gri/android/BackgroundService.java index f0df3b1..f58c035 100644 --- a/android/app/src/main/java/mw/gri/android/BackgroundService.java +++ b/android/app/src/main/java/mw/gri/android/BackgroundService.java @@ -23,6 +23,10 @@ public class BackgroundService extends Service { private boolean mStopped = false; private static final int NOTIFICATION_ID = 1; + // One-shot "payment received" notification, separate from the persistent + // sync notification above. + private static final int PAYMENT_NOTIFICATION_ID = 2; + private static final String PAYMENT_CHANNEL_ID = "PaymentReceived"; private NotificationCompat.Builder mNotificationBuilder; private String mNotificationContentText = ""; @@ -189,6 +193,40 @@ public class BackgroundService extends Service { notificationManager.cancel(NOTIFICATION_ID); } + // Show a one-shot "payment received" notification (id=2), separate from + // the persistent sync notification (id=1). Called from native code via + // MainActivity when a payment slatepack is received over nostr, possibly + // while the app is backgrounded. Localization of the fixed strings is a + // follow-up (text is composed here at Java side). + public static void notifyPaymentReceived(Context context, String name, String amount) { + NotificationManager manager = context.getSystemService(NotificationManager.class); + if (manager == null) { + return; + } + // High-importance channel so the notification pops with sound + vibration. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + PAYMENT_CHANNEL_ID, "Payments", NotificationManager.IMPORTANCE_HIGH + ); + manager.createNotificationChannel(channel); + } + Intent i = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_IMMUTABLE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, PAYMENT_CHANNEL_ID) + .setContentTitle("Payment received") + .setContentText(name + " paid " + amount + " ツ") + .setSmallIcon(R.drawable.ic_stat_name) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentIntent(pendingIntent); + try { + manager.notify(PAYMENT_NOTIFICATION_ID, builder.build()); + } catch (SecurityException e) { + // POST_NOTIFICATIONS not granted: skip the notification, never the payment. + } + } + // Start the service. public static void start(Context c) { if (!isServiceRunning(c)) { diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index e3903c6..f5ac7a8 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -421,6 +421,12 @@ public class MainActivity extends GameActivity { // Notify native code to stop activity (e.g. node) if app was terminated from recent apps. public native void onTermination(); + // Called from native code to show a "payment received" notification + // (BackgroundService id=2) when a payment arrives over nostr. + public void notifyPaymentReceived(String name, String amount) { + BackgroundService.notifyPaymentReceived(this, name, amount); + } + // Called from native code to set text into clipboard. public void copyText(String data) { ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); @@ -619,7 +625,12 @@ public class MainActivity extends GameActivity { // Called from native code to pick the file. public void pickFile() { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("text/*"); + // Permissive type: custom extensions like .backup have no registered + // MIME type, so any narrower filter greys them out in the picker and + // locks users out of restoring their identity. Content is validated + // on the native side after selection. + intent.setType("*/*"); + intent.addCategory(Intent.CATEGORY_OPENABLE); try { mFilePickResult.launch(Intent.createChooser(intent, "Pick file")); } catch (android.content.ActivityNotFoundException ex) { diff --git a/android/app/src/main/res/drawable-hdpi/ic_stat_name.png b/android/app/src/main/res/drawable-hdpi/ic_stat_name.png index c4385b478860e7d1852731602d1244bea39de9ce..a766e6e260bfee3d45eeb496b3693d6e19c12d07 100644 GIT binary patch delta 1386 zcmZXUdomU$GUd&?$>OWMR#5`G}mYuGwH#PB5vJ^l`&j- z>_n7ANpzV<-I_a(sg^idw-80EI}*y>eV%jA{hsgle7@)VJ)iUa@B0L~td(a80UM)t z(kK9M1P1^aSpcvIsQC&2cz6JqW&i-O2mr90($>JQ)QeS7WIt~;)$!4*4N?Qe*%`uB zlksOM<30P;4Kf*|_#mf|8dw{fMu(yq^=MPF4?TH*WHOT&oy1fFaKJk_+v1%eTY^In z-kC^nB@*y9csvo0kEjqj|Hoh_>}SOs{>S*gux;7=lv=R<_e3&_&EzIWvN?Y{F1C0= zkb@)9>5u&D2)d+~3ILjZ5a{g@MDLasv10|nhCf3kBLVVIHZ#PRVyZXM+lW8qw0b7H z{en-0&z!B5<`PZM*ZV>sW-y0Tfb*3~NGeZ^tIhe5AcfMW9NXUTpSqS48<{T6J7aisJqEK{Xb+6{K>F1@sft4*(V}6+4C1 z>c%4N;FzP&s0_COH*;f9Lh{#TtXHH@-7{ z{SdzUbY2M}9HL+GEXjE8ux@%wSJ$K~E5_irhB6W2IdL#GH-@pL zp;I+zCcTPSzuip>RvRdzfnio>*6BUz<~M_(maKdck3SDi7dlx_6^Ral5Iu!1lUV24 z^Hr@P%i%+j3^e?-{}Fdw7deS8g44BCPbKyMB*&F5k203woE|}4)6H2#XhsBx@st(t zQ^<=HTA|4QI5UOZ`T#ThBJsN6b;Rz$rJEsU{1!&L=qg`^(w^Vd>L(jnUu{9!ij3f6 z_J6o`KXF73ciC=O@aL@S64p0IO+mw30h>2|n4<^S%4$i&Lb{<-Q?bDaK3E9fVS zsVkh0TGL`VtZD~QacP&r<2UenO)_FI{xeI}2H#<$L~$%i^Lm-L;D_LEfsu?3DS^!# zonPQdNZQt#pg*vy=Pq@+(u*pLP@qp*_MXYGuH_ z$E%Kw&sIsGCc4I`@dMrB057K!|H6f*ojN^4#nsgA@E*e`;|AII{YUwOAym+9rtNx3hsJLY`<+NIT6ZrU9Ma~QWe1qu4ahZaQ#oRzs!QEG zKBr;hS3KIw&nAv$($D+h5C=35YT&k)O(5zeEhA}!oXSlhHI@}``(4@Rj@gSGxYUa#Cj(t_x&9h61am>%TJ$WgI?0jK@w%Fvwn%9oTO>!cglAK2 e`dU`XKf#4(`(q`yE`_Q85div7z3V+0$A1UpSzxdL delta 963 zcmV;!13div3fKpbBYy**NklVM zdeGVELV@qlVQ8U!^1J60RM+?XE6}jSnxBCNLtUW-&^wC<-a_-BF3=F@lEv5go;ibN z<3ykQD%8k%98=%yqMxC`&d-DHTh5r+`z_HAzjl9^%_F= zpyS>nh>y40@!lhdpNDQj4HCTYb6@es8|ZafFaq9O(W#s9vmsV$O-mEJ;pR>A9ub|{ zTvFb6;{)^}Ef@i>wdf5!fOj*}K49)oyYtNZ8;ZkRL@3kkf3 zdflH3;C}_^mVNhd0p&ImruIYMD*&&%U*EGJM7Z4@0tL!k-K7D?TB zpu+bGz?*M5W!_k6#zk>6!TcWP6TZ#ql4XTu9GU?=w|u_&j2UQo*DYs>H`_w5l1zMW z3Y|8u3~Cqq*+5=<(ao6fvx)Q9na3N?94ZJFiGL12KG6Yars$jFKqh;jdcLkQklz5> zZ}mJUEraGrYQg0=8|CYM0dunGe&bo|bE}Hz;>xk+c`sSkt~5laD~}9;H9jX7Bz2}D zx1l~hCo4!EZG}g@mn))gDvYU--merCtg^yl@BIyun&!*BmlY&8lhl;m;cXK?qeBi6JZ=WWV!piAAna!^tcg@yJUgSOGNZ&>A5UA zUGqf`bG(tx(C#=1Y=b&lA8wpHobdsCFHD diff --git a/android/app/src/main/res/drawable-mdpi/ic_stat_name.png b/android/app/src/main/res/drawable-mdpi/ic_stat_name.png index 39c3c488ca976502c78bac92d5c7cba870388a6d..38c3b637aed2ac28d3d33f2b3fd49a9d17b7dbba 100644 GIT binary patch literal 965 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`Gj3dtTpz6=aiY77hwEes65fI3W4Y~O#nQ4`{HR$|N>;h`gfZI@#nVVW%l9*cn)nl$}U>IU#Y-IvM z5KH)GpY~&5V4C3R;uzv_eCt*3j_^_u_7CTKCTG^4owMTT1XkNC9Nf2D19i0w1fs4i zirTh9!AodyyWS#Km)ym!f}FWaiUM1tSk2aQRZY4Xtfjgz%Eb4~>0|k`iyxkQ`+xpL zx%eG(PQU;2&hq)Z_mw=VAM2BLJPGUBcJBPK*b3JChjDBEsq?kH;_pk8d7mI^_9*5| zGPB-6?i+n8gFj7hnKYB(yN*z+)R%}e0}-*0qs7dnfcWtybAQ|)A<*u&4B-&88iPP$BbIY-7% zrEnoXCw(eLJ^ubu~(BlZhhRHYWc7?CbughJ|(N-Y!E&T8I4fzcppZyc8i7rbk zV9Ho-sl=X9Rhwc}KU%x$d|?5lRky(>~U`Ss{Qf6p@giw(SKlV$uj zX`eJN-L`k*&C(6)^kn^t)}%gJt@2%^(&%K!>l&q#cRlS^C|S%%iJzp+W*IT_$xoHp zli#f9n&Dkk{c^f`<=UqY7OSkDY%}r6ZWVLYou(&ey<3rC&}&{O|4t+BS;3Ys#T9$c zJrwtObo|wm*wW<9>gdAu-~vz;~!J2PeHYW@xe>L^vvMt>gTe~DWM4fOTC}S delta 591 zcmV-V0KkBYy%eNkl+1gNZ)9eZ;65CJmf*LzcMn=)&!|OXJzTJ=9>ONr=zpIJorC(ke?-x=6P`dj z%z-mu($g>-mcjt^Z+Lq?wV zMVqGwHNkrL=5)Xq=z*^m*E{vD)11-JaoiO$g|8zhY2&nHx)+z+|L(PcVfuE%<7k|K zyH%WtwTdr^M*77%xqBzdd zg=x=gBZ;1sFsO&+wNjRDs z-D`yrr5=;qd$oqgw=dCr_O&zw2G-Vex8*^Pf;mK6?^`xMDFU=Dy&?$jn^j#K`(;?b=X( z$Bz9*)v~DEr-dEcM?1OBJG9ywCHx}M<5$SrCHREm=NdpA&T3GBAS6|lYJnD6fxSQ^ zJ8shJmU^OjWRd||CgB*S`Fb1ptkhTazq+O#Xw?|`;4_Q%T*^SjHMV} zhZp4eGro`?4M@+!=aAdmc2x{d2F|mJ5uq?EE|;hj_Jg-av!IH9bl+_qb>PV5j*3nH>r)C2J^lUWFpW6?=v$B zbDuMIj#Zi`GTB+ojAN_Ad0PnPLa9~IxGhtRknQT1d|its!G|s0uUGw-&E@ZX#)BNepa5|}lZS3FZ`$M&SfkUyPeMz&-Rn9#rc}Ii9GPXuy-%EV<7)ubDu2|3OdRh^L@k$F~8E3;# zz^1*7MoemWlHnABzz}r_Y{mICjumpNFRvLh_FW_ISvG@NzE=+L!gx|72;sYydSnJ$ z1lJ&m9VP$Zwy)R3(F-*ZB!Pt+2cugmeAJnouyGm_3AYoQeSB7@F?2k+B2-(~Q7eb% z1JZ`46P)Sdt9lV8jYaH4=1=(vb;rkdy`BMPW$K*4ZBfm(q}*>MMc!Y4aU-~~Jg|;F z)}ctu>Rqw2r?S}V7LZ@k1(B!|9B|KAs zwmknpwDUmbK#`S_THdGdU#8#E>zk=hEymTfwgR6I2wv39DD=ZFGG3nC4(+)Z4cnrY zl$aca`P|&?^U<7=%^~yu22FJ>-WwsGS?j^E7NSO|b51zFNC@l)E0x;*QKj($$CtZ& z%Tv)uJjVDf@3qbC7Pby}9iMF-=E-u=yE}S$IW|7<5{sx})?)L-Nz0=tx9^s&QHNpX z0;$Xh(P!s>@HL+p&yqy+z+PoZp+cO)Vis$yZxhs6opVHa%Lb>+6b{0ehTJ>fG?qll zemkhz=NN1Yl@SV)zy?%BWKHk8^()wNcU{VbV%wE;46EI1Q~WG-n2F?f>H5h}OxPq z?#j<%2E^UAPlHPy{Ko^MV=1#69(;~+Y=stTs|(7-`o$iU<;pMT;)V9}$>xc1#>$*E};`_3z#B8HNPnYAq85(%70 zugHe5TQxGH@l*C4X6>R~$W8~HrwDEVH>~0tW->l}{cc9IS7gyr6(hrA_vCtrrEiQs zbS8sol%CdmEn`R)2iq6%Zq6oF6g|h+|DIeV-?N)&7LBS|U!QY2%j1R;5@oRG*rd#Jl!e1Ym?22te=_y4R+$N!DMeLd8 z@<4p3d>THGsYwl}6jb6$AK9c{HnCOR3t80Qf}_eeZT8i97J=L<>q`+2e21%$`{vZ) fuWkJn&$zF;9lKAR<>T)Qe;EM89mh7ik@EilAxtj0 delta 1380 zcmV-q1)KWL4)zL=BYy=wNkl(XOP4PHY0T_8Xe)FLG=E%u1(^}hQs@h4iTb+~ zT)-UYFDrNtIze+8dMT6n6Pl&DT>?5j6xtCkCi4e$7c@F#N2fA+$~Vx#vF(n74)=sM z$%QDqxrd>Lpl`)f-h@VIKOF;|-5)xC06G>L2b~SQ5i+q284H z37V{#wm<{ZpqHV0pqbDfseBT&J#|DDY266?Wg&a1`VsicLzaCUk;?h5on?M4FG5qa zZUYDmwZ>_V{0O}d{VwGvYW)cOi6J{){RsS7AxroqIDbj-Z{#hjc!8yK36M~w7!DOhtMW1SImd4@+Gv!d^pF& za=q5?1ICdd#9!`|;BooC4EjD~&I#2^LFQd(qn0bkTo~GAAM9)Q;GYidOg?XBLa5#! zx;6#KLVxuM%8xU@IkH@R1)0yFEn2RauhQf@=os}0%CAlta+UfFJe5p7y8gRY%zqv7 zwt5lsr}i84EB{wN18)fQNosR}`WbkmtazzctIxnk$$U(`i1|##S&2+lKLc-i%8)bE z&zMh>r1=cf)0gG@KsKvKGw)aKP)|YT@|0zR1AkUmV#O0B>(nRa^L(DRYgb`p9!=RD z>e0+&`%d)~WX4!AGdyTvh?ozF8DKU&vPVXmSh^o|Kx7MPl{aX%0zFqZ}gn z>3>$y%33kfihbAy^%=OYXwRmpuUO+|Ez@hh2jVs&4i~PyuAzhla*B+QIzr!>w}W zS?HkP^Jy)%VkgXnv35(n$Qn0Kz0g|d4fE-a@Fts{2in2>B6HikSRPBcj>Pi^{RLXL z6a+6e-vX5J3p7XT{9DlGJo8_;4336*TBj+fFxh;5k8m2Bq1nL#ZJ%ocZ-VdT(SMq4 z2~<1)`aCq&-mmZvuwt`FxXIgBGi`weo=AxUiIrho%5iXT3!e0tPcGyQ@9RzTzQy4uG4@y>^yvGPYeSg>vO!CgJrNWG3h z!TdP6(EK=9_hedu69v~AwmC-o>nP@1>*Jv-%@0J+L0innEw>$bIzaVj;ph+n23T12m}%X?5y0l zwEHWBz})PyQfteFQh=SC69^Qo4gw{lfI!>aR>A@Z6o~+V76~8_mI?yNh37Y&Hs%h% z7aVM@xa8Vz%jTP02#4F@Nn8^C73@%4KerGh15Vb0i-J6I+S)ZJ>I%1|Mz;1MU-Tyj zVgn)qxd5RMC^Q^_h9gn#2s9RHh(#i_5eO^-;rr|{`hNsrS1uASMg4z)&aU!Zu0Z3z z8_2}4KoZ$MEc`zj12_Wdj?%-P`pf^lL_Dv@nS1uiF~wfQ3XQ`A@M;N3 z%2__xi3qwn=1Umc+v@*WfN3cpO_C;i+Kh(cgPx8tYv+yENNw{`@%?K*_xAdHlX57);sY@aU3`@#Pb1nm}RdFL_2qT11Q`cBO-L4R|ohn$2<6ZsjxqN{HpY;&7Aw zq8c%QRtxjhVqF6cvcAv{I{kru;0d+rpyo|ga^|-BILt(o#o>o~I4quEi2|y-wZkh~ zK#xoY7MRL%$N!=7+}rxL;%m_c1pDXP#L`ySHJ%TN3WUQ5h~bEt3r{p3Gr_ayxR->^ zMt@#nf2!j*bpn{JUDSaI^yd!~a2CVVf4)YOeF2W9zQ`|^_YyM}h!v$mBAiXwQPU^C ztHXpXb`r;>Vm(#aCkb@nzAkNs2O}{eTgr;6=w3#gqrY@DJ>C zJ`O9S8hn&(vqR!z{IPbelOezmGe5v#g;f_0v#B$sTU}byc9PpidB4sxFitHaM#2`L zo4UzE!~8AoDe+yudHlf3PSq@q;H5xCQId?Fp(6Frz2Fl`xuC+Ddhr1h2kgu0xR5G6 zA4MwEgs$XxP0=zo_BaBbc$tJ^lhSZ;&c9r&vOJeS%V;BR zx)s+=LT|;S2_5sYW!MABhmsE-HPK z_j&SY2(<2t5vD=GEA%%qHi2~-NoeC&>ojZ@Oimv-tH~A(FAk`4%|RLX z5zR{7XJ2C-nvt2Id5M1WDrvXBkgCIV5hW>mXR#W4a94rY_HSPK7bp023^1E3=3Oei zQsogT%q;e<5&dJ1LfSIx;9~&fP(IWZRE66 zPxX0D9*)wJQ9t#fUGcA+wLq+ zKEK!N`?RnxLAZ!3y$s0N%KU`by59zLv7l@pu~?>2_gsXYG{Y+9lL3B8yyMo36r$-< zj(m{fH!bt=Pp(Si-wp=_=aRJg@0C$Q75UqsTbZ$!2Cj{q?2^pM$uHK8i5eSnU?9R z%#r~t)mpP>?i`%5J>a__V%C9BUD}SvFP6~bBs?R{S#xr1J78x-%f>#-Ou-nxcyP+*Jp1i6Bd$+CH_``V5>sdsiD@_v9 zFT+MYoNInJGT)vq%uKZ**bXBxKcubH|I`!vRrTksw#LP08xM`Vm&x}z*n`B8^&RH? z7By7j#Dk-dSl#P=1(3Z|+4xn&>7zzy(T0&(`sTU;G}g8_L1l@Daz2Zbv*~Q{zU0m% z+0^)BkfRIXCVG4;(9cN!gW;B)!lO{pR6|ls;(R`_x9@h>3ZXaw5H<^lOZp^gJzB+HHpLk{jf7ci(tkoBtDr5QsT$!O9gl4B(<(cz1=nVB`bVq$} zHwH@Q7Gm6zl5Tkd^<#+@lv9p_%XG?PQ3Y7rEnH0H8T&PjjG*?KtOcG%tjRg=8eg`i6L;LVsTz>ueF z`%(9MrEN@Ezs_j=Z&7b+b45y&?5v~b%g0=H@PZBgqb$&=5k5R`Y(LQDuHmuxyF!J7 zgxrv65NjBGRhC&b(k5|Z!g>A&vgx$Vu_pcAK3S`m*Q&2FrleYkLvsazu8utO=xK>u zkFA{+JzwBvpt<+`#RHr4@Th0*{E>qD3ukpm{%@}A--USW*|sh>HK0siMtSi$I@P`r zJgub~_x>FL(a(gO4nd8-eD1Ss)(Z7MQ?~cS{8iS~9z8f+X3n^$;&*GB56xAT9{GSA zWF9f9$~pq%skSG4r|7Q7Z2RSMQc4~$`%>x*+d|f-n&IW9tMt`yU@eDq3 z{~4Fl)J_LCe5frJYxpYlK3N>7sTTaD#;!`;Yl~OBSrp{*u(K|@u(@Ym;L7vCIKPV) zuj+$1mmYOrUeVCg7%Ed^!ideZW3iWWdiIZ5J|*WJ8h-U-#P{_F`5ts~lKdJp=)plH z*i0+ z)^N=N1#e(k+Jh)_8|@vg?Lt1Z+sjqWe@IKYc7cJ7zr=2+;6JoOXp6d!L(L+zgQtAz z_=fu|5U}C<`JL48GukHZTc9%9PtB)}x$ZmBu)*)5z36w*z<+69r>&Xz&!K&7sw{_p z(>6@oFtMgh)|7nj-nf^X3kI{S>_)D~NXgkq1p8E4F+P_j! z2mhp)?>r?7l!+D}T&1D>EQ;@T#tJ=^@n!LDn9 z+P9!OYu<&9Ad=;8LPjiB3msRa{g(DBVpckxwq05wV#c~D+Ee1}w5~1?y(r~nbi5dc z`tMsE;(vN14~c(YjJ7@PG}^Bb_oijga%o*zp#H6jztFCuT||4<$_hT~^>u;rlH^t| z?}9!@C;lmV5Q?eP=D%B6!3kbpD|A>5_2WV=AMiT6Km>~g$9Q=c^!ZfcKP(C(-s*KW zLh%`1!;oy{b#_6Y{S*Jayu1tg?3(zGLf%V341ZjDqSIjqueS^O?2`Cz?d4t2XSc+E z6rQ6k>Gf8i=s@>G%n&EiwxDf@$jN*c<+vwh53jci!~{^#xPWbfKKmyADH`xKUVfCe zK5a|H7gE>uPk6l*DEhfM{e!lp>jZyaoAv|Ki^E*o1%0+j{6~2}rzHLbx6;;jt>Ew5 zqJO&FxxsZM5aGY3zLeJS0Fkdb-+V4}T^EQoTEQrv*u2DFXwYws#aO6$ta4=L?QS|2U!x;ChvoA~eP=PI=I(T(CsQ*gh ze}R{`fpWh24li#5`&`!DDH3LxYZqIpn7l;r+gSt?lnYHUc-Kr?(cK%0O z&2@DkX4Ycd{1ZCXUk>#vB>v)I*9<^;>F|J;=YU9#6?_jF+dQa#+M;tkhgeMCoqzb8 zZvZ_njBD{B~$`T@zR%RCx6o(Di^n+e3NDyMfvB_{#GuY&)wdR9O$=^)$^!p z1|agkDVw_{2m0M;^~?r0dOLDJHTJv3H3JYk1t~|kCI{3f(V9@tQesO9IrWkw5ufBO zsP8FF-0bbh0oC2#vs^QPg$J1UVPwog4q}h04c+*eh+T=sh?ixH(zZe*?|+(-T?LQ0 ze;0`HrwB_5Dr4RN#NyM}VmrF74eD1x{j4N(lZRo16sLS>hfmc}hpw3&K?_tf7n<(d zHwq~Du{kNuXkGNBpgHwI#|cbsLo&otszAUqWAbt%(bqofY`Nk4s~4}hmuXK)|8_3Lh(YEkAb;{f$^kh_fc*lI zS=8QM9hy-$_Byjb$VDI+2h60hK*A8nAN4o!Pf*3zf z@ zGgMO{VmH^00_*t@;tebMeXo~~0_*u=silD;|Is4nHdyJ^et)GY6lo8=n~;JyW0hp#Mz;&g(~8~pI5G;#PP^X`63cvIwO?MT1{xRKfVVfSmBq@oJw{6 zO1luT@3kr7G=J>;U{Zh5R-%0zLCI2k=<6c8c*CWx&X9ktUQ**wI~tpY^dtl})iE=4r00Q5=P z{j{5D-$HCntC#Bkb_^YJ_4M@g^z`)f^yKpzsc%sLY72yHzCRYTgCm7*$?#ysXV zOJiyuTGbMBAG4yWir0JZU3abf;huH&`JJ`*`mep#|I^-y)>g)+xDng{0N|9Vi2>$> zuKx*6@X0$%d8mB?@jE6MO8_831^^&F1^|Aaq=?@EfCoqbVA&M_KvMt!SYU4R%}XZ* zu)Ddj!3j^!zJ5#03Ah4H?1N9}`d_i>r#F1^frOY^8bX#KK$xQ9J5|csNlH4z5EtU% z7UGG%6XbaUfGSc|QyHnLj8e5hYNAovXcSTri9{oj&M&hx|Bv8qfQJu0{Qnmy{kcFo z5y<^-hY+8;p1~n*cLV=Nqos^Q*{G_a)&Fz;p>_l#_6l_Faav9Ru%MG3m9)@m3=Wi!0 z(2sRPk>Yn&U9a_i{ueQMRArtfa7_@|HxrddtZMdEj}ffq!a=AI#5)-?d4XQyMNk1y z1(^QEOoQ%1kEH|Y;)O5P?R+5hfL%J+6}1UQg2Fg(WR2JlM)pKRv*4y25)S8SrFi(e ziU%f8s&z+1zJpe|Lpb;ENpA9P0ygbf->6(<;W#*wI}HB$Y#3OZ=hw);qC|=7U$~5c zvVa5NnG4hQoEGf&Kvx*qu0w8k!Y4y~{n(X%MdQbHSrf1AnzFF--&XG7mvSZ?g`eWp zB1f1%6@pGxu5*6@-Xd=QSZ{+mK`h2%&pt*JLPq%D;x!-5-77J(p5$(ZA9_9#@Ci_6 z7Q!)uWkux)PIAP-H_rt=9zHY@SnWt3^?Q&op3o1H+Bh(a!onyql1Pz8ucnf%d;v#ae3*Mo^?gQ9Vqc7k&UD#=Z%KIU2j zyf|mp{w-pLnMH*Se=X<=dj!6=RA!C(hHcpL1?g+4MsZ<71FA~{Yb1U}b=AnT(Mp!n zo&K8&69$d}NqRYY{2*b^w(0~pckOV5K#zC=kVbRC8SIb>mz(tt)LZ+mgW9F|JpGKZ zz`u&j&nZ6|l|QkzB1M~yh^>^Sl!*ksJV&w;=q6(6g3`a-dfdrQ6Fzh5EI6uDzSHB& z)pOz3K6XdBnlplv0qwH)?PgqCZ(K+Q1~6dL4mBEXxe~l!z^C^$I6FF*J?Fu%Z_qo$ z0luqR3u0M9Jo7>pAWxFrSJ1htNJXI<^SCs+M+YCb4}TqfIQA+i@7C`c(=GmLB^Hgl zu(;}{gm(t-T0~+czF;E+_>;^$eiLV`(+Z$kR1Xp8DTs$61~+GpxK_EGl(nw=(Sum) zt&zIsE~)9U^)2rF*z;ij=Wu&PzZlvdtFa%^YcCa#JtyV({LPa!787D<=Tgk)R0~hT zWr|nPZI;u+BR~AvpUd%X5{=b=0<9qJY*5nyWQ*}ohc=w|D@(xb$f}F&0!@**s-U*w zieF={2B|rEmGng7LyNOS1Mn?5_T+I}AG}xgM2`IMac$O`up|>#@}TX7)=&mv7>4i{ zQ*-pjH{{sg{-!&`bdd+nz$g=B{P?t&a{-Dron`M#+SbaRXv2@oO3X}VO~|RW=(;*< zNx1>=p_O3DUHsf2wU-&;>(yG&$rr%i1&JIV8DLte(gc}QT9&0I?YU550KWbUQ$>w- zd60@GYOax~f$umw(5_Tvd}wHHVJj?}2$BAnJumxPcT+5n=Qbw9Hvy#YnNnNjD7~#1 zi=y{%r{$N6Gx>PfH=JJ@_I|(g#K*4x{Ok1F-s05_-a@5?RKU|cOPPemHY+5pF5KyG zgy~|t;03^B9!(Zi#x&x-c;GdJS)w z>gGpEmeU!u#dGZ_T^aKdrCUZ*Mg?BapPUP<6x~~?F2xDVxACgdj6<4XkHXV;6KX_~ z&b_Uhxl_K@hzDRGeK`{$Y85xC8#VBu@xAC?rmcr%rBGl)Utls>1Od?n{$)1!JNLtB z-GfG*Y#Weu#h+uy0&7_^<|ELa$FC3fTB&1%4F-d57PWAGu>)>f1^ES}tF)Rxe1|S# zF2oEp&`O`*@n>s4Bb`5+4ZM#6H{K>|Px34i#XvC$vzG_onY=B2_mU<-&=&wc7pDTN zte^TxeR@!iLH`MSg}MAr@5zVF7a&<9~(|8F*v2!jG41Y9LL!fJgSKg&(=W>9e2E*nTC;R{>RWq_DRw8BK%X% zqbSr^uIX9pmz`}vhNP*z#G3e-DAsce6Vc=dmOR<(@25___%X_IzT*u~7hSFLotdSS{+@>B^KExsg3}ivG4Cyt!ZX zeMK~LV~~3x1^R1fiX>gD;bdRP{_s`jtKc(3JF_W;BlsGqwt9DLz0LJQmGEgDg$3L_ zhu7G!4;xRiUsH_uRUQcNZFWtjrx89|X7@PLkMa+kC%td>0HpmFthsV4pRx(+VLnZB z9Gy}3u7TppX8)<)>QhKv@@uvmi=Uj&mC$Q|5V-%CCOWx=U1H9Lx^Z;~&tx4j_(d=t zaem`)YVK&e8C1@oBZ5^^4RM0I7rf9El73ig2>tF-M2%OkHpzAp5*t6~8bW!w=nhe` z{opGE&Q$!1hEp#~tGI~$tx}$|)L)XHNVmjR@W&KxY+w6{l`(-xk$T<6U1AZ8slU@2)ygnlmxVJr%jM zR(S0w0^+-YtEwh@efOmzkeq&*7yDeh4 zL!gRARNn7n=Ci6xxALsf49x_RJ-y`jeYZvqZIkjO(-9@`Qc@DkpucTvR-3dtK40aZAou##2fx2HlI0|5?j>F+Qxz78 zo%_G~nY=c=VK;7ss-MM0Ek!Xo2jwP(-~MHv z%Tew0Npr*Nny7;pb|y;v3@xe5CH!@0zmf#~45;2LUIKfs{peelWDegmO19X2rtF>4 z>)B4Trhj(K_1gF9htZ5o(xX|9rHBZn6x&^^R|F9iXSRQ)}>&F?kQfZ*``@sRz%L6O-TH%@t4yhR!vR_7hXZB?0cm9Cj*<=(6eRDn?5AM zw&4TAR8ySr!M*N&GR&=|Qeqa(kaCgj^Vwa^2Bt`~Cmo&Yli`;(QB0hilVFlZ72S_p z(TT5cc^n@$-*-sK2qjgSZ6&R=PUaRCMK?Sf9&%#^Z;z8b;p1p2cdZ}{`*rI(cIiTZWURU zn3(}2x%;T+mN@*eCtkB2jxHGh*h~wjFxV0p?9GpMaA1i`bR{WLa+Fvs5F}H$t34Q@&R-E>PGo!jw(waBB*OHX_v^Y}OOH6H)G7^SUt{6#AOBaU3y zuiz)B4fbAh^(n*;uLlQmK6@jS?Ozfrc+t7Bza^GN9HiOKDLzu5@~k_s_RPZn;B-v% zGi6k5?0E{ttOj~s=IpE@q_Kloh4)Uns* z9|i14uI$2W%gVpKALE# z&CkN}5RW34zQ|@N7gkfuVz zMbcDNJSwmNAw9@75bCGlZ3(>=6bfrP{o`P5=fecj3UYs#zu3Rh;6oQ9GxF;7mHqU> zI+Qr~n@sMoYU(l}NG;^FKEq&D8I2PokMGZA^-pY delta 2973 zcmV;O3u5&69ikVIBYz8NNkloylolB(MaHcBVOUo8hKfR?5L&lPu}qmFB*`jdp2x56xBb33@Ao{#kJr2JB1um)(>hzoQ&O zxu4tBMYfb<2fkALowB>ztrW1~n^S&iHzdX9C|kRI6|~uEU|WT{qmA5tLBj^GPWimu zh;jS{(N(SPzJDC)P)-@x)aBEZRo&Nug^hiy-+`LkOxf4{4dJnU20Cpx)BP<-*wBMe z9t@ju1LgUY?FX`yvZAQag8xvS;@7*NU<0?L{MH&im-sd1VSe2U0yb=I%Ga&IL$n4s z2Qe$!f${{(@rb8-lD9>+>fev1>`K{%ascJ+l;0Heihq2GvW8#(yoZf?zqb@|gWg72 z$MuKs*b>T*M$Kw*E9FVv3fgQ;`GffqKc;Nx`tt%__!`Jh4G(qwx**?g zi&I>`F37JlESfRVf$!zYQb&9uqW(w%Z}b4@Z92bN#x| z_R;RJzz0wflj8C95%=azl(W#)n}c|geRZiI)_--Bmrx#V`nr(IMzlw{x~Sg9lvLpQjdxMk-d`H3#{*+CV?I6nw7m z<87dy7Z-eX#dpzWETGYm?x`aV^K-QUvDzJSu^(>(s>i#wG>@{<=*#YAM$QL-Uh_4l(QdXHm(x+|M-@8tjPt z2>&(8R&FB@X-2IT#$}_ zT@vJH1K&W6AQyGErhL_Ghg|9UZNTW1v8y*g4_6BPeH+(z5JAK5neCAJZ$8pa6n~uB z(sgqnrm%{|J6+9KK3VX2i63tYB442=)wzbUw(IAh?oLQ|N7uP-5-M)tmIKak-708v zf0S3z;!&>K6pSW!Zt&wxp^yCv{)XH{S<7{+pv`;T5n}A9j?z-;FtL(0qNYusjgQ7qtm+~C%Ik|5M5ERfx2P4x?Wvqdw9Xu zkajE1Maj4R2bd&;u;^Z&7no0Rq3e}Edj4Pi>QsQ3Hy63ob?SnchdbA}UVj}Bp>rjO ziX7#7O`(rhm``z|>y<$I)k)6#n}7&Y|Eb_p0kl0`uL?vyAe9x<-?nxADroZ}^K&;W z?A!+Wc%Jzcx4K>lD&6T;=%{v*ewLeGg|Q#tdN~k-@2AX1kxx)oal0JU-3H}U$ZV@p6QOs zJ+4;*Q6%L|JZ~5DvmVlm6lIUH$#foD4G{*&g-41==k*v9fkaFbQ(dj5%e5YcDiEcU zPW5yd=_07TU9T4ba@*r6Um8U!ngMa=U(S50o^gNW) zheN(OdbRCA`fa_%^-56bD{h4e=fp+m>k4eCV7@H&Hbk!#&&8xZGDQ9YJYX;Y;8-pgFKDTskEwa!SPLHQk+U>FeRYyYw(gU&5jg?7>aUh=HDs-Xh27g;cUsKT6gDG!8IVziC z2e)qm?GJT_1L`ND5EXAJKIXbjKsrudL@U|aNuQ=5GIgmWQIXbr0}z!Y-*<-t>KBe7 z7EJfJ2SlHGpzGIxc8@`N5hm2#EJcGbLR)kWL?S(xw^mkc7q_be?OyH92B=HQj$KKR zF8>qS+dEPZT7Lzi{5!Ss6v{I<-UmD>h}5BCQ7Lr8HgTJAAa?SkQkvp$*RKTeI_c)5 z(1q8^79m!jshEq<=L6~uOj7`{=|hFJDOC7X>%ltFEq0v7KzZW~M-7!*XMjZtBaBc}Ng#)PKH$Ts@R}i)@Um7KpHF?gT>9 zN;Tj_#J3$@OSu{`n@J&G$M|_>6U4aiN~EXXLJynvJ7~AQiqT$$h!~_Et|4lFc9UQu z=;$bkCZ*Wfkog$M+;Mcq~> zQG*h6ynj2=`GP7`WxbvIo2wu;b?bp?ie^tcxxY?9WRh|-hZNdL@(A}c*Fo&q_fnN{ zs6clE_cIj`A41YVG;VMfKSRSc!(0MUaGP3?7E!mX#D2S}<|Oc>&qDr3&|b0a-DbYQ zHtd1?z^s|h61SOA5D`m9^ZWx`cfP{5ywn>w;D2n_opBIvfpd}T&1Vo@t_pRX*?-7T zu6Przf?0DJ#2Udl!S!Ywls95%(3<%HY04mGf6l~Z@e{!dc(Pv+xy|(!0&Le>D5p3l zDl(oJq(2L(Y1z82I}H%&(;1n2BmXUlD_nO*K!jj(9P9dBgLwP2ZAzt-Q=jf4|1}l8 z1%FR;{i%Y89;WifL$r)`Wk0;yTuONZy zbT)O{EGe@=A%-A_b~e=gJIywB~Y z2I|#!4$+NKIpW0Mjx*fn)IcP&eF4@4^{M(X6h+((;jc7^AquMW z8L+Ba9!ED&n+-Kql|HYA2rZ~mV=*Go_t+Ip;NAeWuc)TPSZ|p z9BKXW!Kr}wl$eTPQ~S*3J4(gsOE7akwTh^<(e@zq^OGdFXB&+R+Jo>^G%YQhD3NS$p$vyH~I4=Km=YFc|X<>{EAXzIKGvAaZHqt%U3siS@v?KZp~@y6Vam{Mv%rc!rR z>q%6VtiNdyrYWqRXx-%Hh()8q7fTR7cxk=S)6>(_)6>(_)6>(_)6>(lk{0kk2aS01 T `code.gri.mw/ardocrat/node`, checked out at `bce5a714`, working tree clean. +- `wallet/` -> `code.gri.mw/ardocrat/wallet`, branch `grim`, checked out at `c2db754` ("fix: ci"), + working tree clean. `c2db754` is exactly the tip of `origin/grim`. So the vendored wallet is + byte-for-byte an unmodified published upstream branch. Confirmed untouched. + +GRIM pins the same wallet repo differently: its superproject HEAD records gitlink +`5c54e7c` (the tip of branch `grim-staging`), while the local `grim/wallet` checkout in this +workspace sits at `8847ee5` ("build: fix deps", a local commit on an older base; the clone is +shallow, which is why `8847ee5` looks parentless there). The two wallet branches are different +lineages of the same repo: + +- `grim` branch (what Goblin pins): full history, merges `mimblewimble/master` (`a3e71a8`) and + carries extra fixes GRIM staging does not have (`full_scan_fix` trio, `840bde7` lmdb backend + migration, `ff1238c`/`b197aff` lmdb no-panic fixes). +- `grim-staging` branch (what GRIM pins): squashed shallow lineage plus the 2026-06 Tor arti work. + +## 2. Application source audit (grim/src vs goblin/src) + +40 inherited files are MODIFIED, 7 units are ADDITIVE (Goblin-only), 3 units are REMOVED +(GRIM-only). Everything checks out as intentional; risk flags are collected in section 3. + +### 2.1 Additive (Goblin-only), 7 units + +- `src/nostr/` (11 files): payment messaging subsystem. Contacts, NIP-17 DMs, NIP-44/59 + encryption, relay management, standalone identity (random or imported, never seed-derived), + NIP-05 registration, message protocol/ingest, rkv store. +- `src/nym/` (3 files): Nym mixnet transport, in-process SDK (no sidecar since Build 65/66), + SOCKS5 client, HTTP routing and WebSocket relay dial through the mixnet. +- `src/gui/views/goblin/`: the phone-first payment app surface (GoblinWalletView), the primary UI + since the Phase-0 redesign. +- `src/gui/theme.rs`: design token system (Light/Dark/Yellow themes, density scales); `colors.rs` + now maps its legacy API onto these tokens. +- `src/http/price.rs`: CoinGecko fiat/BTC rate fetch routed over the Nym mixnet, lazy cached per + currency (backs the Pairing setting). +- `locales/*.yml`: ~370 goblin-prefixed keys across 6 locales (drift-tested), plus new + uncommitted Phase-0 keys. +- `examples/avatar_ring.rs` (uncommitted Phase-0): avatar ring rendering example. + +### 2.2 Removed (GRIM-only), 3 units + +- `src/tor/` (4 files): Tor service, onion addresses, circuit management. Replaced by `src/nym`. +- `src/gui/views/settings/tor.rs`: Tor proxy/bridge settings UI. The settings screen block that + used it is gone; an integrated node control panel took its place. +- `src/gui/views/wallets/wallet/transport/`: Tor transport panel (slatepack address over onion, + QR). Replaced by the goblin payment surface and Nostr/Nym slatepack exchange. + +### 2.3 Modified inherited files, 40 files, one line each + +Core: + +- `src/lib.rs`: nostr/nym modules replace tor; BUILD number constant; rustls ring provider setup; + Nym warm-up; Goblin branding, fonts, theme wiring. +- `src/logger.rs`: drops the arti (Tor) log filter. +- `src/main.rs`: adds a Wayland app_id so the taskbar icon resolves. +- `src/gui/mod.rs`: exposes `pub mod theme`. +- `src/gui/app.rs`: Android status-bar icon heartbeat, app visibility frame mark, X11 background + fill fix for light/yellow themes, "Goblin - Build N" title. +- `src/gui/colors.rs`: refactored from hard-coded constants to theme-token lookups, same API. + +Platform: + +- `src/gui/platform/mod.rs`: new platform hooks: save_file, share_text, pick_image_file, + set_status_bar_white_icons, vibrate_error, vibrate_copy. +- `src/gui/platform/android/mod.rs`: JNI implementations of the above (SAF save, share sheet, + status-bar icon color, haptics, image picker). +- `src/gui/platform/desktop/mod.rs`: camera rework for QR scanning: enumeration off the UI + thread, native device indices (v4l gaps), YUYV/NV12 to JPEG transcoding, graceful frame errors; + plus pick_image_file. Deliberate Build 9 robustness fix, confirmed again. + +Views, shared: + +- `src/gui/views/mod.rs`: exposes `pub mod goblin`. +- `src/gui/views/views.rs`: Goblin mark instead of Grim logo, quiet "Build N" label, theme-driven + tinting. +- `src/gui/views/content.rs`: (Phase-0, uncommitted) integrated-node warning only shows when node + autostart is enabled, so external-node setups are not nagged. +- `src/gui/views/camera.rs`: "No camera found" after 5 s wait, modal_ui inlined at callers, adds a + QR decode test with the center mark. +- `src/gui/views/input/edit.rs`: additive builders (hint_text, text_color, body) plus native-IME + path; soft-keyboard suppression default changed from `is_android()` to `true` on all platforms + (post-Build-39 change, intentional: native input everywhere). + +Views, network and settings: + +- `src/gui/views/network/connections.rs`: adapts to the changed NodeConfig API + (get_api_address returns a full address, URL built as http://address). +- `src/gui/views/network/settings.rs`: drops the "listen on all interfaces" toggle, direct radio + IP selection. +- `src/gui/views/network/setup/node.rs`: uses get_api_ip_port instead of the removed combined + call. +- `src/gui/views/network/setup/p2p.rs`: P2P setup reduced to port only, per-interface binding UI + removed. +- `src/gui/views/settings/content.rs`: Tor block removed; integrated node controls (status, + enable, autorun, link to full node settings) added. +- `src/gui/views/settings/mod.rs`: drops `mod tor`. + +Views, wallets: + +- `src/gui/views/wallets/mod.rs`: visibility widened (`pub mod wallet`) so the goblin surface can + reuse wallet views; slightly broader than GRIM's pub(crate), cosmetic. +- `src/gui/views/wallets/content.rs`: transport content removed, goblin surface is the wallet + screen; (Phase-0, uncommitted) back button no longer falls through to the wallet chooser, wallet + switching goes through explicit switch/lock controls. +- `src/gui/views/wallets/creation/mnemonic.rs`: word_list_ui made pub(crate) for reuse. +- `src/gui/views/wallets/wallet/mod.rs`: drops `mod transport`. +- `src/gui/views/wallets/wallet/content.rs`: goblin surface owns the wallet screen and modal + lifecycle; GRIM's legacy_container_ui kept under `#[allow(dead_code)]` on purpose. +- `src/gui/views/wallets/wallet/request/invoice.rs`: GRIM's newer sender-slatepack-address input + (typed plus QR scan) not adopted; Goblin's request flow rides Nostr instead. +- `src/gui/views/wallets/wallet/request/send.rs`: scanner modal UI inlined here after the + camera.rs modal_ui removal. +- `src/gui/views/wallets/wallet/txs/content.rs`: SendingTor state and SendTor/FinalizeTor task + buttons removed. +- `src/gui/views/wallets/wallet/txs/tx.rs`: Tor finalization states and guards removed, slate + state read simplified, generic "address" label. + +HTTP, node, settings, wallet core: + +- `src/http/mod.rs`: registers the price module. +- `src/http/release.rs`: update checks point at Goblin releases, build-number versioning instead + of semver, goblin artifact names, platform list trimmed. +- `src/node/config.rs`: does not carry GRIM's newer IPv6/all-interfaces work (a91d901); Goblin + stays IPv4 host:port with split get_api_address/get_api_ip_port. See section 3. +- `src/node/node.rs`: Android notification wording fix ("Listening" when the integrated node is + off in external-node setups). +- `src/settings/config.rs`: adds theme, density, pairing (Off/Usd/Eur/Gbp/Jpy/Cny/Btc/Sats), + last_wallet_id; migrates the legacy fiat_preview flag; check_updates fallback flipped to false + when the key is absent (GRIM falls back to true). Intentional: no clearnet phone-home by + default. +- `src/settings/settings.rs`: TorConfig removed, working dir renamed .grim to .goblin. +- `src/wallet/config.rs`: adds get_nostr_path/get_nostr_db_path storage helpers. +- `src/wallet/connections/external.rs`: default mainnet node list reordered and extended + (api.grin.money first, then main.us-ea.st, grincoin.org, main.gri.mw, raubritter). See + section 4, open decision. +- `src/wallet/store.rs`: rkv capacity headroom (+16) so the Nostr store can coexist with the tx + store without reopen churn. +- `src/wallet/types.rs`: SendingTor action and SendTor/FinalizeTor tasks replaced by + NostrSend/Request/Resend/PayRequest/DeclineRequest/CancelOutgoing/CancelSend; adds + ManualSlatepackOutcome. No slate state machine change. +- `src/wallet/wallet.rs`: about +733 lines, additive only: nostr identity lifecycle, NostrService, + payment-message tasks, last-fee cache; GRIM's slate/tx state machine, locking, and encryption + untouched; Tor send/post paths removed. (Phase-0, uncommitted: from_unlocked_keys drops the + derivation_account arg.) The garbled duplicate comment near line 333 noted in Build 39 remains, + cosmetic. +- `Cargo.toml`: arti/tor dependency stack (9 crates) swapped for nostr-sdk, nym-sdk, + nostr-relay-pool, reqwest(socks), tokio-socks, rustls(ring); openssl vendored on Android/Linux; + grin crates come from the `node/` submodule via path deps. + +## 3. Risky or unexpected findings + +Nothing looks accidental or money-dangerous in inherited code. Items worth eyes: + +1. IPv6/multi-interface node support (upstream-newer, not adopted). GRIM added all-interface + binding, IPv6 parsing, and a listen-all toggle (node/config.rs plus the network settings UI). + Goblin is IPv4 host:port only. Not a bug, but a growing gap against upstream; decide whether + to pull it or declare it out of scope for a phone-first wallet. +2. Invoice sender-address input (upstream-newer, not adopted). GRIM's invoice request screen can + attach the requester's slatepack address (typed or QR). Goblin's request flow carries identity + over Nostr instead, so this was consciously not picked up. Revisit only if manual slatepack + invoicing should reach feature parity with GRIM. +3. check_updates fallback flip (true in GRIM, false in Goblin when the config key is missing). + Intentional privacy default, but the Default struct still writes Some(true) on first run, so + the flipped fallback only matters for configs missing the key. Harmless, slightly inconsistent. +4. edit.rs soft-keyboard default now differs from GRIM on desktop too (true everywhere). This is + a deliberate post-Build-39 change for the native IME path; noted because the Build 39 audit + recorded it as byte-identical, which is no longer true. +5. Build 39 items re-confirmed: camera/MJPEG desktop rewrite is a deliberate QR fix; the X11 + background fill in app.rs is a real fix; legacy_container_ui is intentionally kept dead; + wallet.rs nostr layer is additive and does not touch GRIM's slate handling. + +## 4. Open product decision + +Default mainnet node order (`src/wallet/connections/external.rs`): Goblin ships +api.grin.money first (Build 92, health-verified), then the Goblin-run main.us-ea.st, then +grincoin.org, with GRIM's main.gri.mw demoted to fourth. Intended infra lean for the fork, still +awaiting explicit owner confirmation. This remains the single open decision from the Build 39 +audit. + +## 5. Vendored wallet audit (goblin/wallet vs GRIM's wallet pins) + +Reference points: + +- Goblin pin: `c2db754` = tip of `ardocrat/wallet` branch `grim`, clean checkout, zero local edits. +- GRIM local checkout: `8847ee5` (branch-`grim`-side lineage plus "build: fix deps"). + File-level delta to Goblin: 20 files, all explained by the lineage split, none by Goblin edits. +- GRIM recorded pin (superproject HEAD): `5c54e7c` = `grim-staging` tip. File-level delta to + Goblin: 29 files plus staging-only `.gitmodules`/`grin/` submodule and `impls/src/adapters/tor.rs`, + `impls/src/tor/arti.rs` (arti Tor client). Goblin-side extra: `impls/src/adapters/http.rs` + (staging folded it into the Tor adapter). + +Two facts drive every verdict below: + +- Goblin's app never enters the wallet's synchronous send flow: every `InitTxArgs` is built with + `..Default::default()` (send_args always None) and `receive_tx` is always called with + r_addr None (goblin/src/wallet/wallet.rs lines ~1400-1550). Slatepack exchange happens at the + app layer over Nostr/Nym, and slatepack files are written by the app + (create_slatepack_message), not by the wallet API. So every grim-staging hunk that lives inside + the `try_slatepack_sync_workflow` call sites is unreachable code for Goblin. +- Goblin's wallet lineage already has `update_tx_slate_state` wired inside + `libwallet/src/api_impl/foreign.rs` (lines ~131, 170, 230) with hard `?` propagation on the + actual receive/finalize money path. Staging only calls it best-effort (`let _ =`) at the API + layer after Tor sends. + +### 5.1 Commit-by-commit verdicts (plan INCLUDE list vs grim-staging@5c54e7c) + +| Commit | Subject | In Goblin wallet? | Verdict | +| --- | --- | --- | --- | +| `129ad2f` | save last-scanned block, wallet scanning (#748) | Yes, git ancestor | Already present; Goblin also carries the stronger `full_scan_fix` trio (2880-block window, last-block hash) staging lacks | +| `06ab92a` | lmdb update (#755) | Yes, git ancestor | Already present; plus `840bde7` backend migration and `ff1238c`/`b197aff` lmdb no-panic fixes on top | +| `9570ed4`/`e9e75c5` | openssl 0.10.80 (#752) | Yes (`9570ed4` ancestor) | Already present; Cargo.lock confirms openssl 0.10.80. `e9e75c5` is the same change on the shallow lineage | +| `602d79e`/`8401963` | rust edition 2021 (#749) | Yes (`602d79e` + `da3f60b` ancestors) | Already present; all member crates say edition 2021 | +| `d4867d5` | remove panics (slatepack/slate parsing) | No | PORT. `git apply --check` passes clean against `c2db754`. Turns unwrap/panic into typed errors in slatepack armor/types and slate_versions ser/v4_bin, adds a malformed-plaintext decrypt test. Directly protects Goblin: slatepacks arrive from untrusted Nostr peers | +| `1825e66` | lock on process-invoice while updating slate state | No | Hunk 1 (scope the wallet mutex tightly around owner::process_invoice_tx) is inert today because send_args is always None, but it is cheap future-proofing for a path Goblin does call (pay(), wallet.rs:1503). Hunk 2 patches an update_tx_slate_state block that only exists in the staging lineage. Port hunk 1 (optional), skip hunk 2 | +| `f92a2d6` | slatepack concrete error on sync workflow, send-requirement detection | Partially, effectively | The money substance (update_tx_slate_state with real error propagation) already exists in Goblin's libwallet, stronger than staging's. The rest changes try_slatepack_sync_workflow's signature/behavior (Tor send ergonomics), TorConfig::skip_send, and CLI command flows Goblin never runs. Skip | +| `5c20635`/`86bae1c` | version bumps to 5.4.1 | No | Metadata only; Goblin wallet is 5.4.0-alpha.1. Bumping would dirty a clean submodule for zero behavior. Skip | +| `ca5686a` | node submodules (#758), grin submodule build wiring | No, solved differently | Staging vendors grin node crates as a `grin/` submodule inside the wallet repo. Goblin's wallet branch already wires grin crates via `../../node/*` path deps to the goblin/node submodule. Adopting ca5686a would move the build base, explicitly off-limits. Skip | + +### 5.2 MIXED commits, hunk classification + +`3f89cbc` (api: output slatepack file after tor finalization for invoice, update slate state after +tor finalization on receive): + +- Money hunks: `api/src/owner.rs` removal of the double state update in init_send_tx (fixes a bug + f92a2d6 introduced in the staging lineage; the block does not exist in Goblin's lineage, N/A); + `api/src/owner.rs` output_slatepack_file() helper plus its call after the invoice sync send + (Goblin writes slatepack files at the app layer, dead path); `api/src/foreign.rs` slate state + update after the receive-side sync send (Goblin's libwallet receive_tx already updates state + internally, dead path). +- Tor hunk: `impls/src/tor/arti.rs` cosmetic cleanup, staging-only file. Skip. +- Net: nothing to port. + +`2292cb3` (api: log errors on update tx slate state and slatepack file output after tor sync +flow): + +- Money-adjacent observability only: converts two `let _ =` calls into match + error! logging, in + `api/src/foreign.rs` and `api/src/owner.rs`. Both call sites are the Tor sync flow and do not + exist in Goblin's lineage. Port only if the 3f89cbc-equivalent code ever lands. Skip. + +Plan EXCLUDE list (`411bcff` arti client, `4587eb9` global Tor state, `1806098` tor send check, +`5c54e7c`/`8696288` pay_tor_result merges, all of `impls/src/tor/` arti work): confirmed skipped, +nothing from these is present in or needed by Goblin. Note Goblin's wallet still contains +upstream grin-wallet's old process-based `impls/src/tor/` module; it is unused by the app +(Owner::new is constructed with tor_config None) and harmless. + +### 5.3 Port list + +Mechanics: commit inside the wallet submodule on a Goblin-owned branch (or fork remote) on top of +`c2db754`, then bump the `wallet` gitlink in the goblin superproject. Do NOT rebase the submodule +onto grim-staging and do not advance grim's own submodule pointer. + +- [x] 1. DONE 2026-07-01: cherry-picked as `906dc55` on new local branch `goblin-money` (base `c2db754`), unpushed. Libwallet slatepack tests pass incl. `slatepack_decrypt_rejects_malformed_plaintexts`; goblin lib tests (44) pass against the patched submodule. Original item: Cherry-pick `d4867d5` "Removing some panics" onto goblin/wallet `c2db754`. Applies clean + (verified with `git apply --check`). Files: + - `wallet/libwallet/src/slate_versions/ser.rs` + - `wallet/libwallet/src/slate_versions/v4_bin.rs` + - `wallet/libwallet/src/slatepack/armor.rs` + - `wallet/libwallet/src/slatepack/types.rs` + Then run the libwallet tests, including the new + `slatepack_decrypt_rejects_malformed_plaintexts`. +- [x] 2. SKIPPED 2026-07-01 (ponytail): dead path in Goblin (send_args always None, no Tor sync workflow), mutex releases at fn end; revisit only if a slatepack sync workflow is ever adopted. Original item: (Optional hardening) Port hunk 1 of `1825e66` to `wallet/api/src/owner.rs` + process_invoice_tx: wrap the `wallet_inst.lock()` / `lc_provider` / `owner::process_invoice_tx` + sequence in an inner block so the wallet mutex drops before the send_args branch. About 6 + lines, adapt to Goblin's 6-arg try_slatepack_sync_workflow context. Skip hunk 2 (targets + staging-only code). +- [x] 3. Confirmed skipped, already present: `129ad2f`, `06ab92a`, `9570ed4`/`e9e75c5`, `602d79e`/`8401963`. +- [x] 4. Confirmed skipped, Tor-only, dead-path, or build-base: `f92a2d6` (except its update_tx_slate_state + substance, already present), `3f89cbc`, `2292cb3`, `1806098`, `411bcff`, `4587eb9`, + `5c20635`/`86bae1c` (version metadata), `ca5686a` (grin submodule wiring, superseded by + Goblin's node/ path deps), `5c54e7c`/`8696288` (merges). +- [ ] 5. PENDING at commit time (gitlink bump deferred per commit discipline; submodule working tree carries the patch now). After the gitlink bump: `cargo build`, `cargo clippy -- -D warnings`, wallet unit tests, + and a slatepack round-trip between two Goblin identities (malformed-slatepack input now errors + instead of panicking). + +## 6. Summary counts + +- Additive: 7 units (nostr 11 files, nym 3 files, views/goblin, theme.rs, http/price.rs, locale + keys, Phase-0 example). +- Modified inherited files: 40 (all intentional; zero unexplained drift). +- Removed: 3 units (src/tor 4 files, settings/tor.rs, wallet transport panel). +- Vendored crates: untouched. wallet = ardocrat/wallet@grim `c2db754` exactly, node = + ardocrat/node `bce5a714`, both clean. +- Wallet port work: 1 required cherry-pick (`d4867d5`), 1 optional adapted hunk (`1825e66` hunk + 1), everything else already present or correctly excluded. diff --git a/examples/avatar_ring.rs b/examples/avatar_ring.rs new file mode 100644 index 0000000..7b139a0 --- /dev/null +++ b/examples/avatar_ring.rs @@ -0,0 +1,128 @@ +//! G1 sizing-checkpoint harness: renders the REAL `avatar_tex` (custom-image +//! avatar + username conic ring) and `gradient_avatar` across every size the +//! app uses, so the ring thickness/inset can be dialed in by eye. +//! Run: `cargo run --example avatar_ring` (screenshots taken externally). + +use eframe::egui; +use grim::gui::views::goblin::widgets as w; + +const SIZES: [f32; 6] = [28.0, 40.0, 48.0, 56.0, 72.0, 96.0]; +const NAMES: [&str; 3] = ["alice", "bob", "carmen"]; + +struct App { + tex: Vec, +} + +/// A synthetic "profile photo": diagonal two-tone blend with a light disc, so +/// the ring is judged against something photo-like rather than a flat fill. +fn photo(ctx: &egui::Context, name: &str, a: [u8; 3], b: [u8; 3]) -> egui::TextureHandle { + const N: usize = 128; + let mut px = Vec::with_capacity(N * N); + for y in 0..N { + for x in 0..N { + let t = (x + y) as f32 / (2 * N) as f32; + let mut r = a[0] as f32 * (1.0 - t) + b[0] as f32 * t; + let mut g = a[1] as f32 * (1.0 - t) + b[1] as f32 * t; + let mut bl = a[2] as f32 * (1.0 - t) + b[2] as f32 * t; + let dx = x as f32 - 44.0; + let dy = y as f32 - 40.0; + if (dx * dx + dy * dy).sqrt() < 26.0 { + r = (r + 90.0).min(255.0); + g = (g + 90.0).min(255.0); + bl = (bl + 90.0).min(255.0); + } + px.push(egui::Color32::from_rgb(r as u8, g as u8, bl as u8)); + } + } + let img = egui::ColorImage { + size: [N, N], + source_size: egui::Vec2::splat(N as f32), + pixels: px, + }; + ctx.load_texture(name.to_string(), img, Default::default()) +} + +impl App { + fn new(cc: &eframe::CreationContext) -> Self { + egui_extras::install_image_loaders(&cc.egui_ctx); + let tex = vec![ + photo(&cc.egui_ctx, "alice", [180, 120, 90], [90, 60, 120]), + photo(&cc.egui_ctx, "bob", [70, 110, 160], [40, 160, 120]), + photo(&cc.egui_ctx, "carmen", [160, 70, 90], [220, 170, 80]), + ]; + Self { tex } + } +} + +impl eframe::App for App { + fn update(&mut self, ctx: &egui::Context, _f: &mut eframe::Frame) { + egui::CentralPanel::default() + .frame(egui::Frame::default().fill(egui::Color32::from_rgb(0xFA, 0xFA, 0xF7))) + .show(ctx, |ui| { + ui.add_space(10.0); + ui.heading( + "G1 avatar ring — sizing sheet (thickness = max(1, size*0.06), gap = max(1, size*0.03))", + ); + ui.add_space(12.0); + for (i, name) in NAMES.iter().enumerate() { + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label(format!("{name:>7}")); + for size in SIZES { + ui.add_space(14.0); + w::avatar_tex(ui, &self.tex[i], name, size); + } + }); + ui.add_space(14.0); + } + ui.separator(); + ui.label("anonymous npub (grinmark gradient, ring-less):"); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label(" "); + for (i, size) in SIZES.iter().enumerate() { + ui.add_space(14.0); + w::gradient_avatar(ui, &format!("{i}deadbeef{i}"), *size); + } + }); + ui.add_space(14.0); + ui.label("named account (SAME gradient, unchanged) + username ring:"); + ui.add_space(8.0); + for name in NAMES { + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label(format!("{name:>7}")); + for size in SIZES { + ui.add_space(14.0); + w::gradient_avatar_ringed(ui, "deadbeefcafe", name, size); + } + }); + ui.add_space(6.0); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.add_space(12.0); + ui.label("sizes: "); + for size in SIZES { + ui.add_space(14.0); + ui.allocate_ui(egui::Vec2::new(size, 16.0), |ui| { + ui.centered_and_justified(|ui| ui.small(format!("{size}"))); + }); + } + }); + }); + } +} + +fn main() -> eframe::Result { + let opts = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([900.0, 640.0]), + ..Default::default() + }; + eframe::run_native( + "avatar-ring", + opts, + Box::new(|cc| Ok(Box::new(App::new(cc)))), + ) +} diff --git a/locales/de.yml b/locales/de.yml index 2e25076..a3934e4 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -365,6 +365,8 @@ goblin: cant_reach_node: "Node nicht erreichbar" node_synced: "Node synchronisiert" syncing: "Synchronisiere…" + balance_updating: "Guthaben wird aktualisiert…" + listening: "Wartet auf Zahlungen" block: "Block %{height}" waiting_for_chain: "Warte auf Chain…" nav_wallet: "Wallet" @@ -434,12 +436,14 @@ goblin: requesting: "Fordere %{amt}%{tsu} an — teilen, um bezahlt zu werden" clear_request: "Anfrage löschen" share_handle: "Teile deinen Handle, um bezahlt zu werden" + share_npub: "Teile deinen npub, um bezahlt zu werden" copied: "Kopiert" copy_nostr_id: "nostr-ID kopieren" copy_address: "Adresse kopieren" copy_npub: "npub kopieren" share_message: "Bezahl mich auf Goblin (goblin.st) — %{npub}" privacy_note: "Dein Benutzername ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt." + privacy_note_npub: "Dein npub ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt." profile: title: "Profil" activity: "Aktivität" @@ -460,7 +464,10 @@ goblin: wallet: "Wallet" display_unit: "Anzeigeeinheit" relays: "Relays" + nostr_relays: "Nostr-Relays" node: "Node" + integrated_node: "Einstellungen des integrierten Nodes" + node_advanced: "Erweitert" slatepacks: "Slatepacks" slatepacks_value: "Manuelle Transaktion" lock_wallet: "Wallet sperren" @@ -470,7 +477,7 @@ goblin: mixnet_routing: "Mixnet-Routing" messages_lookups: "Nachrichten & Abfragen" auto_accept: "Automatisch annehmen" - pairing: "Kopplung" + pairing: "Preiswährung" accept_anyone: "Jeder" accept_contacts: "Nur Kontakte" accept_ask: "Immer fragen" @@ -485,6 +492,7 @@ goblin: archive: "Archiv" export_archive: "Archiv exportieren" wipe_history: "Zahlungsverlauf löschen" + wipe_history_confirm: "Zum Löschen erneut tippen — kann nicht rückgängig gemacht werden" about: "Über" goblin: "Goblin" build: "Build %{build}" @@ -561,7 +569,7 @@ goblin: keep_it: "Behalten" release_it: "Freigeben" username: "Benutzername" - username_note: "Wird als you angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt." + username_note: "Wird als dein Name angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt." release_username: "Benutzername freigeben" pick_username: "Benutzernamen wählen — optional" working: "Arbeite…" diff --git a/locales/en.yml b/locales/en.yml index aedc8d7..e43a9ec 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -365,6 +365,8 @@ goblin: cant_reach_node: "Can't reach node" node_synced: "Node synced" syncing: "Syncing…" + balance_updating: "Balance updating…" + listening: "Listening for payments" block: "Block %{height}" waiting_for_chain: "Waiting for chain…" nav_wallet: "Wallet" @@ -434,12 +436,14 @@ goblin: requesting: "Requesting %{amt}%{tsu} — share to get paid" clear_request: "Clear request" share_handle: "Share your handle to get paid" + share_npub: "Share your npub to get paid" copied: "Copied" copy_nostr_id: "Copy nostr ID" copy_address: "Copy address" copy_npub: "Copy npub" share_message: "Pay me on Goblin (goblin.st) — %{npub}" privacy_note: "Your username is public. Payment contents stay encrypted over the network." + privacy_note_npub: "Your npub is public. Payment contents stay encrypted over the network." profile: title: "Profile" activity: "Activity" @@ -460,7 +464,10 @@ goblin: wallet: "Wallet" display_unit: "Display unit" relays: "Relays" + nostr_relays: "Nostr Relays" node: "Node" + integrated_node: "Integrated node settings" + node_advanced: "Advanced" slatepacks: "Slatepacks" slatepacks_value: "Manual transaction" lock_wallet: "Lock wallet" @@ -470,7 +477,7 @@ goblin: mixnet_routing: "Mixnet routing" messages_lookups: "Messages & lookups" auto_accept: "Auto-accept" - pairing: "Pairing" + pairing: "Price currency" accept_anyone: "Anyone" accept_contacts: "Contacts only" accept_ask: "Always ask" @@ -485,6 +492,7 @@ goblin: archive: "Archive" export_archive: "Export archive" wipe_history: "Wipe payment history" + wipe_history_confirm: "Tap again to wipe — this can't be undone" about: "About" goblin: "Goblin" build: "Build %{build}" @@ -561,7 +569,7 @@ goblin: keep_it: "Keep it" release_it: "Release it" username: "Username" - username_note: "Shown as you. Public on goblin.st. Payments stay encrypted." + username_note: "Shown as your name. Public on goblin.st. Payments stay encrypted." release_username: "Release username" pick_username: "Pick a username — optional" working: "Working…" diff --git a/locales/fr.yml b/locales/fr.yml index 37bc554..0dd7d61 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -365,6 +365,8 @@ goblin: cant_reach_node: "Nœud injoignable" node_synced: "Nœud synchronisé" syncing: "Synchronisation…" + balance_updating: "Solde en cours de mise à jour…" + listening: "En attente de paiements" block: "Bloc %{height}" waiting_for_chain: "En attente de la chaîne…" nav_wallet: "Portefeuille" @@ -434,12 +436,14 @@ goblin: requesting: "Demande de %{amt}%{tsu} — partagez pour être payé" clear_request: "Effacer la demande" share_handle: "Partagez votre identifiant pour être payé" + share_npub: "Partagez votre npub pour être payé" copied: "Copié" copy_nostr_id: "Copier l'ID nostr" copy_address: "Copier l'adresse" copy_npub: "Copier npub" share_message: "Payez-moi sur Goblin (goblin.st) — %{npub}" privacy_note: "Votre nom d'utilisateur est public. Le contenu des paiements reste chiffré sur le réseau." + privacy_note_npub: "Votre npub est public. Le contenu des paiements reste chiffré sur le réseau." profile: title: "Profil" activity: "Activité" @@ -460,7 +464,10 @@ goblin: wallet: "Portefeuille" display_unit: "Unité d'affichage" relays: "Relais" + nostr_relays: "Relais Nostr" node: "Nœud" + integrated_node: "Paramètres du nœud intégré" + node_advanced: "Avancé" slatepacks: "Slatepacks" slatepacks_value: "Transaction manuelle" lock_wallet: "Verrouiller le portefeuille" @@ -470,7 +477,7 @@ goblin: mixnet_routing: "Routage par mixnet" messages_lookups: "Messages et recherches" auto_accept: "Acceptation auto" - pairing: "Appairage" + pairing: "Devise des prix" accept_anyone: "Tout le monde" accept_contacts: "Contacts seulement" accept_ask: "Toujours demander" @@ -485,6 +492,7 @@ goblin: archive: "Archive" export_archive: "Exporter l'archive" wipe_history: "Effacer l'historique des paiements" + wipe_history_confirm: "Appuyez à nouveau pour effacer — action irréversible" about: "À propos" goblin: "Goblin" build: "Build %{build}" @@ -561,7 +569,7 @@ goblin: keep_it: "Le garder" release_it: "Le libérer" username: "Nom d'utilisateur" - username_note: "Affiché comme you. Public sur goblin.st. Les paiements restent chiffrés." + username_note: "Affiché comme votre nom. Public sur goblin.st. Les paiements restent chiffrés." release_username: "Libérer le nom d'utilisateur" pick_username: "Choisir un nom d'utilisateur — facultatif" working: "En cours…" diff --git a/locales/ru.yml b/locales/ru.yml index 10bbd5c..0933ebd 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -365,6 +365,8 @@ goblin: cant_reach_node: "Нет связи с узлом" node_synced: "Узел синхронизирован" syncing: "Синхронизация…" + balance_updating: "Баланс обновляется…" + listening: "Ожидание платежей" block: "Блок %{height}" waiting_for_chain: "Ожидание цепочки…" nav_wallet: "Кошелёк" @@ -434,12 +436,14 @@ goblin: requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату" clear_request: "Очистить запрос" share_handle: "Поделитесь именем, чтобы получить оплату" + share_npub: "Поделитесь своим npub, чтобы получить оплату" copied: "Скопировано" copy_nostr_id: "Копировать nostr ID" copy_address: "Копировать адрес" copy_npub: "Копировать npub" share_message: "Заплатите мне в Goblin (goblin.st) — %{npub}" privacy_note: "Ваше имя публично. Содержимое платежей остаётся зашифрованным в сети." + privacy_note_npub: "Ваш npub публичен. Содержимое платежей остаётся зашифрованным в сети." profile: title: "Профиль" activity: "Действия" @@ -460,7 +464,10 @@ goblin: wallet: "Кошелёк" display_unit: "Единица отображения" relays: "Реле" + nostr_relays: "Реле Nostr" node: "Узел" + integrated_node: "Настройки встроенного узла" + node_advanced: "Дополнительно" slatepacks: "Slatepacks" slatepacks_value: "Ручная транзакция" lock_wallet: "Заблокировать кошелёк" @@ -470,7 +477,7 @@ goblin: mixnet_routing: "Маршрутизация через mixnet" messages_lookups: "Сообщения и поиск" auto_accept: "Автоприём" - pairing: "Привязка" + pairing: "Валюта цены" accept_anyone: "Любой" accept_contacts: "Только контакты" accept_ask: "Всегда спрашивать" @@ -485,6 +492,7 @@ goblin: archive: "Архив" export_archive: "Экспорт архива" wipe_history: "Стереть историю платежей" + wipe_history_confirm: "Нажмите ещё раз, чтобы стереть — это нельзя отменить" about: "О приложении" goblin: "Goblin" build: "Сборка %{build}" @@ -561,7 +569,7 @@ goblin: keep_it: "Оставить" release_it: "Освободить" username: "Имя пользователя" - username_note: "Показывается как you. Публично на goblin.st. Платежи остаются зашифрованными." + username_note: "Отображается как ваше имя. Публично на goblin.st. Платежи остаются зашифрованными." release_username: "Освободить имя" pick_username: "Выберите имя — необязательно" working: "Обработка…" diff --git a/locales/tr.yml b/locales/tr.yml index bcd0b90..c558b10 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -365,6 +365,8 @@ goblin: cant_reach_node: "Düğüme ulaşılamıyor" node_synced: "Düğüm eşitlendi" syncing: "Eşitleniyor…" + balance_updating: "Bakiye güncelleniyor…" + listening: "Ödemeler bekleniyor" block: "Blok %{height}" waiting_for_chain: "Zincir bekleniyor…" nav_wallet: "Cüzdan" @@ -434,12 +436,14 @@ goblin: requesting: "%{amt}%{tsu} isteniyor — ödeme almak için paylaş" clear_request: "İsteği temizle" share_handle: "Ödeme almak için kullanıcı adını paylaş" + share_npub: "Ödeme almak için npub'ını paylaş" copied: "Kopyalandı" copy_nostr_id: "nostr kimliğini kopyala" copy_address: "Adresi kopyala" copy_npub: "npub kopyala" share_message: "Goblin'de bana öde (goblin.st) — %{npub}" privacy_note: "Kullanıcı adın herkese açıktır. Ödeme içeriği ağ üzerinde şifreli kalır." + privacy_note_npub: "npub'ın herkese açıktır. Ödeme içeriği ağ üzerinde şifreli kalır." profile: title: "Profil" activity: "Etkinlik" @@ -460,7 +464,10 @@ goblin: wallet: "Cüzdan" display_unit: "Görüntüleme birimi" relays: "Relaylar" + nostr_relays: "Nostr Relayları" node: "Düğüm" + integrated_node: "Tümleşik düğüm ayarları" + node_advanced: "Gelişmiş" slatepacks: "Slatepackler" slatepacks_value: "Manuel işlem" lock_wallet: "Cüzdanı kilitle" @@ -470,7 +477,7 @@ goblin: mixnet_routing: "Mixnet yönlendirme" messages_lookups: "Mesajlar ve aramalar" auto_accept: "Otomatik kabul" - pairing: "Eşleştirme" + pairing: "Fiyat para birimi" accept_anyone: "Herkes" accept_contacts: "Yalnızca kişiler" accept_ask: "Her zaman sor" @@ -485,6 +492,7 @@ goblin: archive: "Arşiv" export_archive: "Arşivi dışa aktar" wipe_history: "Ödeme geçmişini sil" + wipe_history_confirm: "Silmek için tekrar dokun — geri alınamaz" about: "Hakkında" goblin: "Goblin" build: "Sürüm %{build}" @@ -561,7 +569,7 @@ goblin: keep_it: "Vazgeç" release_it: "Bırak" username: "Kullanıcı adı" - username_note: "you olarak gösterilir. goblin.st'de herkese açık. Ödemeler şifreli kalır." + username_note: "Adınız olarak gösterilir. goblin.st'de herkese açık. Ödemeler şifreli kalır." release_username: "Kullanıcı adını bırak" pick_username: "Bir kullanıcı adı seç — isteğe bağlı" working: "Çalışıyor…" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 0a000b7..a0cb526 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -365,6 +365,8 @@ goblin: cant_reach_node: "无法连接节点" node_synced: "节点已同步" syncing: "同步中…" + balance_updating: "余额更新中…" + listening: "正在监听付款" block: "区块 %{height}" waiting_for_chain: "等待链数据…" nav_wallet: "钱包" @@ -434,12 +436,14 @@ goblin: requesting: "正在请求 %{amt}%{tsu} — 分享以收款" clear_request: "清除请求" share_handle: "分享你的用户名以收款" + share_npub: "分享你的 npub 以收款" copied: "已复制" copy_nostr_id: "复制 nostr ID" copy_address: "复制地址" copy_npub: "复制 npub" share_message: "在 Goblin 上向我付款 (goblin.st) — %{npub}" privacy_note: "你的用户名是公开的。付款内容在网络中保持加密。" + privacy_note_npub: "你的 npub 是公开的。付款内容在网络中保持加密。" profile: title: "资料" activity: "动态" @@ -460,7 +464,10 @@ goblin: wallet: "钱包" display_unit: "显示单位" relays: "中继" + nostr_relays: "Nostr 中继" node: "节点" + integrated_node: "集成节点设置" + node_advanced: "高级" slatepacks: "Slatepack" slatepacks_value: "手动交易" lock_wallet: "锁定钱包" @@ -470,7 +477,7 @@ goblin: mixnet_routing: "mixnet 路由" messages_lookups: "消息和查询" auto_accept: "自动接受" - pairing: "配对" + pairing: "价格货币" accept_anyone: "任何人" accept_contacts: "仅联系人" accept_ask: "每次询问" @@ -485,6 +492,7 @@ goblin: archive: "存档" export_archive: "导出存档" wipe_history: "清除付款记录" + wipe_history_confirm: "再次点按以清除 — 无法撤销" about: "关于" goblin: "Goblin" build: "构建 %{build}" @@ -561,7 +569,7 @@ goblin: keep_it: "保留" release_it: "释放" username: "用户名" - username_note: "显示为 you。在 goblin.st 上公开。付款保持加密。" + username_note: "显示为你的名字。在 goblin.st 上公开。付款保持加密。" release_username: "释放用户名" pick_username: "选择用户名 — 可选" working: "处理中…" diff --git a/scripts/android.sh b/scripts/android.sh index 5533e2c..d672228 100755 --- a/scripts/android.sh +++ b/scripts/android.sh @@ -80,12 +80,15 @@ function build_apk() { fi if [[ $1 == "" ]] && [ $success -eq 1 ]; then - # Launch application at all connected devices. + # Launch application at all connected devices. The installed application id + # (st.goblin.wallet) differs from the Java namespace (mw.gri.android), so + # derive it from build.gradle and launch the fully-qualified activity. + app_id=$(grep -m 1 -Po 'applicationId "\K[^"]*' app/build.gradle) for SERIAL in $(adb devices | grep -v List | cut -f 1); do adb -s "$SERIAL" install ${apk_path} sleep 1s - adb -s "$SERIAL" shell am start -n mw.gri.android/.MainActivity; + adb -s "$SERIAL" shell am start -n "${app_id}/mw.gri.android.MainActivity"; done elif [ $success -eq 1 ]; then # Get version diff --git a/scripts/gen_icons.sh b/scripts/gen_icons.sh index 8ddbe07..e3fef61 100755 --- a/scripts/gen_icons.sh +++ b/scripts/gen_icons.sh @@ -36,6 +36,18 @@ for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do -gravity center -extent "${fg}x${fg}" PNG32:"$RES/mipmap-$d/ic_launcher_foreground.png" done +# --- Android notification (status-bar) icon: white-on-transparent mascot --- +# Android renders ic_stat_name as an alpha-only silhouette, so the RGB channels +# are forced to pure white; ~90% of the canvas matches the old asset's padding. +declare -A STAT_SIZES=( [mdpi]=24 [hdpi]=36 [xhdpi]=48 [xxhdpi]=72 [xxxhdpi]=96 ) +for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do + s=${STAT_SIZES[$d]} + art=$(( s * 9 / 10 )) + magick -background none img/goblin-logo2.svg -resize "${art}x${art}" \ + -gravity center -extent "${s}x${s}" \ + -channel RGB -evaluate set 100% +channel PNG32:"$RES/drawable-$d/ic_stat_name.png" +done + # --- Windows installer + file-type icon (WiX wix/Product.ico) --- magick "$ICON" -define icon:auto-resize=256,128,64,48,32,24,16 wix/Product.ico diff --git a/src/gui/app.rs b/src/gui/app.rs index 45c7495..d1efcd4 100755 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -330,14 +330,38 @@ impl App { ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); } - // Paint the title. + // Paint the title. Centering on the full rect runs the tail of the + // string under the right-side window buttons at narrow widths (the + // "Build NNN" digits collide with the minimize caret at 390px), so + // when the centered galley would reach the button cluster, center it + // in the free span between the theme toggle and the buttons instead, + // eliding if even that span is too tight. let title_text = format!("Goblin ツ · Build {}", crate::BUILD); - painter.text( - title_rect.center(), - egui::Align2::CENTER_CENTER, - title_text, - egui::FontId::proportional(15.0), - Colors::title(true), + let title_font = egui::FontId::proportional(15.0); + let title_ink = Colors::title(true); + const BUTTONS_LEFT_INSET: f32 = 60.0; // theme toggle + const BUTTONS_RIGHT_INSET: f32 = 168.0; // minimize + fullscreen + close + let free_left = title_rect.min.x + BUTTONS_LEFT_INSET; + let free_right = title_rect.max.x - BUTTONS_RIGHT_INSET; + let mut galley = painter.layout_no_wrap(title_text.clone(), title_font.clone(), title_ink); + let mut center_x = title_rect.center().x; + if center_x + galley.size().x / 2.0 > free_right { + center_x = (free_left + free_right) / 2.0; + if galley.size().x > free_right - free_left { + let mut job = + egui::text::LayoutJob::simple_singleline(title_text, title_font, title_ink); + job.wrap = + egui::text::TextWrapping::truncate_at_width((free_right - free_left).max(0.0)); + galley = painter.layout_job(job); + } + } + painter.galley( + egui::pos2( + center_x - galley.size().x / 2.0, + title_rect.center().y - galley.size().y / 2.0, + ), + galley, + title_ink, ); ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| { diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index cc31c4c..35807ea 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -40,6 +40,12 @@ pub struct Android { impl Android { /// Create new Android platform instance from provided [`AndroidApp`]. pub fn new(app: AndroidApp) -> Self { + // Keep a process-wide handle so non-GUI threads (the nostr service) + // can reach Java too (see `notify_payment_received`). + { + let mut w_app = ANDROID_APP.write(); + *w_app = Some(app.clone()); + } Self { android_app: app, ctx: Arc::new(RwLock::new(None)), @@ -267,6 +273,48 @@ lazy_static! { static ref LAST_CAMERA_IMAGE: Arc, u32)>>> = Arc::new(RwLock::new(None)); /// Picked file path. static ref PICKED_FILE_PATH: Arc>> = Arc::new(RwLock::new(None)); + /// App handle for JNI calls from threads without a platform reference. + static ref ANDROID_APP: Arc>> = Arc::new(RwLock::new(None)); +} + +/// Show the one-shot "payment received" system notification (Java side +/// `BackgroundService.notifyPaymentReceived`, id=2, separate from the +/// persistent sync notification id=1). Called by the nostr service on +/// slatepack receipt from a non-GUI thread, hence the stored [`AndroidApp`] +/// handle instead of a platform reference. Fail-open: a missing handle or +/// JNI error just skips the notification, never the payment. +pub fn notify_payment_received(name: &str, amount: &str) { + let app = { + let r_app = ANDROID_APP.read(); + r_app.clone() + }; + let Some(app) = app else { + return; + }; + let platform = Android { + android_app: app, + ctx: Arc::new(RwLock::new(None)), + }; + let Ok(vm) = (unsafe { jni::JavaVM::from_raw(platform.android_app.vm_as_ptr() as _) }) else { + return; + }; + let Ok(env) = vm.attach_current_thread() else { + return; + }; + let Ok(j_name) = env.new_string(name) else { + return; + }; + let Ok(j_amount) = env.new_string(amount) else { + return; + }; + let _ = platform.call_java_method( + "notifyPaymentReceived", + "(Ljava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Object(&JObject::from(j_name)), + JValue::Object(&JObject::from(j_amount)), + ], + ); } /// Callback from Java code with last entered character from soft keyboard. diff --git a/src/gui/theme.rs b/src/gui/theme.rs index fa00a8d..f5eccd1 100644 --- a/src/gui/theme.rs +++ b/src/gui/theme.rs @@ -351,12 +351,6 @@ pub fn ink_for(bg: Color32) -> Color32 { } } -/// Avatar (background, ink) pair for a hue index. -pub fn avatar_pair(hue: usize) -> (Color32, Color32) { - let pairs = &tokens().avatar_pairs; - pairs[hue % pairs.len()] -} - /// Number of avatar color pairs (hue derivation modulus). pub fn avatar_pairs_len() -> usize { tokens().avatar_pairs.len() diff --git a/src/gui/views/content.rs b/src/gui/views/content.rs index 75fe2c3..0592266 100644 --- a/src/gui/views/content.rs +++ b/src/gui/views/content.rs @@ -144,6 +144,9 @@ impl ContentContainer for Content { .show(); } else if OperatingSystem::from_target_os() == OperatingSystem::Android && AppConfig::android_integrated_node_warning_needed() + // The warning is about INTEGRATED-node background sync; on the + // external-node default it nags about a node we do not run. + && AppConfig::autostart_node() { Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL) .title(t!("network.node")) diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index a593c9a..ffb75f2 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -31,7 +31,6 @@ pub struct ActivityItem { /// Canceled/expired before completing (wallet-cancelled tx or expired meta). pub canceled: bool, pub system: bool, - pub hue: usize, pub time: i64, /// Counterparty npub hex, when known. pub npub: Option, @@ -44,7 +43,6 @@ pub struct ActivityItem { pub struct ReceiptDetail { pub tx_id: u32, pub title: String, - pub hue: usize, pub npub: Option, pub amount: u64, pub incoming: bool, @@ -79,18 +77,16 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option { let meta: Option = slate_id .as_ref() .and_then(|sid| store_ref.and_then(|s| s.tx_meta(sid))); - let (title, hue) = if system { - ("Mining reward".to_string(), 5) + let title = if system { + "Mining reward".to_string() } else if let Some(m) = &meta { store_ref .map(|s| contact_title(s, &m.npub)) - .unwrap_or_else(|| (short_npub(&m.npub), 0)) + .unwrap_or_else(|| short_npub(&m.npub)) + } else if incoming { + "Received".to_string() } else { - let label = if incoming { "Received" } else { "Sent" }; - ( - label.to_string(), - (tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(), - ) + "Sent".to_string() }; let note = meta.as_ref().and_then(|m| m.note.clone()); let time = tx @@ -133,7 +129,6 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option { Some(ReceiptDetail { tx_id, title, - hue, npub: meta.map(|m| m.npub), amount: tx.amount, incoming, @@ -184,12 +179,11 @@ fn is_canceled(tx: &WalletTx, meta: Option<&TxNostrMeta>) -> bool { } /// Resolve the display title for a contact npub. -pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) { +pub fn contact_title(store: &NostrStore, npub: &str) -> String { if let Some(contact) = store.contact(npub) { - (display_name(&contact), contact.hue as usize) + display_name(&contact) } else { - let hue = hue_of(&npub); - (short_npub(npub), hue) + short_npub(npub) } } @@ -229,9 +223,9 @@ pub fn name_verification(contact: &Contact) -> Option> { } } -/// Short npub display (npub1abcd…wxyz) from a hex pubkey. /// Avatar hue index derived from a hex pubkey (stable per identity, spread -/// across the full color-pair palette). +/// across the full color-pair palette). Only fills the persisted +/// `Contact.hue` field these days — nothing reads it for rendering anymore. pub fn hue_of(hex: &str) -> usize { usize::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0) % crate::gui::theme::avatar_pairs_len() @@ -249,16 +243,17 @@ pub fn short_handle(handle: &str) -> String { format!("{head}…{tail}") } +/// Short npub display (npub1abcd…wxyz) from a hex pubkey. pub fn short_npub(hex: &str) -> String { use nostr_sdk::{PublicKey, ToBech32}; if let Ok(pk) = PublicKey::from_hex(hex) { - if let Ok(npub) = pk.to_bech32() { - // Standard truncation: "npub1" + 7 head chars … 6 tail chars. - if npub.len() > 18 { - return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]); - } - return npub; + // `to_bech32` for a valid key is infallible. + let Ok(npub) = pk.to_bech32(); + // Standard truncation: "npub1" + 7 head chars … 6 tail chars. + if npub.len() > 18 { + return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]); } + return npub; } format!("{}…", &hex[..8.min(hex.len())]) } @@ -300,23 +295,17 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem { .as_ref() .and_then(|sid| store.and_then(|s| s.tx_meta(sid))); - let (title, hue) = if system { - ("Mining reward".to_string(), 5) + let title = if system { + "Mining reward".to_string() } else if let Some(meta) = &meta { store .map(|s| contact_title(s, &meta.npub)) - .unwrap_or_else(|| (short_npub(&meta.npub), 0)) + .unwrap_or_else(|| short_npub(&meta.npub)) + } else if incoming { + // Fall back to a generic label when there's no nostr counterparty. + "Received".to_string() } else { - // Fall back to slatepack address counterparty or generic label. - let label = if incoming { - "Received".to_string() - } else { - "Sent".to_string() - }; - ( - label, - (tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(), - ) + "Sent".to_string() }; let note = meta.as_ref().and_then(|m| m.note.clone()); @@ -337,14 +326,14 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem { confirmed: tx.data.confirmed, canceled, system, - hue, time, npub: meta.map(|m| m.npub), } } -/// Recent unique peers for the home strip (most recent first). -pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> { +/// Recent unique peers for the home strip (most recent first), as +/// `(display name, npub hex)`. +pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, String)> { let store = match wallet.nostr_service() { Some(s) => s.store.clone(), None => return vec![], @@ -354,13 +343,14 @@ pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String contacts .into_iter() .take(limit) - .map(|c| (display_name(&c), c.hue as usize, c.npub)) + .map(|c| (display_name(&c), c.npub)) .collect() } /// Local contacts whose petname / nip05 / npub contains `query` (case- /// insensitive) — the instant, no-network half of the recipient search. -pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, usize, String)> { +/// Returns `(display name, npub hex)` pairs. +pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, String)> { let store = match wallet.nostr_service() { Some(s) => s.store.clone(), None => return vec![], @@ -369,7 +359,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin if q.is_empty() { return vec![]; } - let mut hits: Vec<(String, usize, String)> = store + let mut hits: Vec<(String, String)> = store .all_contacts() .into_iter() .filter(|c| { @@ -383,7 +373,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin .unwrap_or(false) || c.npub.to_lowercase().contains(&q) }) - .map(|c| (display_name(&c), c.hue as usize, c.npub)) + .map(|c| (display_name(&c), c.npub)) .collect(); hits.truncate(limit); hits diff --git a/src/gui/views/goblin/identicon.rs b/src/gui/views/goblin/identicon.rs index 4b0fc06..794a1e0 100644 --- a/src/gui/views/goblin/identicon.rs +++ b/src/gui/views/goblin/identicon.rs @@ -36,8 +36,8 @@ const LOGO_FRAC: f64 = 0.90; const LOGO_OPACITY: f64 = 0.67; const GRIN_NATIVE: f64 = 61.0; -/// Standard HSL → RGB → `#rrggbb`. f64 throughout for cross-port byte-identity. -fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String { +/// Standard HSL → RGB bytes. f64 throughout for cross-port byte-identity. +pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) { let c = (1.0 - (2.0 * l - 1.0).abs()) * s; let hp = h / 60.0; let x = c * (1.0 - ((hp % 2.0) - 1.0).abs()); @@ -51,7 +51,23 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String { }; let m = l - c / 2.0; let to = |v: f64| ((v + m) * 255.0).round() as u8; - format!("#{:02x}{:02x}{:02x}", to(r), to(g), to(b)) + (to(r), to(g), to(b)) +} + +/// Standard HSL → RGB → `#rrggbb`. +fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String { + let (r, g, b) = hsl_rgb8(h, s, l); + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// Conic-ring hue path for a custom-image avatar, seeded by the USERNAME (not +/// the pubkey, per the design): base hue from the first two hash bytes, sweep +/// width (60°–180°) from the third. Deterministic, like the gradients. +pub(super) fn ring_params(name: &str) -> (f64, f64) { + let hash = Sha256::digest(name.as_bytes()); + let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0; + let sweep = 60.0 + (hash[2] as f64 / 255.0) * 120.0; + (base, sweep) } /// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical @@ -79,17 +95,6 @@ fn gradient_params(hex: &str) -> (String, String, f64) { (c1, c2, angle) } -/// The seeded two-tone gradient WITHOUT the Grin mark — a bare background tile. -/// Used for **named** users, where the app paints the person's initial on top -/// (see `widgets::gradient_letter_avatar`) instead of the Grin mark. Same seed → -/// same background as the anonymous gradient avatar, so one key reads consistently. -pub fn gradient_bg_svg(hex: &str, size: u32) -> String { - let (c1, c2, angle) = gradient_params(hex); - format!( - r##""## - ) -} - /// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase /// hex pubkey). `id_suffix` makes the gradient element id unique when several /// are inlined into ONE html document; for a standalone document (how egui diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index c3799d1..bc09822 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -91,8 +91,12 @@ pub struct GoblinWalletView { request_amount: Option, /// Sub-page open inside the Settings tab. settings_page: SettingsPage, - /// GRIM's native node-connections screen (embedded under Advanced). - grim_connections: crate::gui::views::network::ConnectionsContent, + /// Active GRIM integrated-node tab (Info/Metrics/Mining/Settings), hosted + /// inside Goblin chrome — GRIM's dual-panel shell is never rendered. + node_tab: Box, + /// Where the integrated-node page returns to (it has two entry points: + /// the Settings screen and the Node screen). + node_tab_back: SettingsPage, /// Inline state for the Advanced settings page (recovery/repair/delete). advanced: AdvancedState, /// One-shot signal to the wallet host: deselect this wallet (return to the @@ -119,6 +123,9 @@ pub struct GoblinWalletView { cancel_msg: Option<(crate::nostr::CancelOutcome, std::time::Instant)>, /// Transient "Copied" flash for the settings backup card (npub/keys). copy_flash: Option, + /// "Wipe payment history" tap-twice confirm: armed after the first tap, + /// wipes on the second (cleared once fired). + wipe_confirm: bool, } /// Sub-pages of the Settings tab. @@ -126,8 +133,8 @@ pub struct GoblinWalletView { enum SettingsPage { Main, Node, - /// GRIM's native node-connections screen, embedded. - Connections, + /// GRIM's integrated-node tabs, embedded in Goblin chrome. + IntegratedNode, Relays, Nips, Pairing, @@ -194,7 +201,8 @@ impl Default for GoblinWalletView { pay_shake: None, request_amount: None, settings_page: SettingsPage::Main, - grim_connections: Default::default(), + node_tab: Box::new(crate::gui::views::network::NetworkNode), + node_tab_back: SettingsPage::Main, advanced: AdvancedState::default(), switch_requested: false, node_url_input: String::new(), @@ -207,6 +215,7 @@ impl Default for GoblinWalletView { cancel_confirm: None, cancel_msg: None, copy_flash: None, + wipe_confirm: false, } } } @@ -351,6 +360,17 @@ impl GoblinWalletView { std::mem::take(&mut self.switch_requested) } + /// Whether back navigation has anything left to consume: an overlay, a + /// settings sub-page, or a non-Home tab (back routes to Home). Mirrors + /// [`Self::on_back`], so the host never falls back to the wallet chooser. + pub fn can_back(&self) -> bool { + self.receipt.is_some() + || self.profile.is_some() + || self.send.is_some() + || (self.tab == Tab::Me && self.settings_page != SettingsPage::Main) + || self.tab != Tab::Home + } + /// Handle a back navigation; returns true if not consumed. pub fn on_back(&mut self) -> bool { if self.receipt.is_some() { @@ -728,7 +748,7 @@ impl GoblinWalletView { self.settings_page = SettingsPage::Node; } ui.add_space(8.0); - let (handle, connected, npub_hex) = wallet + let (handle, npub_hex) = wallet .nostr_service() .map(|s| { let id = s.identity.read(); @@ -737,16 +757,11 @@ impl GoblinWalletView { .clone() .map(|n| n.split('@').next().unwrap_or("").to_string()) .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); - (h, s.is_connected(), hex_of(&id.npub)) + (h, hex_of(&id.npub)) }) .unwrap_or_else(|| { - ( - t!("goblin.home.anonymous").to_string(), - false, - String::new(), - ) + (t!("goblin.home.anonymous").to_string(), String::new()) }); - let hue = data::hue_of(&npub_hex); let tex = self.handle_tex(ui.ctx(), wallet, &handle); // Identity chip → identity settings. let id_resp = ui @@ -754,7 +769,7 @@ impl GoblinWalletView { w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { - w::avatar_any(ui, &handle, &npub_hex, 28.0, hue, tex.as_ref()); + w::avatar_any(ui, &handle, &npub_hex, 28.0, tex.as_ref()); ui.add_space(10.0); ui.vertical(|ui| { // Scale the handle to its length: short @names get a @@ -769,7 +784,9 @@ impl GoblinWalletView { .color(t.surface_text), ); ui.label( - RichText::new(if connected { + // Relay-gated: "Connected over Nym" only once a + // relay is live on the current tunnel generation. + RichText::new(if crate::nym::transport_ready() { t!("goblin.home.connected_nym") } else if crate::nym::is_ready() { t!("goblin.home.nym_ready") @@ -877,6 +894,15 @@ impl GoblinWalletView { .truncate(), ); }); + // Low-opacity gear so the card reads as a tappable settings + // shortcut; on-surface ink keeps it theme-aware. + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(crate::gui::icons::GEAR) + .font(FontId::new(16.0, fonts::regular())) + .color(t.surface_text.gamma_multiply(0.35)), + ); + }); }); }); } @@ -902,9 +928,8 @@ impl GoblinWalletView { let id = s.identity.read(); let hex = hex_of(&id.npub); // With a verified handle show "@name"; otherwise fall back to - // the short npub so avatar_any draws the deterministic gradient - // (it keys the gradient branch off a leading "npub"), not a - // meaningless lettered tile. + // the short npub (avatar_any then draws the deterministic + // pubkey-seeded gradient). let h = id .nip05 .clone() @@ -913,7 +938,6 @@ impl GoblinWalletView { (h, hex) }) .unwrap_or_else(|| ("N".to_string(), String::new())); - let header_hue = data::hue_of(&header_hex); let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle); ui.horizontal(|ui| { widgets_logo(ui); @@ -929,7 +953,6 @@ impl GoblinWalletView { &header_handle, &header_hex, 36.0, - header_hue, header_tex.as_ref(), ) .clicked() @@ -968,7 +991,22 @@ impl GoblinWalletView { .as_ref() .map(|d| (d.info.total, d.info.amount_currently_spendable)) .unwrap_or((0, 0)); - w::balance_hero(ui, total, spendable, fiat_line(&data).as_deref(), 56.0); + // Zero can just mean "in transit" (locked change / awaiting + // finalization) or a first sync still running. + let in_flight = data + .as_ref() + .map(|d| d.info.amount_locked + d.info.amount_awaiting_finalization) + .unwrap_or(0); + let updating = total == 0 && (in_flight > 0 || wallet.syncing()); + w::balance_hero( + ui, + total, + spendable, + updating, + wallet.info_sync_progress(), + fiat_line(&data).as_deref(), + 56.0, + ); ui.add_space(20.0); let (send, receive) = w::send_receive(ui); if send { @@ -1009,7 +1047,7 @@ impl GoblinWalletView { } let texs: Vec> = peers .iter() - .map(|(name, _, _)| self.handle_tex(ui.ctx(), wallet, name)) + .map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name)) .collect(); w::kicker(ui, &t!("goblin.home.recent")); ui.add_space(12.0); @@ -1018,14 +1056,14 @@ impl GoblinWalletView { .auto_shrink([false, true]) .show(ui, |ui| { ui.horizontal(|ui| { - for ((name, hue, npub), tex) in peers.iter().zip(texs.iter()) { + for ((name, npub), tex) in peers.iter().zip(texs.iter()) { // Fixed-width centered cell so the name sits centered under the // avatar (not left-aligned to a wider label). ui.allocate_ui_with_layout( Vec2::new(72.0, 78.0), Layout::top_down(Align::Center), |ui| { - let resp = w::avatar_any(ui, name, npub, 48.0, *hue, tex.as_ref()); + let resp = w::avatar_any(ui, name, npub, 48.0, tex.as_ref()); ui.add_space(6.0); let chars: Vec = name.chars().collect(); let short: String = if chars.len() > 8 { @@ -1066,7 +1104,6 @@ impl GoblinWalletView { (h, hex) }) .unwrap_or_else(|| ("N".to_string(), String::new())); - let header_hue = data::hue_of(&header_hex); let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle); ui.horizontal(|ui| { // Goblin mark (left), sized to match the right-side controls. @@ -1078,16 +1115,9 @@ impl GoblinWalletView { // Right cluster: scan QR (black, no background) then the profile // picture at the far right; all three controls about the same size. ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if w::avatar_any( - ui, - &header_handle, - &header_hex, - 40.0, - header_hue, - header_tex.as_ref(), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() + if w::avatar_any(ui, &header_handle, &header_hex, 40.0, header_tex.as_ref()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() { self.tab = Tab::Me; } @@ -1170,24 +1200,12 @@ impl GoblinWalletView { }; ui.add_space(if tall { 32.0 } else { 16.0 } + drop); - // Numpad at narrow (mobile-shell) widths, typed input on the wide - // desktop layout — gate by width like the shell itself, or narrow - // desktop windows get neither input. - let typed_hint = !narrow && self.pay_amount.is_empty(); - if narrow { - w::numpad(ui, &mut self.pay_amount, cb); - } else { - w::amount_typed_input(ui, &mut self.pay_amount); - if typed_hint { - ui.vertical_centered(|ui| { - ui.label( - RichText::new(t!("goblin.home.type_amount")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_mute), - ); - }); - } - } + // The pay column is capped at 480 by `centered_column`, so the old + // `< 700` width gate was always narrow: the numpad always showed and + // the typed-input branch was dead — a physical keyboard did nothing. + // Show the pad and accept typed digits alongside it. + w::numpad(ui, &mut self.pay_amount, cb); + w::amount_typed_input(ui, &mut self.pay_amount); ui.add_space(20.0); // Request | Pay actions, half width each. @@ -1237,8 +1255,7 @@ impl GoblinWalletView { }, ); }); - // Skip when the "Type an amount" hint is already showing above. - if !valid && !typed_hint { + if !valid { ui.add_space(8.0); ui.vertical_centered(|ui| { ui.label( @@ -1325,7 +1342,6 @@ impl GoblinWalletView { &d.title, d.npub.as_deref().unwrap_or(""), 64.0, - d.hue, tex.as_ref(), ); ui.add_space(10.0); @@ -1582,10 +1598,10 @@ impl GoblinWalletView { npub: &str, ) -> bool { let t = theme::tokens(); - let (name, hue) = wallet + let name = wallet .nostr_service() .map(|s| data::contact_title(&s.store, npub)) - .unwrap_or_else(|| (data::short_npub(npub), 0)); + .unwrap_or_else(|| data::short_npub(npub)); let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub)); let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false); let nip05 = contact.as_ref().and_then(|c| c.nip05.clone()); @@ -1620,7 +1636,7 @@ impl GoblinWalletView { .show(ui, |ui| { ui.add_space(8.0); ui.vertical_centered(|ui| { - w::avatar_any(ui, &name, npub, 72.0, hue, tex.as_ref()); + w::avatar_any(ui, &name, npub, 72.0, tex.as_ref()); ui.add_space(12.0); ui.label( RichText::new(&name) @@ -1655,7 +1671,14 @@ impl GoblinWalletView { ); } else { for (item, htex) in history.iter().zip(htexs.iter()) { - let sign = if item.incoming { "+ " } else { "− " }; + // No +/- for canceled: nothing moved. + let sign = if item.canceled { + "" + } else if item.incoming { + "+ " + } else { + "− " + }; let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); let status_word = if item.canceled { @@ -1675,10 +1698,10 @@ impl GoblinWalletView { ui, &item.title, &subtitle, - item.hue, item.npub.as_deref().unwrap_or(""), &amount, item.incoming, + item.canceled, item.system, htex.as_ref(), ) @@ -1732,7 +1755,8 @@ impl GoblinWalletView { nip05: nip05.clone(), nip05_verified_at: None, relays: vec![], - hue: hue as u8, + nip44_v3: false, + hue: data::hue_of(npub) as u8, unknown: true, added_at: crate::nostr::unix_time(), last_paid_at: None, @@ -1817,8 +1841,11 @@ impl GoblinWalletView { ); } else { // Unconfirmed (< min confirmations) pinned on top as Pending. - let pending: Vec<&_> = - items.iter().filter(|i| !i.confirmed && !i.system).collect(); + // Canceled txs are not pending — they group with history below. + let pending: Vec<&_> = items + .iter() + .filter(|i| !i.confirmed && !i.system && !i.canceled) + .collect(); if !pending.is_empty() { w::section_header(ui, &t!("goblin.activity.pending_header")); for item in pending { @@ -1826,9 +1853,12 @@ impl GoblinWalletView { } ui.add_space(8.0); } - // Confirmed, grouped by day (newest first). + // Confirmed (and canceled), grouped by day (newest first). let mut last: Option = None; - for item in items.iter().filter(|i| i.confirmed || i.system) { + for item in items + .iter() + .filter(|i| i.confirmed || i.system || i.canceled) + { let label = Self::day_label(item.time); if last.as_deref() != Some(label.as_str()) { w::section_header(ui, &label); @@ -1848,7 +1878,14 @@ impl GoblinWalletView { wallet: &Wallet, _cb: &dyn PlatformCallbacks, ) { - let sign = if item.incoming { "+ " } else { "− " }; + // No +/- for canceled: nothing moved. + let sign = if item.canceled { + "" + } else if item.incoming { + "+ " + } else { + "− " + }; let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); let status_word = if item.canceled { t!("goblin.activity.canceled").to_string() @@ -1866,10 +1903,10 @@ impl GoblinWalletView { ui, &item.title, &subtitle, - item.hue, item.npub.as_deref().unwrap_or(""), &amount, item.incoming, + item.canceled, item.system, tex.as_ref(), ) @@ -1886,14 +1923,14 @@ impl GoblinWalletView { wallet: &Wallet, ) { let t = theme::tokens(); - let (name, hue) = wallet + let name = wallet .nostr_service() .map(|s| data::contact_title(&s.store, &req.npub)) - .unwrap_or_else(|| (data::short_npub(&req.npub), 0)); + .unwrap_or_else(|| data::short_npub(&req.npub)); let tex = self.handle_tex(ui.ctx(), wallet, &name); w::card(ui, |ui| { ui.horizontal(|ui| { - w::avatar_any(ui, &name, &req.npub, 40.0, hue, tex.as_ref()); + w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref()); ui.add_space(12.0); ui.vertical(|ui| { ui.label( @@ -2003,10 +2040,10 @@ impl GoblinWalletView { let Some(req) = self.approve_review.clone() else { return true; }; - let (name, hue) = wallet + let name = wallet .nostr_service() .map(|s| data::contact_title(&s.store, &req.npub)) - .unwrap_or_else(|| (data::short_npub(&req.npub), 0)); + .unwrap_or_else(|| data::short_npub(&req.npub)); let tex = self.handle_tex(ui.ctx(), wallet, &name); // Paying a request spends our balance, so guard against over-balance and // disable the accept gesture (re-checked each frame). @@ -2041,7 +2078,7 @@ impl GoblinWalletView { ui.set_min_width(ui.available_width()); ui.add_space(8.0); ui.vertical_centered(|ui| { - w::avatar_any(ui, &name, &req.npub, 40.0, hue, tex.as_ref()); + w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref()); ui.add_space(6.0); ui.label( RichText::new(t!("goblin.request.title", name => &name)) @@ -2081,7 +2118,7 @@ impl GoblinWalletView { )); } let fee_val = match wallet.calculated_fee(req.amount) { - Some(fee) => format!("{} {}", w::amount_str(fee), w::TSU), + Some(fee) => format!("{}{}", w::amount_str(fee), w::TSU), None => { ui.ctx().request_repaint_after( std::time::Duration::from_millis(120), @@ -2157,17 +2194,18 @@ impl GoblinWalletView { ); ui.add_space(16.0); - let handle = wallet + // `has_name`: a claimed nip05 name exists — gates the "handle"/"username" + // wording, which would mislead when only the raw npub is shown. + let (handle, has_name) = wallet .nostr_service() .map(|s| { let identity = s.identity.read(); - identity - .nip05 - .clone() - .map(|n| n.split('@').next().unwrap_or("").to_string()) - .unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub))) + match identity.nip05.clone() { + Some(n) => (n.split('@').next().unwrap_or("").to_string(), true), + None => (data::short_npub(&hex_of(&identity.npub)), false), + } }) - .unwrap_or_else(|| "—".to_string()); + .unwrap_or_else(|| ("—".to_string(), false)); let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default(); let nprofile = wallet .nostr_service() @@ -2203,8 +2241,13 @@ impl GoblinWalletView { } } None => { + let caption = if has_name { + t!("goblin.receive.share_handle") + } else { + t!("goblin.receive.share_npub") + }; ui.label( - RichText::new(t!("goblin.receive.share_handle")) + RichText::new(caption) .font(FontId::new(13.0, fonts::regular())) .color(t.surface_text_dim), ); @@ -2263,8 +2306,13 @@ impl GoblinWalletView { }); ui.add_space(16.0); + let privacy_note = if has_name { + t!("goblin.receive.privacy_note") + } else { + t!("goblin.receive.privacy_note_npub") + }; ui.label( - RichText::new(t!("goblin.receive.privacy_note")) + RichText::new(privacy_note) .font(FontId::new(12.0, fonts::regular())) .color(t.text_mute), ); @@ -2274,7 +2322,7 @@ impl GoblinWalletView { let t = theme::tokens(); match self.settings_page { SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb), - SettingsPage::Connections => return self.grim_connections_ui(ui, cb), + SettingsPage::IntegratedNode => return self.integrated_node_ui(ui, cb), SettingsPage::Relays => return self.relays_ui(ui, wallet, cb), SettingsPage::Nips => return self.nips_ui(ui), SettingsPage::Pairing => return self.pairing_settings_ui(ui), @@ -2322,7 +2370,6 @@ impl GoblinWalletView { ) }); - let hue = data::hue_of(&npub_hex); let own_tex = bare_name .as_deref() .and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle)); @@ -2330,9 +2377,9 @@ impl GoblinWalletView { w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { - // Avatar is a generated identicon (gradient + initial) — Goblin has - // no uploaded profile pictures. - w::avatar_any(ui, &handle, &npub_hex, 56.0, hue, own_tex.as_ref()); + // Custom picture when one is set; otherwise the deterministic + // pubkey-seeded gradient identicon. + w::avatar_any(ui, &handle, &npub_hex, 56.0, own_tex.as_ref()); ui.add_space(14.0); ui.vertical(|ui| { ui.horizontal(|ui| { @@ -2351,9 +2398,15 @@ impl GoblinWalletView { ); } }); - // Mixnet status (fast) in place of the redundant second npub line. - let mixnet = if crate::nym::is_ready() { + // Transport status in place of the redundant second npub line. + // "Connected over Nym" is RELAY-GATED (transport_ready): the + // tunnel being warm is not enough — a relay must actually carry + // our traffic on the current exit. Otherwise show the tunnel is + // up but relays are still connecting/reconnecting. + let mixnet = if crate::nym::transport_ready() { t!("goblin.home.connected_nym") + } else if crate::nym::is_ready() { + t!("goblin.home.nym_ready") } else { t!("goblin.home.connecting_nym") }; @@ -2373,7 +2426,7 @@ impl GoblinWalletView { .font(FontId::new(12.0, fonts::regular())) .color(t.surface_text_mute), ); - if !crate::nym::is_ready() || !connected { + if !crate::nym::transport_ready() || !connected { ui.ctx() .request_repaint_after(std::time::Duration::from_millis(600)); } @@ -2404,6 +2457,10 @@ impl GoblinWalletView { } self.claim_ui(ui, wallet, cb); ui.add_space(8.0); + // Hoisted above the identity card: the Nostr Relays row now lives + // inside that card (relays are a nostr concern, like the keys), but + // its open handler runs further down — so the flag is declared here. + let mut open_relays = false; w::card(ui, |ui| { if !npub.is_empty() { if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) { @@ -2437,6 +2494,16 @@ impl GoblinWalletView { { self.import_nsec = Some(ImportState::default()); } + // Nostr relays the wallet publishes/reads gift wraps on. + // Sits with the identity rows because relays are a nostr + // concern; opens the relay editor (handled below). + if settings_row_nav( + ui, + &t!("goblin.settings.nostr_relays"), + &relay_summary(wallet), + ) { + open_relays = true; + } // Federation: which name authority (server) registers and // verifies names. Shows the current host on the right. let authority = wallet @@ -2503,17 +2570,24 @@ impl GoblinWalletView { } ui.add_space(16.0); - let mut open_relays = false; let mut open_node = false; + let mut open_integrated = false; let mut open_slatepack = false; settings_group(ui, &t!("goblin.settings.wallet"), |ui| { - settings_row(ui, &t!("goblin.settings.display_unit"), "ツ (grin)"); - if settings_row_nav(ui, &t!("goblin.settings.relays"), &relay_summary(wallet)) { - open_relays = true; - } if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) { open_node = true; } + // GRIM's integrated-node tabs (info, metrics, mining, node + // settings), shown in Goblin chrome. Live sync status when + // the node runs, like the Node row above. + let node_value = if crate::node::Node::is_running() { + crate::node::Node::get_sync_status_text() + } else { + String::new() + }; + if settings_row_nav(ui, &t!("goblin.settings.integrated_node"), &node_value) { + open_integrated = true; + } // GRIM's native by-hand slatepack exchange, for when a payment // can't go through a username. if settings_row_nav( @@ -2529,9 +2603,11 @@ impl GoblinWalletView { self.settings_page = SettingsPage::Slatepack; } if open_relays { + // The ACTIVE set (override or per-identity advertised set), + // so the editor shows what is really in use. self.relay_edit = wallet .nostr_service() - .map(|s| s.config.read().relays()) + .map(|s| s.relays()) .unwrap_or_default(); self.relay_input.clear(); self.settings_page = SettingsPage::Relays; @@ -2541,24 +2617,30 @@ impl GoblinWalletView { self.node_secret_input.clear(); self.settings_page = SettingsPage::Node; } + if open_integrated { + self.node_tab = Box::new(crate::gui::views::network::NetworkNode); + self.node_tab_back = SettingsPage::Main; + self.settings_page = SettingsPage::IntegratedNode; + } ui.add_space(16.0); let mut open_pairing = false; let mut open_privacy = false; settings_group(ui, &t!("goblin.settings.privacy"), |ui| { // Messages, names, price and avatars ride the mixnet; the grin - // node connects directly. Flagged in the privacy color so it - // still reads as the headline guarantee — tap for the breakdown. - if settings_row_nav_ink( + // node connects directly. Normal dim value ink: the salmon + // privacy color doubled as the destructive-action color on + // this page, making a plain navigable row read as a warning. + if settings_row_nav( ui, &t!("goblin.settings.mixnet_routing"), &t!("goblin.settings.messages_lookups"), - theme::tokens().neg, ) { open_privacy = true; } - // Tap to cycle the incoming-payment accept policy. - if settings_row_btn( + // Tap to cycle the incoming-payment accept policy. Value styled + // like the sibling rows' values (small/dim), not like an icon. + if settings_row_cycle( ui, &t!("goblin.settings.auto_accept"), &accept_policy_label(wallet), @@ -2626,13 +2708,21 @@ impl GoblinWalletView { cb.vibrate_copy(); } } - if settings_row_btn( - ui, - &t!("goblin.settings.wipe_history"), - crate::gui::icons::X, - ) { - if let Some(s) = wallet.nostr_service() { - s.store.wipe_archive(); + // Destructive: danger styling + tap-twice confirm (like the + // receipt's "Cancel payment") before the archive is wiped. + let wipe_label = if self.wipe_confirm { + t!("goblin.settings.wipe_history_confirm") + } else { + t!("goblin.settings.wipe_history") + }; + if settings_row_danger(ui, &wipe_label, crate::gui::icons::X) { + if self.wipe_confirm { + if let Some(s) = wallet.nostr_service() { + s.store.wipe_archive(); + } + self.wipe_confirm = false; + } else { + self.wipe_confirm = true; } } }); @@ -3084,24 +3174,66 @@ impl GoblinWalletView { self.settings_page = SettingsPage::Main; } if open_node { - // Advanced → "Manage node connection" opens GRIM's native connections UI. - self.settings_page = SettingsPage::Connections; + // Advanced → "Manage node connection" opens Goblin's own Node screen + // (its Advanced button reaches the integrated-node tabs from there). + self.node_url_input.clear(); + self.node_secret_input.clear(); + self.settings_page = SettingsPage::Node; } } - /// GRIM's native node-connections screen, embedded under a Goblin back header. - fn grim_connections_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { - use crate::gui::views::types::ContentContainer; - if self.sub_header(ui, &t!("goblin.node.title")) { - self.settings_page = SettingsPage::Advanced; + /// GRIM's four integrated-node tabs (Info / Metrics / Mining / Settings) + /// hosted under a Goblin back header and segmented control — GRIM's + /// dual-panel and floating-navbar chrome are never rendered. The header + /// title follows the active tab, like GRIM's own title panel. + fn integrated_node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + use crate::gui::icons::{DATABASE, FACTORY, FADERS, GAUGE}; + use crate::gui::views::network::types::NodeTabType; + use crate::gui::views::network::{ + NetworkContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings, + disabled_node_ui, node_error_ui, + }; + use crate::node::Node; + let title = self.node_tab.get_type().title(); + if self.sub_header(ui, &title) { + self.settings_page = self.node_tab_back; return; } - ScrollArea::vertical() - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - self.grim_connections.ui(ui, cb); - }); + let selected = match self.node_tab.get_type() { + NodeTabType::Info => 0, + NodeTabType::Metrics => 1, + NodeTabType::Mining => 2, + NodeTabType::Settings => 3, + }; + if let Some(i) = w::segmented(ui, &[DATABASE, GAUGE, FACTORY, FADERS], selected) { + self.node_tab = match i { + 0 => Box::new(NetworkNode), + 1 => Box::new(NetworkMetrics), + 2 => Box::new(NetworkMining::default()), + _ => Box::new(NetworkSettings::default()), + }; + } + ui.add_space(12.0); + // Same availability gate as GRIM's NetworkContent: the Settings tab is + // editable with the node off; the live tabs need a running node with + // stats before their content can draw. + if self.node_tab.get_type() != NodeTabType::Settings { + if let Some(err) = Node::get_error() { + node_error_ui(ui, err); + } else if !Node::is_running() { + disabled_node_ui(ui); + } else if Node::get_stats().is_none() || Node::is_restarting() || Node::is_stopping() { + NetworkContent::loading_ui(ui, None::); + } else { + self.node_tab.tab_ui(ui, cb); + } + } else { + self.node_tab.tab_ui(ui, cb); + } + // Keep the stats fresh while the node runs. + if Node::is_running() { + ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY); + } } fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { @@ -3162,10 +3294,13 @@ impl GoblinWalletView { .color(t.pos), ); } else { + // Trash, not an X: a grey × next to the active row's + // green check read as a failed-status icon, not the + // remove action it actually is. let x = ui.label( - RichText::new(crate::gui::icons::X) + RichText::new(crate::gui::icons::TRASH_SIMPLE) .font(FontId::new(15.0, fonts::regular())) - .color(t.surface_text_mute), + .color(t.surface_text_dim), ); if x.interact(Sense::click()).clicked() { ConnectionsConfig::remove_ext_conn(conn.id); @@ -3227,6 +3362,14 @@ impl GoblinWalletView { self.node_url_input.clear(); self.node_secret_input.clear(); } + ui.add_space(10.0); + // Advanced: GRIM's integrated-node tabs (info, metrics, mining + // with stratum, node settings) inside Goblin chrome. + if w::big_action(ui, &t!("goblin.settings.node_advanced"), true).clicked() { + self.node_tab = Box::new(crate::gui::views::network::NetworkNode); + self.node_tab_back = SettingsPage::Node; + self.settings_page = SettingsPage::IntegratedNode; + } ui.add_space(16.0); }); } @@ -4707,6 +4850,29 @@ fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { row.response.interact(Sense::click()).clicked() } +/// A settings row whose value cycles in place on tap (no navigation): the +/// value is drawn in the same small/dim style as [`settings_row_nav`] so it +/// sits consistently next to chevroned siblings, just without the chevron. +fn settings_row_cycle(ui: &mut egui::Ui, label: &str, value: &str) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(value) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + /// A settings row that navigates somewhere: value + chevron, whole row taps. fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool { let t = theme::tokens(); @@ -4734,34 +4900,6 @@ fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool { row.response.interact(Sense::click()).clicked() } -/// Like [`settings_row_nav`] but the value is drawn in an explicit ink — used to -/// flag the mixnet-routing row in the privacy color while still navigating. -fn settings_row_nav_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) -> bool { - let t = theme::tokens(); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(crate::gui::icons::CARET_RIGHT) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_mute), - ); - ui.add_space(4.0); - ui.label( - RichText::new(value) - .font(FontId::new(13.0, fonts::regular())) - .color(value_ink), - ); - }); - }); - ui.add_space(10.0); - row.response.interact(Sense::click()).clicked() -} - /// One channel row on the Network-privacy page: a status dot, a title and a /// wrapped blurb explaining where that traffic goes. fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) { diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 5216614..6a90880 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -21,7 +21,7 @@ use eframe::epaint::FontId; use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2}; use grin_util::ZeroingString; -use crate::gui::icons::ARROW_LEFT; +use crate::gui::icons::{ARROW_LEFT, CHECK}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult}; @@ -75,6 +75,8 @@ pub struct OnboardingContent { /// step so a returning user can keep their old npub + username instead of the /// freshly-generated random key. import: Option, + /// Moment the recovery phrase was copied, for the transient "Copied" check. + words_copied: Option, } /// Onboarding identity-import state. Reuses the wallet password the user just @@ -104,7 +106,7 @@ impl Default for OnboardingContent { // Default to the Instant path (connect to a public node) so a new // user is online immediately, with no chain-sync wait. integrated: false, - ext_url: "https://api.grin.money".to_string(), + ext_url: "https://grincoin.org".to_string(), restore: false, name: "Main wallet".to_string(), pass: String::new(), @@ -115,6 +117,7 @@ impl Default for OnboardingContent { wallet: None, claim: ClaimState::default(), import: None, + words_copied: None, } } } @@ -561,9 +564,24 @@ impl OnboardingContent { ); }); ui.add_space(14.0); - } else if w::chip(ui, &t!("goblin.onboarding.words.copy_clipboard"), false).clicked() { - cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase()); - cb.vibrate_copy(); + } else { + // Transient "Copied" feedback (the Build 82/89 pattern): a silent + // copy of the recovery phrase reads as a dead button. + let copied = matches!(self.words_copied, Some(at) if at.elapsed().as_millis() < 1500); + if self.words_copied.is_some() { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); + } + let label = if copied { + format!("{} {}", CHECK, t!("goblin.receive.copied")) + } else { + t!("goblin.onboarding.words.copy_clipboard").to_string() + }; + if w::chip(ui, &label, false).clicked() { + cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase()); + cb.vibrate_copy(); + self.words_copied = Some(std::time::Instant::now()); + } } if !restore { ui.add_space(14.0); @@ -746,7 +764,8 @@ impl OnboardingContent { // for this key; only fall back to a placeholder while the key is // still being generated (npub not yet available). if npub.is_empty() { - w::avatar(ui, "N", 44.0, 6); + // Key still generating: a fixed-seed gradient placeholder. + w::gradient_avatar(ui, "goblin", 44.0); } else { w::gradient_avatar(ui, &npub, 44.0); } @@ -783,7 +802,9 @@ impl OnboardingContent { ); } ui.label( - RichText::new(if connected { + // Relay-gated readiness: "connected over Nym" only once a + // relay is actually live, not merely when the tunnel is warm. + RichText::new(if crate::nym::transport_ready() { t!("goblin.onboarding.identity.connected_nym") } else { t!("goblin.onboarding.identity.connecting_nym") diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index e9fc7cb..ec8e0b1 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -76,7 +76,6 @@ enum ScanTab { struct Recipient { name: String, npub: String, - hue: usize, /// Recipient relay hints (nprofile / NIP-05 resolution), extra delivery /// targets for a recipient whose kind 10050 isn't discoverable yet. relay_hints: Vec, @@ -87,7 +86,6 @@ struct Recipient { struct Candidate { name: String, npub: String, - hue: usize, /// Known contact, resolved goblin handle, or has a published nostr /// profile. Unverified = a syntactically valid key with no profile. verified: bool, @@ -180,11 +178,9 @@ impl Default for SendFlow { impl SendFlow { /// Pre-fill a contact and skip to amount entry. pub fn prefill_contact(&mut self, name: String, npub: String) { - let hue = data::hue_of(&npub); self.recipient = Some(Recipient { name, npub, - hue, relay_hints: vec![], }); self.stage = Stage::Amount; @@ -473,7 +469,7 @@ impl SendFlow { let peers = recent_peers(wallet, 20); let texs: Vec> = peers .iter() - .map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name)) + .map(|(name, _)| tex_for(avatars, ui.ctx(), wallet, name)) .collect(); ScrollArea::vertical() .auto_shrink([false; 2]) @@ -486,16 +482,16 @@ impl SendFlow { .color(t.text_dim), ); } - for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) { + for ((name, npub), tex) in peers.into_iter().zip(texs.iter()) { if w::activity_row( ui, &name, &data::full_npub(&npub), - hue, &npub, "", false, false, + false, tex.as_ref(), ) .clicked() @@ -503,7 +499,6 @@ impl SendFlow { self.pick(Candidate { name, npub, - hue, verified: true, tag: "", relay_hints: vec![], @@ -517,10 +512,9 @@ impl SendFlow { // Type-ahead results: instant local matches + the network candidate. let mut cands: Vec = search_contacts(wallet, &query, 6) .into_iter() - .map(|(name, hue, npub)| Candidate { + .map(|(name, npub)| Candidate { name, npub, - hue, verified: true, tag: "contact", relay_hints: vec![], @@ -555,11 +549,11 @@ impl SendFlow { ui, &c.name, &tag, - c.hue, &c.npub, "", false, false, + false, tex.as_ref(), ) .clicked() @@ -599,7 +593,6 @@ impl SendFlow { self.recipient = Some(Recipient { name: cand.name, npub: cand.npub, - hue: cand.hue, relay_hints: cand.relay_hints, }); let preset = amount_from_hr_string(&self.amount) @@ -826,20 +819,16 @@ impl SendFlow { // Valid key → confirm it's a live identity via its kind-0 profile. self.looking_up = true; let service = wallet.nostr_service(); - let known = wallet.nostr_service().and_then(|s| { - s.store - .contact(&hex) - .map(|c| (display_name(&c), c.hue as usize)) - }); + let known = wallet + .nostr_service() + .and_then(|s| s.store.contact(&hex).map(|c| display_name(&c))); std::thread::spawn(move || { - let hue = data::hue_of(&hex); let profile = service.and_then(|s| s.fetch_profile_blocking(&hex, &key_hints)); let res = match (known, profile) { // Already a saved contact — trust it. - (Some((name, hue)), _) => LookupResult::Found(Candidate { + (Some(name), _) => LookupResult::Found(Candidate { name, npub: hex, - hue, verified: true, tag: "contact", relay_hints: key_hints, @@ -854,7 +843,6 @@ impl SendFlow { LookupResult::Found(Candidate { name, npub: hex, - hue, verified: true, tag: "on nostr", relay_hints: key_hints, @@ -863,7 +851,6 @@ impl SendFlow { (None, None) => LookupResult::Unverified(Candidate { name: short_npub(&hex), npub: hex, - hue, verified: false, tag: "", relay_hints: key_hints, @@ -893,7 +880,6 @@ impl SendFlow { LookupResult::Found(Candidate { name: display, npub: hex.clone(), - hue: data::hue_of(&hex), // A successful NIP-05 resolution (home OR a named foreign // authority) is verified — the user typed a specific // handle and the domain is shown, so no bare-key gate. @@ -953,7 +939,6 @@ impl SendFlow { &recipient.name, &recipient.npub, 28.0, - recipient.hue, chip_tex.as_ref(), ); ui.add_space(8.0); @@ -1006,13 +991,16 @@ impl SendFlow { // above it means the pad stays visible and tappable, instead of being // hidden behind the keyboard (the old order trapped you in the note). let note_focused = ui.ctx().memory(|m| m.has_focus(note_id)); - if !View::is_desktop() { - if w::numpad(ui, &mut self.amount, cb) { - // Tapping the pad means you're back on the amount — drop the note's - // focus so its keyboard goes away. - ui.ctx().memory_mut(|m| m.surrender_focus(note_id)); - } - } else if !note_focused { + // The send column is capped at 480 by `centered_column`, so the old + // `< 700` width gate was always narrow and the typed branch dead (same + // fix as pay_ui, so both amount screens match): show the pad and accept + // typed digits alongside it. + if w::numpad(ui, &mut self.amount, cb) { + // Tapping the pad means you're back on the amount — drop the note's + // focus so its keyboard goes away. + ui.ctx().memory_mut(|m| m.surrender_focus(note_id)); + } + if !note_focused { // Only consume keystrokes for the amount when the note field is // not focused, so typing a note doesn't also edit the amount. w::amount_typed_input(ui, &mut self.amount); @@ -1054,13 +1042,12 @@ impl SendFlow { let valid = amount_from_hr_string(&self.amount) .map(|a| a > 0) .unwrap_or(false); - ui.add_enabled_ui(valid, |ui| { - if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() { - if over { - cb.vibrate_error(); - } else { - self.stage = Stage::Review; - } + // Greyed out while over balance, matching the red guard above; the + // `!over` in the click also refuses it in case the disabled state is + // ever bypassed. + ui.add_enabled_ui(valid && !over, |ui| { + if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() && !over { + self.stage = Stage::Review; } }); false @@ -1122,7 +1109,6 @@ impl SendFlow { &recipient.name, &recipient.npub, 40.0, - recipient.hue, hero_tex.as_ref(), ); ui.add_space(6.0); @@ -1172,7 +1158,7 @@ impl SendFlow { wallet.task(WalletTask::CalculateFee(amount_nano, 0)); } let fee_val = match wallet.calculated_fee(amount_nano) { - Some(fee) => format!("{} {}", w::amount_str(fee), w::TSU), + Some(fee) => format!("{}{}", w::amount_str(fee), w::TSU), None => { // Result lands on a worker thread; poll until it does. ui.ctx() diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 158b10f..e5508a1 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -27,36 +27,67 @@ pub fn amount_str(atomic: u64) -> String { grin_core::core::amount_to_hr_string(atomic, true) } -/// Draw a colored avatar puck with the contact initial. -pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response { +/// A custom-picture avatar: the texture drawn in a circle, wrapped by a thin +/// conic-gradient ring derived deterministically from the username (the name, +/// not the npub). The image is inset so the ring sits at the perimeter. +pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, name: &str, size: f32) -> Response { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - let (bg, ink) = theme::avatar_pair(hue); - ui.painter().circle_filled(rect.center(), size / 2.0, bg); - // First letter of the name — never the @ prefix or other decoration. - let initial = name - .chars() - .find(|c| c.is_alphanumeric()) - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_else(|| "?".to_string()); - ui.painter().text( + let thickness = (size * 0.06).max(1.0); + let gap = (size * 0.03).max(1.0); + let img_rect = rect.shrink(thickness + gap); + let rounding = eframe::epaint::CornerRadius::same((img_rect.width() / 2.0) as u8); + egui::Image::new(tex) + .corner_radius(rounding) + .fit_to_exact_size(img_rect.size()) + .paint_at(ui, img_rect); + conic_ring( + ui, rect.center(), - egui::Align2::CENTER_CENTER, - initial, - FontId::new(size * 0.42, fonts::bold()), - ink, + size / 2.0 - thickness / 2.0, + thickness, + name, ); resp } -/// A custom-picture avatar: the texture drawn in a circle. -pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response { - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - let rounding = eframe::epaint::CornerRadius::same((size / 2.0) as u8); - egui::Image::new(tex) - .corner_radius(rounding) - .fit_to_exact_size(Vec2::splat(size)) - .paint_at(ui, rect); - resp +/// Thin conic-gradient ring at the avatar perimeter, hue path seeded by the +/// username (see `identicon::ring_params`). Drawn as a feathered triangle mesh +/// (~64 segments, per-vertex color) so edges stay smooth; a triangle-wave hue +/// sweep keeps the gradient seamless where the circle closes. No new deps. +fn conic_ring(ui: &Ui, center: egui::Pos2, r_mid: f32, thickness: f32, name: &str) { + use eframe::epaint::{Mesh, Shape, Vertex, WHITE_UV}; + let (base_hue, sweep) = super::identicon::ring_params(name); + const SEGS: u32 = 64; + const FEATHER: f32 = 0.75; + let r_in = r_mid - thickness / 2.0; + let r_out = r_mid + thickness / 2.0; + let radii = [r_out + FEATHER, r_out, r_in, (r_in - FEATHER).max(0.0)]; + let alphas = [0u8, 255, 255, 0]; + let mut mesh = Mesh::default(); + for i in 0..=SEGS { + let frac = i as f32 / SEGS as f32; + let theta = frac * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2; + let wave = 1.0 - (2.0 * frac - 1.0).abs(); + let hue = (base_hue + sweep * f64::from(wave)) % 360.0; + let (r, g, b) = super::identicon::hsl_rgb8(hue, 0.62, 0.55); + let dir = Vec2::new(theta.cos(), theta.sin()); + for (radius, alpha) in radii.iter().zip(alphas) { + mesh.vertices.push(Vertex { + pos: center + dir * *radius, + uv: WHITE_UV, + color: Color32::from_rgba_unmultiplied(r, g, b, alpha), + }); + } + } + for i in 0..SEGS { + let a = i * 4; + let b = a + 4; + for ring in 0..3 { + let (p0, p1, p2, p3) = (a + ring, a + ring + 1, b + ring, b + ring + 1); + mesh.indices.extend_from_slice(&[p0, p1, p2, p1, p3, p2]); + } + } + ui.painter().add(Shape::mesh(mesh)); } /// Deterministic gradient avatar (a pubkey-seeded two-tone tile with the Grin @@ -65,80 +96,66 @@ pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response /// the same avatar (see [`super::identicon`]). Cached per-pubkey by egui. pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - let hex = super::identicon::to_hex_seed(id); - // Rasterize at 2x for crispness; egui caches the texture by the `uri`, so the - // SVG is generated/rasterized once per pubkey regardless of frames or size. - let svg = super::identicon::gradient_avatar_svg(&hex, (size * 2.0) as u32, ""); - let uri = format!("bytes://gobavatar-{}-{}.svg", hex, size as u32); - egui::Image::new(egui::ImageSource::Bytes { - uri: uri.into(), - bytes: svg.into_bytes().into(), - }) - .corner_radius(CornerRadius::same((size / 2.0) as u8)) - .fit_to_exact_size(Vec2::splat(size)) - .paint_at(ui, rect); + paint_gradient(ui, id, rect); resp } -/// A named user's avatar: the same pubkey-seeded gradient background as -/// [`gradient_avatar`], but with the person's initial painted on top (white with -/// a faint dark shadow for legibility on any hue) instead of the Grin mark. `id` -/// seeds the gradient; `name` supplies the letter. -pub fn gradient_letter_avatar(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response { +/// The UNCHANGED npub-seeded grinmark gradient with a thin conic ring seeded by +/// the USERNAME simply added around its edge — the gradient stays exactly as a +/// ring-less avatar draws it; the ring is the only thing the username adds. +pub fn gradient_avatar_ringed(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response { let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - let hex = super::identicon::to_hex_seed(id); - let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32); - let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32); - egui::Image::new(egui::ImageSource::Bytes { - uri: uri.into(), - bytes: svg.into_bytes().into(), - }) - .corner_radius(CornerRadius::same((size / 2.0) as u8)) - .fit_to_exact_size(Vec2::splat(size)) - .paint_at(ui, rect); - // Initial — first alphanumeric of the name, never the @ prefix. - let initial = name - .chars() - .find(|c| c.is_alphanumeric()) - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_else(|| "?".to_string()); - let font = FontId::new(size * 0.46, fonts::bold()); - let c = rect.center(); - ui.painter().text( - c + Vec2::splat(size * 0.03), - egui::Align2::CENTER_CENTER, - &initial, - font.clone(), - Color32::from_black_alpha(80), - ); - ui.painter().text( - c, - egui::Align2::CENTER_CENTER, - &initial, - font, - Color32::from_rgb(0xFA, 0xFA, 0xF7), + paint_gradient(ui, id, rect); + let thickness = (size * 0.06).max(1.0); + conic_ring( + ui, + rect.center(), + size / 2.0 - thickness / 2.0, + thickness, + name, ); resp } -/// Picture avatar when a texture exists; otherwise the deterministic -/// pubkey-seeded gradient: with the Grin mark for an anonymous key (display name -/// is an `npub…`), or with the person's initial for a named contact/@handle. A -/// flat lettered tile is the last resort when no pubkey is known. `id` is the -/// npub/hex used to seed the gradient. +/// Paint the pubkey-seeded grinmark gradient into `rect` (rasterized at 2x, +/// cached by egui via the `uri`). +fn paint_gradient(ui: &mut Ui, id: &str, rect: egui::Rect) { + let hex = super::identicon::to_hex_seed(id); + let px = (rect.width() * 2.0) as u32; + let svg = super::identicon::gradient_avatar_svg(&hex, px, ""); + let uri = format!("bytes://gobavatar-{}-{}.svg", hex, rect.width() as u32); + egui::Image::new(egui::ImageSource::Bytes { + uri: uri.into(), + bytes: svg.into_bytes().into(), + }) + .corner_radius(CornerRadius::same((rect.width() / 2.0) as u8)) + .fit_to_exact_size(rect.size()) + .paint_at(ui, rect); +} + +/// Picture avatar (with the username conic ring) when a texture exists; +/// otherwise the deterministic pubkey-seeded grinmark gradient for everyone, +/// named or anonymous. When no pubkey is known (last resort) the name seeds the +/// gradient instead, so the tile is still deterministic. `id` is the npub/hex +/// used to seed the gradient. pub fn avatar_any( ui: &mut Ui, name: &str, id: &str, size: f32, - hue: usize, tex: Option<&egui::TextureHandle>, ) -> Response { + // A conic ring (seeded by the username) marks a CLAIMED identity — on a + // custom picture or the grinmark gradient. Anonymous keys (the display name + // is still an `npub…`) stay ring-less. + let named = !name.is_empty() && !name.starts_with("npub"); match tex { - Some(t) => avatar_tex(ui, t, size), - None if name.starts_with("npub") && !id.is_empty() => gradient_avatar(ui, id, size), - None if !id.is_empty() => gradient_letter_avatar(ui, id, name, size), - None => avatar(ui, name, size, hue), + Some(t) => avatar_tex(ui, t, name, size), + None if named => { + gradient_avatar_ringed(ui, if id.is_empty() { name } else { id }, name, size) + } + None if !id.is_empty() => gradient_avatar(ui, id, size), + None => gradient_avatar(ui, name, size), } } @@ -309,12 +326,20 @@ pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response { let t = theme::tokens(); let desired = Vec2::new(ui.available_width(), 56.0); let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); - let (fill, ink, stroke) = if secondary { + let (mut fill, mut ink, mut stroke) = if secondary { (Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line)) } else { (t.accent, t.accent_ink, Stroke::NONE) }; - let visual_fill = if resp.hovered() && !secondary { + // Inside `add_enabled_ui(false)` the button must LOOK disabled too, so a + // blocked action (e.g. Review while over balance) never reads as a live CTA. + let enabled = ui.is_enabled(); + if !enabled { + fill = fill.gamma_multiply(0.35); + ink = ink.gamma_multiply(0.45); + stroke.color = stroke.color.gamma_multiply(0.45); + } + let visual_fill = if enabled && resp.hovered() && !secondary { t.accent_dark } else { fill @@ -446,30 +471,6 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { resp } -/// An outline pill chip (transparent fill, line border) per the design's -/// amount quick-select row. -pub fn chip_outline(ui: &mut Ui, label: &str) -> Response { - let t = theme::tokens(); - let galley = ui.painter().layout_no_wrap( - label.to_string(), - FontId::new(13.0, fonts::semibold()), - t.text, - ); - let pad = Vec2::new(14.0, 8.0); - let size = galley.size() + pad * 2.0; - let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); - ui.painter().rect( - rect, - CornerRadius::same(255), - Color32::TRANSPARENT, - Stroke::new(1.0, t.line), - egui::StrokeKind::Inside, - ); - ui.painter() - .galley(rect.center() - galley.size() / 2.0, galley, t.text); - resp -} - /// Paint a QR code for `text` with the goblin mark centered. Always dark modules /// on a white plate, whatever the theme — inverted codes fail to decode in many /// scanners. Encoded synchronously each frame; modules are plain painter rects. @@ -536,7 +537,17 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) { } /// A balance hero block: kicker, big number + ツ, optional fiat line. -pub fn balance_hero(ui: &mut Ui, total: u64, spendable: u64, fiat: Option<&str>, size: f32) { +/// `updating` marks a zero balance that is only zero because funds are in +/// flight or the first sync is still running. +pub fn balance_hero( + ui: &mut Ui, + total: u64, + spendable: u64, + updating: bool, + sync_pct: u8, + fiat: Option<&str>, + size: f32, +) { let t = theme::tokens(); // Headline is the TOTAL the wallet holds — same number GRIM shows — so a // wallet mid-confirmation doesn't look empty. @@ -562,6 +573,25 @@ pub fn balance_hero(ui: &mut Ui, total: u64, spendable: u64, fiat: Option<&str>, ); }); } + // A fresh sync or funds in flight leave a stark 0 that reads as "funds + // vanished" — say the balance is still updating, with the scan % when the + // node reports one (1..99), so the user sees progress without opening the + // wallet switcher. + if total == 0 && updating { + let label = if (1..100).contains(&sync_pct) { + format!("{} {sync_pct}%", t!("goblin.home.balance_updating")) + } else { + t!("goblin.home.balance_updating").to_string() + }; + ui.add_space(4.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(12.5, fonts::medium())) + .color(t.text_dim), + ); + }); + } if let Some(fiat) = fiat { ui.add_space(4.0); ui.vertical_centered(|ui| { @@ -580,10 +610,10 @@ pub fn activity_row( ui: &mut Ui, title: &str, subtitle: &str, - hue: usize, id: &str, amount: &str, incoming: bool, + canceled: bool, system: bool, tex: Option<&egui::TextureHandle>, ) -> Response { @@ -614,7 +644,7 @@ pub fn activity_row( t.text, ); } else { - avatar_any(ui, title, id, 40.0, hue, tex); + avatar_any(ui, title, id, 40.0, tex); } ui.add_space(12.0); ui.vertical(|ui| { @@ -639,10 +669,18 @@ pub fn activity_row( ); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + // A canceled tx delivered no funds: mute the amount so it never + // reads as a completed green credit (or a real debit). ui.label( RichText::new(amount) .font(FontId::new(15.0, fonts::mono_semibold())) - .color(if incoming { t.pos } else { t.text }), + .color(if canceled { + t.text_dim + } else if incoming { + t.pos + } else { + t.text + }), ); }); }); @@ -884,12 +922,6 @@ pub fn apply_key(amount: &mut String, key: &str) { } } -/// Paint a full-rect background fill on the current panel. -pub fn fill_bg(ui: &Ui, color: Color32) { - let rect = ui.ctx().screen_rect(); - ui.painter().rect_filled(rect, CornerRadius::ZERO, color); -} - /// Center a fixed-width column for narrow content on wide screens. /// Hands the child the full remaining height: wrapping in `horizontal()` /// would start the row a single line tall, so a `ScrollArea` inside would @@ -981,11 +1013,3 @@ impl HoldToSend { false } } - -/// Shorten a long key/address for display (8…6). -pub fn short_key(key: &str) -> String { - if key.len() <= 16 { - return key.to_string(); - } - format!("{}…{}", &key[..8], &key[key.len() - 6..]) -} diff --git a/src/gui/views/network/content.rs b/src/gui/views/network/content.rs index 7d2cc03..629c82f 100644 --- a/src/gui/views/network/content.rs +++ b/src/gui/views/network/content.rs @@ -349,7 +349,7 @@ impl NetworkContent { } /// Content to draw when node is disabled. -fn disabled_node_ui(ui: &mut egui::Ui) { +pub fn disabled_node_ui(ui: &mut egui::Ui) { View::center_content(ui, 156.0, |ui| { let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL); ui.label( diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index fe6313d..addc3f8 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -406,11 +406,11 @@ impl WalletsContent { } return false; } else if self.showing_wallet() { - // Go back at stack or close wallet. + // Go back at stack; on Home with nothing open, back is a no-op. + // Leaving the wallet is intentional-only (switch/lock controls), + // never a back fallback to the chooser. if self.wallet_content.can_back() { self.wallet_content.back(cb); - } else { - self.wallets.select(None); } return false; } @@ -549,10 +549,10 @@ impl WalletsContent { }); } else if show_wallet && !dual_panel { View::title_button_big(ui, ARROW_LEFT, |_| { + // Same rule as system back: never fall back to the + // chooser; on Home the arrow is a no-op. if self.wallet_content.can_back() { self.wallet_content.back(cb); - } else { - self.wallets.select(None); } }); } else if self.creating_wallet() { diff --git a/src/gui/views/wallets/wallet/content.rs b/src/gui/views/wallets/wallet/content.rs index 58d5120..9da2728 100644 --- a/src/gui/views/wallets/wallet/content.rs +++ b/src/gui/views/wallets/wallet/content.rs @@ -83,6 +83,13 @@ impl WalletContentContainer for WalletContent { } fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + // Drawing this wallet means the app is foreground with the wallet + // on-screen: resume on-demand node polling right away so the balance + // goes live (the sync thread otherwise re-checks the foreground + // signal only once per cycle). No-op unless polling was paused. + if wallet.node_polling_paused() { + wallet.resume_node_polling(); + } // Goblin surface is the primary UI. Show a sync screen until data is // ready, then hand the whole surface to the payment-app-style view. let block_nav_goblin = self.block_navigation_on_sync(wallet); @@ -305,9 +312,11 @@ impl WalletContent { } } - /// Check if it's possible to go back at navigation stack. + /// Check if it's possible to go back at navigation stack. Delegates to the + /// goblin view too (overlays, settings sub-pages, non-Home tabs), so the + /// host never falls through to the wallet chooser on back. pub fn can_back(&self) -> bool { - self.goblin.overlay_active() || self.account_content.can_back() + self.goblin.can_back() || self.account_content.can_back() } /// Take the pending "switch wallet" request from the goblin settings, so the diff --git a/src/lib.rs b/src/lib.rs index e4073a2..662d3d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ mod http; pub mod logger; mod node; pub mod nostr; -mod nym; +pub mod nym; mod settings; mod wallet; @@ -123,7 +123,7 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe: if AppConfig::autostart_node() { Node::start(); } - // Pre-warm the in-process Nym mixnet client so price/NIP-05/nostr are ready at + // Pre-warm the in-process Nym mixnet tunnel so price/NIP-05/nostr are ready at // first use. All of Goblin's outbound traffic egresses through it; nothing // clearnet. nym::warm_up(); @@ -410,6 +410,21 @@ pub fn app_foreground() -> bool { last != 0 && now_unix_secs() - last <= FOREGROUND_STALE_SECS } +/// Fire the platform "payment received" notification with the payer's display +/// name and human-readable amount. Android shows a one-shot system +/// notification (`BackgroundService.notifyPaymentReceived`, id=2, separate +/// from the persistent sync notification); other platforms are a no-op. +/// Crate-root so the nostr service can reach it without holding a platform +/// reference. +pub fn notify_payment_received(name: &str, amount: &str) { + #[cfg(target_os = "android")] + gui::platform::notify_payment_received(name, amount); + #[cfg(not(target_os = "android"))] + { + let _ = (name, amount); + } +} + lazy_static! { /// Data provided from deeplink or opened file. pub static ref INCOMING_DATA: Arc>> = Arc::new(RwLock::new(None)); diff --git a/src/node/node.rs b/src/node/node.rs index 14fa1e2..04f6809 100644 --- a/src/node/node.rs +++ b/src/node/node.rs @@ -721,7 +721,15 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText( _class: jni::objects::JObject, _activity: jni::objects::JObject, ) -> jni::sys::jstring { - let status_text = Node::get_sync_status_text(); + // The keep-alive notification must reflect the real connection: on the + // external-node default the integrated node is deliberately off, so "Node + // is down" is wrong — the service's actual background job is the + // Nostr-over-Nym payment listen. + let status_text = if Node::is_running() || Node::is_starting() { + Node::get_sync_status_text() + } else { + t!("goblin.home.listening").into() + }; let j_text = _env.new_string(status_text); return j_text.unwrap().into_raw(); } @@ -736,7 +744,14 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncTitle( _class: jni::objects::JObject, _activity: jni::objects::JObject, ) -> jni::sys::jstring { - let j_text = _env.new_string(t!("network.node")); + // Match the status text: only title the notification "Node" when the + // integrated node is actually in use. + let title = if Node::is_running() || Node::is_starting() { + t!("network.node").to_string() + } else { + "Goblin".to_string() + }; + let j_text = _env.new_string(title); return j_text.unwrap().into_raw(); } diff --git a/src/nostr/client.rs b/src/nostr/client.rs index b689755..8a98a6e 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -15,10 +15,11 @@ //! Per-wallet nostr service: relay connections over the Nym mixnet, //! identity event publishing, the guarded ingest loop and the DM send path. +use grin_core::core::amount_to_hr_string; use log::{error, info, warn}; use nostr_sdk::{ Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification, - RelayStatus, Tag, TagKind, Timestamp, ToBech32, + RelayStatus, SubscriptionId, Tag, TagKind, Timestamp, ToBech32, }; use parking_lot::{Mutex, RwLock}; use std::collections::HashMap; @@ -32,6 +33,7 @@ use crate::nostr::ingest::{IngestContext, IngestDecision, decide}; use crate::nostr::protocol; use crate::nostr::relays::MAX_DM_RELAYS; use crate::nostr::types::*; +use crate::nostr::wrapv3; use crate::nostr::{NostrConfig, NostrIdentity, NostrStore}; use crate::nym::NymWebSocketTransport; use crate::wallet::Wallet; @@ -44,6 +46,12 @@ pub struct NostrProfile { pub nip05: Option, } +/// Stable subscription id for our kind:1059 gift-wrap inbox. Reusing ONE id +/// (rather than a fresh random id per (re)subscribe) means re-establishing the +/// subscription after a tunnel reselect REPLACES it instead of piling up +/// duplicate REQs on the relays. +const GIFTWRAP_SUB: &str = "goblin-giftwrap"; + /// Subscription look-back window beyond the last connection time: gift wrap /// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days. const LOOKBACK_SECS: i64 = 3 * 86_400; @@ -360,8 +368,17 @@ impl NostrService { }); } - /// Current relay list. + /// Current relay list: a user-set nostr.toml override wins, otherwise the + /// per-identity sticky advertised set (Goblin relay + pool picks), with + /// the built-in defaults until one has been selected. pub fn relays(&self) -> Vec { + if let Some(over) = self.config.read().relays_override() { + return over; + } + let sticky = self.identity.read().dm_relays.clone(); + if !sticky.is_empty() { + return sticky; + } self.config.read().relays() } @@ -498,32 +515,15 @@ impl NostrService { let content = protocol::build_payment_content(slatepack); let tags = protocol::build_rumor_tags(note); - // Resolve receiver DM relays (kind 10050) with our relays as fallback. - let mut urls = self.fetch_dm_relays(&client, &receiver).await; - for r in relay_hints { - if !urls.contains(r) { - urls.push(r.clone()); - } - } - for r in self.relays() { - if !urls.contains(&r) { - urls.push(r); - } - } + let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await; // NIP-17 delivers to the RECIPIENT's relays, which may differ from ours; // dial any we don't already hold so the gift wrap actually reaches their // inbox (otherwise `send_*_to` errors "relay not found" / never arrives). connect_relays(&client, &urls).await; - let res = tokio::time::timeout( - SEND_TIMEOUT, - client.send_private_msg_to(urls, receiver, content, tags), - ) - .await - .map_err(|_| "send timeout".to_string())? - .map_err(|e| format!("send failed: {e}"))?; - Ok(res.val.to_hex()) + self.dispatch_dm(&client, urls, v3, receiver, content, tags) + .await } /// Dispatch a control DM that voids a pending request (a decline by the payer @@ -544,7 +544,61 @@ impl NostrService { let content = protocol::build_control_content(); let tags = protocol::build_control_tags(slate_id); - let mut urls = self.fetch_dm_relays(&client, &receiver).await; + let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await; + + connect_relays(&client, &urls).await; + + self.dispatch_dm(&client, urls, v3, receiver, content, tags) + .await + } + + /// Dispatch one gift-wrapped DM over the negotiated encryption: when the + /// recipient advertises `nip44_v3` the wrap is built by [`wrapv3::wrap`], + /// otherwise it goes through the unchanged nostr-sdk v2 path (best mutual + /// wins; absent capability = v2, so v2-only peers see no change). + async fn dispatch_dm( + &self, + client: &Client, + urls: Vec, + v3: bool, + receiver: PublicKey, + content: String, + tags: Vec, + ) -> Result { + let sent = if v3 { + let wrap = wrapv3::wrap(&self.keys, &receiver, content, tags)?; + tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(urls, &wrap)).await + } else { + tokio::time::timeout( + SEND_TIMEOUT, + client.send_private_msg_to(urls, receiver, content, tags), + ) + .await + }; + let res = sent + .map_err(|_| "send timeout".to_string())? + .map_err(|e| format!("send failed: {e}"))?; + Ok(res.val.to_hex()) + } + + /// Publish targets for one DM plus the negotiated NIP-44 v3 capability: + /// the recipient's advertised 10050 inbox (capped at 3) when they publish + /// one; otherwise the pragmatic fallback of nprofile relay hints plus our + /// own relay set (most Goblin peers share the Goblin relay). No extra + /// targets beyond that — wider fan-out adds metadata surface, not + /// deliverability. `true` means the recipient's 10050 `encryption` tag + /// advertises `nip44_v3`; no tag (or no 10050 at all) = v2 only. + async fn send_targets( + &self, + client: &Client, + receiver: &PublicKey, + relay_hints: &[String], + ) -> (Vec, bool) { + let (urls, v3) = self.fetch_dm_relays(client, receiver).await; + if !urls.is_empty() { + return (urls, v3); + } + let mut urls: Vec = vec![]; for r in relay_hints { if !urls.contains(r) { urls.push(r.clone()); @@ -555,51 +609,63 @@ impl NostrService { urls.push(r); } } - - connect_relays(&client, &urls).await; - - let res = tokio::time::timeout( - SEND_TIMEOUT, - client.send_private_msg_to(urls, receiver, content, tags), - ) - .await - .map_err(|_| "send timeout".to_string())? - .map_err(|e| format!("send failed: {e}"))?; - Ok(res.val.to_hex()) + (urls, v3) } - /// Fetch a contact's kind 10050 DM relay list from our relays. - async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> Vec { - // Use cached relays first. - if let Some(contact) = self.store.contact(&pk.to_hex()) { - if !contact.relays.is_empty() { - return contact.relays.into_iter().take(MAX_DM_RELAYS).collect(); + /// Fetch a contact's kind 10050 DM relay list plus their advertised + /// NIP-44 v3 capability (the `encryption` tag of the same event). Queries + /// our own relays AND the pool's discovery indexers — the recipient's + /// 10050 lives on their relays and the indexers, not necessarily on + /// anything we share. Both facts are cached on the contact together. + async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> (Vec, bool) { + // Use cached relays (and the capability learned with them) first. + if let Some(contact) = self.store.contact(&pk.to_hex()) + && !contact.relays.is_empty() + { + return ( + contact.relays.into_iter().take(MAX_DM_RELAYS).collect(), + contact.nip44_v3, + ); + } + let mut from = self.relays(); + for url in crate::nostr::pool::usable_discovery_relays().await { + if !from.contains(&url) { + from.push(url); } } + connect_relays(client, &from).await; let filter = Filter::new().kind(Kind::InboxRelays).author(*pk).limit(1); let mut out = vec![]; - if let Ok(events) = client.fetch_events(filter, FETCH_TIMEOUT).await { - if let Some(event) = events.first() { - for tag in event.tags.iter() { - let parts = tag.as_slice(); - if parts.first().map(|s| s.as_str()) == Some("relay") { - if let Some(url) = parts.get(1) { - if out.len() < MAX_DM_RELAYS { - out.push(url.trim_end_matches('/').to_string()); - } + let mut v3 = false; + if let Ok(events) = client.fetch_events_from(&from, filter, FETCH_TIMEOUT).await + && let Some(event) = events.first() + { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + match parts.first().map(|s| s.as_str()) { + Some("relay") => { + if let Some(url) = parts.get(1) + && out.len() < MAX_DM_RELAYS + { + out.push(url.trim_end_matches('/').to_string()); } } + Some("encryption") => { + v3 = wrapv3::peer_supports_v3(parts.get(1).map(|s| s.as_str())); + } + _ => {} } } } - // Cache discovered relays on the contact when present. - if !out.is_empty() { - if let Some(mut contact) = self.store.contact(&pk.to_hex()) { - contact.relays = out.clone(); - self.store.save_contact(&contact); - } + // Cache discovered relays + capability on the contact when present. + if !out.is_empty() + && let Some(mut contact) = self.store.contact(&pk.to_hex()) + { + contact.relays = out.clone(); + contact.nip44_v3 = v3; + self.store.save_contact(&contact); } - out + (out, v3) } /// Ensure a contact entry exists for a sender (auto-added as unknown). @@ -619,6 +685,7 @@ impl NostrService { nip05: None, nip05_verified_at: None, relays: vec![], + nip44_v3: false, hue, unknown: true, added_at: unix_time(), @@ -733,32 +800,22 @@ async fn run_service(svc: Arc, wallet: Wallet) { *svc.rt_handle.write() = Some(tokio::runtime::Handle::current()); // Mirror the configured name authority so resolution + display follow it. crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain()); - let relays = svc.relays(); - info!( - "nostr: starting service for {} with relays {:?}", - svc.npub(), - relays - ); let client = Client::builder() .signer(svc.keys.clone()) .websocket_transport(NymWebSocketTransport) .build(); - for relay in &relays { - if let Err(e) = client.add_relay(relay.clone()).await { - warn!("nostr: add relay {relay} failed: {e}"); - } - } - // Wait for the in-process Nym SOCKS5 proxy (:1080) before dialing relays. - // `warm_up()` starts it at launch, but a fast wallet-open can beat the cold - // mixnet bootstrap — and dialing before it's up drops every relay into - // nostr-sdk's backing-off reconnect, leaving the wallet on "Connecting…" long - // after the mixnet is actually ready. Once it's warm this returns immediately. + // Wait for the in-process Nym mixnet tunnel before any network work + // (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at + // launch, but a fast wallet-open can beat the cold mixnet bootstrap — and + // dialing before it's up drops every relay into nostr-sdk's backing-off + // reconnect, leaving the wallet on "Connecting…" long after the mixnet is + // actually ready. Once it's warm this returns immediately. for i in 0..60u32 { - if nym_socks_ready().await { + if crate::nym::is_ready() { if i > 0 { info!( - "nostr: Nym proxy ready after ~{}ms, dialing relays", + "nostr: Nym tunnel ready after ~{}ms, dialing relays", i * 500 ); } @@ -766,6 +823,52 @@ async fn run_service(svc: Arc, wallet: Wallet) { } tokio::time::sleep(Duration::from_millis(500)).await; } + // We are now a relay consumer: arm nymproc's relay-reachability governance of + // exit health for our lifetime, so a DNS-ok-but-relay-dead exit gets + // condemned. Disarmed when the loop exits (see below), so plain HTTP-only + // usage of the tunnel never condemns an otherwise-healthy exit. + crate::nym::set_relay_consumer(true); + // Refresh the relay candidate pool cache (gist over Nym) when stale. + tokio::spawn(crate::nostr::pool::refresh_if_stale()); + // Select this identity's advertised relay set if it hasn't one yet. + ensure_advertised_set(&svc).await; + + let relays = svc.relays(); + info!( + "nostr: starting service for {} with relays {:?}", + svc.npub(), + relays + ); + // Prewarm mix-dns for the hosts we're about to (or will soon) hit — the + // relays being dialed, the NIP-05 name authority (Claim username), and the + // price API — so those resolutions are already cached by the time the user + // acts, rather than each paying a cold mixnet round trip inline. Runs off the + // critical path (a raced+retried resolve is cheap); the node host is NOT here + // — it never rides the mixnet. + if let Some(tunnel) = crate::nym::nymproc::tunnel() { + let mut hosts: Vec = relays + .iter() + .filter_map(|r| nostr_sdk::Url::parse(r).ok()) + .filter_map(|u| u.host_str().map(|h| h.to_string())) + .collect(); + hosts.push(svc.config.read().home_domain()); + hosts.push("api.coingecko.com".to_string()); + hosts.retain(|h| !h.is_empty()); + hosts.sort(); + hosts.dedup(); + tokio::spawn(async move { + crate::nym::dns::prewarm(&tunnel, &hosts).await; + }); + } + for relay in &relays { + if let Err(e) = client.add_relay(relay.clone()).await { + warn!("nostr: add relay {relay} failed: {e}"); + } + } + // The tunnel generation these relays are being dialed on. If the exit is + // later reselected (generation bumped by nymproc), the status loop drops + // these now-dead sockets and re-dials through the fresh tunnel. + let mut dial_gen = crate::nym::tunnel_generation(); let connect_started = std::time::Instant::now(); client.connect().await; { @@ -778,6 +881,7 @@ async fn run_service(svc: Arc, wallet: Wallet) { { let client_probe = client.clone(); let svc_probe = svc.clone(); + let report_gen = dial_gen; tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_millis(250)).await; @@ -786,6 +890,11 @@ async fn run_service(svc: Arc, wallet: Wallet) { "nostr: first relay Connected ~{}ms after connect()", connect_started.elapsed().as_millis() ); + // FAST relay-live report: closes nymproc's relay-readiness + // window as soon as the exit is proven to carry relay traffic, + // independent of the up-to-30s catch-up fetch below (a slow + // catch-up must not get a good exit wrongly condemned). + crate::nym::report_relay_live(report_gen); return; } if svc_probe.shutdown.load(Ordering::SeqCst) @@ -804,7 +913,10 @@ async fn run_service(svc: Arc, wallet: Wallet) { // Publish identity events (kind 10050 DM relays; kind 0 only when named). publish_identity(&svc, &client).await; - // Catch-up + live subscription for our gift wraps. + // Catch-up + live subscription for our gift wraps — targeted at our OWN + // advertised set only. A pool-wide subscription would be inherited by + // relays added later for sends and discovery fan-out, handing them a REQ + // filter that names our pubkey as a listener. let since = svc .store .last_connected_at() @@ -816,13 +928,26 @@ async fn run_service(svc: Arc, wallet: Wallet) { .pubkey(svc.public_key()) .since(Timestamp::from_secs(since)); - if let Ok(events) = client.fetch_events(filter.clone(), FETCH_TIMEOUT).await { + if let Ok(events) = client + .fetch_events_from(&relays, filter.clone(), FETCH_TIMEOUT) + .await + { info!("nostr: catch-up fetched {} wraps", events.len()); for event in events.into_iter() { - handle_wrap(&svc, &wallet, &client, event).await; + handle_wrap(&svc, &wallet, event).await; } } - if let Err(e) = client.subscribe(filter, None).await { + // Stable-id subscription so a re-subscribe after a tunnel reselect replaces + // rather than duplicates it. Keep `filter` owned for that re-subscribe. + if let Err(e) = client + .subscribe_with_id_to( + &relays, + SubscriptionId::new(GIFTWRAP_SUB), + filter.clone(), + None, + ) + .await + { error!("nostr: subscribe failed: {e}"); } @@ -843,8 +968,14 @@ async fn run_service(svc: Arc, wallet: Wallet) { // Reflect the connection the moment we reach the loop instead of leaving the // UI on "Connecting…" until the first heartbeat — by now catch-up has run, so // a relay is typically already up. - svc.connected - .store(relays_connected(&client).await, Ordering::Relaxed); + let connected = relays_connected(&client).await; + svc.connected.store(connected, Ordering::Relaxed); + // Feed the relay-gated readiness signal so "Connected over Nym" reflects an + // actual connected+subscribed relay on THIS tunnel generation, not merely a + // warm tunnel — and so nymproc's relay-readiness window closes successfully. + if connected { + crate::nym::report_relay_live(dial_gen); + } let mut notifications = client.notifications(); // Poll connection state on a SHORT, INDEPENDENT interval. This used to live in @@ -870,7 +1001,7 @@ async fn run_service(svc: Arc, wallet: Wallet) { notification = notifications.recv() => { match notification { Ok(RelayPoolNotification::Event { event, .. }) => { - handle_wrap(&svc, &wallet, &client, *event).await; + handle_wrap(&svc, &wallet, *event).await; } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { @@ -880,8 +1011,28 @@ async fn run_service(svc: Arc, wallet: Wallet) { } } _ = status_tick.tick() => { - svc.connected - .store(relays_connected(&client).await, Ordering::Relaxed); + // A tunnel reselect (new exit) bumps the generation. The current + // relay sockets rode the now-dead exit, so drop them and re-dial + // through the fresh tunnel, re-establishing the kind:1059 + // subscription — a reselect thus transparently restores + // receive+send. (An individual relay bounce with the exit still + // healthy is left to nostr-sdk's own auto-reconnect + resubscribe.) + let generation = crate::nym::tunnel_generation(); + if generation != dial_gen { + info!("nostr: tunnel reselected (gen {dial_gen} -> {generation}); re-dialing relays over the new exit"); + redial_on_new_tunnel(&client, &relays, &filter).await; + dial_gen = generation; + } + let connected = relays_connected(&client).await; + svc.connected.store(connected, Ordering::Relaxed); + // Relay-gated readiness + exit-health feedback for THIS generation: + // a live relay closes/keeps-open nymproc's readiness window; all + // relays down for too long condemns the exit and reselects. + if connected { + crate::nym::report_relay_live(dial_gen); + } else { + crate::nym::report_relay_down(dial_gen); + } let now = unix_time(); if now - last_heartbeat >= 30 { last_heartbeat = now; @@ -922,6 +1073,9 @@ async fn run_service(svc: Arc, wallet: Wallet) { } } + // No longer a relay consumer: disarm relay-reachability governance so the + // idle tunnel isn't condemned for "no relay" once we stop dialing. + crate::nym::set_relay_consumer(false); { let mut w_client = svc.client.write(); *w_client = None; @@ -929,19 +1083,6 @@ async fn run_service(svc: Arc, wallet: Wallet) { client.disconnect().await; } -/// Quick, non-blocking check that the Nym SOCKS5 proxy is accepting -/// connections on its loopback port (i.e. the mixnet is ready to carry traffic). -async fn nym_socks_ready() -> bool { - matches!( - tokio::time::timeout( - Duration::from_millis(500), - tokio::net::TcpStream::connect(crate::nym::socks5_addr()), - ) - .await, - Ok(Ok(_)) - ) -} - /// Add + dial every relay in `urls` so a targeted send reaches relays we don't /// already hold (NIP-65/gossip: the recipient's relays may differ from ours). /// `add_relay` is idempotent and `try_connect_relay` returns once connected or @@ -960,6 +1101,33 @@ async fn connect_relays(client: &Client, urls: &[String]) { futures::future::join_all(dials).await; } +/// A tunnel reselect happened: the pool's relay sockets rode the now-dead exit. +/// Drop them and re-dial every required relay through the fresh tunnel, then +/// re-establish the kind:1059 gift-wrap subscription (same stable id → replaces, +/// never duplicates) so we never silently stop receiving. Bounded by +/// nostr-sdk's own connect timeouts — no busy loop; the generation-aware re-dial +/// is ours, the per-relay reconnect backoff is the pool's. +async fn redial_on_new_tunnel(client: &Client, relays: &[String], filter: &Filter) { + // Close the stale sockets so nostr-sdk re-dials through the current tunnel + // (the transport grabs the freshly-selected exit on each new connect). + client.disconnect().await; + for url in relays { + let _ = client.add_relay(url).await; + } + client.connect().await; + if let Err(e) = client + .subscribe_with_id_to( + relays, + SubscriptionId::new(GIFTWRAP_SUB), + filter.clone(), + None, + ) + .await + { + error!("nostr: re-subscribe after reselect failed: {e}"); + } +} + /// True when at least one relay has completed its handshake. async fn relays_connected(client: &Client) -> bool { client @@ -969,18 +1137,73 @@ async fn relays_connected(client: &Client) -> bool { .any(|r| r.status() == RelayStatus::Connected) } -/// Publish kind 10050 DM relay list and, for named identities, kind 0 metadata. +/// One-time advertised-set selection: the Goblin relay plus up to two pool +/// "dm" relays, weighted-random (vetted entries 3:1), each gated by a NIP-11 +/// probe at pick time so only relays about to be used are probed. Persisted +/// on the identity and sticky thereafter — no timer rotation, since 10050 +/// churn breaks payers' cached routing. A user relay override in nostr.toml +/// disables selection entirely. When no pool relay passes (e.g. offline), +/// nothing is persisted and the built-in defaults serve this session; +/// selection retries next start. +async fn ensure_advertised_set(svc: &Arc) { + use crate::nostr::pool; + use crate::nostr::relays::DEFAULT_RELAYS; + use rand::Rng; + if svc.config.read().relays_override().is_some() || !svc.identity.read().dm_relays.is_empty() { + return; + } + let goblin = DEFAULT_RELAYS[0]; + let candidates = pool::load().dm_relays(); + let order = pool::weighted_order(goblin, &candidates, |total| { + rand::rng().random_range(0..total.max(1)) + }); + let mut set = vec![goblin.to_string()]; + for url in order.into_iter().skip(1) { + if set.len() >= MAX_DM_RELAYS { + break; + } + if pool::probe(&url).await { + set.push(url); + } + } + if set.len() < 2 { + warn!("nostr: no pool relay passed vetting, keeping default relays for now"); + return; + } + info!("nostr: selected advertised relay set {:?}", set); + svc.identity.write().dm_relays = set; + svc.save_identity(); +} + +/// Publish the replaceable identity events — the kind 10050 DM relay list, +/// its kind 10002 (NIP-65) mirror, and kind 0 metadata for named identities — +/// to the advertised set, then fan the SAME events out to the pool's +/// discovery indexers so payers who share no relay with us can still find our +/// inbox list. The fan-out is additive and publish-only: we never subscribe +/// on discovery relays. async fn publish_identity(svc: &Arc, client: &Client) { - let relays = svc.relays(); - let dm_tags: Vec = relays + let advertised: Vec = svc.relays().into_iter().take(MAX_DM_RELAYS).collect(); + + let mut dm_tags: Vec = advertised .iter() - .take(MAX_DM_RELAYS) .map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()])) .collect(); - let builder = EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags); - if let Err(e) = client.send_event_builder(builder).await { - warn!("nostr: publish 10050 failed: {e}"); - } + // NIP-17 backward-compat extension: advertise our NIP-44 capabilities, + // space-separated best-first, so v3-aware senders pick v3 (G4). + dm_tags.push(Tag::custom( + TagKind::custom("encryption"), + [wrapv3::ENCRYPTION_CAPABILITY.to_string()], + )); + let mut builders = vec![ + EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags), + // The NIP-65 list mirrors the same set, unmarked (read + write). + EventBuilder::relay_list( + advertised + .iter() + .filter_map(|r| nostr_sdk::RelayUrl::parse(r).ok()) + .map(|u| (u, None)), + ), + ]; let (anonymous, nip05) = { let identity = svc.identity.read(); @@ -995,12 +1218,45 @@ async fn publish_identity(svc: &Arc, client: &Client) { .name(name) .nip05(nip05) .custom_field("goblin_accepts_requests", allow_requests); - let builder = EventBuilder::metadata(&metadata); - if let Err(e) = client.send_event_builder(builder).await { - warn!("nostr: publish kind 0 failed: {e}"); - } + builders.push(EventBuilder::metadata(&metadata)); } } + + // Sign each event ONCE so the advertised set and the indexers receive the + // same replaceable event, and sends stay targeted (a plain send would also + // hit whatever recipient relays happen to be connected). + let mut events = vec![]; + for builder in builders { + match client.sign_event_builder(builder).await { + Ok(event) => events.push(event), + Err(e) => warn!("nostr: identity event signing failed: {e}"), + } + } + for event in &events { + if let Err(e) = client.send_event_to(&advertised, event).await { + warn!("nostr: publish kind {} failed: {e}", event.kind); + } + } + + // Discovery fan-out off the caller's path: each indexer is gated by the + // lazy NIP-11 probe (over Nym) before use. + let client = client.clone(); + tokio::spawn(async move { + let targets: Vec = crate::nostr::pool::usable_discovery_relays() + .await + .into_iter() + .filter(|u| !advertised.contains(u)) + .collect(); + if targets.is_empty() { + return; + } + connect_relays(&client, &targets).await; + for event in &events { + if let Err(e) = client.send_event_to(&targets, event).await { + warn!("nostr: discovery publish kind {} failed: {e}", event.kind); + } + } + }); } /// A transaction in a terminal state never expires (already done or canceled). @@ -1157,7 +1413,7 @@ fn handle_request_void(svc: &Arc, wallet: &Wallet, slate_id: &str, } } -async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, event: Event) { +async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { // 0. Only gift wraps. if event.kind != Kind::GiftWrap { return; @@ -1178,8 +1434,10 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, if !svc.allow_global_unwrap() { return; } - // 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed). - let unwrapped = match client.unwrap_gift_wrap(&event).await { + // 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed), + // dispatched on the NIP-44 payload version byte: 0x02 = the unchanged + // nostr-sdk path, 0x03 = the nip44 crate (G4); anything else errors cleanly. + let unwrapped = match wrapv3::unwrap(&svc.keys, &event).await { Ok(u) => u, Err(_) => { svc.store.mark_processed(&wrap_id); @@ -1323,6 +1581,10 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, // Resolve the sender's @username so the receive shows their name in // activity, not a bare npub. svc.resolve_contact_identity(&sender_hex); + // A payment is arriving: un-pause on-demand node polling BEFORE the + // receive so confirmation tracking is never dropped — polling stays + // live until the tx confirms (see `maybe_pause_node_polling`). + wallet.resume_node_polling(); match wallet.nostr_receive(&slate) { Ok((_, reply_text)) => { // Record BEFORE dispatching the reply: crash here is @@ -1347,6 +1609,15 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, svc.store.mark_processed(&wrap_id); svc.store.mark_processed(&rumor_id); svc.store.mark_processed(&slate_marker); + // "Payment received" system notification (Android; no-op + // on desktop): payer's display name (or short npub) and + // the human-readable amount. + { + let name = + crate::gui::views::goblin::data::contact_title(&svc.store, &sender_hex); + let amount = amount_to_hr_string(slate.amount, true); + crate::notify_payment_received(&name, &amount); + } match svc .send_payment_dm(&sender_hex, &reply_text, None, &[]) .await @@ -1395,6 +1666,10 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, // @username so the completed request shows their name, not a bare npub. svc.ensure_contact(&sender_hex); svc.resolve_contact_identity(&sender_hex); + // Node work ahead (finalize + broadcast + confirm): un-pause + // on-demand node polling BEFORE it so confirmation tracking is + // never dropped. + wallet.resume_node_polling(); match wallet.nostr_finalize_post(&slate) { Ok(true) => { svc.store @@ -1456,6 +1731,7 @@ mod tests { nip05: Some("ada@goblin.st".to_string()), nip05_verified_at: Some(1000), relays: vec![], + nip44_v3: false, hue: 0, unknown: false, added_at: 1, diff --git a/src/nostr/config.rs b/src/nostr/config.rs index ff7eb2e..dda84e2 100644 --- a/src/nostr/config.rs +++ b/src/nostr/config.rs @@ -104,12 +104,16 @@ impl NostrConfig { } pub fn relays(&self) -> Vec { - self.relays - .clone() - .filter(|r| !r.is_empty()) + self.relays_override() .unwrap_or_else(|| DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect()) } + /// The relay list explicitly set by the user in nostr.toml, if any. An + /// override disables the per-identity advertised-set selection entirely. + pub fn relays_override(&self) -> Option> { + self.relays.clone().filter(|r| !r.is_empty()) + } + pub fn set_relays(&mut self, relays: Vec) { self.relays = Some(relays); self.save(); diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index 299561e..e4d5cb6 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -12,24 +12,24 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Per-wallet nostr identity: NIP-06 derived from the wallet mnemonic by -//! default (one seed restores money AND identity) or imported from an nsec. +//! Per-wallet nostr identity: a random standalone nsec (or an imported one), +//! deliberately independent of the wallet seed — the seed proves nothing about +//! the identity and cannot resurrect it; the nsec is its own backup. //! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password. use nostr_sdk::nips::nip44; use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity}; -use nostr_sdk::prelude::FromMnemonic; use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32}; use serde_derive::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -/// Where the keys came from. +/// Where the keys came from. The legacy NIP-06 `Derived` source is gone: a +/// pre-Build-8 `"source":"Derived"` file no longer parses, so `load()` returns +/// `None` and wallet init writes a fresh random identity (the wanted behavior; +/// binding a messaging identity to the money seed was the dangerous design). #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] pub enum IdentitySource { - /// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the - /// identity to the seed forever). - Derived, /// Imported nsec. Imported, /// Freshly generated random key, independent of the wallet seed: the @@ -42,8 +42,6 @@ pub enum IdentitySource { pub struct NostrIdentity { pub ver: u8, pub source: IdentitySource, - /// NIP-06 account index used for derivation. - pub derivation_account: u32, /// NIP-49 encrypted secret key (bech32 ncryptsec). pub ncryptsec: String, /// Public key, bech32 npub (plaintext so the UI can render pre-unlock). @@ -55,6 +53,12 @@ pub struct NostrIdentity { /// Previous npubs from key rotations (newest last), for reference. #[serde(default)] pub prev_npubs: Vec, + /// Advertised DM relays (kind 10050): the Goblin relay plus 1-2 pool + /// relays picked once for this identity and kept sticky — no timer + /// rotation, since 10050 churn breaks payers' cached routing. Empty until + /// the first service start selects them. + #[serde(default)] + pub dm_relays: Vec, } /// NIP-49 scrypt work factor (~64 MiB, interactive-grade). @@ -142,24 +146,6 @@ impl NostrIdentity { let _ = fs::remove_file(Self::path(nostr_dir)); } - /// Derive keys from a BIP-39 mnemonic phrase via NIP-06. - pub fn derive_keys(mnemonic: &str, account: u32) -> Result { - Keys::from_mnemonic_with_account(mnemonic, None, Some(account)) - .map_err(|e| IdentityError::Key(format!("{e}"))) - } - - /// Create a derived identity from the wallet mnemonic, encrypting the - /// secret key with the wallet password. - pub fn create_derived( - mnemonic: &str, - password: &str, - account: u32, - ) -> Result<(NostrIdentity, Keys), IdentityError> { - let keys = Self::derive_keys(mnemonic, account)?; - let identity = Self::from_keys(&keys, password, IdentitySource::Derived, account)?; - Ok((identity, keys)) - } - /// Build an identity from already-unlocked keys under a (possibly /// different) password — used when importing a backup that was exported /// under another wallet's password. @@ -167,15 +153,14 @@ impl NostrIdentity { keys: &Keys, password: &str, source: IdentitySource, - account: u32, ) -> Result { - Self::from_keys(keys, password, source, account) + Self::from_keys(keys, password, source) } /// Create a brand-new random identity, independent of the wallet seed. pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> { let keys = Keys::generate(); - let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?; + let identity = Self::from_keys(&keys, password, IdentitySource::Random)?; Ok((identity, keys)) } @@ -187,7 +172,7 @@ impl NostrIdentity { let secret = SecretKey::parse(nsec.trim()) .map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?; let keys = Keys::new(secret); - let identity = Self::from_keys(&keys, password, IdentitySource::Imported, 0)?; + let identity = Self::from_keys(&keys, password, IdentitySource::Imported)?; Ok((identity, keys)) } @@ -195,7 +180,6 @@ impl NostrIdentity { keys: &Keys, password: &str, source: IdentitySource, - account: u32, ) -> Result { let encrypted = EncryptedSecretKey::new( keys.secret_key(), @@ -214,12 +198,12 @@ impl NostrIdentity { Ok(NostrIdentity { ver: 1, source, - derivation_account: account, ncryptsec, npub, nip05: None, anonymous: true, prev_npubs: Vec::new(), + dm_relays: Vec::new(), }) } @@ -318,13 +302,7 @@ mod tests { let json = serde_json::to_string(&a).unwrap(); let parsed: NostrIdentity = serde_json::from_str(&json).unwrap(); let keys = parsed.unlock("old-pw").unwrap(); - let b = NostrIdentity::from_unlocked_keys( - &keys, - "new-pw", - parsed.source, - parsed.derivation_account, - ) - .unwrap(); + let b = NostrIdentity::from_unlocked_keys(&keys, "new-pw", parsed.source).unwrap(); assert_eq!(b.npub, a.npub); assert!(b.unlock("new-pw").is_ok()); assert!(b.unlock("old-pw").is_err()); @@ -364,21 +342,10 @@ mod tests { assert!(a.unlock("wrong").is_err()); } - // NIP-06 test vector: this mnemonic must derive this npub (account 0). - const NIP06_MNEMONIC: &str = - "leader monkey parrot ring guide accident before fence cannon height naive bean"; - const NIP06_NPUB: &str = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu"; - - #[test] - fn nip06_derivation_vector() { - let keys = NostrIdentity::derive_keys(NIP06_MNEMONIC, 0).unwrap(); - assert_eq!(keys.public_key().to_bech32().unwrap(), NIP06_NPUB); - } - #[test] fn encrypt_unlock_roundtrip() { - let (identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "hunter2", 0).unwrap(); - assert_eq!(identity.source, IdentitySource::Derived); + let (identity, keys) = NostrIdentity::create_random("hunter2").unwrap(); + assert_eq!(identity.source, IdentitySource::Random); assert!(identity.anonymous); let unlocked = identity.unlock("hunter2").unwrap(); assert_eq!(unlocked.public_key(), keys.public_key()); @@ -401,7 +368,7 @@ mod tests { fn identity_file_is_owner_only() { use std::os::unix::fs::PermissionsExt; let dir = std::env::temp_dir().join(format!("goblin-id-test-{}", std::process::id())); - let (identity, _) = NostrIdentity::create_derived(NIP06_MNEMONIC, "pw", 0).unwrap(); + let (identity, _) = NostrIdentity::create_random("pw").unwrap(); identity.save(&dir).unwrap(); let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap(); // The ncryptsec blob must never be group/world readable. @@ -415,7 +382,7 @@ mod tests { #[test] fn reencrypt_changes_password() { - let (mut identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "old", 0).unwrap(); + let (mut identity, keys) = NostrIdentity::create_random("old").unwrap(); identity.reencrypt("old", "new").unwrap(); assert!(identity.unlock("old").is_err()); assert_eq!( diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 663c311..0cc7dad 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -22,6 +22,7 @@ pub use types::*; pub mod config; pub use config::{AcceptPolicy, NostrConfig}; +pub mod pool; pub mod relays; mod store; @@ -33,6 +34,8 @@ pub use identity::{IdentitySource, NostrIdentity}; pub mod protocol; pub use protocol::*; +pub mod wrapv3; + pub mod ingest; pub use ingest::*; diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index 47a55cb..fc104a0 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -13,7 +13,7 @@ // limitations under the License. //! NIP-05 username resolution/verification and goblin.st registration, -//! all HTTP routed through the Nym mixnet (the local SOCKS5 proxy). Nothing +//! all HTTP routed through the Nym mixnet (the in-process smolmix tunnel). Nothing //! here touches clearnet. use base64::Engine; @@ -323,57 +323,6 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str } } -/// Upload a processed avatar PNG for an owned name. Returns the content -/// hash on success. NIP-98 payload hashing makes the request replay-proof. -pub async fn upload_avatar( - server: &str, - name: &str, - keys: &Keys, - png: Vec, -) -> Result { - let server = server.trim_end_matches('/'); - let url = format!("{}/api/v1/avatar/{}", server, urlencode(name)); - let Some(auth) = nip98_auth(keys, &url, "POST", Some(&png)) else { - return Err("couldn't sign the request".to_string()); - }; - let headers = vec![ - ("Authorization".to_string(), auth), - ( - "Content-Type".to_string(), - "application/octet-stream".to_string(), - ), - ]; - match nym::http_request_bytes("POST", url, Some(png), headers).await { - Some((201, raw)) => serde_json::from_slice::(&raw) - .ok() - .and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from)) - .ok_or_else(|| "unexpected server response".to_string()), - Some((429, _)) => Err("Avatar limit reached — try again tomorrow".to_string()), - Some((413, _)) => Err("Image too large".to_string()), - Some((422, _)) => Err("That file doesn't look like a usable image".to_string()), - Some((code, raw)) => Err(serde_json::from_slice::(&raw) - .ok() - .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)) - .unwrap_or_else(|| format!("server error ({code})"))), - None => Err("network unreachable".to_string()), - } -} - -/// Remove the avatar for an owned name. -pub async fn delete_avatar(server: &str, name: &str, keys: &Keys) -> Result<(), String> { - let server = server.trim_end_matches('/'); - let url = format!("{}/api/v1/avatar/{}", server, urlencode(name)); - let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else { - return Err("couldn't sign the request".to_string()); - }; - let headers = vec![("Authorization".to_string(), auth)]; - match nym::http_request_bytes("DELETE", url, None, headers).await { - Some((200, _)) => Ok(()), - Some((code, _)) => Err(format!("server error ({code})")), - None => Err("network unreachable".to_string()), - } -} - /// Public profile probe: `None` = network failure, `Some(None)` = name has /// no avatar (or no such name), `Some(Some(hash))` = avatar content hash. pub async fn fetch_profile(server: &str, name: &str) -> Option> { diff --git a/src/nostr/pool.rs b/src/nostr/pool.rs new file mode 100644 index 0000000..89172b8 --- /dev/null +++ b/src/nostr/pool.rs @@ -0,0 +1,535 @@ +// 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. + +//! Relay candidate pool: a maintained list of vetted public relays fetched +//! from the project gist over the Nym mixnet, cached on disk, with a pinned +//! copy compiled in for first-run/offline. Pool relays are gated LAZILY: a +//! NIP-11 probe (also over Nym) runs only right before a relay is actually +//! used — no background sweeps. + +use lazy_static::lazy_static; +use log::{info, warn}; +use parking_lot::RwLock; +use serde_derive::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crate::Settings; +use crate::nostr::types::unix_time; + +/// Raw gist URL serving the maintained candidate pool (schema v1). Fetched +/// UNSIGNED: authenticity rests on the gist account's public edit history. +/// TODO(signing): verify a maintainer signature (minisign or a signed nostr +/// event) before trusting a fetched pool. +const POOL_URL: &str = "https://gist.githubusercontent.com/2ro/79cd885540c88d074fe52f8388a3e5b4/raw/goblin-relay-pool.json"; + +/// Pool cache file name inside the app base dir (`~/.goblin`). +const CACHE_FILE: &str = "relay-pool.json"; + +/// Refresh the disk cache on start when older than this (7 days). +const CACHE_MAX_AGE_SECS: u64 = 7 * 86_400; + +/// NIP-11 probe results are reused for this long (24 h, in memory). +const PROBE_TTL_SECS: i64 = 24 * 3600; + +/// Per-probe cap: a dead relay must not stall the caller for the full mixnet +/// HTTP timeout — a failed probe just skips the relay this time. +const PROBE_TIMEOUT: Duration = Duration::from_secs(12); + +/// Gift-wrap size floor: a worst-case Goblin payment (30 KB slatepack) is a +/// ~66 KB event on the wire, so a DM relay must accept at least 128 KiB +/// messages for 2x headroom. The gist can only RAISE this, never lower it. +pub const MIN_MESSAGE_LENGTH: u64 = 131_072; + +/// NIP-59 backdates wrap timestamps up to 2 days; a relay whose +/// `created_at_lower_limit` is tighter than this rejects our wraps. +const MIN_BACKDATE_SECS: u64 = 172_800; + +/// Pinned fallback pool, byte-for-byte the gist contents, so first-run and +/// offline behave exactly like a fresh fetch. +const PINNED_POOL: &str = r#"{ + "version": 1, + "updated": "2026-07-02", + "notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. The optional per-relay 'exit' is that operator's co-located scoped Nym exit (Recipient address); wallets may reach the relay over the mixnet through it without public DNS.", + "min_message_length": 131072, + "relays": [ + { "url": "wss://relay.goblin.st", "roles": ["dm", "discovery"], "vetted": "2026-07-01", "exit": "4XPnpmFdieZBY1BM2jU9Qn915v5RGz58ywpgQhuFKBao.8NMrW1i4VaPhY6qhV7supid7P1YcWJ9mGZBKjGEuqN9U@B8bX5x5yKa7oQMCNioLS9seYwNCio3U9jYPxgCZoKjk5" }, + { "url": "wss://relay.primal.net", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://relay.0xchat.com", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://relay.snort.social", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://nostr.mom", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://nostr.oxtr.dev", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://relay.nostr.net", "roles": ["dm"], "vetted": "2026-07-01" }, + { "url": "wss://purplepag.es", "roles": ["discovery"], "vetted": "2026-07-01" }, + { "url": "wss://indexer.coracle.social", "roles": ["discovery"], "vetted": "2026-07-01" } + ] +}"#; + +/// One pool entry. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PoolRelay { + pub url: String, + /// Roles: "dm" (gift-wrap inbox duty) and/or "discovery" (indexer for the + /// replaceable identity events 0/10002/10050 — never a wrap target). + pub roles: Vec, + /// Last-vetted date; presence marks the entry as vetted. + #[serde(default)] + pub vetted: Option, + /// This relay operator's CO-LOCATED Nym exit address, when they run one (the + /// bundled floonet-rs / floonet-strfry `exit = true` feature). It is a Nym + /// `Recipient` (`.@`) for a SCOPED MixnetStream proxy + /// that forwards ONLY to this relay — so the wallet can reach the relay over + /// the mixnet WITHOUT public DNS and WITHOUT depending on a public IPR exit + /// (the anchor; see [`crate::nym::nymproc`]). Absent → this relay is reached + /// the old way (public-IPR smolmix + in-tunnel DoT). Carried in the pinned + /// pool so the money-path default relay's exit bootstraps OFFLINE, before any + /// network — breaking the chicken-and-egg of learning it over the very path + /// it is meant to replace. + #[serde(default)] + pub exit: Option, +} + +impl PoolRelay { + fn has_role(&self, role: &str) -> bool { + self.roles.iter().any(|r| r == role) + } +} + +/// The candidate pool (gist schema v1). +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct RelayPool { + pub version: u32, + pub updated: String, + pub min_message_length: u64, + pub relays: Vec, +} + +impl RelayPool { + /// Parse and validate a pool document; `None` for anything unusable so the + /// caller falls back rather than trusting a broken or hostile file. + pub fn parse(raw: &str) -> Option { + let pool: RelayPool = serde_json::from_str(raw).ok()?; + // Bound the probe/cache work a fetched file can demand. + if pool.version != 1 || pool.relays.is_empty() || pool.relays.len() > 64 { + return None; + } + Some(pool) + } + + /// Entries carrying the "dm" role. + pub fn dm_relays(&self) -> Vec { + self.relays + .iter() + .filter(|r| r.has_role("dm")) + .cloned() + .collect() + } + + /// Urls of entries carrying the "discovery" role. + pub fn discovery_relays(&self) -> Vec { + self.relays + .iter() + .filter(|r| r.has_role("discovery")) + .map(|r| r.url.clone()) + .collect() + } + + /// The operator's co-located Nym exit address for `url`, if the pool + /// advertises one (url compared modulo a trailing slash). `None` → reach the + /// relay over the public-IPR path as before. This is how the wallet learns + /// the anchor exit for its money-path relay (see [`PoolRelay::exit`]). + pub fn exit_for(&self, url: &str) -> Option { + let want = url.trim_end_matches('/'); + self.relays + .iter() + .find(|r| r.url.trim_end_matches('/') == want) + .and_then(|r| r.exit.clone()) + .filter(|e| !e.trim().is_empty()) + } + + /// Like [`Self::exit_for`], but keyed on the HOSTNAME — the HTTP dial site + /// ([`crate::nym::request_once`]) knows only `host`, never the relay's ws + /// URL. HTTPS to a host whose relay advertises a co-located exit (its + /// NIP-11 probe, in practice) rides that exit too. + pub fn exit_for_host(&self, host: &str) -> Option { + self.relays + .iter() + .find(|r| { + url::Url::parse(&r.url) + .ok() + .and_then(|u| u.host_str().map(|h| h.eq_ignore_ascii_case(host))) + .unwrap_or(false) + }) + .and_then(|r| r.exit.clone()) + .filter(|e| !e.trim().is_empty()) + } +} + +/// Disk path of the cached pool file. +fn cache_path() -> PathBuf { + Settings::config_path(CACHE_FILE, None) +} + +/// Current pool: the disk cache when present and valid, the pinned copy +/// otherwise. +pub fn load() -> RelayPool { + std::fs::read_to_string(cache_path()) + .ok() + .and_then(|raw| RelayPool::parse(&raw)) + .unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses")) +} + +/// Refresh the disk cache from the gist — over the Nym mixnet, like all other +/// HTTP — when it is absent or older than 7 days. At most one attempt per app +/// run; call only once the Nym tunnel is up. +pub async fn refresh_if_stale() { + static TRIED: AtomicBool = AtomicBool::new(false); + if TRIED.swap(true, Ordering::SeqCst) { + return; + } + let path = cache_path(); + let fresh = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.elapsed().ok()) + .map(|age| age.as_secs() < CACHE_MAX_AGE_SECS) + .unwrap_or(false); + if fresh { + return; + } + let Some(raw) = crate::nym::http_request("GET", POOL_URL.to_string(), None, vec![]).await + else { + warn!("relay pool: refresh fetch failed, keeping current pool"); + return; + }; + match RelayPool::parse(&raw) { + Some(pool) => { + if let Err(e) = std::fs::write(&path, &raw) { + warn!("relay pool: cache write failed: {e}"); + } else { + info!( + "relay pool: refreshed (v{}, {} relays, updated {})", + pool.version, + pool.relays.len(), + pool.updated + ); + } + } + None => warn!("relay pool: fetched file failed validation, keeping current pool"), + } +} + +lazy_static! { + /// Probe cache: url → (passed, checked_at unix secs). + static ref PROBES: RwLock> = RwLock::new(HashMap::new()); +} + +/// The NIP-11 gate: a pool relay is usable only when its info document does +/// not advertise a constraint that breaks gift-wrapped payments. Absent +/// fields pass (most relays publish sparse documents); `min_len` is the +/// message-size floor. +fn nip11_pass(doc: &serde_json::Value, min_len: u64) -> bool { + let lim = doc.get("limitation"); + let field = |k: &str| lim.and_then(|l| l.get(k)); + let off = |k: &str| !field(k).and_then(|v| v.as_bool()).unwrap_or(false); + // Our worst-case wrap must fit. + field("max_message_length") + .and_then(|v| v.as_u64()) + .map(|n| n >= min_len) + .unwrap_or(true) + // Free, open writes; phase 1 speaks no NIP-42 AUTH. + && off("payment_required") + && off("restricted_writes") + && off("auth_required") + // Must admit NIP-59's up-to-2-day backdated timestamps. + && field("created_at_lower_limit") + .and_then(|v| v.as_u64()) + .map(|n| n >= MIN_BACKDATE_SECS) + .unwrap_or(true) +} + +/// Lazy per-use probe: fetch the relay's NIP-11 document (HTTP over Nym, +/// `Accept: application/nostr+json`) and apply the gate. Results are cached +/// for 24 h; an unreachable or unparseable document fails, which just skips +/// the relay this time. +pub async fn probe(url: &str) -> bool { + let now = unix_time(); + if let Some(&(ok, at)) = PROBES.read().get(url) + && now - at < PROBE_TTL_SECS + { + return ok; + } + let http_url = url + .replacen("wss://", "https://", 1) + .replacen("ws://", "http://", 1); + let min_len = load().min_message_length.max(MIN_MESSAGE_LENGTH); + let headers = vec![("Accept".to_string(), "application/nostr+json".to_string())]; + let ok = tokio::time::timeout( + PROBE_TIMEOUT, + crate::nym::http_request("GET", http_url, None, headers), + ) + .await + .ok() + .flatten() + .and_then(|body| serde_json::from_str::(&body).ok()) + .map(|doc| nip11_pass(&doc, min_len)) + .unwrap_or(false); + if !ok { + info!("relay pool: NIP-11 gate failed for {url}, skipping"); + } + PROBES.write().insert(url.to_string(), (ok, now)); + ok +} + +/// The pool's "discovery" relays that pass the lazy NIP-11 gate right now. +pub async fn usable_discovery_relays() -> Vec { + let mut out = vec![]; + for url in load().discovery_relays() { + if probe(&url).await { + out.push(url); + } + } + out +} + +/// Weighted-random candidate ORDER for the advertised set: the Goblin relay +/// first, then every "dm" candidate exactly once, drawn without replacement +/// with vetted entries weighted 3:1. The caller walks the order and keeps the +/// first candidates that pass the NIP-11 gate, so only relays about to be +/// used are probed. `pick` receives the remaining total weight and returns a +/// roll below it (injectable for tests). +pub fn weighted_order( + goblin_relay: &str, + candidates: &[PoolRelay], + mut pick: impl FnMut(u64) -> u64, +) -> Vec { + let goblin = goblin_relay.trim_end_matches('/').to_string(); + let mut out = vec![goblin.clone()]; + let mut pool: Vec<(&PoolRelay, u64)> = candidates + .iter() + .filter(|r| r.url.trim_end_matches('/') != goblin) + .map(|r| (r, if r.vetted.is_some() { 3 } else { 1 })) + .collect(); + while !pool.is_empty() { + let total: u64 = pool.iter().map(|(_, w)| w).sum(); + let mut roll = pick(total) % total.max(1); + let idx = pool + .iter() + .position(|(_, w)| { + if roll < *w { + true + } else { + roll -= w; + false + } + }) + .unwrap_or(0); + out.push(pool.remove(idx).0.url.clone()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_pool_parses() { + let pool = RelayPool::parse(PINNED_POOL).expect("pinned pool must parse"); + assert_eq!(pool.version, 1); + assert_eq!(pool.min_message_length, MIN_MESSAGE_LENGTH); + assert_eq!(pool.relays.len(), 12); + let dm = pool.dm_relays(); + assert_eq!(dm.len(), 10); + assert!(dm.iter().any(|r| r.url == "wss://relay.goblin.st")); + assert!(dm.iter().all(|r| r.vetted.is_some())); + let disc = pool.discovery_relays(); + // relay.goblin.st carries both roles; the two indexers are discovery-only. + assert_eq!(disc.len(), 3); + assert!(disc.contains(&"wss://purplepag.es".to_string())); + assert!(disc.contains(&"wss://indexer.coracle.social".to_string())); + } + + #[test] + fn exit_field_is_optional_and_looked_up_by_url() { + // The pinned pool advertises the co-located scoped exit for the money-path + // relay; no other pinned entry carries one. + let pinned = RelayPool::parse(PINNED_POOL).unwrap(); + assert!(pinned.exit_for("wss://relay.goblin.st").is_some()); + assert!( + pinned + .relays + .iter() + .filter(|r| r.url.trim_end_matches('/') != "wss://relay.goblin.st") + .all(|r| r.exit.is_none()) + ); + + // A pool that DOES advertise an exit for one relay. + let pool = RelayPool::parse( + r#"{"version":1,"updated":"x","min_message_length":131072,"relays":[ + {"url":"wss://relay.goblin.st/","roles":["dm"],"exit":"aaa.bbb@ccc"}, + {"url":"wss://nos.lol","roles":["dm"]}, + {"url":"wss://blank.example","roles":["dm"],"exit":" "} + ]}"#, + ) + .unwrap(); + // Trailing-slash-insensitive lookup. + assert_eq!( + pool.exit_for("wss://relay.goblin.st"), + Some("aaa.bbb@ccc".to_string()) + ); + // No exit field → None; blank exit → None (treated as unset). + assert!(pool.exit_for("wss://nos.lol").is_none()); + assert!(pool.exit_for("wss://blank.example").is_none()); + // Unknown url → None. + assert!(pool.exit_for("wss://unknown.example").is_none()); + + // Host-keyed lookup (the HTTP dial site): same answers by hostname. + assert_eq!( + pool.exit_for_host("relay.goblin.st"), + Some("aaa.bbb@ccc".to_string()) + ); + assert_eq!( + pool.exit_for_host("RELAY.GOBLIN.ST"), + Some("aaa.bbb@ccc".to_string()) + ); + assert!(pool.exit_for_host("nos.lol").is_none()); + assert!(pool.exit_for_host("blank.example").is_none()); + assert!(pool.exit_for_host("unknown.example").is_none()); + } + + #[test] + fn pool_validation_rejects_bad_documents() { + assert!(RelayPool::parse("not json").is_none()); + assert!(RelayPool::parse("{}").is_none()); + // Wrong schema version. + assert!( + RelayPool::parse( + r#"{"version":2,"updated":"x","min_message_length":1, + "relays":[{"url":"wss://a","roles":["dm"]}]}"# + ) + .is_none() + ); + // Empty relay list. + assert!( + RelayPool::parse(r#"{"version":1,"updated":"x","min_message_length":1,"relays":[]}"#) + .is_none() + ); + // Unknown fields (like the gist's "notes") are tolerated; a missing + // "vetted" parses as unvetted. + let pool = RelayPool::parse( + r#"{"version":1,"updated":"x","notes":"n","min_message_length":131072, + "relays":[{"url":"wss://a","roles":["dm"]}]}"#, + ) + .unwrap(); + assert!(pool.relays[0].vetted.is_none()); + } + + fn doc(limitation: &str) -> serde_json::Value { + serde_json::from_str(&format!(r#"{{"name":"r","limitation":{limitation}}}"#)).unwrap() + } + + #[test] + fn nip11_gate_predicate() { + let min = MIN_MESSAGE_LENGTH; + // Sparse documents pass: absent limitation and absent fields. + assert!(nip11_pass(&serde_json::json!({}), min)); + assert!(nip11_pass(&doc("{}"), min)); + // Size floor. + assert!(nip11_pass(&doc(r#"{"max_message_length":131072}"#), min)); + assert!(nip11_pass(&doc(r#"{"max_message_length":1000000}"#), min)); + assert!(!nip11_pass(&doc(r#"{"max_message_length":65535}"#), min)); + // Paid / restricted / AUTH-gated relays fail; explicit false passes. + assert!(!nip11_pass(&doc(r#"{"payment_required":true}"#), min)); + assert!(!nip11_pass(&doc(r#"{"restricted_writes":true}"#), min)); + assert!(!nip11_pass(&doc(r#"{"auth_required":true}"#), min)); + assert!(nip11_pass( + &doc(r#"{"payment_required":false,"auth_required":false}"#), + min + )); + // created_at window must admit 2-day backdating. + assert!(nip11_pass( + &doc(r#"{"created_at_lower_limit":94608000}"#), + min + )); + assert!(!nip11_pass(&doc(r#"{"created_at_lower_limit":3600}"#), min)); + // One bad field fails the whole gate. + assert!(!nip11_pass( + &doc(r#"{"max_message_length":1000000,"payment_required":true}"#), + min + )); + } + + fn candidates() -> Vec { + let mk = |url: &str, vetted: bool| PoolRelay { + url: url.to_string(), + roles: vec!["dm".to_string()], + vetted: vetted.then(|| "2026-07-01".to_string()), + exit: None, + }; + vec![ + mk("wss://a.example", false), + mk("wss://b.example", true), + mk("wss://c.example", true), + ] + } + + #[test] + fn weighted_order_selection() { + // Goblin relay always first; every candidate appears exactly once. + let order = weighted_order("wss://relay.goblin.st", &candidates(), |_| 0); + assert_eq!(order[0], "wss://relay.goblin.st"); + assert_eq!(order.len(), 4); + for url in ["wss://a.example", "wss://b.example", "wss://c.example"] { + assert_eq!(order.iter().filter(|u| *u == url).count(), 1); + } + + // The goblin relay is never duplicated when it is also a pool entry. + let mut with_goblin = candidates(); + with_goblin.push(PoolRelay { + url: "wss://relay.goblin.st".to_string(), + roles: vec!["dm".to_string()], + vetted: Some("2026-07-01".to_string()), + exit: None, + }); + let order = weighted_order("wss://relay.goblin.st", &with_goblin, |_| 0); + assert_eq!(order.len(), 4); + assert_eq!( + order + .iter() + .filter(|u| *u == "wss://relay.goblin.st") + .count(), + 1 + ); + + // Weights: [a:1, b:3, c:3]. A roll of 0 lands on a (first weight + // bracket); a roll of 1 skips a's single unit and lands on vetted b. + let order = weighted_order("wss://g", &candidates(), |_| 1); + assert_eq!(order[1], "wss://b.example"); + // Total weight offered to the first draw is 1 + 3 + 3 = 7. + let mut seen_total = 0; + let _ = weighted_order("wss://g", &candidates(), |total| { + if seen_total == 0 { + seen_total = total; + } + 0 + }); + assert_eq!(seen_total, 7); + } +} diff --git a/src/nostr/types.rs b/src/nostr/types.rs index 39e0b88..fdc0829 100644 --- a/src/nostr/types.rs +++ b/src/nostr/types.rs @@ -97,6 +97,11 @@ pub struct Contact { pub nip05_verified_at: Option, /// Known DM relays (kind 10050) of the contact. pub relays: Vec, + /// The contact advertises NIP-44 v3 in the `encryption` tag of the same + /// kind 10050 the relays come from (NIP-17 backward-compat extension). + /// Absent tag = v2 only, hence the conservative default. + #[serde(default)] + pub nip44_v3: bool, /// Avatar palette index. pub hue: u8, /// Auto-added from an incoming payment, not yet confirmed by the user. diff --git a/src/nostr/wrapv3.rs b/src/nostr/wrapv3.rs new file mode 100644 index 0000000..1dc258c --- /dev/null +++ b/src/nostr/wrapv3.rs @@ -0,0 +1,296 @@ +// 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. + +//! NIP-44 v3 gift wrapping and the version-dispatched unwrap (the NIP-17 +//! backward-compat extension, plan G4). +//! +//! nostr-sdk's gift-wrap builders hardcode NIP-44 v2, so [`wrap`] constructs +//! the NIP-59 layers itself when the recipient advertises `nip44_v3`: +//! the seal (kind 13) carries the v3-encrypted rumor JSON with context +//! `kind=13`/scope `""`, the gift wrap (kind 1059, ephemeral key) carries the +//! v3-encrypted seal JSON with context `kind=1059`/scope `""`. Tags and +//! created_at fuzzing mirror nostr-sdk's v2 builders exactly. +//! +//! [`unwrap`] dispatches on the payload version byte: `0x02` goes through the +//! unchanged nostr-sdk path, `0x03` through the `nip44` crate — a v2-only +//! peer is completely unaffected. + +use nostr_sdk::nips::nip59::{self, UnwrappedGift}; +use nostr_sdk::{ + Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent, +}; + +/// The capability Goblin advertises in its kind 10050 `encryption` tag, +/// space-separated best-first (NIP-17 backward-compat extension). +pub const ENCRYPTION_CAPABILITY: &str = "nip44_v3 nip44_v2"; + +/// The token a peer's `encryption` tag must contain for us to send v3. +const V3_TOKEN: &str = "nip44_v3"; + +/// v3 context bound into the seal's ciphertext: the seal event kind, no scope. +const SEAL_CTX_KIND: u32 = 13; +/// v3 context bound into the gift wrap's ciphertext: the wrap event kind. +const WRAP_CTX_KIND: u32 = 1059; +/// Both layers use the empty scope. +const SCOPE: &[u8] = b""; + +/// True when a kind 10050 `encryption` tag value advertises NIP-44 v3. +/// `None` (no tag) = v2 only, per the extension. +pub fn peer_supports_v3(encryption: Option<&str>) -> bool { + encryption + .map(|v| v.split_whitespace().any(|t| t == V3_TOKEN)) + .unwrap_or(false) +} + +/// Derive the v3 conversation key between our secret key and a peer's +/// public key, bridging nostr-sdk's key types (secp256k1 0.29) to the nip44 +/// crate's (0.31) via their byte serializations. +fn conversation_key(secret: &nostr_sdk::SecretKey, public: &PublicKey) -> Result<[u8; 32], String> { + let sk = secp256k1::SecretKey::from_byte_array(secret.to_secret_bytes()) + .map_err(|e| format!("invalid secret key: {e}"))?; + let pk = secp256k1::XOnlyPublicKey::from_byte_array(*public.as_bytes()) + .map_err(|e| format!("invalid public key: {e}"))?; + Ok(nip44::get_conversation_key_v3(sk, pk)) +} + +/// Build a NIP-17 private-message gift wrap encrypted with NIP-44 v3. +/// Mirrors `EventBuilder::private_msg` (rumor shape, tags, created_at +/// fuzzing), with only the two encryption layers switched to v3. +pub fn wrap( + sender: &Keys, + receiver: &PublicKey, + content: String, + rumor_extra_tags: Vec, +) -> Result { + // Rumor: kind 14, receiver p-tag first, then the extra tags — the same + // shape `EventBuilder::private_msg_rumor` builds. Never signed (NIP-59). + let mut rumor: UnsignedEvent = EventBuilder::new(Kind::PrivateDirectMessage, content) + .tag(Tag::public_key(*receiver)) + .tags(rumor_extra_tags) + .build(sender.public_key()); + rumor.ensure_id(); + + // Seal (kind 13): v3-encrypted rumor JSON, context kind=13/scope "", + // signed by the sender, created_at fuzzed up to 2 days into the past + // exactly like nostr-sdk's v2 `make_seal`. + let ck = conversation_key(sender.secret_key(), receiver)?; + let sealed = nip44::encrypt_v3(&ck, rumor.as_json().as_bytes(), SEAL_CTX_KIND, SCOPE) + .map_err(|e| format!("v3 seal encrypt failed: {e}"))?; + let seal: Event = EventBuilder::new(Kind::Seal, sealed) + .custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK)) + .sign_with_keys(sender) + .map_err(|e| format!("seal signing failed: {e}"))?; + + // Gift wrap (kind 1059): one-time ephemeral key, v3-encrypted seal JSON, + // context kind=1059/scope "", canonical receiver p-tag and the same + // created_at fuzzing as nostr-sdk's `gift_wrap_from_seal`. + let ephemeral = Keys::generate(); + let ck = conversation_key(ephemeral.secret_key(), receiver)?; + let wrapped = nip44::encrypt_v3(&ck, seal.as_json().as_bytes(), WRAP_CTX_KIND, SCOPE) + .map_err(|e| format!("v3 wrap encrypt failed: {e}"))?; + EventBuilder::new(Kind::GiftWrap, wrapped) + .tag(Tag::public_key(*receiver)) + .custom_created_at(Timestamp::tweaked(nip59::RANGE_RANDOM_TIMESTAMP_TWEAK)) + .sign_with_keys(&ephemeral) + .map_err(|e| format!("wrap signing failed: {e}")) +} + +/// Unwrap a gift wrap addressed to `keys`, dispatching on the NIP-44 payload +/// version byte: v2 payloads go through the unchanged nostr-sdk path, v3 +/// through the nip44 crate. Unknown or malformed payloads error cleanly. +pub async fn unwrap(keys: &Keys, event: &Event) -> Result { + if event.kind != Kind::GiftWrap { + return Err("not a gift wrap".to_string()); + } + match nip44::payload_version(&event.content) { + Ok(3) => unwrap_v3(keys, event), + Ok(2) => UnwrappedGift::from_gift_wrap(keys, event) + .await + .map_err(|e| format!("v2 unwrap failed: {e}")), + Ok(v) => Err(format!("unsupported NIP-44 payload version {v}")), + Err(e) => Err(format!("undecodable NIP-44 payload: {e}")), + } +} + +/// The v3 leg of [`unwrap`]: decrypt the wrap (context 1059/""), verify the +/// seal's kind and signature, decrypt the seal (context 13/"") and enforce +/// the NIP-17 rumor-author == seal-signer rule, mirroring nostr-sdk's +/// `UnwrappedGift::from_gift_wrap`. +fn unwrap_v3(keys: &Keys, event: &Event) -> Result { + let ck = conversation_key(keys.secret_key(), &event.pubkey)?; + let seal_json = nip44::decrypt_v3(&ck, &event.content, WRAP_CTX_KIND, SCOPE) + .map_err(|e| format!("v3 wrap decrypt failed: {e}"))?; + let seal = Event::from_json(seal_json).map_err(|e| format!("seal parse failed: {e}"))?; + if seal.kind != Kind::Seal { + return Err("decrypted inner event is not a seal".to_string()); + } + seal.verify() + .map_err(|e| format!("seal signature invalid: {e}"))?; + + let ck = conversation_key(keys.secret_key(), &seal.pubkey)?; + let rumor_json = nip44::decrypt_v3(&ck, &seal.content, SEAL_CTX_KIND, SCOPE) + .map_err(|e| format!("v3 seal decrypt failed: {e}"))?; + let rumor = + UnsignedEvent::from_json(rumor_json).map_err(|e| format!("rumor parse failed: {e}"))?; + if rumor.pubkey != seal.pubkey { + return Err("rumor author differs from seal signer".to_string()); + } + Ok(UnwrappedGift { + sender: seal.pubkey, + rumor, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::nostr::protocol; + use base64::Engine; + + const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \ + pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT. \ + ENDSLATEPACK."; + + /// (a) v3 <-> v3: a payment gift wrap round-trips between two fresh + /// Goblin identities through the wrap + unwrap seam, no network. + #[tokio::test] + async fn v3_gift_wrap_round_trip() { + let alice = Keys::generate(); + let bob = Keys::generate(); + let content = protocol::build_payment_content(SLATEPACK); + let tags = protocol::build_rumor_tags(Some("lunch :)")); + + let wrap = wrap(&alice, &bob.public_key(), content.clone(), tags).unwrap(); + // Wire shape: kind 1059, signed by an EPHEMERAL key, receiver p-tag, + // v3 version byte, created_at not in the future. + assert_eq!(wrap.kind, Kind::GiftWrap); + assert_ne!(wrap.pubkey, alice.public_key()); + assert!(wrap.verify().is_ok()); + assert!(wrap.tags.public_keys().any(|pk| *pk == bob.public_key())); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&wrap.content) + .unwrap(); + assert_eq!(decoded[0], 0x03); + assert!(wrap.created_at <= Timestamp::now()); + + let unwrapped = unwrap(&bob, &wrap).await.unwrap(); + assert_eq!(unwrapped.sender, alice.public_key()); + assert_eq!(unwrapped.rumor.pubkey, alice.public_key()); + assert_eq!(unwrapped.rumor.kind, Kind::PrivateDirectMessage); + assert_eq!(unwrapped.rumor.content, content); + assert_eq!( + protocol::extract_slatepack(&unwrapped.rumor.content).unwrap(), + SLATEPACK + ); + assert_eq!( + protocol::extract_subject(&unwrapped.rumor.tags), + Some("lunch :)".to_string()) + ); + + // Only the addressee can open it. + let mallory = Keys::generate(); + assert!(unwrap(&mallory, &wrap).await.is_err()); + } + + /// (b) v3 -> v2 regression: a recipient with no `encryption` tag (or a + /// v2-only one) negotiates v2, and the sdk-built v2 wrap still decrypts + /// through the same unwrap seam — a v2-only peer is unaffected. + #[tokio::test] + async fn v2_only_peer_unaffected() { + // Negotiation: absent or v2-only tag never selects v3; our own + // advertised capability does. + assert!(!peer_supports_v3(None)); + assert!(!peer_supports_v3(Some(""))); + assert!(!peer_supports_v3(Some("nip44_v2"))); + assert!(!peer_supports_v3(Some("nip44_v3000"))); // whole-token match + assert!(peer_supports_v3(Some("nip44_v3 nip44_v2"))); + assert!(peer_supports_v3(Some("nip44_v2 nip44_v3"))); + assert!(peer_supports_v3(Some(ENCRYPTION_CAPABILITY))); + + // The v2 path (what the sender produces for such a peer) is the + // unchanged nostr-sdk builder; our unwrap dispatches it to the sdk. + let alice = Keys::generate(); + let bob = Keys::generate(); + let content = protocol::build_payment_content(SLATEPACK); + let rumor = EventBuilder::new(Kind::PrivateDirectMessage, content.clone()) + .tag(Tag::public_key(bob.public_key())) + .tags(protocol::build_rumor_tags(None)) + .build(alice.public_key()); + let wrap_v2 = EventBuilder::gift_wrap(&alice, &bob.public_key(), rumor, []) + .await + .unwrap(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&wrap_v2.content) + .unwrap(); + assert_eq!(decoded[0], 0x02); + + let unwrapped = unwrap(&bob, &wrap_v2).await.unwrap(); + assert_eq!(unwrapped.sender, alice.public_key()); + assert_eq!(unwrapped.rumor.content, content); + } + + /// (c) Version-byte dispatch on malformed or unknown payloads errors + /// cleanly — no panic, no misrouting. + #[tokio::test] + async fn dispatch_rejects_malformed_payloads() { + let bob = Keys::generate(); + let make = |content: String| { + EventBuilder::new(Kind::GiftWrap, content) + .tag(Tag::public_key(bob.public_key())) + .sign_with_keys(&Keys::generate()) + .unwrap() + }; + let b64 = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + + // Unknown version byte. + let mut junk = vec![0x01u8]; + junk.extend_from_slice(&[7u8; 90]); + assert!(unwrap(&bob, &make(b64(&junk))).await.is_err()); + // Version byte from the future. + junk[0] = 0x04; + assert!(unwrap(&bob, &make(b64(&junk))).await.is_err()); + // Not base64 at all. + assert!( + unwrap(&bob, &make("not base64!!".to_string())) + .await + .is_err() + ); + // Empty content. + assert!(unwrap(&bob, &make(String::new())).await.is_err()); + // Truncated v3 payload (version byte right, body too short). + assert!(unwrap(&bob, &make(b64(&[0x03, 1, 2, 3]))).await.is_err()); + // Valid v3 framing but garbage ciphertext. + let mut garbage = vec![0x03u8]; + garbage.extend_from_slice(&[9u8; 120]); + assert!(unwrap(&bob, &make(b64(&garbage))).await.is_err()); + // Not a gift wrap at all. + let not_wrap = EventBuilder::new(Kind::TextNote, "hi") + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(unwrap(&bob, ¬_wrap).await.is_err()); + } + + /// Context binding: a v3 payload encrypted for one layer must not decrypt + /// as the other (kind is authenticated by the MAC). + #[test] + fn v3_context_binding_enforced() { + let alice = Keys::generate(); + let bob = Keys::generate(); + let ck = conversation_key(alice.secret_key(), &bob.public_key()).unwrap(); + let sealed = nip44::encrypt_v3(&ck, b"payload", SEAL_CTX_KIND, SCOPE).unwrap(); + let ck_bob = conversation_key(bob.secret_key(), &alice.public_key()).unwrap(); + assert!(nip44::decrypt_v3(&ck_bob, &sealed, SEAL_CTX_KIND, SCOPE).is_ok()); + assert!(nip44::decrypt_v3(&ck_bob, &sealed, WRAP_CTX_KIND, SCOPE).is_err()); + } +} diff --git a/src/nym/dns.rs b/src/nym/dns.rs new file mode 100644 index 0000000..f200b95 --- /dev/null +++ b/src/nym/dns.rs @@ -0,0 +1,612 @@ +// 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. + +//! DNS resolution THROUGH the mixnet, over DoT (DNS-over-TLS, RFC 7858). +//! `Tunnel::tcp_connect` takes a `SocketAddr`, so resolving the hostname is our +//! job (the old SOCKS5 network requester resolved at the exit for us) — and it +//! rides the tunnel so neither the query nor its answer ever touches the clear: +//! a clearnet lookup would leak exactly which relays/nodes Goblin contacts, +//! defeating the mixnet. +//! +//! WHY DoT (TCP+TLS), not the old UDP mix-dns: the previous path sent raw UDP +//! datagrams over the mixnet, and mixnet UDP LOSES packets — a lost datagram +//! stalled behind a multi-second timeout, and Phase-1 measurements showed +//! resolves taking ~10s (21 lost-datagram retries) which tipped relay connects +//! past the exit-condemnation grace and drove the 2-3 minute reselect loop. DoT +//! runs the DNS query over a TCP+TLS connection through the tunnel: TCP +//! RETRANSMITS, so there are no packet-loss stalls, and TLS ENCRYPTS the query +//! end to end, so not even the IPR exit can see (or forge) which host we asked +//! for. Reliable AND private AND authenticated — smolmix is a TCP tunnel and is +//! good at TCP. (The exit policy allows :853 — verified live by the +//! `probe_dns_ports` harness before shipping this; if a future exit blocks 853, +//! DoH on 443 is the drop-in fallback.) +//! +//! Wire codec: hickory-proto — already in the dependency graph via +//! nym-http-api-client, so no vendored encode/parse is needed. DoT framing is +//! the DNS message prefixed with its 2-byte big-endian length (RFC 1035 §4.2.2). +//! Answers land in a TTL-respecting in-memory cache and hosts are prewarmed at +//! startup, so a warm entry (not a fresh mixnet round trip) serves the common +//! case. IPv4-only, like the rest of the app (GRIM audit). +//! +//! A legacy UDP path is retained behind `GOBLIN_DNS_UDP=1` for measuring the +//! regression this replaced; it is never used in shipped builds. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use futures::stream::{FuturesUnordered, StreamExt}; +use hickory_proto::op::{Message, MessageType, Query, ResponseCode}; +use hickory_proto::rr::{Name, RData, RecordType}; +use http_body_util::{BodyExt, Full}; +use hyper_util::rt::TokioIo; +use lazy_static::lazy_static; +use log::{debug, warn}; +use parking_lot::RwLock; +use smolmix::Tunnel; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// A DoT resolver: the IP:853 to dial through the tunnel and the SNI / cert name +/// its DoT endpoint presents (the query is validated against this hostname, so a +/// hostile exit that redirects the IP cannot MITM the lookup). +struct DotResolver { + addr: SocketAddr, + sni: &'static str, +} + +/// DoT resolvers, RACED against each other (not primary/fallback) so a slow or +/// unlucky handshake to one never stalls behind it — whichever answers first +/// wins. Addressed BY IP (no bootstrap chicken-and-egg); the SNI is validated. +const DOT_RESOLVERS: [DotResolver; 2] = [ + DotResolver { + addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 853), + sni: "cloudflare-dns.com", + }, + DotResolver { + addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 853), + sni: "dns.quad9.net", + }, +]; + +/// Legacy UDP resolvers (port 53) — only used when `GOBLIN_DNS_UDP=1`. +const UDP_RESOLVERS: [SocketAddr; 2] = [ + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53), +]; + +/// A DoH resolver: the IP:443 to dial through the tunnel, its SNI/cert + Host +/// name, and the RFC 8484 query path. DoH is the FALLBACK for an exit whose +/// policy blocks DoT (:853) — 443 is guaranteed reachable (relays + HTTPS ride +/// it), so DNS never has to touch the clearnet. +struct DohResolver { + ip: SocketAddr, + sni: &'static str, + host: &'static str, + path: &'static str, +} + +const DOH_RESOLVERS: [DohResolver; 2] = [ + DohResolver { + ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443), + sni: "cloudflare-dns.com", + host: "cloudflare-dns.com", + path: "/dns-query", + }, + DohResolver { + ip: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443), + sni: "dns.quad9.net", + host: "dns.quad9.net", + path: "/dns-query", + }, +]; + +/// Which in-tunnel DNS transport a lookup uses. NEVER clearnet. +#[derive(Clone, Copy)] +enum DnsMode { + /// DoT — DNS-over-TLS on :853 (preferred; smallest overhead). + Dot, + /// DoH — DNS-over-HTTPS on :443 (fallback when an exit blocks :853). + Doh, + /// Legacy UDP-over-mixnet (`GOBLIN_DNS_UDP=1`, measurement only). + Udp, +} + +/// Sticky: set once an exit is found to block DoT (:853), so we stop paying the +/// DoT timeout on every subsequent lookup and go straight to DoH (:443). Both +/// stay inside the tunnel — this only picks which in-tunnel transport to use. +static PREFER_DOH: AtomicBool = AtomicBool::new(false); + +/// Per-query answer wait. DoT includes a TCP + TLS handshake over the mixnet +/// (a few seconds of deliberate per-hop delay), so allow more headroom than the +/// UDP path did; a round that exceeds this is retried rather than waited out. +const DOT_QUERY_TIMEOUT: Duration = Duration::from_secs(8); +/// UDP per-query wait (legacy path). +const UDP_QUERY_TIMEOUT: Duration = Duration::from_secs(4); + +/// Quick race-both-resolvers rounds before giving up. DoT is TCP-reliable within +/// a round, so two rounds is plenty (the second only matters if a whole +/// connection was dropped); the UDP path needs one more because it loses +/// datagrams. +const DOT_ROUNDS: usize = 2; +const UDP_ROUNDS: usize = 3; + +/// DoH per-query wait (TCP + TLS + one HTTP round trip over the mixnet) and its +/// round count. Same reliability as DoT (TCP), a touch more per-request overhead +/// (HTTP framing), so the timeout is a shade more generous. +const DOH_QUERY_TIMEOUT: Duration = Duration::from_secs(10); +const DOH_ROUNDS: usize = 2; + +/// TTL floor/ceiling for the cache: don't hammer resolvers for zero-TTL +/// records, don't trust a stale record for more than an hour. +const TTL_FLOOR_SECS: u32 = 60; +const TTL_CEILING_SECS: u32 = 3600; + +lazy_static! { + /// host → (addresses, expiry). + static ref CACHE: RwLock, Instant)>> = + RwLock::new(HashMap::new()); + + /// Shared rustls client config for DoT (webpki roots; ring provider installed + /// at startup — the Build 65/66 rule), reused for every resolver handshake. + static ref DOT_TLS: Arc = { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ) + }; +} + +/// Restore the pre-Build-98 UDP mix-dns path. Measurement/debug only (reproduce +/// the lossy-UDP regression DoT replaced); default OFF. +fn use_legacy_udp() -> bool { + matches!(std::env::var("GOBLIN_DNS_UDP").as_deref(), Ok("1")) +} + +/// Resolve `host` to a socket address for `tcp_connect`, entirely over the +/// mixnet via DoT. IP-literal hosts skip DNS; cached answers are honored until +/// their (clamped) TTL lapses. Each round RACES both resolvers concurrently and +/// takes the first valid answer; a round with no answer is retried. Returns +/// `None` only after every round fails. +pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> Option { + // IP literals (v4 or v6) need no lookup at all. + if let Ok(ip) = host.parse::() { + return Some(SocketAddr::new(ip, port)); + } + if let Some(ip) = cached(host) { + return Some(SocketAddr::new(IpAddr::V4(ip), port)); + } + // Legacy measurement path (UDP-over-mixnet), never in shipped builds. + if use_legacy_udp() { + return resolve_via(tunnel, host, port, DnsMode::Udp).await; + } + // If a previous lookup already learned this exit blocks DoT, go straight to + // DoH — still entirely inside the tunnel. + if PREFER_DOH.load(Ordering::Acquire) { + return resolve_via(tunnel, host, port, DnsMode::Doh).await; + } + // DoT first; on total failure (exit likely blocks :853) fall back to DoH on + // :443 — which is guaranteed reachable through the exit. There is NEVER a + // clearnet fallback: both transports ride the mixnet. + if let Some(addr) = resolve_via(tunnel, host, port, DnsMode::Dot).await { + return Some(addr); + } + if !PREFER_DOH.swap(true, Ordering::AcqRel) { + warn!("dns: DoT (:853) unavailable through this exit; using DoH (:443) over the tunnel"); + } + resolve_via(tunnel, host, port, DnsMode::Doh).await +} + +/// Run the round loop for one in-tunnel DNS transport, writing the cache on the +/// first valid answer. Shared by DoT / DoH / legacy-UDP. +async fn resolve_via(tunnel: &Tunnel, host: &str, port: u16, mode: DnsMode) -> Option { + let (proto, rounds) = match mode { + DnsMode::Dot => ("dot-dns", DOT_ROUNDS), + DnsMode::Doh => ("doh-dns", DOH_ROUNDS), + DnsMode::Udp => ("udp-dns", UDP_ROUNDS), + }; + let start = Instant::now(); + for round in 0..rounds { + let answer = match mode { + DnsMode::Dot => race_dot(tunnel, host).await, + DnsMode::Doh => race_doh(tunnel, host).await, + DnsMode::Udp => race_udp(tunnel, host).await, + }; + if let Some((resolver, ips, ttl)) = answer { + let ttl = ttl.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS); + debug!( + "{proto}: resolved {host} -> {} in {}ms (via {resolver}, round {}/{rounds}, \ + ttl {ttl}s, {} record(s))", + ips[0], + start.elapsed().as_millis(), + round + 1, + ips.len() + ); + let expiry = Instant::now() + Duration::from_secs(ttl as u64); + CACHE + .write() + .insert(host.to_string(), (ips.clone(), expiry)); + return Some(SocketAddr::new(IpAddr::V4(ips[0]), port)); + } + debug!( + "{proto}: no answer for {host} in round {}/{rounds}, retrying", + round + 1 + ); + } + debug!( + "{proto}: resolution failed for {host} after {rounds} rounds ({}ms)", + start.elapsed().as_millis() + ); + None +} + +/// One DoT round: fire an A query at EVERY resolver concurrently and return the +/// first valid, non-empty answer (with the resolver address that produced it). A +/// resolver that errors or times out is simply outrun. +async fn race_dot(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec, u32)> { + let mut inflight = FuturesUnordered::new(); + for resolver in &DOT_RESOLVERS { + inflight.push(async move { + let answer = tokio::time::timeout(DOT_QUERY_TIMEOUT, query_dot(tunnel, host, resolver)) + .await + .ok() + .flatten(); + (resolver.addr, answer) + }); + } + while let Some((addr, answer)) = inflight.next().await { + if let Some((ips, ttl)) = answer + && !ips.is_empty() + { + return Some((addr, ips, ttl)); + } + } + None +} + +/// One DoT A query/response over the tunnel against `resolver`: TCP connect +/// through the mixnet, TLS (rustls, webpki roots, SNI-validated), then the DNS +/// message framed with its 2-byte big-endian length, and the length-framed +/// response read back. +async fn query_dot( + tunnel: &Tunnel, + host: &str, + resolver: &DotResolver, +) -> Option<(Vec, u32)> { + let tcp = tunnel + .tcp_connect(resolver.addr) + .await + .map_err(|e| debug!("dot-dns: connect to {} failed: {e}", resolver.addr)) + .ok()?; + let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?; + let mut tls = tokio_rustls::TlsConnector::from(DOT_TLS.clone()) + .connect(server_name, tcp) + .await + .map_err(|e| debug!("dot-dns: tls handshake with {} failed: {e}", resolver.sni)) + .ok()?; + + let id = rand::random::(); + let query = encode_query(id, host)?; + // RFC 7858 / RFC 1035 §4.2.2: 2-byte big-endian length prefix + message. + let mut framed = Vec::with_capacity(2 + query.len()); + framed.extend_from_slice(&(query.len() as u16).to_be_bytes()); + framed.extend_from_slice(&query); + tls.write_all(&framed) + .await + .map_err(|e| debug!("dot-dns: send to {} failed: {e}", resolver.sni)) + .ok()?; + tls.flush().await.ok()?; + + let mut len_buf = [0u8; 2]; + tls.read_exact(&mut len_buf) + .await + .map_err(|e| debug!("dot-dns: recv len from {} failed: {e}", resolver.sni)) + .ok()?; + let len = u16::from_be_bytes(len_buf) as usize; + if len == 0 { + return None; + } + let mut resp = vec![0u8; len]; + tls.read_exact(&mut resp) + .await + .map_err(|e| debug!("dot-dns: recv body from {} failed: {e}", resolver.sni)) + .ok()?; + parse_response(id, &resp) +} + +/// One DoH round: race both resolvers and take the first valid, non-empty +/// answer (with the resolver IP that produced it). +async fn race_doh(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec, u32)> { + let mut inflight = FuturesUnordered::new(); + for resolver in &DOH_RESOLVERS { + inflight.push(async move { + let answer = tokio::time::timeout(DOH_QUERY_TIMEOUT, query_doh(tunnel, host, resolver)) + .await + .ok() + .flatten(); + (resolver.ip, answer) + }); + } + while let Some((ip, answer)) = inflight.next().await { + if let Some((ips, ttl)) = answer + && !ips.is_empty() + { + return Some((ip, ips, ttl)); + } + } + None +} + +/// One DoH A query over the tunnel against `resolver` (RFC 8484): TCP connect +/// through the mixnet, TLS (SNI-validated), then an HTTP/1.1 POST to the +/// resolver's /dns-query with the wire-format DNS message as the body and +/// `application/dns-message` content type; the wire-format response body is +/// parsed the same way as DoT/UDP. +async fn query_doh( + tunnel: &Tunnel, + host: &str, + resolver: &DohResolver, +) -> Option<(Vec, u32)> { + let id = rand::random::(); + let query = encode_query(id, host)?; + + let tcp = tunnel + .tcp_connect(resolver.ip) + .await + .map_err(|e| debug!("doh-dns: connect to {} failed: {e}", resolver.ip)) + .ok()?; + let server_name = rustls::pki_types::ServerName::try_from(resolver.sni.to_string()).ok()?; + let tls = tokio_rustls::TlsConnector::from(DOT_TLS.clone()) + .connect(server_name, tcp) + .await + .map_err(|e| debug!("doh-dns: tls handshake with {} failed: {e}", resolver.sni)) + .ok()?; + + let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls)) + .await + .map_err(|e| debug!("doh-dns: http handshake with {} failed: {e}", resolver.host)) + .ok()?; + tokio::spawn(async move { + let _ = conn.await; + }); + + let req = hyper::Request::builder() + .method(hyper::Method::POST) + .uri(resolver.path) + .header(hyper::header::HOST, resolver.host) + .header(hyper::header::CONTENT_TYPE, "application/dns-message") + .header(hyper::header::ACCEPT, "application/dns-message") + .header(hyper::header::USER_AGENT, "goblin-wallet") + .body(Full::new(Bytes::from(query))) + .ok()?; + let resp = sender + .send_request(req) + .await + .map_err(|e| debug!("doh-dns: request to {} failed: {e}", resolver.host)) + .ok()?; + if resp.status() != hyper::StatusCode::OK { + debug!("doh-dns: {} returned {}", resolver.host, resp.status()); + return None; + } + let body = resp.into_body().collect().await.ok()?.to_bytes(); + parse_response(id, &body) +} + +/// One legacy-UDP round (only reached with `GOBLIN_DNS_UDP=1`). +async fn race_udp(tunnel: &Tunnel, host: &str) -> Option<(SocketAddr, Vec, u32)> { + let mut inflight = FuturesUnordered::new(); + for resolver in UDP_RESOLVERS { + inflight.push(async move { (resolver, query_udp(tunnel, host, resolver).await) }); + } + while let Some((resolver, answer)) = inflight.next().await { + if let Some((ips, ttl)) = answer + && !ips.is_empty() + { + return Some((resolver, ips, ttl)); + } + } + None +} + +/// Resolve a batch of hosts concurrently to populate the cache, so the first +/// real use (relay dial, NIP-05 name claim, price fetch) hits a warm entry +/// instead of paying the mixnet DoT round trip inline. Best-effort; the port is +/// irrelevant here (only the host-keyed cache is filled) so a placeholder is used. +pub async fn prewarm(tunnel: &Tunnel, hosts: &[String]) { + let mut inflight = FuturesUnordered::new(); + for host in hosts { + inflight.push(resolve(tunnel, host, 0)); + } + while inflight.next().await.is_some() {} +} + +/// A cached, unexpired address for `host`. +fn cached(host: &str) -> Option { + let cache = CACHE.read(); + let (ips, expiry) = cache.get(host)?; + if Instant::now() < *expiry { + ips.first().copied() + } else { + None + } +} + +/// Address the liveness probe dials THROUGH the tunnel: Cloudflare's anycast +/// resolver on 443. Any reachable public IP works; 443 is chosen because it is +/// never firewalled by an exit policy (relays + HTTPS already ride it). +const PROBE_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443); +/// The probe must complete within this; a mixnet TCP handshake is a few seconds. +const PROBE_TIMEOUT: Duration = Duration::from_secs(8); + +/// End-to-end exit-liveness probe: open a TCP connection THROUGH the tunnel to a +/// stable public address and immediately drop it. Because TCP over the mixnet +/// RETRANSMITS, a single lost datagram does not spuriously fail a healthy exit — +/// unlike the old UDP DNS probe, whose lost datagrams falsely declared good +/// exits DEAD and drove reselects. Proves the full path (mixnet → IPR exit → +/// internet) and keeps the gateway/IPR session from idling out. Used by the +/// fresh-tunnel gate and the watchdog keepalive. +pub async fn probe(tunnel: &Tunnel) -> bool { + match tokio::time::timeout(PROBE_TIMEOUT, tunnel.tcp_connect(PROBE_ADDR)).await { + Ok(Ok(_stream)) => true, + Ok(Err(e)) => { + debug!("probe: tcp_connect to {PROBE_ADDR} through tunnel failed: {e}"); + false + } + Err(_) => { + debug!("probe: tcp_connect to {PROBE_ADDR} through tunnel timed out"); + false + } + } +} + +/// One legacy-UDP A query/response round trip over the tunnel against `resolver`. +async fn query_udp( + tunnel: &Tunnel, + host: &str, + resolver: SocketAddr, +) -> Option<(Vec, u32)> { + let udp = match tunnel.udp_socket().await { + Ok(s) => s, + Err(e) => { + warn!("udp-dns: udp socket failed: {e}"); + return None; + } + }; + let id = rand::random::(); + let query = encode_query(id, host)?; + if let Err(e) = udp.send_to(&query, resolver).await { + warn!("udp-dns: send to {resolver} failed: {e}"); + return None; + } + let mut buf = vec![0u8; 1500]; + let (n, from) = match tokio::time::timeout(UDP_QUERY_TIMEOUT, udp.recv_from(&mut buf)).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + warn!("udp-dns: recv from {resolver} failed: {e}"); + return None; + } + Err(_) => { + debug!("udp-dns: query to {resolver} timed out (will retry)"); + return None; + } + }; + if from != resolver { + warn!("udp-dns: dropping answer from unexpected source {from}"); + return None; + } + parse_response(id, &buf[..n]) +} + +/// Encode a recursive A query for `host` with transaction id `id`. +fn encode_query(id: u16, host: &str) -> Option> { + let name = Name::from_ascii(host).ok()?; + let mut msg = Message::query(); + msg.metadata.id = id; + msg.metadata.recursion_desired = true; + msg.add_query(Query::query(name, RecordType::A)); + msg.to_vec().ok() +} + +/// Parse a response to transaction `id`: all A records in the answer section +/// plus the smallest TTL among them. `None` on id mismatch, non-response, +/// error rcode or no A records (CNAMEs and other types are skipped). +fn parse_response(id: u16, raw: &[u8]) -> Option<(Vec, u32)> { + let msg = Message::from_vec(raw).ok()?; + if msg.metadata.id != id + || msg.metadata.message_type != MessageType::Response + || msg.metadata.response_code != ResponseCode::NoError + { + return None; + } + let mut ips = Vec::new(); + let mut ttl = u32::MAX; + for record in &msg.answers { + if let RData::A(a) = record.data { + ips.push(a.0); + ttl = ttl.min(record.ttl); + } + } + if ips.is_empty() { + None + } else { + Some((ips, ttl)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Query for `example.com` A/IN, id 0x1234, RD set — the canonical fixture + /// (same bytes smolmix's own docs use). + const QUERY_FIXTURE: &[u8] = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\ + \x07example\x03com\x00\x00\x01\x00\x01"; + + /// Response to `QUERY_FIXTURE`: flags 0x8180 (QR, RD, RA, NOERROR), one + /// question, two answers — a CNAME (ttl 3600, rdata = compression pointer + /// back to the qname) that must be skipped, then an A record for + /// 93.184.216.34 with ttl 300. + const RESPONSE_FIXTURE: &[u8] = b"\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\ + \x07example\x03com\x00\x00\x01\x00\x01\ + \xc0\x0c\x00\x05\x00\x01\x00\x00\x0e\x10\x00\x02\xc0\x0c\ + \xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + + #[test] + fn encode_query_matches_fixture() { + let bytes = encode_query(0x1234, "example.com").unwrap(); + assert_eq!(bytes, QUERY_FIXTURE); + } + + #[test] + fn parse_response_extracts_a_records_and_min_ttl() { + let (ips, ttl) = parse_response(0x1234, RESPONSE_FIXTURE).unwrap(); + assert_eq!(ips, vec![Ipv4Addr::new(93, 184, 216, 34)]); + // The CNAME's larger ttl (3600) must not win: only A records count. + assert_eq!(ttl, 300); + } + + #[test] + fn parse_response_rejects_wrong_id() { + assert!(parse_response(0x5678, RESPONSE_FIXTURE).is_none()); + } + + #[test] + fn parse_response_rejects_query_and_garbage() { + // A query (QR=0) is not an answer. + assert!(parse_response(0x1234, QUERY_FIXTURE).is_none()); + // Truncated/garbage input parses to nothing. + assert!(parse_response(0x1234, &RESPONSE_FIXTURE[..7]).is_none()); + assert!(parse_response(0x1234, b"\x00").is_none()); + } + + #[test] + fn parse_response_rejects_error_rcode() { + // Same fixture with rcode NXDOMAIN (flags 0x8183) and no answers. + let nx: &[u8] = b"\x12\x34\x81\x83\x00\x01\x00\x00\x00\x00\x00\x00\ + \x07example\x03com\x00\x00\x01\x00\x01"; + assert!(parse_response(0x1234, nx).is_none()); + } + + #[test] + fn ttl_clamp_bounds() { + assert_eq!(5u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 60); + assert_eq!(999_999u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 3600); + assert_eq!(300u32.clamp(TTL_FLOOR_SECS, TTL_CEILING_SECS), 300); + } +} diff --git a/src/nym/mod.rs b/src/nym/mod.rs index 2476c70..95e45b7 100644 --- a/src/nym/mod.rs +++ b/src/nym/mod.rs @@ -13,65 +13,98 @@ // limitations under the License. //! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and -//! every HTTP request (NIP-05, price, avatars) — is routed through Goblin's -//! in-process Nym SOCKS5 client (the Nym SDK linked directly, no subprocess) -//! that tunnels over the 5-hop mixnet to a network requester. The mixnet breaks -//! the sender↔receiver timing correlation that Mimblewimble's interactive slate -//! exchange otherwise leaks at the network layer, and it bootstraps in ~2s. -//! Nothing goes clearnet. +//! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet: +//! by default one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an +//! auto-selected public IPR exit, so neither the payload nor the +//! destination-in-flight ever touches the clearnet. Hostnames resolve through +//! the same tunnel too ([`dns`], DoT — DNS-over-TLS), so nothing goes +//! clearnet. MONEY-PATH ANCHOR: a host whose relay advertises a co-located +//! scoped exit in the pool is instead dialed over a MixnetStream straight to +//! that exit ([`streamexit`]) — no DNS and no public IPR at all — falling +//! back to the tunnel on any failure. The mixnet breaks the sender↔receiver +//! timing correlation that Mimblewimble's interactive slate exchange +//! otherwise leaks at the network layer. +//! +//! DNS reliability was the one weak spot: the original mix-dns sent UDP over the +//! mixnet, and mixnet UDP loses packets — resolves stalled on multi-second +//! timeouts (~10s measured), tipping relay connects past the exit-condemnation +//! grace and driving a 2-3 minute reselect loop. Build 98 moves DNS to DoT +//! (TCP+TLS through the tunnel): TCP retransmits (no packet-loss stalls) and TLS +//! encrypts the query from the exit — reliable AND private. (`GOBLIN_DNS_UDP=1` +//! restores the old UDP path for measuring the regression.) -pub mod sidecar; +pub mod dns; +pub mod nymproc; +pub mod streamexit; pub mod transport; +use std::sync::Arc; use std::time::Duration; -pub use sidecar::{is_ready, warm_up}; +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper_util::rt::TokioIo; +use log::{debug, warn}; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub use nymproc::{ + is_ready, report_relay_down, report_relay_live, set_relay_consumer, transport_ready, + tunnel_generation, warm_up, +}; pub use transport::NymWebSocketTransport; -/// Local SOCKS5 endpoint exposed by the in-process Nym SOCKS5 client. -/// `socks5h` keeps DNS resolution inside the proxy so the destination host is -/// never resolved on the clear. -pub const SOCKS5_HOST: &str = "127.0.0.1"; -pub const SOCKS5_PORT: u16 = 1080; +/// How long a single HTTP exchange (one redirect hop) may take end to end. +/// The mixnet adds deliberate per-hop delay; allow generous time. +const HTTP_TIMEOUT: Duration = Duration::from_secs(60); -/// `socks5h://127.0.0.1:1080` proxy URL for reqwest. -pub fn proxy_url() -> String { - format!("socks5h://{SOCKS5_HOST}:{SOCKS5_PORT}") -} +/// How long to wait for the shared tunnel before giving up on a request. +const TUNNEL_WAIT: Duration = Duration::from_secs(30); -/// `127.0.0.1:1080` for the raw SOCKS5 TCP dialer (relay websockets). -pub fn socks5_addr() -> String { - format!("{SOCKS5_HOST}:{SOCKS5_PORT}") -} +/// Redirect hops to follow before giving up (matches the old client, which +/// followed redirects transparently). +const MAX_REDIRECTS: usize = 5; -/// An HTTP request routed over the Nym mixnet via the in-process SOCKS5 client. -/// Returns `(status, body)`. +/// An HTTP request routed over the Nym mixnet: resolve the host over the tunnel +/// (DoT — see [`dns`]), then `tcp_connect` to that IP through the tunnel, then +/// rustls (webpki roots) for https, then HTTP/1.1. Follows redirects. Returns +/// `(status, body)`. pub async fn http_request_bytes( method: &str, url: String, body: Option>, headers: Vec<(String, String)>, ) -> Option<(u16, Vec)> { - let proxy = reqwest::Proxy::all(proxy_url()).ok()?; - let client = reqwest::Client::builder() - .proxy(proxy) - .user_agent("goblin-wallet") - // The mixnet adds deliberate per-hop delay; allow generous time. - .timeout(Duration::from_secs(60)) - .build() - .ok()?; - let m = reqwest::Method::from_bytes(method.as_bytes()).ok()?; - let mut req = client.request(m, &url); - for (k, v) in headers { - req = req.header(k, v); + let tunnel = nymproc::wait_for_tunnel(TUNNEL_WAIT).await?; + let mut url = url::Url::parse(&url).ok()?; + let mut method = method.to_uppercase(); + let mut body = body; + for _ in 0..=MAX_REDIRECTS { + let (status, resp_body, location) = tokio::time::timeout( + HTTP_TIMEOUT, + request_once(&tunnel, &method, &url, body.clone(), &headers), + ) + .await + .map_err(|_| warn!("nym http: request to {} timed out", redacted(&url))) + .ok()??; + match location { + Some(loc) => { + url = url.join(&loc).ok()?; + // Like the old client: 303 (and legacy 301/302) turn into a + // bodiless GET; 307/308 replay the method + body. + if matches!(status, 301..=303) { + method = "GET".to_string(); + body = None; + } + debug!( + "nym http: following {status} redirect to {}", + redacted(&url) + ); + } + None => return Some((status, resp_body)), + } } - if let Some(b) = body { - req = req.body(b); - } - let resp = req.send().await.ok()?; - let code = resp.status().as_u16(); - let bytes = resp.bytes().await.ok()?.to_vec(); - Some((code, bytes)) + warn!("nym http: too many redirects for {}", redacted(&url)); + None } /// String-bodied convenience wrapper around [`http_request_bytes`]. @@ -85,3 +118,173 @@ pub async fn http_request( .await .map(|(_, raw)| String::from_utf8_lossy(&raw).to_string()) } + +/// Host without path/query, for logs (never log full URLs). +fn redacted(url: &url::Url) -> String { + url.host_str().unwrap_or("").to_string() +} + +/// A single HTTP/1.1 exchange over the tunnel. Returns the status, the +/// collected body and, for 3xx responses, the `Location` target. +async fn request_once( + tunnel: &smolmix::Tunnel, + method: &str, + url: &url::Url, + body: Option>, + headers: &[(String, String)], +) -> Option<(u16, Vec, Option)> { + let host = url.host_str()?.to_string(); + let https = url.scheme() == "https"; + let port = url.port().unwrap_or(if https { 443 } else { 80 }); + + // MONEY-PATH ANCHOR fork: HTTPS to a host whose relay advertises a + // co-located scoped Nym exit (its NIP-11 probe, in practice) rides a + // MixnetStream to that exit instead of the tunnel — no public DNS, no + // public IPR. Failure just falls through to the tunnel path below (anchor + // + fallback, never pin-only). + let exit_io = if https { + match crate::nostr::pool::load().exit_for_host(&host) { + Some(exit) => exit_connect(&host, &exit).await, + None => None, + } + } else { + None + }; + + let io: Box = match exit_io { + Some(io) => io, + None => { + // Resolve the host over the tunnel (DoT — see dns), then dial that + // IP through the same tunnel so nothing (lookup or body) touches + // the clear. + let addr = dns::resolve(tunnel, &host, port).await?; + let tcp = match tunnel.tcp_connect(addr).await { + Ok(s) => s, + Err(e) => { + warn!("nym http: connect to {host} failed: {e}"); + return None; + } + }; + if https { + match tls_connect(&host, tcp).await { + Some(tls) => Box::new(tls), + None => return None, + } + } else { + Box::new(tcp) + } + } + }; + + let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io)) + .await + .map_err(|e| warn!("nym http: handshake with {host} failed: {e}")) + .ok()?; + // Drive the connection until the exchange finishes; it ends itself once + // the response (and body) is done or the sender is dropped. + tokio::spawn(async move { + let _ = conn.await; + }); + + let m = hyper::Method::from_bytes(method.as_bytes()).ok()?; + let path = match url.query() { + Some(q) => format!("{}?{q}", url.path()), + None => url.path().to_string(), + }; + let host_header = if (https && port == 443) || (!https && port == 80) { + host.clone() + } else { + format!("{host}:{port}") + }; + let mut req = hyper::Request::builder() + .method(m) + .uri(path) + .header(hyper::header::HOST, host_header) + .header(hyper::header::USER_AGENT, "goblin-wallet"); + for (k, v) in headers { + req = req.header(k, v); + } + let req = req + .body(Full::new(Bytes::from(body.unwrap_or_default()))) + .ok()?; + + let resp = sender + .send_request(req) + .await + .map_err(|e| warn!("nym http: request to {host} failed: {e}")) + .ok()?; + let status = resp.status().as_u16(); + let location = if resp.status().is_redirection() { + resp.headers() + .get(hyper::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + } else { + None + }; + let bytes = resp.into_body().collect().await.ok()?.to_bytes().to_vec(); + Some((status, bytes, location)) +} + +/// Try the scoped-exit egress for an HTTPS `host`: a MixnetStream to the +/// relay operator's exit ([`streamexit`]), then the SAME hostname-validated +/// [`tls_connect`] as the tunnel path — SNI = `host`, so the exit sees only +/// ciphertext. `None` (logged) on ANY failure, and the whole attempt is +/// bounded by the shared bootstrap cap — a dead exit costs seconds inside the +/// caller's [`HTTP_TIMEOUT`] budget, leaving room to fall back to the tunnel. +async fn exit_connect(host: &str, exit: &str) -> Option> { + let cap = nymproc::BOOTSTRAP_TIMEOUT; + let dial = async { + let stream = streamexit::open_stream(exit, cap) + .await + .map_err(|e| warn!("nym http: scoped exit for {host} unavailable: {e}")) + .ok()?; + let tls = tls_connect(host, stream).await?; + debug!("nym http: {host} riding its operator's scoped exit"); + Some(Box::new(tls) as Box) + }; + match tokio::time::timeout(cap, dial).await { + Ok(io) => io, + Err(_) => { + warn!( + "nym http: scoped exit dial for {host} exceeded {}s; falling back to the tunnel", + cap.as_secs() + ); + None + } + } +} + +/// Everything hyper needs from the tunneled stream, boxable for the plain +/// http / https split. +trait Stream: AsyncRead + AsyncWrite + Send + Unpin {} +impl Stream for T {} + +/// TLS-wrap a tunneled TCP stream with rustls + webpki roots (never the +/// platform verifier — it panics on Android outside a full app context). The +/// certificate is validated against the HOSTNAME even though the dial went to a +/// DoT-resolved IP, so a lying resolver or a hostile exit cannot MITM. +async fn tls_connect(host: &str, stream: S) -> Option> +where + S: AsyncRead + AsyncWrite + Send + Unpin, +{ + // Shared rustls client config (webpki roots; ring provider installed at + // startup — the Build 65/66 rule). + lazy_static::lazy_static! { + static ref TLS_CONFIG: Arc = { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ) + }; + } + let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).ok()?; + tokio_rustls::TlsConnector::from(TLS_CONFIG.clone()) + .connect(server_name, stream) + .await + .map_err(|e| warn!("nym http: tls handshake with {host} failed: {e}")) + .ok() +} diff --git a/src/nym/nymproc.rs b/src/nym/nymproc.rs new file mode 100644 index 0000000..6e927dc --- /dev/null +++ b/src/nym/nymproc.rs @@ -0,0 +1,706 @@ +// 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. + +//! In-process Nym mixnet tunnel — the wallet's PUBLIC-EXIT path. Goblin links +//! smolmix directly (no sidecar, no bundled binary, no loopback SOCKS5 seam). +//! One process-lifetime [`Tunnel`] carries relay websockets and HTTP requests +//! as raw TCP over the mixnet to an IPR exit gateway, with PREFER-WITH-FALLBACK +//! selection ([`ExitSelector`]): `GOBLIN_NYM_IPR` may name a PREFERRED PUBLIC +//! IPR to try first each cycle; on bootstrap/liveness failure the cycle falls +//! back to an AUTO-SELECTED public exit and retries the preferred one on the +//! next reselect. Unset → pure auto-select, as before. Losing any one exit just +//! re-selects, so there is no single-exit SPOF. Hostnames resolve via +//! [`super::dns`] over DoT through the same tunnel, so nothing touches clearnet. +//! +//! This is the FALLBACK / discovery-and-secondary-relay path. The MONEY-PATH +//! primary relay is reached over a SCOPED MixnetStream to a Floonet operator's +//! CO-LOCATED exit when the pool advertises one ([`crate::nostr::pool::PoolRelay::exit`]), +//! which needs no public DNS and no public IPR — see the streamexit egress +//! (design in ~/.claude/plans/floonet-nym-exit.md). That anchor+fallback split +//! is the "prefer our exit, never pin-only" rule at the transport level. +//! +//! Should smolmix ever regress, the fallback design (SOCKS5 network requester +//! + ordered exit failover) is specified in the plan, section G14. +//! +//! Cover traffic: `TunnelBuilder` has no knob today, so the first cut accepts +//! smolmix defaults (cover traffic ON). The G13 low-power posture needs an +//! upstream nym-sdk patch exposing `IpMixStream::from_client` so a tuned +//! `MixnetClient` (loop-cover config) can back the tunnel; revisit then. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use log::{error, info, warn}; +use parking_lot::RwLock; +use smolmix::{Recipient, Tunnel}; + +/// The shared process-lifetime tunnel, set once the mixnet bootstrap finishes. +static TUNNEL: RwLock> = RwLock::new(None); + +/// Set once the tunnel is up (mirrors `TUNNEL`, but cheap to poll each frame). +static MIXNET_READY: AtomicBool = AtomicBool::new(false); + +/// Monotonic tunnel generation: bumped each time a NEW tunnel (a freshly +/// auto-selected exit) is published. This is the crux of relay-gated readiness: +/// a relay-liveness report tagged with an older generation can never mark the +/// current tunnel ready, so readiness cannot latch true on a stale exit. Starts +/// at 0 ("no tunnel yet"); the first tunnel is generation 1. +static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0); + +/// The tunnel generation on which the nostr client currently has a relay +/// connected AND subscribed, or 0 for "no relay live". A SINGLE atomic (not a +/// bool+gen pair) so [`transport_ready`] can compare it to `TUNNEL_GEN` in one +/// shot — no half-updated `(live, gen)` tuple can slip a stale-exit "ready" +/// through. Written by the nostr client via [`report_relay_live`] / +/// [`report_relay_down`], read by the watchdog and [`transport_ready`]. +static RELAY_LIVE_GEN: AtomicU64 = AtomicU64::new(0); + +/// Whether a nostr consumer (a running `NostrService`) currently WANTS relays +/// over the tunnel. Relay reachability governs exit health ONLY while this is +/// true: the tunnel also carries plain HTTP (NIP-05, price, relay pool) with no +/// relay at all — e.g. before a wallet is open — and such usage must NOT get an +/// otherwise-healthy exit condemned for "no relay". Bracketed by the service via +/// [`set_relay_consumer`]; when false the DNS keepalive is the sole health +/// signal, exactly as before this hardening. +static RELAY_CONSUMER: AtomicBool = AtomicBool::new(false); + +/// Guards the background bootstrap thread so `warm_up()` is idempotent. +static STARTED: AtomicBool = AtomicBool::new(false); + +/// Pre-warm the mixnet tunnel in the background so relays / NIP-05 / price are +/// ready by first use. Idempotent — later calls (including the lazy-init path +/// in [`wait_for_tunnel`]) are no-ops. +pub fn warm_up() { + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + thread::spawn(run_tunnel); +} + +/// Whether the mixnet tunnel is warm. Cheap and cached — safe to poll from the +/// UI each frame. Distinct from a relay being connected (see +/// [`transport_ready`]): the tunnel can be up while no relay yet rides it. +pub fn is_ready() -> bool { + MIXNET_READY.load(Ordering::Relaxed) +} + +/// The current tunnel generation. The nostr client reads this right before it +/// dials so it can tag its relay-liveness reports with the exit they ride. +pub fn tunnel_generation() -> u64 { + TUNNEL_GEN.load(Ordering::Acquire) +} + +/// Relay-gated readiness — the AUTHORITATIVE "ready to receive/send over Nym" +/// signal, distinct from the tunnel-only [`is_ready`]. True only when the +/// tunnel is up AND a required relay is connected+subscribed on the CURRENT +/// generation. Money path: when in doubt this is false, so the UI shows +/// "connecting/reconnecting" rather than a false "Connected over Nym", and the +/// dead-for-our-purposes exit gets condemned rather than blackholing us. +pub fn transport_ready() -> bool { + let generation = TUNNEL_GEN.load(Ordering::Acquire); + generation != 0 && RELAY_LIVE_GEN.load(Ordering::Acquire) == generation && is_ready() +} + +/// Client → transport report: a relay is connected+subscribed on `generation`. +/// `fetch_max` so a late report for an older exit can never move liveness +/// backwards over a newer one. +pub fn report_relay_live(generation: u64) { + RELAY_LIVE_GEN.fetch_max(generation, Ordering::AcqRel); +} + +/// Client → transport report: no relay is currently live on `generation` (all +/// dropped). Clears liveness only when `generation` is still the live one, so a +/// stale "down" can't wipe a fresh report from a newer exit. +pub fn report_relay_down(generation: u64) { + let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire); +} + +/// Bracket a nostr consumer's lifetime: the running `NostrService` sets this +/// true while it wants relays and false when it stops. Arms/disarms +/// relay-reachability governance of exit health (see [`RELAY_CONSUMER`]). +pub fn set_relay_consumer(active: bool) { + RELAY_CONSUMER.store(active, Ordering::Release); +} + +/// Whether a nostr consumer currently wants relays over the tunnel. +fn relay_consumer() -> bool { + RELAY_CONSUMER.load(Ordering::Acquire) +} + +/// Whether a relay is live on `generation` — the watchdog's authoritative view +/// of whether the current exit actually carries our relay traffic. +fn relay_live_for(generation: u64) -> bool { + generation != 0 && RELAY_LIVE_GEN.load(Ordering::Acquire) == generation +} + +/// The shared tunnel, if it is up. Cloning is a cheap `Arc` bump. +pub fn tunnel() -> Option { + TUNNEL.read().clone() +} + +/// Wait until the shared tunnel is up, starting the bootstrap if nothing has +/// yet (lazy init on first use). Returns `None` once `timeout` lapses. +pub async fn wait_for_tunnel(timeout: Duration) -> Option { + warm_up(); + let deadline = Instant::now() + timeout; + loop { + if let Some(t) = tunnel() { + return Some(t); + } + if Instant::now() >= deadline { + return None; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +/// Build the mixnet tunnel on a dedicated multi-thread tokio runtime, then +/// keep the tunnel (its bridge + smoltcp reactor tasks) AND the runtime alive +/// for the lifetime of the process. Retries with backoff on bootstrap failure +/// (a dead gateway pick just re-selects on the next attempt). Blocks the +/// calling thread. +fn run_tunnel() { + let rt = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + error!("nym: could not build mixnet runtime: {e}"); + return; + } + }; + rt.block_on(async move { + let mut delay = Duration::from_secs(5); + let mut attempt = 0u64; + let mut selector = ExitSelector::new(); + // True while a FALLBACK (auto-selected) exit carries the traffic even + // though an anchor is configured — makes the ANCHOR RECOVERED log honest. + let mut fell_back = false; + loop { + let started = Instant::now(); + attempt += 1; + // Prefer-with-fallback exit selection: the anchor (when configured) + // exactly once per select cycle, auto-select for every further + // attempt in the cycle. Env re-read each attempt so the timing + // harness / a debug session can flip it without a restart. + let anchor = anchor_recipient(); + let choice = selector.next_choice(anchor.is_some()); + let pin = match choice { + ExitChoice::Anchor => { + info!( + "[timing] nym: ANCHOR attempt — trying our preferred IPR exit first (attempt {attempt})" + ); + anchor + } + ExitChoice::Auto => None, + }; + info!( + "[timing] nym: BOOTSTRAP start (attempt {attempt}, {} exit select+build)", + choice.label() + ); + // Cap the build: a dead gateway pick otherwise blocks on the Nym SDK's + // own long "connection response" timeout (~74s measured) before we can + // reselect. Abandoning the future drops the half-built tunnel. + let build = match tokio::time::timeout(BOOTSTRAP_TIMEOUT, build_tunnel(pin)).await { + Ok(result) => result, + Err(_) => { + if choice == ExitChoice::Anchor { + // A dead anchor must not delay connectivity: fall back + // to auto-select IMMEDIATELY (no backoff), same cycle. + warn!( + "[timing] nym: ANCHOR DEAD — anchor build exceeded {}s (attempt {attempt}); \ + FALLBACK to auto-select now", + BOOTSTRAP_TIMEOUT.as_secs() + ); + continue; + } + warn!( + "[timing] nym: DEAD GATEWAY — build_tunnel exceeded {}s (attempt {attempt}); \ + re-selecting immediately", + BOOTSTRAP_TIMEOUT.as_secs() + ); + delay = Duration::from_secs(5); + continue; + } + }; + match build { + Ok(tunnel) => { + let build_ms = started.elapsed().as_millis(); + info!( + "[timing] nym: tunnel BUILT in {build_ms}ms (attempt {attempt}); probing exit liveness" + ); + // Gate readiness on one end-to-end probe: some exits accept + // the IPR handshake but never deliver data (seen live); + // publishing such a tunnel would blackhole every consumer + // until the watchdog caught it minutes later. Re-select + // immediately instead. (This is a CHEAP early signal; relay + // reachability below is the authoritative one.) + if !probe_fresh(&tunnel).await { + warn!( + "[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe after {}ms \ + (attempt {attempt}); {}", + choice.label(), + started.elapsed().as_millis(), + if choice == ExitChoice::Anchor { + "FALLBACK to auto-select now" + } else { + "re-selecting immediately" + } + ); + tunnel.shutdown().await; + if choice == ExitChoice::Auto { + delay = (delay * 2).min(Duration::from_secs(60)); + } + continue; + } + // A NEW exit is live: bump the generation BEFORE publishing so + // any relay-liveness left over from the previous exit is + // instantly stale (RELAY_LIVE_GEN != TUNNEL_GEN) and cannot + // mark this tunnel ready. + let generation = TUNNEL_GEN.fetch_add(1, Ordering::AcqRel) + 1; + let published = Instant::now(); + info!( + "[timing] nym: TUNNEL READY in ~{}ms total (build {build_ms}ms + probe, \ + {} exit, allocated ip {}, gen {generation}, attempt {attempt})", + started.elapsed().as_millis(), + choice.label(), + tunnel.allocated_ips().ipv4 + ); + // Close the select cycle: the NEXT reselect tries the anchor + // first again, whichever exit won this one. + selector.tunnel_published(); + match choice { + ExitChoice::Anchor => { + if fell_back { + info!( + "[timing] nym: ANCHOR RECOVERED — back on our preferred exit (gen {generation})" + ); + } + fell_back = false; + } + ExitChoice::Auto if anchor.is_some() => { + fell_back = true; + info!( + "[timing] nym: running on FALLBACK auto-selected exit (gen {generation}); \ + anchor will be retried on the next reselect" + ); + } + ExitChoice::Auto => {} + } + *TUNNEL.write() = Some(tunnel.clone()); + MIXNET_READY.store(true, Ordering::Relaxed); + delay = Duration::from_secs(5); + // Hold the exit warm and govern its health. The watchdog weighs TWO + // signals: the cheap DNS keepalive (as before) AND — authoritatively, + // whenever a nostr consumer is present — RELAY REACHABILITY. The DNS + // probe only proves the exit reaches the internet; some exits pass it + // yet never carry our relay traffic (exit policy blocks the relay, relay + // unreachable through it, subscription never establishes). Such an exit + // is condemned and rebuilt on a fresh auto-selected one rather than left + // blackholing the wallet while the UI (falsely) reads "Connected over + // Nym". Losing any one exit must never take the wallet down. + watch_tunnel(&tunnel, generation).await; + error!( + "[timing] nym: exit gen {generation} condemned after {}s alive; rebuilding on a fresh exit", + published.elapsed().as_secs() + ); + MIXNET_READY.store(false, Ordering::Relaxed); + *TUNNEL.write() = None; + tunnel.shutdown().await; + // Rebuild floor: never re-select faster than once per + // MIN_EXIT_LIFETIME. In the legacy path (and any future bug) + // this is the hard guarantee that a condemnation can't thrash + // the mixnet into a tight reselect loop. + let alive = published.elapsed(); + if !legacy_watchdog() && alive < MIN_EXIT_LIFETIME { + let floor = MIN_EXIT_LIFETIME - alive; + info!( + "[timing] nym: rebuild floor — waiting {}ms before next exit select", + floor.as_millis() + ); + tokio::time::sleep(floor).await; + } + } + Err(e) => { + if choice == ExitChoice::Anchor { + // Anchor unreachable (not bonded yet / condemned by the + // network / bad address): fall back to auto-select + // IMMEDIATELY — no backoff, connectivity first. + warn!( + "[timing] nym: ANCHOR failed to build: {e}; FALLBACK to auto-select now" + ); + continue; + } + error!( + "nym: mixnet tunnel failed to start: {e}; retrying in {}s", + delay.as_secs() + ); + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(60)); + } + } + } + }); +} + +/// Two attempts of the (TCP, retransmitting) liveness probe before rejecting a +/// fresh tunnel — one transient hiccup while the exit settles must not condemn +/// an otherwise healthy exit. +async fn probe_fresh(tunnel: &smolmix::Tunnel) -> bool { + for _ in 0..2 { + if super::dns::probe(tunnel).await { + return true; + } + } + false +} + +/// Exit-liveness keepalive period and the consecutive probe failures that +/// declare death (the probe is now a TCP connect through the tunnel, not UDP DNS). +const KEEPALIVE_PERIOD: Duration = Duration::from_secs(60); +const KEEPALIVE_MAX_FAILS: u32 = 3; + +/// How long a running nostr consumer may go with ZERO reachable relays through +/// the current exit before the exit-liveness gate is consulted. Covers BOTH +/// cases the relay signal governs: an exit that never carries a relay after a +/// consumer starts dialing (relay-dead-on-arrival), and one that was carrying +/// relays and then can't re-establish any (exit went bad, as opposed to a single +/// relay bouncing — which nostr-sdk auto-reconnects within seconds, resetting +/// this timer). The timer resets on every live report, so only CONTINUOUS relay +/// absence counts. With clearnet DNS a healthy relay connects in ~1s, so this +/// window is never reached in normal operation; when it IS reached we do NOT +/// condemn on "no relay yet" alone — we first probe the exit for genuine +/// connectivity (see [`watch_tunnel`]). +const RELAY_GRACE: Duration = Duration::from_secs(25); + +/// Hard backstop: even if the exit keeps PASSING its connectivity probe (so it +/// reaches the internet) yet a consumer still has zero live relays for this +/// long, condemn anyway — this is the "exit reaches the net but its policy +/// blocks our relay port / the relay is unreachable through it" case the G14 +/// hardening guards. Long enough that a slow-but-working handshake never trips +/// it, so it can't drive a reselect loop. +const RELAY_HARD_GRACE: Duration = Duration::from_secs(90); + +/// Rebuild floor: an exit must live at least this long before the watchdog may +/// condemn+rebuild it, and `run_tunnel` waits out any remainder before selecting +/// the next exit. This bounds the reselect rate to at most once per +/// MIN_EXIT_LIFETIME no matter what, so a transient hiccup can never thrash the +/// mixnet into the 2-3 minute loop this build fixes. +const MIN_EXIT_LIFETIME: Duration = Duration::from_secs(20); + +/// Abandon a single `build_tunnel()` that hasn't finished within this and +/// re-select. A healthy gateway+IPR bootstrap completes in ~4-7s; without this +/// cap a DEAD first pick blocked for ~74s (measured) on the Nym SDK's own +/// "listening for connection response" timeout before we even got to reselect. +/// A few seconds of patience, not a minute. Shared with the scoped-exit egress +/// ([`super::streamexit`]) as ITS dial cap, so both mixnet bootstraps fail +/// equally fast. +pub(crate) const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(20); + +/// Restore the pre-Build-98 watchdog (condemn on RELAY_GRACE of no-relay alone, +/// no connectivity gate, no rebuild floor). Debug/measurement only — lets a cold +/// run reproduce the old reselect loop for a BEFORE/AFTER comparison. Default +/// OFF. +fn legacy_watchdog() -> bool { + matches!(std::env::var("GOBLIN_LEGACY_WATCHDOG").as_deref(), Ok("1")) +} + +/// Watchdog poll cadence. The relay-reachability check is a bare atomic load +/// (free), so a short cadence costs nothing and never touches the network; the +/// DNS keepalive still only fires every [`KEEPALIVE_PERIOD`], preserving the +/// G13 low-power posture. +const WATCH_TICK: Duration = Duration::from_secs(5); + +/// Hold the tunnel warm and govern exit health for generation `generation`. Two +/// signals, cheapest first: +/// * relay reachability (AUTHORITATIVE, but only while a nostr consumer is +/// present — see [`RELAY_CONSUMER`]) — a bare atomic read every +/// [`WATCH_TICK`]; a consumer with zero live relays on this exit for +/// [`RELAY_GRACE`] condemns it. Without a consumer (onboarding / HTTP-only) +/// this signal is inert, so plain HTTP usage never condemns a good exit. +/// * DNS keepalive (cheaper backstop, always on) — one tiny mixnet round trip +/// every [`KEEPALIVE_PERIOD`]; [`KEEPALIVE_MAX_FAILS`] in a row condemns the +/// exit and, as a side effect, keeps the gateway/IPR session from idling out. +/// +/// Returns once either signal declares the current exit dead, whereupon +/// `run_tunnel` rebuilds on a fresh auto-selected exit. +async fn watch_tunnel(tunnel: &smolmix::Tunnel, generation: u64) { + let legacy = legacy_watchdog(); + let published = Instant::now(); + let mut dns_fails = 0u32; + let mut since_dns = Duration::ZERO; + let mut relay_lost: Option = None; + loop { + tokio::time::sleep(WATCH_TICK).await; + // (1) Relay reachability — authoritative, but ONLY when a nostr consumer + // actually wants relays on this exit. No consumer → the DNS keepalive + // below is the sole health signal, exactly as before this hardening. + if relay_consumer() && !relay_live_for(generation) { + let lost = *relay_lost.get_or_insert_with(Instant::now); + let absent = lost.elapsed(); + if legacy { + // Pre-Build-98: condemn on RELAY_GRACE of no-relay alone. Kept for + // BEFORE/AFTER measurement; this is the branch that produced the + // reselect loop when mix-dns made relays slow to connect. + if absent >= RELAY_GRACE { + warn!( + "[timing] nym: CONDEMN gen {generation} reason=no-relay-{}s (legacy watchdog); \ + exit lived {}s, re-selecting", + RELAY_GRACE.as_secs(), + published.elapsed().as_secs() + ); + return; + } + } else if published.elapsed() >= MIN_EXIT_LIFETIME && absent >= RELAY_GRACE { + // Robust: past the settle floor AND relays absent for the grace. + // Don't condemn on "no relay yet" alone — first prove the exit + // itself has NO connectivity (a genuine blackhole). If the probe + // SUCCEEDS the exit reaches the internet, so relays are merely slow + // or the relay is blocked; only the HARD backstop condemns then. + let exit_reachable = super::dns::probe(tunnel).await; + if !exit_reachable { + warn!( + "[timing] nym: CONDEMN gen {generation} reason=exit-no-connectivity \ + (no relay {}s + probe failed); exit lived {}s, re-selecting", + absent.as_secs(), + published.elapsed().as_secs() + ); + return; + } + if absent >= RELAY_HARD_GRACE { + warn!( + "[timing] nym: CONDEMN gen {generation} reason=relay-blocked-{}s \ + (exit reaches net but no relay); exit lived {}s, re-selecting", + RELAY_HARD_GRACE.as_secs(), + published.elapsed().as_secs() + ); + return; + } + } + } else { + // Relay live, or no consumer demanding one: clear the timer. + relay_lost = None; + } + // (2) Backstop: cheap DNS keepalive, only every KEEPALIVE_PERIOD. This is a + // real mixnet round trip through the exit, so it is the authoritative + // "does this exit reach the internet at all" signal. + since_dns += WATCH_TICK; + if since_dns >= KEEPALIVE_PERIOD { + since_dns = Duration::ZERO; + if super::dns::probe(tunnel).await { + dns_fails = 0; + } else { + dns_fails += 1; + warn!("nym: tunnel keepalive probe failed ({dns_fails}/{KEEPALIVE_MAX_FAILS})"); + if dns_fails >= KEEPALIVE_MAX_FAILS { + warn!( + "[timing] nym: CONDEMN gen {generation} reason=keepalive-{}-fails; \ + exit lived {}s, re-selecting", + KEEPALIVE_MAX_FAILS, + published.elapsed().as_secs() + ); + return; + } + } + } + } +} + +/// Which exit the next tunnel build targets. Decided per attempt by +/// [`ExitSelector`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExitChoice { + /// A PREFERRED public IPR exit (`GOBLIN_NYM_IPR`) tried first — the anchor + /// of the public-exit path. (The money-path anchor to a Floonet operator's + /// own co-located exit is the separate scoped-MixnetStream egress; this + /// selector governs only the public-IPR fallback layer.) + Anchor, + /// A public exit auto-selected from the network pool — the FALLBACK. + Auto, +} + +impl ExitChoice { + /// Short tag for the `[timing]` logs. + fn label(self) -> &'static str { + match self { + ExitChoice::Anchor => "ANCHOR", + ExitChoice::Auto => "auto-selected", + } + } +} + +/// Prefer-with-fallback exit selection (the G14 anchor+fallback rule). A +/// SELECT CYCLE spans every build attempt between two published tunnels. The +/// policy, kept deliberately tiny so it is exhaustively unit-testable: +/// +/// * anchor configured → the FIRST attempt of each cycle targets the anchor; +/// * anchor failed (build timeout, build error or dead-exit probe) → every +/// further attempt in the SAME cycle auto-selects, so a dead anchor can +/// never lock the wallet out (this is why pin-ONLY is forbidden); +/// * a tunnel got published (either exit) → cycle over; the NEXT cycle — +/// i.e. the next reselect after a fallback — tries the anchor first again, +/// because it may have recovered while a public exit carried the traffic; +/// * no anchor configured → pure auto-select, byte-for-byte the old behavior. +/// +/// Thrash safety: the anchor adds at most one bounded attempt +/// ([`BOOTSTRAP_TIMEOUT`] + probe) per cycle, and cycles themselves are rate- +/// limited by [`MIN_EXIT_LIFETIME`] + the watchdog graces, so a permanently +/// dead anchor costs seconds per reselect, never a loop. +struct ExitSelector { + /// Whether the anchor has been tried in the current select cycle. + anchor_tried: bool, +} + +impl ExitSelector { + const fn new() -> Self { + Self { + anchor_tried: false, + } + } + + /// The exit to target for the next build attempt. + fn next_choice(&mut self, anchor_available: bool) -> ExitChoice { + if anchor_available && !self.anchor_tried { + self.anchor_tried = true; + ExitChoice::Anchor + } else { + ExitChoice::Auto + } + } + + /// A tunnel was published: the select cycle is over. Re-arms the anchor for + /// the next cycle. + fn tunnel_published(&mut self) { + self.anchor_tried = false; + } +} + +/// Compile-time default: building with `GOBLIN_NYM_IPR=` in the +/// environment BAKES a preferred PUBLIC IPR into the binary — the only way to +/// configure it on Android, where the app gets no user env. A runtime +/// `GOBLIN_NYM_IPR` still overrides the baked value (set it EMPTY to disable a +/// baked anchor, e.g. for a pure-auto-select measurement run). +const BAKED_ANCHOR: Option<&str> = option_env!("GOBLIN_NYM_IPR"); + +/// The PREFERRED public-IPR exit's recipient, if one is configured. Unset (no +/// runtime env, nothing baked) → `None` → pure auto-select, exactly the +/// behavior before the anchor existed — so the build works and ships fine +/// whether or not a Floonet exit is deployed. +fn anchor_recipient() -> Option { + let raw = match std::env::var("GOBLIN_NYM_IPR") { + Ok(runtime) => runtime, // runtime wins; "" disables + Err(_) => BAKED_ANCHOR?.to_string(), // baked default (release builds) + }; + parse_anchor(&raw) +} + +/// Parse an IPR recipient (`.@`). Empty or +/// whitespace disables the anchor silently; garbage warns and disables — a bad +/// placeholder degrades gracefully to pure auto-select, never a crash. +fn parse_anchor(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + match raw.parse() { + Ok(recipient) => Some(recipient), + Err(e) => { + warn!("nym: ignoring invalid GOBLIN_NYM_IPR anchor (pure auto-select): {e}"); + None + } + } +} + +/// Build the tunnel — pinned to the anchor's IPR when `pin` is set, otherwise +/// with an auto-selected exit. Ephemeral in-memory keys (a fresh mixnet +/// identity per run — no sqlite, no persisted gateway). +/// +/// NEVER make the anchor the ONLY exit: `pin` must always be allowed to fall +/// back to `None` (see [`ExitSelector`]) or the single-exit SPOF — and a +/// single party seeing all exit traffic — comes back. +async fn build_tunnel(pin: Option) -> Result { + let mut builder = Tunnel::builder(); + if let Some(recipient) = pin { + builder = builder.ipr_address(recipient); + } + builder.build().await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_anchor_is_pure_auto_select() { + let mut s = ExitSelector::new(); + for _ in 0..5 { + assert_eq!(s.next_choice(false), ExitChoice::Auto); + } + // Publishing changes nothing without an anchor. + s.tunnel_published(); + assert_eq!(s.next_choice(false), ExitChoice::Auto); + } + + #[test] + fn anchor_first_then_auto_within_a_cycle() { + let mut s = ExitSelector::new(); + assert_eq!(s.next_choice(true), ExitChoice::Anchor); + // Anchor failed — every further attempt in the cycle falls back. + assert_eq!(s.next_choice(true), ExitChoice::Auto); + assert_eq!(s.next_choice(true), ExitChoice::Auto); + } + + #[test] + fn anchor_retried_on_the_next_cycle_after_a_fallback() { + let mut s = ExitSelector::new(); + // Cycle 1: anchor fails, a fallback exit gets published. + assert_eq!(s.next_choice(true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true), ExitChoice::Auto); + s.tunnel_published(); + // Cycle 2 (the reselect after the fallback): anchor first again. + assert_eq!(s.next_choice(true), ExitChoice::Anchor); + } + + #[test] + fn anchor_publish_also_rearms_the_anchor() { + let mut s = ExitSelector::new(); + assert_eq!(s.next_choice(true), ExitChoice::Anchor); + s.tunnel_published(); // the anchor itself came up + // Condemned later → next cycle prefers the anchor again. + assert_eq!(s.next_choice(true), ExitChoice::Anchor); + } + + #[test] + fn anchor_appearing_mid_cycle_is_tried() { + let mut s = ExitSelector::new(); + // No anchor yet (env unset / invalid): auto, without burning the try. + assert_eq!(s.next_choice(false), ExitChoice::Auto); + // Anchor becomes available (env fixed mid-run): tried on the next attempt. + assert_eq!(s.next_choice(true), ExitChoice::Anchor); + assert_eq!(s.next_choice(true), ExitChoice::Auto); + } + + #[test] + fn parse_anchor_disables_on_empty_or_garbage() { + assert!(parse_anchor("").is_none()); + assert!(parse_anchor(" ").is_none()); + assert!(parse_anchor("placeholder").is_none()); + assert!(parse_anchor("not.a@recipient").is_none()); + // A dead-but-well-formed anchor is exercised end to end by the + // connect_timing harness instead (needs a live mixnet). + } +} diff --git a/src/nym/sidecar.rs b/src/nym/sidecar.rs deleted file mode 100644 index af36de3..0000000 --- a/src/nym/sidecar.rs +++ /dev/null @@ -1,154 +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. - -//! In-process Nym mixnet client. Goblin links the Nym SDK directly — there is no -//! sidecar subprocess and no bundled/sideloaded binary. It runs the SDK's SOCKS5 -//! client on a private internal tokio runtime, exposing the mixnet at -//! `127.0.0.1:1080`; every relay + HTTP request in the app is pointed at that -//! loopback port, so all traffic egresses through the 5-hop mixnet to our network -//! requester. Nothing goes clearnet. - -use std::net::{SocketAddr, TcpStream}; -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::thread; -use std::time::{Duration, Instant}; - -use log::{error, info, warn}; - -use nym_sdk::mixnet::{MixnetClientBuilder, Socks5, Socks5MixnetClient, StoragePaths}; - -use super::{SOCKS5_HOST, SOCKS5_PORT}; - -/// Network requester (the mixnet exit) Goblin routes through — the SOCKS5 -/// client's `--provider`. Standard Nym exit policy, which permits the wss/443 + -/// HTTPS hosts Goblin needs. Overridable at runtime with `GOBLIN_NYM_PROVIDER`. If -/// left empty, the in-process client isn't started, but an already-running SOCKS5 -/// endpoint on :1080 is still reused. -pub const NETWORK_REQUESTER: &str = "5ibBQ9SS1er3tks5tfmrzCQ29qU1uBSvZN2dUwLKPRwu.HdbktiMVniUyaKBnorFVXLRHdwRb8iG9dV481r5xyopV@2RmEBKhQHsqvw5sjnnt2Bhpy96MPDUkbfWkT6r2RWNCR"; - -/// Pre-warm the mixnet transport in the background so relays / NIP-05 / price are -/// ready by first use. If a SOCKS5 endpoint is already listening on :1080 it is -/// reused as-is; otherwise the in-process client is started. -pub fn warm_up() { - thread::spawn(|| { - if port_open(Duration::from_millis(300)) { - info!("nym: reusing SOCKS5 endpoint already listening on {SOCKS5_HOST}:{SOCKS5_PORT}"); - MIXNET_READY.store(true, Ordering::Relaxed); - return; - } - run_client(); - }); -} - -/// Set once the local mixnet SOCKS5 proxy (:1080) is up and accepting. -static MIXNET_READY: AtomicBool = AtomicBool::new(false); - -/// Whether the mixnet proxy is warm. Cheap and cached — safe to poll from the UI -/// each frame, unlike a fresh TCP probe. Distinct from a relay being connected. -pub fn is_ready() -> bool { - MIXNET_READY.load(Ordering::Relaxed) -} - -/// True when something accepts TCP on the SOCKS5 port. -fn port_open(timeout: Duration) -> bool { - let addr: SocketAddr = match format!("{SOCKS5_HOST}:{SOCKS5_PORT}").parse() { - Ok(a) => a, - Err(_) => return false, - }; - TcpStream::connect_timeout(&addr, timeout).is_ok() -} - -/// The network requester address to register against (`--provider`). -fn provider() -> String { - std::env::var("GOBLIN_NYM_PROVIDER") - .ok() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| NETWORK_REQUESTER.to_string()) -} - -/// Persistent storage dir for the in-process client's identity + gateway choice, -/// so the gateway is selected once and reused across launches (cuts cold-start -/// time). `/.goblin/nym`. `None` ⇒ fall back to ephemeral in-memory keys. -fn storage_dir() -> Option { - dirs::home_dir().map(|h| h.join(".goblin").join("nym")) -} - -/// Build the in-process SOCKS5 mixnet client on a dedicated multi-thread tokio -/// runtime, then keep the client (its SOCKS5 listener + mixnet tasks) AND the -/// runtime alive for the lifetime of the process. Blocks the calling thread. -fn run_client() { - let prov = provider(); - if prov.is_empty() { - warn!( - "nym: no network requester configured (set GOBLIN_NYM_PROVIDER or bake NETWORK_REQUESTER); mixnet disabled" - ); - return; - } - let rt = match tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - error!("nym: could not build mixnet runtime: {e}"); - return; - } - }; - rt.block_on(async move { - let started = Instant::now(); - info!("nym: starting in-process SOCKS5 mixnet client on {SOCKS5_HOST}:{SOCKS5_PORT}"); - let client = match build_client(prov).await { - Ok(c) => c, - Err(e) => { - error!("nym: mixnet client failed to start: {e}"); - return; - } - }; - info!( - "nym: mixnet ready on {SOCKS5_HOST}:{SOCKS5_PORT} in ~{}ms (nym addr {})", - started.elapsed().as_millis(), - client.nym_address() - ); - MIXNET_READY.store(true, Ordering::Relaxed); - // Hold the client (and thus the SOCKS5 listener + mixnet tasks) open for - // the whole process lifetime; the runtime keeps polling them. - std::future::pending::<()>().await; - drop(client); - }); -} - -/// Persistent identity if we have a home dir, else ephemeral in-memory keys. -async fn build_client(provider: String) -> Result { - match storage_dir() { - Some(dir) => { - let _ = std::fs::create_dir_all(&dir); - let paths = StoragePaths::new_from_dir(&dir)?; - MixnetClientBuilder::new_with_default_storage(paths) - .await? - .socks5_config(Socks5::new(provider)) - .build()? - .connect_to_mixnet_via_socks5() - .await - } - None => { - MixnetClientBuilder::new_ephemeral() - .socks5_config(Socks5::new(provider)) - .build()? - .connect_to_mixnet_via_socks5() - .await - } - } -} diff --git a/src/nym/streamexit.rs b/src/nym/streamexit.rs new file mode 100644 index 0000000..ece8ee6 --- /dev/null +++ b/src/nym/streamexit.rs @@ -0,0 +1,229 @@ +// 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. + +//! Scoped-MixnetStream egress — the MONEY-PATH ANCHOR. When the relay pool +//! advertises a relay operator's CO-LOCATED Nym exit +//! ([`crate::nostr::pool::PoolRelay::exit`]), the wallet dials that exit +//! directly over the mixnet with a [`MixnetStream`]; the exit pipes the bytes +//! to its ONE configured relay. No public DNS, no public IPR — the two flaky +//! dependencies of the fallback path are gone from the money path. The exit is +//! scoped (it forwards nowhere else), so the wallet writes nothing but the TLS +//! ClientHello: the dial sites run the SAME hostname-validated TLS (SNI = the +//! relay host) + websocket/HTTP wrap over this stream as over the smolmix +//! tunnel's TCP stream, and the exit sees only ciphertext. +//! +//! ANCHOR + FALLBACK, never pin-only: every failure here (bad address, client +//! bootstrap, stream open, timeout) just returns `Err`, and the dial sites +//! ([`super::transport`], [`super::request_once`]) fall through to the +//! public-IPR tunnel ([`super::nymproc`]) — losing the operator's exit never +//! locks the wallet out. Server side: the bundled `floonet-mixexit` binary +//! (design in ~/.claude/plans/floonet-nym-exit.md). + +use std::time::Duration; + +use log::{info, warn}; +use nym_sdk::mixnet::{MixnetClient, MixnetStream, Recipient}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::Mutex; + +/// Everything the TLS/websocket layer needs from the egress stream. +pub trait ExitStream: AsyncRead + AsyncWrite + Send + Unpin {} +impl ExitStream for T {} + +/// The boxed transport stream handed to the TLS/websocket layer — the same +/// seat the smolmix tunnel's TCP stream occupies on the fallback path. +pub type BoxedStream = Box; + +/// After the Open is SENT, wait this long before handing back a writable +/// stream. `open_stream` returns once the Open message leaves the client, NOT +/// once the exit has `accept()`ed and wired its inbound half. But the caller +/// speaks first (TLS ClientHello over a raw-pipe exit), so a write landing in +/// that gap is dropped and the handshake stalls into a fallback. One mixnet +/// round of slack lets the exit be listening before the first byte. +/// ponytail: fixed settle (measured: 0s always stalls, 3s is reliable). The +/// exit pipes raw bytes to its relay, so it can't inject an accept-ack for the +/// client to wait on; if mixnet jitter ever makes 3s flaky, raise it. +const STREAM_SETTLE: Duration = Duration::from_secs(3); + +/// Process-lifetime mixnet client for the scoped-exit egress, lazily connected +/// on first use (mirrors the tunnel singleton in [`super::nymproc`]). +/// Ephemeral in-memory identity, like the tunnel — a fresh mixnet identity per +/// run. Behind an async mutex because `open_stream` needs `&mut`; a dead +/// client (cancelled shutdown token or a failed open) is dropped so the next +/// dial reconnects fresh. +static CLIENT: Mutex> = Mutex::const_new(None); + +// NOTE ON FIRST-DIAL LATENCY: the exit rides a SECOND ephemeral MixnetClient +// (separate from the smolmix tunnel). On a cold app start both clients acquire +// Nym free-tier bandwidth, and the grants serialize — so the first dial that +// bootstraps this client can take ~a minute while the tunnel already has its +// grant. Measured: a startup pre-warm does NOT help — a second client warming +// in parallel just starves the tunnel/fallback for the same total, and slows +// the tunnel too. The real fix is sharing ONE mixnet client for tunnel + exit +// (larger change; tracked separately). Meanwhile the cost is one-time per cold +// start, the payment itself is fast once connected, and discovery/secondary +// relays + the fallback ride the tunnel, so availability is never blocked. + +/// Open a scoped MixnetStream to `exit` — a pool-advertised Nym address +/// (`.@`) of a relay operator's co-located exit. The +/// whole dial (client bootstrap when cold + stream open) is capped at +/// `min(timeout, BOOTSTRAP_TIMEOUT)` so a stuck bootstrap fails FAST into the +/// caller's public-IPR fallback. NOTE: `open_stream` is fire-and-forget on the +/// mixnet — a DEAD exit still hands back a stream, and its death surfaces at +/// the caller's (timeout-bounded) TLS handshake, which doubles as the +/// liveness probe: no ServerHello through the pipe → fall back. +pub async fn open_stream(exit: &str, timeout: Duration) -> Result { + let recipient: Recipient = exit + .trim() + .parse() + .map_err(|e| format!("invalid exit address: {e}"))?; + let cap = timeout.min(super::nymproc::BOOTSTRAP_TIMEOUT); + let stream = match tokio::time::timeout(cap, open(recipient)).await { + Ok(result) => result?, + Err(_) => return Err(format!("exit dial exceeded {}s", cap.as_secs())), + }; + // Let the exit accept() + wire its inbound half before the caller writes. + tokio::time::sleep(STREAM_SETTLE).await; + Ok(Box::new(stream) as BoxedStream) +} + +/// Ensure the shared client is connected, then open a stream on it. +async fn open(recipient: Recipient) -> Result { + let mut guard = CLIENT.lock().await; + // A dead client (gateway dropped, hosting runtime gone) is discarded and + // rebuilt — the auto-reconnect-on-drop rule. + if guard + .as_ref() + .is_some_and(|c| c.cancellation_token().is_cancelled()) + { + warn!("nym: streamexit client died; reconnecting"); + *guard = None; + } + if guard.is_none() { + let started = std::time::Instant::now(); + let client = MixnetClient::connect_new() + .await + .map_err(|e| format!("mixnet client bootstrap failed: {e}"))?; + info!( + "[timing] nym: streamexit client CONNECTED in {}ms", + started.elapsed().as_millis() + ); + *guard = Some(client); + } + let client = guard.as_mut().expect("client ensured above"); + match client.open_stream(recipient, None).await { + Ok(stream) => Ok(stream), + Err(e) => { + // `open_stream` fails only LOCALLY (the client's input channel) — + // it never waits on the peer — so an error means the client itself + // is broken, not the exit. Drop it; the next dial reconnects. + *guard = None; + Err(format!("open_stream failed: {e}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn bad_exit_address_fails_fast_without_touching_the_mixnet() { + // The address parse runs BEFORE any client bootstrap, so garbage from + // a hostile pool costs nothing and degrades to the fallback path. + let err = open_stream("not-a-recipient", Duration::from_secs(5)) + .await + .err() + .expect("garbage address must fail"); + assert!(err.contains("invalid exit address"), "got: {err}"); + } + + /// LIVE end-to-end smoke test of the money path against the DEPLOYED + /// floonet-mixexit (.8): dial the pinned pool's `exit` for relay.goblin.st + /// over the mixnet with the real [`open_stream`], run the SAME + /// hostname-validated TLS + websocket wrap the wallet uses + /// ([`super::super::transport`]), then send a nostr REQ and require the + /// relay to answer (EVENT/EOSE). Proves mixnet -> exit -> relay:443 -> + /// nostr actually carries traffic. Ignored (needs network + a cold mixnet + /// bootstrap). Run: + /// cargo test --lib nym::streamexit::tests::live_exit_roundtrip -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn live_exit_roundtrip() { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + + // The app installs this at startup (src/lib.rs); an isolated test must + // too, or rustls 0.23 can't pick a provider for the TLS handshake. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let exit = crate::nostr::pool::load() + .exit_for("wss://relay.goblin.st") + .expect("pinned pool advertises the relay.goblin.st exit"); + println!("dialing scoped exit {exit}"); + + // A cold ephemeral mixnet bootstrap can exceed the per-dial cap; the + // real wallet just falls back and retries, so retry until one dial wins. + let mut stream = None; + for attempt in 1..=6 { + let t = std::time::Instant::now(); + match open_stream(&exit, Duration::from_secs(90)).await { + Ok(s) => { + println!( + "open_stream OK on attempt {attempt} in {}ms", + t.elapsed().as_millis() + ); + stream = Some(s); + break; + } + Err(e) => println!( + "attempt {attempt} failed in {}ms: {e}", + t.elapsed().as_millis() + ), + } + } + let stream = stream.expect("exit stream opened within retries"); + + let url = "wss://relay.goblin.st"; + let (mut ws, _resp) = tokio::time::timeout( + Duration::from_secs(45), + tokio_tungstenite::client_async_tls(url, stream), + ) + .await + .expect("TLS+ws handshake timed out (dead exit?)") + .expect("TLS+ws handshake through exit failed"); + println!("TLS+ws handshake through .8 exit OK"); + + ws.send(Message::Text( + r#"["REQ","smoke",{"kinds":[1],"limit":1}]"#.into(), + )) + .await + .expect("send REQ"); + + let reply = tokio::time::timeout(Duration::from_secs(30), ws.next()) + .await + .expect("relay reply timed out") + .expect("ws stream closed early") + .expect("ws frame error"); + let txt = match reply { + Message::Text(t) => t.to_string(), + other => format!("{other:?}"), + }; + println!("relay answered through exit: {txt}"); + assert!( + txt.contains("EVENT") || txt.contains("EOSE"), + "unexpected relay reply: {txt}" + ); + } +} diff --git a/src/nym/transport.rs b/src/nym/transport.rs index 30558d9..93e7df0 100644 --- a/src/nym/transport.rs +++ b/src/nym/transport.rs @@ -12,12 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! WebSocket transport for the Nostr relay pool routed through Goblin's -//! in-process Nym SOCKS5 client, so every relay connection traverses the 5-hop -//! Nym mixnet. We open a SOCKS5 connection to `127.0.0.1:1080`, ask the proxy -//! to reach the relay host (`socks5h`-style: the proxy does the DNS, so the -//! destination is never resolved on the clear), then run the TLS + websocket -//! handshake over that tunnel. Nothing goes clearnet. +//! WebSocket transport for the Nostr relay pool routed through the Nym +//! mixnet, with TWO egresses picked per relay. ANCHOR: a relay whose pool +//! entry advertises its operator's co-located scoped exit +//! ([`crate::nostr::pool::PoolRelay::exit`]) is dialed over a MixnetStream +//! straight to that exit ([`super::streamexit`]) — no DNS, no public IPR. +//! FALLBACK (and every relay without an exit): Goblin's in-process smolmix +//! tunnel — the relay host is resolved by [`super::dns`], the TCP stream is +//! opened via `tunnel.tcp_connect`. Either way the SAME TLS (rustls, webpki +//! roots) + websocket handshake runs over the mixnet-carried stream, so the +//! payload + in-flight destination never touch the clear, and an exit failure +//! only ever falls back — never a lockout. use std::fmt; use std::pin::Pin; @@ -30,7 +35,6 @@ use nostr_relay_pool::transport::error::TransportError; use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport}; use nostr_sdk::Url; use nostr_sdk::util::BoxedFuture; -use tokio_socks::tcp::Socks5Stream; use tokio_tungstenite::tungstenite::Message as TgMessage; /// Error type for transport failures outside the websocket layer. @@ -49,7 +53,7 @@ fn terr(msg: impl Into) -> TransportError { TransportError::backend(NymTransportError(msg.into())) } -/// Nostr websocket transport over the local Nym SOCKS5 proxy. +/// Nostr websocket transport over the in-process Nym mixnet tunnel. #[derive(Debug, Clone, Copy, Default)] pub struct NymWebSocketTransport; @@ -74,17 +78,60 @@ impl WebSocketTransport for NymWebSocketTransport { _ => 443, }); - // Dial the relay host through the local Nym SOCKS5 client. The proxy - // resolves the host inside the mixnet, so no clearnet DNS leak. - let stream = tokio::time::timeout( - timeout, - Socks5Stream::connect(crate::nym::socks5_addr().as_str(), (host.as_str(), port)), - ) - .await - .map_err(|_| terr("nym socks5 connect timeout"))? - .map_err(|e| terr(format!("nym socks5 connect failed: {e}")))?; + // MONEY-PATH ANCHOR: when the pool advertises this relay + // operator's co-located scoped Nym exit, dial THROUGH it — a + // MixnetStream straight to the exit (which pipes to its one + // relay), no public DNS, no public IPR, no tunnel dependency. The + // TLS + websocket wrap inside is byte-for-byte the tunnel path's + // (same `client_async_tls`, SNI = the relay host), so the exit + // sees only ciphertext. ANY failure — bootstrap, open, handshake, + // timeout — falls through to the public-IPR tunnel dial below: + // anchor + fallback, never pin-only. + if let Some(exit) = crate::nostr::pool::load().exit_for(url.as_str()) { + let t_exit = std::time::Instant::now(); + match exit_connect(url, &exit, timeout).await { + Ok(parts) => { + log::info!( + "[timing] nym: relay {host} CONNECTED via scoped exit — \ + stream+tls+ws {}ms", + t_exit.elapsed().as_millis() + ); + return Ok(parts); + } + Err(e) => log::warn!( + "nym: scoped exit dial for {host} failed after {}ms ({e}); \ + falling back to the public-IPR tunnel", + t_exit.elapsed().as_millis() + ), + } + } + + // The shared mixnet tunnel (lazy-started at app launch). + let tunnel = crate::nym::nymproc::wait_for_tunnel(timeout) + .await + .ok_or_else(|| terr("nym tunnel not ready"))?; + + // Resolve the relay host (clearnet by default — see nym::dns), then + // dial the resolved IP THROUGH the same tunnel so the TCP, TLS and + // websocket all still ride the mixnet. Each stage is timed so the + // connect-timing harness can attribute cost per relay. + let t_resolve = std::time::Instant::now(); + let addr = + tokio::time::timeout(timeout, crate::nym::dns::resolve(&tunnel, &host, port)) + .await + .map_err(|_| terr("dns resolve timeout"))? + .ok_or_else(|| terr(format!("could not resolve relay host {host}")))?; + let resolve_ms = t_resolve.elapsed().as_millis(); + + let t_tcp = std::time::Instant::now(); + let stream = tokio::time::timeout(timeout, tunnel.tcp_connect(addr)) + .await + .map_err(|_| terr("nym tunnel connect timeout"))? + .map_err(|e| terr(format!("nym tunnel connect failed: {e}")))?; + let tcp_ms = t_tcp.elapsed().as_millis(); // Perform TLS (for wss) + websocket handshake over the mixnet stream. + let t_ws = std::time::Instant::now(); let (ws, _response) = tokio::time::timeout( timeout, tokio_tungstenite::client_async_tls(url.as_str(), stream), @@ -92,22 +139,61 @@ impl WebSocketTransport for NymWebSocketTransport { .await .map_err(|_| terr("websocket handshake timeout"))? .map_err(|e| terr(format!("websocket handshake failed: {e}")))?; + log::info!( + "[timing] nym: relay {host} CONNECTED — resolve {resolve_ms}ms, \ + tcp_connect(mixnet) {tcp_ms}ms, tls+ws(mixnet) {}ms", + t_ws.elapsed().as_millis() + ); - let (tx, rx) = ws.split(); - - let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink; - let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move { - match msg { - Ok(tg) => tg_to_message(tg).map(Ok), - Err(e) => Some(Err(TransportError::backend(e))), - } - })) as WebSocketStream; - - Ok((sink, stream)) + Ok(split_ws(ws)) }) } } +/// Dial `url` through the relay operator's scoped Nym exit `exit`: a +/// MixnetStream to the exit (which pipes to its one configured relay), then +/// the SAME hostname-validated TLS + websocket handshake as the tunnel path. +/// The handshake doubles as the exit liveness probe — `open_stream` is +/// fire-and-forget, so a dead exit surfaces here as a (bounded) timeout and +/// the caller falls back. +async fn exit_connect( + url: &Url, + exit: &str, + timeout: Duration, +) -> Result<(WebSocketSink, WebSocketStream), TransportError> { + let stream = crate::nym::streamexit::open_stream(exit, timeout) + .await + .map_err(terr)?; + let (ws, _response) = tokio::time::timeout( + timeout, + tokio_tungstenite::client_async_tls(url.as_str(), stream), + ) + .await + .map_err(|_| terr("websocket handshake timeout (exit stream)"))? + .map_err(|e| terr(format!("websocket handshake failed: {e}")))?; + Ok(split_ws(ws)) +} + +/// Split a websocket into the pool's boxed sink/stream halves — shared by the +/// scoped-exit and tunnel dial paths, so everything above the byte transport +/// is identical whichever egress carried the connection. +fn split_ws(ws: tokio_tungstenite::WebSocketStream) -> (WebSocketSink, WebSocketStream) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static, +{ + let (tx, rx) = ws.split(); + + let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink; + let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move { + match msg { + Ok(tg) => tg_to_message(tg).map(Ok), + Err(e) => Some(Err(TransportError::backend(e))), + } + })) as WebSocketStream; + + (sink, stream) +} + /// Convert a tungstenite message into an async-wsocket pool message. /// Returns `None` for raw frames (never surfaced while reading). fn tg_to_message(msg: TgMessage) -> Option { diff --git a/src/wallet/connections/external.rs b/src/wallet/connections/external.rs index 2b59279..0183c89 100644 --- a/src/wallet/connections/external.rs +++ b/src/wallet/connections/external.rs @@ -38,17 +38,16 @@ pub struct ExternalConnection { pub available: Option, } -/// Default external node URLs for main network. api.grin.money leads (verified -/// healthy; grincoin.org's node was returning "rpc call failed"); main.us-ea.st -/// is the Goblin-run node. The rest are independent public nodes so a single -/// operator going down never strands the wallet. -const DEFAULT_MAIN_URLS: [&'static str; 6] = [ - "https://api.grin.money", - "https://main.us-ea.st", +/// Default external node URLs for main network. grincoin.org leads (owner-verified: +/// `/v2/foreign` get_tip returns cleanly). api.grin.money was REMOVED this build: it +/// errors ("Cannot parse response") on `get_unspent_outputs` during a fresh-wallet +/// full scan, surfacing as the "error during synchronization" screen. main.gri.mw and +/// mainnet.grinffindor.org are the other verified-working public nodes, so a single +/// operator going down never strands the wallet. Users can still add their own node. +const DEFAULT_MAIN_URLS: [&'static str; 3] = [ "https://grincoin.org", "https://main.gri.mw", "https://mainnet.grinffindor.org", - "https://main.grin.raubritter.org", ]; /// Default external node URLs for the test network — the testnet counterparts of diff --git a/src/wallet/e2e.rs b/src/wallet/e2e.rs new file mode 100644 index 0000000..089989b --- /dev/null +++ b/src/wallet/e2e.rs @@ -0,0 +1,209 @@ +// 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. Two real Goblin +//! wallets restored from mainnet mnemonics (seeds via env, NEVER a file) connect +//! to `wss://relay.goblin.st` — which rides the scoped Nym exit (.8) per the +//! pinned pool — and one sends a real gift-wrapped Grin payment to the other, +//! asynchronously through the relay. Proves the whole money path a phone would +//! use: mixnet -> exit -> relay -> 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 + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use grin_util::types::ZeroingString; + + use crate::nostr::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; + /// Public mainnet node for the recovery scan + tx post. + const NODE_URL: &str = "https://grincoin.org"; + + /// Build + open a wallet from a 24-word mnemonic on an external node. + fn open_wallet(name: &str, phrase: &str, pw: &ZeroingString, conn_id: i64) -> Wallet { + let mut m = Mnemonic::default(); + m.set_mode(PhraseMode::Import); + m.import(&ZeroingString::from(phrase)); + assert!( + m.valid(), + "{name}: mnemonic did not validate (bad seed words?)" + ); + 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}")); + w.open(pw.clone()) + .unwrap_or_else(|e| panic!("{name}: wallet open failed: {e}")); + w + } + + /// 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::nym::warm_up(); + assert!( + wait_until("nym tunnel is_ready", 180, crate::nym::is_ready), + "nym tunnel never came up" + ); + + // Register the mainnet node once; reuse its id for both wallets. + let node = ExternalConnection::new(NODE_URL.to_string(), Some("grin".to_string()), None); + let conn_id = node.id; + ConnectionsConfig::add_ext_conn(node); + + let pw = ZeroingString::from("e2e-test-pass"); + + println!("[e2e] opening wallet A..."); + let a = open_wallet("goblin-e2e-a", seed_a.trim(), &pw, conn_id); + // 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_id); + + // Nostr services connect to relay.goblin.st (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_conn = Instant::now(); + assert!( + wait_until("A nostr connected", 120, || a_svc.is_connected()), + "A never connected to a relay" + ); + assert!( + wait_until("B nostr connected", 120, || b_svc.is_connected()), + "B never connected to a relay" + ); + println!( + "[e2e] both goblins connected to the relay over the exit in {}s", + t_conn.elapsed().as_secs() + ); + println!("[e2e] A npub = {}", a_svc.npub()); + println!("[e2e] B npub = {}", b_svc.npub()); + + // Recovery scan: concurrent across both wallets. 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. + 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 over the floonet relay. + 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. + let finalized = wait_until("payment finalized", 420, || { + 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: two goblins completed a payment over the floonet relay"); + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 8884aaa..1d6d2bd 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -34,3 +34,6 @@ pub use utils::WalletUtils; mod seed; pub mod store; + +#[cfg(test)] +mod e2e; diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index a2f9da2..b96350f 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -53,7 +53,7 @@ use std::io::Write; use std::net::{SocketAddr, TcpListener, ToSocketAddrs}; use std::path::PathBuf; use std::str::FromStr; -use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicU64, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Arc, mpsc}; use std::thread::Thread; @@ -85,6 +85,14 @@ pub struct Wallet { sync_thread: Arc>>, /// Flag to check if wallet is syncing. syncing: Arc, + /// On-demand node polling (Android battery): pause the heavy node sync at + /// sync thread while the app is backgrounded and nothing is in flight. + /// The relay+Nym nostr service keeps running regardless of this flag. + node_polling_paused: Arc, + /// Resume-signal counter closing the receipt-vs-pause race: bumped by + /// [`Wallet::resume_node_polling`]; the sync thread only pauses when no + /// resume arrived during the node sync it just completed. + node_polling_resume_seq: Arc, /// Info loading progress in percents. info_sync_progress: Arc, /// Error on wallet loading. @@ -161,6 +169,8 @@ impl Wallet { account_time: Arc::new(Default::default()), sync_thread: Arc::from(RwLock::new(None)), syncing: Arc::new(AtomicBool::new(false)), + node_polling_paused: Arc::new(AtomicBool::new(false)), + node_polling_resume_seq: Arc::new(AtomicU64::new(0)), info_sync_progress: Arc::from(AtomicU8::new(0)), sync_error: Arc::from(AtomicBool::new(false)), sync_attempts: Arc::new(AtomicU8::new(0)), @@ -573,13 +583,8 @@ impl Wallet { backup-password field." .to_string() })?; - let mut ident = NostrIdentity::from_unlocked_keys( - &keys, - &password, - backup.source, - backup.derivation_account, - ) - .map_err(|e| format!("re-encryption failed: {e}"))?; + let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source) + .map_err(|e| format!("re-encryption failed: {e}"))?; ident.nip05 = backup.nip05.clone(); ident.anonymous = backup.anonymous; ident.prev_npubs = backup.prev_npubs.clone(); @@ -595,13 +600,8 @@ impl Wallet { field" .to_string() })?; - let mut ident = NostrIdentity::from_unlocked_keys( - &keys, - &password, - backup.source, - backup.derivation_account, - ) - .map_err(|e| format!("re-encryption failed: {e}"))?; + let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source) + .map_err(|e| format!("re-encryption failed: {e}"))?; ident.nip05 = backup.nip05.clone(); ident.anonymous = backup.anonymous; ident.prev_npubs = backup.prev_npubs.clone(); @@ -1153,6 +1153,25 @@ impl Wallet { self.syncing.load(Ordering::Relaxed) } + /// Check if the heavy node polling at sync thread is paused (on-demand + /// node polling: Android battery optimization, never set on desktop). + pub fn node_polling_paused(&self) -> bool { + self.node_polling_paused.load(Ordering::SeqCst) + } + + /// Resume node polling and wake the sync thread. Called when the app is + /// foreground again (the user expects a live balance) and when a slatepack + /// arrives needing node work (post/confirm). MONEY-SAFETY: bumps the + /// resume counter first so a pause decision computed from an older sync + /// snapshot can never override this signal (see + /// [`maybe_pause_node_polling`]). + pub fn resume_node_polling(&self) { + self.node_polling_resume_seq.fetch_add(1, Ordering::SeqCst); + if self.node_polling_paused.swap(false, Ordering::SeqCst) { + self.sync(); + } + } + /// Get running Foreign API server port. pub fn foreign_api_port(&self) -> Option { let r_api = self.foreign_api_server.read(); @@ -2075,8 +2094,22 @@ fn start_sync(wallet: Wallet) -> Thread { } } - // Sync wallet from node. - sync_wallet_data(&wallet, true); + // On-demand node polling (Android battery): while the app is + // backgrounded and no transaction is waiting on the node, skip + // the heavy node sync. The relay+Nym nostr service started + // above keeps running and listening for gift wraps regardless; + // a slatepack receipt resumes polling instantly (see + // `resume_node_polling`). Foreground always polls. + if crate::app_foreground() { + wallet.resume_node_polling(); + } + if !wallet.node_polling_paused() { + let resume_seq = wallet.node_polling_resume_seq.load(Ordering::SeqCst); + // Sync wallet from node. + sync_wallet_data(&wallet, true); + // Pause polling when it's safe to (Android only). + maybe_pause_node_polling(&wallet, resume_seq); + } } // Stop sync if wallet was closed. @@ -2106,6 +2139,57 @@ fn start_sync(wallet: Wallet) -> Thread { .clone() } +/// Pause the heavy node polling after a completed node sync when it's safe +/// (Android only — desktop always polls): the app is backgrounded AND the +/// fresh sync shows nothing waiting on the node AND no resume signal +/// (slatepack receipt / foreground) arrived while that sync ran. +/// MONEY-SAFETY (non-negotiable): confirmation tracking is never dropped — +/// any unconfirmed send/receive keeps the node polled until it confirms, and +/// when in doubt (failed sync, no data, unknown txs) we keep polling. +#[allow(unused_variables)] +fn maybe_pause_node_polling(wallet: &Wallet, resume_seq_before: u64) { + #[cfg(target_os = "android")] + { + // Foreground: the user expects a live balance. + if crate::app_foreground() { + return; + } + // Only pause after a clean, settled sync from the node. + if wallet.sync_error() || wallet.get_sync_attempts() != 0 { + return; + } + let Some(data) = wallet.get_data() else { + return; + }; + // Anything unconfirmed — a send awaiting reply/broadcast or a receive + // awaiting confirmation — keeps the node polled until it confirms. + // Unknown txs count as in flight (when in doubt, poll). + let in_flight = data + .txs + .as_ref() + .map(|txs| { + txs.iter().any(|tx| { + !tx.data.confirmed + && matches!( + tx.data.tx_type, + TxLogEntryType::TxSent | TxLogEntryType::TxReceived + ) + }) + }) + .unwrap_or(true); + if in_flight { + return; + } + wallet.node_polling_paused.store(true, Ordering::SeqCst); + // A slatepack receipt (or foreground) may have raced this pause while + // the sync above ran — its transaction may not be in the data snapshot + // we just inspected. The resume always wins. + if wallet.node_polling_resume_seq.load(Ordering::SeqCst) != resume_seq_before { + wallet.node_polling_paused.store(false, Ordering::SeqCst); + } + } +} + /// Map a wallet error to a short, user-facing reason for the failure screen so /// "Couldn't send" actually explains itself — most often locked/unconfirmed /// funds after a recent payment. @@ -2369,6 +2453,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { nip05: None, nip05_verified_at: None, relays: relay_hints.clone(), + nip44_v3: false, hue: crate::gui::views::goblin::data::hue_of(receiver) as u8, unknown: true, diff --git a/tests/connect_timing.rs b/tests/connect_timing.rs new file mode 100644 index 0000000..0c6bd4b --- /dev/null +++ b/tests/connect_timing.rs @@ -0,0 +1,315 @@ +// 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/xrelay_smoke.rs b/tests/xrelay_smoke.rs new file mode 100644 index 0000000..b400f28 --- /dev/null +++ b/tests/xrelay_smoke.rs @@ -0,0 +1,517 @@ +// 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; +} diff --git a/wallet b/wallet index c2db754..906dc55 160000 --- a/wallet +++ b/wallet @@ -1 +1 @@ -Subproject commit c2db754552b9e5c57c4a843c68744df0cc744ff8 +Subproject commit 906dc55b9513ba60f76cc33de1372ea652be2a53