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.
@@ -24,3 +24,8 @@ Cargo.toml-e
|
|||||||
screenshots/
|
screenshots/
|
||||||
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
|
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
|
||||||
.toolchains/
|
.toolchains/
|
||||||
|
# Runtime wallet/node artifacts + secrets generated by running locally — NEVER commit
|
||||||
|
.owner_api_secret
|
||||||
|
.foreign_api_secret
|
||||||
|
grin-wallet.log
|
||||||
|
grin-wallet.toml
|
||||||
|
|||||||
@@ -2813,6 +2813,47 @@ version = "0.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
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]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
@@ -4496,6 +4537,7 @@ dependencies = [
|
|||||||
"grin_wallet_libwallet",
|
"grin_wallet_libwallet",
|
||||||
"grin_wallet_util",
|
"grin_wallet_util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"hickory-proto",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper 1.8.1",
|
"hyper 1.8.1",
|
||||||
"hyper-proxy2",
|
"hyper-proxy2",
|
||||||
@@ -4509,6 +4551,7 @@ dependencies = [
|
|||||||
"local-ip-address",
|
"local-ip-address",
|
||||||
"log",
|
"log",
|
||||||
"log4rs",
|
"log4rs",
|
||||||
|
"nip44",
|
||||||
"nokhwa",
|
"nokhwa",
|
||||||
"nostr-relay-pool",
|
"nostr-relay-pool",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
@@ -4521,23 +4564,24 @@ dependencies = [
|
|||||||
"qrcodegen",
|
"qrcodegen",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
|
||||||
"rfd",
|
"rfd",
|
||||||
"ring 0.16.20",
|
"ring 0.16.20",
|
||||||
"rkv",
|
"rkv",
|
||||||
"rqrr",
|
"rqrr",
|
||||||
"rust-i18n",
|
"rust-i18n",
|
||||||
"rustls 0.23.40",
|
"rustls 0.23.40",
|
||||||
|
"secp256k1 0.31.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"smolmix",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio 0.2.25",
|
"tokio 0.2.25",
|
||||||
"tokio 1.49.0",
|
"tokio 1.49.0",
|
||||||
"tokio-socks 0.5.3",
|
"tokio-rustls 0.26.4",
|
||||||
"tokio-tungstenite 0.26.2",
|
"tokio-tungstenite 0.26.2",
|
||||||
"tokio-util 0.2.0",
|
"tokio-util 0.2.0",
|
||||||
"toml 0.9.11+spec-1.1.0",
|
"toml 0.9.11+spec-1.1.0",
|
||||||
@@ -4545,6 +4589,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"usvg",
|
"usvg",
|
||||||
"uuid 0.8.2",
|
"uuid 0.8.2",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
"wgpu",
|
"wgpu",
|
||||||
"winit",
|
"winit",
|
||||||
"winresource",
|
"winresource",
|
||||||
@@ -5045,6 +5090,15 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hash32"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -5162,6 +5216,16 @@ dependencies = [
|
|||||||
"http 1.4.0",
|
"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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -5660,7 +5724,6 @@ dependencies = [
|
|||||||
"tokio 1.49.0",
|
"tokio 1.49.0",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots 1.0.7",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6983,6 +7046,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "managed"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -7408,6 +7477,21 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
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]]
|
[[package]]
|
||||||
name = "nodrop"
|
name = "nodrop"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -11017,44 +11101,6 @@ dependencies = [
|
|||||||
"winreg 0.50.0",
|
"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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -11780,6 +11826,17 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "secp256k1-sys"
|
name = "secp256k1-sys"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -11798,6 +11855,15 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secp256k1-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrecy"
|
name = "secrecy"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -12306,6 +12372,36 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "snow"
|
name = "snow"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -13362,6 +13458,20 @@ dependencies = [
|
|||||||
"tokio 1.49.0",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-socks"
|
name = "tokio-socks"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ rkv = "0.20.0"
|
|||||||
usvg = "0.45.1"
|
usvg = "0.45.1"
|
||||||
ring = "0.16.20"
|
ring = "0.16.20"
|
||||||
hyper = { version = "1.6.0", features = ["full"], package = "hyper" }
|
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"
|
http-body-util = "0.1.3"
|
||||||
bytes = "1.11.0"
|
bytes = "1.11.0"
|
||||||
hyper-socks2 = "0.9.1"
|
hyper-socks2 = "0.9.1"
|
||||||
@@ -100,29 +100,42 @@ num-bigint = "0.4.6"
|
|||||||
## nostr
|
## nostr
|
||||||
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
|
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
|
||||||
nostr-relay-pool = "0.44"
|
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"
|
async-wsocket = "0.13"
|
||||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
## HTTP client routed through the local Nym SOCKS5 sidecar (rustls, no native
|
## rustls is pulled by both our TLS (tungstenite, ring) and nym-sdk
|
||||||
## 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
|
|
||||||
## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default,
|
## (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
|
## 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"] }
|
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
|
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary).
|
||||||
## run the SDK's SOCKS5 client on an internal tokio task exposing 127.0.0.1:1080,
|
## Path deps into the local nym checkout, PINNED at rev
|
||||||
## the same loopback seam the transport already dials. Path dep: the local nym
|
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b: it carries the load-bearing local
|
||||||
## checkout carries our Android webpki-roots patch.
|
## 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" }
|
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
|
## NIP-98 payload hashing
|
||||||
sha2 = "0.10.8"
|
sha2 = "0.10.8"
|
||||||
@@ -184,3 +197,9 @@ base64 = "0.22"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
serde_yaml = "0.9"
|
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 boolean mStopped = false;
|
||||||
|
|
||||||
private static final int NOTIFICATION_ID = 1;
|
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 NotificationCompat.Builder mNotificationBuilder;
|
||||||
|
|
||||||
private String mNotificationContentText = "";
|
private String mNotificationContentText = "";
|
||||||
@@ -189,6 +193,40 @@ public class BackgroundService extends Service {
|
|||||||
notificationManager.cancel(NOTIFICATION_ID);
|
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.
|
// Start the service.
|
||||||
public static void start(Context c) {
|
public static void start(Context c) {
|
||||||
if (!isServiceRunning(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.
|
// Notify native code to stop activity (e.g. node) if app was terminated from recent apps.
|
||||||
public native void onTermination();
|
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.
|
// Called from native code to set text into clipboard.
|
||||||
public void copyText(String data) {
|
public void copyText(String data) {
|
||||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||||
@@ -619,7 +625,12 @@ public class MainActivity extends GameActivity {
|
|||||||
// Called from native code to pick the file.
|
// Called from native code to pick the file.
|
||||||
public void pickFile() {
|
public void pickFile() {
|
||||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
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 {
|
try {
|
||||||
mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
|
mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
|
||||||
} catch (android.content.ActivityNotFoundException ex) {
|
} catch (android.content.ActivityNotFoundException ex) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 984 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 615 B After Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -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.
|
||||||
@@ -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)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -365,6 +365,8 @@ goblin:
|
|||||||
cant_reach_node: "Node nicht erreichbar"
|
cant_reach_node: "Node nicht erreichbar"
|
||||||
node_synced: "Node synchronisiert"
|
node_synced: "Node synchronisiert"
|
||||||
syncing: "Synchronisiere…"
|
syncing: "Synchronisiere…"
|
||||||
|
balance_updating: "Guthaben wird aktualisiert…"
|
||||||
|
listening: "Wartet auf Zahlungen"
|
||||||
block: "Block %{height}"
|
block: "Block %{height}"
|
||||||
waiting_for_chain: "Warte auf Chain…"
|
waiting_for_chain: "Warte auf Chain…"
|
||||||
nav_wallet: "Wallet"
|
nav_wallet: "Wallet"
|
||||||
@@ -434,12 +436,14 @@ goblin:
|
|||||||
requesting: "Fordere %{amt}%{tsu} an — teilen, um bezahlt zu werden"
|
requesting: "Fordere %{amt}%{tsu} an — teilen, um bezahlt zu werden"
|
||||||
clear_request: "Anfrage löschen"
|
clear_request: "Anfrage löschen"
|
||||||
share_handle: "Teile deinen Handle, um bezahlt zu werden"
|
share_handle: "Teile deinen Handle, um bezahlt zu werden"
|
||||||
|
share_npub: "Teile deinen npub, um bezahlt zu werden"
|
||||||
copied: "Kopiert"
|
copied: "Kopiert"
|
||||||
copy_nostr_id: "nostr-ID kopieren"
|
copy_nostr_id: "nostr-ID kopieren"
|
||||||
copy_address: "Adresse kopieren"
|
copy_address: "Adresse kopieren"
|
||||||
copy_npub: "npub kopieren"
|
copy_npub: "npub kopieren"
|
||||||
share_message: "Bezahl mich auf Goblin (goblin.st) — %{npub}"
|
share_message: "Bezahl mich auf Goblin (goblin.st) — %{npub}"
|
||||||
privacy_note: "Dein Benutzername ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt."
|
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:
|
profile:
|
||||||
title: "Profil"
|
title: "Profil"
|
||||||
activity: "Aktivität"
|
activity: "Aktivität"
|
||||||
@@ -460,7 +464,10 @@ goblin:
|
|||||||
wallet: "Wallet"
|
wallet: "Wallet"
|
||||||
display_unit: "Anzeigeeinheit"
|
display_unit: "Anzeigeeinheit"
|
||||||
relays: "Relays"
|
relays: "Relays"
|
||||||
|
nostr_relays: "Nostr-Relays"
|
||||||
node: "Node"
|
node: "Node"
|
||||||
|
integrated_node: "Einstellungen des integrierten Nodes"
|
||||||
|
node_advanced: "Erweitert"
|
||||||
slatepacks: "Slatepacks"
|
slatepacks: "Slatepacks"
|
||||||
slatepacks_value: "Manuelle Transaktion"
|
slatepacks_value: "Manuelle Transaktion"
|
||||||
lock_wallet: "Wallet sperren"
|
lock_wallet: "Wallet sperren"
|
||||||
@@ -470,7 +477,7 @@ goblin:
|
|||||||
mixnet_routing: "Mixnet-Routing"
|
mixnet_routing: "Mixnet-Routing"
|
||||||
messages_lookups: "Nachrichten & Abfragen"
|
messages_lookups: "Nachrichten & Abfragen"
|
||||||
auto_accept: "Automatisch annehmen"
|
auto_accept: "Automatisch annehmen"
|
||||||
pairing: "Kopplung"
|
pairing: "Preiswährung"
|
||||||
accept_anyone: "Jeder"
|
accept_anyone: "Jeder"
|
||||||
accept_contacts: "Nur Kontakte"
|
accept_contacts: "Nur Kontakte"
|
||||||
accept_ask: "Immer fragen"
|
accept_ask: "Immer fragen"
|
||||||
@@ -485,6 +492,7 @@ goblin:
|
|||||||
archive: "Archiv"
|
archive: "Archiv"
|
||||||
export_archive: "Archiv exportieren"
|
export_archive: "Archiv exportieren"
|
||||||
wipe_history: "Zahlungsverlauf löschen"
|
wipe_history: "Zahlungsverlauf löschen"
|
||||||
|
wipe_history_confirm: "Zum Löschen erneut tippen — kann nicht rückgängig gemacht werden"
|
||||||
about: "Über"
|
about: "Über"
|
||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Build %{build}"
|
build: "Build %{build}"
|
||||||
@@ -561,7 +569,7 @@ goblin:
|
|||||||
keep_it: "Behalten"
|
keep_it: "Behalten"
|
||||||
release_it: "Freigeben"
|
release_it: "Freigeben"
|
||||||
username: "Benutzername"
|
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"
|
release_username: "Benutzername freigeben"
|
||||||
pick_username: "Benutzernamen wählen — optional"
|
pick_username: "Benutzernamen wählen — optional"
|
||||||
working: "Arbeite…"
|
working: "Arbeite…"
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ goblin:
|
|||||||
cant_reach_node: "Can't reach node"
|
cant_reach_node: "Can't reach node"
|
||||||
node_synced: "Node synced"
|
node_synced: "Node synced"
|
||||||
syncing: "Syncing…"
|
syncing: "Syncing…"
|
||||||
|
balance_updating: "Balance updating…"
|
||||||
|
listening: "Listening for payments"
|
||||||
block: "Block %{height}"
|
block: "Block %{height}"
|
||||||
waiting_for_chain: "Waiting for chain…"
|
waiting_for_chain: "Waiting for chain…"
|
||||||
nav_wallet: "Wallet"
|
nav_wallet: "Wallet"
|
||||||
@@ -434,12 +436,14 @@ goblin:
|
|||||||
requesting: "Requesting %{amt}%{tsu} — share to get paid"
|
requesting: "Requesting %{amt}%{tsu} — share to get paid"
|
||||||
clear_request: "Clear request"
|
clear_request: "Clear request"
|
||||||
share_handle: "Share your handle to get paid"
|
share_handle: "Share your handle to get paid"
|
||||||
|
share_npub: "Share your npub to get paid"
|
||||||
copied: "Copied"
|
copied: "Copied"
|
||||||
copy_nostr_id: "Copy nostr ID"
|
copy_nostr_id: "Copy nostr ID"
|
||||||
copy_address: "Copy address"
|
copy_address: "Copy address"
|
||||||
copy_npub: "Copy npub"
|
copy_npub: "Copy npub"
|
||||||
share_message: "Pay me on Goblin (goblin.st) — %{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: "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:
|
profile:
|
||||||
title: "Profile"
|
title: "Profile"
|
||||||
activity: "Activity"
|
activity: "Activity"
|
||||||
@@ -460,7 +464,10 @@ goblin:
|
|||||||
wallet: "Wallet"
|
wallet: "Wallet"
|
||||||
display_unit: "Display unit"
|
display_unit: "Display unit"
|
||||||
relays: "Relays"
|
relays: "Relays"
|
||||||
|
nostr_relays: "Nostr Relays"
|
||||||
node: "Node"
|
node: "Node"
|
||||||
|
integrated_node: "Integrated node settings"
|
||||||
|
node_advanced: "Advanced"
|
||||||
slatepacks: "Slatepacks"
|
slatepacks: "Slatepacks"
|
||||||
slatepacks_value: "Manual transaction"
|
slatepacks_value: "Manual transaction"
|
||||||
lock_wallet: "Lock wallet"
|
lock_wallet: "Lock wallet"
|
||||||
@@ -470,7 +477,7 @@ goblin:
|
|||||||
mixnet_routing: "Mixnet routing"
|
mixnet_routing: "Mixnet routing"
|
||||||
messages_lookups: "Messages & lookups"
|
messages_lookups: "Messages & lookups"
|
||||||
auto_accept: "Auto-accept"
|
auto_accept: "Auto-accept"
|
||||||
pairing: "Pairing"
|
pairing: "Price currency"
|
||||||
accept_anyone: "Anyone"
|
accept_anyone: "Anyone"
|
||||||
accept_contacts: "Contacts only"
|
accept_contacts: "Contacts only"
|
||||||
accept_ask: "Always ask"
|
accept_ask: "Always ask"
|
||||||
@@ -485,6 +492,7 @@ goblin:
|
|||||||
archive: "Archive"
|
archive: "Archive"
|
||||||
export_archive: "Export archive"
|
export_archive: "Export archive"
|
||||||
wipe_history: "Wipe payment history"
|
wipe_history: "Wipe payment history"
|
||||||
|
wipe_history_confirm: "Tap again to wipe — this can't be undone"
|
||||||
about: "About"
|
about: "About"
|
||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Build %{build}"
|
build: "Build %{build}"
|
||||||
@@ -561,7 +569,7 @@ goblin:
|
|||||||
keep_it: "Keep it"
|
keep_it: "Keep it"
|
||||||
release_it: "Release it"
|
release_it: "Release it"
|
||||||
username: "Username"
|
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"
|
release_username: "Release username"
|
||||||
pick_username: "Pick a username — optional"
|
pick_username: "Pick a username — optional"
|
||||||
working: "Working…"
|
working: "Working…"
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ goblin:
|
|||||||
cant_reach_node: "Nœud injoignable"
|
cant_reach_node: "Nœud injoignable"
|
||||||
node_synced: "Nœud synchronisé"
|
node_synced: "Nœud synchronisé"
|
||||||
syncing: "Synchronisation…"
|
syncing: "Synchronisation…"
|
||||||
|
balance_updating: "Solde en cours de mise à jour…"
|
||||||
|
listening: "En attente de paiements"
|
||||||
block: "Bloc %{height}"
|
block: "Bloc %{height}"
|
||||||
waiting_for_chain: "En attente de la chaîne…"
|
waiting_for_chain: "En attente de la chaîne…"
|
||||||
nav_wallet: "Portefeuille"
|
nav_wallet: "Portefeuille"
|
||||||
@@ -434,12 +436,14 @@ goblin:
|
|||||||
requesting: "Demande de %{amt}%{tsu} — partagez pour être payé"
|
requesting: "Demande de %{amt}%{tsu} — partagez pour être payé"
|
||||||
clear_request: "Effacer la demande"
|
clear_request: "Effacer la demande"
|
||||||
share_handle: "Partagez votre identifiant pour être payé"
|
share_handle: "Partagez votre identifiant pour être payé"
|
||||||
|
share_npub: "Partagez votre npub pour être payé"
|
||||||
copied: "Copié"
|
copied: "Copié"
|
||||||
copy_nostr_id: "Copier l'ID nostr"
|
copy_nostr_id: "Copier l'ID nostr"
|
||||||
copy_address: "Copier l'adresse"
|
copy_address: "Copier l'adresse"
|
||||||
copy_npub: "Copier npub"
|
copy_npub: "Copier npub"
|
||||||
share_message: "Payez-moi sur Goblin (goblin.st) — %{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: "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:
|
profile:
|
||||||
title: "Profil"
|
title: "Profil"
|
||||||
activity: "Activité"
|
activity: "Activité"
|
||||||
@@ -460,7 +464,10 @@ goblin:
|
|||||||
wallet: "Portefeuille"
|
wallet: "Portefeuille"
|
||||||
display_unit: "Unité d'affichage"
|
display_unit: "Unité d'affichage"
|
||||||
relays: "Relais"
|
relays: "Relais"
|
||||||
|
nostr_relays: "Relais Nostr"
|
||||||
node: "Nœud"
|
node: "Nœud"
|
||||||
|
integrated_node: "Paramètres du nœud intégré"
|
||||||
|
node_advanced: "Avancé"
|
||||||
slatepacks: "Slatepacks"
|
slatepacks: "Slatepacks"
|
||||||
slatepacks_value: "Transaction manuelle"
|
slatepacks_value: "Transaction manuelle"
|
||||||
lock_wallet: "Verrouiller le portefeuille"
|
lock_wallet: "Verrouiller le portefeuille"
|
||||||
@@ -470,7 +477,7 @@ goblin:
|
|||||||
mixnet_routing: "Routage par mixnet"
|
mixnet_routing: "Routage par mixnet"
|
||||||
messages_lookups: "Messages et recherches"
|
messages_lookups: "Messages et recherches"
|
||||||
auto_accept: "Acceptation auto"
|
auto_accept: "Acceptation auto"
|
||||||
pairing: "Appairage"
|
pairing: "Devise des prix"
|
||||||
accept_anyone: "Tout le monde"
|
accept_anyone: "Tout le monde"
|
||||||
accept_contacts: "Contacts seulement"
|
accept_contacts: "Contacts seulement"
|
||||||
accept_ask: "Toujours demander"
|
accept_ask: "Toujours demander"
|
||||||
@@ -485,6 +492,7 @@ goblin:
|
|||||||
archive: "Archive"
|
archive: "Archive"
|
||||||
export_archive: "Exporter l'archive"
|
export_archive: "Exporter l'archive"
|
||||||
wipe_history: "Effacer l'historique des paiements"
|
wipe_history: "Effacer l'historique des paiements"
|
||||||
|
wipe_history_confirm: "Appuyez à nouveau pour effacer — action irréversible"
|
||||||
about: "À propos"
|
about: "À propos"
|
||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Build %{build}"
|
build: "Build %{build}"
|
||||||
@@ -561,7 +569,7 @@ goblin:
|
|||||||
keep_it: "Le garder"
|
keep_it: "Le garder"
|
||||||
release_it: "Le libérer"
|
release_it: "Le libérer"
|
||||||
username: "Nom d'utilisateur"
|
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"
|
release_username: "Libérer le nom d'utilisateur"
|
||||||
pick_username: "Choisir un nom d'utilisateur — facultatif"
|
pick_username: "Choisir un nom d'utilisateur — facultatif"
|
||||||
working: "En cours…"
|
working: "En cours…"
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ goblin:
|
|||||||
cant_reach_node: "Нет связи с узлом"
|
cant_reach_node: "Нет связи с узлом"
|
||||||
node_synced: "Узел синхронизирован"
|
node_synced: "Узел синхронизирован"
|
||||||
syncing: "Синхронизация…"
|
syncing: "Синхронизация…"
|
||||||
|
balance_updating: "Баланс обновляется…"
|
||||||
|
listening: "Ожидание платежей"
|
||||||
block: "Блок %{height}"
|
block: "Блок %{height}"
|
||||||
waiting_for_chain: "Ожидание цепочки…"
|
waiting_for_chain: "Ожидание цепочки…"
|
||||||
nav_wallet: "Кошелёк"
|
nav_wallet: "Кошелёк"
|
||||||
@@ -434,12 +436,14 @@ goblin:
|
|||||||
requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату"
|
requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату"
|
||||||
clear_request: "Очистить запрос"
|
clear_request: "Очистить запрос"
|
||||||
share_handle: "Поделитесь именем, чтобы получить оплату"
|
share_handle: "Поделитесь именем, чтобы получить оплату"
|
||||||
|
share_npub: "Поделитесь своим npub, чтобы получить оплату"
|
||||||
copied: "Скопировано"
|
copied: "Скопировано"
|
||||||
copy_nostr_id: "Копировать nostr ID"
|
copy_nostr_id: "Копировать nostr ID"
|
||||||
copy_address: "Копировать адрес"
|
copy_address: "Копировать адрес"
|
||||||
copy_npub: "Копировать npub"
|
copy_npub: "Копировать npub"
|
||||||
share_message: "Заплатите мне в Goblin (goblin.st) — %{npub}"
|
share_message: "Заплатите мне в Goblin (goblin.st) — %{npub}"
|
||||||
privacy_note: "Ваше имя публично. Содержимое платежей остаётся зашифрованным в сети."
|
privacy_note: "Ваше имя публично. Содержимое платежей остаётся зашифрованным в сети."
|
||||||
|
privacy_note_npub: "Ваш npub публичен. Содержимое платежей остаётся зашифрованным в сети."
|
||||||
profile:
|
profile:
|
||||||
title: "Профиль"
|
title: "Профиль"
|
||||||
activity: "Действия"
|
activity: "Действия"
|
||||||
@@ -460,7 +464,10 @@ goblin:
|
|||||||
wallet: "Кошелёк"
|
wallet: "Кошелёк"
|
||||||
display_unit: "Единица отображения"
|
display_unit: "Единица отображения"
|
||||||
relays: "Реле"
|
relays: "Реле"
|
||||||
|
nostr_relays: "Реле Nostr"
|
||||||
node: "Узел"
|
node: "Узел"
|
||||||
|
integrated_node: "Настройки встроенного узла"
|
||||||
|
node_advanced: "Дополнительно"
|
||||||
slatepacks: "Slatepacks"
|
slatepacks: "Slatepacks"
|
||||||
slatepacks_value: "Ручная транзакция"
|
slatepacks_value: "Ручная транзакция"
|
||||||
lock_wallet: "Заблокировать кошелёк"
|
lock_wallet: "Заблокировать кошелёк"
|
||||||
@@ -470,7 +477,7 @@ goblin:
|
|||||||
mixnet_routing: "Маршрутизация через mixnet"
|
mixnet_routing: "Маршрутизация через mixnet"
|
||||||
messages_lookups: "Сообщения и поиск"
|
messages_lookups: "Сообщения и поиск"
|
||||||
auto_accept: "Автоприём"
|
auto_accept: "Автоприём"
|
||||||
pairing: "Привязка"
|
pairing: "Валюта цены"
|
||||||
accept_anyone: "Любой"
|
accept_anyone: "Любой"
|
||||||
accept_contacts: "Только контакты"
|
accept_contacts: "Только контакты"
|
||||||
accept_ask: "Всегда спрашивать"
|
accept_ask: "Всегда спрашивать"
|
||||||
@@ -485,6 +492,7 @@ goblin:
|
|||||||
archive: "Архив"
|
archive: "Архив"
|
||||||
export_archive: "Экспорт архива"
|
export_archive: "Экспорт архива"
|
||||||
wipe_history: "Стереть историю платежей"
|
wipe_history: "Стереть историю платежей"
|
||||||
|
wipe_history_confirm: "Нажмите ещё раз, чтобы стереть — это нельзя отменить"
|
||||||
about: "О приложении"
|
about: "О приложении"
|
||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Сборка %{build}"
|
build: "Сборка %{build}"
|
||||||
@@ -561,7 +569,7 @@ goblin:
|
|||||||
keep_it: "Оставить"
|
keep_it: "Оставить"
|
||||||
release_it: "Освободить"
|
release_it: "Освободить"
|
||||||
username: "Имя пользователя"
|
username: "Имя пользователя"
|
||||||
username_note: "Показывается как you. Публично на goblin.st. Платежи остаются зашифрованными."
|
username_note: "Отображается как ваше имя. Публично на goblin.st. Платежи остаются зашифрованными."
|
||||||
release_username: "Освободить имя"
|
release_username: "Освободить имя"
|
||||||
pick_username: "Выберите имя — необязательно"
|
pick_username: "Выберите имя — необязательно"
|
||||||
working: "Обработка…"
|
working: "Обработка…"
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ goblin:
|
|||||||
cant_reach_node: "Düğüme ulaşılamıyor"
|
cant_reach_node: "Düğüme ulaşılamıyor"
|
||||||
node_synced: "Düğüm eşitlendi"
|
node_synced: "Düğüm eşitlendi"
|
||||||
syncing: "Eşitleniyor…"
|
syncing: "Eşitleniyor…"
|
||||||
|
balance_updating: "Bakiye güncelleniyor…"
|
||||||
|
listening: "Ödemeler bekleniyor"
|
||||||
block: "Blok %{height}"
|
block: "Blok %{height}"
|
||||||
waiting_for_chain: "Zincir bekleniyor…"
|
waiting_for_chain: "Zincir bekleniyor…"
|
||||||
nav_wallet: "Cüzdan"
|
nav_wallet: "Cüzdan"
|
||||||
@@ -434,12 +436,14 @@ goblin:
|
|||||||
requesting: "%{amt}%{tsu} isteniyor — ödeme almak için paylaş"
|
requesting: "%{amt}%{tsu} isteniyor — ödeme almak için paylaş"
|
||||||
clear_request: "İsteği temizle"
|
clear_request: "İsteği temizle"
|
||||||
share_handle: "Ödeme almak için kullanıcı adını paylaş"
|
share_handle: "Ödeme almak için kullanıcı adını paylaş"
|
||||||
|
share_npub: "Ödeme almak için npub'ını paylaş"
|
||||||
copied: "Kopyalandı"
|
copied: "Kopyalandı"
|
||||||
copy_nostr_id: "nostr kimliğini kopyala"
|
copy_nostr_id: "nostr kimliğini kopyala"
|
||||||
copy_address: "Adresi kopyala"
|
copy_address: "Adresi kopyala"
|
||||||
copy_npub: "npub kopyala"
|
copy_npub: "npub kopyala"
|
||||||
share_message: "Goblin'de bana öde (goblin.st) — %{npub}"
|
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: "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:
|
profile:
|
||||||
title: "Profil"
|
title: "Profil"
|
||||||
activity: "Etkinlik"
|
activity: "Etkinlik"
|
||||||
@@ -460,7 +464,10 @@ goblin:
|
|||||||
wallet: "Cüzdan"
|
wallet: "Cüzdan"
|
||||||
display_unit: "Görüntüleme birimi"
|
display_unit: "Görüntüleme birimi"
|
||||||
relays: "Relaylar"
|
relays: "Relaylar"
|
||||||
|
nostr_relays: "Nostr Relayları"
|
||||||
node: "Düğüm"
|
node: "Düğüm"
|
||||||
|
integrated_node: "Tümleşik düğüm ayarları"
|
||||||
|
node_advanced: "Gelişmiş"
|
||||||
slatepacks: "Slatepackler"
|
slatepacks: "Slatepackler"
|
||||||
slatepacks_value: "Manuel işlem"
|
slatepacks_value: "Manuel işlem"
|
||||||
lock_wallet: "Cüzdanı kilitle"
|
lock_wallet: "Cüzdanı kilitle"
|
||||||
@@ -470,7 +477,7 @@ goblin:
|
|||||||
mixnet_routing: "Mixnet yönlendirme"
|
mixnet_routing: "Mixnet yönlendirme"
|
||||||
messages_lookups: "Mesajlar ve aramalar"
|
messages_lookups: "Mesajlar ve aramalar"
|
||||||
auto_accept: "Otomatik kabul"
|
auto_accept: "Otomatik kabul"
|
||||||
pairing: "Eşleştirme"
|
pairing: "Fiyat para birimi"
|
||||||
accept_anyone: "Herkes"
|
accept_anyone: "Herkes"
|
||||||
accept_contacts: "Yalnızca kişiler"
|
accept_contacts: "Yalnızca kişiler"
|
||||||
accept_ask: "Her zaman sor"
|
accept_ask: "Her zaman sor"
|
||||||
@@ -485,6 +492,7 @@ goblin:
|
|||||||
archive: "Arşiv"
|
archive: "Arşiv"
|
||||||
export_archive: "Arşivi dışa aktar"
|
export_archive: "Arşivi dışa aktar"
|
||||||
wipe_history: "Ödeme geçmişini sil"
|
wipe_history: "Ödeme geçmişini sil"
|
||||||
|
wipe_history_confirm: "Silmek için tekrar dokun — geri alınamaz"
|
||||||
about: "Hakkında"
|
about: "Hakkında"
|
||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "Sürüm %{build}"
|
build: "Sürüm %{build}"
|
||||||
@@ -561,7 +569,7 @@ goblin:
|
|||||||
keep_it: "Vazgeç"
|
keep_it: "Vazgeç"
|
||||||
release_it: "Bırak"
|
release_it: "Bırak"
|
||||||
username: "Kullanıcı adı"
|
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"
|
release_username: "Kullanıcı adını bırak"
|
||||||
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
|
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
|
||||||
working: "Çalışıyor…"
|
working: "Çalışıyor…"
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ goblin:
|
|||||||
cant_reach_node: "无法连接节点"
|
cant_reach_node: "无法连接节点"
|
||||||
node_synced: "节点已同步"
|
node_synced: "节点已同步"
|
||||||
syncing: "同步中…"
|
syncing: "同步中…"
|
||||||
|
balance_updating: "余额更新中…"
|
||||||
|
listening: "正在监听付款"
|
||||||
block: "区块 %{height}"
|
block: "区块 %{height}"
|
||||||
waiting_for_chain: "等待链数据…"
|
waiting_for_chain: "等待链数据…"
|
||||||
nav_wallet: "钱包"
|
nav_wallet: "钱包"
|
||||||
@@ -434,12 +436,14 @@ goblin:
|
|||||||
requesting: "正在请求 %{amt}%{tsu} — 分享以收款"
|
requesting: "正在请求 %{amt}%{tsu} — 分享以收款"
|
||||||
clear_request: "清除请求"
|
clear_request: "清除请求"
|
||||||
share_handle: "分享你的用户名以收款"
|
share_handle: "分享你的用户名以收款"
|
||||||
|
share_npub: "分享你的 npub 以收款"
|
||||||
copied: "已复制"
|
copied: "已复制"
|
||||||
copy_nostr_id: "复制 nostr ID"
|
copy_nostr_id: "复制 nostr ID"
|
||||||
copy_address: "复制地址"
|
copy_address: "复制地址"
|
||||||
copy_npub: "复制 npub"
|
copy_npub: "复制 npub"
|
||||||
share_message: "在 Goblin 上向我付款 (goblin.st) — %{npub}"
|
share_message: "在 Goblin 上向我付款 (goblin.st) — %{npub}"
|
||||||
privacy_note: "你的用户名是公开的。付款内容在网络中保持加密。"
|
privacy_note: "你的用户名是公开的。付款内容在网络中保持加密。"
|
||||||
|
privacy_note_npub: "你的 npub 是公开的。付款内容在网络中保持加密。"
|
||||||
profile:
|
profile:
|
||||||
title: "资料"
|
title: "资料"
|
||||||
activity: "动态"
|
activity: "动态"
|
||||||
@@ -460,7 +464,10 @@ goblin:
|
|||||||
wallet: "钱包"
|
wallet: "钱包"
|
||||||
display_unit: "显示单位"
|
display_unit: "显示单位"
|
||||||
relays: "中继"
|
relays: "中继"
|
||||||
|
nostr_relays: "Nostr 中继"
|
||||||
node: "节点"
|
node: "节点"
|
||||||
|
integrated_node: "集成节点设置"
|
||||||
|
node_advanced: "高级"
|
||||||
slatepacks: "Slatepack"
|
slatepacks: "Slatepack"
|
||||||
slatepacks_value: "手动交易"
|
slatepacks_value: "手动交易"
|
||||||
lock_wallet: "锁定钱包"
|
lock_wallet: "锁定钱包"
|
||||||
@@ -470,7 +477,7 @@ goblin:
|
|||||||
mixnet_routing: "mixnet 路由"
|
mixnet_routing: "mixnet 路由"
|
||||||
messages_lookups: "消息和查询"
|
messages_lookups: "消息和查询"
|
||||||
auto_accept: "自动接受"
|
auto_accept: "自动接受"
|
||||||
pairing: "配对"
|
pairing: "价格货币"
|
||||||
accept_anyone: "任何人"
|
accept_anyone: "任何人"
|
||||||
accept_contacts: "仅联系人"
|
accept_contacts: "仅联系人"
|
||||||
accept_ask: "每次询问"
|
accept_ask: "每次询问"
|
||||||
@@ -485,6 +492,7 @@ goblin:
|
|||||||
archive: "存档"
|
archive: "存档"
|
||||||
export_archive: "导出存档"
|
export_archive: "导出存档"
|
||||||
wipe_history: "清除付款记录"
|
wipe_history: "清除付款记录"
|
||||||
|
wipe_history_confirm: "再次点按以清除 — 无法撤销"
|
||||||
about: "关于"
|
about: "关于"
|
||||||
goblin: "Goblin"
|
goblin: "Goblin"
|
||||||
build: "构建 %{build}"
|
build: "构建 %{build}"
|
||||||
@@ -561,7 +569,7 @@ goblin:
|
|||||||
keep_it: "保留"
|
keep_it: "保留"
|
||||||
release_it: "释放"
|
release_it: "释放"
|
||||||
username: "用户名"
|
username: "用户名"
|
||||||
username_note: "显示为 you。在 goblin.st 上公开。付款保持加密。"
|
username_note: "显示为你的名字。在 goblin.st 上公开。付款保持加密。"
|
||||||
release_username: "释放用户名"
|
release_username: "释放用户名"
|
||||||
pick_username: "选择用户名 — 可选"
|
pick_username: "选择用户名 — 可选"
|
||||||
working: "处理中…"
|
working: "处理中…"
|
||||||
|
|||||||
@@ -80,12 +80,15 @@ function build_apk() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $1 == "" ]] && [ $success -eq 1 ]; then
|
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);
|
for SERIAL in $(adb devices | grep -v List | cut -f 1);
|
||||||
do
|
do
|
||||||
adb -s "$SERIAL" install ${apk_path}
|
adb -s "$SERIAL" install ${apk_path}
|
||||||
sleep 1s
|
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
|
done
|
||||||
elif [ $success -eq 1 ]; then
|
elif [ $success -eq 1 ]; then
|
||||||
# Get version
|
# Get version
|
||||||
|
|||||||
@@ -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"
|
-gravity center -extent "${fg}x${fg}" PNG32:"$RES/mipmap-$d/ic_launcher_foreground.png"
|
||||||
done
|
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) ---
|
# --- 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
|
magick "$ICON" -define icon:auto-resize=256,128,64,48,32,24,16 wix/Product.ico
|
||||||
|
|
||||||
|
|||||||
@@ -330,14 +330,38 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
|||||||
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
|
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);
|
let title_text = format!("Goblin ツ · Build {}", crate::BUILD);
|
||||||
painter.text(
|
let title_font = egui::FontId::proportional(15.0);
|
||||||
title_rect.center(),
|
let title_ink = Colors::title(true);
|
||||||
egui::Align2::CENTER_CENTER,
|
const BUTTONS_LEFT_INSET: f32 = 60.0; // theme toggle
|
||||||
title_text,
|
const BUTTONS_RIGHT_INSET: f32 = 168.0; // minimize + fullscreen + close
|
||||||
egui::FontId::proportional(15.0),
|
let free_left = title_rect.min.x + BUTTONS_LEFT_INSET;
|
||||||
Colors::title(true),
|
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| {
|
ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| {
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ pub struct Android {
|
|||||||
impl Android {
|
impl Android {
|
||||||
/// Create new Android platform instance from provided [`AndroidApp`].
|
/// Create new Android platform instance from provided [`AndroidApp`].
|
||||||
pub fn new(app: AndroidApp) -> Self {
|
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 {
|
Self {
|
||||||
android_app: app,
|
android_app: app,
|
||||||
ctx: Arc::new(RwLock::new(None)),
|
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));
|
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||||
/// Picked file path.
|
/// Picked file path.
|
||||||
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
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.
|
/// Callback from Java code with last entered character from soft keyboard.
|
||||||
|
|||||||
@@ -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).
|
/// Number of avatar color pairs (hue derivation modulus).
|
||||||
pub fn avatar_pairs_len() -> usize {
|
pub fn avatar_pairs_len() -> usize {
|
||||||
tokens().avatar_pairs.len()
|
tokens().avatar_pairs.len()
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ impl ContentContainer for Content {
|
|||||||
.show();
|
.show();
|
||||||
} else if OperatingSystem::from_target_os() == OperatingSystem::Android
|
} else if OperatingSystem::from_target_os() == OperatingSystem::Android
|
||||||
&& AppConfig::android_integrated_node_warning_needed()
|
&& 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)
|
Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL)
|
||||||
.title(t!("network.node"))
|
.title(t!("network.node"))
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ pub struct ActivityItem {
|
|||||||
/// Canceled/expired before completing (wallet-cancelled tx or expired meta).
|
/// Canceled/expired before completing (wallet-cancelled tx or expired meta).
|
||||||
pub canceled: bool,
|
pub canceled: bool,
|
||||||
pub system: bool,
|
pub system: bool,
|
||||||
pub hue: usize,
|
|
||||||
pub time: i64,
|
pub time: i64,
|
||||||
/// Counterparty npub hex, when known.
|
/// Counterparty npub hex, when known.
|
||||||
pub npub: Option<String>,
|
pub npub: Option<String>,
|
||||||
@@ -44,7 +43,6 @@ pub struct ActivityItem {
|
|||||||
pub struct ReceiptDetail {
|
pub struct ReceiptDetail {
|
||||||
pub tx_id: u32,
|
pub tx_id: u32,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub hue: usize,
|
|
||||||
pub npub: Option<String>,
|
pub npub: Option<String>,
|
||||||
pub amount: u64,
|
pub amount: u64,
|
||||||
pub incoming: bool,
|
pub incoming: bool,
|
||||||
@@ -79,18 +77,16 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
|||||||
let meta: Option<TxNostrMeta> = slate_id
|
let meta: Option<TxNostrMeta> = slate_id
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|sid| store_ref.and_then(|s| s.tx_meta(sid)));
|
.and_then(|sid| store_ref.and_then(|s| s.tx_meta(sid)));
|
||||||
let (title, hue) = if system {
|
let title = if system {
|
||||||
("Mining reward".to_string(), 5)
|
"Mining reward".to_string()
|
||||||
} else if let Some(m) = &meta {
|
} else if let Some(m) = &meta {
|
||||||
store_ref
|
store_ref
|
||||||
.map(|s| contact_title(s, &m.npub))
|
.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 {
|
} else {
|
||||||
let label = if incoming { "Received" } else { "Sent" };
|
"Sent".to_string()
|
||||||
(
|
|
||||||
label.to_string(),
|
|
||||||
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||||
let time = tx
|
let time = tx
|
||||||
@@ -133,7 +129,6 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
|||||||
Some(ReceiptDetail {
|
Some(ReceiptDetail {
|
||||||
tx_id,
|
tx_id,
|
||||||
title,
|
title,
|
||||||
hue,
|
|
||||||
npub: meta.map(|m| m.npub),
|
npub: meta.map(|m| m.npub),
|
||||||
amount: tx.amount,
|
amount: tx.amount,
|
||||||
incoming,
|
incoming,
|
||||||
@@ -184,12 +179,11 @@ fn is_canceled(tx: &WalletTx, meta: Option<&TxNostrMeta>) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the display title for a contact npub.
|
/// 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) {
|
if let Some(contact) = store.contact(npub) {
|
||||||
(display_name(&contact), contact.hue as usize)
|
display_name(&contact)
|
||||||
} else {
|
} else {
|
||||||
let hue = hue_of(&npub);
|
short_npub(npub)
|
||||||
(short_npub(npub), hue)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
/// 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 {
|
pub fn hue_of(hex: &str) -> usize {
|
||||||
usize::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0)
|
usize::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0)
|
||||||
% crate::gui::theme::avatar_pairs_len()
|
% crate::gui::theme::avatar_pairs_len()
|
||||||
@@ -249,16 +243,17 @@ pub fn short_handle(handle: &str) -> String {
|
|||||||
format!("{head}…{tail}")
|
format!("{head}…{tail}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short npub display (npub1abcd…wxyz) from a hex pubkey.
|
||||||
pub fn short_npub(hex: &str) -> String {
|
pub fn short_npub(hex: &str) -> String {
|
||||||
use nostr_sdk::{PublicKey, ToBech32};
|
use nostr_sdk::{PublicKey, ToBech32};
|
||||||
if let Ok(pk) = PublicKey::from_hex(hex) {
|
if let Ok(pk) = PublicKey::from_hex(hex) {
|
||||||
if let Ok(npub) = pk.to_bech32() {
|
// `to_bech32` for a valid key is infallible.
|
||||||
// Standard truncation: "npub1" + 7 head chars … 6 tail chars.
|
let Ok(npub) = pk.to_bech32();
|
||||||
if npub.len() > 18 {
|
// Standard truncation: "npub1" + 7 head chars … 6 tail chars.
|
||||||
return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]);
|
if npub.len() > 18 {
|
||||||
}
|
return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]);
|
||||||
return npub;
|
|
||||||
}
|
}
|
||||||
|
return npub;
|
||||||
}
|
}
|
||||||
format!("{}…", &hex[..8.min(hex.len())])
|
format!("{}…", &hex[..8.min(hex.len())])
|
||||||
}
|
}
|
||||||
@@ -300,23 +295,17 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|sid| store.and_then(|s| s.tx_meta(sid)));
|
.and_then(|sid| store.and_then(|s| s.tx_meta(sid)));
|
||||||
|
|
||||||
let (title, hue) = if system {
|
let title = if system {
|
||||||
("Mining reward".to_string(), 5)
|
"Mining reward".to_string()
|
||||||
} else if let Some(meta) = &meta {
|
} else if let Some(meta) = &meta {
|
||||||
store
|
store
|
||||||
.map(|s| contact_title(s, &meta.npub))
|
.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 {
|
} else {
|
||||||
// Fall back to slatepack address counterparty or generic label.
|
"Sent".to_string()
|
||||||
let label = if incoming {
|
|
||||||
"Received".to_string()
|
|
||||||
} else {
|
|
||||||
"Sent".to_string()
|
|
||||||
};
|
|
||||||
(
|
|
||||||
label,
|
|
||||||
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
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,
|
confirmed: tx.data.confirmed,
|
||||||
canceled,
|
canceled,
|
||||||
system,
|
system,
|
||||||
hue,
|
|
||||||
time,
|
time,
|
||||||
npub: meta.map(|m| m.npub),
|
npub: meta.map(|m| m.npub),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recent unique peers for the home strip (most recent first).
|
/// Recent unique peers for the home strip (most recent first), as
|
||||||
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> {
|
/// `(display name, npub hex)`.
|
||||||
|
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, String)> {
|
||||||
let store = match wallet.nostr_service() {
|
let store = match wallet.nostr_service() {
|
||||||
Some(s) => s.store.clone(),
|
Some(s) => s.store.clone(),
|
||||||
None => return vec![],
|
None => return vec![],
|
||||||
@@ -354,13 +343,14 @@ pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String
|
|||||||
contacts
|
contacts
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.map(|c| (display_name(&c), c.hue as usize, c.npub))
|
.map(|c| (display_name(&c), c.npub))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local contacts whose petname / nip05 / npub contains `query` (case-
|
/// Local contacts whose petname / nip05 / npub contains `query` (case-
|
||||||
/// insensitive) — the instant, no-network half of the recipient search.
|
/// 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() {
|
let store = match wallet.nostr_service() {
|
||||||
Some(s) => s.store.clone(),
|
Some(s) => s.store.clone(),
|
||||||
None => return vec![],
|
None => return vec![],
|
||||||
@@ -369,7 +359,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
|||||||
if q.is_empty() {
|
if q.is_empty() {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
let mut hits: Vec<(String, usize, String)> = store
|
let mut hits: Vec<(String, String)> = store
|
||||||
.all_contacts()
|
.all_contacts()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| {
|
.filter(|c| {
|
||||||
@@ -383,7 +373,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
|| c.npub.to_lowercase().contains(&q)
|
|| 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();
|
.collect();
|
||||||
hits.truncate(limit);
|
hits.truncate(limit);
|
||||||
hits
|
hits
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ const LOGO_FRAC: f64 = 0.90;
|
|||||||
const LOGO_OPACITY: f64 = 0.67;
|
const LOGO_OPACITY: f64 = 0.67;
|
||||||
const GRIN_NATIVE: f64 = 61.0;
|
const GRIN_NATIVE: f64 = 61.0;
|
||||||
|
|
||||||
/// Standard HSL → RGB → `#rrggbb`. f64 throughout for cross-port byte-identity.
|
/// Standard HSL → RGB bytes. f64 throughout for cross-port byte-identity.
|
||||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
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 c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||||
let hp = h / 60.0;
|
let hp = h / 60.0;
|
||||||
let x = c * (1.0 - ((hp % 2.0) - 1.0).abs());
|
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 m = l - c / 2.0;
|
||||||
let to = |v: f64| ((v + m) * 255.0).round() as u8;
|
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
|
/// 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)
|
(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
|
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
|
||||||
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
|
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
|
||||||
/// are inlined into ONE html document; for a standalone document (how egui
|
/// are inlined into ONE html document; for a standalone document (how egui
|
||||||
|
|||||||
@@ -91,8 +91,12 @@ pub struct GoblinWalletView {
|
|||||||
request_amount: Option<String>,
|
request_amount: Option<String>,
|
||||||
/// Sub-page open inside the Settings tab.
|
/// Sub-page open inside the Settings tab.
|
||||||
settings_page: SettingsPage,
|
settings_page: SettingsPage,
|
||||||
/// GRIM's native node-connections screen (embedded under Advanced).
|
/// Active GRIM integrated-node tab (Info/Metrics/Mining/Settings), hosted
|
||||||
grim_connections: crate::gui::views::network::ConnectionsContent,
|
/// 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).
|
/// Inline state for the Advanced settings page (recovery/repair/delete).
|
||||||
advanced: AdvancedState,
|
advanced: AdvancedState,
|
||||||
/// One-shot signal to the wallet host: deselect this wallet (return to the
|
/// 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)>,
|
cancel_msg: Option<(crate::nostr::CancelOutcome, std::time::Instant)>,
|
||||||
/// Transient "Copied" flash for the settings backup card (npub/keys).
|
/// Transient "Copied" flash for the settings backup card (npub/keys).
|
||||||
copy_flash: Option<std::time::Instant>,
|
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.
|
/// Sub-pages of the Settings tab.
|
||||||
@@ -126,8 +133,8 @@ pub struct GoblinWalletView {
|
|||||||
enum SettingsPage {
|
enum SettingsPage {
|
||||||
Main,
|
Main,
|
||||||
Node,
|
Node,
|
||||||
/// GRIM's native node-connections screen, embedded.
|
/// GRIM's integrated-node tabs, embedded in Goblin chrome.
|
||||||
Connections,
|
IntegratedNode,
|
||||||
Relays,
|
Relays,
|
||||||
Nips,
|
Nips,
|
||||||
Pairing,
|
Pairing,
|
||||||
@@ -194,7 +201,8 @@ impl Default for GoblinWalletView {
|
|||||||
pay_shake: None,
|
pay_shake: None,
|
||||||
request_amount: None,
|
request_amount: None,
|
||||||
settings_page: SettingsPage::Main,
|
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(),
|
advanced: AdvancedState::default(),
|
||||||
switch_requested: false,
|
switch_requested: false,
|
||||||
node_url_input: String::new(),
|
node_url_input: String::new(),
|
||||||
@@ -207,6 +215,7 @@ impl Default for GoblinWalletView {
|
|||||||
cancel_confirm: None,
|
cancel_confirm: None,
|
||||||
cancel_msg: None,
|
cancel_msg: None,
|
||||||
copy_flash: None,
|
copy_flash: None,
|
||||||
|
wipe_confirm: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,6 +360,17 @@ impl GoblinWalletView {
|
|||||||
std::mem::take(&mut self.switch_requested)
|
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.
|
/// Handle a back navigation; returns true if not consumed.
|
||||||
pub fn on_back(&mut self) -> bool {
|
pub fn on_back(&mut self) -> bool {
|
||||||
if self.receipt.is_some() {
|
if self.receipt.is_some() {
|
||||||
@@ -728,7 +748,7 @@ impl GoblinWalletView {
|
|||||||
self.settings_page = SettingsPage::Node;
|
self.settings_page = SettingsPage::Node;
|
||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
let (handle, connected, npub_hex) = wallet
|
let (handle, npub_hex) = wallet
|
||||||
.nostr_service()
|
.nostr_service()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let id = s.identity.read();
|
let id = s.identity.read();
|
||||||
@@ -737,16 +757,11 @@ impl GoblinWalletView {
|
|||||||
.clone()
|
.clone()
|
||||||
.map(|n| n.split('@').next().unwrap_or("").to_string())
|
.map(|n| n.split('@').next().unwrap_or("").to_string())
|
||||||
.unwrap_or_else(|| data::short_npub(&hex_of(&id.npub)));
|
.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(|| {
|
.unwrap_or_else(|| {
|
||||||
(
|
(t!("goblin.home.anonymous").to_string(), String::new())
|
||||||
t!("goblin.home.anonymous").to_string(),
|
|
||||||
false,
|
|
||||||
String::new(),
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
let hue = data::hue_of(&npub_hex);
|
|
||||||
let tex = self.handle_tex(ui.ctx(), wallet, &handle);
|
let tex = self.handle_tex(ui.ctx(), wallet, &handle);
|
||||||
// Identity chip → identity settings.
|
// Identity chip → identity settings.
|
||||||
let id_resp = ui
|
let id_resp = ui
|
||||||
@@ -754,7 +769,7 @@ impl GoblinWalletView {
|
|||||||
w::card(ui, |ui| {
|
w::card(ui, |ui| {
|
||||||
ui.set_min_width(ui.available_width());
|
ui.set_min_width(ui.available_width());
|
||||||
ui.horizontal(|ui| {
|
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.add_space(10.0);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
// Scale the handle to its length: short @names get a
|
// Scale the handle to its length: short @names get a
|
||||||
@@ -769,7 +784,9 @@ impl GoblinWalletView {
|
|||||||
.color(t.surface_text),
|
.color(t.surface_text),
|
||||||
);
|
);
|
||||||
ui.label(
|
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")
|
t!("goblin.home.connected_nym")
|
||||||
} else if crate::nym::is_ready() {
|
} else if crate::nym::is_ready() {
|
||||||
t!("goblin.home.nym_ready")
|
t!("goblin.home.nym_ready")
|
||||||
@@ -877,6 +894,15 @@ impl GoblinWalletView {
|
|||||||
.truncate(),
|
.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 id = s.identity.read();
|
||||||
let hex = hex_of(&id.npub);
|
let hex = hex_of(&id.npub);
|
||||||
// With a verified handle show "@name"; otherwise fall back to
|
// With a verified handle show "@name"; otherwise fall back to
|
||||||
// the short npub so avatar_any draws the deterministic gradient
|
// the short npub (avatar_any then draws the deterministic
|
||||||
// (it keys the gradient branch off a leading "npub"), not a
|
// pubkey-seeded gradient).
|
||||||
// meaningless lettered tile.
|
|
||||||
let h = id
|
let h = id
|
||||||
.nip05
|
.nip05
|
||||||
.clone()
|
.clone()
|
||||||
@@ -913,7 +938,6 @@ impl GoblinWalletView {
|
|||||||
(h, hex)
|
(h, hex)
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| ("N".to_string(), String::new()));
|
.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);
|
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
widgets_logo(ui);
|
widgets_logo(ui);
|
||||||
@@ -929,7 +953,6 @@ impl GoblinWalletView {
|
|||||||
&header_handle,
|
&header_handle,
|
||||||
&header_hex,
|
&header_hex,
|
||||||
36.0,
|
36.0,
|
||||||
header_hue,
|
|
||||||
header_tex.as_ref(),
|
header_tex.as_ref(),
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -968,7 +991,22 @@ impl GoblinWalletView {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|d| (d.info.total, d.info.amount_currently_spendable))
|
.map(|d| (d.info.total, d.info.amount_currently_spendable))
|
||||||
.unwrap_or((0, 0));
|
.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);
|
ui.add_space(20.0);
|
||||||
let (send, receive) = w::send_receive(ui);
|
let (send, receive) = w::send_receive(ui);
|
||||||
if send {
|
if send {
|
||||||
@@ -1009,7 +1047,7 @@ impl GoblinWalletView {
|
|||||||
}
|
}
|
||||||
let texs: Vec<Option<egui::TextureHandle>> = peers
|
let texs: Vec<Option<egui::TextureHandle>> = peers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, _, _)| self.handle_tex(ui.ctx(), wallet, name))
|
.map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name))
|
||||||
.collect();
|
.collect();
|
||||||
w::kicker(ui, &t!("goblin.home.recent"));
|
w::kicker(ui, &t!("goblin.home.recent"));
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
@@ -1018,14 +1056,14 @@ impl GoblinWalletView {
|
|||||||
.auto_shrink([false, true])
|
.auto_shrink([false, true])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.horizontal(|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
|
// Fixed-width centered cell so the name sits centered under the
|
||||||
// avatar (not left-aligned to a wider label).
|
// avatar (not left-aligned to a wider label).
|
||||||
ui.allocate_ui_with_layout(
|
ui.allocate_ui_with_layout(
|
||||||
Vec2::new(72.0, 78.0),
|
Vec2::new(72.0, 78.0),
|
||||||
Layout::top_down(Align::Center),
|
Layout::top_down(Align::Center),
|
||||||
|ui| {
|
|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);
|
ui.add_space(6.0);
|
||||||
let chars: Vec<char> = name.chars().collect();
|
let chars: Vec<char> = name.chars().collect();
|
||||||
let short: String = if chars.len() > 8 {
|
let short: String = if chars.len() > 8 {
|
||||||
@@ -1066,7 +1104,6 @@ impl GoblinWalletView {
|
|||||||
(h, hex)
|
(h, hex)
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| ("N".to_string(), String::new()));
|
.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);
|
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
// Goblin mark (left), sized to match the right-side controls.
|
// 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
|
// Right cluster: scan QR (black, no background) then the profile
|
||||||
// picture at the far right; all three controls about the same size.
|
// picture at the far right; all three controls about the same size.
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||||
if w::avatar_any(
|
if w::avatar_any(ui, &header_handle, &header_hex, 40.0, header_tex.as_ref())
|
||||||
ui,
|
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||||
&header_handle,
|
.clicked()
|
||||||
&header_hex,
|
|
||||||
40.0,
|
|
||||||
header_hue,
|
|
||||||
header_tex.as_ref(),
|
|
||||||
)
|
|
||||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
|
||||||
.clicked()
|
|
||||||
{
|
{
|
||||||
self.tab = Tab::Me;
|
self.tab = Tab::Me;
|
||||||
}
|
}
|
||||||
@@ -1170,24 +1200,12 @@ impl GoblinWalletView {
|
|||||||
};
|
};
|
||||||
ui.add_space(if tall { 32.0 } else { 16.0 } + drop);
|
ui.add_space(if tall { 32.0 } else { 16.0 } + drop);
|
||||||
|
|
||||||
// Numpad at narrow (mobile-shell) widths, typed input on the wide
|
// The pay column is capped at 480 by `centered_column`, so the old
|
||||||
// desktop layout — gate by width like the shell itself, or narrow
|
// `< 700` width gate was always narrow: the numpad always showed and
|
||||||
// desktop windows get neither input.
|
// the typed-input branch was dead — a physical keyboard did nothing.
|
||||||
let typed_hint = !narrow && self.pay_amount.is_empty();
|
// Show the pad and accept typed digits alongside it.
|
||||||
if narrow {
|
w::numpad(ui, &mut self.pay_amount, cb);
|
||||||
w::numpad(ui, &mut self.pay_amount, cb);
|
w::amount_typed_input(ui, &mut self.pay_amount);
|
||||||
} 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),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ui.add_space(20.0);
|
ui.add_space(20.0);
|
||||||
|
|
||||||
// Request | Pay actions, half width each.
|
// 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 {
|
||||||
if !valid && !typed_hint {
|
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
@@ -1325,7 +1342,6 @@ impl GoblinWalletView {
|
|||||||
&d.title,
|
&d.title,
|
||||||
d.npub.as_deref().unwrap_or(""),
|
d.npub.as_deref().unwrap_or(""),
|
||||||
64.0,
|
64.0,
|
||||||
d.hue,
|
|
||||||
tex.as_ref(),
|
tex.as_ref(),
|
||||||
);
|
);
|
||||||
ui.add_space(10.0);
|
ui.add_space(10.0);
|
||||||
@@ -1582,10 +1598,10 @@ impl GoblinWalletView {
|
|||||||
npub: &str,
|
npub: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let t = theme::tokens();
|
let t = theme::tokens();
|
||||||
let (name, hue) = wallet
|
let name = wallet
|
||||||
.nostr_service()
|
.nostr_service()
|
||||||
.map(|s| data::contact_title(&s.store, npub))
|
.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 contact = wallet.nostr_service().and_then(|s| s.store.contact(npub));
|
||||||
let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false);
|
let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false);
|
||||||
let nip05 = contact.as_ref().and_then(|c| c.nip05.clone());
|
let nip05 = contact.as_ref().and_then(|c| c.nip05.clone());
|
||||||
@@ -1620,7 +1636,7 @@ impl GoblinWalletView {
|
|||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.vertical_centered(|ui| {
|
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.add_space(12.0);
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(&name)
|
RichText::new(&name)
|
||||||
@@ -1655,7 +1671,14 @@ impl GoblinWalletView {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
for (item, htex) in history.iter().zip(htexs.iter()) {
|
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 =
|
let amount =
|
||||||
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
|
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
|
||||||
let status_word = if item.canceled {
|
let status_word = if item.canceled {
|
||||||
@@ -1675,10 +1698,10 @@ impl GoblinWalletView {
|
|||||||
ui,
|
ui,
|
||||||
&item.title,
|
&item.title,
|
||||||
&subtitle,
|
&subtitle,
|
||||||
item.hue,
|
|
||||||
item.npub.as_deref().unwrap_or(""),
|
item.npub.as_deref().unwrap_or(""),
|
||||||
&amount,
|
&amount,
|
||||||
item.incoming,
|
item.incoming,
|
||||||
|
item.canceled,
|
||||||
item.system,
|
item.system,
|
||||||
htex.as_ref(),
|
htex.as_ref(),
|
||||||
)
|
)
|
||||||
@@ -1732,7 +1755,8 @@ impl GoblinWalletView {
|
|||||||
nip05: nip05.clone(),
|
nip05: nip05.clone(),
|
||||||
nip05_verified_at: None,
|
nip05_verified_at: None,
|
||||||
relays: vec![],
|
relays: vec![],
|
||||||
hue: hue as u8,
|
nip44_v3: false,
|
||||||
|
hue: data::hue_of(npub) as u8,
|
||||||
unknown: true,
|
unknown: true,
|
||||||
added_at: crate::nostr::unix_time(),
|
added_at: crate::nostr::unix_time(),
|
||||||
last_paid_at: None,
|
last_paid_at: None,
|
||||||
@@ -1817,8 +1841,11 @@ impl GoblinWalletView {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Unconfirmed (< min confirmations) pinned on top as Pending.
|
// Unconfirmed (< min confirmations) pinned on top as Pending.
|
||||||
let pending: Vec<&_> =
|
// Canceled txs are not pending — they group with history below.
|
||||||
items.iter().filter(|i| !i.confirmed && !i.system).collect();
|
let pending: Vec<&_> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| !i.confirmed && !i.system && !i.canceled)
|
||||||
|
.collect();
|
||||||
if !pending.is_empty() {
|
if !pending.is_empty() {
|
||||||
w::section_header(ui, &t!("goblin.activity.pending_header"));
|
w::section_header(ui, &t!("goblin.activity.pending_header"));
|
||||||
for item in pending {
|
for item in pending {
|
||||||
@@ -1826,9 +1853,12 @@ impl GoblinWalletView {
|
|||||||
}
|
}
|
||||||
ui.add_space(8.0);
|
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;
|
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);
|
let label = Self::day_label(item.time);
|
||||||
if last.as_deref() != Some(label.as_str()) {
|
if last.as_deref() != Some(label.as_str()) {
|
||||||
w::section_header(ui, &label);
|
w::section_header(ui, &label);
|
||||||
@@ -1848,7 +1878,14 @@ impl GoblinWalletView {
|
|||||||
wallet: &Wallet,
|
wallet: &Wallet,
|
||||||
_cb: &dyn PlatformCallbacks,
|
_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 amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
|
||||||
let status_word = if item.canceled {
|
let status_word = if item.canceled {
|
||||||
t!("goblin.activity.canceled").to_string()
|
t!("goblin.activity.canceled").to_string()
|
||||||
@@ -1866,10 +1903,10 @@ impl GoblinWalletView {
|
|||||||
ui,
|
ui,
|
||||||
&item.title,
|
&item.title,
|
||||||
&subtitle,
|
&subtitle,
|
||||||
item.hue,
|
|
||||||
item.npub.as_deref().unwrap_or(""),
|
item.npub.as_deref().unwrap_or(""),
|
||||||
&amount,
|
&amount,
|
||||||
item.incoming,
|
item.incoming,
|
||||||
|
item.canceled,
|
||||||
item.system,
|
item.system,
|
||||||
tex.as_ref(),
|
tex.as_ref(),
|
||||||
)
|
)
|
||||||
@@ -1886,14 +1923,14 @@ impl GoblinWalletView {
|
|||||||
wallet: &Wallet,
|
wallet: &Wallet,
|
||||||
) {
|
) {
|
||||||
let t = theme::tokens();
|
let t = theme::tokens();
|
||||||
let (name, hue) = wallet
|
let name = wallet
|
||||||
.nostr_service()
|
.nostr_service()
|
||||||
.map(|s| data::contact_title(&s.store, &req.npub))
|
.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);
|
let tex = self.handle_tex(ui.ctx(), wallet, &name);
|
||||||
w::card(ui, |ui| {
|
w::card(ui, |ui| {
|
||||||
ui.horizontal(|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.add_space(12.0);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
@@ -2003,10 +2040,10 @@ impl GoblinWalletView {
|
|||||||
let Some(req) = self.approve_review.clone() else {
|
let Some(req) = self.approve_review.clone() else {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
let (name, hue) = wallet
|
let name = wallet
|
||||||
.nostr_service()
|
.nostr_service()
|
||||||
.map(|s| data::contact_title(&s.store, &req.npub))
|
.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);
|
let tex = self.handle_tex(ui.ctx(), wallet, &name);
|
||||||
// Paying a request spends our balance, so guard against over-balance and
|
// Paying a request spends our balance, so guard against over-balance and
|
||||||
// disable the accept gesture (re-checked each frame).
|
// disable the accept gesture (re-checked each frame).
|
||||||
@@ -2041,7 +2078,7 @@ impl GoblinWalletView {
|
|||||||
ui.set_min_width(ui.available_width());
|
ui.set_min_width(ui.available_width());
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.vertical_centered(|ui| {
|
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.add_space(6.0);
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(t!("goblin.request.title", name => &name))
|
RichText::new(t!("goblin.request.title", name => &name))
|
||||||
@@ -2081,7 +2118,7 @@ impl GoblinWalletView {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let fee_val = match wallet.calculated_fee(req.amount) {
|
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 => {
|
None => {
|
||||||
ui.ctx().request_repaint_after(
|
ui.ctx().request_repaint_after(
|
||||||
std::time::Duration::from_millis(120),
|
std::time::Duration::from_millis(120),
|
||||||
@@ -2157,17 +2194,18 @@ impl GoblinWalletView {
|
|||||||
);
|
);
|
||||||
ui.add_space(16.0);
|
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()
|
.nostr_service()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
let identity = s.identity.read();
|
let identity = s.identity.read();
|
||||||
identity
|
match identity.nip05.clone() {
|
||||||
.nip05
|
Some(n) => (n.split('@').next().unwrap_or("").to_string(), true),
|
||||||
.clone()
|
None => (data::short_npub(&hex_of(&identity.npub)), false),
|
||||||
.map(|n| n.split('@').next().unwrap_or("").to_string())
|
}
|
||||||
.unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub)))
|
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "—".to_string());
|
.unwrap_or_else(|| ("—".to_string(), false));
|
||||||
let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default();
|
let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default();
|
||||||
let nprofile = wallet
|
let nprofile = wallet
|
||||||
.nostr_service()
|
.nostr_service()
|
||||||
@@ -2203,8 +2241,13 @@ impl GoblinWalletView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
let caption = if has_name {
|
||||||
|
t!("goblin.receive.share_handle")
|
||||||
|
} else {
|
||||||
|
t!("goblin.receive.share_npub")
|
||||||
|
};
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(t!("goblin.receive.share_handle"))
|
RichText::new(caption)
|
||||||
.font(FontId::new(13.0, fonts::regular()))
|
.font(FontId::new(13.0, fonts::regular()))
|
||||||
.color(t.surface_text_dim),
|
.color(t.surface_text_dim),
|
||||||
);
|
);
|
||||||
@@ -2263,8 +2306,13 @@ impl GoblinWalletView {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ui.add_space(16.0);
|
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(
|
ui.label(
|
||||||
RichText::new(t!("goblin.receive.privacy_note"))
|
RichText::new(privacy_note)
|
||||||
.font(FontId::new(12.0, fonts::regular()))
|
.font(FontId::new(12.0, fonts::regular()))
|
||||||
.color(t.text_mute),
|
.color(t.text_mute),
|
||||||
);
|
);
|
||||||
@@ -2274,7 +2322,7 @@ impl GoblinWalletView {
|
|||||||
let t = theme::tokens();
|
let t = theme::tokens();
|
||||||
match self.settings_page {
|
match self.settings_page {
|
||||||
SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb),
|
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::Relays => return self.relays_ui(ui, wallet, cb),
|
||||||
SettingsPage::Nips => return self.nips_ui(ui),
|
SettingsPage::Nips => return self.nips_ui(ui),
|
||||||
SettingsPage::Pairing => return self.pairing_settings_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
|
let own_tex = bare_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle));
|
.and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle));
|
||||||
@@ -2330,9 +2377,9 @@ impl GoblinWalletView {
|
|||||||
w::card(ui, |ui| {
|
w::card(ui, |ui| {
|
||||||
ui.set_min_width(ui.available_width());
|
ui.set_min_width(ui.available_width());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
// Avatar is a generated identicon (gradient + initial) — Goblin has
|
// Custom picture when one is set; otherwise the deterministic
|
||||||
// no uploaded profile pictures.
|
// pubkey-seeded gradient identicon.
|
||||||
w::avatar_any(ui, &handle, &npub_hex, 56.0, hue, own_tex.as_ref());
|
w::avatar_any(ui, &handle, &npub_hex, 56.0, own_tex.as_ref());
|
||||||
ui.add_space(14.0);
|
ui.add_space(14.0);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -2351,9 +2398,15 @@ impl GoblinWalletView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Mixnet status (fast) in place of the redundant second npub line.
|
// Transport status in place of the redundant second npub line.
|
||||||
let mixnet = if crate::nym::is_ready() {
|
// "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")
|
t!("goblin.home.connected_nym")
|
||||||
|
} else if crate::nym::is_ready() {
|
||||||
|
t!("goblin.home.nym_ready")
|
||||||
} else {
|
} else {
|
||||||
t!("goblin.home.connecting_nym")
|
t!("goblin.home.connecting_nym")
|
||||||
};
|
};
|
||||||
@@ -2373,7 +2426,7 @@ impl GoblinWalletView {
|
|||||||
.font(FontId::new(12.0, fonts::regular()))
|
.font(FontId::new(12.0, fonts::regular()))
|
||||||
.color(t.surface_text_mute),
|
.color(t.surface_text_mute),
|
||||||
);
|
);
|
||||||
if !crate::nym::is_ready() || !connected {
|
if !crate::nym::transport_ready() || !connected {
|
||||||
ui.ctx()
|
ui.ctx()
|
||||||
.request_repaint_after(std::time::Duration::from_millis(600));
|
.request_repaint_after(std::time::Duration::from_millis(600));
|
||||||
}
|
}
|
||||||
@@ -2404,6 +2457,10 @@ impl GoblinWalletView {
|
|||||||
}
|
}
|
||||||
self.claim_ui(ui, wallet, cb);
|
self.claim_ui(ui, wallet, cb);
|
||||||
ui.add_space(8.0);
|
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| {
|
w::card(ui, |ui| {
|
||||||
if !npub.is_empty() {
|
if !npub.is_empty() {
|
||||||
if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) {
|
if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) {
|
||||||
@@ -2437,6 +2494,16 @@ impl GoblinWalletView {
|
|||||||
{
|
{
|
||||||
self.import_nsec = Some(ImportState::default());
|
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
|
// Federation: which name authority (server) registers and
|
||||||
// verifies names. Shows the current host on the right.
|
// verifies names. Shows the current host on the right.
|
||||||
let authority = wallet
|
let authority = wallet
|
||||||
@@ -2503,17 +2570,24 @@ impl GoblinWalletView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
let mut open_relays = false;
|
|
||||||
let mut open_node = false;
|
let mut open_node = false;
|
||||||
|
let mut open_integrated = false;
|
||||||
let mut open_slatepack = false;
|
let mut open_slatepack = false;
|
||||||
settings_group(ui, &t!("goblin.settings.wallet"), |ui| {
|
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)) {
|
if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) {
|
||||||
open_node = true;
|
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
|
// GRIM's native by-hand slatepack exchange, for when a payment
|
||||||
// can't go through a username.
|
// can't go through a username.
|
||||||
if settings_row_nav(
|
if settings_row_nav(
|
||||||
@@ -2529,9 +2603,11 @@ impl GoblinWalletView {
|
|||||||
self.settings_page = SettingsPage::Slatepack;
|
self.settings_page = SettingsPage::Slatepack;
|
||||||
}
|
}
|
||||||
if open_relays {
|
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
|
self.relay_edit = wallet
|
||||||
.nostr_service()
|
.nostr_service()
|
||||||
.map(|s| s.config.read().relays())
|
.map(|s| s.relays())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.relay_input.clear();
|
self.relay_input.clear();
|
||||||
self.settings_page = SettingsPage::Relays;
|
self.settings_page = SettingsPage::Relays;
|
||||||
@@ -2541,24 +2617,30 @@ impl GoblinWalletView {
|
|||||||
self.node_secret_input.clear();
|
self.node_secret_input.clear();
|
||||||
self.settings_page = SettingsPage::Node;
|
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);
|
ui.add_space(16.0);
|
||||||
let mut open_pairing = false;
|
let mut open_pairing = false;
|
||||||
let mut open_privacy = false;
|
let mut open_privacy = false;
|
||||||
settings_group(ui, &t!("goblin.settings.privacy"), |ui| {
|
settings_group(ui, &t!("goblin.settings.privacy"), |ui| {
|
||||||
// Messages, names, price and avatars ride the mixnet; the grin
|
// Messages, names, price and avatars ride the mixnet; the grin
|
||||||
// node connects directly. Flagged in the privacy color so it
|
// node connects directly. Normal dim value ink: the salmon
|
||||||
// still reads as the headline guarantee — tap for the breakdown.
|
// privacy color doubled as the destructive-action color on
|
||||||
if settings_row_nav_ink(
|
// this page, making a plain navigable row read as a warning.
|
||||||
|
if settings_row_nav(
|
||||||
ui,
|
ui,
|
||||||
&t!("goblin.settings.mixnet_routing"),
|
&t!("goblin.settings.mixnet_routing"),
|
||||||
&t!("goblin.settings.messages_lookups"),
|
&t!("goblin.settings.messages_lookups"),
|
||||||
theme::tokens().neg,
|
|
||||||
) {
|
) {
|
||||||
open_privacy = true;
|
open_privacy = true;
|
||||||
}
|
}
|
||||||
// Tap to cycle the incoming-payment accept policy.
|
// Tap to cycle the incoming-payment accept policy. Value styled
|
||||||
if settings_row_btn(
|
// like the sibling rows' values (small/dim), not like an icon.
|
||||||
|
if settings_row_cycle(
|
||||||
ui,
|
ui,
|
||||||
&t!("goblin.settings.auto_accept"),
|
&t!("goblin.settings.auto_accept"),
|
||||||
&accept_policy_label(wallet),
|
&accept_policy_label(wallet),
|
||||||
@@ -2626,13 +2708,21 @@ impl GoblinWalletView {
|
|||||||
cb.vibrate_copy();
|
cb.vibrate_copy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if settings_row_btn(
|
// Destructive: danger styling + tap-twice confirm (like the
|
||||||
ui,
|
// receipt's "Cancel payment") before the archive is wiped.
|
||||||
&t!("goblin.settings.wipe_history"),
|
let wipe_label = if self.wipe_confirm {
|
||||||
crate::gui::icons::X,
|
t!("goblin.settings.wipe_history_confirm")
|
||||||
) {
|
} else {
|
||||||
if let Some(s) = wallet.nostr_service() {
|
t!("goblin.settings.wipe_history")
|
||||||
s.store.wipe_archive();
|
};
|
||||||
|
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;
|
self.settings_page = SettingsPage::Main;
|
||||||
}
|
}
|
||||||
if open_node {
|
if open_node {
|
||||||
// Advanced → "Manage node connection" opens GRIM's native connections UI.
|
// Advanced → "Manage node connection" opens Goblin's own Node screen
|
||||||
self.settings_page = SettingsPage::Connections;
|
// (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.
|
/// GRIM's four integrated-node tabs (Info / Metrics / Mining / Settings)
|
||||||
fn grim_connections_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
/// hosted under a Goblin back header and segmented control — GRIM's
|
||||||
use crate::gui::views::types::ContentContainer;
|
/// dual-panel and floating-navbar chrome are never rendered. The header
|
||||||
if self.sub_header(ui, &t!("goblin.node.title")) {
|
/// title follows the active tab, like GRIM's own title panel.
|
||||||
self.settings_page = SettingsPage::Advanced;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
ScrollArea::vertical()
|
let selected = match self.node_tab.get_type() {
|
||||||
.auto_shrink([false; 2])
|
NodeTabType::Info => 0,
|
||||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
NodeTabType::Metrics => 1,
|
||||||
.show(ui, |ui| {
|
NodeTabType::Mining => 2,
|
||||||
self.grim_connections.ui(ui, cb);
|
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) {
|
fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||||
@@ -3162,10 +3294,13 @@ impl GoblinWalletView {
|
|||||||
.color(t.pos),
|
.color(t.pos),
|
||||||
);
|
);
|
||||||
} else {
|
} 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(
|
let x = ui.label(
|
||||||
RichText::new(crate::gui::icons::X)
|
RichText::new(crate::gui::icons::TRASH_SIMPLE)
|
||||||
.font(FontId::new(15.0, fonts::regular()))
|
.font(FontId::new(15.0, fonts::regular()))
|
||||||
.color(t.surface_text_mute),
|
.color(t.surface_text_dim),
|
||||||
);
|
);
|
||||||
if x.interact(Sense::click()).clicked() {
|
if x.interact(Sense::click()).clicked() {
|
||||||
ConnectionsConfig::remove_ext_conn(conn.id);
|
ConnectionsConfig::remove_ext_conn(conn.id);
|
||||||
@@ -3227,6 +3362,14 @@ impl GoblinWalletView {
|
|||||||
self.node_url_input.clear();
|
self.node_url_input.clear();
|
||||||
self.node_secret_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);
|
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()
|
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.
|
/// A settings row that navigates somewhere: value + chevron, whole row taps.
|
||||||
fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
|
fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
|
||||||
let t = theme::tokens();
|
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()
|
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
|
/// One channel row on the Network-privacy page: a status dot, a title and a
|
||||||
/// wrapped blurb explaining where that traffic goes.
|
/// wrapped blurb explaining where that traffic goes.
|
||||||
fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) {
|
fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use eframe::epaint::FontId;
|
|||||||
use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2};
|
use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2};
|
||||||
use grin_util::ZeroingString;
|
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::platform::PlatformCallbacks;
|
||||||
use crate::gui::theme::{self, fonts};
|
use crate::gui::theme::{self, fonts};
|
||||||
use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult};
|
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
|
/// step so a returning user can keep their old npub + username instead of the
|
||||||
/// freshly-generated random key.
|
/// freshly-generated random key.
|
||||||
import: Option<OnbImport>,
|
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
|
/// 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
|
// Default to the Instant path (connect to a public node) so a new
|
||||||
// user is online immediately, with no chain-sync wait.
|
// user is online immediately, with no chain-sync wait.
|
||||||
integrated: false,
|
integrated: false,
|
||||||
ext_url: "https://api.grin.money".to_string(),
|
ext_url: "https://grincoin.org".to_string(),
|
||||||
restore: false,
|
restore: false,
|
||||||
name: "Main wallet".to_string(),
|
name: "Main wallet".to_string(),
|
||||||
pass: String::new(),
|
pass: String::new(),
|
||||||
@@ -115,6 +117,7 @@ impl Default for OnboardingContent {
|
|||||||
wallet: None,
|
wallet: None,
|
||||||
claim: ClaimState::default(),
|
claim: ClaimState::default(),
|
||||||
import: None,
|
import: None,
|
||||||
|
words_copied: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -561,9 +564,24 @@ impl OnboardingContent {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
ui.add_space(14.0);
|
ui.add_space(14.0);
|
||||||
} else if w::chip(ui, &t!("goblin.onboarding.words.copy_clipboard"), false).clicked() {
|
} else {
|
||||||
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
|
// Transient "Copied" feedback (the Build 82/89 pattern): a silent
|
||||||
cb.vibrate_copy();
|
// 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 {
|
if !restore {
|
||||||
ui.add_space(14.0);
|
ui.add_space(14.0);
|
||||||
@@ -746,7 +764,8 @@ impl OnboardingContent {
|
|||||||
// for this key; only fall back to a placeholder while the key is
|
// for this key; only fall back to a placeholder while the key is
|
||||||
// still being generated (npub not yet available).
|
// still being generated (npub not yet available).
|
||||||
if npub.is_empty() {
|
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 {
|
} else {
|
||||||
w::gradient_avatar(ui, &npub, 44.0);
|
w::gradient_avatar(ui, &npub, 44.0);
|
||||||
}
|
}
|
||||||
@@ -783,7 +802,9 @@ impl OnboardingContent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
ui.label(
|
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")
|
t!("goblin.onboarding.identity.connected_nym")
|
||||||
} else {
|
} else {
|
||||||
t!("goblin.onboarding.identity.connecting_nym")
|
t!("goblin.onboarding.identity.connecting_nym")
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ enum ScanTab {
|
|||||||
struct Recipient {
|
struct Recipient {
|
||||||
name: String,
|
name: String,
|
||||||
npub: String,
|
npub: String,
|
||||||
hue: usize,
|
|
||||||
/// Recipient relay hints (nprofile / NIP-05 resolution), extra delivery
|
/// Recipient relay hints (nprofile / NIP-05 resolution), extra delivery
|
||||||
/// targets for a recipient whose kind 10050 isn't discoverable yet.
|
/// targets for a recipient whose kind 10050 isn't discoverable yet.
|
||||||
relay_hints: Vec<String>,
|
relay_hints: Vec<String>,
|
||||||
@@ -87,7 +86,6 @@ struct Recipient {
|
|||||||
struct Candidate {
|
struct Candidate {
|
||||||
name: String,
|
name: String,
|
||||||
npub: String,
|
npub: String,
|
||||||
hue: usize,
|
|
||||||
/// Known contact, resolved goblin handle, or has a published nostr
|
/// Known contact, resolved goblin handle, or has a published nostr
|
||||||
/// profile. Unverified = a syntactically valid key with no profile.
|
/// profile. Unverified = a syntactically valid key with no profile.
|
||||||
verified: bool,
|
verified: bool,
|
||||||
@@ -180,11 +178,9 @@ impl Default for SendFlow {
|
|||||||
impl SendFlow {
|
impl SendFlow {
|
||||||
/// Pre-fill a contact and skip to amount entry.
|
/// Pre-fill a contact and skip to amount entry.
|
||||||
pub fn prefill_contact(&mut self, name: String, npub: String) {
|
pub fn prefill_contact(&mut self, name: String, npub: String) {
|
||||||
let hue = data::hue_of(&npub);
|
|
||||||
self.recipient = Some(Recipient {
|
self.recipient = Some(Recipient {
|
||||||
name,
|
name,
|
||||||
npub,
|
npub,
|
||||||
hue,
|
|
||||||
relay_hints: vec![],
|
relay_hints: vec![],
|
||||||
});
|
});
|
||||||
self.stage = Stage::Amount;
|
self.stage = Stage::Amount;
|
||||||
@@ -473,7 +469,7 @@ impl SendFlow {
|
|||||||
let peers = recent_peers(wallet, 20);
|
let peers = recent_peers(wallet, 20);
|
||||||
let texs: Vec<Option<egui::TextureHandle>> = peers
|
let texs: Vec<Option<egui::TextureHandle>> = peers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name))
|
.map(|(name, _)| tex_for(avatars, ui.ctx(), wallet, name))
|
||||||
.collect();
|
.collect();
|
||||||
ScrollArea::vertical()
|
ScrollArea::vertical()
|
||||||
.auto_shrink([false; 2])
|
.auto_shrink([false; 2])
|
||||||
@@ -486,16 +482,16 @@ impl SendFlow {
|
|||||||
.color(t.text_dim),
|
.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(
|
if w::activity_row(
|
||||||
ui,
|
ui,
|
||||||
&name,
|
&name,
|
||||||
&data::full_npub(&npub),
|
&data::full_npub(&npub),
|
||||||
hue,
|
|
||||||
&npub,
|
&npub,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
tex.as_ref(),
|
tex.as_ref(),
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -503,7 +499,6 @@ impl SendFlow {
|
|||||||
self.pick(Candidate {
|
self.pick(Candidate {
|
||||||
name,
|
name,
|
||||||
npub,
|
npub,
|
||||||
hue,
|
|
||||||
verified: true,
|
verified: true,
|
||||||
tag: "",
|
tag: "",
|
||||||
relay_hints: vec![],
|
relay_hints: vec![],
|
||||||
@@ -517,10 +512,9 @@ impl SendFlow {
|
|||||||
// Type-ahead results: instant local matches + the network candidate.
|
// Type-ahead results: instant local matches + the network candidate.
|
||||||
let mut cands: Vec<Candidate> = search_contacts(wallet, &query, 6)
|
let mut cands: Vec<Candidate> = search_contacts(wallet, &query, 6)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(name, hue, npub)| Candidate {
|
.map(|(name, npub)| Candidate {
|
||||||
name,
|
name,
|
||||||
npub,
|
npub,
|
||||||
hue,
|
|
||||||
verified: true,
|
verified: true,
|
||||||
tag: "contact",
|
tag: "contact",
|
||||||
relay_hints: vec![],
|
relay_hints: vec![],
|
||||||
@@ -555,11 +549,11 @@ impl SendFlow {
|
|||||||
ui,
|
ui,
|
||||||
&c.name,
|
&c.name,
|
||||||
&tag,
|
&tag,
|
||||||
c.hue,
|
|
||||||
&c.npub,
|
&c.npub,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
tex.as_ref(),
|
tex.as_ref(),
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -599,7 +593,6 @@ impl SendFlow {
|
|||||||
self.recipient = Some(Recipient {
|
self.recipient = Some(Recipient {
|
||||||
name: cand.name,
|
name: cand.name,
|
||||||
npub: cand.npub,
|
npub: cand.npub,
|
||||||
hue: cand.hue,
|
|
||||||
relay_hints: cand.relay_hints,
|
relay_hints: cand.relay_hints,
|
||||||
});
|
});
|
||||||
let preset = amount_from_hr_string(&self.amount)
|
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.
|
// Valid key → confirm it's a live identity via its kind-0 profile.
|
||||||
self.looking_up = true;
|
self.looking_up = true;
|
||||||
let service = wallet.nostr_service();
|
let service = wallet.nostr_service();
|
||||||
let known = wallet.nostr_service().and_then(|s| {
|
let known = wallet
|
||||||
s.store
|
.nostr_service()
|
||||||
.contact(&hex)
|
.and_then(|s| s.store.contact(&hex).map(|c| display_name(&c)));
|
||||||
.map(|c| (display_name(&c), c.hue as usize))
|
|
||||||
});
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let hue = data::hue_of(&hex);
|
|
||||||
let profile = service.and_then(|s| s.fetch_profile_blocking(&hex, &key_hints));
|
let profile = service.and_then(|s| s.fetch_profile_blocking(&hex, &key_hints));
|
||||||
let res = match (known, profile) {
|
let res = match (known, profile) {
|
||||||
// Already a saved contact — trust it.
|
// Already a saved contact — trust it.
|
||||||
(Some((name, hue)), _) => LookupResult::Found(Candidate {
|
(Some(name), _) => LookupResult::Found(Candidate {
|
||||||
name,
|
name,
|
||||||
npub: hex,
|
npub: hex,
|
||||||
hue,
|
|
||||||
verified: true,
|
verified: true,
|
||||||
tag: "contact",
|
tag: "contact",
|
||||||
relay_hints: key_hints,
|
relay_hints: key_hints,
|
||||||
@@ -854,7 +843,6 @@ impl SendFlow {
|
|||||||
LookupResult::Found(Candidate {
|
LookupResult::Found(Candidate {
|
||||||
name,
|
name,
|
||||||
npub: hex,
|
npub: hex,
|
||||||
hue,
|
|
||||||
verified: true,
|
verified: true,
|
||||||
tag: "on nostr",
|
tag: "on nostr",
|
||||||
relay_hints: key_hints,
|
relay_hints: key_hints,
|
||||||
@@ -863,7 +851,6 @@ impl SendFlow {
|
|||||||
(None, None) => LookupResult::Unverified(Candidate {
|
(None, None) => LookupResult::Unverified(Candidate {
|
||||||
name: short_npub(&hex),
|
name: short_npub(&hex),
|
||||||
npub: hex,
|
npub: hex,
|
||||||
hue,
|
|
||||||
verified: false,
|
verified: false,
|
||||||
tag: "",
|
tag: "",
|
||||||
relay_hints: key_hints,
|
relay_hints: key_hints,
|
||||||
@@ -893,7 +880,6 @@ impl SendFlow {
|
|||||||
LookupResult::Found(Candidate {
|
LookupResult::Found(Candidate {
|
||||||
name: display,
|
name: display,
|
||||||
npub: hex.clone(),
|
npub: hex.clone(),
|
||||||
hue: data::hue_of(&hex),
|
|
||||||
// A successful NIP-05 resolution (home OR a named foreign
|
// A successful NIP-05 resolution (home OR a named foreign
|
||||||
// authority) is verified — the user typed a specific
|
// authority) is verified — the user typed a specific
|
||||||
// handle and the domain is shown, so no bare-key gate.
|
// handle and the domain is shown, so no bare-key gate.
|
||||||
@@ -953,7 +939,6 @@ impl SendFlow {
|
|||||||
&recipient.name,
|
&recipient.name,
|
||||||
&recipient.npub,
|
&recipient.npub,
|
||||||
28.0,
|
28.0,
|
||||||
recipient.hue,
|
|
||||||
chip_tex.as_ref(),
|
chip_tex.as_ref(),
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
@@ -1006,13 +991,16 @@ impl SendFlow {
|
|||||||
// above it means the pad stays visible and tappable, instead of being
|
// above it means the pad stays visible and tappable, instead of being
|
||||||
// hidden behind the keyboard (the old order trapped you in the note).
|
// hidden behind the keyboard (the old order trapped you in the note).
|
||||||
let note_focused = ui.ctx().memory(|m| m.has_focus(note_id));
|
let note_focused = ui.ctx().memory(|m| m.has_focus(note_id));
|
||||||
if !View::is_desktop() {
|
// The send column is capped at 480 by `centered_column`, so the old
|
||||||
if w::numpad(ui, &mut self.amount, cb) {
|
// `< 700` width gate was always narrow and the typed branch dead (same
|
||||||
// Tapping the pad means you're back on the amount — drop the note's
|
// fix as pay_ui, so both amount screens match): show the pad and accept
|
||||||
// focus so its keyboard goes away.
|
// typed digits alongside it.
|
||||||
ui.ctx().memory_mut(|m| m.surrender_focus(note_id));
|
if w::numpad(ui, &mut self.amount, cb) {
|
||||||
}
|
// Tapping the pad means you're back on the amount — drop the note's
|
||||||
} else if !note_focused {
|
// 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
|
// Only consume keystrokes for the amount when the note field is
|
||||||
// not focused, so typing a note doesn't also edit the amount.
|
// not focused, so typing a note doesn't also edit the amount.
|
||||||
w::amount_typed_input(ui, &mut self.amount);
|
w::amount_typed_input(ui, &mut self.amount);
|
||||||
@@ -1054,13 +1042,12 @@ impl SendFlow {
|
|||||||
let valid = amount_from_hr_string(&self.amount)
|
let valid = amount_from_hr_string(&self.amount)
|
||||||
.map(|a| a > 0)
|
.map(|a| a > 0)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
ui.add_enabled_ui(valid, |ui| {
|
// Greyed out while over balance, matching the red guard above; the
|
||||||
if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() {
|
// `!over` in the click also refuses it in case the disabled state is
|
||||||
if over {
|
// ever bypassed.
|
||||||
cb.vibrate_error();
|
ui.add_enabled_ui(valid && !over, |ui| {
|
||||||
} else {
|
if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() && !over {
|
||||||
self.stage = Stage::Review;
|
self.stage = Stage::Review;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
false
|
false
|
||||||
@@ -1122,7 +1109,6 @@ impl SendFlow {
|
|||||||
&recipient.name,
|
&recipient.name,
|
||||||
&recipient.npub,
|
&recipient.npub,
|
||||||
40.0,
|
40.0,
|
||||||
recipient.hue,
|
|
||||||
hero_tex.as_ref(),
|
hero_tex.as_ref(),
|
||||||
);
|
);
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
@@ -1172,7 +1158,7 @@ impl SendFlow {
|
|||||||
wallet.task(WalletTask::CalculateFee(amount_nano, 0));
|
wallet.task(WalletTask::CalculateFee(amount_nano, 0));
|
||||||
}
|
}
|
||||||
let fee_val = match wallet.calculated_fee(amount_nano) {
|
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 => {
|
None => {
|
||||||
// Result lands on a worker thread; poll until it does.
|
// Result lands on a worker thread; poll until it does.
|
||||||
ui.ctx()
|
ui.ctx()
|
||||||
|
|||||||
@@ -27,36 +27,67 @@ pub fn amount_str(atomic: u64) -> String {
|
|||||||
grin_core::core::amount_to_hr_string(atomic, true)
|
grin_core::core::amount_to_hr_string(atomic, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draw a colored avatar puck with the contact initial.
|
/// A custom-picture avatar: the texture drawn in a circle, wrapped by a thin
|
||||||
pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response {
|
/// 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 (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||||
let (bg, ink) = theme::avatar_pair(hue);
|
let thickness = (size * 0.06).max(1.0);
|
||||||
ui.painter().circle_filled(rect.center(), size / 2.0, bg);
|
let gap = (size * 0.03).max(1.0);
|
||||||
// First letter of the name — never the @ prefix or other decoration.
|
let img_rect = rect.shrink(thickness + gap);
|
||||||
let initial = name
|
let rounding = eframe::epaint::CornerRadius::same((img_rect.width() / 2.0) as u8);
|
||||||
.chars()
|
egui::Image::new(tex)
|
||||||
.find(|c| c.is_alphanumeric())
|
.corner_radius(rounding)
|
||||||
.map(|c| c.to_uppercase().to_string())
|
.fit_to_exact_size(img_rect.size())
|
||||||
.unwrap_or_else(|| "?".to_string());
|
.paint_at(ui, img_rect);
|
||||||
ui.painter().text(
|
conic_ring(
|
||||||
|
ui,
|
||||||
rect.center(),
|
rect.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
size / 2.0 - thickness / 2.0,
|
||||||
initial,
|
thickness,
|
||||||
FontId::new(size * 0.42, fonts::bold()),
|
name,
|
||||||
ink,
|
|
||||||
);
|
);
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A custom-picture avatar: the texture drawn in a circle.
|
/// Thin conic-gradient ring at the avatar perimeter, hue path seeded by the
|
||||||
pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response {
|
/// username (see `identicon::ring_params`). Drawn as a feathered triangle mesh
|
||||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
/// (~64 segments, per-vertex color) so edges stay smooth; a triangle-wave hue
|
||||||
let rounding = eframe::epaint::CornerRadius::same((size / 2.0) as u8);
|
/// sweep keeps the gradient seamless where the circle closes. No new deps.
|
||||||
egui::Image::new(tex)
|
fn conic_ring(ui: &Ui, center: egui::Pos2, r_mid: f32, thickness: f32, name: &str) {
|
||||||
.corner_radius(rounding)
|
use eframe::epaint::{Mesh, Shape, Vertex, WHITE_UV};
|
||||||
.fit_to_exact_size(Vec2::splat(size))
|
let (base_hue, sweep) = super::identicon::ring_params(name);
|
||||||
.paint_at(ui, rect);
|
const SEGS: u32 = 64;
|
||||||
resp
|
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
|
/// 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.
|
/// the same avatar (see [`super::identicon`]). Cached per-pubkey by egui.
|
||||||
pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response {
|
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 (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||||
let hex = super::identicon::to_hex_seed(id);
|
paint_gradient(ui, id, rect);
|
||||||
// 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);
|
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A named user's avatar: the same pubkey-seeded gradient background as
|
/// The UNCHANGED npub-seeded grinmark gradient with a thin conic ring seeded by
|
||||||
/// [`gradient_avatar`], but with the person's initial painted on top (white with
|
/// the USERNAME simply added around its edge — the gradient stays exactly as a
|
||||||
/// a faint dark shadow for legibility on any hue) instead of the Grin mark. `id`
|
/// ring-less avatar draws it; the ring is the only thing the username adds.
|
||||||
/// seeds the gradient; `name` supplies the letter.
|
pub fn gradient_avatar_ringed(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response {
|
||||||
pub fn gradient_letter_avatar(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response {
|
|
||||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||||
let hex = super::identicon::to_hex_seed(id);
|
paint_gradient(ui, id, rect);
|
||||||
let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32);
|
let thickness = (size * 0.06).max(1.0);
|
||||||
let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32);
|
conic_ring(
|
||||||
egui::Image::new(egui::ImageSource::Bytes {
|
ui,
|
||||||
uri: uri.into(),
|
rect.center(),
|
||||||
bytes: svg.into_bytes().into(),
|
size / 2.0 - thickness / 2.0,
|
||||||
})
|
thickness,
|
||||||
.corner_radius(CornerRadius::same((size / 2.0) as u8))
|
name,
|
||||||
.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),
|
|
||||||
);
|
);
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picture avatar when a texture exists; otherwise the deterministic
|
/// Paint the pubkey-seeded grinmark gradient into `rect` (rasterized at 2x,
|
||||||
/// pubkey-seeded gradient: with the Grin mark for an anonymous key (display name
|
/// cached by egui via the `uri`).
|
||||||
/// is an `npub…`), or with the person's initial for a named contact/@handle. A
|
fn paint_gradient(ui: &mut Ui, id: &str, rect: egui::Rect) {
|
||||||
/// flat lettered tile is the last resort when no pubkey is known. `id` is the
|
let hex = super::identicon::to_hex_seed(id);
|
||||||
/// npub/hex used to seed the gradient.
|
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(
|
pub fn avatar_any(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
name: &str,
|
name: &str,
|
||||||
id: &str,
|
id: &str,
|
||||||
size: f32,
|
size: f32,
|
||||||
hue: usize,
|
|
||||||
tex: Option<&egui::TextureHandle>,
|
tex: Option<&egui::TextureHandle>,
|
||||||
) -> Response {
|
) -> 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 {
|
match tex {
|
||||||
Some(t) => avatar_tex(ui, t, size),
|
Some(t) => avatar_tex(ui, t, name, size),
|
||||||
None if name.starts_with("npub") && !id.is_empty() => gradient_avatar(ui, id, size),
|
None if named => {
|
||||||
None if !id.is_empty() => gradient_letter_avatar(ui, id, name, size),
|
gradient_avatar_ringed(ui, if id.is_empty() { name } else { id }, name, size)
|
||||||
None => avatar(ui, name, size, hue),
|
}
|
||||||
|
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 t = theme::tokens();
|
||||||
let desired = Vec2::new(ui.available_width(), 56.0);
|
let desired = Vec2::new(ui.available_width(), 56.0);
|
||||||
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
|
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))
|
(Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line))
|
||||||
} else {
|
} else {
|
||||||
(t.accent, t.accent_ink, Stroke::NONE)
|
(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
|
t.accent_dark
|
||||||
} else {
|
} else {
|
||||||
fill
|
fill
|
||||||
@@ -446,30 +471,6 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
|
|||||||
resp
|
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
|
/// 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
|
/// on a white plate, whatever the theme — inverted codes fail to decode in many
|
||||||
/// scanners. Encoded synchronously each frame; modules are plain painter rects.
|
/// 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.
|
/// 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();
|
let t = theme::tokens();
|
||||||
// Headline is the TOTAL the wallet holds — same number GRIM shows — so a
|
// Headline is the TOTAL the wallet holds — same number GRIM shows — so a
|
||||||
// wallet mid-confirmation doesn't look empty.
|
// 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 {
|
if let Some(fiat) = fiat {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
@@ -580,10 +610,10 @@ pub fn activity_row(
|
|||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
title: &str,
|
title: &str,
|
||||||
subtitle: &str,
|
subtitle: &str,
|
||||||
hue: usize,
|
|
||||||
id: &str,
|
id: &str,
|
||||||
amount: &str,
|
amount: &str,
|
||||||
incoming: bool,
|
incoming: bool,
|
||||||
|
canceled: bool,
|
||||||
system: bool,
|
system: bool,
|
||||||
tex: Option<&egui::TextureHandle>,
|
tex: Option<&egui::TextureHandle>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
@@ -614,7 +644,7 @@ pub fn activity_row(
|
|||||||
t.text,
|
t.text,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
avatar_any(ui, title, id, 40.0, hue, tex);
|
avatar_any(ui, title, id, 40.0, tex);
|
||||||
}
|
}
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
@@ -639,10 +669,18 @@ pub fn activity_row(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
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(
|
ui.label(
|
||||||
RichText::new(amount)
|
RichText::new(amount)
|
||||||
.font(FontId::new(15.0, fonts::mono_semibold()))
|
.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.
|
/// Center a fixed-width column for narrow content on wide screens.
|
||||||
/// Hands the child the full remaining height: wrapping in `horizontal()`
|
/// Hands the child the full remaining height: wrapping in `horizontal()`
|
||||||
/// would start the row a single line tall, so a `ScrollArea` inside would
|
/// would start the row a single line tall, so a `ScrollArea` inside would
|
||||||
@@ -981,11 +1013,3 @@ impl HoldToSend {
|
|||||||
false
|
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..])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ impl NetworkContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Content to draw when node is disabled.
|
/// 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| {
|
View::center_content(ui, 156.0, |ui| {
|
||||||
let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL);
|
let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL);
|
||||||
ui.label(
|
ui.label(
|
||||||
|
|||||||
@@ -406,11 +406,11 @@ impl WalletsContent {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} else if self.showing_wallet() {
|
} 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() {
|
if self.wallet_content.can_back() {
|
||||||
self.wallet_content.back(cb);
|
self.wallet_content.back(cb);
|
||||||
} else {
|
|
||||||
self.wallets.select(None);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -549,10 +549,10 @@ impl WalletsContent {
|
|||||||
});
|
});
|
||||||
} else if show_wallet && !dual_panel {
|
} else if show_wallet && !dual_panel {
|
||||||
View::title_button_big(ui, ARROW_LEFT, |_| {
|
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() {
|
if self.wallet_content.can_back() {
|
||||||
self.wallet_content.back(cb);
|
self.wallet_content.back(cb);
|
||||||
} else {
|
|
||||||
self.wallets.select(None);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if self.creating_wallet() {
|
} else if self.creating_wallet() {
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ impl WalletContentContainer for WalletContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
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
|
// 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.
|
// ready, then hand the whole surface to the payment-app-style view.
|
||||||
let block_nav_goblin = self.block_navigation_on_sync(wallet);
|
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 {
|
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
|
/// Take the pending "switch wallet" request from the goblin settings, so the
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ mod http;
|
|||||||
pub mod logger;
|
pub mod logger;
|
||||||
mod node;
|
mod node;
|
||||||
pub mod nostr;
|
pub mod nostr;
|
||||||
mod nym;
|
pub mod nym;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod wallet;
|
mod wallet;
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
|
|||||||
if AppConfig::autostart_node() {
|
if AppConfig::autostart_node() {
|
||||||
Node::start();
|
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
|
// first use. All of Goblin's outbound traffic egresses through it; nothing
|
||||||
// clearnet.
|
// clearnet.
|
||||||
nym::warm_up();
|
nym::warm_up();
|
||||||
@@ -410,6 +410,21 @@ pub fn app_foreground() -> bool {
|
|||||||
last != 0 && now_unix_secs() - last <= FOREGROUND_STALE_SECS
|
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! {
|
lazy_static! {
|
||||||
/// Data provided from deeplink or opened file.
|
/// Data provided from deeplink or opened file.
|
||||||
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||||
|
|||||||
@@ -721,7 +721,15 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText(
|
|||||||
_class: jni::objects::JObject,
|
_class: jni::objects::JObject,
|
||||||
_activity: jni::objects::JObject,
|
_activity: jni::objects::JObject,
|
||||||
) -> jni::sys::jstring {
|
) -> 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);
|
let j_text = _env.new_string(status_text);
|
||||||
return j_text.unwrap().into_raw();
|
return j_text.unwrap().into_raw();
|
||||||
}
|
}
|
||||||
@@ -736,7 +744,14 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncTitle(
|
|||||||
_class: jni::objects::JObject,
|
_class: jni::objects::JObject,
|
||||||
_activity: jni::objects::JObject,
|
_activity: jni::objects::JObject,
|
||||||
) -> jni::sys::jstring {
|
) -> 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();
|
return j_text.unwrap().into_raw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,11 @@
|
|||||||
//! Per-wallet nostr service: relay connections over the Nym mixnet,
|
//! Per-wallet nostr service: relay connections over the Nym mixnet,
|
||||||
//! identity event publishing, the guarded ingest loop and the DM send path.
|
//! 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 log::{error, info, warn};
|
||||||
use nostr_sdk::{
|
use nostr_sdk::{
|
||||||
Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification,
|
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 parking_lot::{Mutex, RwLock};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -32,6 +33,7 @@ use crate::nostr::ingest::{IngestContext, IngestDecision, decide};
|
|||||||
use crate::nostr::protocol;
|
use crate::nostr::protocol;
|
||||||
use crate::nostr::relays::MAX_DM_RELAYS;
|
use crate::nostr::relays::MAX_DM_RELAYS;
|
||||||
use crate::nostr::types::*;
|
use crate::nostr::types::*;
|
||||||
|
use crate::nostr::wrapv3;
|
||||||
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
|
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
|
||||||
use crate::nym::NymWebSocketTransport;
|
use crate::nym::NymWebSocketTransport;
|
||||||
use crate::wallet::Wallet;
|
use crate::wallet::Wallet;
|
||||||
@@ -44,6 +46,12 @@ pub struct NostrProfile {
|
|||||||
pub nip05: Option<String>,
|
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
|
/// 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.
|
/// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days.
|
||||||
const LOOKBACK_SECS: i64 = 3 * 86_400;
|
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> {
|
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()
|
self.config.read().relays()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,32 +515,15 @@ impl NostrService {
|
|||||||
let content = protocol::build_payment_content(slatepack);
|
let content = protocol::build_payment_content(slatepack);
|
||||||
let tags = protocol::build_rumor_tags(note);
|
let tags = protocol::build_rumor_tags(note);
|
||||||
|
|
||||||
// Resolve receiver DM relays (kind 10050) with our relays as fallback.
|
let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NIP-17 delivers to the RECIPIENT's relays, which may differ from ours;
|
// 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
|
// dial any we don't already hold so the gift wrap actually reaches their
|
||||||
// inbox (otherwise `send_*_to` errors "relay not found" / never arrives).
|
// inbox (otherwise `send_*_to` errors "relay not found" / never arrives).
|
||||||
connect_relays(&client, &urls).await;
|
connect_relays(&client, &urls).await;
|
||||||
|
|
||||||
let res = tokio::time::timeout(
|
self.dispatch_dm(&client, urls, v3, receiver, content, tags)
|
||||||
SEND_TIMEOUT,
|
.await
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a control DM that voids a pending request (a decline by the payer
|
/// 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 content = protocol::build_control_content();
|
||||||
let tags = protocol::build_control_tags(slate_id);
|
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 {
|
for r in relay_hints {
|
||||||
if !urls.contains(r) {
|
if !urls.contains(r) {
|
||||||
urls.push(r.clone());
|
urls.push(r.clone());
|
||||||
@@ -555,51 +609,63 @@ impl NostrService {
|
|||||||
urls.push(r);
|
urls.push(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(urls, v3)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a contact's kind 10050 DM relay list from our relays.
|
/// Fetch a contact's kind 10050 DM relay list plus their advertised
|
||||||
async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> Vec<String> {
|
/// NIP-44 v3 capability (the `encryption` tag of the same event). Queries
|
||||||
// Use cached relays first.
|
/// our own relays AND the pool's discovery indexers — the recipient's
|
||||||
if let Some(contact) = self.store.contact(&pk.to_hex()) {
|
/// 10050 lives on their relays and the indexers, not necessarily on
|
||||||
if !contact.relays.is_empty() {
|
/// anything we share. Both facts are cached on the contact together.
|
||||||
return contact.relays.into_iter().take(MAX_DM_RELAYS).collect();
|
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 filter = Filter::new().kind(Kind::InboxRelays).author(*pk).limit(1);
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
if let Ok(events) = client.fetch_events(filter, FETCH_TIMEOUT).await {
|
let mut v3 = false;
|
||||||
if let Some(event) = events.first() {
|
if let Ok(events) = client.fetch_events_from(&from, filter, FETCH_TIMEOUT).await
|
||||||
for tag in event.tags.iter() {
|
&& let Some(event) = events.first()
|
||||||
let parts = tag.as_slice();
|
{
|
||||||
if parts.first().map(|s| s.as_str()) == Some("relay") {
|
for tag in event.tags.iter() {
|
||||||
if let Some(url) = parts.get(1) {
|
let parts = tag.as_slice();
|
||||||
if out.len() < MAX_DM_RELAYS {
|
match parts.first().map(|s| s.as_str()) {
|
||||||
out.push(url.trim_end_matches('/').to_string());
|
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.
|
// Cache discovered relays + capability on the contact when present.
|
||||||
if !out.is_empty() {
|
if !out.is_empty()
|
||||||
if let Some(mut contact) = self.store.contact(&pk.to_hex()) {
|
&& let Some(mut contact) = self.store.contact(&pk.to_hex())
|
||||||
contact.relays = out.clone();
|
{
|
||||||
self.store.save_contact(&contact);
|
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).
|
/// Ensure a contact entry exists for a sender (auto-added as unknown).
|
||||||
@@ -619,6 +685,7 @@ impl NostrService {
|
|||||||
nip05: None,
|
nip05: None,
|
||||||
nip05_verified_at: None,
|
nip05_verified_at: None,
|
||||||
relays: vec![],
|
relays: vec![],
|
||||||
|
nip44_v3: false,
|
||||||
hue,
|
hue,
|
||||||
unknown: true,
|
unknown: true,
|
||||||
added_at: unix_time(),
|
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());
|
*svc.rt_handle.write() = Some(tokio::runtime::Handle::current());
|
||||||
// Mirror the configured name authority so resolution + display follow it.
|
// Mirror the configured name authority so resolution + display follow it.
|
||||||
crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain());
|
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()
|
let client = Client::builder()
|
||||||
.signer(svc.keys.clone())
|
.signer(svc.keys.clone())
|
||||||
.websocket_transport(NymWebSocketTransport)
|
.websocket_transport(NymWebSocketTransport)
|
||||||
.build();
|
.build();
|
||||||
for relay in &relays {
|
// Wait for the in-process Nym mixnet tunnel before any network work
|
||||||
if let Err(e) = client.add_relay(relay.clone()).await {
|
// (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at
|
||||||
warn!("nostr: add relay {relay} failed: {e}");
|
// 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
|
||||||
// Wait for the in-process Nym SOCKS5 proxy (:1080) before dialing relays.
|
// actually ready. Once it's warm this returns immediately.
|
||||||
// `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 {
|
for i in 0..60u32 {
|
||||||
if nym_socks_ready().await {
|
if crate::nym::is_ready() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
info!(
|
info!(
|
||||||
"nostr: Nym proxy ready after ~{}ms, dialing relays",
|
"nostr: Nym tunnel ready after ~{}ms, dialing relays",
|
||||||
i * 500
|
i * 500
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -766,6 +823,52 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
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();
|
let connect_started = std::time::Instant::now();
|
||||||
client.connect().await;
|
client.connect().await;
|
||||||
{
|
{
|
||||||
@@ -778,6 +881,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
{
|
{
|
||||||
let client_probe = client.clone();
|
let client_probe = client.clone();
|
||||||
let svc_probe = svc.clone();
|
let svc_probe = svc.clone();
|
||||||
|
let report_gen = dial_gen;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
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()",
|
"nostr: first relay Connected ~{}ms after connect()",
|
||||||
connect_started.elapsed().as_millis()
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if svc_probe.shutdown.load(Ordering::SeqCst)
|
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 events (kind 10050 DM relays; kind 0 only when named).
|
||||||
publish_identity(&svc, &client).await;
|
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
|
let since = svc
|
||||||
.store
|
.store
|
||||||
.last_connected_at()
|
.last_connected_at()
|
||||||
@@ -816,13 +928,26 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
.pubkey(svc.public_key())
|
.pubkey(svc.public_key())
|
||||||
.since(Timestamp::from_secs(since));
|
.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());
|
info!("nostr: catch-up fetched {} wraps", events.len());
|
||||||
for event in events.into_iter() {
|
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}");
|
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
|
// 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
|
// UI on "Connecting…" until the first heartbeat — by now catch-up has run, so
|
||||||
// a relay is typically already up.
|
// a relay is typically already up.
|
||||||
svc.connected
|
let connected = relays_connected(&client).await;
|
||||||
.store(relays_connected(&client).await, Ordering::Relaxed);
|
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();
|
let mut notifications = client.notifications();
|
||||||
// Poll connection state on a SHORT, INDEPENDENT interval. This used to live in
|
// 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() => {
|
notification = notifications.recv() => {
|
||||||
match notification {
|
match notification {
|
||||||
Ok(RelayPoolNotification::Event { event, .. }) => {
|
Ok(RelayPoolNotification::Event { event, .. }) => {
|
||||||
handle_wrap(&svc, &wallet, &client, *event).await;
|
handle_wrap(&svc, &wallet, *event).await;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||||
@@ -880,8 +1011,28 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = status_tick.tick() => {
|
_ = status_tick.tick() => {
|
||||||
svc.connected
|
// A tunnel reselect (new exit) bumps the generation. The current
|
||||||
.store(relays_connected(&client).await, Ordering::Relaxed);
|
// 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();
|
let now = unix_time();
|
||||||
if now - last_heartbeat >= 30 {
|
if now - last_heartbeat >= 30 {
|
||||||
last_heartbeat = now;
|
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();
|
let mut w_client = svc.client.write();
|
||||||
*w_client = None;
|
*w_client = None;
|
||||||
@@ -929,19 +1083,6 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
|||||||
client.disconnect().await;
|
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
|
/// 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).
|
/// 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
|
/// `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;
|
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.
|
/// True when at least one relay has completed its handshake.
|
||||||
async fn relays_connected(client: &Client) -> bool {
|
async fn relays_connected(client: &Client) -> bool {
|
||||||
client
|
client
|
||||||
@@ -969,18 +1137,73 @@ async fn relays_connected(client: &Client) -> bool {
|
|||||||
.any(|r| r.status() == RelayStatus::Connected)
|
.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) {
|
async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
|
||||||
let relays = svc.relays();
|
let advertised: Vec<String> = svc.relays().into_iter().take(MAX_DM_RELAYS).collect();
|
||||||
let dm_tags: Vec<Tag> = relays
|
|
||||||
|
let mut dm_tags: Vec<Tag> = advertised
|
||||||
.iter()
|
.iter()
|
||||||
.take(MAX_DM_RELAYS)
|
|
||||||
.map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()]))
|
.map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()]))
|
||||||
.collect();
|
.collect();
|
||||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags);
|
// NIP-17 backward-compat extension: advertise our NIP-44 capabilities,
|
||||||
if let Err(e) = client.send_event_builder(builder).await {
|
// space-separated best-first, so v3-aware senders pick v3 (G4).
|
||||||
warn!("nostr: publish 10050 failed: {e}");
|
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 (anonymous, nip05) = {
|
||||||
let identity = svc.identity.read();
|
let identity = svc.identity.read();
|
||||||
@@ -995,12 +1218,45 @@ async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
|
|||||||
.name(name)
|
.name(name)
|
||||||
.nip05(nip05)
|
.nip05(nip05)
|
||||||
.custom_field("goblin_accepts_requests", allow_requests);
|
.custom_field("goblin_accepts_requests", allow_requests);
|
||||||
let builder = EventBuilder::metadata(&metadata);
|
builders.push(EventBuilder::metadata(&metadata));
|
||||||
if let Err(e) = client.send_event_builder(builder).await {
|
|
||||||
warn!("nostr: publish kind 0 failed: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
/// 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.
|
// 0. Only gift wraps.
|
||||||
if event.kind != Kind::GiftWrap {
|
if event.kind != Kind::GiftWrap {
|
||||||
return;
|
return;
|
||||||
@@ -1178,8 +1434,10 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
|||||||
if !svc.allow_global_unwrap() {
|
if !svc.allow_global_unwrap() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed).
|
// 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed),
|
||||||
let unwrapped = match client.unwrap_gift_wrap(&event).await {
|
// 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,
|
Ok(u) => u,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
svc.store.mark_processed(&wrap_id);
|
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
|
// Resolve the sender's @username so the receive shows their name in
|
||||||
// activity, not a bare npub.
|
// activity, not a bare npub.
|
||||||
svc.resolve_contact_identity(&sender_hex);
|
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) {
|
match wallet.nostr_receive(&slate) {
|
||||||
Ok((_, reply_text)) => {
|
Ok((_, reply_text)) => {
|
||||||
// Record BEFORE dispatching the reply: crash here is
|
// 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(&wrap_id);
|
||||||
svc.store.mark_processed(&rumor_id);
|
svc.store.mark_processed(&rumor_id);
|
||||||
svc.store.mark_processed(&slate_marker);
|
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
|
match svc
|
||||||
.send_payment_dm(&sender_hex, &reply_text, None, &[])
|
.send_payment_dm(&sender_hex, &reply_text, None, &[])
|
||||||
.await
|
.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.
|
// @username so the completed request shows their name, not a bare npub.
|
||||||
svc.ensure_contact(&sender_hex);
|
svc.ensure_contact(&sender_hex);
|
||||||
svc.resolve_contact_identity(&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) {
|
match wallet.nostr_finalize_post(&slate) {
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
svc.store
|
svc.store
|
||||||
@@ -1456,6 +1731,7 @@ mod tests {
|
|||||||
nip05: Some("ada@goblin.st".to_string()),
|
nip05: Some("ada@goblin.st".to_string()),
|
||||||
nip05_verified_at: Some(1000),
|
nip05_verified_at: Some(1000),
|
||||||
relays: vec![],
|
relays: vec![],
|
||||||
|
nip44_v3: false,
|
||||||
hue: 0,
|
hue: 0,
|
||||||
unknown: false,
|
unknown: false,
|
||||||
added_at: 1,
|
added_at: 1,
|
||||||
|
|||||||
@@ -104,12 +104,16 @@ impl NostrConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn relays(&self) -> Vec<String> {
|
pub fn relays(&self) -> Vec<String> {
|
||||||
self.relays
|
self.relays_override()
|
||||||
.clone()
|
|
||||||
.filter(|r| !r.is_empty())
|
|
||||||
.unwrap_or_else(|| DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect())
|
.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>) {
|
pub fn set_relays(&mut self, relays: Vec<String>) {
|
||||||
self.relays = Some(relays);
|
self.relays = Some(relays);
|
||||||
self.save();
|
self.save();
|
||||||
|
|||||||
@@ -12,24 +12,24 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//! Per-wallet nostr identity: NIP-06 derived from the wallet mnemonic by
|
//! Per-wallet nostr identity: a random standalone nsec (or an imported one),
|
||||||
//! default (one seed restores money AND identity) or imported from an nsec.
|
//! 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.
|
//! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password.
|
||||||
|
|
||||||
use nostr_sdk::nips::nip44;
|
use nostr_sdk::nips::nip44;
|
||||||
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
|
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
|
||||||
use nostr_sdk::prelude::FromMnemonic;
|
|
||||||
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
|
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub enum IdentitySource {
|
pub enum IdentitySource {
|
||||||
/// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the
|
|
||||||
/// identity to the seed forever).
|
|
||||||
Derived,
|
|
||||||
/// Imported nsec.
|
/// Imported nsec.
|
||||||
Imported,
|
Imported,
|
||||||
/// Freshly generated random key, independent of the wallet seed: the
|
/// Freshly generated random key, independent of the wallet seed: the
|
||||||
@@ -42,8 +42,6 @@ pub enum IdentitySource {
|
|||||||
pub struct NostrIdentity {
|
pub struct NostrIdentity {
|
||||||
pub ver: u8,
|
pub ver: u8,
|
||||||
pub source: IdentitySource,
|
pub source: IdentitySource,
|
||||||
/// NIP-06 account index used for derivation.
|
|
||||||
pub derivation_account: u32,
|
|
||||||
/// NIP-49 encrypted secret key (bech32 ncryptsec).
|
/// NIP-49 encrypted secret key (bech32 ncryptsec).
|
||||||
pub ncryptsec: String,
|
pub ncryptsec: String,
|
||||||
/// Public key, bech32 npub (plaintext so the UI can render pre-unlock).
|
/// 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.
|
/// Previous npubs from key rotations (newest last), for reference.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prev_npubs: Vec<String>,
|
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).
|
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
|
||||||
@@ -142,24 +146,6 @@ impl NostrIdentity {
|
|||||||
let _ = fs::remove_file(Self::path(nostr_dir));
|
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
|
/// Build an identity from already-unlocked keys under a (possibly
|
||||||
/// different) password — used when importing a backup that was exported
|
/// different) password — used when importing a backup that was exported
|
||||||
/// under another wallet's password.
|
/// under another wallet's password.
|
||||||
@@ -167,15 +153,14 @@ impl NostrIdentity {
|
|||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
password: &str,
|
password: &str,
|
||||||
source: IdentitySource,
|
source: IdentitySource,
|
||||||
account: u32,
|
|
||||||
) -> Result<NostrIdentity, IdentityError> {
|
) -> 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.
|
/// Create a brand-new random identity, independent of the wallet seed.
|
||||||
pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> {
|
pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> {
|
||||||
let keys = Keys::generate();
|
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))
|
Ok((identity, keys))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +172,7 @@ impl NostrIdentity {
|
|||||||
let secret = SecretKey::parse(nsec.trim())
|
let secret = SecretKey::parse(nsec.trim())
|
||||||
.map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?;
|
.map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?;
|
||||||
let keys = Keys::new(secret);
|
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))
|
Ok((identity, keys))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +180,6 @@ impl NostrIdentity {
|
|||||||
keys: &Keys,
|
keys: &Keys,
|
||||||
password: &str,
|
password: &str,
|
||||||
source: IdentitySource,
|
source: IdentitySource,
|
||||||
account: u32,
|
|
||||||
) -> Result<NostrIdentity, IdentityError> {
|
) -> Result<NostrIdentity, IdentityError> {
|
||||||
let encrypted = EncryptedSecretKey::new(
|
let encrypted = EncryptedSecretKey::new(
|
||||||
keys.secret_key(),
|
keys.secret_key(),
|
||||||
@@ -214,12 +198,12 @@ impl NostrIdentity {
|
|||||||
Ok(NostrIdentity {
|
Ok(NostrIdentity {
|
||||||
ver: 1,
|
ver: 1,
|
||||||
source,
|
source,
|
||||||
derivation_account: account,
|
|
||||||
ncryptsec,
|
ncryptsec,
|
||||||
npub,
|
npub,
|
||||||
nip05: None,
|
nip05: None,
|
||||||
anonymous: true,
|
anonymous: true,
|
||||||
prev_npubs: Vec::new(),
|
prev_npubs: Vec::new(),
|
||||||
|
dm_relays: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,13 +302,7 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&a).unwrap();
|
let json = serde_json::to_string(&a).unwrap();
|
||||||
let parsed: NostrIdentity = serde_json::from_str(&json).unwrap();
|
let parsed: NostrIdentity = serde_json::from_str(&json).unwrap();
|
||||||
let keys = parsed.unlock("old-pw").unwrap();
|
let keys = parsed.unlock("old-pw").unwrap();
|
||||||
let b = NostrIdentity::from_unlocked_keys(
|
let b = NostrIdentity::from_unlocked_keys(&keys, "new-pw", parsed.source).unwrap();
|
||||||
&keys,
|
|
||||||
"new-pw",
|
|
||||||
parsed.source,
|
|
||||||
parsed.derivation_account,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(b.npub, a.npub);
|
assert_eq!(b.npub, a.npub);
|
||||||
assert!(b.unlock("new-pw").is_ok());
|
assert!(b.unlock("new-pw").is_ok());
|
||||||
assert!(b.unlock("old-pw").is_err());
|
assert!(b.unlock("old-pw").is_err());
|
||||||
@@ -364,21 +342,10 @@ mod tests {
|
|||||||
assert!(a.unlock("wrong").is_err());
|
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]
|
#[test]
|
||||||
fn encrypt_unlock_roundtrip() {
|
fn encrypt_unlock_roundtrip() {
|
||||||
let (identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "hunter2", 0).unwrap();
|
let (identity, keys) = NostrIdentity::create_random("hunter2").unwrap();
|
||||||
assert_eq!(identity.source, IdentitySource::Derived);
|
assert_eq!(identity.source, IdentitySource::Random);
|
||||||
assert!(identity.anonymous);
|
assert!(identity.anonymous);
|
||||||
let unlocked = identity.unlock("hunter2").unwrap();
|
let unlocked = identity.unlock("hunter2").unwrap();
|
||||||
assert_eq!(unlocked.public_key(), keys.public_key());
|
assert_eq!(unlocked.public_key(), keys.public_key());
|
||||||
@@ -401,7 +368,7 @@ mod tests {
|
|||||||
fn identity_file_is_owner_only() {
|
fn identity_file_is_owner_only() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let dir = std::env::temp_dir().join(format!("goblin-id-test-{}", std::process::id()));
|
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();
|
identity.save(&dir).unwrap();
|
||||||
let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap();
|
let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap();
|
||||||
// The ncryptsec blob must never be group/world readable.
|
// The ncryptsec blob must never be group/world readable.
|
||||||
@@ -415,7 +382,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reencrypt_changes_password() {
|
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();
|
identity.reencrypt("old", "new").unwrap();
|
||||||
assert!(identity.unlock("old").is_err());
|
assert!(identity.unlock("old").is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub use types::*;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub use config::{AcceptPolicy, NostrConfig};
|
pub use config::{AcceptPolicy, NostrConfig};
|
||||||
|
|
||||||
|
pub mod pool;
|
||||||
pub mod relays;
|
pub mod relays;
|
||||||
|
|
||||||
mod store;
|
mod store;
|
||||||
@@ -33,6 +34,8 @@ pub use identity::{IdentitySource, NostrIdentity};
|
|||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub use protocol::*;
|
pub use protocol::*;
|
||||||
|
|
||||||
|
pub mod wrapv3;
|
||||||
|
|
||||||
pub mod ingest;
|
pub mod ingest;
|
||||||
pub use ingest::*;
|
pub use ingest::*;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//! NIP-05 username resolution/verification and goblin.st registration,
|
//! 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.
|
//! here touches clearnet.
|
||||||
|
|
||||||
use base64::Engine;
|
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
|
/// Public profile probe: `None` = network failure, `Some(None)` = name has
|
||||||
/// no avatar (or no such name), `Some(Some(hash))` = avatar content hash.
|
/// no avatar (or no such name), `Some(Some(hash))` = avatar content hash.
|
||||||
pub async fn fetch_profile(server: &str, name: &str) -> Option<Option<String>> {
|
pub async fn fetch_profile(server: &str, name: &str) -> Option<Option<String>> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,11 @@ pub struct Contact {
|
|||||||
pub nip05_verified_at: Option<i64>,
|
pub nip05_verified_at: Option<i64>,
|
||||||
/// Known DM relays (kind 10050) of the contact.
|
/// Known DM relays (kind 10050) of the contact.
|
||||||
pub relays: Vec<String>,
|
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.
|
/// Avatar palette index.
|
||||||
pub hue: u8,
|
pub hue: u8,
|
||||||
/// Auto-added from an incoming payment, not yet confirmed by the user.
|
/// Auto-added from an incoming payment, not yet confirmed by the user.
|
||||||
|
|||||||
@@ -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, ¬_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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,65 +13,98 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
|
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
|
||||||
//! every HTTP request (NIP-05, price, avatars) — is routed through Goblin's
|
//! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet:
|
||||||
//! in-process Nym SOCKS5 client (the Nym SDK linked directly, no subprocess)
|
//! by default one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an
|
||||||
//! that tunnels over the 5-hop mixnet to a network requester. The mixnet breaks
|
//! auto-selected public IPR exit, so neither the payload nor the
|
||||||
//! the sender↔receiver timing correlation that Mimblewimble's interactive slate
|
//! destination-in-flight ever touches the clearnet. Hostnames resolve through
|
||||||
//! exchange otherwise leaks at the network layer, and it bootstraps in ~2s.
|
//! the same tunnel too ([`dns`], DoT — DNS-over-TLS), so nothing goes
|
||||||
//! Nothing goes clearnet.
|
//! 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;
|
pub mod transport;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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;
|
pub use transport::NymWebSocketTransport;
|
||||||
|
|
||||||
/// Local SOCKS5 endpoint exposed by the in-process Nym SOCKS5 client.
|
/// How long a single HTTP exchange (one redirect hop) may take end to end.
|
||||||
/// `socks5h` keeps DNS resolution inside the proxy so the destination host is
|
/// The mixnet adds deliberate per-hop delay; allow generous time.
|
||||||
/// never resolved on the clear.
|
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
pub const SOCKS5_HOST: &str = "127.0.0.1";
|
|
||||||
pub const SOCKS5_PORT: u16 = 1080;
|
|
||||||
|
|
||||||
/// `socks5h://127.0.0.1:1080` proxy URL for reqwest.
|
/// How long to wait for the shared tunnel before giving up on a request.
|
||||||
pub fn proxy_url() -> String {
|
const TUNNEL_WAIT: Duration = Duration::from_secs(30);
|
||||||
format!("socks5h://{SOCKS5_HOST}:{SOCKS5_PORT}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `127.0.0.1:1080` for the raw SOCKS5 TCP dialer (relay websockets).
|
/// Redirect hops to follow before giving up (matches the old client, which
|
||||||
pub fn socks5_addr() -> String {
|
/// followed redirects transparently).
|
||||||
format!("{SOCKS5_HOST}:{SOCKS5_PORT}")
|
const MAX_REDIRECTS: usize = 5;
|
||||||
}
|
|
||||||
|
|
||||||
/// An HTTP request routed over the Nym mixnet via the in-process SOCKS5 client.
|
/// An HTTP request routed over the Nym mixnet: resolve the host over the tunnel
|
||||||
/// Returns `(status, body)`.
|
/// (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(
|
pub async fn http_request_bytes(
|
||||||
method: &str,
|
method: &str,
|
||||||
url: String,
|
url: String,
|
||||||
body: Option<Vec<u8>>,
|
body: Option<Vec<u8>>,
|
||||||
headers: Vec<(String, String)>,
|
headers: Vec<(String, String)>,
|
||||||
) -> Option<(u16, Vec<u8>)> {
|
) -> Option<(u16, Vec<u8>)> {
|
||||||
let proxy = reqwest::Proxy::all(proxy_url()).ok()?;
|
let tunnel = nymproc::wait_for_tunnel(TUNNEL_WAIT).await?;
|
||||||
let client = reqwest::Client::builder()
|
let mut url = url::Url::parse(&url).ok()?;
|
||||||
.proxy(proxy)
|
let mut method = method.to_uppercase();
|
||||||
.user_agent("goblin-wallet")
|
let mut body = body;
|
||||||
// The mixnet adds deliberate per-hop delay; allow generous time.
|
for _ in 0..=MAX_REDIRECTS {
|
||||||
.timeout(Duration::from_secs(60))
|
let (status, resp_body, location) = tokio::time::timeout(
|
||||||
.build()
|
HTTP_TIMEOUT,
|
||||||
.ok()?;
|
request_once(&tunnel, &method, &url, body.clone(), &headers),
|
||||||
let m = reqwest::Method::from_bytes(method.as_bytes()).ok()?;
|
)
|
||||||
let mut req = client.request(m, &url);
|
.await
|
||||||
for (k, v) in headers {
|
.map_err(|_| warn!("nym http: request to {} timed out", redacted(&url)))
|
||||||
req = req.header(k, v);
|
.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 {
|
warn!("nym http: too many redirects for {}", redacted(&url));
|
||||||
req = req.body(b);
|
None
|
||||||
}
|
|
||||||
let resp = req.send().await.ok()?;
|
|
||||||
let code = resp.status().as_u16();
|
|
||||||
let bytes = resp.bytes().await.ok()?.to_vec();
|
|
||||||
Some((code, bytes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// String-bodied convenience wrapper around [`http_request_bytes`].
|
/// String-bodied convenience wrapper around [`http_request_bytes`].
|
||||||
@@ -85,3 +118,173 @@ pub async fn http_request(
|
|||||||
.await
|
.await
|
||||||
.map(|(_, raw)| String::from_utf8_lossy(&raw).to_string())
|
.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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,17 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//! WebSocket transport for the Nostr relay pool routed through Goblin's
|
//! WebSocket transport for the Nostr relay pool routed through the Nym
|
||||||
//! in-process Nym SOCKS5 client, so every relay connection traverses the 5-hop
|
//! mixnet, with TWO egresses picked per relay. ANCHOR: a relay whose pool
|
||||||
//! Nym mixnet. We open a SOCKS5 connection to `127.0.0.1:1080`, ask the proxy
|
//! entry advertises its operator's co-located scoped exit
|
||||||
//! to reach the relay host (`socks5h`-style: the proxy does the DNS, so the
|
//! ([`crate::nostr::pool::PoolRelay::exit`]) is dialed over a MixnetStream
|
||||||
//! destination is never resolved on the clear), then run the TLS + websocket
|
//! straight to that exit ([`super::streamexit`]) — no DNS, no public IPR.
|
||||||
//! handshake over that tunnel. Nothing goes clearnet.
|
//! 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::fmt;
|
||||||
use std::pin::Pin;
|
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_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
|
||||||
use nostr_sdk::Url;
|
use nostr_sdk::Url;
|
||||||
use nostr_sdk::util::BoxedFuture;
|
use nostr_sdk::util::BoxedFuture;
|
||||||
use tokio_socks::tcp::Socks5Stream;
|
|
||||||
use tokio_tungstenite::tungstenite::Message as TgMessage;
|
use tokio_tungstenite::tungstenite::Message as TgMessage;
|
||||||
|
|
||||||
/// Error type for transport failures outside the websocket layer.
|
/// 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()))
|
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)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct NymWebSocketTransport;
|
pub struct NymWebSocketTransport;
|
||||||
|
|
||||||
@@ -74,17 +78,60 @@ impl WebSocketTransport for NymWebSocketTransport {
|
|||||||
_ => 443,
|
_ => 443,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dial the relay host through the local Nym SOCKS5 client. The proxy
|
// MONEY-PATH ANCHOR: when the pool advertises this relay
|
||||||
// resolves the host inside the mixnet, so no clearnet DNS leak.
|
// operator's co-located scoped Nym exit, dial THROUGH it — a
|
||||||
let stream = tokio::time::timeout(
|
// MixnetStream straight to the exit (which pipes to its one
|
||||||
timeout,
|
// relay), no public DNS, no public IPR, no tunnel dependency. The
|
||||||
Socks5Stream::connect(crate::nym::socks5_addr().as_str(), (host.as_str(), port)),
|
// TLS + websocket wrap inside is byte-for-byte the tunnel path's
|
||||||
)
|
// (same `client_async_tls`, SNI = the relay host), so the exit
|
||||||
.await
|
// sees only ciphertext. ANY failure — bootstrap, open, handshake,
|
||||||
.map_err(|_| terr("nym socks5 connect timeout"))?
|
// timeout — falls through to the public-IPR tunnel dial below:
|
||||||
.map_err(|e| terr(format!("nym socks5 connect failed: {e}")))?;
|
// 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.
|
// Perform TLS (for wss) + websocket handshake over the mixnet stream.
|
||||||
|
let t_ws = std::time::Instant::now();
|
||||||
let (ws, _response) = tokio::time::timeout(
|
let (ws, _response) = tokio::time::timeout(
|
||||||
timeout,
|
timeout,
|
||||||
tokio_tungstenite::client_async_tls(url.as_str(), stream),
|
tokio_tungstenite::client_async_tls(url.as_str(), stream),
|
||||||
@@ -92,22 +139,61 @@ impl WebSocketTransport for NymWebSocketTransport {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| terr("websocket handshake timeout"))?
|
.map_err(|_| terr("websocket handshake timeout"))?
|
||||||
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
|
.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();
|
Ok(split_ws(ws))
|
||||||
|
|
||||||
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))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// Convert a tungstenite message into an async-wsocket pool message.
|
||||||
/// Returns `None` for raw frames (never surfaced while reading).
|
/// Returns `None` for raw frames (never surfaced while reading).
|
||||||
fn tg_to_message(msg: TgMessage) -> Option<Message> {
|
fn tg_to_message(msg: TgMessage) -> Option<Message> {
|
||||||
|
|||||||
@@ -38,17 +38,16 @@ pub struct ExternalConnection {
|
|||||||
pub available: Option<bool>,
|
pub available: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default external node URLs for main network. api.grin.money leads (verified
|
/// Default external node URLs for main network. grincoin.org leads (owner-verified:
|
||||||
/// healthy; grincoin.org's node was returning "rpc call failed"); main.us-ea.st
|
/// `/v2/foreign` get_tip returns cleanly). api.grin.money was REMOVED this build: it
|
||||||
/// is the Goblin-run node. The rest are independent public nodes so a single
|
/// errors ("Cannot parse response") on `get_unspent_outputs` during a fresh-wallet
|
||||||
/// operator going down never strands the wallet.
|
/// full scan, surfacing as the "error during synchronization" screen. main.gri.mw and
|
||||||
const DEFAULT_MAIN_URLS: [&'static str; 6] = [
|
/// mainnet.grinffindor.org are the other verified-working public nodes, so a single
|
||||||
"https://api.grin.money",
|
/// operator going down never strands the wallet. Users can still add their own node.
|
||||||
"https://main.us-ea.st",
|
const DEFAULT_MAIN_URLS: [&'static str; 3] = [
|
||||||
"https://grincoin.org",
|
"https://grincoin.org",
|
||||||
"https://main.gri.mw",
|
"https://main.gri.mw",
|
||||||
"https://mainnet.grinffindor.org",
|
"https://mainnet.grinffindor.org",
|
||||||
"https://main.grin.raubritter.org",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Default external node URLs for the test network — the testnet counterparts of
|
/// Default external node URLs for the test network — the testnet counterparts of
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,3 +34,6 @@ pub use utils::WalletUtils;
|
|||||||
|
|
||||||
mod seed;
|
mod seed;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod e2e;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ use std::io::Write;
|
|||||||
use std::net::{SocketAddr, TcpListener, ToSocketAddrs};
|
use std::net::{SocketAddr, TcpListener, ToSocketAddrs};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::str::FromStr;
|
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::mpsc::Sender;
|
||||||
use std::sync::{Arc, mpsc};
|
use std::sync::{Arc, mpsc};
|
||||||
use std::thread::Thread;
|
use std::thread::Thread;
|
||||||
@@ -85,6 +85,14 @@ pub struct Wallet {
|
|||||||
sync_thread: Arc<RwLock<Option<Thread>>>,
|
sync_thread: Arc<RwLock<Option<Thread>>>,
|
||||||
/// Flag to check if wallet is syncing.
|
/// Flag to check if wallet is syncing.
|
||||||
syncing: Arc<AtomicBool>,
|
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 loading progress in percents.
|
||||||
info_sync_progress: Arc<AtomicU8>,
|
info_sync_progress: Arc<AtomicU8>,
|
||||||
/// Error on wallet loading.
|
/// Error on wallet loading.
|
||||||
@@ -161,6 +169,8 @@ impl Wallet {
|
|||||||
account_time: Arc::new(Default::default()),
|
account_time: Arc::new(Default::default()),
|
||||||
sync_thread: Arc::from(RwLock::new(None)),
|
sync_thread: Arc::from(RwLock::new(None)),
|
||||||
syncing: Arc::new(AtomicBool::new(false)),
|
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)),
|
info_sync_progress: Arc::from(AtomicU8::new(0)),
|
||||||
sync_error: Arc::from(AtomicBool::new(false)),
|
sync_error: Arc::from(AtomicBool::new(false)),
|
||||||
sync_attempts: Arc::new(AtomicU8::new(0)),
|
sync_attempts: Arc::new(AtomicU8::new(0)),
|
||||||
@@ -573,13 +583,8 @@ impl Wallet {
|
|||||||
backup-password field."
|
backup-password field."
|
||||||
.to_string()
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
let mut ident = NostrIdentity::from_unlocked_keys(
|
let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
|
||||||
&keys,
|
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||||
&password,
|
|
||||||
backup.source,
|
|
||||||
backup.derivation_account,
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
|
||||||
ident.nip05 = backup.nip05.clone();
|
ident.nip05 = backup.nip05.clone();
|
||||||
ident.anonymous = backup.anonymous;
|
ident.anonymous = backup.anonymous;
|
||||||
ident.prev_npubs = backup.prev_npubs.clone();
|
ident.prev_npubs = backup.prev_npubs.clone();
|
||||||
@@ -595,13 +600,8 @@ impl Wallet {
|
|||||||
field"
|
field"
|
||||||
.to_string()
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
let mut ident = NostrIdentity::from_unlocked_keys(
|
let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
|
||||||
&keys,
|
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||||
&password,
|
|
||||||
backup.source,
|
|
||||||
backup.derivation_account,
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
|
||||||
ident.nip05 = backup.nip05.clone();
|
ident.nip05 = backup.nip05.clone();
|
||||||
ident.anonymous = backup.anonymous;
|
ident.anonymous = backup.anonymous;
|
||||||
ident.prev_npubs = backup.prev_npubs.clone();
|
ident.prev_npubs = backup.prev_npubs.clone();
|
||||||
@@ -1153,6 +1153,25 @@ impl Wallet {
|
|||||||
self.syncing.load(Ordering::Relaxed)
|
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.
|
/// Get running Foreign API server port.
|
||||||
pub fn foreign_api_port(&self) -> Option<u16> {
|
pub fn foreign_api_port(&self) -> Option<u16> {
|
||||||
let r_api = self.foreign_api_server.read();
|
let r_api = self.foreign_api_server.read();
|
||||||
@@ -2075,8 +2094,22 @@ fn start_sync(wallet: Wallet) -> Thread {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync wallet from node.
|
// On-demand node polling (Android battery): while the app is
|
||||||
sync_wallet_data(&wallet, true);
|
// 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.
|
// Stop sync if wallet was closed.
|
||||||
@@ -2106,6 +2139,57 @@ fn start_sync(wallet: Wallet) -> Thread {
|
|||||||
.clone()
|
.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
|
/// 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
|
/// "Couldn't send" actually explains itself — most often locked/unconfirmed
|
||||||
/// funds after a recent payment.
|
/// funds after a recent payment.
|
||||||
@@ -2369,6 +2453,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
|||||||
nip05: None,
|
nip05: None,
|
||||||
nip05_verified_at: None,
|
nip05_verified_at: None,
|
||||||
relays: relay_hints.clone(),
|
relays: relay_hints.clone(),
|
||||||
|
nip44_v3: false,
|
||||||
hue: crate::gui::views::goblin::data::hue_of(receiver)
|
hue: crate::gui::views::goblin::data::hue_of(receiver)
|
||||||
as u8,
|
as u8,
|
||||||
unknown: true,
|
unknown: true,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||