1
0
forked from GRIN/grim

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.
This commit is contained in:
2ro
2026-07-02 04:17:59 -04:00
parent 1e8e0f6526
commit c701f0f480
56 changed files with 5741 additions and 950 deletions
+5
View File
@@ -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
Generated
+151 -41
View File
@@ -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"
+33 -14
View File
@@ -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"] }
@@ -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)) {
@@ -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) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 984 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 B

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+300
View File
@@ -0,0 +1,300 @@
# GRIM to Goblin deviations audit
Audit date: 2026-07-01 (supersedes and incorporates the Build 39 deviation audit of 2026-06-12).
Comparison snapshot:
- Goblin: `git.us-ea.st/GRIN/goblin` at `1e8e0f6`, plus uncommitted Phase-0 UI work in the tree
(avatar, back-nav, balance, notification edits, new locale keys, `examples/avatar_ring.rs`).
Phase-0 changes are treated as intentional Goblin-side additive work, not drift.
- GRIM: `code.gri.mw/GUI/grim` local clone at `ee88415`, which is exactly `origin/master` (0 ahead, 0 behind).
- The two repos have separate git histories (no merge base), so this audit is a working tree
directory diff, not a git diff.
## 1. Repo topology, corrected
Earlier notes described `goblin/wallet` as a plain vendored directory. That is wrong. Both grin
crates are real git submodules in Goblin (`goblin/.gitmodules`):
- `node/` -> `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.
+128
View File
@@ -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<egui::TextureHandle>,
}
/// 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)))),
)
}
+10 -2
View File
@@ -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…"
+10 -2
View File
@@ -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…"
+10 -2
View File
@@ -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…"
+10 -2
View File
@@ -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: "Обработка…"
+10 -2
View File
@@ -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…"
+10 -2
View File
@@ -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: "处理中…"
+5 -2
View File
@@ -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
+12
View File
@@ -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
+31 -7
View File
@@ -330,14 +330,38 @@ impl<Platform: PlatformCallbacks> App<Platform> {
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| {
+48
View File
@@ -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<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
/// Picked file path.
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// App handle for JNI calls from threads without a platform reference.
static ref ANDROID_APP: Arc<RwLock<Option<AndroidApp>>> = 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.
-6
View File
@@ -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()
+3
View File
@@ -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"))
+33 -43
View File
@@ -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<String>,
@@ -44,7 +43,6 @@ pub struct ActivityItem {
pub struct ReceiptDetail {
pub tx_id: u32,
pub title: String,
pub hue: usize,
pub npub: Option<String>,
pub amount: u64,
pub incoming: bool,
@@ -79,18 +77,16 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
let meta: Option<TxNostrMeta> = 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<ReceiptDetail> {
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<Option<String>> {
}
}
/// 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
+19 -14
View File
@@ -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##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><defs><linearGradient id="g" gradientUnits="objectBoundingBox" gradientTransform="rotate({angle:.1},0.5,0.5)"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs><rect width="{size}" height="{size}" fill="url(#g)"/></svg>"##
)
}
/// 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
+292 -154
View File
@@ -91,8 +91,12 @@ pub struct GoblinWalletView {
request_amount: Option<String>,
/// 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<dyn crate::gui::views::network::types::NodeTab>,
/// 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<std::time::Instant>,
/// "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<Option<egui::TextureHandle>> = 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<char> = 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<String> = 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::<String>);
} 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) {
+28 -7
View File
@@ -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<OnbImport>,
/// Moment the recovery phrase was copied, for the transient "Copied" check.
words_copied: Option<std::time::Instant>,
}
/// 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")
+26 -40
View File
@@ -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<String>,
@@ -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<Option<egui::TextureHandle>> = 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<Candidate> = 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()
+150 -126
View File
@@ -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..])
}
+1 -1
View File
@@ -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(
+5 -5
View File
@@ -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() {
+11 -2
View File
@@ -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
+17 -2
View File
@@ -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<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
+17 -2
View File
@@ -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();
}
+387 -111
View File
@@ -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<String>,
}
/// 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<String> {
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<String>,
v3: bool,
receiver: PublicKey,
content: String,
tags: Vec<Tag>,
) -> Result<String, String> {
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<String>, bool) {
let (urls, v3) = self.fetch_dm_relays(client, receiver).await;
if !urls.is_empty() {
return (urls, v3);
}
let mut urls: Vec<String> = 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<String> {
// 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<String>, 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<NostrService>, 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<NostrService>, 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<String> = 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>) {
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<NostrService>, client: &Client) {
let relays = svc.relays();
let dm_tags: Vec<Tag> = relays
let advertised: Vec<String> = svc.relays().into_iter().take(MAX_DM_RELAYS).collect();
let mut dm_tags: Vec<Tag> = 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<NostrService>, 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<String> = 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<NostrService>, wallet: &Wallet, slate_id: &str,
}
}
async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client, event: Event) {
async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, event: Event) {
// 0. Only gift wraps.
if event.kind != Kind::GiftWrap {
return;
@@ -1178,8 +1434,10 @@ async fn handle_wrap(svc: &Arc<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, 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,
+7 -3
View File
@@ -104,12 +104,16 @@ impl NostrConfig {
}
pub fn relays(&self) -> Vec<String> {
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<Vec<String>> {
self.relays.clone().filter(|r| !r.is_empty())
}
pub fn set_relays(&mut self, relays: Vec<String>) {
self.relays = Some(relays);
self.save();
+22 -55
View File
@@ -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<String>,
/// 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<String>,
}
/// 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, IdentityError> {
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<NostrIdentity, IdentityError> {
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<NostrIdentity, IdentityError> {
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!(
+3
View File
@@ -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::*;
+1 -52
View File
@@ -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<u8>,
) -> Result<String, String> {
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::<serde_json::Value>(&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::<serde_json::Value>(&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<Option<String>> {
+535
View File
@@ -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<String>,
/// Last-vetted date; presence marks the entry as vetted.
#[serde(default)]
pub vetted: Option<String>,
/// 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` (`<client>.<enc>@<gateway>`) 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<String>,
}
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<PoolRelay>,
}
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<RelayPool> {
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<PoolRelay> {
self.relays
.iter()
.filter(|r| r.has_role("dm"))
.cloned()
.collect()
}
/// Urls of entries carrying the "discovery" role.
pub fn discovery_relays(&self) -> Vec<String> {
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<String> {
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<String> {
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<HashMap<String, (bool, i64)>> = 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::<serde_json::Value>(&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<String> {
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<String> {
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<PoolRelay> {
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);
}
}
+5
View File
@@ -97,6 +97,11 @@ pub struct Contact {
pub nip05_verified_at: Option<i64>,
/// Known DM relays (kind 10050) of the contact.
pub relays: Vec<String>,
/// 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.
+296
View File
@@ -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<Tag>,
) -> Result<Event, String> {
// 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<UnwrappedGift, String> {
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<UnwrappedGift, String> {
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, &not_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());
}
}
+612
View File
@@ -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<HashMap<String, (Vec<Ipv4Addr>, 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<rustls::ClientConfig> = {
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<SocketAddr> {
// IP literals (v4 or v6) need no lookup at all.
if let Ok(ip) = host.parse::<IpAddr>() {
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<SocketAddr> {
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<Ipv4Addr>, 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<Ipv4Addr>, 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::<u16>();
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<Ipv4Addr>, 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<Ipv4Addr>, u32)> {
let id = rand::random::<u16>();
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<Ipv4Addr>, 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<Ipv4Addr> {
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<Ipv4Addr>, 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::<u16>();
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<Vec<u8>> {
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<Ipv4Addr>, 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);
}
}
+245 -42
View File
@@ -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<Vec<u8>>,
headers: Vec<(String, String)>,
) -> Option<(u16, Vec<u8>)> {
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("<no-host>").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<Vec<u8>>,
headers: &[(String, String)],
) -> Option<(u16, Vec<u8>, Option<String>)> {
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<dyn Stream> = 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<Box<dyn Stream>> {
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<dyn Stream>)
};
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<T: AsyncRead + AsyncWrite + Send + Unpin> 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<S>(host: &str, stream: S) -> Option<tokio_rustls::client::TlsStream<S>>
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<rustls::ClientConfig> = {
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()
}
+706
View File
@@ -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<Option<Tunnel>> = 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> {
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<Tunnel> {
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<Instant> = 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=<recipient>` 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<Recipient> {
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 (`<client_id>.<client_enc>@<gateway_id>`). 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<Recipient> {
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<Recipient>) -> Result<Tunnel, smolmix::SmolmixError> {
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).
}
}
-154
View File
@@ -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). `<home>/.goblin/nym`. `None` ⇒ fall back to ephemeral in-memory keys.
fn storage_dir() -> Option<PathBuf> {
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<Socks5MixnetClient, nym_sdk::Error> {
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
}
}
}
+229
View File
@@ -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<T: AsyncRead + AsyncWrite + Send + Unpin> 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<dyn ExitStream>;
/// 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<Option<MixnetClient>> = 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
/// (`<client>.<enc>@<gateway>`) 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<BoxedStream, String> {
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<MixnetStream, String> {
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}"
);
}
}
+114 -28
View File
@@ -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<String>) -> 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<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (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<Message> {
+7 -8
View File
@@ -38,17 +38,16 @@ pub struct ExternalConnection {
pub available: Option<bool>,
}
/// 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
+209
View File
@@ -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");
}
}
+3
View File
@@ -34,3 +34,6 @@ pub use utils::WalletUtils;
mod seed;
pub mod store;
#[cfg(test)]
mod e2e;
+102 -17
View File
@@ -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<RwLock<Option<Thread>>>,
/// Flag to check if wallet is syncing.
syncing: Arc<AtomicBool>,
/// 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<AtomicBool>,
/// 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<AtomicU64>,
/// Info loading progress in percents.
info_sync_progress: Arc<AtomicU8>,
/// 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<u16> {
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,
+315
View File
@@ -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()
),
}
}
}
+517
View File
@@ -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 <host> -> <ip> ..." — each relay resolved OVER the tunnel
// * "v3 delivered + decrypted" — a real 0x03 wrap crossed the wire
use std::time::{Duration, Instant};
use grim::nostr::{protocol, wrapv3};
use grim::nym::NymWebSocketTransport;
use nostr_sdk::prelude::*;
/// A small but valid-looking slatepack armor block (same fixture the in-tree
/// wrapv3 unit test uses), so extraction is exercised end to end.
const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \
dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \
ENDSLATEPACK.";
const SUBJECT: &str = "lunch :)";
/// Install the ring crypto provider (the app does this in `grim::start()`; a
/// test binary must do it itself or the first TLS handshake panics — Build
/// 65/66 rule) and route logs to stdout at debug so the tunnel + mix-dns lines
/// are visible under --nocapture. Both are idempotent.
fn init() {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = env_logger::builder()
.is_test(false)
.filter_level(log::LevelFilter::Info)
.filter_module("grim::nym", log::LevelFilter::Debug)
.parse_default_env() // honor RUST_LOG if set
.try_init();
}
/// Bring the shared in-process mixnet tunnel up before any relay dial, exactly
/// like the real service loop (client.rs `run_service`). Panics if the mixnet
/// never bootstraps — that IS the blocker the on-chain test would hit.
async fn ensure_tunnel() {
grim::nym::warm_up();
let started = Instant::now();
for _ in 0..240 {
if grim::nym::is_ready() {
eprintln!(
"[harness] mixnet tunnel ready after ~{}ms",
started.elapsed().as_millis()
);
return;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
panic!(
"BLOCKER: mixnet tunnel never became ready after {}s — smolmix bootstrap failed \
(see nym: log lines above). On-chain payment test cannot proceed.",
started.elapsed().as_secs()
);
}
/// Build a Goblin-style client for `keys` over the real mixnet transport —
/// byte-for-byte the builder from `src/nostr/client.rs::run_service`.
fn goblin_client(keys: &Keys) -> Client {
Client::builder()
.signer(keys.clone())
.websocket_transport(NymWebSocketTransport)
.build()
}
/// Advertise a kind-10050 DM-relay list for `who` pointing at `inbox_relays`,
/// carrying the v3 encryption capability, so the wire shape matches what a real
/// Goblin peer publishes (client.rs `publish_identity`). Best-effort.
async fn advertise_inbox(client: &Client, inbox_relays: &[&str]) {
let mut tags: Vec<Tag> = inbox_relays
.iter()
.map(|r| Tag::custom(TagKind::custom("relay"), [r.to_string()]))
.collect();
tags.push(Tag::custom(
TagKind::custom("encryption"),
[wrapv3::ENCRYPTION_CAPABILITY.to_string()],
));
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
let targets: Vec<String> = inbox_relays.iter().map(|s| s.to_string()).collect();
match client.sign_event_builder(builder).await {
Ok(ev) => {
if let Err(e) = client.send_event_to(&targets, &ev).await {
eprintln!("[harness] warn: advertise 10050 failed: {e}");
}
}
Err(e) => eprintln!("[harness] warn: sign 10050 failed: {e}"),
}
}
/// Wait up to `timeout` for a kind-1059 gift wrap addressed to `me` on the
/// notification stream, unwrap it through Goblin's version-dispatched
/// `wrapv3::unwrap` (proves the 0x03 path over the wire), and return the sender
/// + rumor. Any other event is ignored.
async fn recv_and_unwrap(
client: &Client,
me: &Keys,
timeout: Duration,
) -> Result<(PublicKey, UnsignedEvent), String> {
let mut notifications = client.notifications();
tokio::time::timeout(timeout, async {
loop {
if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await {
if event.kind != Kind::GiftWrap {
continue;
}
match wrapv3::unwrap(me, &event).await {
Ok(u) => return (u.sender, u.rumor),
// A wrap we cannot open (someone else's) — keep waiting.
Err(e) => {
eprintln!("[harness] ignoring undecryptable wrap: {e}");
continue;
}
}
}
}
})
.await
.map_err(|_| "timed out waiting for gift wrap".to_string())
}
/// Assert the received rumor is exactly the payment DM Alice sent.
fn assert_payment(sender: PublicKey, alice: &Keys, rumor: &UnsignedEvent, content: &str) {
assert_eq!(sender, alice.public_key(), "sender must be Alice");
assert_eq!(
rumor.pubkey,
alice.public_key(),
"rumor author == seal signer"
);
assert_eq!(rumor.kind, Kind::PrivateDirectMessage);
assert_eq!(
rumor.content, content,
"payment content must survive the wire"
);
let armor = protocol::extract_slatepack(&rumor.content).expect("slatepack must extract");
assert!(armor.starts_with("BEGINSLATEPACK.") && armor.ends_with("ENDSLATEPACK."));
assert_eq!(
protocol::extract_subject(&rumor.tags).as_deref(),
Some(SUBJECT)
);
}
/// RELAY-GATED READINESS (the point of the G14 hardening): `transport_ready()`
/// must be FALSE while only the tunnel is up, and become TRUE only once a relay
/// is actually connected+subscribed on the CURRENT tunnel generation — the
/// signal that governs the "Connected over Nym" UI and the exit-health window.
///
/// The bare `nostr_sdk::Client` used here is not the app's `NostrService`, so it
/// doesn't feed the readiness signal on its own; we drive the SAME report the
/// service loop makes (`report_relay_live(tunnel_generation())`) exactly when a
/// relay has connected+subscribed, and assert the gate flips only then. Proves
/// the cross-module contract: tunnel-up alone is NOT ready; a live relay on the
/// current generation IS.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn transport_ready_is_relay_gated() {
init();
ensure_tunnel().await;
let generation = grim::nym::tunnel_generation();
assert!(
generation != 0,
"a live tunnel must have a non-zero generation"
);
// Clear any liveness a prior test left on this (process-global) generation,
// so the assertion is order-independent.
grim::nym::report_relay_down(generation);
assert!(
grim::nym::is_ready(),
"precondition: tunnel (is_ready) must be up"
);
assert!(
!grim::nym::transport_ready(),
"BUG: transport_ready must be FALSE on a warm tunnel with no live relay \
(this is exactly the false 'Connected over Nym' the hardening fixes)"
);
// Bring one relay to connected+subscribed over the mixnet, like the service.
let relay = "wss://relay.damus.io";
let bob = Keys::generate();
let bob_client = goblin_client(&bob);
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
// Wait for the websocket handshake to actually complete over Nym, then feed
// the readiness signal the way `run_service`'s status tick does. A generous
// budget: a relay handshake over the mixnet is variable (seen 10-30s).
let mut connected = false;
for _ in 0..120 {
if bob_client
.relays()
.await
.values()
.any(|r| r.status() == RelayStatus::Connected)
{
connected = true;
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(connected, "BLOCKER: relay never connected over the mixnet");
grim::nym::report_relay_live(generation);
assert!(
grim::nym::transport_ready(),
"transport_ready must be TRUE once a relay is live on the current generation"
);
// A report tagged with an OLDER generation must not keep us 'ready' after a
// (hypothetical) reselect: simulate the generation moving on and confirm the
// stale report no longer counts.
grim::nym::report_relay_live(generation - 1);
// Still ready: the current-generation liveness stands (fetch_max floor).
assert!(
grim::nym::transport_ready(),
"a stale-generation report must not lower current readiness"
);
eprintln!("[harness] relay-gated readiness verified at gen {generation}");
bob_client.disconnect().await;
}
/// CONDEMN + RESELECT (deterministic simulation of a relay-dead exit): with a
/// nostr consumer present but NO relay ever reported live on the current exit,
/// nymproc must condemn the exit within its grace window and rebuild on a fresh
/// auto-selected one (the generation advances), then recover. Proves the
/// exit-health state machine — the whole point of requirement 2 — end to end
/// without needing a naturally bad-for-relays exit (which can't be forced
/// deterministically). In the real app the NostrService DOES report relay-live,
/// so a HEALTHY exit is never condemned (see `v3_cross_relay`).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn dead_for_relays_exit_is_condemned_and_reselected() {
init();
ensure_tunnel().await;
let gen0 = grim::nym::tunnel_generation();
assert!(gen0 != 0, "a live tunnel must have a non-zero generation");
eprintln!(
"[harness] arming relay consumer at gen {gen0}; withholding relay-live to simulate a relay-dead exit"
);
// Arm relay-reachability governance but never report a live relay: nymproc
// must treat this exit as dead-for-our-purposes and reselect.
grim::nym::set_relay_consumer(true);
// Budget generously: condemnation itself takes RELAY_GRACE (~25s), then a
// FRESH mixnet bootstrap follows (variable, seen 5-70s), so allow ~150s for
// the generation to advance.
let started = Instant::now();
let mut advanced = 0u64;
for _ in 0..300 {
let g = grim::nym::tunnel_generation();
if g > gen0 {
advanced = g;
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
// Disarm FIRST so a failed assert can't leave governance armed for later tests.
grim::nym::set_relay_consumer(false);
assert!(
advanced > gen0,
"BLOCKER: a relay-dead exit was not condemned+reselected within {}s (gen stuck at {gen0})",
started.elapsed().as_secs()
);
eprintln!(
"[harness] exit condemned + reselected: gen {gen0} -> {advanced} in ~{}s",
started.elapsed().as_secs()
);
// Recovery: with governance disarmed, the freshly-built tunnel settles ready.
let mut ready = false;
for _ in 0..80 {
if grim::nym::is_ready() {
ready = true;
break;
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
assert!(ready, "tunnel must recover ready after the reselect");
eprintln!(
"[harness] tunnel recovered ready after reselect at gen {}",
grim::nym::tunnel_generation()
);
}
/// SINGLE-RELAY: a NIP-44 v3 gift wrap round-trips between two fresh Goblin
/// identities over ONE relay, entirely through the smolmix tunnel + mix-dns.
/// Proves the migrated transport delivers the v3 path against a real relay.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn v3_roundtrip_single_relay() {
init();
ensure_tunnel().await;
let relay = "wss://relay.damus.io";
let alice = Keys::generate();
let bob = Keys::generate();
eprintln!("[harness] single-relay {relay}");
eprintln!(
"[harness] alice {}",
alice.public_key().to_bech32().unwrap()
);
eprintln!(
"[harness] bob {}",
bob.public_key().to_bech32().unwrap()
);
let bob_client = goblin_client(&bob);
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
advertise_inbox(&bob_client, &[relay]).await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
let alice_client = goblin_client(&alice);
alice_client.add_relay(relay).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some(SUBJECT));
let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap");
assert_eq!(wrap.kind, Kind::GiftWrap);
let sent = Instant::now();
alice_client
.send_event_to(vec![relay.to_string()], &wrap)
.await
.expect("publish v3 wrap over mixnet");
eprintln!("[harness] alice published v3 wrap; waiting for delivery...");
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
.await
.expect("BLOCKER: v3 gift wrap never delivered single-relay");
assert_payment(sender, &alice, &rumor, &content);
eprintln!(
"[harness] v3 delivered + decrypted single-relay in {} ms over {relay}",
sent.elapsed().as_millis()
);
bob_client.disconnect().await;
alice_client.disconnect().await;
}
/// SINGLE-RELAY v2: the unchanged nostr-sdk NIP-44 v2 gift-wrap path
/// (`send_private_msg_to`) delivered over the SAME smolmix transport, unwrapped
/// through Goblin's version-dispatched `wrapv3::unwrap` (which routes 0x02 to
/// the sdk). Proves the migrated transport is payload-version agnostic — a
/// v2-only peer is unaffected over the mixnet.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn v2_roundtrip_single_relay() {
init();
ensure_tunnel().await;
let relay = "wss://relay.damus.io";
let alice = Keys::generate();
let bob = Keys::generate();
eprintln!("[harness] single-relay v2 {relay}");
eprintln!(
"[harness] alice {}",
alice.public_key().to_bech32().unwrap()
);
eprintln!(
"[harness] bob {}",
bob.public_key().to_bech32().unwrap()
);
let bob_client = goblin_client(&bob);
bob_client.add_relay(relay).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
advertise_inbox(&bob_client, &[relay]).await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
let alice_client = goblin_client(&alice);
alice_client.add_relay(relay).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some(SUBJECT));
// nostr-sdk builds a v2 (0x02) gift wrap here.
let sent = Instant::now();
alice_client
.send_private_msg_to([relay], bob.public_key(), content.clone(), tags)
.await
.expect("publish v2 wrap over mixnet");
eprintln!("[harness] alice published v2 wrap; waiting for delivery...");
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
.await
.expect("BLOCKER: v2 gift wrap never delivered single-relay");
assert_payment(sender, &alice, &rumor, &content);
eprintln!(
"[harness] v2 delivered + decrypted single-relay in {} ms over {relay}",
sent.elapsed().as_millis()
);
bob_client.disconnect().await;
alice_client.disconnect().await;
}
/// CROSS-RELAY (the redundancy direction): Bob's inbox is nos.lol ONLY; Alice's
/// home is damus. Alice publishes the SAME v3 wrap redundantly to BOTH relays;
/// Bob, subscribed only on nos.lol, still receives + decrypts it. Proves
/// delivery does not depend on a single shared relay and that the v3 path works
/// over the real mixnet transport across two relays with no overlap in what the
/// two identities read.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore]
async fn v3_cross_relay() {
init();
ensure_tunnel().await;
let alice_home = "wss://relay.damus.io";
let bob_inbox = "wss://nos.lol";
let alice = Keys::generate();
let bob = Keys::generate();
eprintln!("[harness] cross-relay: alice_home={alice_home} bob_inbox={bob_inbox}");
eprintln!(
"[harness] alice {}",
alice.public_key().to_bech32().unwrap()
);
eprintln!(
"[harness] bob {}",
bob.public_key().to_bech32().unwrap()
);
// Bob lives ONLY on nos.lol and advertises it as his inbox.
let bob_client = goblin_client(&bob);
bob_client.add_relay(bob_inbox).await.unwrap();
bob_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
advertise_inbox(&bob_client, &[bob_inbox]).await;
bob_client
.subscribe(
Filter::new()
.kind(Kind::GiftWrap)
.pubkey(bob.public_key())
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
None,
)
.await
.unwrap();
// Alice's home is damus; she also connects to Bob's inbox to deposit there.
let alice_client = goblin_client(&alice);
alice_client.add_relay(alice_home).await.unwrap();
alice_client.add_relay(bob_inbox).await.unwrap();
alice_client.connect().await;
tokio::time::sleep(Duration::from_secs(3)).await;
let content = protocol::build_payment_content(SLATEPACK);
let tags = protocol::build_rumor_tags(Some(SUBJECT));
let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap");
// Redundant publish to BOTH relays; Bob reads only nos.lol.
let sent = Instant::now();
alice_client
.send_event_to(vec![alice_home.to_string(), bob_inbox.to_string()], &wrap)
.await
.expect("publish v3 wrap to both relays over mixnet");
eprintln!(
"[harness] alice published v3 wrap to [{alice_home}, {bob_inbox}]; bob reads only {bob_inbox}"
);
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
.await
.expect("BLOCKER: v3 gift wrap never crossed to bob's inbox relay");
assert_payment(sender, &alice, &rumor, &content);
eprintln!(
"[harness] v3 delivered + decrypted CROSS-RELAY in {} ms (alice@{alice_home} -> bob@{bob_inbox})",
sent.elapsed().as_millis()
);
bob_client.disconnect().await;
alice_client.disconnect().await;
}
+1 -1
Submodule wallet updated: c2db754552...906dc55b95