Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce23214d98 | |||
| e6e262009e | |||
| 2478a7c08a | |||
| d95efef896 | |||
| 698806421f | |||
| 61545b767d | |||
| 87f82eb61e | |||
| b3165c3964 | |||
| 4d1db9cb28 | |||
| 138785cf67 | |||
| 9caa2b6809 | |||
| b00719f2f9 | |||
| c83771bbf8 | |||
| 54344bd1d3 | |||
| 23bb845689 | |||
| aa39737d3b | |||
| 5733b9a894 | |||
| 8f1a955f9a | |||
| ae4306febe | |||
| 989fd5b04a | |||
| acf9a140f6 | |||
| db793bc13d | |||
| 71bf9b90e5 | |||
| 5869ff78be | |||
| 300d9cea4c | |||
| 337220299f | |||
| 12f78f3af7 | |||
| bb2e8602ff | |||
| 78e141f319 | |||
| c701f0f480 | |||
| 1e8e0f6526 | |||
| 9262d7429b | |||
| 36e63d4751 |
@@ -0,0 +1,21 @@
|
||||
name: Fetch nip44 crate
|
||||
description: >
|
||||
Clone the nip44 crate (v2 + v3 encryption) from our mirror
|
||||
(github.com/2ro/nip44, branch `v3`) into ../nip44, so the
|
||||
`nip44 = { path = "../nip44" }` dependency resolves.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clone nip44
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEST="$(dirname "$GITHUB_WORKSPACE")/nip44"
|
||||
if [ -e "$DEST/Cargo.toml" ]; then
|
||||
echo "nip44 already present at $DEST"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "$DEST"
|
||||
git clone --branch v3 --depth 1 https://github.com/2ro/nip44.git "$DEST"
|
||||
echo "nip44 cloned from 2ro/nip44@v3 -> $DEST"
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
submodules: recursive
|
||||
# nym-sdk is a path dep on ../nym; materialize it before building.
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
# nip44 is a path dep on ../nip44; materialize it before building.
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -28,6 +30,7 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -39,5 +42,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# macOS is DEFERRED until Linux/Windows/Android are polished — so this is
|
||||
# manual-dispatch only for now (no auto-build on release publish). When macOS
|
||||
# is back on the table, re-add `release: { types: [published] }` here and the
|
||||
# macOS job will attach a universal build to each release automatically.
|
||||
# macOS builds on its native runner automatically when a release is published
|
||||
# (the macOS job has no dispatch-only gate). Linux/Windows stay dispatch-only —
|
||||
# they are built locally and uploaded with the release; run the workflow by hand
|
||||
# to (re)build those on runners against an existing tag.
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -38,6 +40,7 @@ jobs:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
|
||||
@@ -63,6 +66,7 @@ jobs:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
|
||||
@@ -110,6 +114,7 @@ jobs:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build both architectures
|
||||
run: |
|
||||
export GOBLIN_BUILD="${TAG#build}"
|
||||
|
||||
@@ -24,3 +24,8 @@ Cargo.toml-e
|
||||
screenshots/
|
||||
# GRIM-canonical build toolchains fetched by scripts/toolchain.sh
|
||||
.toolchains/
|
||||
# Runtime wallet/node artifacts + secrets generated by running locally — NEVER commit
|
||||
.owner_api_secret
|
||||
.foreign_api_secret
|
||||
grin-wallet.log
|
||||
grin-wallet.toml
|
||||
|
||||
@@ -2813,6 +2813,47 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||
dependencies = [
|
||||
"defmt 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2 1.0.106",
|
||||
"quote 1.0.44",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -4496,6 +4537,7 @@ dependencies = [
|
||||
"grin_wallet_libwallet",
|
||||
"grin_wallet_util",
|
||||
"hex",
|
||||
"hickory-proto",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-proxy2",
|
||||
@@ -4509,6 +4551,7 @@ dependencies = [
|
||||
"local-ip-address",
|
||||
"log",
|
||||
"log4rs",
|
||||
"nip44",
|
||||
"nokhwa",
|
||||
"nostr-relay-pool",
|
||||
"nostr-sdk",
|
||||
@@ -4521,23 +4564,24 @@ dependencies = [
|
||||
"qrcodegen",
|
||||
"rand 0.9.2",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rfd",
|
||||
"ring 0.16.20",
|
||||
"rkv",
|
||||
"rqrr",
|
||||
"rust-i18n",
|
||||
"rustls 0.23.40",
|
||||
"secp256k1 0.31.1",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2 0.10.9",
|
||||
"smolmix",
|
||||
"sys-locale",
|
||||
"thiserror 2.0.18",
|
||||
"tokio 0.2.25",
|
||||
"tokio 1.49.0",
|
||||
"tokio-socks 0.5.3",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-tungstenite 0.26.2",
|
||||
"tokio-util 0.2.0",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
@@ -4545,6 +4589,7 @@ dependencies = [
|
||||
"url",
|
||||
"usvg",
|
||||
"uuid 0.8.2",
|
||||
"webpki-roots 1.0.7",
|
||||
"wgpu",
|
||||
"winit",
|
||||
"winresource",
|
||||
@@ -4552,7 +4597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_api"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"bytes 1.11.1",
|
||||
@@ -4585,7 +4630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_chain"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"bit-vec 0.6.3",
|
||||
"bitflags 1.3.2",
|
||||
@@ -4607,7 +4652,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_config"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"dirs 2.0.2",
|
||||
"grin_core",
|
||||
@@ -4622,7 +4667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_core"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"blake2-rfc",
|
||||
"byteorder",
|
||||
@@ -4647,7 +4692,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_keychain"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"blake2-rfc",
|
||||
"byteorder",
|
||||
@@ -4668,7 +4713,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_p2p"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"built",
|
||||
@@ -4690,7 +4735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_pool"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"blake2-rfc",
|
||||
"chrono",
|
||||
@@ -4721,7 +4766,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_servers"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"chrono",
|
||||
@@ -4751,7 +4796,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_store"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"croaring",
|
||||
@@ -4769,7 +4814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "grin_util"
|
||||
version = "5.4.0"
|
||||
version = "5.4.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"backtrace",
|
||||
@@ -5045,6 +5090,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -5162,6 +5216,16 @@ dependencies = [
|
||||
"http 1.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.3.3"
|
||||
@@ -5660,7 +5724,6 @@ dependencies = [
|
||||
"tokio 1.49.0",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6983,6 +7046,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "managed"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -7408,6 +7477,23 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nip44"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b65fa17d19c3eb6b7b4a908eea19a18ded30f1d95a80a08234f1f80ffe1cb9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chacha20 0.9.1",
|
||||
"constant_time_eq 0.4.2",
|
||||
"hkdf 0.12.4",
|
||||
"hmac 0.12.1",
|
||||
"rand_core 0.9.5",
|
||||
"secp256k1 0.31.1",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -11017,44 +11103,6 @@ dependencies = [
|
||||
"winreg 0.50.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes 1.11.1",
|
||||
"futures-core",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite 0.2.16",
|
||||
"quinn",
|
||||
"rustls 0.23.40",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio 1.49.0",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -11780,6 +11828,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2"
|
||||
dependencies = [
|
||||
"bitcoin_hashes 0.14.100",
|
||||
"rand 0.9.2",
|
||||
"secp256k1-sys 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.8.2"
|
||||
@@ -11798,6 +11857,15 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secp256k1-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secrecy"
|
||||
version = "0.6.0"
|
||||
@@ -12306,6 +12374,36 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smolmix"
|
||||
version = "1.21.1"
|
||||
dependencies = [
|
||||
"futures 0.3.31",
|
||||
"nym-ip-packet-requests",
|
||||
"nym-sdk",
|
||||
"smoltcp",
|
||||
"thiserror 2.0.18",
|
||||
"tokio 1.49.0",
|
||||
"tokio-smoltcp",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"byteorder",
|
||||
"cfg-if 1.0.4",
|
||||
"defmt 0.3.100",
|
||||
"heapless",
|
||||
"libc",
|
||||
"log",
|
||||
"managed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "snow"
|
||||
version = "0.9.6"
|
||||
@@ -13362,6 +13460,20 @@ dependencies = [
|
||||
"tokio 1.49.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-smoltcp"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5f5d53da1c3095663a8900d86c2abb0ffe02d3f6aa86527b066148fcb33e65e"
|
||||
dependencies = [
|
||||
"futures 0.3.31",
|
||||
"parking_lot 0.12.5",
|
||||
"pin-project-lite 0.2.16",
|
||||
"smoltcp",
|
||||
"tokio 1.49.0",
|
||||
"tokio-util 0.7.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-socks"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -87,7 +87,7 @@ rkv = "0.20.0"
|
||||
usvg = "0.45.1"
|
||||
ring = "0.16.20"
|
||||
hyper = { version = "1.6.0", features = ["full"], package = "hyper" }
|
||||
hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy"] }
|
||||
hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy", "tokio"] }
|
||||
http-body-util = "0.1.3"
|
||||
bytes = "1.11.0"
|
||||
hyper-socks2 = "0.9.1"
|
||||
@@ -100,29 +100,42 @@ num-bigint = "0.4.6"
|
||||
## nostr
|
||||
nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] }
|
||||
nostr-relay-pool = "0.44"
|
||||
## NIP-44 v3 (+ v2) encryption for the NIP-17 backward-compat extension (G4).
|
||||
## Now published to crates.io as v0.3.0 (the M0 deliverable, all upstream test
|
||||
## vectors green) — no local sibling checkout required. secp256k1 0.31, bridged
|
||||
## to nostr-sdk's 0.29 in wrapv3.rs (see the secp256k1 note below).
|
||||
nip44 = "0.3.0"
|
||||
## Only to construct the key types the `nip44` crate takes: nostr-sdk pins
|
||||
## secp256k1 0.29, the nip44 crate 0.31 — bridged via byte arrays in wrapv3.rs.
|
||||
secp256k1 = "0.31"
|
||||
async-wsocket = "0.13"
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
regex = "1"
|
||||
base64 = "0.22"
|
||||
hex = "0.4"
|
||||
## HTTP client routed through the local Nym SOCKS5 sidecar (rustls, no native
|
||||
## TLS so it cross-compiles to Android; `socks` so every request — NIP-05,
|
||||
## price, avatars — goes over the mixnet, never clearnet).
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "socks"] }
|
||||
## SOCKS5 TCP dialer for the nostr relay WebSocket transport over the mixnet.
|
||||
tokio-socks = "0.5"
|
||||
|
||||
## rustls is pulled by both our TLS (tungstenite/reqwest, ring) and nym-sdk
|
||||
## rustls is pulled by both our TLS (tungstenite, ring) and nym-sdk
|
||||
## (aws-lc-rs); with two providers present rustls 0.23 can't auto-pick a default,
|
||||
## so we install ring explicitly at startup (see lib.rs). Direct dep just to make
|
||||
## `rustls::crypto::ring::default_provider()` reachable.
|
||||
## `rustls::crypto::ring::default_provider()` reachable. NOTHING here may pull
|
||||
## rustls-platform-verifier — it panics on Android outside a full app context.
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
## TLS-over-tunnel for the mixnet HTTP client (webpki roots, never the platform
|
||||
## verifier — see the rustls note above).
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
||||
webpki-roots = "1"
|
||||
|
||||
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary). We
|
||||
## run the SDK's SOCKS5 client on an internal tokio task exposing 127.0.0.1:1080,
|
||||
## the same loopback seam the transport already dials. Path dep: the local nym
|
||||
## checkout carries our Android webpki-roots patch.
|
||||
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary).
|
||||
## Path deps into the local nym checkout, PINNED at rev
|
||||
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b: it carries the load-bearing local
|
||||
## commit "http-api-client: preconfigured webpki roots on Android". Do not
|
||||
## float the checkout past that rev without re-verifying the Android build.
|
||||
nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
|
||||
## smolmix: TCP/UDP tunnel over the mixnet with an AUTO-SELECTED IPR exit —
|
||||
## the single-network-requester SPOF is structurally gone (plan G14).
|
||||
smolmix = { path = "../nym/smolmix/core" }
|
||||
## mix-dns wire codec. Already in the dependency graph via nym-http-api-client
|
||||
## (Cargo.lock), so we reuse it instead of vendoring a DNS encode/parse.
|
||||
hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
|
||||
|
||||
## NIP-98 payload hashing
|
||||
sha2 = "0.10.8"
|
||||
@@ -184,3 +197,9 @@ base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
serde_yaml = "0.9"
|
||||
## G14 transport-validation harness (tests/xrelay_smoke.rs): re-expose deps that
|
||||
## already live in the main graph so the smolmix transport can be exercised and
|
||||
## its tunnel/mix-dns logs captured. No new compiles — same versions unify.
|
||||
log = "0.4.27"
|
||||
env_logger = "0.11.3"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
|
||||
@@ -64,7 +64,7 @@ Install the Android SDK / NDK, then from the repo root:
|
||||
|
||||
## Identity service (`goblin-nip05d`)
|
||||
|
||||
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration/transfer/release. The wallet is fully usable — and fully anonymous — without it. Avatars aren't stored or served — clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
|
||||
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred — on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable — and fully anonymous — without it. Avatars aren't stored or served — clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -11,8 +11,15 @@ android {
|
||||
applicationId "st.goblin.wallet"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 5
|
||||
versionName "0.3.6"
|
||||
// Version tracks Goblin's build number (GOBLIN_BUILD) so the Android
|
||||
// package identity moves forward with every release instead of freezing.
|
||||
// Same env the Rust side stamps into crate::BUILD; the in-app updater
|
||||
// compares build numbers, and this keeps versionCode (Android's
|
||||
// upgrade/downgrade integer) in lockstep. Bare local gradle → fallback.
|
||||
// ponytail: fallback keeps a keyless ./gradlew working; CI/release sets the env
|
||||
def goblinBuild = (System.getenv("GOBLIN_BUILD") ?: "").trim()
|
||||
versionCode goblinBuild.isEmpty() ? 5 : goblinBuild.toInteger()
|
||||
versionName goblinBuild.isEmpty() ? "0.3.6" : goblinBuild
|
||||
}
|
||||
|
||||
lint {
|
||||
|
||||
@@ -23,6 +23,14 @@ public class BackgroundService extends Service {
|
||||
private boolean mStopped = false;
|
||||
|
||||
private static final int NOTIFICATION_ID = 1;
|
||||
// One-shot "payment received" notification, separate from the persistent
|
||||
// sync notification above.
|
||||
private static final int PAYMENT_NOTIFICATION_ID = 2;
|
||||
private static final String PAYMENT_CHANNEL_ID = "PaymentReceived";
|
||||
// One-shot "payment requested" notification (someone asking us to pay them),
|
||||
// separate from both the sync (id=1) and received-payment (id=2) notifications.
|
||||
private static final int REQUEST_NOTIFICATION_ID = 3;
|
||||
private static final String REQUEST_CHANNEL_ID = "PaymentRequested";
|
||||
private NotificationCompat.Builder mNotificationBuilder;
|
||||
|
||||
private String mNotificationContentText = "";
|
||||
@@ -70,12 +78,13 @@ public class BackgroundService extends Service {
|
||||
if (Build.VERSION.SDK_INT > 25) {
|
||||
startStopIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
|
||||
}
|
||||
if (canStart) {
|
||||
startStopIntent.setAction(ACTION_START_NODE);
|
||||
PendingIntent i = PendingIntent
|
||||
.getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
|
||||
mNotificationBuilder.addAction(R.drawable.ic_start, getStartText(), i);
|
||||
} else if (canStop) {
|
||||
// Goblin's background job is the light Nostr-over-Nym payment
|
||||
// listen (the "Listening for payments" status); the heavy
|
||||
// integrated node is never STARTED from this notification --
|
||||
// Goblin defaults to an external node, so the GRIM "Enable"
|
||||
// action is removed. Only offer STOP as a safety valve if the
|
||||
// node is somehow already running (started elsewhere).
|
||||
if (canStop) {
|
||||
startStopIntent.setAction(ACTION_STOP_NODE);
|
||||
PendingIntent i = PendingIntent
|
||||
.getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
|
||||
@@ -189,6 +198,74 @@ public class BackgroundService extends Service {
|
||||
notificationManager.cancel(NOTIFICATION_ID);
|
||||
}
|
||||
|
||||
// Show a one-shot "payment received" notification (id=2), separate from
|
||||
// the persistent sync notification (id=1). Called from native code via
|
||||
// MainActivity when a payment slatepack is received over nostr, possibly
|
||||
// while the app is backgrounded. Localization of the fixed strings is a
|
||||
// follow-up (text is composed here at Java side).
|
||||
public static void notifyPaymentReceived(Context context, String name, String amount) {
|
||||
NotificationManager manager = context.getSystemService(NotificationManager.class);
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
// High-importance channel so the notification pops with sound + vibration.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
PAYMENT_CHANNEL_ID, "Payments", NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
manager.createNotificationChannel(channel);
|
||||
}
|
||||
Intent i = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_IMMUTABLE);
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, PAYMENT_CHANNEL_ID)
|
||||
.setContentTitle("Payment received")
|
||||
.setContentText(name + " paid " + amount + " ツ")
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||
.setContentIntent(pendingIntent);
|
||||
try {
|
||||
manager.notify(PAYMENT_NOTIFICATION_ID, builder.build());
|
||||
} catch (SecurityException e) {
|
||||
// POST_NOTIFICATIONS not granted: skip the notification, never the payment.
|
||||
}
|
||||
}
|
||||
|
||||
// Show a one-shot "payment requested" notification (id=3), separate from both
|
||||
// the persistent sync notification (id=1) and the received-payment one (id=2).
|
||||
// Called from native code via MainActivity when a payment request (Invoice1)
|
||||
// arrives over nostr, possibly while the app is backgrounded. Mirrors
|
||||
// notifyPaymentReceived; strings are composed here Java-side.
|
||||
public static void notifyPaymentRequested(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(
|
||||
REQUEST_CHANNEL_ID, "Payment requests", 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, REQUEST_CHANNEL_ID)
|
||||
.setContentTitle("Payment requested")
|
||||
.setContentText(name + " requested " + amount + " ツ")
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||
.setContentIntent(pendingIntent);
|
||||
try {
|
||||
manager.notify(REQUEST_NOTIFICATION_ID, builder.build());
|
||||
} catch (SecurityException e) {
|
||||
// POST_NOTIFICATIONS not granted: skip the notification, never the request.
|
||||
}
|
||||
}
|
||||
|
||||
// Start the service.
|
||||
public static void start(Context c) {
|
||||
if (!isServiceRunning(c)) {
|
||||
|
||||
@@ -421,6 +421,18 @@ public class MainActivity extends GameActivity {
|
||||
// Notify native code to stop activity (e.g. node) if app was terminated from recent apps.
|
||||
public native void onTermination();
|
||||
|
||||
// Called from native code to show a "payment received" notification
|
||||
// (BackgroundService id=2) when a payment arrives over nostr.
|
||||
public void notifyPaymentReceived(String name, String amount) {
|
||||
BackgroundService.notifyPaymentReceived(this, name, amount);
|
||||
}
|
||||
|
||||
// Called from native code to show a "payment requested" notification
|
||||
// (BackgroundService id=3) when a payment request arrives over nostr.
|
||||
public void notifyPaymentRequested(String name, String amount) {
|
||||
BackgroundService.notifyPaymentRequested(this, name, amount);
|
||||
}
|
||||
|
||||
// Called from native code to set text into clipboard.
|
||||
public void copyText(String data) {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
@@ -619,7 +631,12 @@ public class MainActivity extends GameActivity {
|
||||
// Called from native code to pick the file.
|
||||
public void pickFile() {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.setType("text/*");
|
||||
// Permissive type: custom extensions like .backup have no registered
|
||||
// MIME type, so any narrower filter greys them out in the picker and
|
||||
// locks users out of restoring their identity. Content is validated
|
||||
// on the native side after selection.
|
||||
intent.setType("*/*");
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
try {
|
||||
mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
|
||||
} catch (android.content.ActivityNotFoundException ex) {
|
||||
|
||||
|
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 |
@@ -46,12 +46,12 @@ fn main() {
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
Command::new("cmd")
|
||||
.args(&["/C", &git_hooks])
|
||||
.args(["/C", &git_hooks])
|
||||
.output()
|
||||
.expect("failed to execute git config for hooks");
|
||||
} else {
|
||||
Command::new("sh")
|
||||
.args(&["-c", &git_hooks])
|
||||
.args(["-c", &git_hooks])
|
||||
.output()
|
||||
.expect("failed to execute git config for hooks");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Avatar sizing-checkpoint harness: renders the REAL `avatar_tex` (custom
|
||||
//! image, no ring) and `gradient_avatar` across every size the app uses.
|
||||
//! Names never affect the avatar — this just checks sizing 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
|
||||
/// sizing 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("avatar sizing sheet (no ring — names never affect the avatar)");
|
||||
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):");
|
||||
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(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)))),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Local network measurement for the Nym read tunnel. Uses the wallet's REAL
|
||||
// transport (warm_up + tuned tunnel + reselect + DNS cache + HTTP keep-alive
|
||||
// pool), then fetches the live price API over the mixnet on a fixed interval
|
||||
// so we can see (a) cold connect time, (b) whether the connection stays warm,
|
||||
// (c) per-fetch latency over time.
|
||||
//
|
||||
// cargo run --release --example tunnel_measure -- <seconds> [interval_secs]
|
||||
//
|
||||
// e.g. `-- 300` (5 min) or `-- 600 15` (10 min, every 15s).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let total_secs: u64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(300);
|
||||
let interval_secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(15);
|
||||
|
||||
let run_start = Instant::now();
|
||||
println!("[t=0.0s] warm_up(): starting the tunnel");
|
||||
grim::nym::warm_up();
|
||||
|
||||
// Cold connect time: poll is_ready().
|
||||
let mut connect_ms = None;
|
||||
let t_connect = Instant::now();
|
||||
while t_connect.elapsed() < Duration::from_secs(120) {
|
||||
if grim::nym::is_ready() {
|
||||
connect_ms = Some(t_connect.elapsed().as_millis());
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
match connect_ms {
|
||||
Some(ms) => println!(
|
||||
"[t={:.1}s] TUNNEL READY (cold connect {} ms)",
|
||||
run_start.elapsed().as_secs_f64(),
|
||||
ms
|
||||
),
|
||||
None => {
|
||||
println!("tunnel never became ready in 120s; aborting");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Warm-loop: fetch price over the mixnet every interval, record latency.
|
||||
let mut lats: Vec<u128> = vec![];
|
||||
let mut fails = 0u32;
|
||||
let deadline = run_start + Duration::from_secs(total_secs);
|
||||
let mut n = 0u32;
|
||||
while Instant::now() < deadline {
|
||||
n += 1;
|
||||
let t = Instant::now();
|
||||
let ok = grim::nym::http_request("GET", PRICE_URL.to_string(), None, vec![]).await;
|
||||
let ms = t.elapsed().as_millis();
|
||||
match ok {
|
||||
Some(body) if body.contains("grin") => {
|
||||
lats.push(ms);
|
||||
println!(
|
||||
"[t={:.1}s] fetch #{n}: {} ms ready={}",
|
||||
run_start.elapsed().as_secs_f64(),
|
||||
ms,
|
||||
grim::nym::is_ready()
|
||||
);
|
||||
}
|
||||
other => {
|
||||
fails += 1;
|
||||
println!(
|
||||
"[t={:.1}s] fetch #{n}: FAIL after {} ms (ready={}, body={:?})",
|
||||
run_start.elapsed().as_secs_f64(),
|
||||
ms,
|
||||
grim::nym::is_ready(),
|
||||
other.map(|b| b.chars().take(40).collect::<String>())
|
||||
);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
|
||||
}
|
||||
|
||||
// Summary.
|
||||
lats.sort_unstable();
|
||||
let n_ok = lats.len();
|
||||
let sum: u128 = lats.iter().sum();
|
||||
let median = lats.get(n_ok / 2).copied().unwrap_or(0);
|
||||
println!(
|
||||
"\n==== SUMMARY ({}s run, {}s interval) ====",
|
||||
total_secs, interval_secs
|
||||
);
|
||||
println!("cold connect: {} ms", connect_ms.unwrap());
|
||||
println!("fetches: {} ok, {} failed", n_ok, fails);
|
||||
if n_ok > 0 {
|
||||
println!(
|
||||
"warm fetch latency ms: min {} / median {} / max {} / mean {}",
|
||||
lats.first().unwrap(),
|
||||
median,
|
||||
lats.last().unwrap(),
|
||||
sum / n_ok as u128
|
||||
);
|
||||
let head: Vec<u128> = lats.iter().take(3).copied().collect();
|
||||
println!("(sorted sample) fastest 3: {:?}", head);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ set -euo pipefail
|
||||
|
||||
platform="${1:-x86_64}"
|
||||
case "${platform}" in
|
||||
x86_64) arch="x86_64-unknown-linux-gnu" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu" ;;
|
||||
x86_64) arch="x86_64-unknown-linux-gnu"; appimage_arch="x86_64" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu"; appimage_arch="aarch64" ;;
|
||||
*) echo "Usage: build_release.sh [platform] (platform: 'x86_64' | 'arm')" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -51,7 +51,13 @@ out="target/${arch}/release/Goblin-${platform}.AppImage"
|
||||
rm -f "target/${arch}/release/"*.AppImage
|
||||
# Use the DEV appimagetool + type2 runtime when fetched, else the system tool.
|
||||
appimagetool_bin="${GOBLIN_APPIMAGETOOL:-appimagetool}"
|
||||
# The type2 runtime must match the target arch. env.sh sets GOBLIN_APPIMAGE_RUNTIME
|
||||
# to the x86_64 runtime; for a non-x86_64 target use the sibling runtime-<arch>.
|
||||
runtime_file="${GOBLIN_APPIMAGE_RUNTIME:-}"
|
||||
if [ "${appimage_arch}" != "x86_64" ] && [ -n "${runtime_file}" ]; then
|
||||
runtime_file="$(dirname "${runtime_file}")/runtime-${appimage_arch}"
|
||||
fi
|
||||
runtime_arg=()
|
||||
[ -n "${GOBLIN_APPIMAGE_RUNTIME:-}" ] && runtime_arg=(--runtime-file "${GOBLIN_APPIMAGE_RUNTIME}")
|
||||
ARCH=x86_64 "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
[ -n "${runtime_file}" ] && [ -e "${runtime_file}" ] && runtime_arg=(--runtime-file "${runtime_file}")
|
||||
ARCH="${appimage_arch}" "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
echo "built: ${out}"
|
||||
|
||||
@@ -365,6 +365,8 @@ goblin:
|
||||
cant_reach_node: "Node nicht erreichbar"
|
||||
node_synced: "Node synchronisiert"
|
||||
syncing: "Synchronisiere…"
|
||||
balance_updating: "Guthaben wird aktualisiert…"
|
||||
listening: "Wartet auf Zahlungen"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Warte auf Chain…"
|
||||
nav_wallet: "Wallet"
|
||||
@@ -434,12 +436,14 @@ goblin:
|
||||
requesting: "Fordere %{amt}%{tsu} an — teilen, um bezahlt zu werden"
|
||||
clear_request: "Anfrage löschen"
|
||||
share_handle: "Teile deinen Handle, um bezahlt zu werden"
|
||||
share_npub: "Teile deinen npub, um bezahlt zu werden"
|
||||
copied: "Kopiert"
|
||||
copy_nostr_id: "nostr-ID kopieren"
|
||||
copy_address: "Adresse kopieren"
|
||||
copy_npub: "npub kopieren"
|
||||
share_message: "Bezahl mich auf Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Dein Benutzername ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt."
|
||||
privacy_note_npub: "Dein npub ist öffentlich. Zahlungsinhalte bleiben im Netzwerk verschlüsselt."
|
||||
profile:
|
||||
title: "Profil"
|
||||
activity: "Aktivität"
|
||||
@@ -460,7 +464,10 @@ goblin:
|
||||
wallet: "Wallet"
|
||||
display_unit: "Anzeigeeinheit"
|
||||
relays: "Relays"
|
||||
nostr_relays: "Nostr-Relays"
|
||||
node: "Node"
|
||||
integrated_node: "Einstellungen des integrierten Nodes"
|
||||
node_advanced: "Erweitert"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Manuelle Transaktion"
|
||||
lock_wallet: "Wallet sperren"
|
||||
@@ -470,7 +477,7 @@ goblin:
|
||||
mixnet_routing: "Mixnet-Routing"
|
||||
messages_lookups: "Nachrichten & Abfragen"
|
||||
auto_accept: "Automatisch annehmen"
|
||||
pairing: "Kopplung"
|
||||
pairing: "Preiswährung"
|
||||
accept_anyone: "Jeder"
|
||||
accept_contacts: "Nur Kontakte"
|
||||
accept_ask: "Immer fragen"
|
||||
@@ -485,6 +492,7 @@ goblin:
|
||||
archive: "Archiv"
|
||||
export_archive: "Archiv exportieren"
|
||||
wipe_history: "Zahlungsverlauf löschen"
|
||||
wipe_history_confirm: "Zum Löschen erneut tippen — kann nicht rückgängig gemacht werden"
|
||||
about: "Über"
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
@@ -561,7 +569,7 @@ goblin:
|
||||
keep_it: "Behalten"
|
||||
release_it: "Freigeben"
|
||||
username: "Benutzername"
|
||||
username_note: "Wird als you angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt."
|
||||
username_note: "Wird als dein Name angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt."
|
||||
release_username: "Benutzername freigeben"
|
||||
pick_username: "Benutzernamen wählen — optional"
|
||||
working: "Arbeite…"
|
||||
@@ -601,6 +609,12 @@ goblin:
|
||||
repair_confirm: "Ja, jetzt reparieren"
|
||||
repair_confirm_note: "Die Reparatur scannt die Chain neu und kann einige Minuten dauern."
|
||||
restore_confirm_note: "Dies löscht lokale Daten und baut sie aus deinem Seed neu auf — das kann einige Minuten dauern."
|
||||
nostr_key: "Nostr-Schlüssel"
|
||||
nostr_key_desc: "Dein nsec, der geheime Schlüssel deiner Nostr-Identität. Kopiere ihn oder zeige den QR-Code, um dich bei Nostr-Apps wie magick.market anzumelden. Wer ihn hat, kontrolliert deine Identität, also halte ihn geheim."
|
||||
reveal_nsec: "Schlüssel anzeigen"
|
||||
copy_nsec: "nsec kopieren"
|
||||
show_qr: "QR anzeigen"
|
||||
hide_qr: "QR ausblenden"
|
||||
privacy:
|
||||
title: "Netzwerk-Privatsphäre"
|
||||
intro: "Goblin sendet seinen privaten Datenverkehr durch das Nym mixnet — ein Netzwerk mit fünf Sprüngen, das verbirgt, wer mit wem kommuniziert, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
|
||||
|
||||
@@ -365,6 +365,8 @@ goblin:
|
||||
cant_reach_node: "Can't reach node"
|
||||
node_synced: "Node synced"
|
||||
syncing: "Syncing…"
|
||||
balance_updating: "Balance updating…"
|
||||
listening: "Listening for payments"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Waiting for chain…"
|
||||
nav_wallet: "Wallet"
|
||||
@@ -434,12 +436,14 @@ goblin:
|
||||
requesting: "Requesting %{amt}%{tsu} — share to get paid"
|
||||
clear_request: "Clear request"
|
||||
share_handle: "Share your handle to get paid"
|
||||
share_npub: "Share your npub to get paid"
|
||||
copied: "Copied"
|
||||
copy_nostr_id: "Copy nostr ID"
|
||||
copy_address: "Copy address"
|
||||
copy_npub: "Copy npub"
|
||||
share_message: "Pay me on Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Your username is public. Payment contents stay encrypted over the network."
|
||||
privacy_note_npub: "Your npub is public. Payment contents stay encrypted over the network."
|
||||
profile:
|
||||
title: "Profile"
|
||||
activity: "Activity"
|
||||
@@ -460,7 +464,10 @@ goblin:
|
||||
wallet: "Wallet"
|
||||
display_unit: "Display unit"
|
||||
relays: "Relays"
|
||||
nostr_relays: "Nostr Relays"
|
||||
node: "Node"
|
||||
integrated_node: "Integrated node settings"
|
||||
node_advanced: "Advanced"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Manual transaction"
|
||||
lock_wallet: "Lock wallet"
|
||||
@@ -470,7 +477,7 @@ goblin:
|
||||
mixnet_routing: "Mixnet routing"
|
||||
messages_lookups: "Messages & lookups"
|
||||
auto_accept: "Auto-accept"
|
||||
pairing: "Pairing"
|
||||
pairing: "Price currency"
|
||||
accept_anyone: "Anyone"
|
||||
accept_contacts: "Contacts only"
|
||||
accept_ask: "Always ask"
|
||||
@@ -485,6 +492,7 @@ goblin:
|
||||
archive: "Archive"
|
||||
export_archive: "Export archive"
|
||||
wipe_history: "Wipe payment history"
|
||||
wipe_history_confirm: "Tap again to wipe — this can't be undone"
|
||||
about: "About"
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
@@ -561,7 +569,7 @@ goblin:
|
||||
keep_it: "Keep it"
|
||||
release_it: "Release it"
|
||||
username: "Username"
|
||||
username_note: "Shown as you. Public on goblin.st. Payments stay encrypted."
|
||||
username_note: "Shown as your name. Public on goblin.st. Payments stay encrypted."
|
||||
release_username: "Release username"
|
||||
pick_username: "Pick a username — optional"
|
||||
working: "Working…"
|
||||
@@ -601,6 +609,12 @@ goblin:
|
||||
repair_confirm: "Yes, repair now"
|
||||
repair_confirm_note: "Repair re-scans the chain and can take a few minutes."
|
||||
restore_confirm_note: "This erases local data and rebuilds it from your seed — it can take several minutes."
|
||||
nostr_key: "Nostr key"
|
||||
nostr_key_desc: "Your nsec, the secret key to your nostr identity. Copy it or show its QR to log in to nostr apps like magick.market. Anyone who has it controls your identity, so keep it private."
|
||||
reveal_nsec: "Show key"
|
||||
copy_nsec: "Copy nsec"
|
||||
show_qr: "Show QR"
|
||||
hide_qr: "Hide QR"
|
||||
privacy:
|
||||
title: "Network privacy"
|
||||
intro: "Goblin sends its private traffic through the Nym mixnet — a five-hop network that hides who is talking to whom, so a relay can't link a payment back to you."
|
||||
|
||||
@@ -365,6 +365,8 @@ goblin:
|
||||
cant_reach_node: "Nœud injoignable"
|
||||
node_synced: "Nœud synchronisé"
|
||||
syncing: "Synchronisation…"
|
||||
balance_updating: "Solde en cours de mise à jour…"
|
||||
listening: "En attente de paiements"
|
||||
block: "Bloc %{height}"
|
||||
waiting_for_chain: "En attente de la chaîne…"
|
||||
nav_wallet: "Portefeuille"
|
||||
@@ -434,12 +436,14 @@ goblin:
|
||||
requesting: "Demande de %{amt}%{tsu} — partagez pour être payé"
|
||||
clear_request: "Effacer la demande"
|
||||
share_handle: "Partagez votre identifiant pour être payé"
|
||||
share_npub: "Partagez votre npub pour être payé"
|
||||
copied: "Copié"
|
||||
copy_nostr_id: "Copier l'ID nostr"
|
||||
copy_address: "Copier l'adresse"
|
||||
copy_npub: "Copier npub"
|
||||
share_message: "Payez-moi sur Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Votre nom d'utilisateur est public. Le contenu des paiements reste chiffré sur le réseau."
|
||||
privacy_note_npub: "Votre npub est public. Le contenu des paiements reste chiffré sur le réseau."
|
||||
profile:
|
||||
title: "Profil"
|
||||
activity: "Activité"
|
||||
@@ -460,7 +464,10 @@ goblin:
|
||||
wallet: "Portefeuille"
|
||||
display_unit: "Unité d'affichage"
|
||||
relays: "Relais"
|
||||
nostr_relays: "Relais Nostr"
|
||||
node: "Nœud"
|
||||
integrated_node: "Paramètres du nœud intégré"
|
||||
node_advanced: "Avancé"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Transaction manuelle"
|
||||
lock_wallet: "Verrouiller le portefeuille"
|
||||
@@ -470,7 +477,7 @@ goblin:
|
||||
mixnet_routing: "Routage par mixnet"
|
||||
messages_lookups: "Messages et recherches"
|
||||
auto_accept: "Acceptation auto"
|
||||
pairing: "Appairage"
|
||||
pairing: "Devise des prix"
|
||||
accept_anyone: "Tout le monde"
|
||||
accept_contacts: "Contacts seulement"
|
||||
accept_ask: "Toujours demander"
|
||||
@@ -485,6 +492,7 @@ goblin:
|
||||
archive: "Archive"
|
||||
export_archive: "Exporter l'archive"
|
||||
wipe_history: "Effacer l'historique des paiements"
|
||||
wipe_history_confirm: "Appuyez à nouveau pour effacer — action irréversible"
|
||||
about: "À propos"
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
@@ -561,7 +569,7 @@ goblin:
|
||||
keep_it: "Le garder"
|
||||
release_it: "Le libérer"
|
||||
username: "Nom d'utilisateur"
|
||||
username_note: "Affiché comme you. Public sur goblin.st. Les paiements restent chiffrés."
|
||||
username_note: "Affiché comme votre nom. Public sur goblin.st. Les paiements restent chiffrés."
|
||||
release_username: "Libérer le nom d'utilisateur"
|
||||
pick_username: "Choisir un nom d'utilisateur — facultatif"
|
||||
working: "En cours…"
|
||||
@@ -601,6 +609,12 @@ goblin:
|
||||
repair_confirm: "Oui, réparer maintenant"
|
||||
repair_confirm_note: "La réparation réanalyse la chaîne et peut prendre quelques minutes."
|
||||
restore_confirm_note: "Cela efface les données locales et les reconstruit depuis votre seed — cela peut prendre plusieurs minutes."
|
||||
nostr_key: "Clé Nostr"
|
||||
nostr_key_desc: "Votre nsec, la clé secrète de votre identité Nostr. Copiez-la ou affichez son QR pour vous connecter à des applis Nostr comme magick.market. Quiconque la possède contrôle votre identité, gardez-la privée."
|
||||
reveal_nsec: "Afficher la clé"
|
||||
copy_nsec: "Copier le nsec"
|
||||
show_qr: "Afficher le QR"
|
||||
hide_qr: "Masquer le QR"
|
||||
privacy:
|
||||
title: "Confidentialité réseau"
|
||||
intro: "Goblin envoie son trafic privé via le mixnet Nym — un réseau à cinq sauts qui masque qui parle à qui, afin qu'un relais ne puisse pas relier un paiement à vous."
|
||||
|
||||
@@ -365,6 +365,8 @@ goblin:
|
||||
cant_reach_node: "Нет связи с узлом"
|
||||
node_synced: "Узел синхронизирован"
|
||||
syncing: "Синхронизация…"
|
||||
balance_updating: "Баланс обновляется…"
|
||||
listening: "Ожидание платежей"
|
||||
block: "Блок %{height}"
|
||||
waiting_for_chain: "Ожидание цепочки…"
|
||||
nav_wallet: "Кошелёк"
|
||||
@@ -434,12 +436,14 @@ goblin:
|
||||
requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату"
|
||||
clear_request: "Очистить запрос"
|
||||
share_handle: "Поделитесь именем, чтобы получить оплату"
|
||||
share_npub: "Поделитесь своим npub, чтобы получить оплату"
|
||||
copied: "Скопировано"
|
||||
copy_nostr_id: "Копировать nostr ID"
|
||||
copy_address: "Копировать адрес"
|
||||
copy_npub: "Копировать npub"
|
||||
share_message: "Заплатите мне в Goblin (goblin.st) — %{npub}"
|
||||
privacy_note: "Ваше имя публично. Содержимое платежей остаётся зашифрованным в сети."
|
||||
privacy_note_npub: "Ваш npub публичен. Содержимое платежей остаётся зашифрованным в сети."
|
||||
profile:
|
||||
title: "Профиль"
|
||||
activity: "Действия"
|
||||
@@ -460,7 +464,10 @@ goblin:
|
||||
wallet: "Кошелёк"
|
||||
display_unit: "Единица отображения"
|
||||
relays: "Реле"
|
||||
nostr_relays: "Реле Nostr"
|
||||
node: "Узел"
|
||||
integrated_node: "Настройки встроенного узла"
|
||||
node_advanced: "Дополнительно"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Ручная транзакция"
|
||||
lock_wallet: "Заблокировать кошелёк"
|
||||
@@ -470,7 +477,7 @@ goblin:
|
||||
mixnet_routing: "Маршрутизация через mixnet"
|
||||
messages_lookups: "Сообщения и поиск"
|
||||
auto_accept: "Автоприём"
|
||||
pairing: "Привязка"
|
||||
pairing: "Валюта цены"
|
||||
accept_anyone: "Любой"
|
||||
accept_contacts: "Только контакты"
|
||||
accept_ask: "Всегда спрашивать"
|
||||
@@ -485,6 +492,7 @@ goblin:
|
||||
archive: "Архив"
|
||||
export_archive: "Экспорт архива"
|
||||
wipe_history: "Стереть историю платежей"
|
||||
wipe_history_confirm: "Нажмите ещё раз, чтобы стереть — это нельзя отменить"
|
||||
about: "О приложении"
|
||||
goblin: "Goblin"
|
||||
build: "Сборка %{build}"
|
||||
@@ -561,7 +569,7 @@ goblin:
|
||||
keep_it: "Оставить"
|
||||
release_it: "Освободить"
|
||||
username: "Имя пользователя"
|
||||
username_note: "Показывается как you. Публично на goblin.st. Платежи остаются зашифрованными."
|
||||
username_note: "Отображается как ваше имя. Публично на goblin.st. Платежи остаются зашифрованными."
|
||||
release_username: "Освободить имя"
|
||||
pick_username: "Выберите имя — необязательно"
|
||||
working: "Обработка…"
|
||||
@@ -601,6 +609,12 @@ goblin:
|
||||
repair_confirm: "Да, восстановить сейчас"
|
||||
repair_confirm_note: "Восстановление повторно сканирует цепочку и может занять несколько минут."
|
||||
restore_confirm_note: "Это стирает локальные данные и восстанавливает их из seed-фразы — может занять несколько минут."
|
||||
nostr_key: "Ключ Nostr"
|
||||
nostr_key_desc: "Ваш nsec, секретный ключ вашей личности Nostr. Скопируйте его или покажите QR-код, чтобы войти в приложения Nostr, такие как magick.market. Любой, у кого он есть, управляет вашей личностью, держите его в секрете."
|
||||
reveal_nsec: "Показать ключ"
|
||||
copy_nsec: "Копировать nsec"
|
||||
show_qr: "Показать QR"
|
||||
hide_qr: "Скрыть QR"
|
||||
privacy:
|
||||
title: "Сетевая приватность"
|
||||
intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами."
|
||||
|
||||
@@ -365,6 +365,8 @@ goblin:
|
||||
cant_reach_node: "Düğüme ulaşılamıyor"
|
||||
node_synced: "Düğüm eşitlendi"
|
||||
syncing: "Eşitleniyor…"
|
||||
balance_updating: "Bakiye güncelleniyor…"
|
||||
listening: "Ödemeler bekleniyor"
|
||||
block: "Blok %{height}"
|
||||
waiting_for_chain: "Zincir bekleniyor…"
|
||||
nav_wallet: "Cüzdan"
|
||||
@@ -434,12 +436,14 @@ goblin:
|
||||
requesting: "%{amt}%{tsu} isteniyor — ödeme almak için paylaş"
|
||||
clear_request: "İsteği temizle"
|
||||
share_handle: "Ödeme almak için kullanıcı adını paylaş"
|
||||
share_npub: "Ödeme almak için npub'ını paylaş"
|
||||
copied: "Kopyalandı"
|
||||
copy_nostr_id: "nostr kimliğini kopyala"
|
||||
copy_address: "Adresi kopyala"
|
||||
copy_npub: "npub kopyala"
|
||||
share_message: "Goblin'de bana öde (goblin.st) — %{npub}"
|
||||
privacy_note: "Kullanıcı adın herkese açıktır. Ödeme içeriği ağ üzerinde şifreli kalır."
|
||||
privacy_note_npub: "npub'ın herkese açıktır. Ödeme içeriği ağ üzerinde şifreli kalır."
|
||||
profile:
|
||||
title: "Profil"
|
||||
activity: "Etkinlik"
|
||||
@@ -460,7 +464,10 @@ goblin:
|
||||
wallet: "Cüzdan"
|
||||
display_unit: "Görüntüleme birimi"
|
||||
relays: "Relaylar"
|
||||
nostr_relays: "Nostr Relayları"
|
||||
node: "Düğüm"
|
||||
integrated_node: "Tümleşik düğüm ayarları"
|
||||
node_advanced: "Gelişmiş"
|
||||
slatepacks: "Slatepackler"
|
||||
slatepacks_value: "Manuel işlem"
|
||||
lock_wallet: "Cüzdanı kilitle"
|
||||
@@ -470,7 +477,7 @@ goblin:
|
||||
mixnet_routing: "Mixnet yönlendirme"
|
||||
messages_lookups: "Mesajlar ve aramalar"
|
||||
auto_accept: "Otomatik kabul"
|
||||
pairing: "Eşleştirme"
|
||||
pairing: "Fiyat para birimi"
|
||||
accept_anyone: "Herkes"
|
||||
accept_contacts: "Yalnızca kişiler"
|
||||
accept_ask: "Her zaman sor"
|
||||
@@ -485,6 +492,7 @@ goblin:
|
||||
archive: "Arşiv"
|
||||
export_archive: "Arşivi dışa aktar"
|
||||
wipe_history: "Ödeme geçmişini sil"
|
||||
wipe_history_confirm: "Silmek için tekrar dokun — geri alınamaz"
|
||||
about: "Hakkında"
|
||||
goblin: "Goblin"
|
||||
build: "Sürüm %{build}"
|
||||
@@ -561,7 +569,7 @@ goblin:
|
||||
keep_it: "Vazgeç"
|
||||
release_it: "Bırak"
|
||||
username: "Kullanıcı adı"
|
||||
username_note: "you olarak gösterilir. goblin.st'de herkese açık. Ödemeler şifreli kalır."
|
||||
username_note: "Adınız olarak gösterilir. goblin.st'de herkese açık. Ödemeler şifreli kalır."
|
||||
release_username: "Kullanıcı adını bırak"
|
||||
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
|
||||
working: "Çalışıyor…"
|
||||
@@ -601,6 +609,12 @@ goblin:
|
||||
repair_confirm: "Evet, şimdi onar"
|
||||
repair_confirm_note: "Onarım zinciri yeniden tarar ve birkaç dakika sürebilir."
|
||||
restore_confirm_note: "Bu, yerel verileri siler ve seed'inizden yeniden oluşturur — birkaç dakika sürebilir."
|
||||
nostr_key: "Nostr anahtarı"
|
||||
nostr_key_desc: "nsec'iniz, Nostr kimliğinizin gizli anahtarı. magick.market gibi Nostr uygulamalarında oturum açmak için kopyalayın veya QR kodunu gösterin. Ona sahip olan herkes kimliğinizi kontrol eder, gizli tutun."
|
||||
reveal_nsec: "Anahtarı göster"
|
||||
copy_nsec: "nsec'i kopyala"
|
||||
show_qr: "QR göster"
|
||||
hide_qr: "QR gizle"
|
||||
privacy:
|
||||
title: "Ağ gizliliği"
|
||||
intro: "Goblin özel trafiğini Nym mixnet üzerinden gönderir — kimin kiminle konuştuğunu gizleyen beş atlamalı bir ağ, böylece bir relay bir ödemeyi sana bağlayamaz."
|
||||
|
||||
@@ -365,6 +365,8 @@ goblin:
|
||||
cant_reach_node: "无法连接节点"
|
||||
node_synced: "节点已同步"
|
||||
syncing: "同步中…"
|
||||
balance_updating: "余额更新中…"
|
||||
listening: "正在监听付款"
|
||||
block: "区块 %{height}"
|
||||
waiting_for_chain: "等待链数据…"
|
||||
nav_wallet: "钱包"
|
||||
@@ -434,12 +436,14 @@ goblin:
|
||||
requesting: "正在请求 %{amt}%{tsu} — 分享以收款"
|
||||
clear_request: "清除请求"
|
||||
share_handle: "分享你的用户名以收款"
|
||||
share_npub: "分享你的 npub 以收款"
|
||||
copied: "已复制"
|
||||
copy_nostr_id: "复制 nostr ID"
|
||||
copy_address: "复制地址"
|
||||
copy_npub: "复制 npub"
|
||||
share_message: "在 Goblin 上向我付款 (goblin.st) — %{npub}"
|
||||
privacy_note: "你的用户名是公开的。付款内容在网络中保持加密。"
|
||||
privacy_note_npub: "你的 npub 是公开的。付款内容在网络中保持加密。"
|
||||
profile:
|
||||
title: "资料"
|
||||
activity: "动态"
|
||||
@@ -460,7 +464,10 @@ goblin:
|
||||
wallet: "钱包"
|
||||
display_unit: "显示单位"
|
||||
relays: "中继"
|
||||
nostr_relays: "Nostr 中继"
|
||||
node: "节点"
|
||||
integrated_node: "集成节点设置"
|
||||
node_advanced: "高级"
|
||||
slatepacks: "Slatepack"
|
||||
slatepacks_value: "手动交易"
|
||||
lock_wallet: "锁定钱包"
|
||||
@@ -470,7 +477,7 @@ goblin:
|
||||
mixnet_routing: "mixnet 路由"
|
||||
messages_lookups: "消息和查询"
|
||||
auto_accept: "自动接受"
|
||||
pairing: "配对"
|
||||
pairing: "价格货币"
|
||||
accept_anyone: "任何人"
|
||||
accept_contacts: "仅联系人"
|
||||
accept_ask: "每次询问"
|
||||
@@ -485,6 +492,7 @@ goblin:
|
||||
archive: "存档"
|
||||
export_archive: "导出存档"
|
||||
wipe_history: "清除付款记录"
|
||||
wipe_history_confirm: "再次点按以清除 — 无法撤销"
|
||||
about: "关于"
|
||||
goblin: "Goblin"
|
||||
build: "构建 %{build}"
|
||||
@@ -561,7 +569,7 @@ goblin:
|
||||
keep_it: "保留"
|
||||
release_it: "释放"
|
||||
username: "用户名"
|
||||
username_note: "显示为 you。在 goblin.st 上公开。付款保持加密。"
|
||||
username_note: "显示为你的名字。在 goblin.st 上公开。付款保持加密。"
|
||||
release_username: "释放用户名"
|
||||
pick_username: "选择用户名 — 可选"
|
||||
working: "处理中…"
|
||||
@@ -601,6 +609,12 @@ goblin:
|
||||
repair_confirm: "是的,立即修复"
|
||||
repair_confirm_note: "修复会重新扫描链,可能需要几分钟。"
|
||||
restore_confirm_note: "这会清除本地数据并从助记词重建——可能需要几分钟。"
|
||||
nostr_key: "Nostr 密钥"
|
||||
nostr_key_desc: "您的 nsec,即 Nostr 身份的私钥。复制它或显示二维码,即可登录 magick.market 等 Nostr 应用。持有它的人即可控制您的身份,请妥善保管。"
|
||||
reveal_nsec: "显示密钥"
|
||||
copy_nsec: "复制 nsec"
|
||||
show_qr: "显示二维码"
|
||||
hide_qr: "隐藏二维码"
|
||||
privacy:
|
||||
title: "网络隐私"
|
||||
intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。"
|
||||
|
||||
@@ -80,12 +80,15 @@ function build_apk() {
|
||||
fi
|
||||
|
||||
if [[ $1 == "" ]] && [ $success -eq 1 ]; then
|
||||
# Launch application at all connected devices.
|
||||
# Launch application at all connected devices. The installed application id
|
||||
# (st.goblin.wallet) differs from the Java namespace (mw.gri.android), so
|
||||
# derive it from build.gradle and launch the fully-qualified activity.
|
||||
app_id=$(grep -m 1 -Po 'applicationId "\K[^"]*' app/build.gradle)
|
||||
for SERIAL in $(adb devices | grep -v List | cut -f 1);
|
||||
do
|
||||
adb -s "$SERIAL" install ${apk_path}
|
||||
sleep 1s
|
||||
adb -s "$SERIAL" shell am start -n mw.gri.android/.MainActivity;
|
||||
adb -s "$SERIAL" shell am start -n "${app_id}/mw.gri.android.MainActivity";
|
||||
done
|
||||
elif [ $success -eq 1 ]; then
|
||||
# Get version
|
||||
|
||||
@@ -36,6 +36,18 @@ for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
|
||||
-gravity center -extent "${fg}x${fg}" PNG32:"$RES/mipmap-$d/ic_launcher_foreground.png"
|
||||
done
|
||||
|
||||
# --- Android notification (status-bar) icon: white-on-transparent mascot ---
|
||||
# Android renders ic_stat_name as an alpha-only silhouette, so the RGB channels
|
||||
# are forced to pure white; ~90% of the canvas matches the old asset's padding.
|
||||
declare -A STAT_SIZES=( [mdpi]=24 [hdpi]=36 [xhdpi]=48 [xxhdpi]=72 [xxxhdpi]=96 )
|
||||
for d in mdpi hdpi xhdpi xxhdpi xxxhdpi; do
|
||||
s=${STAT_SIZES[$d]}
|
||||
art=$(( s * 9 / 10 ))
|
||||
magick -background none img/goblin-logo2.svg -resize "${art}x${art}" \
|
||||
-gravity center -extent "${s}x${s}" \
|
||||
-channel RGB -evaluate set 100% +channel PNG32:"$RES/drawable-$d/ic_stat_name.png"
|
||||
done
|
||||
|
||||
# --- Windows installer + file-type icon (WiX wix/Product.ico) ---
|
||||
magick "$ICON" -define icon:auto-resize=256,128,64,48,32,24,16 wix/Product.ico
|
||||
|
||||
|
||||
@@ -49,11 +49,12 @@ fetch_zig() {
|
||||
}
|
||||
|
||||
fetch_appimage() {
|
||||
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
|
||||
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtime…"
|
||||
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && [ -e "${TC}/runtime-aarch64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
|
||||
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtimes (x86_64 + aarch64)…"
|
||||
dl "${DEV}/appimagetool/releases/download/${AT_VER}/appimagetool-x86_64.AppImage" "${TC}/appimagetool"
|
||||
dl "${DEV}/appimage-type2-runtime/releases/download/${RT_TAG}/runtime-x86_64" "${TC}/runtime-x86_64"
|
||||
chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64"
|
||||
dl "${DEV}/appimage-type2-runtime/releases/download/${RT_TAG}/runtime-aarch64" "${TC}/runtime-aarch64"
|
||||
chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64" "${TC}/runtime-aarch64"
|
||||
}
|
||||
|
||||
# Assemble a minimal Android SDK (build-tools + platform + platform-tools) from
|
||||
|
||||
@@ -330,14 +330,38 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
|
||||
// Paint the title.
|
||||
// Paint the title. Centering on the full rect runs the tail of the
|
||||
// string under the right-side window buttons at narrow widths (the
|
||||
// "Build NNN" digits collide with the minimize caret at 390px), so
|
||||
// when the centered galley would reach the button cluster, center it
|
||||
// in the free span between the theme toggle and the buttons instead,
|
||||
// eliding if even that span is too tight.
|
||||
let title_text = format!("Goblin ツ · Build {}", crate::BUILD);
|
||||
painter.text(
|
||||
title_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
title_text,
|
||||
egui::FontId::proportional(15.0),
|
||||
Colors::title(true),
|
||||
let title_font = egui::FontId::proportional(15.0);
|
||||
let title_ink = Colors::title(true);
|
||||
const BUTTONS_LEFT_INSET: f32 = 60.0; // theme toggle
|
||||
const BUTTONS_RIGHT_INSET: f32 = 168.0; // minimize + fullscreen + close
|
||||
let free_left = title_rect.min.x + BUTTONS_LEFT_INSET;
|
||||
let free_right = title_rect.max.x - BUTTONS_RIGHT_INSET;
|
||||
let mut galley = painter.layout_no_wrap(title_text.clone(), title_font.clone(), title_ink);
|
||||
let mut center_x = title_rect.center().x;
|
||||
if center_x + galley.size().x / 2.0 > free_right {
|
||||
center_x = (free_left + free_right) / 2.0;
|
||||
if galley.size().x > free_right - free_left {
|
||||
let mut job =
|
||||
egui::text::LayoutJob::simple_singleline(title_text, title_font, title_ink);
|
||||
job.wrap =
|
||||
egui::text::TextWrapping::truncate_at_width((free_right - free_left).max(0.0));
|
||||
galley = painter.layout_job(job);
|
||||
}
|
||||
}
|
||||
painter.galley(
|
||||
egui::pos2(
|
||||
center_x - galley.size().x / 2.0,
|
||||
title_rect.center().y - galley.size().y / 2.0,
|
||||
),
|
||||
galley,
|
||||
title_ink,
|
||||
);
|
||||
|
||||
ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| {
|
||||
|
||||
@@ -40,6 +40,12 @@ pub struct Android {
|
||||
impl Android {
|
||||
/// Create new Android platform instance from provided [`AndroidApp`].
|
||||
pub fn new(app: AndroidApp) -> Self {
|
||||
// Keep a process-wide handle so non-GUI threads (the nostr service)
|
||||
// can reach Java too (see `notify_payment_received`).
|
||||
{
|
||||
let mut w_app = ANDROID_APP.write();
|
||||
*w_app = Some(app.clone());
|
||||
}
|
||||
Self {
|
||||
android_app: app,
|
||||
ctx: Arc::new(RwLock::new(None)),
|
||||
@@ -267,6 +273,89 @@ lazy_static! {
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
/// Picked file path.
|
||||
static ref PICKED_FILE_PATH: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
/// App handle for JNI calls from threads without a platform reference.
|
||||
static ref ANDROID_APP: Arc<RwLock<Option<AndroidApp>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Show the one-shot "payment received" system notification (Java side
|
||||
/// `BackgroundService.notifyPaymentReceived`, id=2, separate from the
|
||||
/// persistent sync notification id=1). Called by the nostr service on
|
||||
/// slatepack receipt from a non-GUI thread, hence the stored [`AndroidApp`]
|
||||
/// handle instead of a platform reference. Fail-open: a missing handle or
|
||||
/// JNI error just skips the notification, never the payment.
|
||||
pub fn notify_payment_received(name: &str, amount: &str) {
|
||||
let app = {
|
||||
let r_app = ANDROID_APP.read();
|
||||
r_app.clone()
|
||||
};
|
||||
let Some(app) = app else {
|
||||
return;
|
||||
};
|
||||
let platform = Android {
|
||||
android_app: app,
|
||||
ctx: Arc::new(RwLock::new(None)),
|
||||
};
|
||||
let Ok(vm) = (unsafe { jni::JavaVM::from_raw(platform.android_app.vm_as_ptr() as _) }) else {
|
||||
return;
|
||||
};
|
||||
let Ok(env) = vm.attach_current_thread() else {
|
||||
return;
|
||||
};
|
||||
let Ok(j_name) = env.new_string(name) else {
|
||||
return;
|
||||
};
|
||||
let Ok(j_amount) = env.new_string(amount) else {
|
||||
return;
|
||||
};
|
||||
let _ = platform.call_java_method(
|
||||
"notifyPaymentReceived",
|
||||
"(Ljava/lang/String;Ljava/lang/String;)V",
|
||||
&[
|
||||
JValue::Object(&JObject::from(j_name)),
|
||||
JValue::Object(&JObject::from(j_amount)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Show the one-shot "payment requested" system notification (Java side
|
||||
/// `BackgroundService.notifyPaymentRequested`, id=3, separate from both the
|
||||
/// persistent sync notification id=1 and the received-payment one id=2). Called
|
||||
/// by the nostr service when a payment request (Invoice1) is ingested 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 request. Mirrors [`notify_payment_received`].
|
||||
pub fn notify_payment_requested(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(
|
||||
"notifyPaymentRequested",
|
||||
"(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.
|
||||
|
||||
@@ -351,12 +351,6 @@ pub fn ink_for(bg: Color32) -> Color32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar (background, ink) pair for a hue index.
|
||||
pub fn avatar_pair(hue: usize) -> (Color32, Color32) {
|
||||
let pairs = &tokens().avatar_pairs;
|
||||
pairs[hue % pairs.len()]
|
||||
}
|
||||
|
||||
/// Number of avatar color pairs (hue derivation modulus).
|
||||
pub fn avatar_pairs_len() -> usize {
|
||||
tokens().avatar_pairs.len()
|
||||
|
||||
@@ -144,6 +144,9 @@ impl ContentContainer for Content {
|
||||
.show();
|
||||
} else if OperatingSystem::from_target_os() == OperatingSystem::Android
|
||||
&& AppConfig::android_integrated_node_warning_needed()
|
||||
// The warning is about INTEGRATED-node background sync; on the
|
||||
// external-node default it nags about a node we do not run.
|
||||
&& AppConfig::autostart_node()
|
||||
{
|
||||
Modal::new(ANDROID_INTEGRATED_NODE_WARNING_MODAL)
|
||||
.title(t!("network.node"))
|
||||
|
||||
@@ -115,15 +115,6 @@ impl AvatarTextures {
|
||||
None
|
||||
}
|
||||
|
||||
/// Install the just-uploaded avatar without waiting for a round-trip.
|
||||
pub fn set_own(&mut self, ctx: &egui::Context, name: &str, hash: &str, png: &[u8]) {
|
||||
let name = name.trim_start_matches('@').to_lowercase();
|
||||
self.cache.store(&name, hash, png);
|
||||
let tex = decode(png)
|
||||
.map(|img| ctx.load_texture(format!("avatar_{name}"), img, Default::default()));
|
||||
self.textures.insert(name, tex);
|
||||
}
|
||||
|
||||
/// Forget a name (released or rotated away).
|
||||
pub fn invalidate(&mut self, name: &str) {
|
||||
let name = name.trim_start_matches('@').to_lowercase();
|
||||
|
||||
@@ -31,7 +31,6 @@ pub struct ActivityItem {
|
||||
/// Canceled/expired before completing (wallet-cancelled tx or expired meta).
|
||||
pub canceled: bool,
|
||||
pub system: bool,
|
||||
pub hue: usize,
|
||||
pub time: i64,
|
||||
/// Counterparty npub hex, when known.
|
||||
pub npub: Option<String>,
|
||||
@@ -44,7 +43,6 @@ pub struct ActivityItem {
|
||||
pub struct ReceiptDetail {
|
||||
pub tx_id: u32,
|
||||
pub title: String,
|
||||
pub hue: usize,
|
||||
pub npub: Option<String>,
|
||||
pub amount: u64,
|
||||
pub incoming: bool,
|
||||
@@ -79,18 +77,16 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
||||
let meta: Option<TxNostrMeta> = slate_id
|
||||
.as_ref()
|
||||
.and_then(|sid| store_ref.and_then(|s| s.tx_meta(sid)));
|
||||
let (title, hue) = if system {
|
||||
("Mining reward".to_string(), 5)
|
||||
let title = if system {
|
||||
"Mining reward".to_string()
|
||||
} else if let Some(m) = &meta {
|
||||
store_ref
|
||||
.map(|s| contact_title(s, &m.npub))
|
||||
.unwrap_or_else(|| (short_npub(&m.npub), 0))
|
||||
.unwrap_or_else(|| short_npub(&m.npub))
|
||||
} else if incoming {
|
||||
"Received".to_string()
|
||||
} else {
|
||||
let label = if incoming { "Received" } else { "Sent" };
|
||||
(
|
||||
label.to_string(),
|
||||
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
|
||||
)
|
||||
"Sent".to_string()
|
||||
};
|
||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||
let time = tx
|
||||
@@ -133,7 +129,6 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option<ReceiptDetail> {
|
||||
Some(ReceiptDetail {
|
||||
tx_id,
|
||||
title,
|
||||
hue,
|
||||
npub: meta.map(|m| m.npub),
|
||||
amount: tx.amount,
|
||||
incoming,
|
||||
@@ -184,12 +179,11 @@ fn is_canceled(tx: &WalletTx, meta: Option<&TxNostrMeta>) -> bool {
|
||||
}
|
||||
|
||||
/// Resolve the display title for a contact npub.
|
||||
pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) {
|
||||
pub fn contact_title(store: &NostrStore, npub: &str) -> String {
|
||||
if let Some(contact) = store.contact(npub) {
|
||||
(display_name(&contact), contact.hue as usize)
|
||||
display_name(&contact)
|
||||
} else {
|
||||
let hue = hue_of(&npub);
|
||||
(short_npub(npub), hue)
|
||||
short_npub(npub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,9 +223,9 @@ pub fn name_verification(contact: &Contact) -> Option<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Short npub display (npub1abcd…wxyz) from a hex pubkey.
|
||||
/// Avatar hue index derived from a hex pubkey (stable per identity, spread
|
||||
/// across the full color-pair palette).
|
||||
/// across the full color-pair palette). Only fills the persisted
|
||||
/// `Contact.hue` field these days — nothing reads it for rendering anymore.
|
||||
pub fn hue_of(hex: &str) -> usize {
|
||||
usize::from_str_radix(&hex[..2.min(hex.len())], 16).unwrap_or(0)
|
||||
% crate::gui::theme::avatar_pairs_len()
|
||||
@@ -249,16 +243,17 @@ pub fn short_handle(handle: &str) -> String {
|
||||
format!("{head}…{tail}")
|
||||
}
|
||||
|
||||
/// Short npub display (npub1abcd…wxyz) from a hex pubkey.
|
||||
pub fn short_npub(hex: &str) -> String {
|
||||
use nostr_sdk::{PublicKey, ToBech32};
|
||||
if let Ok(pk) = PublicKey::from_hex(hex) {
|
||||
if let Ok(npub) = pk.to_bech32() {
|
||||
// Standard truncation: "npub1" + 7 head chars … 6 tail chars.
|
||||
if npub.len() > 18 {
|
||||
return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]);
|
||||
}
|
||||
return npub;
|
||||
// `to_bech32` for a valid key is infallible.
|
||||
let Ok(npub) = pk.to_bech32();
|
||||
// Standard truncation: "npub1" + 7 head chars … 6 tail chars.
|
||||
if npub.len() > 18 {
|
||||
return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]);
|
||||
}
|
||||
return npub;
|
||||
}
|
||||
format!("{}…", &hex[..8.min(hex.len())])
|
||||
}
|
||||
@@ -300,23 +295,17 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
.as_ref()
|
||||
.and_then(|sid| store.and_then(|s| s.tx_meta(sid)));
|
||||
|
||||
let (title, hue) = if system {
|
||||
("Mining reward".to_string(), 5)
|
||||
let title = if system {
|
||||
"Mining reward".to_string()
|
||||
} else if let Some(meta) = &meta {
|
||||
store
|
||||
.map(|s| contact_title(s, &meta.npub))
|
||||
.unwrap_or_else(|| (short_npub(&meta.npub), 0))
|
||||
.unwrap_or_else(|| short_npub(&meta.npub))
|
||||
} else if incoming {
|
||||
// Fall back to a generic label when there's no nostr counterparty.
|
||||
"Received".to_string()
|
||||
} else {
|
||||
// Fall back to slatepack address counterparty or generic label.
|
||||
let label = if incoming {
|
||||
"Received".to_string()
|
||||
} else {
|
||||
"Sent".to_string()
|
||||
};
|
||||
(
|
||||
label,
|
||||
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
|
||||
)
|
||||
"Sent".to_string()
|
||||
};
|
||||
|
||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||
@@ -337,14 +326,14 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
confirmed: tx.data.confirmed,
|
||||
canceled,
|
||||
system,
|
||||
hue,
|
||||
time,
|
||||
npub: meta.map(|m| m.npub),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent unique peers for the home strip (most recent first).
|
||||
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> {
|
||||
/// Recent unique peers for the home strip (most recent first), as
|
||||
/// `(display name, npub hex)`.
|
||||
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, String)> {
|
||||
let store = match wallet.nostr_service() {
|
||||
Some(s) => s.store.clone(),
|
||||
None => return vec![],
|
||||
@@ -354,13 +343,14 @@ pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String
|
||||
contacts
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|c| (display_name(&c), c.hue as usize, c.npub))
|
||||
.map(|c| (display_name(&c), c.npub))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Local contacts whose petname / nip05 / npub contains `query` (case-
|
||||
/// insensitive) — the instant, no-network half of the recipient search.
|
||||
pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, usize, String)> {
|
||||
/// Returns `(display name, npub hex)` pairs.
|
||||
pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, String)> {
|
||||
let store = match wallet.nostr_service() {
|
||||
Some(s) => s.store.clone(),
|
||||
None => return vec![],
|
||||
@@ -369,7 +359,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
||||
if q.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut hits: Vec<(String, usize, String)> = store
|
||||
let mut hits: Vec<(String, String)> = store
|
||||
.all_contacts()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
@@ -383,7 +373,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
||||
.unwrap_or(false)
|
||||
|| c.npub.to_lowercase().contains(&q)
|
||||
})
|
||||
.map(|c| (display_name(&c), c.hue as usize, c.npub))
|
||||
.map(|c| (display_name(&c), c.npub))
|
||||
.collect();
|
||||
hits.truncate(limit);
|
||||
hits
|
||||
|
||||
@@ -36,8 +36,8 @@ const LOGO_FRAC: f64 = 0.90;
|
||||
const LOGO_OPACITY: f64 = 0.67;
|
||||
const GRIN_NATIVE: f64 = 61.0;
|
||||
|
||||
/// Standard HSL → RGB → `#rrggbb`. f64 throughout for cross-port byte-identity.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
/// Standard HSL → RGB bytes. f64 throughout for cross-port byte-identity.
|
||||
pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = h / 60.0;
|
||||
let x = c * (1.0 - ((hp % 2.0) - 1.0).abs());
|
||||
@@ -51,7 +51,13 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
let to = |v: f64| ((v + m) * 255.0).round() as u8;
|
||||
format!("#{:02x}{:02x}{:02x}", to(r), to(g), to(b))
|
||||
(to(r), to(g), to(b))
|
||||
}
|
||||
|
||||
/// Standard HSL → RGB → `#rrggbb`.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
let (r, g, b) = hsl_rgb8(h, s, l);
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical
|
||||
@@ -79,17 +85,6 @@ fn gradient_params(hex: &str) -> (String, String, f64) {
|
||||
(c1, c2, angle)
|
||||
}
|
||||
|
||||
/// The seeded two-tone gradient WITHOUT the Grin mark — a bare background tile.
|
||||
/// Used for **named** users, where the app paints the person's initial on top
|
||||
/// (see `widgets::gradient_letter_avatar`) instead of the Grin mark. Same seed →
|
||||
/// same background as the anonymous gradient avatar, so one key reads consistently.
|
||||
pub fn gradient_bg_svg(hex: &str, size: u32) -> String {
|
||||
let (c1, c2, angle) = gradient_params(hex);
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}" role="img"><defs><linearGradient id="g" gradientUnits="objectBoundingBox" gradientTransform="rotate({angle:.1},0.5,0.5)"><stop offset="0" stop-color="{c1}"/><stop offset="1" stop-color="{c2}"/></linearGradient></defs><rect width="{size}" height="{size}" fill="url(#g)"/></svg>"##
|
||||
)
|
||||
}
|
||||
|
||||
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
|
||||
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
|
||||
/// are inlined into ONE html document; for a standalone document (how egui
|
||||
|
||||
@@ -91,8 +91,12 @@ pub struct GoblinWalletView {
|
||||
request_amount: Option<String>,
|
||||
/// Sub-page open inside the Settings tab.
|
||||
settings_page: SettingsPage,
|
||||
/// GRIM's native node-connections screen (embedded under Advanced).
|
||||
grim_connections: crate::gui::views::network::ConnectionsContent,
|
||||
/// Active GRIM integrated-node tab (Info/Metrics/Mining/Settings), hosted
|
||||
/// inside Goblin chrome — GRIM's dual-panel shell is never rendered.
|
||||
node_tab: Box<dyn crate::gui::views::network::types::NodeTab>,
|
||||
/// Where the integrated-node page returns to (it has two entry points:
|
||||
/// the Settings screen and the Node screen).
|
||||
node_tab_back: SettingsPage,
|
||||
/// Inline state for the Advanced settings page (recovery/repair/delete).
|
||||
advanced: AdvancedState,
|
||||
/// One-shot signal to the wallet host: deselect this wallet (return to the
|
||||
@@ -119,6 +123,9 @@ pub struct GoblinWalletView {
|
||||
cancel_msg: Option<(crate::nostr::CancelOutcome, std::time::Instant)>,
|
||||
/// Transient "Copied" flash for the settings backup card (npub/keys).
|
||||
copy_flash: Option<std::time::Instant>,
|
||||
/// "Wipe payment history" tap-twice confirm: armed after the first tap,
|
||||
/// wipes on the second (cleared once fired).
|
||||
wipe_confirm: bool,
|
||||
}
|
||||
|
||||
/// Sub-pages of the Settings tab.
|
||||
@@ -126,8 +133,8 @@ pub struct GoblinWalletView {
|
||||
enum SettingsPage {
|
||||
Main,
|
||||
Node,
|
||||
/// GRIM's native node-connections screen, embedded.
|
||||
Connections,
|
||||
/// GRIM's integrated-node tabs, embedded in Goblin chrome.
|
||||
IntegratedNode,
|
||||
Relays,
|
||||
Nips,
|
||||
Pairing,
|
||||
@@ -146,6 +153,15 @@ struct AdvancedState {
|
||||
revealed: Option<String>,
|
||||
/// Set when the entered password didn't decrypt the seed.
|
||||
wrong_pass: bool,
|
||||
/// Password typed to reveal the nostr secret key (nsec).
|
||||
nsec_pass: String,
|
||||
/// The revealed nsec, held only while shown (cleared on hide/back).
|
||||
nsec_revealed: Option<String>,
|
||||
/// Set when the entered password didn't unlock the nostr identity.
|
||||
nsec_wrong: bool,
|
||||
/// Whether the nsec QR is expanded (so it can be scanned to log in
|
||||
/// elsewhere, e.g. magick.market's private-key login).
|
||||
nsec_qr: bool,
|
||||
/// Armed "really restore?" confirm.
|
||||
confirm_restore: bool,
|
||||
/// Armed "really repair?" confirm (repair takes a few minutes).
|
||||
@@ -194,7 +210,8 @@ impl Default for GoblinWalletView {
|
||||
pay_shake: None,
|
||||
request_amount: None,
|
||||
settings_page: SettingsPage::Main,
|
||||
grim_connections: Default::default(),
|
||||
node_tab: Box::new(crate::gui::views::network::NetworkNode),
|
||||
node_tab_back: SettingsPage::Main,
|
||||
advanced: AdvancedState::default(),
|
||||
switch_requested: false,
|
||||
node_url_input: String::new(),
|
||||
@@ -207,6 +224,7 @@ impl Default for GoblinWalletView {
|
||||
cancel_confirm: None,
|
||||
cancel_msg: None,
|
||||
copy_flash: None,
|
||||
wipe_confirm: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,6 +369,17 @@ impl GoblinWalletView {
|
||||
std::mem::take(&mut self.switch_requested)
|
||||
}
|
||||
|
||||
/// Whether back navigation has anything left to consume: an overlay, a
|
||||
/// settings sub-page, or a non-Home tab (back routes to Home). Mirrors
|
||||
/// [`Self::on_back`], so the host never falls back to the wallet chooser.
|
||||
pub fn can_back(&self) -> bool {
|
||||
self.receipt.is_some()
|
||||
|| self.profile.is_some()
|
||||
|| self.send.is_some()
|
||||
|| (self.tab == Tab::Me && self.settings_page != SettingsPage::Main)
|
||||
|| self.tab != Tab::Home
|
||||
}
|
||||
|
||||
/// Handle a back navigation; returns true if not consumed.
|
||||
pub fn on_back(&mut self) -> bool {
|
||||
if self.receipt.is_some() {
|
||||
@@ -728,7 +757,7 @@ impl GoblinWalletView {
|
||||
self.settings_page = SettingsPage::Node;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let (handle, connected, npub_hex) = wallet
|
||||
let (handle, npub_hex) = wallet
|
||||
.nostr_service()
|
||||
.map(|s| {
|
||||
let id = s.identity.read();
|
||||
@@ -737,16 +766,11 @@ impl GoblinWalletView {
|
||||
.clone()
|
||||
.map(|n| n.split('@').next().unwrap_or("").to_string())
|
||||
.unwrap_or_else(|| data::short_npub(&hex_of(&id.npub)));
|
||||
(h, s.is_connected(), hex_of(&id.npub))
|
||||
(h, hex_of(&id.npub))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
t!("goblin.home.anonymous").to_string(),
|
||||
false,
|
||||
String::new(),
|
||||
)
|
||||
(t!("goblin.home.anonymous").to_string(), String::new())
|
||||
});
|
||||
let hue = data::hue_of(&npub_hex);
|
||||
let tex = self.handle_tex(ui.ctx(), wallet, &handle);
|
||||
// Identity chip → identity settings.
|
||||
let id_resp = ui
|
||||
@@ -754,7 +778,7 @@ impl GoblinWalletView {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
w::avatar_any(ui, &handle, &npub_hex, 28.0, hue, tex.as_ref());
|
||||
w::avatar_any(ui, &handle, &npub_hex, 28.0, tex.as_ref());
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
// Scale the handle to its length: short @names get a
|
||||
@@ -769,7 +793,9 @@ impl GoblinWalletView {
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(if connected {
|
||||
// Relay-gated: "Connected over Nym" only once a
|
||||
// relay is live on the current tunnel generation.
|
||||
RichText::new(if crate::nym::transport_ready() {
|
||||
t!("goblin.home.connected_nym")
|
||||
} else if crate::nym::is_ready() {
|
||||
t!("goblin.home.nym_ready")
|
||||
@@ -877,6 +903,15 @@ impl GoblinWalletView {
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
// Low-opacity gear so the card reads as a tappable settings
|
||||
// shortcut; on-surface ink keeps it theme-aware.
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(crate::gui::icons::GEAR)
|
||||
.font(FontId::new(16.0, fonts::regular()))
|
||||
.color(t.surface_text.gamma_multiply(0.35)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -902,9 +937,8 @@ impl GoblinWalletView {
|
||||
let id = s.identity.read();
|
||||
let hex = hex_of(&id.npub);
|
||||
// With a verified handle show "@name"; otherwise fall back to
|
||||
// the short npub so avatar_any draws the deterministic gradient
|
||||
// (it keys the gradient branch off a leading "npub"), not a
|
||||
// meaningless lettered tile.
|
||||
// the short npub (avatar_any then draws the deterministic
|
||||
// pubkey-seeded gradient).
|
||||
let h = id
|
||||
.nip05
|
||||
.clone()
|
||||
@@ -913,7 +947,6 @@ impl GoblinWalletView {
|
||||
(h, hex)
|
||||
})
|
||||
.unwrap_or_else(|| ("N".to_string(), String::new()));
|
||||
let header_hue = data::hue_of(&header_hex);
|
||||
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
|
||||
ui.horizontal(|ui| {
|
||||
widgets_logo(ui);
|
||||
@@ -929,7 +962,6 @@ impl GoblinWalletView {
|
||||
&header_handle,
|
||||
&header_hex,
|
||||
36.0,
|
||||
header_hue,
|
||||
header_tex.as_ref(),
|
||||
)
|
||||
.clicked()
|
||||
@@ -968,7 +1000,22 @@ impl GoblinWalletView {
|
||||
.as_ref()
|
||||
.map(|d| (d.info.total, d.info.amount_currently_spendable))
|
||||
.unwrap_or((0, 0));
|
||||
w::balance_hero(ui, total, spendable, fiat_line(&data).as_deref(), 56.0);
|
||||
// Zero can just mean "in transit" (locked change / awaiting
|
||||
// finalization) or a first sync still running.
|
||||
let in_flight = data
|
||||
.as_ref()
|
||||
.map(|d| d.info.amount_locked + d.info.amount_awaiting_finalization)
|
||||
.unwrap_or(0);
|
||||
let updating = total == 0 && (in_flight > 0 || wallet.syncing());
|
||||
w::balance_hero(
|
||||
ui,
|
||||
total,
|
||||
spendable,
|
||||
updating,
|
||||
wallet.info_sync_progress(),
|
||||
fiat_line(&data).as_deref(),
|
||||
56.0,
|
||||
);
|
||||
ui.add_space(20.0);
|
||||
let (send, receive) = w::send_receive(ui);
|
||||
if send {
|
||||
@@ -1009,7 +1056,7 @@ impl GoblinWalletView {
|
||||
}
|
||||
let texs: Vec<Option<egui::TextureHandle>> = peers
|
||||
.iter()
|
||||
.map(|(name, _, _)| self.handle_tex(ui.ctx(), wallet, name))
|
||||
.map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name))
|
||||
.collect();
|
||||
w::kicker(ui, &t!("goblin.home.recent"));
|
||||
ui.add_space(12.0);
|
||||
@@ -1018,14 +1065,14 @@ impl GoblinWalletView {
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for ((name, hue, npub), tex) in peers.iter().zip(texs.iter()) {
|
||||
for ((name, npub), tex) in peers.iter().zip(texs.iter()) {
|
||||
// Fixed-width centered cell so the name sits centered under the
|
||||
// avatar (not left-aligned to a wider label).
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(72.0, 78.0),
|
||||
Layout::top_down(Align::Center),
|
||||
|ui| {
|
||||
let resp = w::avatar_any(ui, name, npub, 48.0, *hue, tex.as_ref());
|
||||
let resp = w::avatar_any(ui, name, npub, 48.0, tex.as_ref());
|
||||
ui.add_space(6.0);
|
||||
let chars: Vec<char> = name.chars().collect();
|
||||
let short: String = if chars.len() > 8 {
|
||||
@@ -1066,7 +1113,6 @@ impl GoblinWalletView {
|
||||
(h, hex)
|
||||
})
|
||||
.unwrap_or_else(|| ("N".to_string(), String::new()));
|
||||
let header_hue = data::hue_of(&header_hex);
|
||||
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
|
||||
ui.horizontal(|ui| {
|
||||
// Goblin mark (left), sized to match the right-side controls.
|
||||
@@ -1078,16 +1124,9 @@ impl GoblinWalletView {
|
||||
// Right cluster: scan QR (black, no background) then the profile
|
||||
// picture at the far right; all three controls about the same size.
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if w::avatar_any(
|
||||
ui,
|
||||
&header_handle,
|
||||
&header_hex,
|
||||
40.0,
|
||||
header_hue,
|
||||
header_tex.as_ref(),
|
||||
)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
if w::avatar_any(ui, &header_handle, &header_hex, 40.0, header_tex.as_ref())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
self.tab = Tab::Me;
|
||||
}
|
||||
@@ -1170,24 +1209,12 @@ impl GoblinWalletView {
|
||||
};
|
||||
ui.add_space(if tall { 32.0 } else { 16.0 } + drop);
|
||||
|
||||
// Numpad at narrow (mobile-shell) widths, typed input on the wide
|
||||
// desktop layout — gate by width like the shell itself, or narrow
|
||||
// desktop windows get neither input.
|
||||
let typed_hint = !narrow && self.pay_amount.is_empty();
|
||||
if narrow {
|
||||
w::numpad(ui, &mut self.pay_amount, cb);
|
||||
} else {
|
||||
w::amount_typed_input(ui, &mut self.pay_amount);
|
||||
if typed_hint {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.home.type_amount"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
// The pay column is capped at 480 by `centered_column`, so the old
|
||||
// `< 700` width gate was always narrow: the numpad always showed and
|
||||
// the typed-input branch was dead — a physical keyboard did nothing.
|
||||
// Show the pad and accept typed digits alongside it.
|
||||
w::numpad(ui, &mut self.pay_amount, cb);
|
||||
w::amount_typed_input(ui, &mut self.pay_amount);
|
||||
ui.add_space(20.0);
|
||||
|
||||
// Request | Pay actions, half width each.
|
||||
@@ -1237,8 +1264,7 @@ impl GoblinWalletView {
|
||||
},
|
||||
);
|
||||
});
|
||||
// Skip when the "Type an amount" hint is already showing above.
|
||||
if !valid && !typed_hint {
|
||||
if !valid {
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
@@ -1325,7 +1351,6 @@ impl GoblinWalletView {
|
||||
&d.title,
|
||||
d.npub.as_deref().unwrap_or(""),
|
||||
64.0,
|
||||
d.hue,
|
||||
tex.as_ref(),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
@@ -1582,10 +1607,10 @@ impl GoblinWalletView {
|
||||
npub: &str,
|
||||
) -> bool {
|
||||
let t = theme::tokens();
|
||||
let (name, hue) = wallet
|
||||
let name = wallet
|
||||
.nostr_service()
|
||||
.map(|s| data::contact_title(&s.store, npub))
|
||||
.unwrap_or_else(|| (data::short_npub(npub), 0));
|
||||
.unwrap_or_else(|| data::short_npub(npub));
|
||||
let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub));
|
||||
let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false);
|
||||
let nip05 = contact.as_ref().and_then(|c| c.nip05.clone());
|
||||
@@ -1620,7 +1645,7 @@ impl GoblinWalletView {
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
w::avatar_any(ui, &name, npub, 72.0, hue, tex.as_ref());
|
||||
w::avatar_any(ui, &name, npub, 72.0, tex.as_ref());
|
||||
ui.add_space(12.0);
|
||||
ui.label(
|
||||
RichText::new(&name)
|
||||
@@ -1655,7 +1680,14 @@ impl GoblinWalletView {
|
||||
);
|
||||
} else {
|
||||
for (item, htex) in history.iter().zip(htexs.iter()) {
|
||||
let sign = if item.incoming { "+ " } else { "− " };
|
||||
// No +/- for canceled: nothing moved.
|
||||
let sign = if item.canceled {
|
||||
""
|
||||
} else if item.incoming {
|
||||
"+ "
|
||||
} else {
|
||||
"− "
|
||||
};
|
||||
let amount =
|
||||
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
|
||||
let status_word = if item.canceled {
|
||||
@@ -1675,10 +1707,10 @@ impl GoblinWalletView {
|
||||
ui,
|
||||
&item.title,
|
||||
&subtitle,
|
||||
item.hue,
|
||||
item.npub.as_deref().unwrap_or(""),
|
||||
&amount,
|
||||
item.incoming,
|
||||
item.canceled,
|
||||
item.system,
|
||||
htex.as_ref(),
|
||||
)
|
||||
@@ -1732,7 +1764,8 @@ impl GoblinWalletView {
|
||||
nip05: nip05.clone(),
|
||||
nip05_verified_at: None,
|
||||
relays: vec![],
|
||||
hue: hue as u8,
|
||||
nip44_v3: false,
|
||||
hue: data::hue_of(npub) as u8,
|
||||
unknown: true,
|
||||
added_at: crate::nostr::unix_time(),
|
||||
last_paid_at: None,
|
||||
@@ -1817,8 +1850,11 @@ impl GoblinWalletView {
|
||||
);
|
||||
} else {
|
||||
// Unconfirmed (< min confirmations) pinned on top as Pending.
|
||||
let pending: Vec<&_> =
|
||||
items.iter().filter(|i| !i.confirmed && !i.system).collect();
|
||||
// Canceled txs are not pending — they group with history below.
|
||||
let pending: Vec<&_> = items
|
||||
.iter()
|
||||
.filter(|i| !i.confirmed && !i.system && !i.canceled)
|
||||
.collect();
|
||||
if !pending.is_empty() {
|
||||
w::section_header(ui, &t!("goblin.activity.pending_header"));
|
||||
for item in pending {
|
||||
@@ -1826,9 +1862,12 @@ impl GoblinWalletView {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
// Confirmed, grouped by day (newest first).
|
||||
// Confirmed (and canceled), grouped by day (newest first).
|
||||
let mut last: Option<String> = None;
|
||||
for item in items.iter().filter(|i| i.confirmed || i.system) {
|
||||
for item in items
|
||||
.iter()
|
||||
.filter(|i| i.confirmed || i.system || i.canceled)
|
||||
{
|
||||
let label = Self::day_label(item.time);
|
||||
if last.as_deref() != Some(label.as_str()) {
|
||||
w::section_header(ui, &label);
|
||||
@@ -1848,7 +1887,14 @@ impl GoblinWalletView {
|
||||
wallet: &Wallet,
|
||||
_cb: &dyn PlatformCallbacks,
|
||||
) {
|
||||
let sign = if item.incoming { "+ " } else { "− " };
|
||||
// No +/- for canceled: nothing moved.
|
||||
let sign = if item.canceled {
|
||||
""
|
||||
} else if item.incoming {
|
||||
"+ "
|
||||
} else {
|
||||
"− "
|
||||
};
|
||||
let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
|
||||
let status_word = if item.canceled {
|
||||
t!("goblin.activity.canceled").to_string()
|
||||
@@ -1866,10 +1912,10 @@ impl GoblinWalletView {
|
||||
ui,
|
||||
&item.title,
|
||||
&subtitle,
|
||||
item.hue,
|
||||
item.npub.as_deref().unwrap_or(""),
|
||||
&amount,
|
||||
item.incoming,
|
||||
item.canceled,
|
||||
item.system,
|
||||
tex.as_ref(),
|
||||
)
|
||||
@@ -1886,14 +1932,14 @@ impl GoblinWalletView {
|
||||
wallet: &Wallet,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
let (name, hue) = wallet
|
||||
let name = wallet
|
||||
.nostr_service()
|
||||
.map(|s| data::contact_title(&s.store, &req.npub))
|
||||
.unwrap_or_else(|| (data::short_npub(&req.npub), 0));
|
||||
.unwrap_or_else(|| data::short_npub(&req.npub));
|
||||
let tex = self.handle_tex(ui.ctx(), wallet, &name);
|
||||
w::card(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
w::avatar_any(ui, &name, &req.npub, 40.0, hue, tex.as_ref());
|
||||
w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref());
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
@@ -2003,10 +2049,10 @@ impl GoblinWalletView {
|
||||
let Some(req) = self.approve_review.clone() else {
|
||||
return true;
|
||||
};
|
||||
let (name, hue) = wallet
|
||||
let name = wallet
|
||||
.nostr_service()
|
||||
.map(|s| data::contact_title(&s.store, &req.npub))
|
||||
.unwrap_or_else(|| (data::short_npub(&req.npub), 0));
|
||||
.unwrap_or_else(|| data::short_npub(&req.npub));
|
||||
let tex = self.handle_tex(ui.ctx(), wallet, &name);
|
||||
// Paying a request spends our balance, so guard against over-balance and
|
||||
// disable the accept gesture (re-checked each frame).
|
||||
@@ -2041,7 +2087,7 @@ impl GoblinWalletView {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
w::avatar_any(ui, &name, &req.npub, 40.0, hue, tex.as_ref());
|
||||
w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref());
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.request.title", name => &name))
|
||||
@@ -2081,7 +2127,7 @@ impl GoblinWalletView {
|
||||
));
|
||||
}
|
||||
let fee_val = match wallet.calculated_fee(req.amount) {
|
||||
Some(fee) => format!("{} {}", w::amount_str(fee), w::TSU),
|
||||
Some(fee) => format!("{}{}", w::amount_str(fee), w::TSU),
|
||||
None => {
|
||||
ui.ctx().request_repaint_after(
|
||||
std::time::Duration::from_millis(120),
|
||||
@@ -2157,17 +2203,18 @@ impl GoblinWalletView {
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
|
||||
let handle = wallet
|
||||
// `has_name`: a claimed nip05 name exists — gates the "handle"/"username"
|
||||
// wording, which would mislead when only the raw npub is shown.
|
||||
let (handle, has_name) = wallet
|
||||
.nostr_service()
|
||||
.map(|s| {
|
||||
let identity = s.identity.read();
|
||||
identity
|
||||
.nip05
|
||||
.clone()
|
||||
.map(|n| n.split('@').next().unwrap_or("").to_string())
|
||||
.unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub)))
|
||||
match identity.nip05.clone() {
|
||||
Some(n) => (n.split('@').next().unwrap_or("").to_string(), true),
|
||||
None => (data::short_npub(&hex_of(&identity.npub)), false),
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
.unwrap_or_else(|| ("—".to_string(), false));
|
||||
let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default();
|
||||
let nprofile = wallet
|
||||
.nostr_service()
|
||||
@@ -2203,8 +2250,13 @@ impl GoblinWalletView {
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let caption = if has_name {
|
||||
t!("goblin.receive.share_handle")
|
||||
} else {
|
||||
t!("goblin.receive.share_npub")
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.receive.share_handle"))
|
||||
RichText::new(caption)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
@@ -2263,8 +2315,13 @@ impl GoblinWalletView {
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
let privacy_note = if has_name {
|
||||
t!("goblin.receive.privacy_note")
|
||||
} else {
|
||||
t!("goblin.receive.privacy_note_npub")
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.receive.privacy_note"))
|
||||
RichText::new(privacy_note)
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -2274,7 +2331,7 @@ impl GoblinWalletView {
|
||||
let t = theme::tokens();
|
||||
match self.settings_page {
|
||||
SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb),
|
||||
SettingsPage::Connections => return self.grim_connections_ui(ui, cb),
|
||||
SettingsPage::IntegratedNode => return self.integrated_node_ui(ui, cb),
|
||||
SettingsPage::Relays => return self.relays_ui(ui, wallet, cb),
|
||||
SettingsPage::Nips => return self.nips_ui(ui),
|
||||
SettingsPage::Pairing => return self.pairing_settings_ui(ui),
|
||||
@@ -2322,7 +2379,6 @@ impl GoblinWalletView {
|
||||
)
|
||||
});
|
||||
|
||||
let hue = data::hue_of(&npub_hex);
|
||||
let own_tex = bare_name
|
||||
.as_deref()
|
||||
.and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle));
|
||||
@@ -2330,9 +2386,9 @@ impl GoblinWalletView {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
// Avatar is a generated identicon (gradient + initial) — Goblin has
|
||||
// no uploaded profile pictures.
|
||||
w::avatar_any(ui, &handle, &npub_hex, 56.0, hue, own_tex.as_ref());
|
||||
// Custom picture when one is set; otherwise the deterministic
|
||||
// pubkey-seeded gradient identicon.
|
||||
w::avatar_any(ui, &handle, &npub_hex, 56.0, own_tex.as_ref());
|
||||
ui.add_space(14.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -2351,9 +2407,15 @@ impl GoblinWalletView {
|
||||
);
|
||||
}
|
||||
});
|
||||
// Mixnet status (fast) in place of the redundant second npub line.
|
||||
let mixnet = if crate::nym::is_ready() {
|
||||
// Transport status in place of the redundant second npub line.
|
||||
// "Connected over Nym" is RELAY-GATED (transport_ready): the
|
||||
// tunnel being warm is not enough — a relay must actually carry
|
||||
// our traffic on the current exit. Otherwise show the tunnel is
|
||||
// up but relays are still connecting/reconnecting.
|
||||
let mixnet = if crate::nym::transport_ready() {
|
||||
t!("goblin.home.connected_nym")
|
||||
} else if crate::nym::is_ready() {
|
||||
t!("goblin.home.nym_ready")
|
||||
} else {
|
||||
t!("goblin.home.connecting_nym")
|
||||
};
|
||||
@@ -2373,7 +2435,7 @@ impl GoblinWalletView {
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
);
|
||||
if !crate::nym::is_ready() || !connected {
|
||||
if !crate::nym::transport_ready() || !connected {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(600));
|
||||
}
|
||||
@@ -2404,6 +2466,10 @@ impl GoblinWalletView {
|
||||
}
|
||||
self.claim_ui(ui, wallet, cb);
|
||||
ui.add_space(8.0);
|
||||
// Hoisted above the identity card: the Nostr Relays row now lives
|
||||
// inside that card (relays are a nostr concern, like the keys), but
|
||||
// its open handler runs further down — so the flag is declared here.
|
||||
let mut open_relays = false;
|
||||
w::card(ui, |ui| {
|
||||
if !npub.is_empty() {
|
||||
if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) {
|
||||
@@ -2437,6 +2503,16 @@ impl GoblinWalletView {
|
||||
{
|
||||
self.import_nsec = Some(ImportState::default());
|
||||
}
|
||||
// Nostr relays the wallet publishes/reads gift wraps on.
|
||||
// Sits with the identity rows because relays are a nostr
|
||||
// concern; opens the relay editor (handled below).
|
||||
if settings_row_nav(
|
||||
ui,
|
||||
&t!("goblin.settings.nostr_relays"),
|
||||
&relay_summary(wallet),
|
||||
) {
|
||||
open_relays = true;
|
||||
}
|
||||
// Federation: which name authority (server) registers and
|
||||
// verifies names. Shows the current host on the right.
|
||||
let authority = wallet
|
||||
@@ -2503,17 +2579,24 @@ impl GoblinWalletView {
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
let mut open_relays = false;
|
||||
let mut open_node = false;
|
||||
let mut open_integrated = false;
|
||||
let mut open_slatepack = false;
|
||||
settings_group(ui, &t!("goblin.settings.wallet"), |ui| {
|
||||
settings_row(ui, &t!("goblin.settings.display_unit"), "ツ (grin)");
|
||||
if settings_row_nav(ui, &t!("goblin.settings.relays"), &relay_summary(wallet)) {
|
||||
open_relays = true;
|
||||
}
|
||||
if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) {
|
||||
open_node = true;
|
||||
}
|
||||
// GRIM's integrated-node tabs (info, metrics, mining, node
|
||||
// settings), shown in Goblin chrome. Live sync status when
|
||||
// the node runs, like the Node row above.
|
||||
let node_value = if crate::node::Node::is_running() {
|
||||
crate::node::Node::get_sync_status_text()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if settings_row_nav(ui, &t!("goblin.settings.integrated_node"), &node_value) {
|
||||
open_integrated = true;
|
||||
}
|
||||
// GRIM's native by-hand slatepack exchange, for when a payment
|
||||
// can't go through a username.
|
||||
if settings_row_nav(
|
||||
@@ -2529,9 +2612,11 @@ impl GoblinWalletView {
|
||||
self.settings_page = SettingsPage::Slatepack;
|
||||
}
|
||||
if open_relays {
|
||||
// The ACTIVE set (override or per-identity advertised set),
|
||||
// so the editor shows what is really in use.
|
||||
self.relay_edit = wallet
|
||||
.nostr_service()
|
||||
.map(|s| s.config.read().relays())
|
||||
.map(|s| s.relays())
|
||||
.unwrap_or_default();
|
||||
self.relay_input.clear();
|
||||
self.settings_page = SettingsPage::Relays;
|
||||
@@ -2541,24 +2626,30 @@ impl GoblinWalletView {
|
||||
self.node_secret_input.clear();
|
||||
self.settings_page = SettingsPage::Node;
|
||||
}
|
||||
if open_integrated {
|
||||
self.node_tab = Box::new(crate::gui::views::network::NetworkNode);
|
||||
self.node_tab_back = SettingsPage::Main;
|
||||
self.settings_page = SettingsPage::IntegratedNode;
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
let mut open_pairing = false;
|
||||
let mut open_privacy = false;
|
||||
settings_group(ui, &t!("goblin.settings.privacy"), |ui| {
|
||||
// Messages, names, price and avatars ride the mixnet; the grin
|
||||
// node connects directly. Flagged in the privacy color so it
|
||||
// still reads as the headline guarantee — tap for the breakdown.
|
||||
if settings_row_nav_ink(
|
||||
// node connects directly. Normal dim value ink: the salmon
|
||||
// privacy color doubled as the destructive-action color on
|
||||
// this page, making a plain navigable row read as a warning.
|
||||
if settings_row_nav(
|
||||
ui,
|
||||
&t!("goblin.settings.mixnet_routing"),
|
||||
&t!("goblin.settings.messages_lookups"),
|
||||
theme::tokens().neg,
|
||||
) {
|
||||
open_privacy = true;
|
||||
}
|
||||
// Tap to cycle the incoming-payment accept policy.
|
||||
if settings_row_btn(
|
||||
// Tap to cycle the incoming-payment accept policy. Value styled
|
||||
// like the sibling rows' values (small/dim), not like an icon.
|
||||
if settings_row_cycle(
|
||||
ui,
|
||||
&t!("goblin.settings.auto_accept"),
|
||||
&accept_policy_label(wallet),
|
||||
@@ -2626,13 +2717,21 @@ impl GoblinWalletView {
|
||||
cb.vibrate_copy();
|
||||
}
|
||||
}
|
||||
if settings_row_btn(
|
||||
ui,
|
||||
&t!("goblin.settings.wipe_history"),
|
||||
crate::gui::icons::X,
|
||||
) {
|
||||
if let Some(s) = wallet.nostr_service() {
|
||||
s.store.wipe_archive();
|
||||
// Destructive: danger styling + tap-twice confirm (like the
|
||||
// receipt's "Cancel payment") before the archive is wiped.
|
||||
let wipe_label = if self.wipe_confirm {
|
||||
t!("goblin.settings.wipe_history_confirm")
|
||||
} else {
|
||||
t!("goblin.settings.wipe_history")
|
||||
};
|
||||
if settings_row_danger(ui, &wipe_label, crate::gui::icons::X) {
|
||||
if self.wipe_confirm {
|
||||
if let Some(s) = wallet.nostr_service() {
|
||||
s.store.wipe_archive();
|
||||
}
|
||||
self.wipe_confirm = false;
|
||||
} else {
|
||||
self.wipe_confirm = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2657,7 +2756,9 @@ impl GoblinWalletView {
|
||||
let mut open_nips = false;
|
||||
settings_group(ui, &t!("goblin.settings.third_party"), |ui| {
|
||||
if settings_row_nav(ui, &t!("goblin.settings.grim"), crate::VERSION) {
|
||||
open_url(ui, "https://github.com/ardocrat/grim");
|
||||
// Live upstream GRIM (GitHub mirror of code.gri.mw/GUI/grim).
|
||||
// Was github.com/ardocrat/grim — a stale personal fork.
|
||||
open_url(ui, "https://github.com/GetGrin/grim");
|
||||
}
|
||||
if settings_row_nav(ui, &t!("goblin.settings.grin_node"), "5.4.0") {
|
||||
open_url(ui, "https://github.com/mimblewimble/grin");
|
||||
@@ -3047,6 +3148,88 @@ impl GoblinWalletView {
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
|
||||
// Nostr key (nsec). Password-gated reveal, then Copy + a QR
|
||||
// so it can be carried into a nostr app's private-key login
|
||||
// (e.g. magick.market) without retyping. Same gate as the
|
||||
// recovery phrase above.
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
advanced_head(ui, &t!("goblin.advanced.nostr_key"), t.surface_text);
|
||||
advanced_desc(ui, &t!("goblin.advanced.nostr_key_desc"));
|
||||
ui.add_space(10.0);
|
||||
if let Some(nsec) = adv.nsec_revealed.clone() {
|
||||
w::field_well(ui, |ui| {
|
||||
ui.label(
|
||||
RichText::new(&nsec)
|
||||
.font(FontId::new(14.0, fonts::medium()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
if w::big_action_on_card(ui, &t!("goblin.advanced.copy_nsec")).clicked()
|
||||
{
|
||||
cb.copy_string_to_buffer(nsec.clone());
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let qr_label = if adv.nsec_qr {
|
||||
t!("goblin.advanced.hide_qr")
|
||||
} else {
|
||||
t!("goblin.advanced.show_qr")
|
||||
};
|
||||
if w::big_action_on_card(ui, &qr_label).clicked() {
|
||||
adv.nsec_qr = !adv.nsec_qr;
|
||||
}
|
||||
if adv.nsec_qr {
|
||||
ui.add_space(10.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
w::qr_code(ui, &nsec, 220.0);
|
||||
});
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() {
|
||||
adv.nsec_revealed = None;
|
||||
adv.nsec_qr = false;
|
||||
adv.nsec_pass.clear();
|
||||
}
|
||||
} else {
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("advanced_nsec_pass"))
|
||||
.focus(false)
|
||||
.hint_text(t!("goblin.advanced.password"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut adv.nsec_pass, cb);
|
||||
});
|
||||
if adv.nsec_wrong {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.advanced.wrong_password"))
|
||||
.font(FontId::new(13.0, fonts::medium()))
|
||||
.color(t.neg),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.add_enabled_ui(!adv.nsec_pass.is_empty(), |ui| {
|
||||
if w::big_action_on_card(ui, &t!("goblin.advanced.reveal_nsec"))
|
||||
.clicked()
|
||||
{
|
||||
match wallet.get_nostr_nsec(adv.nsec_pass.clone()) {
|
||||
Ok(nsec) => {
|
||||
adv.nsec_revealed = Some(nsec);
|
||||
adv.nsec_wrong = false;
|
||||
adv.nsec_pass.clear();
|
||||
}
|
||||
Err(_) => {
|
||||
adv.nsec_wrong = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
|
||||
// Delete.
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
@@ -3082,24 +3265,66 @@ impl GoblinWalletView {
|
||||
self.settings_page = SettingsPage::Main;
|
||||
}
|
||||
if open_node {
|
||||
// Advanced → "Manage node connection" opens GRIM's native connections UI.
|
||||
self.settings_page = SettingsPage::Connections;
|
||||
// Advanced → "Manage node connection" opens Goblin's own Node screen
|
||||
// (its Advanced button reaches the integrated-node tabs from there).
|
||||
self.node_url_input.clear();
|
||||
self.node_secret_input.clear();
|
||||
self.settings_page = SettingsPage::Node;
|
||||
}
|
||||
}
|
||||
|
||||
/// GRIM's native node-connections screen, embedded under a Goblin back header.
|
||||
fn grim_connections_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
use crate::gui::views::types::ContentContainer;
|
||||
if self.sub_header(ui, &t!("goblin.node.title")) {
|
||||
self.settings_page = SettingsPage::Advanced;
|
||||
/// GRIM's four integrated-node tabs (Info / Metrics / Mining / Settings)
|
||||
/// hosted under a Goblin back header and segmented control — GRIM's
|
||||
/// dual-panel and floating-navbar chrome are never rendered. The header
|
||||
/// title follows the active tab, like GRIM's own title panel.
|
||||
fn integrated_node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
use crate::gui::icons::{DATABASE, FACTORY, FADERS, GAUGE};
|
||||
use crate::gui::views::network::types::NodeTabType;
|
||||
use crate::gui::views::network::{
|
||||
NetworkContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings,
|
||||
disabled_node_ui, node_error_ui,
|
||||
};
|
||||
use crate::node::Node;
|
||||
let title = self.node_tab.get_type().title();
|
||||
if self.sub_header(ui, &title) {
|
||||
self.settings_page = self.node_tab_back;
|
||||
return;
|
||||
}
|
||||
ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
self.grim_connections.ui(ui, cb);
|
||||
});
|
||||
let selected = match self.node_tab.get_type() {
|
||||
NodeTabType::Info => 0,
|
||||
NodeTabType::Metrics => 1,
|
||||
NodeTabType::Mining => 2,
|
||||
NodeTabType::Settings => 3,
|
||||
};
|
||||
if let Some(i) = w::segmented(ui, &[DATABASE, GAUGE, FACTORY, FADERS], selected) {
|
||||
self.node_tab = match i {
|
||||
0 => Box::new(NetworkNode),
|
||||
1 => Box::new(NetworkMetrics),
|
||||
2 => Box::new(NetworkMining::default()),
|
||||
_ => Box::new(NetworkSettings::default()),
|
||||
};
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
// Same availability gate as GRIM's NetworkContent: the Settings tab is
|
||||
// editable with the node off; the live tabs need a running node with
|
||||
// stats before their content can draw.
|
||||
if self.node_tab.get_type() != NodeTabType::Settings {
|
||||
if let Some(err) = Node::get_error() {
|
||||
node_error_ui(ui, err);
|
||||
} else if !Node::is_running() {
|
||||
disabled_node_ui(ui);
|
||||
} else if Node::get_stats().is_none() || Node::is_restarting() || Node::is_stopping() {
|
||||
NetworkContent::loading_ui(ui, None::<String>);
|
||||
} else {
|
||||
self.node_tab.tab_ui(ui, cb);
|
||||
}
|
||||
} else {
|
||||
self.node_tab.tab_ui(ui, cb);
|
||||
}
|
||||
// Keep the stats fresh while the node runs.
|
||||
if Node::is_running() {
|
||||
ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
@@ -3160,10 +3385,13 @@ impl GoblinWalletView {
|
||||
.color(t.pos),
|
||||
);
|
||||
} else {
|
||||
// Trash, not an X: a grey × next to the active row's
|
||||
// green check read as a failed-status icon, not the
|
||||
// remove action it actually is.
|
||||
let x = ui.label(
|
||||
RichText::new(crate::gui::icons::X)
|
||||
RichText::new(crate::gui::icons::TRASH_SIMPLE)
|
||||
.font(FontId::new(15.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
if x.interact(Sense::click()).clicked() {
|
||||
ConnectionsConfig::remove_ext_conn(conn.id);
|
||||
@@ -3225,6 +3453,14 @@ impl GoblinWalletView {
|
||||
self.node_url_input.clear();
|
||||
self.node_secret_input.clear();
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// Advanced: GRIM's integrated-node tabs (info, metrics, mining
|
||||
// with stratum, node settings) inside Goblin chrome.
|
||||
if w::big_action(ui, &t!("goblin.settings.node_advanced"), true).clicked() {
|
||||
self.node_tab = Box::new(crate::gui::views::network::NetworkNode);
|
||||
self.node_tab_back = SettingsPage::Node;
|
||||
self.settings_page = SettingsPage::IntegratedNode;
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
});
|
||||
}
|
||||
@@ -4705,6 +4941,29 @@ fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool {
|
||||
row.response.interact(Sense::click()).clicked()
|
||||
}
|
||||
|
||||
/// A settings row whose value cycles in place on tap (no navigation): the
|
||||
/// value is drawn in the same small/dim style as [`settings_row_nav`] so it
|
||||
/// sits consistently next to chevroned siblings, just without the chevron.
|
||||
fn settings_row_cycle(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
|
||||
let t = theme::tokens();
|
||||
let row = ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(15.0, fonts::medium()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(value)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
row.response.interact(Sense::click()).clicked()
|
||||
}
|
||||
|
||||
/// A settings row that navigates somewhere: value + chevron, whole row taps.
|
||||
fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
|
||||
let t = theme::tokens();
|
||||
@@ -4732,34 +4991,6 @@ fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
|
||||
row.response.interact(Sense::click()).clicked()
|
||||
}
|
||||
|
||||
/// Like [`settings_row_nav`] but the value is drawn in an explicit ink — used to
|
||||
/// flag the mixnet-routing row in the privacy color while still navigating.
|
||||
fn settings_row_nav_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) -> bool {
|
||||
let t = theme::tokens();
|
||||
let row = ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(15.0, fonts::medium()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(crate::gui::icons::CARET_RIGHT)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(value)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(value_ink),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
row.response.interact(Sense::click()).clicked()
|
||||
}
|
||||
|
||||
/// One channel row on the Network-privacy page: a status dot, a title and a
|
||||
/// wrapped blurb explaining where that traffic goes.
|
||||
fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) {
|
||||
|
||||
@@ -21,7 +21,7 @@ use eframe::epaint::FontId;
|
||||
use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2};
|
||||
use grin_util::ZeroingString;
|
||||
|
||||
use crate::gui::icons::ARROW_LEFT;
|
||||
use crate::gui::icons::{ARROW_LEFT, CHECK};
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::theme::{self, fonts};
|
||||
use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult};
|
||||
@@ -75,6 +75,8 @@ pub struct OnboardingContent {
|
||||
/// step so a returning user can keep their old npub + username instead of the
|
||||
/// freshly-generated random key.
|
||||
import: Option<OnbImport>,
|
||||
/// Moment the recovery phrase was copied, for the transient "Copied" check.
|
||||
words_copied: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// Onboarding identity-import state. Reuses the wallet password the user just
|
||||
@@ -104,7 +106,7 @@ impl Default for OnboardingContent {
|
||||
// Default to the Instant path (connect to a public node) so a new
|
||||
// user is online immediately, with no chain-sync wait.
|
||||
integrated: false,
|
||||
ext_url: "https://api.grin.money".to_string(),
|
||||
ext_url: "https://grincoin.org".to_string(),
|
||||
restore: false,
|
||||
name: "Main wallet".to_string(),
|
||||
pass: String::new(),
|
||||
@@ -115,6 +117,7 @@ impl Default for OnboardingContent {
|
||||
wallet: None,
|
||||
claim: ClaimState::default(),
|
||||
import: None,
|
||||
words_copied: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,9 +564,24 @@ impl OnboardingContent {
|
||||
);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
} else if w::chip(ui, &t!("goblin.onboarding.words.copy_clipboard"), false).clicked() {
|
||||
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
|
||||
cb.vibrate_copy();
|
||||
} else {
|
||||
// Transient "Copied" feedback (the Build 82/89 pattern): a silent
|
||||
// copy of the recovery phrase reads as a dead button.
|
||||
let copied = matches!(self.words_copied, Some(at) if at.elapsed().as_millis() < 1500);
|
||||
if self.words_copied.is_some() {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
let label = if copied {
|
||||
format!("{} {}", CHECK, t!("goblin.receive.copied"))
|
||||
} else {
|
||||
t!("goblin.onboarding.words.copy_clipboard").to_string()
|
||||
};
|
||||
if w::chip(ui, &label, false).clicked() {
|
||||
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
|
||||
cb.vibrate_copy();
|
||||
self.words_copied = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
if !restore {
|
||||
ui.add_space(14.0);
|
||||
@@ -746,7 +764,8 @@ impl OnboardingContent {
|
||||
// for this key; only fall back to a placeholder while the key is
|
||||
// still being generated (npub not yet available).
|
||||
if npub.is_empty() {
|
||||
w::avatar(ui, "N", 44.0, 6);
|
||||
// Key still generating: a fixed-seed gradient placeholder.
|
||||
w::gradient_avatar(ui, "goblin", 44.0);
|
||||
} else {
|
||||
w::gradient_avatar(ui, &npub, 44.0);
|
||||
}
|
||||
@@ -783,7 +802,9 @@ impl OnboardingContent {
|
||||
);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(if connected {
|
||||
// Relay-gated readiness: "connected over Nym" only once a
|
||||
// relay is actually live, not merely when the tunnel is warm.
|
||||
RichText::new(if crate::nym::transport_ready() {
|
||||
t!("goblin.onboarding.identity.connected_nym")
|
||||
} else {
|
||||
t!("goblin.onboarding.identity.connecting_nym")
|
||||
|
||||
@@ -76,7 +76,6 @@ enum ScanTab {
|
||||
struct Recipient {
|
||||
name: String,
|
||||
npub: String,
|
||||
hue: usize,
|
||||
/// Recipient relay hints (nprofile / NIP-05 resolution), extra delivery
|
||||
/// targets for a recipient whose kind 10050 isn't discoverable yet.
|
||||
relay_hints: Vec<String>,
|
||||
@@ -87,7 +86,6 @@ struct Recipient {
|
||||
struct Candidate {
|
||||
name: String,
|
||||
npub: String,
|
||||
hue: usize,
|
||||
/// Known contact, resolved goblin handle, or has a published nostr
|
||||
/// profile. Unverified = a syntactically valid key with no profile.
|
||||
verified: bool,
|
||||
@@ -180,11 +178,9 @@ impl Default for SendFlow {
|
||||
impl SendFlow {
|
||||
/// Pre-fill a contact and skip to amount entry.
|
||||
pub fn prefill_contact(&mut self, name: String, npub: String) {
|
||||
let hue = data::hue_of(&npub);
|
||||
self.recipient = Some(Recipient {
|
||||
name,
|
||||
npub,
|
||||
hue,
|
||||
relay_hints: vec![],
|
||||
});
|
||||
self.stage = Stage::Amount;
|
||||
@@ -428,6 +424,7 @@ impl SendFlow {
|
||||
.hint_text(t!("goblin.send.search_hint"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.paste()
|
||||
.scan_qr();
|
||||
te.ui(ui, &mut search, cb);
|
||||
// scan_qr() already starts the camera on tap.
|
||||
@@ -473,7 +470,7 @@ impl SendFlow {
|
||||
let peers = recent_peers(wallet, 20);
|
||||
let texs: Vec<Option<egui::TextureHandle>> = peers
|
||||
.iter()
|
||||
.map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name))
|
||||
.map(|(name, _)| tex_for(avatars, ui.ctx(), wallet, name))
|
||||
.collect();
|
||||
ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
@@ -486,16 +483,16 @@ impl SendFlow {
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) {
|
||||
for ((name, npub), tex) in peers.into_iter().zip(texs.iter()) {
|
||||
if w::activity_row(
|
||||
ui,
|
||||
&name,
|
||||
&data::full_npub(&npub),
|
||||
hue,
|
||||
&npub,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
tex.as_ref(),
|
||||
)
|
||||
.clicked()
|
||||
@@ -503,7 +500,6 @@ impl SendFlow {
|
||||
self.pick(Candidate {
|
||||
name,
|
||||
npub,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "",
|
||||
relay_hints: vec![],
|
||||
@@ -517,10 +513,9 @@ impl SendFlow {
|
||||
// Type-ahead results: instant local matches + the network candidate.
|
||||
let mut cands: Vec<Candidate> = search_contacts(wallet, &query, 6)
|
||||
.into_iter()
|
||||
.map(|(name, hue, npub)| Candidate {
|
||||
.map(|(name, npub)| Candidate {
|
||||
name,
|
||||
npub,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "contact",
|
||||
relay_hints: vec![],
|
||||
@@ -555,11 +550,11 @@ impl SendFlow {
|
||||
ui,
|
||||
&c.name,
|
||||
&tag,
|
||||
c.hue,
|
||||
&c.npub,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
tex.as_ref(),
|
||||
)
|
||||
.clicked()
|
||||
@@ -599,7 +594,6 @@ impl SendFlow {
|
||||
self.recipient = Some(Recipient {
|
||||
name: cand.name,
|
||||
npub: cand.npub,
|
||||
hue: cand.hue,
|
||||
relay_hints: cand.relay_hints,
|
||||
});
|
||||
let preset = amount_from_hr_string(&self.amount)
|
||||
@@ -686,17 +680,30 @@ impl SendFlow {
|
||||
// seed words or slatepack contents into the search box.
|
||||
match &result {
|
||||
QrScanResult::Text(text) => {
|
||||
let text = text.trim();
|
||||
let text = text
|
||||
.strip_prefix("nostr:")
|
||||
.or_else(|| text.strip_prefix("NOSTR:"))
|
||||
.unwrap_or(text);
|
||||
// Parse as a (possibly amount-bearing) pay-URI. UNTRUSTED
|
||||
// input: the parser is pure, fail-closed, and only ever
|
||||
// PREFILLS — the recipient still resolves + verifies via the
|
||||
// picker and the amount/review screens still gate the send.
|
||||
// A bad amount/memo is dropped; a bare `nostr:<nprofile>`
|
||||
// behaves exactly as before.
|
||||
let pay = crate::nostr::payuri::parse(text);
|
||||
// Drop the scanned key into the search box; the picker's
|
||||
// debounced lookup resolves + verifies it like typed input.
|
||||
self.search = text.to_string();
|
||||
self.search = pay.recipient;
|
||||
self.input_changed_at = ui.input(|i| i.time);
|
||||
self.lookup_query.clear();
|
||||
self.net_candidate = None;
|
||||
// Prefill the amount only when the wallet's own parser
|
||||
// accepted it (strictly positive). We stay on the normal
|
||||
// picker -> amount/review flow; nothing auto-advances.
|
||||
if let Some(amount) = pay.amount {
|
||||
self.amount = amount;
|
||||
}
|
||||
// Prefill the send note from the (already sanitized) memo;
|
||||
// it rides along into the tx message via `dispatch`.
|
||||
if let Some(memo) = pay.memo {
|
||||
self.note = memo;
|
||||
}
|
||||
let _ = wallet;
|
||||
}
|
||||
_ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()),
|
||||
@@ -826,20 +833,16 @@ impl SendFlow {
|
||||
// Valid key → confirm it's a live identity via its kind-0 profile.
|
||||
self.looking_up = true;
|
||||
let service = wallet.nostr_service();
|
||||
let known = wallet.nostr_service().and_then(|s| {
|
||||
s.store
|
||||
.contact(&hex)
|
||||
.map(|c| (display_name(&c), c.hue as usize))
|
||||
});
|
||||
let known = wallet
|
||||
.nostr_service()
|
||||
.and_then(|s| s.store.contact(&hex).map(|c| display_name(&c)));
|
||||
std::thread::spawn(move || {
|
||||
let hue = data::hue_of(&hex);
|
||||
let profile = service.and_then(|s| s.fetch_profile_blocking(&hex, &key_hints));
|
||||
let res = match (known, profile) {
|
||||
// Already a saved contact — trust it.
|
||||
(Some((name, hue)), _) => LookupResult::Found(Candidate {
|
||||
(Some(name), _) => LookupResult::Found(Candidate {
|
||||
name,
|
||||
npub: hex,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "contact",
|
||||
relay_hints: key_hints,
|
||||
@@ -854,7 +857,6 @@ impl SendFlow {
|
||||
LookupResult::Found(Candidate {
|
||||
name,
|
||||
npub: hex,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "on nostr",
|
||||
relay_hints: key_hints,
|
||||
@@ -863,7 +865,6 @@ impl SendFlow {
|
||||
(None, None) => LookupResult::Unverified(Candidate {
|
||||
name: short_npub(&hex),
|
||||
npub: hex,
|
||||
hue,
|
||||
verified: false,
|
||||
tag: "",
|
||||
relay_hints: key_hints,
|
||||
@@ -893,7 +894,6 @@ impl SendFlow {
|
||||
LookupResult::Found(Candidate {
|
||||
name: display,
|
||||
npub: hex.clone(),
|
||||
hue: data::hue_of(&hex),
|
||||
// A successful NIP-05 resolution (home OR a named foreign
|
||||
// authority) is verified — the user typed a specific
|
||||
// handle and the domain is shown, so no bare-key gate.
|
||||
@@ -953,7 +953,6 @@ impl SendFlow {
|
||||
&recipient.name,
|
||||
&recipient.npub,
|
||||
28.0,
|
||||
recipient.hue,
|
||||
chip_tex.as_ref(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
@@ -1006,13 +1005,16 @@ impl SendFlow {
|
||||
// above it means the pad stays visible and tappable, instead of being
|
||||
// hidden behind the keyboard (the old order trapped you in the note).
|
||||
let note_focused = ui.ctx().memory(|m| m.has_focus(note_id));
|
||||
if !View::is_desktop() {
|
||||
if w::numpad(ui, &mut self.amount, cb) {
|
||||
// Tapping the pad means you're back on the amount — drop the note's
|
||||
// focus so its keyboard goes away.
|
||||
ui.ctx().memory_mut(|m| m.surrender_focus(note_id));
|
||||
}
|
||||
} else if !note_focused {
|
||||
// The send column is capped at 480 by `centered_column`, so the old
|
||||
// `< 700` width gate was always narrow and the typed branch dead (same
|
||||
// fix as pay_ui, so both amount screens match): show the pad and accept
|
||||
// typed digits alongside it.
|
||||
if w::numpad(ui, &mut self.amount, cb) {
|
||||
// Tapping the pad means you're back on the amount — drop the note's
|
||||
// focus so its keyboard goes away.
|
||||
ui.ctx().memory_mut(|m| m.surrender_focus(note_id));
|
||||
}
|
||||
if !note_focused {
|
||||
// Only consume keystrokes for the amount when the note field is
|
||||
// not focused, so typing a note doesn't also edit the amount.
|
||||
w::amount_typed_input(ui, &mut self.amount);
|
||||
@@ -1054,13 +1056,12 @@ impl SendFlow {
|
||||
let valid = amount_from_hr_string(&self.amount)
|
||||
.map(|a| a > 0)
|
||||
.unwrap_or(false);
|
||||
ui.add_enabled_ui(valid, |ui| {
|
||||
if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() {
|
||||
if over {
|
||||
cb.vibrate_error();
|
||||
} else {
|
||||
self.stage = Stage::Review;
|
||||
}
|
||||
// Greyed out while over balance, matching the red guard above; the
|
||||
// `!over` in the click also refuses it in case the disabled state is
|
||||
// ever bypassed.
|
||||
ui.add_enabled_ui(valid && !over, |ui| {
|
||||
if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() && !over {
|
||||
self.stage = Stage::Review;
|
||||
}
|
||||
});
|
||||
false
|
||||
@@ -1122,7 +1123,6 @@ impl SendFlow {
|
||||
&recipient.name,
|
||||
&recipient.npub,
|
||||
40.0,
|
||||
recipient.hue,
|
||||
hero_tex.as_ref(),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
@@ -1172,7 +1172,7 @@ impl SendFlow {
|
||||
wallet.task(WalletTask::CalculateFee(amount_nano, 0));
|
||||
}
|
||||
let fee_val = match wallet.calculated_fee(amount_nano) {
|
||||
Some(fee) => format!("{} {}", w::amount_str(fee), w::TSU),
|
||||
Some(fee) => format!("{}{}", w::amount_str(fee), w::TSU),
|
||||
None => {
|
||||
// Result lands on a worker thread; poll until it does.
|
||||
ui.ctx()
|
||||
@@ -1492,7 +1492,18 @@ fn resolve_nip05_blocking(name: &str, domain: &str) -> Option<nip05::Nip05Resolu
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()?;
|
||||
rt.block_on(nip05::resolve(&name, &domain))
|
||||
// Overall 15s cap: without it a miss could block ~90s (up to a 30s tunnel
|
||||
// wait + a 60s HTTP timeout), which reads to the user as a silent
|
||||
// indefinite hang. Capping makes a miss fast and retryable instead.
|
||||
rt.block_on(async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
nip05::resolve(&name, &domain),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
.join()
|
||||
.ok()
|
||||
|
||||
@@ -27,34 +27,14 @@ pub fn amount_str(atomic: u64) -> String {
|
||||
grin_core::core::amount_to_hr_string(atomic, true)
|
||||
}
|
||||
|
||||
/// Draw a colored avatar puck with the contact initial.
|
||||
pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response {
|
||||
/// A custom-picture avatar: the texture drawn to fill the circle. Names never
|
||||
/// affect the avatar — claimed and anonymous identities render identically.
|
||||
pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, _name: &str, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let (bg, ink) = theme::avatar_pair(hue);
|
||||
ui.painter().circle_filled(rect.center(), size / 2.0, bg);
|
||||
// First letter of the name — never the @ prefix or other decoration.
|
||||
let initial = name
|
||||
.chars()
|
||||
.find(|c| c.is_alphanumeric())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
initial,
|
||||
FontId::new(size * 0.42, fonts::bold()),
|
||||
ink,
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A custom-picture avatar: the texture drawn in a circle.
|
||||
pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let rounding = eframe::epaint::CornerRadius::same((size / 2.0) as u8);
|
||||
let rounding = eframe::epaint::CornerRadius::same((rect.width() / 2.0) as u8);
|
||||
egui::Image::new(tex)
|
||||
.corner_radius(rounding)
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.fit_to_exact_size(rect.size())
|
||||
.paint_at(ui, rect);
|
||||
resp
|
||||
}
|
||||
@@ -65,80 +45,42 @@ pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, size: f32) -> Response
|
||||
/// the same avatar (see [`super::identicon`]). Cached per-pubkey by egui.
|
||||
pub fn gradient_avatar(ui: &mut Ui, id: &str, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let hex = super::identicon::to_hex_seed(id);
|
||||
// Rasterize at 2x for crispness; egui caches the texture by the `uri`, so the
|
||||
// SVG is generated/rasterized once per pubkey regardless of frames or size.
|
||||
let svg = super::identicon::gradient_avatar_svg(&hex, (size * 2.0) as u32, "");
|
||||
let uri = format!("bytes://gobavatar-{}-{}.svg", hex, size as u32);
|
||||
egui::Image::new(egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: svg.into_bytes().into(),
|
||||
})
|
||||
.corner_radius(CornerRadius::same((size / 2.0) as u8))
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.paint_at(ui, rect);
|
||||
paint_gradient(ui, id, rect);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A named user's avatar: the same pubkey-seeded gradient background as
|
||||
/// [`gradient_avatar`], but with the person's initial painted on top (white with
|
||||
/// a faint dark shadow for legibility on any hue) instead of the Grin mark. `id`
|
||||
/// seeds the gradient; `name` supplies the letter.
|
||||
pub fn gradient_letter_avatar(ui: &mut Ui, id: &str, name: &str, size: f32) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
/// Paint the pubkey-seeded grinmark gradient into `rect` (rasterized at 2x,
|
||||
/// cached by egui via the `uri`).
|
||||
fn paint_gradient(ui: &mut Ui, id: &str, rect: egui::Rect) {
|
||||
let hex = super::identicon::to_hex_seed(id);
|
||||
let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32);
|
||||
let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32);
|
||||
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((size / 2.0) as u8))
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.corner_radius(CornerRadius::same((rect.width() / 2.0) as u8))
|
||||
.fit_to_exact_size(rect.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
|
||||
}
|
||||
|
||||
/// Picture avatar when a texture exists; otherwise the deterministic
|
||||
/// pubkey-seeded gradient: with the Grin mark for an anonymous key (display name
|
||||
/// is an `npub…`), or with the person's initial for a named contact/@handle. A
|
||||
/// flat lettered tile is the last resort when no pubkey is known. `id` is the
|
||||
/// npub/hex used to seed the gradient.
|
||||
/// pubkey-seeded grinmark gradient for everyone, named or anonymous — names
|
||||
/// never affect the avatar. When no pubkey is known (last resort) the name
|
||||
/// seeds the gradient instead, so the tile is still deterministic. `id` is
|
||||
/// the npub/hex used to seed the gradient.
|
||||
pub fn avatar_any(
|
||||
ui: &mut Ui,
|
||||
name: &str,
|
||||
id: &str,
|
||||
size: f32,
|
||||
hue: usize,
|
||||
tex: Option<&egui::TextureHandle>,
|
||||
) -> Response {
|
||||
match tex {
|
||||
Some(t) => avatar_tex(ui, t, size),
|
||||
None if name.starts_with("npub") && !id.is_empty() => gradient_avatar(ui, id, size),
|
||||
None if !id.is_empty() => gradient_letter_avatar(ui, id, name, size),
|
||||
None => avatar(ui, name, size, hue),
|
||||
Some(t) => avatar_tex(ui, t, name, size),
|
||||
None if !id.is_empty() => gradient_avatar(ui, id, size),
|
||||
None => gradient_avatar(ui, name, size),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,12 +251,20 @@ pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response {
|
||||
let t = theme::tokens();
|
||||
let desired = Vec2::new(ui.available_width(), 56.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
|
||||
let (fill, ink, stroke) = if secondary {
|
||||
let (mut fill, mut ink, mut stroke) = if secondary {
|
||||
(Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line))
|
||||
} else {
|
||||
(t.accent, t.accent_ink, Stroke::NONE)
|
||||
};
|
||||
let visual_fill = if resp.hovered() && !secondary {
|
||||
// Inside `add_enabled_ui(false)` the button must LOOK disabled too, so a
|
||||
// blocked action (e.g. Review while over balance) never reads as a live CTA.
|
||||
let enabled = ui.is_enabled();
|
||||
if !enabled {
|
||||
fill = fill.gamma_multiply(0.35);
|
||||
ink = ink.gamma_multiply(0.45);
|
||||
stroke.color = stroke.color.gamma_multiply(0.45);
|
||||
}
|
||||
let visual_fill = if enabled && resp.hovered() && !secondary {
|
||||
t.accent_dark
|
||||
} else {
|
||||
fill
|
||||
@@ -446,30 +396,6 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
|
||||
resp
|
||||
}
|
||||
|
||||
/// An outline pill chip (transparent fill, line border) per the design's
|
||||
/// amount quick-select row.
|
||||
pub fn chip_outline(ui: &mut Ui, label: &str) -> Response {
|
||||
let t = theme::tokens();
|
||||
let galley = ui.painter().layout_no_wrap(
|
||||
label.to_string(),
|
||||
FontId::new(13.0, fonts::semibold()),
|
||||
t.text,
|
||||
);
|
||||
let pad = Vec2::new(14.0, 8.0);
|
||||
let size = galley.size() + pad * 2.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(255),
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.0, t.line),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
ui.painter()
|
||||
.galley(rect.center() - galley.size() / 2.0, galley, t.text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Paint a QR code for `text` with the goblin mark centered. Always dark modules
|
||||
/// on a white plate, whatever the theme — inverted codes fail to decode in many
|
||||
/// scanners. Encoded synchronously each frame; modules are plain painter rects.
|
||||
@@ -536,7 +462,17 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
|
||||
}
|
||||
|
||||
/// A balance hero block: kicker, big number + ツ, optional fiat line.
|
||||
pub fn balance_hero(ui: &mut Ui, total: u64, spendable: u64, fiat: Option<&str>, size: f32) {
|
||||
/// `updating` marks a zero balance that is only zero because funds are in
|
||||
/// flight or the first sync is still running.
|
||||
pub fn balance_hero(
|
||||
ui: &mut Ui,
|
||||
total: u64,
|
||||
spendable: u64,
|
||||
updating: bool,
|
||||
sync_pct: u8,
|
||||
fiat: Option<&str>,
|
||||
size: f32,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
// Headline is the TOTAL the wallet holds — same number GRIM shows — so a
|
||||
// wallet mid-confirmation doesn't look empty.
|
||||
@@ -562,6 +498,25 @@ pub fn balance_hero(ui: &mut Ui, total: u64, spendable: u64, fiat: Option<&str>,
|
||||
);
|
||||
});
|
||||
}
|
||||
// A fresh sync or funds in flight leave a stark 0 that reads as "funds
|
||||
// vanished" — say the balance is still updating, with the scan % when the
|
||||
// node reports one (1..99), so the user sees progress without opening the
|
||||
// wallet switcher.
|
||||
if total == 0 && updating {
|
||||
let label = if (1..100).contains(&sync_pct) {
|
||||
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
|
||||
} else {
|
||||
t!("goblin.home.balance_updating").to_string()
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
if let Some(fiat) = fiat {
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
@@ -580,10 +535,10 @@ pub fn activity_row(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
hue: usize,
|
||||
id: &str,
|
||||
amount: &str,
|
||||
incoming: bool,
|
||||
canceled: bool,
|
||||
system: bool,
|
||||
tex: Option<&egui::TextureHandle>,
|
||||
) -> Response {
|
||||
@@ -614,7 +569,7 @@ pub fn activity_row(
|
||||
t.text,
|
||||
);
|
||||
} else {
|
||||
avatar_any(ui, title, id, 40.0, hue, tex);
|
||||
avatar_any(ui, title, id, 40.0, tex);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
@@ -639,10 +594,18 @@ pub fn activity_row(
|
||||
);
|
||||
});
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// A canceled tx delivered no funds: mute the amount so it never
|
||||
// reads as a completed green credit (or a real debit).
|
||||
ui.label(
|
||||
RichText::new(amount)
|
||||
.font(FontId::new(15.0, fonts::mono_semibold()))
|
||||
.color(if incoming { t.pos } else { t.text }),
|
||||
.color(if canceled {
|
||||
t.text_dim
|
||||
} else if incoming {
|
||||
t.pos
|
||||
} else {
|
||||
t.text
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -884,12 +847,6 @@ pub fn apply_key(amount: &mut String, key: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a full-rect background fill on the current panel.
|
||||
pub fn fill_bg(ui: &Ui, color: Color32) {
|
||||
let rect = ui.ctx().screen_rect();
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, color);
|
||||
}
|
||||
|
||||
/// Center a fixed-width column for narrow content on wide screens.
|
||||
/// Hands the child the full remaining height: wrapping in `horizontal()`
|
||||
/// would start the row a single line tall, so a `ScrollArea` inside would
|
||||
@@ -981,11 +938,3 @@ impl HoldToSend {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Shorten a long key/address for display (8…6).
|
||||
pub fn short_key(key: &str) -> String {
|
||||
if key.len() <= 16 {
|
||||
return key.to_string();
|
||||
}
|
||||
format!("{}…{}", &key[..8], &key[key.len() - 6..])
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ impl NetworkContent {
|
||||
}
|
||||
|
||||
/// Content to draw when node is disabled.
|
||||
fn disabled_node_ui(ui: &mut egui::Ui) {
|
||||
pub fn disabled_node_ui(ui: &mut egui::Ui) {
|
||||
View::center_content(ui, 156.0, |ui| {
|
||||
let text = t!("network.disabled_server", "dots" => DOTS_THREE_OUTLINE_VERTICAL);
|
||||
ui.label(
|
||||
|
||||
@@ -406,11 +406,11 @@ impl WalletsContent {
|
||||
}
|
||||
return false;
|
||||
} else if self.showing_wallet() {
|
||||
// Go back at stack or close wallet.
|
||||
// Go back at stack; on Home with nothing open, back is a no-op.
|
||||
// Leaving the wallet is intentional-only (switch/lock controls),
|
||||
// never a back fallback to the chooser.
|
||||
if self.wallet_content.can_back() {
|
||||
self.wallet_content.back(cb);
|
||||
} else {
|
||||
self.wallets.select(None);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -549,10 +549,10 @@ impl WalletsContent {
|
||||
});
|
||||
} else if show_wallet && !dual_panel {
|
||||
View::title_button_big(ui, ARROW_LEFT, |_| {
|
||||
// Same rule as system back: never fall back to the
|
||||
// chooser; on Home the arrow is a no-op.
|
||||
if self.wallet_content.can_back() {
|
||||
self.wallet_content.back(cb);
|
||||
} else {
|
||||
self.wallets.select(None);
|
||||
}
|
||||
});
|
||||
} else if self.creating_wallet() {
|
||||
@@ -847,7 +847,7 @@ impl WalletsContent {
|
||||
let ver_text = if let Some(size) = update.size.as_ref() {
|
||||
format!("{} {} ({} MB)", BOOKMARKS, update.version, size)
|
||||
} else {
|
||||
format!("{} {} > {}", BOOKMARKS, crate::VERSION, update.version)
|
||||
format!("{} build{} > {}", BOOKMARKS, crate::BUILD, update.version)
|
||||
};
|
||||
View::ellipsize_text(ui, ver_text, 15.0, Colors::text(false));
|
||||
ui.add_space(1.0);
|
||||
|
||||
@@ -83,6 +83,13 @@ impl WalletContentContainer for WalletContent {
|
||||
}
|
||||
|
||||
fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
// Drawing this wallet means the app is foreground with the wallet
|
||||
// on-screen: resume on-demand node polling right away so the balance
|
||||
// goes live (the sync thread otherwise re-checks the foreground
|
||||
// signal only once per cycle). No-op unless polling was paused.
|
||||
if wallet.node_polling_paused() {
|
||||
wallet.resume_node_polling();
|
||||
}
|
||||
// Goblin surface is the primary UI. Show a sync screen until data is
|
||||
// ready, then hand the whole surface to the payment-app-style view.
|
||||
let block_nav_goblin = self.block_navigation_on_sync(wallet);
|
||||
@@ -305,9 +312,11 @@ impl WalletContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if it's possible to go back at navigation stack.
|
||||
/// Check if it's possible to go back at navigation stack. Delegates to the
|
||||
/// goblin view too (overlays, settings sub-pages, non-Home tabs), so the
|
||||
/// host never falls through to the wallet chooser on back.
|
||||
pub fn can_back(&self) -> bool {
|
||||
self.goblin.overlay_active() || self.account_content.can_back()
|
||||
self.goblin.can_back() || self.account_content.can_back()
|
||||
}
|
||||
|
||||
/// Take the pending "switch wallet" request from the goblin settings, so the
|
||||
|
||||
@@ -18,5 +18,5 @@ pub use client::*;
|
||||
mod release;
|
||||
pub use release::*;
|
||||
|
||||
mod price;
|
||||
pub(crate) mod price;
|
||||
pub use price::grin_rate;
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::nym;
|
||||
|
||||
/// Cache refresh interval (seconds).
|
||||
@@ -32,6 +33,19 @@ const REFRESH_SECS: i64 = 300;
|
||||
/// (e.g. no network) does not respawn a thread every frame.
|
||||
const RETRY_SECS: i64 = 30;
|
||||
|
||||
/// How stale a disk-cached rate may be and still be worth painting on cold start
|
||||
/// (48h). Older than this and we start blank rather than show a very wrong price.
|
||||
const SEED_MAX_AGE_SECS: i64 = 48 * 3600;
|
||||
|
||||
/// Eager-probe per-try timeout. The eager fetch on tunnel-ready doubles as the
|
||||
/// end-to-end exit probe: a healthy warm fetch is ~800ms, a dead exit hangs until
|
||||
/// timeout, so a short cap lets us fail fast and condemn a bad exit in seconds.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
/// How many eager-probe fetch attempts before we conclude the (still-"ready")
|
||||
/// exit is blackholing HTTP and condemn it.
|
||||
const PROBE_ATTEMPTS: u32 = 3;
|
||||
|
||||
lazy_static! {
|
||||
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
|
||||
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
|
||||
@@ -87,13 +101,87 @@ fn trigger_refresh(vs: String) {
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
if let Some(rate) = fetch_rate(&vs).await {
|
||||
RATES.write().insert(vs.clone(), (rate, now()));
|
||||
record_rate(&vs, rate);
|
||||
}
|
||||
});
|
||||
FETCHING.write().remove(&vs);
|
||||
});
|
||||
}
|
||||
|
||||
/// Record a freshly fetched rate: into the in-memory cache (with `now()`) AND to
|
||||
/// disk, so the next cold start can paint it instantly (see [`seed_from_disk`]).
|
||||
fn record_rate(vs: &str, rate: f64) {
|
||||
let t = now();
|
||||
RATES.write().insert(vs.to_string(), (rate, t));
|
||||
AppConfig::set_last_rate(vs, rate, t);
|
||||
}
|
||||
|
||||
/// Seed the in-memory cache from the disk-persisted last rate, if it is fresh
|
||||
/// enough (< 48h). Inserted with its ORIGINAL timestamp so it reads as stale —
|
||||
/// [`grin_rate`] returns it immediately for an instant preview, yet `needs_refresh`
|
||||
/// stays true so a live refresh is still kicked. Called once, early in start().
|
||||
pub fn seed_from_disk() {
|
||||
if let Some((vs, rate, at)) = AppConfig::last_rate() {
|
||||
if now() - at <= SEED_MAX_AGE_SECS {
|
||||
RATES.write().entry(vs).or_insert((rate, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick a refresh for the current pairing's currency the moment the tunnel is
|
||||
/// ready, bypassing the [`RETRY_SECS`] gate (but keeping the [`FETCHING`] dedupe).
|
||||
/// It doubles as the end-to-end exit probe: if every attempt fails while the
|
||||
/// tunnel still reports ready, the exit is blackholing HTTP despite passing the
|
||||
/// cheap liveness probe, so we condemn it (bounded: at most one condemnation per
|
||||
/// tunnel generation) rather than let it stall the wallet for minutes.
|
||||
pub fn eager_refresh() {
|
||||
let vs = match AppConfig::pairing().vs_currency() {
|
||||
Some(vs) => vs.to_string(),
|
||||
// Pairing off → nothing to fetch, so no probe either (we never fetch a
|
||||
// price the user hasn't opted into). The watchdog's own signals govern.
|
||||
None => return,
|
||||
};
|
||||
{
|
||||
let mut fetching = FETCHING.write();
|
||||
if fetching.contains(&vs) {
|
||||
return;
|
||||
}
|
||||
fetching.insert(vs.clone());
|
||||
}
|
||||
LAST_TRY.write().insert(vs.clone(), now());
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let generation = nym::tunnel_generation();
|
||||
let mut ok = false;
|
||||
for attempt in 1..=PROBE_ATTEMPTS {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, fetch_rate(&vs)).await {
|
||||
Ok(Some(rate)) => {
|
||||
record_rate(&vs, rate);
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
log::warn!(
|
||||
"price: eager probe fetch {attempt}/{PROBE_ATTEMPTS} failed \
|
||||
(vs {vs}, gen {generation})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every attempt failed AND the tunnel still claims ready on the SAME
|
||||
// generation we probed: the exit is up but blackholing our HTTP. Condemn
|
||||
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
|
||||
// probed generation so a reselect that already happened is never hit.
|
||||
if !ok && nym::is_ready() && nym::tunnel_generation() == generation {
|
||||
nym::condemn_exit(generation);
|
||||
}
|
||||
});
|
||||
FETCHING.write().remove(&vs);
|
||||
}
|
||||
|
||||
/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet.
|
||||
async fn fetch_rate(vs: &str) -> Option<f64> {
|
||||
let url = format!(
|
||||
|
||||
@@ -75,7 +75,7 @@ impl ReleaseInfo {
|
||||
if ARCH == ARM_ARCH {
|
||||
None
|
||||
} else {
|
||||
Some(format!("goblin-{}-linux-x86_64.AppImage", self.tag_name))
|
||||
Some(format!("goblin-{}-linux-x86_64.tar.gz", self.tag_name))
|
||||
}
|
||||
}
|
||||
OperatingSystem::Mac => None,
|
||||
|
||||
@@ -38,7 +38,7 @@ mod http;
|
||||
pub mod logger;
|
||||
mod node;
|
||||
pub mod nostr;
|
||||
mod nym;
|
||||
pub mod nym;
|
||||
mod settings;
|
||||
mod wallet;
|
||||
|
||||
@@ -117,16 +117,20 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
|
||||
// would panic on the first TLS handshake. nym uses its own explicit provider,
|
||||
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so
|
||||
// the mixnet bootstrap (the long pole on cold start) overlaps everything else
|
||||
// and price/NIP-05/nostr are ready at first use. All of Goblin's outbound
|
||||
// traffic egresses through it; nothing clearnet.
|
||||
nym::warm_up();
|
||||
// Seed the price cache from disk so the amount preview can paint an instant
|
||||
// (stale-marked) fiat value while the first live fetch is still in flight.
|
||||
crate::http::price::seed_from_disk();
|
||||
// Setup translations.
|
||||
setup_i18n();
|
||||
// Start integrated node if needed.
|
||||
if AppConfig::autostart_node() {
|
||||
Node::start();
|
||||
}
|
||||
// Pre-warm the in-process Nym mixnet client so price/NIP-05/nostr are ready at
|
||||
// first use. All of Goblin's outbound traffic egresses through it; nothing
|
||||
// clearnet.
|
||||
nym::warm_up();
|
||||
// Launch graphical interface.
|
||||
eframe::run_native("Goblin", options, app_creator)
|
||||
}
|
||||
@@ -410,6 +414,37 @@ pub fn app_foreground() -> bool {
|
||||
last != 0 && now_unix_secs() - last <= FOREGROUND_STALE_SECS
|
||||
}
|
||||
|
||||
/// Fire the platform "payment received" notification with the payer's display
|
||||
/// name and human-readable amount. Android shows a one-shot system
|
||||
/// notification (`BackgroundService.notifyPaymentReceived`, id=2, separate
|
||||
/// from the persistent sync notification); other platforms are a no-op.
|
||||
/// Crate-root so the nostr service can reach it without holding a platform
|
||||
/// reference.
|
||||
pub fn notify_payment_received(name: &str, amount: &str) {
|
||||
#[cfg(target_os = "android")]
|
||||
gui::platform::notify_payment_received(name, amount);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (name, amount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the platform "payment requested" notification with the requester's
|
||||
/// display name and human-readable amount, for an incoming payment request
|
||||
/// (someone asking us to pay them). Android shows a one-shot system
|
||||
/// notification (`BackgroundService.notifyPaymentRequested`, id=3, separate from
|
||||
/// both the persistent sync notification id=1 and the received-payment one
|
||||
/// id=2); other platforms are a no-op. Crate-root so the nostr service can reach
|
||||
/// it without holding a platform reference. Mirrors [`notify_payment_received`].
|
||||
pub fn notify_payment_requested(name: &str, amount: &str) {
|
||||
#[cfg(target_os = "android")]
|
||||
gui::platform::notify_payment_requested(name, amount);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (name, amount);
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Data provided from deeplink or opened file.
|
||||
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
|
||||
@@ -721,7 +721,15 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText(
|
||||
_class: jni::objects::JObject,
|
||||
_activity: jni::objects::JObject,
|
||||
) -> jni::sys::jstring {
|
||||
let status_text = Node::get_sync_status_text();
|
||||
// The keep-alive notification must reflect the real connection: on the
|
||||
// external-node default the integrated node is deliberately off, so "Node
|
||||
// is down" is wrong — the service's actual background job is the
|
||||
// Nostr-over-Nym payment listen.
|
||||
let status_text = if Node::is_running() || Node::is_starting() {
|
||||
Node::get_sync_status_text()
|
||||
} else {
|
||||
t!("goblin.home.listening").into()
|
||||
};
|
||||
let j_text = _env.new_string(status_text);
|
||||
return j_text.unwrap().into_raw();
|
||||
}
|
||||
@@ -736,7 +744,14 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncTitle(
|
||||
_class: jni::objects::JObject,
|
||||
_activity: jni::objects::JObject,
|
||||
) -> jni::sys::jstring {
|
||||
let j_text = _env.new_string(t!("network.node"));
|
||||
// Match the status text: only title the notification "Node" when the
|
||||
// integrated node is actually in use.
|
||||
let title = if Node::is_running() || Node::is_starting() {
|
||||
t!("network.node").to_string()
|
||||
} else {
|
||||
"Goblin".to_string()
|
||||
};
|
||||
let j_text = _env.new_string(title);
|
||||
return j_text.unwrap().into_raw();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,77 +12,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Client-side avatar handling: local preprocessing of a picked picture
|
||||
//! (mirrors the server pipeline so uploads over the mixnet stay small and previews
|
||||
//! are instant — the server still re-validates everything), plus a small
|
||||
//! disk cache of fetched avatars keyed by username.
|
||||
//! Client-side avatar handling: a small disk cache of fetched avatars keyed
|
||||
//! by username.
|
||||
|
||||
use image::codecs::png::PngEncoder;
|
||||
use image::metadata::Orientation;
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Output dimensions (square), matching the server.
|
||||
pub const SIZE: u32 = 256;
|
||||
/// Raw picked files larger than this are rejected before decoding.
|
||||
const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
|
||||
/// Identify the image format from magic bytes alone (PNG/JPEG/WebP).
|
||||
fn sniff(raw: &[u8]) -> Option<ImageFormat> {
|
||||
if raw.len() >= 8 && raw.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
return Some(ImageFormat::Png);
|
||||
}
|
||||
if raw.len() >= 3 && raw.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
return Some(ImageFormat::Jpeg);
|
||||
}
|
||||
if raw.len() >= 12 && &raw[0..4] == b"RIFF" && &raw[8..12] == b"WEBP" {
|
||||
return Some(ImageFormat::WebP);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a picked picture file and normalize it to the canonical 256×256
|
||||
/// PNG (EXIF orientation applied, every byte of metadata destroyed).
|
||||
pub fn process_avatar_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
let meta = std::fs::metadata(path).map_err(|_| "Couldn't read that file".to_string())?;
|
||||
if meta.len() > MAX_FILE_BYTES {
|
||||
return Err("That picture is too large (10 MB max)".to_string());
|
||||
}
|
||||
let raw = std::fs::read(path).map_err(|_| "Couldn't read that file".to_string())?;
|
||||
process_avatar_bytes(&raw)
|
||||
}
|
||||
|
||||
/// Normalize raw image bytes to the canonical avatar PNG.
|
||||
pub fn process_avatar_bytes(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let err = || "That file doesn't look like a usable picture".to_string();
|
||||
let format = sniff(raw).ok_or_else(err)?;
|
||||
let mut reader = ImageReader::with_format(Cursor::new(raw), format);
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(8192);
|
||||
limits.max_image_height = Some(8192);
|
||||
limits.max_alloc = Some(128 * 1024 * 1024);
|
||||
reader.limits(limits);
|
||||
let mut decoder = reader.into_decoder().map_err(|_| err())?;
|
||||
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
|
||||
let mut img = DynamicImage::from_decoder(decoder).map_err(|_| err())?;
|
||||
img.apply_orientation(orientation);
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 {
|
||||
return Err(err());
|
||||
}
|
||||
let side = w.min(h);
|
||||
let img = img.crop_imm((w - side) / 2, (h - side) / 2, side, side);
|
||||
let img = img.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3);
|
||||
let rgba = img.to_rgba8();
|
||||
let mut out = Vec::new();
|
||||
rgba.write_with_encoder(PngEncoder::new(&mut out))
|
||||
.map_err(|_| err())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One cached profile probe.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CacheEntry {
|
||||
@@ -196,33 +132,6 @@ impl AvatarCache {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::RgbaImage;
|
||||
|
||||
fn png_bytes(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = RgbaImage::from_fn(w, h, |x, y| {
|
||||
image::Rgba([(x % 256) as u8, (y % 256) as u8, 7, 255])
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img)
|
||||
.write_with_encoder(PngEncoder::new(&mut out))
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processes_to_canonical_png() {
|
||||
let out = process_avatar_bytes(&png_bytes(500, 300)).unwrap();
|
||||
assert!(out.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
let img = image::load_from_memory(&out).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (SIZE, SIZE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_images() {
|
||||
assert!(process_avatar_bytes(b"<svg onload=alert(1)></svg>").is_err());
|
||||
assert!(process_avatar_bytes(b"GIF89a....").is_err());
|
||||
assert!(process_avatar_bytes(&[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_round_trip_and_remove() {
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
//! Per-wallet nostr service: relay connections over the Nym mixnet,
|
||||
//! identity event publishing, the guarded ingest loop and the DM send path.
|
||||
|
||||
use grin_core::core::amount_to_hr_string;
|
||||
use log::{error, info, warn};
|
||||
use nostr_sdk::{
|
||||
Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification,
|
||||
RelayStatus, Tag, TagKind, Timestamp, ToBech32,
|
||||
RelayStatus, SubscriptionId, Tag, TagKind, Timestamp, ToBech32,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
@@ -32,6 +33,7 @@ use crate::nostr::ingest::{IngestContext, IngestDecision, decide};
|
||||
use crate::nostr::protocol;
|
||||
use crate::nostr::relays::MAX_DM_RELAYS;
|
||||
use crate::nostr::types::*;
|
||||
use crate::nostr::wrapv3;
|
||||
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
|
||||
use crate::nym::NymWebSocketTransport;
|
||||
use crate::wallet::Wallet;
|
||||
@@ -44,6 +46,12 @@ pub struct NostrProfile {
|
||||
pub nip05: Option<String>,
|
||||
}
|
||||
|
||||
/// Stable subscription id for our kind:1059 gift-wrap inbox. Reusing ONE id
|
||||
/// (rather than a fresh random id per (re)subscribe) means re-establishing the
|
||||
/// subscription after a tunnel reselect REPLACES it instead of piling up
|
||||
/// duplicate REQs on the relays.
|
||||
const GIFTWRAP_SUB: &str = "goblin-giftwrap";
|
||||
|
||||
/// Subscription look-back window beyond the last connection time: gift wrap
|
||||
/// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days.
|
||||
const LOOKBACK_SECS: i64 = 3 * 86_400;
|
||||
@@ -59,8 +67,10 @@ const RATE_UNKNOWN_PER_HOUR: usize = 10;
|
||||
const RESEND_WINDOW_SECS: i64 = 7 * 86_400;
|
||||
/// How often a cached @username is re-validated against the identity server, so
|
||||
/// a released or reassigned name stops being shown. Doubles as the freshness
|
||||
/// gate in `resolve_contact_identity`.
|
||||
const NAME_REVERIFY_INTERVAL_SECS: i64 = 78;
|
||||
/// gate in `resolve_contact_identity`. Tuned for release/name-change detection
|
||||
/// freshness, not liveness — a name rarely changes, so 6h is ample and keeps the
|
||||
/// mixnet re-verify traffic off the interactive path.
|
||||
const NAME_REVERIFY_INTERVAL_SECS: i64 = 6 * 3600;
|
||||
/// Cap on contacts re-verified per sweep, so a large contact list rolls through
|
||||
/// instead of bursting dozens of simultaneous mixnet lookups at once.
|
||||
const NAME_REVERIFY_MAX_PER_TICK: usize = 8;
|
||||
@@ -219,15 +229,25 @@ impl NostrService {
|
||||
connect_relays(&client, &dial).await;
|
||||
}
|
||||
let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1);
|
||||
let events = client
|
||||
.fetch_events(filter, Duration::from_secs(10))
|
||||
// First-event-wins, scoped to the relays we just dialed: stream from
|
||||
// exactly that set and return on the FIRST kind-0 that parses as
|
||||
// Metadata (capped at 10s by the stream's own auto-close). The old
|
||||
// `fetch_events` waited for EVERY relay (or the full 10s), so a single
|
||||
// dead hint relay in the set always cost the whole 10s.
|
||||
use futures::StreamExt;
|
||||
let mut stream = client
|
||||
.stream_events_from(dial, filter, Duration::from_secs(10))
|
||||
.await
|
||||
.ok()?;
|
||||
let md: Metadata = serde_json::from_str(&events.first()?.content).ok()?;
|
||||
Some(NostrProfile {
|
||||
name: md.name.filter(|s| !s.is_empty()),
|
||||
nip05: md.nip05.filter(|s| !s.is_empty()),
|
||||
})
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Ok(md) = serde_json::from_str::<Metadata>(&event.content) {
|
||||
return Some(NostrProfile {
|
||||
name: md.name.filter(|s| !s.is_empty()),
|
||||
nip05: md.nip05.filter(|s| !s.is_empty()),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
@@ -239,14 +259,23 @@ impl NostrService {
|
||||
let client = self.client.read().clone()?;
|
||||
let pk = PublicKey::from_hex(hex).ok()?;
|
||||
let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1);
|
||||
let events = client
|
||||
.fetch_events(filter, Duration::from_secs(8))
|
||||
// First-event-wins, scoped to our own connected relays (cap 8s): return on
|
||||
// the first kind-0 that parses as Metadata rather than waiting on every
|
||||
// relay / the full timeout, so one dead relay can't stall the request gate.
|
||||
use futures::StreamExt;
|
||||
let mut stream = client
|
||||
.stream_events_from(self.relays(), filter, Duration::from_secs(8))
|
||||
.await
|
||||
.ok()?;
|
||||
let md: Metadata = serde_json::from_str(&events.first()?.content).ok()?;
|
||||
md.custom
|
||||
.get("goblin_accepts_requests")
|
||||
.and_then(|v| v.as_bool())
|
||||
while let Some(event) = stream.next().await {
|
||||
if let Ok(md) = serde_json::from_str::<Metadata>(&event.content) {
|
||||
return md
|
||||
.custom
|
||||
.get("goblin_accepts_requests")
|
||||
.and_then(|v| v.as_bool());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Republish our kind-0 profile + kind-10050 DM relays (e.g. after toggling
|
||||
@@ -360,8 +389,17 @@ impl NostrService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Current relay list.
|
||||
/// Current relay list: a user-set nostr.toml override wins, otherwise the
|
||||
/// per-identity sticky advertised set (Goblin relay + pool picks), with
|
||||
/// the built-in defaults until one has been selected.
|
||||
pub fn relays(&self) -> Vec<String> {
|
||||
if let Some(over) = self.config.read().relays_override() {
|
||||
return over;
|
||||
}
|
||||
let sticky = self.identity.read().dm_relays.clone();
|
||||
if !sticky.is_empty() {
|
||||
return sticky;
|
||||
}
|
||||
self.config.read().relays()
|
||||
}
|
||||
|
||||
@@ -498,32 +536,15 @@ impl NostrService {
|
||||
let content = protocol::build_payment_content(slatepack);
|
||||
let tags = protocol::build_rumor_tags(note);
|
||||
|
||||
// Resolve receiver DM relays (kind 10050) with our relays as fallback.
|
||||
let mut urls = self.fetch_dm_relays(&client, &receiver).await;
|
||||
for r in relay_hints {
|
||||
if !urls.contains(r) {
|
||||
urls.push(r.clone());
|
||||
}
|
||||
}
|
||||
for r in self.relays() {
|
||||
if !urls.contains(&r) {
|
||||
urls.push(r);
|
||||
}
|
||||
}
|
||||
let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await;
|
||||
|
||||
// NIP-17 delivers to the RECIPIENT's relays, which may differ from ours;
|
||||
// dial any we don't already hold so the gift wrap actually reaches their
|
||||
// inbox (otherwise `send_*_to` errors "relay not found" / never arrives).
|
||||
connect_relays(&client, &urls).await;
|
||||
|
||||
let res = tokio::time::timeout(
|
||||
SEND_TIMEOUT,
|
||||
client.send_private_msg_to(urls, receiver, content, tags),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "send timeout".to_string())?
|
||||
.map_err(|e| format!("send failed: {e}"))?;
|
||||
Ok(res.val.to_hex())
|
||||
self.dispatch_dm(&client, urls, v3, receiver, content, tags)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dispatch a control DM that voids a pending request (a decline by the payer
|
||||
@@ -544,7 +565,61 @@ impl NostrService {
|
||||
let content = protocol::build_control_content();
|
||||
let tags = protocol::build_control_tags(slate_id);
|
||||
|
||||
let mut urls = self.fetch_dm_relays(&client, &receiver).await;
|
||||
let (urls, v3) = self.send_targets(&client, &receiver, relay_hints).await;
|
||||
|
||||
connect_relays(&client, &urls).await;
|
||||
|
||||
self.dispatch_dm(&client, urls, v3, receiver, content, tags)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dispatch one gift-wrapped DM over the negotiated encryption: when the
|
||||
/// recipient advertises `nip44_v3` the wrap is built by [`wrapv3::wrap`],
|
||||
/// otherwise it goes through the unchanged nostr-sdk v2 path (best mutual
|
||||
/// wins; absent capability = v2, so v2-only peers see no change).
|
||||
async fn dispatch_dm(
|
||||
&self,
|
||||
client: &Client,
|
||||
urls: Vec<String>,
|
||||
v3: bool,
|
||||
receiver: PublicKey,
|
||||
content: String,
|
||||
tags: Vec<Tag>,
|
||||
) -> Result<String, String> {
|
||||
let sent = if v3 {
|
||||
let wrap = wrapv3::wrap(&self.keys, &receiver, content, tags)?;
|
||||
tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(urls, &wrap)).await
|
||||
} else {
|
||||
tokio::time::timeout(
|
||||
SEND_TIMEOUT,
|
||||
client.send_private_msg_to(urls, receiver, content, tags),
|
||||
)
|
||||
.await
|
||||
};
|
||||
let res = sent
|
||||
.map_err(|_| "send timeout".to_string())?
|
||||
.map_err(|e| format!("send failed: {e}"))?;
|
||||
Ok(res.val.to_hex())
|
||||
}
|
||||
|
||||
/// Publish targets for one DM plus the negotiated NIP-44 v3 capability:
|
||||
/// the recipient's advertised 10050 inbox (capped at 3) when they publish
|
||||
/// one; otherwise the pragmatic fallback of nprofile relay hints plus our
|
||||
/// own relay set (most Goblin peers share the Goblin relay). No extra
|
||||
/// targets beyond that — wider fan-out adds metadata surface, not
|
||||
/// deliverability. `true` means the recipient's 10050 `encryption` tag
|
||||
/// advertises `nip44_v3`; no tag (or no 10050 at all) = v2 only.
|
||||
async fn send_targets(
|
||||
&self,
|
||||
client: &Client,
|
||||
receiver: &PublicKey,
|
||||
relay_hints: &[String],
|
||||
) -> (Vec<String>, bool) {
|
||||
let (urls, v3) = self.fetch_dm_relays(client, receiver).await;
|
||||
if !urls.is_empty() {
|
||||
return (urls, v3);
|
||||
}
|
||||
let mut urls: Vec<String> = vec![];
|
||||
for r in relay_hints {
|
||||
if !urls.contains(r) {
|
||||
urls.push(r.clone());
|
||||
@@ -555,51 +630,67 @@ impl NostrService {
|
||||
urls.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
connect_relays(&client, &urls).await;
|
||||
|
||||
let res = tokio::time::timeout(
|
||||
SEND_TIMEOUT,
|
||||
client.send_private_msg_to(urls, receiver, content, tags),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "send timeout".to_string())?
|
||||
.map_err(|e| format!("send failed: {e}"))?;
|
||||
Ok(res.val.to_hex())
|
||||
(urls, v3)
|
||||
}
|
||||
|
||||
/// Fetch a contact's kind 10050 DM relay list from our relays.
|
||||
async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> Vec<String> {
|
||||
// Use cached relays first.
|
||||
if let Some(contact) = self.store.contact(&pk.to_hex()) {
|
||||
if !contact.relays.is_empty() {
|
||||
return contact.relays.into_iter().take(MAX_DM_RELAYS).collect();
|
||||
/// Fetch a contact's kind 10050 DM relay list plus their advertised
|
||||
/// NIP-44 v3 capability (the `encryption` tag of the same event). Queries
|
||||
/// our own relays AND the pool's discovery indexers — the recipient's
|
||||
/// 10050 lives on their relays and the indexers, not necessarily on
|
||||
/// anything we share. Both facts are cached on the contact together.
|
||||
async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> (Vec<String>, bool) {
|
||||
// Use cached relays (and the capability learned with them) first.
|
||||
if let Some(contact) = self.store.contact(&pk.to_hex())
|
||||
&& !contact.relays.is_empty()
|
||||
{
|
||||
return (
|
||||
contact.relays.into_iter().take(MAX_DM_RELAYS).collect(),
|
||||
contact.nip44_v3,
|
||||
);
|
||||
}
|
||||
let mut from = self.relays();
|
||||
for url in crate::nostr::pool::usable_discovery_relays().await {
|
||||
if !from.contains(&url) {
|
||||
from.push(url);
|
||||
}
|
||||
}
|
||||
connect_relays(client, &from).await;
|
||||
let filter = Filter::new().kind(Kind::InboxRelays).author(*pk).limit(1);
|
||||
let mut out = vec![];
|
||||
if let Ok(events) = client.fetch_events(filter, FETCH_TIMEOUT).await {
|
||||
if let Some(event) = events.first() {
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some("relay") {
|
||||
if let Some(url) = parts.get(1) {
|
||||
if out.len() < MAX_DM_RELAYS {
|
||||
out.push(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
let mut v3 = false;
|
||||
// Cap at 10s (not the 30s catch-up FETCH_TIMEOUT): this is on the
|
||||
// interactive send path, so a slow/dead discovery relay must fail fast and
|
||||
// fall back to relay hints + our own set rather than stall the send.
|
||||
if let Ok(events) = client
|
||||
.fetch_events_from(&from, filter, Duration::from_secs(10))
|
||||
.await && let Some(event) = events.first()
|
||||
{
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
match parts.first().map(|s| s.as_str()) {
|
||||
Some("relay") => {
|
||||
if let Some(url) = parts.get(1)
|
||||
&& out.len() < MAX_DM_RELAYS
|
||||
{
|
||||
out.push(url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
Some("encryption") => {
|
||||
v3 = wrapv3::peer_supports_v3(parts.get(1).map(|s| s.as_str()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cache discovered relays on the contact when present.
|
||||
if !out.is_empty() {
|
||||
if let Some(mut contact) = self.store.contact(&pk.to_hex()) {
|
||||
contact.relays = out.clone();
|
||||
self.store.save_contact(&contact);
|
||||
}
|
||||
// Cache discovered relays + capability on the contact when present.
|
||||
if !out.is_empty()
|
||||
&& let Some(mut contact) = self.store.contact(&pk.to_hex())
|
||||
{
|
||||
contact.relays = out.clone();
|
||||
contact.nip44_v3 = v3;
|
||||
self.store.save_contact(&contact);
|
||||
}
|
||||
out
|
||||
(out, v3)
|
||||
}
|
||||
|
||||
/// Ensure a contact entry exists for a sender (auto-added as unknown).
|
||||
@@ -619,6 +710,7 @@ impl NostrService {
|
||||
nip05: None,
|
||||
nip05_verified_at: None,
|
||||
relays: vec![],
|
||||
nip44_v3: false,
|
||||
hue,
|
||||
unknown: true,
|
||||
added_at: unix_time(),
|
||||
@@ -733,32 +825,22 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
*svc.rt_handle.write() = Some(tokio::runtime::Handle::current());
|
||||
// Mirror the configured name authority so resolution + display follow it.
|
||||
crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain());
|
||||
let relays = svc.relays();
|
||||
info!(
|
||||
"nostr: starting service for {} with relays {:?}",
|
||||
svc.npub(),
|
||||
relays
|
||||
);
|
||||
|
||||
let client = Client::builder()
|
||||
.signer(svc.keys.clone())
|
||||
.websocket_transport(NymWebSocketTransport)
|
||||
.build();
|
||||
for relay in &relays {
|
||||
if let Err(e) = client.add_relay(relay.clone()).await {
|
||||
warn!("nostr: add relay {relay} failed: {e}");
|
||||
}
|
||||
}
|
||||
// Wait for the in-process Nym SOCKS5 proxy (:1080) before dialing relays.
|
||||
// `warm_up()` starts it at launch, but a fast wallet-open can beat the cold
|
||||
// mixnet bootstrap — and dialing before it's up drops every relay into
|
||||
// nostr-sdk's backing-off reconnect, leaving the wallet on "Connecting…" long
|
||||
// after the mixnet is actually ready. Once it's warm this returns immediately.
|
||||
// Wait for the in-process Nym mixnet tunnel before any network work
|
||||
// (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at
|
||||
// launch, but a fast wallet-open can beat the cold mixnet bootstrap — and
|
||||
// dialing before it's up drops every relay into nostr-sdk's backing-off
|
||||
// reconnect, leaving the wallet on "Connecting…" long after the mixnet is
|
||||
// actually ready. Once it's warm this returns immediately.
|
||||
for i in 0..60u32 {
|
||||
if nym_socks_ready().await {
|
||||
if crate::nym::is_ready() {
|
||||
if i > 0 {
|
||||
info!(
|
||||
"nostr: Nym proxy ready after ~{}ms, dialing relays",
|
||||
"nostr: Nym tunnel ready after ~{}ms, dialing relays",
|
||||
i * 500
|
||||
);
|
||||
}
|
||||
@@ -766,6 +848,75 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
// We are now a relay consumer: arm nymproc's relay-reachability governance of
|
||||
// exit health for our lifetime, so a DNS-ok-but-relay-dead exit gets
|
||||
// condemned. Disarmed when the loop exits (see below), so plain HTTP-only
|
||||
// usage of the tunnel never condemns an otherwise-healthy exit.
|
||||
crate::nym::set_relay_consumer(true);
|
||||
// Refresh the relay candidate pool cache (gist over Nym) when stale.
|
||||
tokio::spawn(crate::nostr::pool::refresh_if_stale());
|
||||
// Select this identity's advertised relay set if it hasn't one yet.
|
||||
ensure_advertised_set(&svc).await;
|
||||
|
||||
let relays = svc.relays();
|
||||
info!(
|
||||
"nostr: starting service for {} with relays {:?}",
|
||||
svc.npub(),
|
||||
relays
|
||||
);
|
||||
// Prewarm mix-dns for the hosts we're about to (or will soon) hit — the
|
||||
// relays being dialed, the NIP-05 name authority (Claim username), and the
|
||||
// price API — so those resolutions are already cached by the time the user
|
||||
// acts, rather than each paying a cold mixnet round trip inline. The node host
|
||||
// is NOT here — it never rides the mixnet.
|
||||
//
|
||||
// Unlike before this no longer silently SKIPS when the tunnel isn't up yet
|
||||
// (the cold-start case that used to leave the first relay dial to a cold DoT
|
||||
// round trip): it WAITS for the tunnel, prewarms, then keeps the entries hot
|
||||
// by re-prewarming on a cadence below the DNS cache TTL floor, so known/stable
|
||||
// hosts are refreshed in the background before they can expire.
|
||||
{
|
||||
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();
|
||||
// The name authority, both from this service's config and the process-wide
|
||||
// configured home domain (they're normally the same; dedup below folds it).
|
||||
hosts.push(svc.config.read().home_domain());
|
||||
hosts.push(crate::nostr::nip05::home_domain());
|
||||
hosts.push("api.coingecko.com".to_string());
|
||||
hosts.retain(|h| !h.is_empty());
|
||||
hosts.sort();
|
||||
hosts.dedup();
|
||||
tokio::spawn(async move {
|
||||
// Wait out the cold start rather than skipping the prewarm entirely.
|
||||
let Some(tunnel) = crate::nym::nymproc::wait_for_tunnel(Duration::from_secs(60)).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
crate::nym::dns::prewarm(&tunnel, &hosts).await;
|
||||
// Keep the entries warm: re-prewarm every 45s (below the 60s TTL
|
||||
// floor) so a stable host never expires out of the cache between
|
||||
// uses. Picks up the current tunnel each cycle, so it survives exit
|
||||
// reselects.
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(45)).await;
|
||||
if let Some(t) = crate::nym::nymproc::tunnel() {
|
||||
crate::nym::dns::prewarm(&t, &hosts).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for relay in &relays {
|
||||
if let Err(e) = client.add_relay(relay.clone()).await {
|
||||
warn!("nostr: add relay {relay} failed: {e}");
|
||||
}
|
||||
}
|
||||
// The tunnel generation these relays are being dialed on. If the exit is
|
||||
// later reselected (generation bumped by nymproc), the status loop drops
|
||||
// these now-dead sockets and re-dials through the fresh tunnel.
|
||||
let mut dial_gen = crate::nym::tunnel_generation();
|
||||
let connect_started = std::time::Instant::now();
|
||||
client.connect().await;
|
||||
{
|
||||
@@ -778,6 +929,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
{
|
||||
let client_probe = client.clone();
|
||||
let svc_probe = svc.clone();
|
||||
let report_gen = dial_gen;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
@@ -786,6 +938,27 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
"nostr: first relay Connected ~{}ms after connect()",
|
||||
connect_started.elapsed().as_millis()
|
||||
);
|
||||
// Flip the UI "Connected" flag on the REAL relay-up signal
|
||||
// (~2-4s over the exit) instead of gating it behind
|
||||
// publish_identity + the up-to-30s catch-up fetch below: those are
|
||||
// receive-side housekeeping and keep running in the background,
|
||||
// while the relay is already usable the moment it reaches
|
||||
// Connected. Without this, one relay slow to EOSE pinned the
|
||||
// indicator on "Connecting relays…" for ~30s even though the
|
||||
// connection was live in ~2-4s.
|
||||
//
|
||||
// Accepted tradeoff: between here and the 2s status loop taking
|
||||
// over, a relay DROP wouldn't flip the flag back for up to ~30s
|
||||
// (until the post-catch-up re-check re-syncs it to reality) — the
|
||||
// same-order staleness as the old pessimistic gap, just optimistic
|
||||
// instead. The transport watchdog (nymproc) still tracks real exit
|
||||
// health independently of this UI flag.
|
||||
svc_probe.connected.store(true, Ordering::Relaxed);
|
||||
// FAST relay-live report: closes nymproc's relay-readiness
|
||||
// window as soon as the exit is proven to carry relay traffic,
|
||||
// independent of the up-to-30s catch-up fetch below (a slow
|
||||
// catch-up must not get a good exit wrongly condemned).
|
||||
crate::nym::report_relay_live(report_gen);
|
||||
return;
|
||||
}
|
||||
if svc_probe.shutdown.load(Ordering::SeqCst)
|
||||
@@ -804,7 +977,10 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
// Publish identity events (kind 10050 DM relays; kind 0 only when named).
|
||||
publish_identity(&svc, &client).await;
|
||||
|
||||
// Catch-up + live subscription for our gift wraps.
|
||||
// Catch-up + live subscription for our gift wraps — targeted at our OWN
|
||||
// advertised set only. A pool-wide subscription would be inherited by
|
||||
// relays added later for sends and discovery fan-out, handing them a REQ
|
||||
// filter that names our pubkey as a listener.
|
||||
let since = svc
|
||||
.store
|
||||
.last_connected_at()
|
||||
@@ -816,13 +992,26 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
.pubkey(svc.public_key())
|
||||
.since(Timestamp::from_secs(since));
|
||||
|
||||
if let Ok(events) = client.fetch_events(filter.clone(), FETCH_TIMEOUT).await {
|
||||
if let Ok(events) = client
|
||||
.fetch_events_from(&relays, filter.clone(), FETCH_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
info!("nostr: catch-up fetched {} wraps", events.len());
|
||||
for event in events.into_iter() {
|
||||
handle_wrap(&svc, &wallet, &client, event).await;
|
||||
handle_wrap(&svc, &wallet, event).await;
|
||||
}
|
||||
}
|
||||
if let Err(e) = client.subscribe(filter, None).await {
|
||||
// Stable-id subscription so a re-subscribe after a tunnel reselect replaces
|
||||
// rather than duplicates it. Keep `filter` owned for that re-subscribe.
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id_to(
|
||||
&relays,
|
||||
SubscriptionId::new(GIFTWRAP_SUB),
|
||||
filter.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("nostr: subscribe failed: {e}");
|
||||
}
|
||||
|
||||
@@ -843,8 +1032,14 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
// Reflect the connection the moment we reach the loop instead of leaving the
|
||||
// UI on "Connecting…" until the first heartbeat — by now catch-up has run, so
|
||||
// a relay is typically already up.
|
||||
svc.connected
|
||||
.store(relays_connected(&client).await, Ordering::Relaxed);
|
||||
let connected = relays_connected(&client).await;
|
||||
svc.connected.store(connected, Ordering::Relaxed);
|
||||
// Feed the relay-gated readiness signal so "Connected over Nym" reflects an
|
||||
// actual connected+subscribed relay on THIS tunnel generation, not merely a
|
||||
// warm tunnel — and so nymproc's relay-readiness window closes successfully.
|
||||
if connected {
|
||||
crate::nym::report_relay_live(dial_gen);
|
||||
}
|
||||
|
||||
let mut notifications = client.notifications();
|
||||
// Poll connection state on a SHORT, INDEPENDENT interval. This used to live in
|
||||
@@ -870,7 +1065,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
notification = notifications.recv() => {
|
||||
match notification {
|
||||
Ok(RelayPoolNotification::Event { event, .. }) => {
|
||||
handle_wrap(&svc, &wallet, &client, *event).await;
|
||||
handle_wrap(&svc, &wallet, *event).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
@@ -880,8 +1075,28 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
}
|
||||
}
|
||||
_ = status_tick.tick() => {
|
||||
svc.connected
|
||||
.store(relays_connected(&client).await, Ordering::Relaxed);
|
||||
// A tunnel reselect (new exit) bumps the generation. The current
|
||||
// relay sockets rode the now-dead exit, so drop them and re-dial
|
||||
// through the fresh tunnel, re-establishing the kind:1059
|
||||
// subscription — a reselect thus transparently restores
|
||||
// receive+send. (An individual relay bounce with the exit still
|
||||
// healthy is left to nostr-sdk's own auto-reconnect + resubscribe.)
|
||||
let generation = crate::nym::tunnel_generation();
|
||||
if generation != dial_gen {
|
||||
info!("nostr: tunnel reselected (gen {dial_gen} -> {generation}); re-dialing relays over the new exit");
|
||||
redial_on_new_tunnel(&client, &relays, &filter).await;
|
||||
dial_gen = generation;
|
||||
}
|
||||
let connected = relays_connected(&client).await;
|
||||
svc.connected.store(connected, Ordering::Relaxed);
|
||||
// Relay-gated readiness + exit-health feedback for THIS generation:
|
||||
// a live relay closes/keeps-open nymproc's readiness window; all
|
||||
// relays down for too long condemns the exit and reselects.
|
||||
if connected {
|
||||
crate::nym::report_relay_live(dial_gen);
|
||||
} else {
|
||||
crate::nym::report_relay_down(dial_gen);
|
||||
}
|
||||
let now = unix_time();
|
||||
if now - last_heartbeat >= 30 {
|
||||
last_heartbeat = now;
|
||||
@@ -922,6 +1137,9 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
}
|
||||
}
|
||||
|
||||
// No longer a relay consumer: disarm relay-reachability governance so the
|
||||
// idle tunnel isn't condemned for "no relay" once we stop dialing.
|
||||
crate::nym::set_relay_consumer(false);
|
||||
{
|
||||
let mut w_client = svc.client.write();
|
||||
*w_client = None;
|
||||
@@ -929,19 +1147,6 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
client.disconnect().await;
|
||||
}
|
||||
|
||||
/// Quick, non-blocking check that the Nym SOCKS5 proxy is accepting
|
||||
/// connections on its loopback port (i.e. the mixnet is ready to carry traffic).
|
||||
async fn nym_socks_ready() -> bool {
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
tokio::net::TcpStream::connect(crate::nym::socks5_addr()),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// Add + dial every relay in `urls` so a targeted send reaches relays we don't
|
||||
/// already hold (NIP-65/gossip: the recipient's relays may differ from ours).
|
||||
/// `add_relay` is idempotent and `try_connect_relay` returns once connected or
|
||||
@@ -960,6 +1165,33 @@ async fn connect_relays(client: &Client, urls: &[String]) {
|
||||
futures::future::join_all(dials).await;
|
||||
}
|
||||
|
||||
/// A tunnel reselect happened: the pool's relay sockets rode the now-dead exit.
|
||||
/// Drop them and re-dial every required relay through the fresh tunnel, then
|
||||
/// re-establish the kind:1059 gift-wrap subscription (same stable id → replaces,
|
||||
/// never duplicates) so we never silently stop receiving. Bounded by
|
||||
/// nostr-sdk's own connect timeouts — no busy loop; the generation-aware re-dial
|
||||
/// is ours, the per-relay reconnect backoff is the pool's.
|
||||
async fn redial_on_new_tunnel(client: &Client, relays: &[String], filter: &Filter) {
|
||||
// Close the stale sockets so nostr-sdk re-dials through the current tunnel
|
||||
// (the transport grabs the freshly-selected exit on each new connect).
|
||||
client.disconnect().await;
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
}
|
||||
client.connect().await;
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id_to(
|
||||
relays,
|
||||
SubscriptionId::new(GIFTWRAP_SUB),
|
||||
filter.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("nostr: re-subscribe after reselect failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one relay has completed its handshake.
|
||||
async fn relays_connected(client: &Client) -> bool {
|
||||
client
|
||||
@@ -969,18 +1201,73 @@ async fn relays_connected(client: &Client) -> bool {
|
||||
.any(|r| r.status() == RelayStatus::Connected)
|
||||
}
|
||||
|
||||
/// Publish kind 10050 DM relay list and, for named identities, kind 0 metadata.
|
||||
/// One-time advertised-set selection: the Goblin relay plus up to two pool
|
||||
/// "dm" relays, weighted-random (vetted entries 3:1), each gated by a NIP-11
|
||||
/// probe at pick time so only relays about to be used are probed. Persisted
|
||||
/// on the identity and sticky thereafter — no timer rotation, since 10050
|
||||
/// churn breaks payers' cached routing. A user relay override in nostr.toml
|
||||
/// disables selection entirely. When no pool relay passes (e.g. offline),
|
||||
/// nothing is persisted and the built-in defaults serve this session;
|
||||
/// selection retries next start.
|
||||
async fn ensure_advertised_set(svc: &Arc<NostrService>) {
|
||||
use crate::nostr::pool;
|
||||
use crate::nostr::relays::DEFAULT_RELAYS;
|
||||
use rand::Rng;
|
||||
if svc.config.read().relays_override().is_some() || !svc.identity.read().dm_relays.is_empty() {
|
||||
return;
|
||||
}
|
||||
let goblin = DEFAULT_RELAYS[0];
|
||||
let candidates = pool::load().dm_relays();
|
||||
let order = pool::weighted_order(goblin, &candidates, |total| {
|
||||
rand::rng().random_range(0..total.max(1))
|
||||
});
|
||||
let mut set = vec![goblin.to_string()];
|
||||
for url in order.into_iter().skip(1) {
|
||||
if set.len() >= MAX_DM_RELAYS {
|
||||
break;
|
||||
}
|
||||
if pool::probe(&url).await {
|
||||
set.push(url);
|
||||
}
|
||||
}
|
||||
if set.len() < 2 {
|
||||
warn!("nostr: no pool relay passed vetting, keeping default relays for now");
|
||||
return;
|
||||
}
|
||||
info!("nostr: selected advertised relay set {:?}", set);
|
||||
svc.identity.write().dm_relays = set;
|
||||
svc.save_identity();
|
||||
}
|
||||
|
||||
/// Publish the replaceable identity events — the kind 10050 DM relay list,
|
||||
/// its kind 10002 (NIP-65) mirror, and kind 0 metadata for named identities —
|
||||
/// to the advertised set, then fan the SAME events out to the pool's
|
||||
/// discovery indexers so payers who share no relay with us can still find our
|
||||
/// inbox list. The fan-out is additive and publish-only: we never subscribe
|
||||
/// on discovery relays.
|
||||
async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
|
||||
let relays = svc.relays();
|
||||
let dm_tags: Vec<Tag> = relays
|
||||
let advertised: Vec<String> = svc.relays().into_iter().take(MAX_DM_RELAYS).collect();
|
||||
|
||||
let mut dm_tags: Vec<Tag> = advertised
|
||||
.iter()
|
||||
.take(MAX_DM_RELAYS)
|
||||
.map(|r| Tag::custom(TagKind::custom("relay"), [r.clone()]))
|
||||
.collect();
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags);
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
warn!("nostr: publish 10050 failed: {e}");
|
||||
}
|
||||
// NIP-17 backward-compat extension: advertise our NIP-44 capabilities,
|
||||
// space-separated best-first, so v3-aware senders pick v3 (G4).
|
||||
dm_tags.push(Tag::custom(
|
||||
TagKind::custom("encryption"),
|
||||
[wrapv3::ENCRYPTION_CAPABILITY.to_string()],
|
||||
));
|
||||
let mut builders = vec![
|
||||
EventBuilder::new(Kind::InboxRelays, "").tags(dm_tags),
|
||||
// The NIP-65 list mirrors the same set, unmarked (read + write).
|
||||
EventBuilder::relay_list(
|
||||
advertised
|
||||
.iter()
|
||||
.filter_map(|r| nostr_sdk::RelayUrl::parse(r).ok())
|
||||
.map(|u| (u, None)),
|
||||
),
|
||||
];
|
||||
|
||||
let (anonymous, nip05) = {
|
||||
let identity = svc.identity.read();
|
||||
@@ -995,12 +1282,52 @@ async fn publish_identity(svc: &Arc<NostrService>, client: &Client) {
|
||||
.name(name)
|
||||
.nip05(nip05)
|
||||
.custom_field("goblin_accepts_requests", allow_requests);
|
||||
let builder = EventBuilder::metadata(&metadata);
|
||||
if let Err(e) = client.send_event_builder(builder).await {
|
||||
warn!("nostr: publish kind 0 failed: {e}");
|
||||
}
|
||||
builders.push(EventBuilder::metadata(&metadata));
|
||||
}
|
||||
}
|
||||
|
||||
// Sign each event ONCE so the advertised set and the indexers receive the
|
||||
// same replaceable event, and sends stay targeted (a plain send would also
|
||||
// hit whatever recipient relays happen to be connected).
|
||||
let mut events = vec![];
|
||||
for builder in builders {
|
||||
match client.sign_event_builder(builder).await {
|
||||
Ok(event) => events.push(event),
|
||||
Err(e) => warn!("nostr: identity event signing failed: {e}"),
|
||||
}
|
||||
}
|
||||
for event in &events {
|
||||
// Time-box each publish (mirrors dispatch_dm's SEND_TIMEOUT): this loop is
|
||||
// awaited before the catch-up fetch and the kind:1059 subscription below, so
|
||||
// an untimed send to a stalled relay would delay real incoming-message
|
||||
// delivery. On timeout, warn and move on to the next event — never abort the
|
||||
// identity sequence.
|
||||
match tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&advertised, event)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => warn!("nostr: publish kind {} failed: {e}", event.kind),
|
||||
Err(_) => warn!("nostr: publish kind {} timed out", event.kind),
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery fan-out off the caller's path: each indexer is gated by the
|
||||
// lazy NIP-11 probe (over Nym) before use.
|
||||
let client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
let targets: Vec<String> = crate::nostr::pool::usable_discovery_relays()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|u| !advertised.contains(u))
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
connect_relays(&client, &targets).await;
|
||||
for event in &events {
|
||||
if let Err(e) = client.send_event_to(&targets, event).await {
|
||||
warn!("nostr: discovery publish kind {} failed: {e}", event.kind);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A transaction in a terminal state never expires (already done or canceled).
|
||||
@@ -1157,7 +1484,7 @@ fn handle_request_void(svc: &Arc<NostrService>, wallet: &Wallet, slate_id: &str,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client, event: Event) {
|
||||
async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, event: Event) {
|
||||
// 0. Only gift wraps.
|
||||
if event.kind != Kind::GiftWrap {
|
||||
return;
|
||||
@@ -1178,8 +1505,10 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
if !svc.allow_global_unwrap() {
|
||||
return;
|
||||
}
|
||||
// 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed).
|
||||
let unwrapped = match client.unwrap_gift_wrap(&event).await {
|
||||
// 3. Unwrap (NIP-59: seal signature is verified, rumor must not be signed),
|
||||
// dispatched on the NIP-44 payload version byte: 0x02 = the unchanged
|
||||
// nostr-sdk path, 0x03 = the nip44 crate (G4); anything else errors cleanly.
|
||||
let unwrapped = match wrapv3::unwrap(&svc.keys, &event).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
@@ -1323,6 +1652,10 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
// Resolve the sender's @username so the receive shows their name in
|
||||
// activity, not a bare npub.
|
||||
svc.resolve_contact_identity(&sender_hex);
|
||||
// A payment is arriving: un-pause on-demand node polling BEFORE the
|
||||
// receive so confirmation tracking is never dropped — polling stays
|
||||
// live until the tx confirms (see `maybe_pause_node_polling`).
|
||||
wallet.resume_node_polling();
|
||||
match wallet.nostr_receive(&slate) {
|
||||
Ok((_, reply_text)) => {
|
||||
// Record BEFORE dispatching the reply: crash here is
|
||||
@@ -1347,6 +1680,15 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
svc.store.mark_processed(&rumor_id);
|
||||
svc.store.mark_processed(&slate_marker);
|
||||
// "Payment received" system notification (Android; no-op
|
||||
// on desktop): payer's display name (or short npub) and
|
||||
// the human-readable amount.
|
||||
{
|
||||
let name =
|
||||
crate::gui::views::goblin::data::contact_title(&svc.store, &sender_hex);
|
||||
let amount = amount_to_hr_string(slate.amount, true);
|
||||
crate::notify_payment_received(&name, &amount);
|
||||
}
|
||||
match svc
|
||||
.send_payment_dm(&sender_hex, &reply_text, None, &[])
|
||||
.await
|
||||
@@ -1388,6 +1730,19 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
svc.store.mark_processed(&rumor_id);
|
||||
svc.store.mark_processed(&slate_marker);
|
||||
// "Payment requested" system notification (Android; no-op on
|
||||
// desktop): only for a genuine incoming request (Invoice1 →
|
||||
// SurfaceRequest, someone asking us to pay them), not a payment
|
||||
// pending approval (SurfaceIncoming). Fires exactly once — this
|
||||
// branch is reached only for a not-yet-seen slate (slate-level
|
||||
// dedupe above + decide() drops already-known slates), mirroring the
|
||||
// received-payment notification's dedup. Requester's display name
|
||||
// (or short npub) and the human-readable amount, with the ツ mark.
|
||||
if decision == IngestDecision::SurfaceRequest {
|
||||
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_requested(&name, &amount);
|
||||
}
|
||||
}
|
||||
IngestDecision::FinalizePost => {
|
||||
// The payer's reply is our first contact with their key on this side of
|
||||
@@ -1395,6 +1750,10 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
// @username so the completed request shows their name, not a bare npub.
|
||||
svc.ensure_contact(&sender_hex);
|
||||
svc.resolve_contact_identity(&sender_hex);
|
||||
// Node work ahead (finalize + broadcast + confirm): un-pause
|
||||
// on-demand node polling BEFORE it so confirmation tracking is
|
||||
// never dropped.
|
||||
wallet.resume_node_polling();
|
||||
match wallet.nostr_finalize_post(&slate) {
|
||||
Ok(true) => {
|
||||
svc.store
|
||||
@@ -1456,6 +1815,7 @@ mod tests {
|
||||
nip05: Some("ada@goblin.st".to_string()),
|
||||
nip05_verified_at: Some(1000),
|
||||
relays: vec![],
|
||||
nip44_v3: false,
|
||||
hue: 0,
|
||||
unknown: false,
|
||||
added_at: 1,
|
||||
|
||||
@@ -32,7 +32,7 @@ pub enum AcceptPolicy {
|
||||
}
|
||||
|
||||
/// Per-wallet nostr configuration.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct NostrConfig {
|
||||
/// Whether the nostr subsystem runs for this wallet.
|
||||
enabled: Option<bool>,
|
||||
@@ -59,21 +59,6 @@ pub struct NostrConfig {
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for NostrConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: None,
|
||||
relays: None,
|
||||
accept_from: None,
|
||||
nip05_server: None,
|
||||
expiry_secs: None,
|
||||
cancel_grace_secs: None,
|
||||
allow_incoming_requests: None,
|
||||
path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NostrConfig {
|
||||
/// Nostr configuration file name inside the wallet directory.
|
||||
pub const FILE_NAME: &'static str = "nostr.toml";
|
||||
@@ -104,12 +89,16 @@ impl NostrConfig {
|
||||
}
|
||||
|
||||
pub fn relays(&self) -> Vec<String> {
|
||||
self.relays
|
||||
.clone()
|
||||
.filter(|r| !r.is_empty())
|
||||
self.relays_override()
|
||||
.unwrap_or_else(|| DEFAULT_RELAYS.iter().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
/// The relay list explicitly set by the user in nostr.toml, if any. An
|
||||
/// override disables the per-identity advertised-set selection entirely.
|
||||
pub fn relays_override(&self) -> Option<Vec<String>> {
|
||||
self.relays.clone().filter(|r| !r.is_empty())
|
||||
}
|
||||
|
||||
pub fn set_relays(&mut self, relays: Vec<String>) {
|
||||
self.relays = Some(relays);
|
||||
self.save();
|
||||
|
||||
@@ -12,24 +12,24 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Per-wallet nostr identity: NIP-06 derived from the wallet mnemonic by
|
||||
//! default (one seed restores money AND identity) or imported from an nsec.
|
||||
//! Per-wallet nostr identity: a random standalone nsec (or an imported one),
|
||||
//! deliberately independent of the wallet seed — the seed proves nothing about
|
||||
//! the identity and cannot resurrect it; the nsec is its own backup.
|
||||
//! Stored at rest as NIP-49 ncryptsec encrypted with the wallet password.
|
||||
|
||||
use nostr_sdk::nips::nip44;
|
||||
use nostr_sdk::nips::nip49::{EncryptedSecretKey, KeySecurity};
|
||||
use nostr_sdk::prelude::FromMnemonic;
|
||||
use nostr_sdk::{FromBech32, Keys, SecretKey, ToBech32};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Where the keys came from.
|
||||
/// Where the keys came from. The legacy NIP-06 `Derived` source is gone: a
|
||||
/// pre-Build-8 `"source":"Derived"` file no longer parses, so `load()` returns
|
||||
/// `None` and wallet init writes a fresh random identity (the wanted behavior;
|
||||
/// binding a messaging identity to the money seed was the dangerous design).
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum IdentitySource {
|
||||
/// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the
|
||||
/// identity to the seed forever).
|
||||
Derived,
|
||||
/// Imported nsec.
|
||||
Imported,
|
||||
/// Freshly generated random key, independent of the wallet seed: the
|
||||
@@ -42,8 +42,6 @@ pub enum IdentitySource {
|
||||
pub struct NostrIdentity {
|
||||
pub ver: u8,
|
||||
pub source: IdentitySource,
|
||||
/// NIP-06 account index used for derivation.
|
||||
pub derivation_account: u32,
|
||||
/// NIP-49 encrypted secret key (bech32 ncryptsec).
|
||||
pub ncryptsec: String,
|
||||
/// Public key, bech32 npub (plaintext so the UI can render pre-unlock).
|
||||
@@ -55,6 +53,12 @@ pub struct NostrIdentity {
|
||||
/// Previous npubs from key rotations (newest last), for reference.
|
||||
#[serde(default)]
|
||||
pub prev_npubs: Vec<String>,
|
||||
/// Advertised DM relays (kind 10050): the Goblin relay plus 1-2 pool
|
||||
/// relays picked once for this identity and kept sticky — no timer
|
||||
/// rotation, since 10050 churn breaks payers' cached routing. Empty until
|
||||
/// the first service start selects them.
|
||||
#[serde(default)]
|
||||
pub dm_relays: Vec<String>,
|
||||
}
|
||||
|
||||
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
|
||||
@@ -142,24 +146,6 @@ impl NostrIdentity {
|
||||
let _ = fs::remove_file(Self::path(nostr_dir));
|
||||
}
|
||||
|
||||
/// Derive keys from a BIP-39 mnemonic phrase via NIP-06.
|
||||
pub fn derive_keys(mnemonic: &str, account: u32) -> Result<Keys, IdentityError> {
|
||||
Keys::from_mnemonic_with_account(mnemonic, None, Some(account))
|
||||
.map_err(|e| IdentityError::Key(format!("{e}")))
|
||||
}
|
||||
|
||||
/// Create a derived identity from the wallet mnemonic, encrypting the
|
||||
/// secret key with the wallet password.
|
||||
pub fn create_derived(
|
||||
mnemonic: &str,
|
||||
password: &str,
|
||||
account: u32,
|
||||
) -> Result<(NostrIdentity, Keys), IdentityError> {
|
||||
let keys = Self::derive_keys(mnemonic, account)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Derived, account)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
/// Build an identity from already-unlocked keys under a (possibly
|
||||
/// different) password — used when importing a backup that was exported
|
||||
/// under another wallet's password.
|
||||
@@ -167,15 +153,14 @@ impl NostrIdentity {
|
||||
keys: &Keys,
|
||||
password: &str,
|
||||
source: IdentitySource,
|
||||
account: u32,
|
||||
) -> Result<NostrIdentity, IdentityError> {
|
||||
Self::from_keys(keys, password, source, account)
|
||||
Self::from_keys(keys, password, source)
|
||||
}
|
||||
|
||||
/// Create a brand-new random identity, independent of the wallet seed.
|
||||
pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> {
|
||||
let keys = Keys::generate();
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Random)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
@@ -187,7 +172,7 @@ impl NostrIdentity {
|
||||
let secret = SecretKey::parse(nsec.trim())
|
||||
.map_err(|e| IdentityError::Key(format!("invalid nsec: {e}")))?;
|
||||
let keys = Keys::new(secret);
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Imported, 0)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Imported)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
@@ -195,7 +180,6 @@ impl NostrIdentity {
|
||||
keys: &Keys,
|
||||
password: &str,
|
||||
source: IdentitySource,
|
||||
account: u32,
|
||||
) -> Result<NostrIdentity, IdentityError> {
|
||||
let encrypted = EncryptedSecretKey::new(
|
||||
keys.secret_key(),
|
||||
@@ -214,12 +198,12 @@ impl NostrIdentity {
|
||||
Ok(NostrIdentity {
|
||||
ver: 1,
|
||||
source,
|
||||
derivation_account: account,
|
||||
ncryptsec,
|
||||
npub,
|
||||
nip05: None,
|
||||
anonymous: true,
|
||||
prev_npubs: Vec::new(),
|
||||
dm_relays: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -318,13 +302,7 @@ mod tests {
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
let parsed: NostrIdentity = serde_json::from_str(&json).unwrap();
|
||||
let keys = parsed.unlock("old-pw").unwrap();
|
||||
let b = NostrIdentity::from_unlocked_keys(
|
||||
&keys,
|
||||
"new-pw",
|
||||
parsed.source,
|
||||
parsed.derivation_account,
|
||||
)
|
||||
.unwrap();
|
||||
let b = NostrIdentity::from_unlocked_keys(&keys, "new-pw", parsed.source).unwrap();
|
||||
assert_eq!(b.npub, a.npub);
|
||||
assert!(b.unlock("new-pw").is_ok());
|
||||
assert!(b.unlock("old-pw").is_err());
|
||||
@@ -364,21 +342,10 @@ mod tests {
|
||||
assert!(a.unlock("wrong").is_err());
|
||||
}
|
||||
|
||||
// NIP-06 test vector: this mnemonic must derive this npub (account 0).
|
||||
const NIP06_MNEMONIC: &str =
|
||||
"leader monkey parrot ring guide accident before fence cannon height naive bean";
|
||||
const NIP06_NPUB: &str = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu";
|
||||
|
||||
#[test]
|
||||
fn nip06_derivation_vector() {
|
||||
let keys = NostrIdentity::derive_keys(NIP06_MNEMONIC, 0).unwrap();
|
||||
assert_eq!(keys.public_key().to_bech32().unwrap(), NIP06_NPUB);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_unlock_roundtrip() {
|
||||
let (identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "hunter2", 0).unwrap();
|
||||
assert_eq!(identity.source, IdentitySource::Derived);
|
||||
let (identity, keys) = NostrIdentity::create_random("hunter2").unwrap();
|
||||
assert_eq!(identity.source, IdentitySource::Random);
|
||||
assert!(identity.anonymous);
|
||||
let unlocked = identity.unlock("hunter2").unwrap();
|
||||
assert_eq!(unlocked.public_key(), keys.public_key());
|
||||
@@ -401,7 +368,7 @@ mod tests {
|
||||
fn identity_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("goblin-id-test-{}", std::process::id()));
|
||||
let (identity, _) = NostrIdentity::create_derived(NIP06_MNEMONIC, "pw", 0).unwrap();
|
||||
let (identity, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
identity.save(&dir).unwrap();
|
||||
let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap();
|
||||
// The ncryptsec blob must never be group/world readable.
|
||||
@@ -415,7 +382,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reencrypt_changes_password() {
|
||||
let (mut identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "old", 0).unwrap();
|
||||
let (mut identity, keys) = NostrIdentity::create_random("old").unwrap();
|
||||
identity.reencrypt("old", "new").unwrap();
|
||||
assert!(identity.unlock("old").is_err());
|
||||
assert_eq!(
|
||||
|
||||
@@ -22,6 +22,7 @@ pub use types::*;
|
||||
pub mod config;
|
||||
pub use config::{AcceptPolicy, NostrConfig};
|
||||
|
||||
pub mod pool;
|
||||
pub mod relays;
|
||||
|
||||
mod store;
|
||||
@@ -33,6 +34,8 @@ pub use identity::{IdentitySource, NostrIdentity};
|
||||
pub mod protocol;
|
||||
pub use protocol::*;
|
||||
|
||||
pub mod wrapv3;
|
||||
|
||||
pub mod ingest;
|
||||
pub use ingest::*;
|
||||
|
||||
@@ -41,3 +44,5 @@ pub use client::{NostrProfile, NostrService, send_phase};
|
||||
|
||||
pub mod avatar;
|
||||
pub mod nip05;
|
||||
|
||||
pub mod payuri;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
//! NIP-05 username resolution/verification and goblin.st registration,
|
||||
//! all HTTP routed through the Nym mixnet (the local SOCKS5 proxy). Nothing
|
||||
//! all HTTP routed through the Nym mixnet (the in-process smolmix tunnel). Nothing
|
||||
//! here touches clearnet.
|
||||
|
||||
use base64::Engine;
|
||||
@@ -305,48 +305,6 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically move an owned name to a new pubkey (key rotation).
|
||||
/// Signed by the OLD key; the server enforces ownership and the
|
||||
/// one-name-per-pubkey rule for the target key.
|
||||
pub async fn transfer(
|
||||
server: &str,
|
||||
name: &str,
|
||||
old_keys: &Keys,
|
||||
new_pubkey_hex: &str,
|
||||
) -> Result<(), String> {
|
||||
let server = server.trim_end_matches('/');
|
||||
let url = format!("{}/api/v1/transfer", server);
|
||||
let body = serde_json::json!({
|
||||
"name": name.to_lowercase(),
|
||||
"new_pubkey": new_pubkey_hex,
|
||||
})
|
||||
.to_string();
|
||||
let Some(auth) = nip98_auth(old_keys, &url, "POST", Some(body.as_bytes())) else {
|
||||
return Err("auth event build failed".to_string());
|
||||
};
|
||||
let headers = vec![
|
||||
("Authorization".to_string(), auth),
|
||||
("Content-Type".to_string(), "application/json".to_string()),
|
||||
];
|
||||
let Some(resp) = nym::http_request("POST", url, Some(body), headers).await else {
|
||||
return Err("network unavailable".to_string());
|
||||
};
|
||||
if resp.contains("\"transferred\":true") {
|
||||
return Ok(());
|
||||
}
|
||||
let err = serde_json::from_str::<Value>(&resp)
|
||||
.ok()
|
||||
.and_then(|d| d.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| {
|
||||
if resp.trim().is_empty() {
|
||||
"name server does not support transfer yet".to_string()
|
||||
} else {
|
||||
format!("unexpected response: {}", resp)
|
||||
}
|
||||
});
|
||||
Err(err)
|
||||
}
|
||||
|
||||
/// Release a registered name (NIP-98 authed by the owner).
|
||||
pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), String> {
|
||||
let server = server.trim_end_matches('/');
|
||||
@@ -365,57 +323,6 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a processed avatar PNG for an owned name. Returns the content
|
||||
/// hash on success. NIP-98 payload hashing makes the request replay-proof.
|
||||
pub async fn upload_avatar(
|
||||
server: &str,
|
||||
name: &str,
|
||||
keys: &Keys,
|
||||
png: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
let server = server.trim_end_matches('/');
|
||||
let url = format!("{}/api/v1/avatar/{}", server, urlencode(name));
|
||||
let Some(auth) = nip98_auth(keys, &url, "POST", Some(&png)) else {
|
||||
return Err("couldn't sign the request".to_string());
|
||||
};
|
||||
let headers = vec![
|
||||
("Authorization".to_string(), auth),
|
||||
(
|
||||
"Content-Type".to_string(),
|
||||
"application/octet-stream".to_string(),
|
||||
),
|
||||
];
|
||||
match nym::http_request_bytes("POST", url, Some(png), headers).await {
|
||||
Some((201, raw)) => serde_json::from_slice::<serde_json::Value>(&raw)
|
||||
.ok()
|
||||
.and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from))
|
||||
.ok_or_else(|| "unexpected server response".to_string()),
|
||||
Some((429, _)) => Err("Avatar limit reached — try again tomorrow".to_string()),
|
||||
Some((413, _)) => Err("Image too large".to_string()),
|
||||
Some((422, _)) => Err("That file doesn't look like a usable image".to_string()),
|
||||
Some((code, raw)) => Err(serde_json::from_slice::<serde_json::Value>(&raw)
|
||||
.ok()
|
||||
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
|
||||
.unwrap_or_else(|| format!("server error ({code})"))),
|
||||
None => Err("network unreachable".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the avatar for an owned name.
|
||||
pub async fn delete_avatar(server: &str, name: &str, keys: &Keys) -> Result<(), String> {
|
||||
let server = server.trim_end_matches('/');
|
||||
let url = format!("{}/api/v1/avatar/{}", server, urlencode(name));
|
||||
let Some(auth) = nip98_auth(keys, &url, "DELETE", None) else {
|
||||
return Err("couldn't sign the request".to_string());
|
||||
};
|
||||
let headers = vec![("Authorization".to_string(), auth)];
|
||||
match nym::http_request_bytes("DELETE", url, None, headers).await {
|
||||
Some((200, _)) => Ok(()),
|
||||
Some((code, _)) => Err(format!("server error ({code})")),
|
||||
None => Err("network unreachable".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Public profile probe: `None` = network failure, `Some(None)` = name has
|
||||
/// no avatar (or no such name), `Some(Some(hash))` = avatar content hash.
|
||||
pub async fn fetch_profile(server: &str, name: &str) -> Option<Option<String>> {
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
// 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.
|
||||
|
||||
//! Pay-URI parser for scanned payment QRs.
|
||||
//!
|
||||
//! A GoblinPay checkout QR extends the plain `nostr:` URI with an optional
|
||||
//! amount (and memo):
|
||||
//!
|
||||
//! ```text
|
||||
//! nostr:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
|
||||
//! ```
|
||||
//!
|
||||
//! This module is a PURE, side-effect-free parser over UNTRUSTED scan input.
|
||||
//! It never sends, never resolves — it only extracts a recipient string to
|
||||
//! feed the existing recipient resolver plus a validated amount/memo to
|
||||
//! prefill. Every failure mode degrades to "recipient only, manual amount"
|
||||
//! (fail-closed): a bad amount is dropped, a bad memo is dropped, and a
|
||||
//! non-`nostr:` payload is returned verbatim exactly as the scanner treated it
|
||||
//! before this URI existed.
|
||||
//!
|
||||
//! Trust model: the recipient bech32 is the ONLY trust anchor (verified later
|
||||
//! by the resolver). Amount, memo and any relay hints are untrusted hints.
|
||||
|
||||
use grin_core::core::amount_from_hr_string;
|
||||
|
||||
/// Total scanned-payload byte cap. Anything larger is abuse, not an address.
|
||||
const MAX_URI_LEN: usize = 4096;
|
||||
/// Memo byte cap (post control-strip), display / tx-message only.
|
||||
const MAX_MEMO_BYTES: usize = 256;
|
||||
|
||||
/// A parsed pay-URI. `recipient` is fed to the existing resolver as-is (the
|
||||
/// bech32/name that used to go straight into the search box). `amount` is the
|
||||
/// raw decimal-GRIN string, present only when `amount_from_hr_string` accepted
|
||||
/// it and it is strictly positive. `memo` is already control-stripped and
|
||||
/// length-capped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PayUri {
|
||||
pub recipient: String,
|
||||
pub amount: Option<String>,
|
||||
pub memo: Option<String>,
|
||||
}
|
||||
|
||||
impl PayUri {
|
||||
/// A recipient-only result with no prefilled amount/memo (today's behavior).
|
||||
fn bare(recipient: String) -> Self {
|
||||
PayUri {
|
||||
recipient,
|
||||
amount: None,
|
||||
memo: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a scanned payload into a [`PayUri`]. Pure and total: never panics,
|
||||
/// never performs I/O, always returns a value. On any problem it falls back to
|
||||
/// recipient-only (fail-closed).
|
||||
pub fn parse(scanned: &str) -> PayUri {
|
||||
let text = scanned.trim();
|
||||
|
||||
// Fail closed on clear abuse: oversize payload or an embedded NUL. Return
|
||||
// nothing usable rather than feeding a hostile blob to the resolver.
|
||||
if text.len() > MAX_URI_LEN || text.as_bytes().contains(&0) {
|
||||
return PayUri::bare(String::new());
|
||||
}
|
||||
|
||||
// Strict scheme: only the `nostr:` prefix (case-insensitive) unlocks
|
||||
// amount/memo parsing, matching the scanner's existing strip logic. Any
|
||||
// other payload (a bare npub, or some other scheme) is returned verbatim,
|
||||
// exactly as the scanner treated it before pay-URIs existed.
|
||||
let rest = match strip_nostr_prefix(text) {
|
||||
Some(rest) => rest,
|
||||
None => return PayUri::bare(text.to_string()),
|
||||
};
|
||||
|
||||
// Split `<recipient>?<query>`. A bare `nostr:<nprofile>` has no `?`, so the
|
||||
// whole remainder is the recipient — identical to the pre-URI behavior.
|
||||
let (recipient, query) = match rest.split_once('?') {
|
||||
Some((r, q)) => (r.to_string(), Some(q)),
|
||||
None => (rest.to_string(), None),
|
||||
};
|
||||
|
||||
let mut amount = None;
|
||||
let mut memo = None;
|
||||
if let Some(query) = query {
|
||||
for pair in query.split('&') {
|
||||
let Some((key, val)) = pair.split_once('=') else {
|
||||
continue; // valueless / malformed segment — ignore
|
||||
};
|
||||
match key {
|
||||
// First occurrence wins; later duplicates are ignored so a
|
||||
// second `amount=` can't override a validated one.
|
||||
"amount" if amount.is_none() => amount = validate_amount(val),
|
||||
"memo" if memo.is_none() => memo = validate_memo(val),
|
||||
// Unknown params are ignored for forward-compat.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PayUri {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip a case-insensitive `nostr:` scheme prefix, returning the remainder.
|
||||
/// Byte-safe against a leading multibyte char (no `[..6]` slice panic).
|
||||
fn strip_nostr_prefix(text: &str) -> Option<&str> {
|
||||
let head = text.get(..6)?;
|
||||
if head.eq_ignore_ascii_case("nostr:") {
|
||||
Some(&text[6..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an `amount` value: percent-decode, then accept it ONLY if the
|
||||
/// wallet's own `amount_from_hr_string` parses it to a strictly positive
|
||||
/// atomic amount. Never custom float parsing; any error → `None` (fall back to
|
||||
/// manual entry). Returns the clean decoded decimal string on success.
|
||||
fn validate_amount(raw: &str) -> Option<String> {
|
||||
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
|
||||
match amount_from_hr_string(&decoded) {
|
||||
Ok(atomic) if atomic > 0 => Some(decoded),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a `memo` value: percent-decode, strip ASCII control chars and
|
||||
/// newlines (untrusted free text — display / tx-message only, never a path or
|
||||
/// route), then hard-cap at [`MAX_MEMO_BYTES`] on a UTF-8 boundary. Empty →
|
||||
/// `None`.
|
||||
fn validate_memo(raw: &str) -> Option<String> {
|
||||
let decoded = percent_decode(raw);
|
||||
// Drop ASCII control bytes (< 0x20, covering NUL / newline / tab) and DEL.
|
||||
let cleaned: Vec<u8> = decoded
|
||||
.into_iter()
|
||||
.filter(|&b| b >= 0x20 && b != 0x7f)
|
||||
.collect();
|
||||
let text = String::from_utf8_lossy(&cleaned).into_owned();
|
||||
let text = truncate_on_char_boundary(text, MAX_MEMO_BYTES);
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max` bytes without splitting a UTF-8 char.
|
||||
fn truncate_on_char_boundary(s: String, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s;
|
||||
}
|
||||
let mut end = max;
|
||||
while end > 0 && !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
s[..end].to_string()
|
||||
}
|
||||
|
||||
/// Minimal, correct RFC-3986 percent-decode over bytes. `%XX` (hex) becomes one
|
||||
/// byte; a stray `%` or a non-hex escape is passed through literally. No new
|
||||
/// dependency — the wallet has no direct percent-encoding crate and this is a
|
||||
/// few lines. `+` is left literal (RFC-3986 query, not form-encoding).
|
||||
fn percent_decode(s: &str) -> Vec<u8> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
|
||||
out.push((hi << 4) | lo);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Hex-nibble value for an ASCII hex digit, or `None`.
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NPROFILE: &str =
|
||||
"nprofile1qqsw3v0m5v6h9q8n0hkxg6l4l5xk2z7z0n6f6q9m8x0q5v4l3k2j1h0gpz3mhxue69uhhyetvv9uju";
|
||||
|
||||
#[test]
|
||||
fn bare_nprofile_unchanged() {
|
||||
let uri = format!("nostr:{NPROFILE}");
|
||||
let out = parse(&uri);
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount, None);
|
||||
assert_eq!(out.memo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_npub_no_scheme_is_verbatim() {
|
||||
// No scheme at all → returned exactly as today (fed to the resolver).
|
||||
let out = parse("npub1abcdef");
|
||||
assert_eq!(out.recipient, "npub1abcdef");
|
||||
assert_eq!(out.amount, None);
|
||||
assert_eq!(out.memo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_scheme_accepted() {
|
||||
let out = parse(&format!("NOSTR:{NPROFILE}?amount=2"));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_amount() {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=1.5"));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("1.5"));
|
||||
assert_eq!(out.memo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_amount_and_memo() {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=0.25&memo=Coffee"));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("0.25"));
|
||||
assert_eq!(out.memo.as_deref(), Some("Coffee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_amount_rejected() {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=-1"));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_empty_amount_rejected() {
|
||||
assert_eq!(parse(&format!("nostr:{NPROFILE}?amount=0")).amount, None);
|
||||
assert_eq!(parse(&format!("nostr:{NPROFILE}?amount=")).amount, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_amount_rejected() {
|
||||
for bad in ["abc", "1.5xyz", "1,5", "0x10", "1 5", " 1"] {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount={bad}"));
|
||||
assert_eq!(out.amount, None, "expected {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_memo_truncated() {
|
||||
let long = "a".repeat(500);
|
||||
let out = parse(&format!("nostr:{NPROFILE}?memo={long}"));
|
||||
let memo = out.memo.expect("memo present");
|
||||
assert_eq!(memo.len(), 256);
|
||||
assert!(memo.bytes().all(|b| b == b'a'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memo_control_chars_stripped() {
|
||||
// Percent-encoded NUL, newline, tab and a raw CR are all removed.
|
||||
let out = parse(&format!("nostr:{NPROFILE}?memo=A%00B%0AC%09D\rE"));
|
||||
assert_eq!(out.memo.as_deref(), Some("ABCDE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memo_percent_decoded() {
|
||||
// "Hi there & co =2" with reserved chars percent-encoded.
|
||||
let out = parse(&format!(
|
||||
"nostr:{NPROFILE}?memo=Hi%20there%20%26%20co%20%3D2"
|
||||
));
|
||||
assert_eq!(out.memo.as_deref(), Some("Hi there & co =2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_nostr_scheme_treated_as_today() {
|
||||
// A different scheme is NOT parsed for amount/memo; returned verbatim.
|
||||
let out = parse("bitcoin:bc1qxyz?amount=1.5");
|
||||
assert_eq!(out.recipient, "bitcoin:bc1qxyz?amount=1.5");
|
||||
assert_eq!(out.amount, None);
|
||||
assert_eq!(out.memo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_params_ignored() {
|
||||
let out = parse(&format!(
|
||||
"nostr:{NPROFILE}?lightning=zzz&amount=3&foo=bar&memo=Hey"
|
||||
));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("3"));
|
||||
assert_eq!(out.memo.as_deref(), Some("Hey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_length_rejected() {
|
||||
let huge = format!("nostr:{}", "a".repeat(5000));
|
||||
let out = parse(&huge);
|
||||
assert_eq!(out.recipient, "");
|
||||
assert_eq!(out.amount, None);
|
||||
assert_eq!(out.memo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_nul_rejected() {
|
||||
let out = parse(&format!("nostr:{NPROFILE}\0?amount=1"));
|
||||
assert_eq!(out.recipient, "");
|
||||
assert_eq!(out.amount, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_amount_first_wins() {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=1&amount=999"));
|
||||
assert_eq!(out.amount.as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_trailing_whitespace_trimmed() {
|
||||
let out = parse(&format!(" nostr:{NPROFILE}?amount=1.5 "));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("1.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_bare_empty() {
|
||||
let out = parse("");
|
||||
assert_eq!(out.recipient, "");
|
||||
assert_eq!(out.amount, None);
|
||||
assert_eq!(out.memo, None);
|
||||
}
|
||||
|
||||
// --- magick.market interop contract -------------------------------------
|
||||
// These guard the magick.market <-> Goblin pay-URI contract: a checkout QR
|
||||
// from magick MUST parse here to the exact recipient / amount / memo. magick
|
||||
// emits this canonical format from `buildGoblinPayUri` in src/lib/grin.ts,
|
||||
// converting its internal integer nanogrin to a decimal-GRIN `amount` string
|
||||
// and carrying the opaque `MM-<hex>` invoice number as the `memo`.
|
||||
|
||||
#[test]
|
||||
fn magick_market_checkout_uri_round_trips() {
|
||||
// 1_500_000_000 nanogrin == "1.5" GRIN (magick's formatGrin() output);
|
||||
// memo is the opaque invoice number that bridges payment <-> order.
|
||||
let invoice = "MM-1A2B3C4D5E6F7A8B9C0D1E2F";
|
||||
let uri = format!("nostr:{NPROFILE}?amount=1.5&memo={invoice}");
|
||||
let out = parse(&uri);
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("1.5"));
|
||||
assert_eq!(out.memo.as_deref(), Some(invoice));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magick_market_amount_precision_range() {
|
||||
// Whole GRIN and the smallest Grin unit (1 nanogrin == 0.000000001 GRIN),
|
||||
// the two ends of the decimal-GRIN strings magick can emit.
|
||||
let whole = parse(&format!("nostr:{NPROFILE}?amount=1&memo=MM-ABC123"));
|
||||
assert_eq!(whole.amount.as_deref(), Some("1"));
|
||||
assert_eq!(whole.memo.as_deref(), Some("MM-ABC123"));
|
||||
|
||||
let smallest = parse(&format!(
|
||||
"nostr:{NPROFILE}?amount=0.000000001&memo=MM-ABC123"
|
||||
));
|
||||
assert_eq!(smallest.amount.as_deref(), Some("0.000000001"));
|
||||
assert_eq!(smallest.memo.as_deref(), Some("MM-ABC123"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
// 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 mixnet exit (Recipient address): a MixnetStream the wallet dials directly to reach the relay with no public DNS and no public IPR — the fast money path.",
|
||||
"min_message_length": 131072,
|
||||
"relays": [
|
||||
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-02", "exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe" },
|
||||
{ "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())
|
||||
}
|
||||
|
||||
/// Whether ANY relay in the pool advertises a co-located exit. The cold-start
|
||||
/// sequencer ([`crate::nym::nymproc`]) reads this to decide whether to give
|
||||
/// the scoped-exit client its bandwidth-grant head start before building the
|
||||
/// public-IPR tunnel — no exit anywhere → no wait, unchanged behavior.
|
||||
pub fn has_exit(&self) -> bool {
|
||||
self.relays
|
||||
.iter()
|
||||
.any(|r| r.exit.as_deref().is_some_and(|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))
|
||||
// A cache written by a pre-exit build parses fine but hides the
|
||||
// scoped-exit money path (and the current primary relay) for up to
|
||||
// CACHE_MAX_AGE_SECS after an app update — relay connects then ride
|
||||
// the slow public-IPR path for days. The pinned pool is newer than
|
||||
// any exit-less file, so prefer it until the next gist refresh.
|
||||
.filter(RelayPool::has_exit)
|
||||
.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)
|
||||
// An exit-less cache predates the current pool shape (see `load`,
|
||||
// which already ignores it) — replace it now instead of serving the
|
||||
// pinned fallback for the rest of the file's 7 days.
|
||||
&& std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|raw| RelayPool::parse(&raw))
|
||||
.is_some_and(|p| p.has_exit());
|
||||
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> {
|
||||
// Probe every candidate CONCURRENTLY (each is a NIP-11 HTTP round trip over
|
||||
// the mixnet — sequentially this cost ~N × a full round trip). The PROBES
|
||||
// cache is RwLock-safe under concurrent access. Zip the pass/fail results back
|
||||
// to the urls and keep the passing ones in the original pool order.
|
||||
let urls = load().discovery_relays();
|
||||
let results = futures::future::join_all(urls.iter().map(|url| probe(url))).await;
|
||||
urls.into_iter()
|
||||
.zip(results)
|
||||
.filter_map(|(url, ok)| ok.then_some(url))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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.floonet.dev"));
|
||||
assert!(dm.iter().all(|r| r.vetted.is_some()));
|
||||
let disc = pool.discovery_relays();
|
||||
// relay.floonet.dev 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 money-path relay's co-located scoped
|
||||
// exit (the .AF floonet-mixexit) so it bootstraps OFFLINE, before any
|
||||
// network; every other relay is exit-less (reached over the tunnel).
|
||||
let pinned = RelayPool::parse(PINNED_POOL).unwrap();
|
||||
assert!(pinned.has_exit());
|
||||
assert!(pinned.exit_for("wss://relay.floonet.dev").is_some());
|
||||
assert!(pinned.exit_for("wss://nos.lol").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);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
//! Default relay set and relay list helpers.
|
||||
|
||||
/// Default DM relays: the Goblin relay plus large public relays for redundancy.
|
||||
/// Default DM relays: the Floonet relay plus large public relays for redundancy.
|
||||
pub const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.goblin.st",
|
||||
"wss://relay.floonet.dev",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
@@ -170,10 +170,6 @@ impl NostrStore {
|
||||
self.put_json(&self.contacts, &contact.npub, contact);
|
||||
}
|
||||
|
||||
pub fn delete_contact(&self, npub_hex: &str) {
|
||||
self.delete(&self.contacts, npub_hex);
|
||||
}
|
||||
|
||||
pub fn all_contacts(&self) -> Vec<Contact> {
|
||||
self.all_json(&self.contacts)
|
||||
}
|
||||
@@ -312,11 +308,4 @@ impl NostrStore {
|
||||
self.clear(&self.requests);
|
||||
self.clear(&self.processed);
|
||||
}
|
||||
|
||||
/// Wipe everything including contacts.
|
||||
pub fn wipe_all(&self) {
|
||||
self.wipe_archive();
|
||||
self.clear(&self.contacts);
|
||||
self.clear(&self.settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,11 @@ pub struct Contact {
|
||||
pub nip05_verified_at: Option<i64>,
|
||||
/// Known DM relays (kind 10050) of the contact.
|
||||
pub relays: Vec<String>,
|
||||
/// The contact advertises NIP-44 v3 in the `encryption` tag of the same
|
||||
/// kind 10050 the relays come from (NIP-17 backward-compat extension).
|
||||
/// Absent tag = v2 only, hence the conservative default.
|
||||
#[serde(default)]
|
||||
pub nip44_v3: bool,
|
||||
/// Avatar palette index.
|
||||
pub hue: u8,
|
||||
/// Auto-added from an incoming payment, not yet confirmed by the user.
|
||||
|
||||
@@ -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,615 @@
|
||||
// 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).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
/// 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).
|
||||
const DOT_ROUNDS: usize = 2;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// TTL floor for KNOWN/stable hosts (relays, the name authority, the price API,
|
||||
/// the DoT/DoH resolvers) — the ones we prewarm. Their addresses change rarely,
|
||||
/// so we keep them cached at least 15 min (up to the 60-min ceiling) instead of
|
||||
/// re-resolving every minute. Combined with serve-stale (below) this means a
|
||||
/// dial to one of these NEVER blocks on a fresh mixnet DoT round trip.
|
||||
const KNOWN_TTL_FLOOR_SECS: u32 = 900;
|
||||
|
||||
lazy_static! {
|
||||
/// host → (addresses, expiry).
|
||||
static ref CACHE: RwLock<HashMap<String, (Vec<Ipv4Addr>, Instant)>> =
|
||||
RwLock::new(HashMap::new());
|
||||
/// Hosts we treat as known/stable (populated by [`prewarm`]). Known hosts get
|
||||
/// the longer [`KNOWN_TTL_FLOOR_SECS`] floor AND serve-stale-while-revalidate.
|
||||
static ref KNOWN: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
|
||||
/// Hosts with a background revalidation in flight — single-flight guard so a
|
||||
/// burst of dials to a stale known host spawns exactly one refresh.
|
||||
static ref REFRESHING: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
|
||||
}
|
||||
|
||||
/// Whether `host` is a known/stable host (has been prewarmed at least once).
|
||||
fn is_known(host: &str) -> bool {
|
||||
KNOWN.read().contains(host)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
match cache_hit(host) {
|
||||
// Fresh entry: serve it, no network at all.
|
||||
Some(CacheHit::Fresh(ip)) => return Some(SocketAddr::new(IpAddr::V4(ip), port)),
|
||||
// SERVE-STALE-WHILE-REVALIDATE for known/stable hosts: hand back the
|
||||
// last-known address immediately (so the dial never blocks on a cold DoT
|
||||
// round trip) and refresh it in the background. Unknown hosts fall
|
||||
// through to a blocking resolve, preserving correctness.
|
||||
Some(CacheHit::Stale(ip)) if is_known(host) => {
|
||||
spawn_revalidate(tunnel, host);
|
||||
return Some(SocketAddr::new(IpAddr::V4(ip), port));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
resolve_cold(tunnel, host, port).await
|
||||
}
|
||||
|
||||
/// The blocking DoT-then-DoH resolve, run when there is no usable cache entry.
|
||||
/// Writes the cache on success.
|
||||
async fn resolve_cold(tunnel: &Tunnel, host: &str, port: u16) -> Option<SocketAddr> {
|
||||
// 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
|
||||
}
|
||||
|
||||
/// Kick off a background refresh of a stale known host through the current
|
||||
/// tunnel, at most one in flight per host.
|
||||
fn spawn_revalidate(tunnel: &Tunnel, host: &str) {
|
||||
let host = host.to_string();
|
||||
// Single-flight: skip if a refresh for this host is already running.
|
||||
if !REFRESHING.write().insert(host.clone()) {
|
||||
return;
|
||||
}
|
||||
let tunnel = tunnel.clone();
|
||||
tokio::spawn(async move {
|
||||
// Port is irrelevant here — only the host-keyed cache is refreshed.
|
||||
let _ = resolve_cold(&tunnel, &host, 0).await;
|
||||
REFRESHING.write().remove(&host);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the round loop for one in-tunnel DNS transport, writing the cache on the
|
||||
/// first valid answer. Shared by DoT / DoH.
|
||||
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),
|
||||
};
|
||||
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,
|
||||
};
|
||||
if let Some((resolver, ips, ttl)) = answer {
|
||||
// Known/stable hosts get the longer floor so they stay cached 15-60
|
||||
// min; everything else keeps the tight 60s..1h window.
|
||||
let floor = if is_known(host) {
|
||||
KNOWN_TTL_FLOOR_SECS
|
||||
} else {
|
||||
TTL_FLOOR_SECS
|
||||
};
|
||||
let ttl = ttl.clamp(floor, 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(super::tls_config())
|
||||
.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(super::tls_config())
|
||||
.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)
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
// Mark these as known/stable so they get the long TTL floor and serve-stale.
|
||||
{
|
||||
let mut known = KNOWN.write();
|
||||
for host in hosts {
|
||||
known.insert(host.clone());
|
||||
}
|
||||
}
|
||||
let mut inflight = FuturesUnordered::new();
|
||||
for host in hosts {
|
||||
inflight.push(resolve(tunnel, host, 0));
|
||||
}
|
||||
while inflight.next().await.is_some() {}
|
||||
}
|
||||
|
||||
/// A cache lookup outcome for `host`: fresh (within TTL) or stale (expired but
|
||||
/// still remembered, usable via serve-stale for known hosts).
|
||||
enum CacheHit {
|
||||
Fresh(Ipv4Addr),
|
||||
Stale(Ipv4Addr),
|
||||
}
|
||||
|
||||
/// Look up `host` in the cache, distinguishing fresh from stale entries. Returns
|
||||
/// `None` only when the host has never been resolved.
|
||||
fn cache_hit(host: &str) -> Option<CacheHit> {
|
||||
let cache = CACHE.read();
|
||||
let (ips, expiry) = cache.get(host)?;
|
||||
let ip = ips.first().copied()?;
|
||||
Some(if Instant::now() < *expiry {
|
||||
CacheHit::Fresh(ip)
|
||||
} else {
|
||||
CacheHit::Stale(ip)
|
||||
})
|
||||
}
|
||||
|
||||
/// Stable public addresses the liveness probe RACES through the tunnel: a tunnel
|
||||
/// is alive if it can reach ANY of them. Racing (not one fixed target) is why a
|
||||
/// momentarily slow path to a single resolver no longer false-declares a healthy
|
||||
/// exit DEAD — the same reason the DoT/DoH resolvers above are raced, not tried in
|
||||
/// series. Both are anycast resolvers on :443 (never exit-policy-firewalled, since
|
||||
/// relays + HTTPS already ride it) and effectively always-on.
|
||||
const PROBE_ADDRS: [SocketAddr; 2] = [
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443),
|
||||
];
|
||||
/// Per-target connect wait; a mixnet TCP handshake is a few seconds.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
/// Probe rounds before a tunnel is declared dead. A single lost mixnet packet
|
||||
/// mid-handshake should not condemn a whole tunnel, so an all-miss round is
|
||||
/// retried once (mirrors the DoT/DoH round loop). Only a tunnel that reaches
|
||||
/// NEITHER stable target across BOTH rounds is DEAD — this is what stops a
|
||||
/// healthy-but-unlucky tunnel from being thrown away and reselected forever.
|
||||
const PROBE_ROUNDS: usize = 2;
|
||||
|
||||
/// End-to-end exit-liveness probe: try to open a TCP connection THROUGH the tunnel
|
||||
/// to any of a few stable public addresses (raced, retried a round) and drop the
|
||||
/// winner immediately. Because TCP over the mixnet RETRANSMITS, a single lost
|
||||
/// datagram does not spuriously fail a healthy exit; racing several targets over
|
||||
/// two rounds additionally absorbs a momentarily slow single path — together they
|
||||
/// stop the false-DEAD reselect churn the old single-target probe caused. 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 {
|
||||
for round in 0..PROBE_ROUNDS {
|
||||
let mut inflight = FuturesUnordered::new();
|
||||
for addr in PROBE_ADDRS {
|
||||
inflight.push(async move {
|
||||
matches!(
|
||||
tokio::time::timeout(PROBE_TIMEOUT, tunnel.tcp_connect(addr)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
});
|
||||
}
|
||||
while let Some(reached) = inflight.next().await {
|
||||
if reached {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
"probe: no stable target reachable through tunnel (round {}/{PROBE_ROUNDS})",
|
||||
round + 1
|
||||
);
|
||||
}
|
||||
debug!("probe: tunnel failed liveness — reached no stable target in {PROBE_ROUNDS} rounds");
|
||||
false
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
|
||||
//! every HTTP request (NIP-05, price, avatars) — is routed through Goblin's
|
||||
//! in-process Nym SOCKS5 client (the Nym SDK linked directly, no subprocess)
|
||||
//! that tunnels over the 5-hop mixnet to a network requester. The mixnet breaks
|
||||
//! the sender↔receiver timing correlation that Mimblewimble's interactive slate
|
||||
//! exchange otherwise leaks at the network layer, and it bootstraps in ~2s.
|
||||
//! Nothing goes clearnet.
|
||||
//! every HTTP request (NIP-05, price, relay pool) — rides the 5-hop mixnet:
|
||||
//! by default one in-process smolmix [`Tunnel`](smolmix::Tunnel) to an
|
||||
//! auto-selected public IPR exit, so neither the payload nor the
|
||||
//! destination-in-flight ever touches the clearnet. Hostnames resolve through
|
||||
//! the same tunnel too ([`dns`], DoT — DNS-over-TLS), so nothing goes
|
||||
//! clearnet. MONEY-PATH ANCHOR: a host whose relay advertises a co-located
|
||||
//! scoped exit in the pool is instead dialed over a MixnetStream straight to
|
||||
//! that exit ([`streamexit`]) — no DNS and no public IPR at all — falling
|
||||
//! back to the tunnel on any failure. The mixnet breaks the sender↔receiver
|
||||
//! timing correlation that Mimblewimble's interactive slate exchange
|
||||
//! otherwise leaks at the network layer.
|
||||
//!
|
||||
//! DNS reliability was the one weak spot: the original mix-dns sent UDP over the
|
||||
//! mixnet, and mixnet UDP loses packets — resolves stalled on multi-second
|
||||
//! timeouts (~10s measured), tipping relay connects past the exit-condemnation
|
||||
//! grace and driving a 2-3 minute reselect loop. Build 98 moves DNS to DoT
|
||||
//! (TCP+TLS through the tunnel): TCP retransmits (no packet-loss stalls) and TLS
|
||||
//! encrypts the query from the exit — reliable AND private.
|
||||
|
||||
pub mod sidecar;
|
||||
pub mod dns;
|
||||
pub mod nymproc;
|
||||
pub mod streamexit;
|
||||
pub mod transport;
|
||||
|
||||
use std::time::Duration;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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::{
|
||||
condemn_exit, is_ready, report_relay_down, report_relay_live, set_relay_consumer,
|
||||
transport_ready, tunnel_generation, warm_up,
|
||||
};
|
||||
pub use transport::NymWebSocketTransport;
|
||||
|
||||
/// Local SOCKS5 endpoint exposed by the in-process Nym SOCKS5 client.
|
||||
/// `socks5h` keeps DNS resolution inside the proxy so the destination host is
|
||||
/// never resolved on the clear.
|
||||
pub const SOCKS5_HOST: &str = "127.0.0.1";
|
||||
pub const SOCKS5_PORT: u16 = 1080;
|
||||
/// How long a single HTTP exchange (one redirect hop) may take end to end.
|
||||
/// The mixnet adds deliberate per-hop delay; allow generous time.
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// `socks5h://127.0.0.1:1080` proxy URL for reqwest.
|
||||
pub fn proxy_url() -> String {
|
||||
format!("socks5h://{SOCKS5_HOST}:{SOCKS5_PORT}")
|
||||
}
|
||||
/// How long to wait for the shared tunnel before giving up on a request.
|
||||
const TUNNEL_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// `127.0.0.1:1080` for the raw SOCKS5 TCP dialer (relay websockets).
|
||||
pub fn socks5_addr() -> String {
|
||||
format!("{SOCKS5_HOST}:{SOCKS5_PORT}")
|
||||
}
|
||||
/// Redirect hops to follow before giving up (matches the old client, which
|
||||
/// followed redirects transparently).
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
|
||||
/// An HTTP request routed over the Nym mixnet via the in-process SOCKS5 client.
|
||||
/// Returns `(status, body)`.
|
||||
/// An HTTP request routed over the Nym mixnet: resolve the host over the tunnel
|
||||
/// (DoT — see [`dns`]), then `tcp_connect` to that IP through the tunnel, then
|
||||
/// rustls (webpki roots) for https, then HTTP/1.1. Follows redirects. Returns
|
||||
/// `(status, body)`.
|
||||
pub async fn http_request_bytes(
|
||||
method: &str,
|
||||
url: String,
|
||||
body: Option<Vec<u8>>,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Option<(u16, Vec<u8>)> {
|
||||
let proxy = reqwest::Proxy::all(proxy_url()).ok()?;
|
||||
let client = reqwest::Client::builder()
|
||||
.proxy(proxy)
|
||||
.user_agent("goblin-wallet")
|
||||
// The mixnet adds deliberate per-hop delay; allow generous time.
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.ok()?;
|
||||
let m = reqwest::Method::from_bytes(method.as_bytes()).ok()?;
|
||||
let mut req = client.request(m, &url);
|
||||
for (k, v) in headers {
|
||||
req = req.header(k, v);
|
||||
let tunnel = nymproc::wait_for_tunnel(TUNNEL_WAIT).await?;
|
||||
let mut url = url::Url::parse(&url).ok()?;
|
||||
let mut method = method.to_uppercase();
|
||||
let mut body = body;
|
||||
for _ in 0..=MAX_REDIRECTS {
|
||||
let (status, resp_body, location) = tokio::time::timeout(
|
||||
HTTP_TIMEOUT,
|
||||
request_once(&tunnel, &method, &url, body.clone(), &headers),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| warn!("nym http: request to {} timed out", redacted(&url)))
|
||||
.ok()??;
|
||||
match location {
|
||||
Some(loc) => {
|
||||
url = url.join(&loc).ok()?;
|
||||
// Like the old client: 303 (and legacy 301/302) turn into a
|
||||
// bodiless GET; 307/308 replay the method + body.
|
||||
if matches!(status, 301..=303) {
|
||||
method = "GET".to_string();
|
||||
body = None;
|
||||
}
|
||||
debug!(
|
||||
"nym http: following {status} redirect to {}",
|
||||
redacted(&url)
|
||||
);
|
||||
}
|
||||
None => return Some((status, resp_body)),
|
||||
}
|
||||
}
|
||||
if let Some(b) = body {
|
||||
req = req.body(b);
|
||||
}
|
||||
let resp = req.send().await.ok()?;
|
||||
let code = resp.status().as_u16();
|
||||
let bytes = resp.bytes().await.ok()?.to_vec();
|
||||
Some((code, bytes))
|
||||
warn!("nym http: too many redirects for {}", redacted(&url));
|
||||
None
|
||||
}
|
||||
|
||||
/// String-bodied convenience wrapper around [`http_request_bytes`].
|
||||
@@ -85,3 +118,294 @@ pub async fn http_request(
|
||||
.await
|
||||
.map(|(_, raw)| String::from_utf8_lossy(&raw).to_string())
|
||||
}
|
||||
|
||||
/// Host without path/query, for logs (never log full URLs).
|
||||
fn redacted(url: &url::Url) -> String {
|
||||
url.host_str().unwrap_or("<no-host>").to_string()
|
||||
}
|
||||
|
||||
/// How long a pooled keep-alive connection may sit idle before we discard it
|
||||
/// rather than reuse a possibly half-dead handle (hyper's `is_closed()` catches
|
||||
/// cleanly-closed ones; this bounds the silent-death window).
|
||||
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Pool key: a live HTTP/1.1 keep-alive connection is reusable only for the same
|
||||
/// host, port and scheme.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
struct ConnKey {
|
||||
host: String,
|
||||
port: u16,
|
||||
https: bool,
|
||||
}
|
||||
|
||||
/// A pooled hyper request handle. The body type matches [`request_once`]'s.
|
||||
type HttpSender = hyper::client::conn::http1::SendRequest<Full<Bytes>>;
|
||||
|
||||
struct Pooled {
|
||||
sender: HttpSender,
|
||||
idle_since: Instant,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Idle keep-alive connections, keyed by (host, port, https). A sender is
|
||||
/// REMOVED while in use and reinserted when the exchange finishes, so the map
|
||||
/// only ever holds idle handles and the lock is never held across an await.
|
||||
static ref CONN_POOL: Mutex<HashMap<ConnKey, Pooled>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Take a live, non-idle-expired pooled sender for `key`, if one exists. A
|
||||
/// closed or stale handle is dropped (tearing down its connection) and `None`
|
||||
/// returned so the caller builds a fresh one.
|
||||
fn take_pooled(key: &ConnKey) -> Option<HttpSender> {
|
||||
let mut pool = CONN_POOL.lock().ok()?;
|
||||
let pooled = pool.remove(key)?;
|
||||
if pooled.sender.is_closed() || pooled.idle_since.elapsed() >= POOL_IDLE_TIMEOUT {
|
||||
return None;
|
||||
}
|
||||
Some(pooled.sender)
|
||||
}
|
||||
|
||||
/// Return a still-live sender to the pool for the next request to reuse.
|
||||
fn store_pooled(key: ConnKey, sender: HttpSender) {
|
||||
if sender.is_closed() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut pool) = CONN_POOL.lock() {
|
||||
pool.insert(
|
||||
key,
|
||||
Pooled {
|
||||
sender,
|
||||
idle_since: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one request/response exchange on `sender`. On success returns the parsed
|
||||
/// `(status, body, location)` AND the sender (drained and ready for the next
|
||||
/// request, so the caller can pool it). `None` if the connection failed.
|
||||
async fn exchange(
|
||||
mut sender: HttpSender,
|
||||
method: &str,
|
||||
url: &url::Url,
|
||||
body: Option<Vec<u8>>,
|
||||
headers: &[(String, String)],
|
||||
host: &str,
|
||||
https: bool,
|
||||
port: u16,
|
||||
) -> Option<((u16, Vec<u8>, Option<String>), HttpSender)> {
|
||||
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.to_string()
|
||||
} 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), sender))
|
||||
}
|
||||
|
||||
/// 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 });
|
||||
let key = ConnKey {
|
||||
host: host.clone(),
|
||||
port,
|
||||
https,
|
||||
};
|
||||
|
||||
// KEEP-ALIVE FAST PATH: reuse a pooled connection for this (host, port,
|
||||
// https) when one is live, skipping a fresh mixnet TCP + TLS + HTTP handshake.
|
||||
// This is what makes the many small reads (price, contact-name resolution)
|
||||
// fast. Only steady-state tunnel connections are pooled (see below); the
|
||||
// cold-start scoped-exit fallback is one-shot.
|
||||
if let Some(sender) = take_pooled(&key) {
|
||||
if let Some((resp, sender)) = exchange(
|
||||
sender,
|
||||
method,
|
||||
url,
|
||||
body.clone(),
|
||||
headers,
|
||||
&host,
|
||||
https,
|
||||
port,
|
||||
)
|
||||
.await
|
||||
{
|
||||
store_pooled(key, sender);
|
||||
return Some(resp);
|
||||
}
|
||||
// Pooled connection died mid-exchange: fall through and build a fresh one.
|
||||
}
|
||||
|
||||
// TUNNEL-FIRST for HTTP. NIP-11/HTTP is PUBLIC data (relay docs, price, name
|
||||
// authority) and both egresses are mixnet-private, so in steady state we ride
|
||||
// the already-warm tunnel — opening a fresh MixnetStream + settle to a scoped
|
||||
// exit PER request was pure latency here. Only when the tunnel isn't up yet
|
||||
// (`!is_ready()`) do we fall to a host's co-located scoped exit to avoid a cold
|
||||
// wait; failure there just falls through to the tunnel path below. transport.rs
|
||||
// (relay websockets) stays exit-first and is untouched — this is the HTTP path
|
||||
// only.
|
||||
let exit_io = if https && !nymproc::is_ready() {
|
||||
match crate::nostr::pool::load().exit_for_host(&host) {
|
||||
Some(exit) => exit_connect(&host, &exit).await,
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The one-shot scoped-exit fallback is NOT pooled — it's a cold-start bridge
|
||||
// while the tunnel comes up. Only tunnel-borne connections go in the pool.
|
||||
let poolable = exit_io.is_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 (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 in the background. It stays alive for keep-alive reuse
|
||||
// as long as the pooled sender is held; it ends once the sender is dropped
|
||||
// (evicted from the pool) or the peer closes the connection.
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
|
||||
let (resp, sender) = exchange(sender, method, url, body, headers, &host, https, port).await?;
|
||||
if poolable {
|
||||
store_pooled(key, sender);
|
||||
}
|
||||
Some(resp)
|
||||
}
|
||||
|
||||
/// 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 (and the TLS/websocket layers) needs from a mixnet-carried
|
||||
/// stream, boxable for the plain http / https / scoped-exit split. Shared with
|
||||
/// the scoped-exit egress ([`streamexit::BoxedStream`]).
|
||||
pub(crate) trait Stream: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
impl<T: AsyncRead + AsyncWrite + Send + Unpin> Stream for T {}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Shared rustls client config (webpki roots; ring provider installed at
|
||||
/// startup — the Build 65/66 rule), reused by every in-tunnel TLS handshake
|
||||
/// (HTTPS here, DoT/DoH in [`dns`]).
|
||||
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(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/// The shared rustls client config (cheap `Arc` bump).
|
||||
pub(crate) fn tls_config() -> Arc<rustls::ClientConfig> {
|
||||
TLS_CONFIG.clone()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
{
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).ok()?;
|
||||
tokio_rustls::TlsConnector::from(tls_config())
|
||||
.connect(server_name, stream)
|
||||
.await
|
||||
.map_err(|e| warn!("nym http: tls handshake with {host} failed: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -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,269 @@
|
||||
// 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::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{info, warn};
|
||||
use nym_sdk::mixnet::{MixnetClient, MixnetStream, Recipient};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// The boxed transport stream handed to the TLS/websocket layer — the same
|
||||
/// seat the smolmix tunnel's TCP stream occupies on the fallback path
|
||||
/// (everything that layer needs is the shared [`super::Stream`] trait).
|
||||
pub(crate) type BoxedStream = Box<dyn super::Stream>;
|
||||
|
||||
/// 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);
|
||||
|
||||
/// True once the exit's `MixnetClient` has bootstrapped and is usable. The
|
||||
/// cold-start sequencer in [`super::nymproc`] reads this to hold the public-IPR
|
||||
/// tunnel's bootstrap until the exit client has its Nym bandwidth grant (see the
|
||||
/// NOTE below), so the money path connects in seconds instead of a minute.
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Whether the scoped-exit mixnet client is bootstrapped and usable.
|
||||
pub fn is_ready() -> bool {
|
||||
READY.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// NOTE ON COLD-START LATENCY (and its fix): the exit rides a SECOND ephemeral
|
||||
// MixnetClient (separate from the smolmix tunnel). When BOTH clients bootstrap
|
||||
// at once on a cold start they serialize on Nym free-tier bandwidth grants — so
|
||||
// whichever dials second waits ~a minute for its grant. The money path must not
|
||||
// be the loser of that race. Fix (see nymproc's cold-start sequencer): the exit
|
||||
// client is allowed to grab its grant FIRST, and the tunnel's bootstrap waits a
|
||||
// bounded head-start for `is_ready()` before it competes — so only ONE client
|
||||
// bootstraps at a time and the money-path relay connects in seconds. The tunnel
|
||||
// (fallback / HTTP / discovery, all non-blocking) comes up right after. A
|
||||
// startup pre-warm of BOTH in parallel does NOT help (measured) — sequencing,
|
||||
// not parallelism, is what removes the stall. Sharing ONE client for tunnel +
|
||||
// exit would remove the second grant entirely but couples the robust exit to
|
||||
// the tunnel's per-reselect client rebuild; deferred as a future upgrade.
|
||||
|
||||
/// 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(crate) 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)
|
||||
}
|
||||
|
||||
/// Bootstrap the shared client ahead of the first dial. The cold-start
|
||||
/// sequencer in [`super::nymproc`] spawns this when the pool advertises an
|
||||
/// exit: without it the client would only bootstrap on the first relay dial —
|
||||
/// which happens after a wallet opens, so the tunnel's bounded head-start wait
|
||||
/// would just expire and both clients would race for their bandwidth grants
|
||||
/// anyway. Failure is non-fatal (the first real dial retries the bootstrap).
|
||||
pub async fn prewarm() {
|
||||
if let Err(e) = ensure_client().await {
|
||||
warn!("nym: streamexit prewarm failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the shared client is connected (bootstrapping it when absent or
|
||||
/// dead) and READY reflects reality. Holds the client lock across the
|
||||
/// bootstrap so concurrent callers coalesce onto one connect.
|
||||
async fn ensure_client() -> Result<(), 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;
|
||||
READY.store(false, Ordering::Relaxed);
|
||||
}
|
||||
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);
|
||||
READY.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure the shared client is connected, then open a stream on it.
|
||||
async fn open(recipient: Recipient) -> Result<MixnetStream, String> {
|
||||
ensure_client().await?;
|
||||
let mut guard = CLIENT.lock().await;
|
||||
// Re-acquired the lock after ensure_client — a concurrent failed dial may
|
||||
// have dropped the client in between; error into the caller's fallback
|
||||
// rather than panic.
|
||||
let Some(client) = guard.as_mut() else {
|
||||
return Err("exit client lost before dial".to_string());
|
||||
};
|
||||
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;
|
||||
READY.store(false, Ordering::Relaxed);
|
||||
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.floonet.dev")
|
||||
.expect("pinned pool advertises the relay.floonet.dev 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.floonet.dev";
|
||||
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,14 +12,18 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! WebSocket transport for the Nostr relay pool routed through Goblin's
|
||||
//! in-process Nym SOCKS5 client, so every relay connection traverses the 5-hop
|
||||
//! Nym mixnet. We open a SOCKS5 connection to `127.0.0.1:1080`, ask the proxy
|
||||
//! to reach the relay host (`socks5h`-style: the proxy does the DNS, so the
|
||||
//! destination is never resolved on the clear), then run the TLS + websocket
|
||||
//! handshake over that tunnel. Nothing goes clearnet.
|
||||
//! WebSocket transport for the Nostr relay pool routed through the Nym
|
||||
//! mixnet, with TWO egresses picked per relay. ANCHOR: a relay whose pool
|
||||
//! entry advertises its operator's co-located scoped exit
|
||||
//! ([`crate::nostr::pool::PoolRelay::exit`]) is dialed over a MixnetStream
|
||||
//! straight to that exit ([`super::streamexit`]) — no DNS, no public IPR.
|
||||
//! FALLBACK (and every relay without an exit): Goblin's in-process smolmix
|
||||
//! tunnel — the relay host is resolved by [`super::dns`], the TCP stream is
|
||||
//! opened via `tunnel.tcp_connect`. Either way the SAME TLS (rustls, webpki
|
||||
//! roots) + websocket handshake runs over the mixnet-carried stream, so the
|
||||
//! payload + in-flight destination never touch the clear, and an exit failure
|
||||
//! only ever falls back — never a lockout.
|
||||
|
||||
use std::fmt;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -30,26 +34,15 @@ use nostr_relay_pool::transport::error::TransportError;
|
||||
use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport};
|
||||
use nostr_sdk::Url;
|
||||
use nostr_sdk::util::BoxedFuture;
|
||||
use tokio_socks::tcp::Socks5Stream;
|
||||
use tokio_tungstenite::tungstenite::Message as TgMessage;
|
||||
|
||||
/// Error type for transport failures outside the websocket layer.
|
||||
#[derive(Debug)]
|
||||
struct NymTransportError(String);
|
||||
|
||||
impl fmt::Display for NymTransportError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NymTransportError {}
|
||||
|
||||
/// A backend transport error (failures outside the websocket layer) carrying
|
||||
/// `msg` as its display text.
|
||||
fn terr(msg: impl Into<String>) -> TransportError {
|
||||
TransportError::backend(NymTransportError(msg.into()))
|
||||
TransportError::backend(std::io::Error::other(msg.into()))
|
||||
}
|
||||
|
||||
/// Nostr websocket transport over the local Nym SOCKS5 proxy.
|
||||
/// Nostr websocket transport over the in-process Nym mixnet tunnel.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NymWebSocketTransport;
|
||||
|
||||
@@ -74,17 +67,60 @@ impl WebSocketTransport for NymWebSocketTransport {
|
||||
_ => 443,
|
||||
});
|
||||
|
||||
// Dial the relay host through the local Nym SOCKS5 client. The proxy
|
||||
// resolves the host inside the mixnet, so no clearnet DNS leak.
|
||||
let stream = tokio::time::timeout(
|
||||
timeout,
|
||||
Socks5Stream::connect(crate::nym::socks5_addr().as_str(), (host.as_str(), port)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| terr("nym socks5 connect timeout"))?
|
||||
.map_err(|e| terr(format!("nym socks5 connect failed: {e}")))?;
|
||||
// MONEY-PATH ANCHOR: when the pool advertises this relay
|
||||
// operator's co-located scoped Nym exit, dial THROUGH it — a
|
||||
// MixnetStream straight to the exit (which pipes to its one
|
||||
// relay), no public DNS, no public IPR, no tunnel dependency. The
|
||||
// TLS + websocket wrap inside is byte-for-byte the tunnel path's
|
||||
// (same `client_async_tls`, SNI = the relay host), so the exit
|
||||
// sees only ciphertext. ANY failure — bootstrap, open, handshake,
|
||||
// timeout — falls through to the public-IPR tunnel dial below:
|
||||
// anchor + fallback, never pin-only.
|
||||
if let Some(exit) = crate::nostr::pool::load().exit_for(url.as_str()) {
|
||||
let t_exit = std::time::Instant::now();
|
||||
match exit_connect(url, &exit, timeout).await {
|
||||
Ok(parts) => {
|
||||
log::info!(
|
||||
"[timing] nym: relay {host} CONNECTED via scoped exit — \
|
||||
stream+tls+ws {}ms",
|
||||
t_exit.elapsed().as_millis()
|
||||
);
|
||||
return Ok(parts);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"nym: scoped exit dial for {host} failed after {}ms ({e}); \
|
||||
falling back to the public-IPR tunnel",
|
||||
t_exit.elapsed().as_millis()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// The shared mixnet tunnel (lazy-started at app launch).
|
||||
let tunnel = crate::nym::nymproc::wait_for_tunnel(timeout)
|
||||
.await
|
||||
.ok_or_else(|| terr("nym tunnel not ready"))?;
|
||||
|
||||
// Resolve the relay host (clearnet by default — see nym::dns), then
|
||||
// dial the resolved IP THROUGH the same tunnel so the TCP, TLS and
|
||||
// websocket all still ride the mixnet. Each stage is timed so the
|
||||
// connect-timing harness can attribute cost per relay.
|
||||
let t_resolve = std::time::Instant::now();
|
||||
let addr =
|
||||
tokio::time::timeout(timeout, crate::nym::dns::resolve(&tunnel, &host, port))
|
||||
.await
|
||||
.map_err(|_| terr("dns resolve timeout"))?
|
||||
.ok_or_else(|| terr(format!("could not resolve relay host {host}")))?;
|
||||
let resolve_ms = t_resolve.elapsed().as_millis();
|
||||
|
||||
let t_tcp = std::time::Instant::now();
|
||||
let stream = tokio::time::timeout(timeout, tunnel.tcp_connect(addr))
|
||||
.await
|
||||
.map_err(|_| terr("nym tunnel connect timeout"))?
|
||||
.map_err(|e| terr(format!("nym tunnel connect failed: {e}")))?;
|
||||
let tcp_ms = t_tcp.elapsed().as_millis();
|
||||
|
||||
// Perform TLS (for wss) + websocket handshake over the mixnet stream.
|
||||
let t_ws = std::time::Instant::now();
|
||||
let (ws, _response) = tokio::time::timeout(
|
||||
timeout,
|
||||
tokio_tungstenite::client_async_tls(url.as_str(), stream),
|
||||
@@ -92,22 +128,61 @@ impl WebSocketTransport for NymWebSocketTransport {
|
||||
.await
|
||||
.map_err(|_| terr("websocket handshake timeout"))?
|
||||
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
|
||||
log::info!(
|
||||
"[timing] nym: relay {host} CONNECTED — resolve {resolve_ms}ms, \
|
||||
tcp_connect(mixnet) {tcp_ms}ms, tls+ws(mixnet) {}ms",
|
||||
t_ws.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let (tx, rx) = ws.split();
|
||||
|
||||
let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink;
|
||||
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
|
||||
match msg {
|
||||
Ok(tg) => tg_to_message(tg).map(Ok),
|
||||
Err(e) => Some(Err(TransportError::backend(e))),
|
||||
}
|
||||
})) as WebSocketStream;
|
||||
|
||||
Ok((sink, stream))
|
||||
Ok(split_ws(ws))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Dial `url` through the relay operator's scoped Nym exit `exit`: a
|
||||
/// MixnetStream to the exit (which pipes to its one configured relay), then
|
||||
/// the SAME hostname-validated TLS + websocket handshake as the tunnel path.
|
||||
/// The handshake doubles as the exit liveness probe — `open_stream` is
|
||||
/// fire-and-forget, so a dead exit surfaces here as a (bounded) timeout and
|
||||
/// the caller falls back.
|
||||
async fn exit_connect(
|
||||
url: &Url,
|
||||
exit: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<(WebSocketSink, WebSocketStream), TransportError> {
|
||||
let stream = crate::nym::streamexit::open_stream(exit, timeout)
|
||||
.await
|
||||
.map_err(terr)?;
|
||||
let (ws, _response) = tokio::time::timeout(
|
||||
timeout,
|
||||
tokio_tungstenite::client_async_tls(url.as_str(), stream),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| terr("websocket handshake timeout (exit stream)"))?
|
||||
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
|
||||
Ok(split_ws(ws))
|
||||
}
|
||||
|
||||
/// Split a websocket into the pool's boxed sink/stream halves — shared by the
|
||||
/// scoped-exit and tunnel dial paths, so everything above the byte transport
|
||||
/// is identical whichever egress carried the connection.
|
||||
fn split_ws<S>(ws: tokio_tungstenite::WebSocketStream<S>) -> (WebSocketSink, WebSocketStream)
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
|
||||
{
|
||||
let (tx, rx) = ws.split();
|
||||
|
||||
let sink: WebSocketSink = Box::new(NymSink(tx)) as WebSocketSink;
|
||||
let stream: WebSocketStream = Box::pin(rx.filter_map(|msg| async move {
|
||||
match msg {
|
||||
Ok(tg) => tg_to_message(tg).map(Ok),
|
||||
Err(e) => Some(Err(TransportError::backend(e))),
|
||||
}
|
||||
})) as WebSocketStream;
|
||||
|
||||
(sink, stream)
|
||||
}
|
||||
|
||||
/// Convert a tungstenite message into an async-wsocket pool message.
|
||||
/// Returns `None` for raw frames (never surfaced while reading).
|
||||
fn tg_to_message(msg: TgMessage) -> Option<Message> {
|
||||
|
||||
@@ -93,6 +93,23 @@ pub struct AppConfig {
|
||||
check_updates: Option<bool>,
|
||||
/// Application update information.
|
||||
app_update: Option<AppUpdate>,
|
||||
|
||||
/// Last-known-good Nym ENTRY gateway (base58 identity). Only the gateway
|
||||
/// CHOICE is remembered — the mixnet keys stay ephemeral — so a warm reconnect
|
||||
/// can skip re-picking a (possibly dead) random first hop.
|
||||
nym_entry_gateway: Option<String>,
|
||||
/// Last-known-good Nym IPR exit recipient (the `<id>.<enc>@<gw>` string), so a
|
||||
/// warm reconnect can try the exit that worked last time before auto-selecting.
|
||||
nym_last_ipr: Option<String>,
|
||||
|
||||
/// Last successfully fetched GRIN rate, so the amount preview can paint an
|
||||
/// instant (stale-marked) fiat value on cold start instead of a blank until the
|
||||
/// first mixnet fetch lands.
|
||||
last_rate: Option<f64>,
|
||||
/// The `vs_currency` the cached [`last_rate`] was priced against.
|
||||
last_rate_vs: Option<String>,
|
||||
/// Unix-seconds timestamp the cached [`last_rate`] was fetched at.
|
||||
last_rate_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin.
|
||||
@@ -204,6 +221,11 @@ impl Default for AppConfig {
|
||||
// update check — payments, relays and identity still stay mixnet-only.
|
||||
check_updates: Some(true),
|
||||
app_update: None,
|
||||
nym_entry_gateway: None,
|
||||
nym_last_ipr: None,
|
||||
last_rate: None,
|
||||
last_rate_vs: None,
|
||||
last_rate_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,6 +509,55 @@ impl AppConfig {
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Get the last-known-good Nym ENTRY gateway (base58 identity), if any.
|
||||
pub fn nym_entry_gateway() -> Option<String> {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
r_config.nym_entry_gateway.clone()
|
||||
}
|
||||
|
||||
/// Save (or clear) the last-known-good Nym ENTRY gateway.
|
||||
pub fn set_nym_entry_gateway(gw: Option<String>) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.nym_entry_gateway = gw;
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Get the last-known-good Nym IPR exit recipient string, if any.
|
||||
pub fn nym_last_ipr() -> Option<String> {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
r_config.nym_last_ipr.clone()
|
||||
}
|
||||
|
||||
/// Save (or clear) the last-known-good Nym IPR exit recipient string.
|
||||
pub fn set_nym_last_ipr(ipr: Option<String>) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.nym_last_ipr = ipr;
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Get the cached GRIN rate as `(vs_currency, rate, fetched_at)`, if one was
|
||||
/// ever persisted. Callers decide whether it is fresh enough to use.
|
||||
pub fn last_rate() -> Option<(String, f64, i64)> {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
match (
|
||||
r_config.last_rate_vs.clone(),
|
||||
r_config.last_rate,
|
||||
r_config.last_rate_at,
|
||||
) {
|
||||
(Some(vs), Some(rate), Some(at)) => Some((vs, rate, at)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the most recent GRIN rate for `vs`, fetched at `at` (unix secs).
|
||||
pub fn set_last_rate(vs: &str, rate: f64, at: i64) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.last_rate_vs = Some(vs.to_string());
|
||||
w_config.last_rate = Some(rate);
|
||||
w_config.last_rate_at = Some(at);
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Check if proxy for network requests is needed.
|
||||
pub fn use_proxy() -> bool {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
|
||||
@@ -38,17 +38,16 @@ pub struct ExternalConnection {
|
||||
pub available: Option<bool>,
|
||||
}
|
||||
|
||||
/// Default external node URLs for main network. api.grin.money leads (verified
|
||||
/// healthy; grincoin.org's node was returning "rpc call failed"); main.us-ea.st
|
||||
/// is the Goblin-run node. The rest are independent public nodes so a single
|
||||
/// operator going down never strands the wallet.
|
||||
const DEFAULT_MAIN_URLS: [&'static str; 6] = [
|
||||
"https://api.grin.money",
|
||||
"https://main.us-ea.st",
|
||||
/// Default external node URLs for main network. grincoin.org leads (owner-verified:
|
||||
/// `/v2/foreign` get_tip returns cleanly). api.grin.money was REMOVED this build: it
|
||||
/// errors ("Cannot parse response") on `get_unspent_outputs` during a fresh-wallet
|
||||
/// full scan, surfacing as the "error during synchronization" screen. main.gri.mw and
|
||||
/// mainnet.grinffindor.org are the other verified-working public nodes, so a single
|
||||
/// operator going down never strands the wallet. Users can still add their own node.
|
||||
const DEFAULT_MAIN_URLS: [&'static str; 3] = [
|
||||
"https://grincoin.org",
|
||||
"https://main.gri.mw",
|
||||
"https://mainnet.grinffindor.org",
|
||||
"https://main.grin.raubritter.org",
|
||||
];
|
||||
|
||||
/// Default external node URLs for the test network — the testnet counterparts of
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
// Copyright 2026 The Goblin Developers
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! LIVE two-wallet end-to-end payment over the Floonet path — over the shared
|
||||
//! exit-backed primary relay, CROSS-NODE. Two real Goblin wallets restored from
|
||||
//! mainnet mnemonics (seeds via env, NEVER a file) both run on the shipped
|
||||
//! default relay (`wss://relay.floonet.dev`, each pinned via its own
|
||||
//! `nostr.toml`) but on DIFFERENT Grin nodes (A on grincoin.org, B on
|
||||
//! main.gri.mw). One sends a real gift-wrapped Grin payment to the other,
|
||||
//! asynchronously through the relay. Proves the whole money path a phone would
|
||||
//! use, plus the outbox model: the sender publishes the wrap to the RECIPIENT's
|
||||
//! advertised (kind 10050) relay, and settlement posts through two independent
|
||||
//! nodes.
|
||||
//! mixnet -> exit -> gift wrap -> S2 -> finalize -> post.
|
||||
//!
|
||||
//! Ignored by default (real mainnet funds + a full recovery scan). Run:
|
||||
//! GOBLIN_E2E_SEED_A="word ..." GOBLIN_E2E_SEED_B="word ..." \
|
||||
//! cargo test --lib wallet::e2e::tests::two_goblins_pay_over_floonet -- --ignored --nocapture
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use grin_util::types::ZeroingString;
|
||||
|
||||
use crate::nostr::{Contact, NostrConfig, NostrSendStatus};
|
||||
use crate::wallet::types::{ConnectionMethod, PhraseMode, WalletTask};
|
||||
use crate::wallet::{ConnectionsConfig, ExternalConnection, Mnemonic, Wallet};
|
||||
|
||||
/// 0.1 GRIN, in nanograin. Small on purpose (mainnet, real funds).
|
||||
const AMOUNT: u64 = 100_000_000;
|
||||
/// Wallet A's mainnet node (recovery scan + tx post).
|
||||
const NODE_A: &str = "https://grincoin.org";
|
||||
/// Wallet B's mainnet node — a DIFFERENT operator, so the payment settles
|
||||
/// across two independent nodes.
|
||||
const NODE_B: &str = "https://main.gri.mw";
|
||||
/// Wallet A's relay (pinned via its nostr.toml, advertised in its 10050).
|
||||
/// The new primary money-path relay, reached over its co-located scoped exit.
|
||||
const RELAY_A: &str = "wss://relay.floonet.dev";
|
||||
/// Wallet B's relay — the SAME shared exit-backed primary as A (how the
|
||||
/// shipped product works: every Goblin wallet defaults to relay.floonet.dev).
|
||||
/// Both reach it over the co-located scoped exit, so the gift-wrap round-trip
|
||||
/// rides the fast money path end to end. Nodes still differ (below), so the
|
||||
/// payment still settles across two independent Grin nodes.
|
||||
const RELAY_B: &str = "wss://relay.floonet.dev";
|
||||
|
||||
/// Build + open a wallet from a 24-word mnemonic on its own external node
|
||||
/// and its own single-relay nostr.toml override.
|
||||
fn open_wallet(
|
||||
name: &str,
|
||||
phrase: &str,
|
||||
pw: &ZeroingString,
|
||||
conn_id: i64,
|
||||
node_url: &str,
|
||||
relay: &str,
|
||||
) -> 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}"));
|
||||
// Pin this wallet to a single relay BEFORE open(): init_nostr loads
|
||||
// nostr.toml from the wallet data dir on open, and a `relays` override
|
||||
// both drives the client's relay set and is advertised as the wallet's
|
||||
// kind 10050 DM inbox (see NostrService::relays / publish_identity).
|
||||
let wallet_dir = PathBuf::from(w.get_config().get_data_path());
|
||||
let mut nostr_cfg = NostrConfig::load(wallet_dir.clone());
|
||||
nostr_cfg.set_relays(vec![relay.to_string()]);
|
||||
println!(
|
||||
"[e2e] {name}: node={node_url} relay={relay} (nostr.toml at {})",
|
||||
wallet_dir.join(NostrConfig::FILE_NAME).display()
|
||||
);
|
||||
w.open(pw.clone())
|
||||
.unwrap_or_else(|e| panic!("{name}: wallet open failed: {e}"));
|
||||
w
|
||||
}
|
||||
|
||||
/// The persisted form of "added this payee from their nprofile": a contact
|
||||
/// carrying their DM relay, so payment routing (send_targets -> fetch_dm_relays)
|
||||
/// uses that relay directly instead of blind kind-10050 discovery over the
|
||||
/// exit-less indexers. BOTH legs of a cross-relay payment need this seeded.
|
||||
fn contact_with_relay(npub_hex: &str, relay: &str) -> Contact {
|
||||
Contact {
|
||||
ver: 1,
|
||||
npub: npub_hex.to_string(),
|
||||
petname: None,
|
||||
nip05: None,
|
||||
nip05_verified_at: None,
|
||||
relays: vec![relay.to_string()],
|
||||
nip44_v3: false,
|
||||
hue: 0,
|
||||
unknown: false,
|
||||
added_at: 0,
|
||||
last_paid_at: None,
|
||||
blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `cond` until true or `secs` elapse; log progress via `label`.
|
||||
fn wait_until(label: &str, secs: u64, mut cond: impl FnMut() -> bool) -> bool {
|
||||
let start = Instant::now();
|
||||
let mut last = 0u64;
|
||||
while start.elapsed() < Duration::from_secs(secs) {
|
||||
if cond() {
|
||||
println!("[e2e] {label}: OK in {}s", start.elapsed().as_secs());
|
||||
return true;
|
||||
}
|
||||
let el = start.elapsed().as_secs();
|
||||
if el >= last + 15 {
|
||||
last = el;
|
||||
println!("[e2e] {label}: waiting... {el}s");
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
println!("[e2e] {label}: TIMEOUT after {secs}s");
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn two_goblins_pay_over_floonet() {
|
||||
let seed_a = std::env::var("GOBLIN_E2E_SEED_A").unwrap_or_default();
|
||||
let seed_b = std::env::var("GOBLIN_E2E_SEED_B").unwrap_or_default();
|
||||
if seed_a.trim().is_empty() || seed_b.trim().is_empty() {
|
||||
println!("[e2e] SKIP: set GOBLIN_E2E_SEED_A and GOBLIN_E2E_SEED_B");
|
||||
return;
|
||||
}
|
||||
|
||||
// Isolate wallet + nym state under a throwaway HOME. MUST precede any
|
||||
// grim call (Settings roots at $HOME/.goblin on first deref).
|
||||
let home = std::env::var("GOBLIN_E2E_HOME").unwrap_or_else(|_| {
|
||||
std::env::temp_dir()
|
||||
.join("goblin-e2e-home")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
unsafe {
|
||||
std::env::set_var("HOME", &home);
|
||||
}
|
||||
println!("[e2e] HOME = {home}");
|
||||
|
||||
// The app installs these at startup (src/lib.rs); a bare test must too.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
crate::nym::warm_up();
|
||||
assert!(
|
||||
wait_until("nym tunnel is_ready", 180, crate::nym::is_ready),
|
||||
"nym tunnel never came up"
|
||||
);
|
||||
|
||||
// Register a SEPARATE mainnet node per wallet. ExternalConnection ids
|
||||
// are unix seconds, and add_ext_conn dedupes on id — two conns built in
|
||||
// the same second would collide — so bump B's id explicitly.
|
||||
let node_a = ExternalConnection::new(NODE_A.to_string(), Some("grin".to_string()), None);
|
||||
let conn_a = node_a.id;
|
||||
ConnectionsConfig::add_ext_conn(node_a);
|
||||
let mut node_b =
|
||||
ExternalConnection::new(NODE_B.to_string(), Some("grin".to_string()), None);
|
||||
node_b.id = conn_a + 1;
|
||||
let conn_b = node_b.id;
|
||||
ConnectionsConfig::add_ext_conn(node_b);
|
||||
|
||||
let strip = |s: &str| {
|
||||
s.trim_start_matches("https://")
|
||||
.trim_start_matches("wss://")
|
||||
.to_string()
|
||||
};
|
||||
println!(
|
||||
"[e2e] A: node={} relay={} | B: node={} relay={}",
|
||||
strip(NODE_A),
|
||||
strip(RELAY_A),
|
||||
strip(NODE_B),
|
||||
strip(RELAY_B)
|
||||
);
|
||||
|
||||
let pw = ZeroingString::from("e2e-test-pass");
|
||||
|
||||
println!("[e2e] opening wallet A...");
|
||||
let a = open_wallet("goblin-e2e-a", seed_a.trim(), &pw, conn_a, NODE_A, RELAY_A);
|
||||
// Wallet id = unix seconds; two creates in the same second collide.
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
println!("[e2e] opening wallet B...");
|
||||
let b = open_wallet("goblin-e2e-b", seed_b.trim(), &pw, conn_b, NODE_B, RELAY_B);
|
||||
|
||||
// Nostr services connect, each to its OWN relay (over the exit).
|
||||
let a_svc = a.nostr_service().expect("A nostr service");
|
||||
let b_svc = b.nostr_service().expect("B nostr service");
|
||||
let t_a = Instant::now();
|
||||
assert!(
|
||||
wait_until("A nostr connected", 240, || a_svc.is_connected()),
|
||||
"A never connected to its relay ({RELAY_A})"
|
||||
);
|
||||
println!("[e2e] A connected in {}s", t_a.elapsed().as_secs());
|
||||
let t_b = Instant::now();
|
||||
assert!(
|
||||
wait_until("B nostr connected", 240, || b_svc.is_connected()),
|
||||
"B never connected to its relay ({RELAY_B})"
|
||||
);
|
||||
println!("[e2e] B connected in {}s", t_b.elapsed().as_secs());
|
||||
println!("[e2e] A effective relays = {:?}", a_svc.relays());
|
||||
println!("[e2e] B effective relays = {:?}", b_svc.relays());
|
||||
assert_eq!(
|
||||
a_svc.relays(),
|
||||
vec![RELAY_A.to_string()],
|
||||
"A's relay override did not take"
|
||||
);
|
||||
assert_eq!(
|
||||
b_svc.relays(),
|
||||
vec![RELAY_B.to_string()],
|
||||
"B's relay override did not take"
|
||||
);
|
||||
println!("[e2e] A npub = {}", a_svc.npub());
|
||||
println!("[e2e] B npub = {}", b_svc.npub());
|
||||
|
||||
// Pre-seed each wallet's contact store with the other (npub + DM relay) —
|
||||
// the realistic "added the payee from their nprofile" path. Payment
|
||||
// routing then uses the cached DM relay directly, so BOTH legs cross
|
||||
// relays deterministically (A -> B's relay over the tunnel, B -> A's relay
|
||||
// over the exit) without the kind-10050 discovery fetch over the exit-less
|
||||
// indexers that stalled the pure-discovery run.
|
||||
a_svc
|
||||
.store
|
||||
.save_contact(&contact_with_relay(&b_svc.public_key().to_hex(), RELAY_B));
|
||||
b_svc
|
||||
.store
|
||||
.save_contact(&contact_with_relay(&a_svc.public_key().to_hex(), RELAY_A));
|
||||
println!("[e2e] seeded contacts: A knows B @ {RELAY_B}, B knows A @ {RELAY_A}");
|
||||
|
||||
// Recovery scan: concurrent across both wallets, each against its own
|
||||
// node. Sender needs spendable.
|
||||
wait_until("A synced_from_node", 2400, || a.synced_from_node());
|
||||
wait_until("B synced_from_node", 2400, || b.synced_from_node());
|
||||
|
||||
let spendable = |w: &Wallet| -> u64 {
|
||||
w.get_data()
|
||||
.map(|d| d.info.amount_currently_spendable)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let a_bal = spendable(&a);
|
||||
let b_bal = spendable(&b);
|
||||
println!("[e2e] spendable: A={a_bal} nano, B={b_bal} nano (need {AMOUNT})");
|
||||
|
||||
// Sender = whichever wallet actually has the funds. Either way the wrap
|
||||
// crosses relays: the sender fetches the recipient's kind 10050 (from
|
||||
// the recipient's relay + the discovery indexers) and publishes the
|
||||
// gift wrap THERE — the outbox path this test exists to prove.
|
||||
let (sender, sender_svc, recv_svc, sender_name) = if a_bal >= AMOUNT + 20_000_000 {
|
||||
(&a, &a_svc, &b_svc, "A")
|
||||
} else if b_bal >= AMOUNT + 20_000_000 {
|
||||
(&b, &b_svc, &a_svc, "B")
|
||||
} else {
|
||||
panic!(
|
||||
"neither wallet has >= {AMOUNT}+fee spendable (A={a_bal}, B={b_bal}); fund one and retry"
|
||||
);
|
||||
};
|
||||
let receiver_hex = recv_svc.public_key().to_hex();
|
||||
println!("[e2e] sender = {sender_name}; paying {AMOUNT} nano to {receiver_hex}");
|
||||
|
||||
// Fire the async payment across the two relays.
|
||||
let t_send = Instant::now();
|
||||
sender.task(WalletTask::NostrSend(
|
||||
AMOUNT,
|
||||
receiver_hex.clone(),
|
||||
Some("floonet e2e".to_string()),
|
||||
vec![],
|
||||
));
|
||||
|
||||
// Watch the sender's meta walk Created -> AwaitingS2 -> Finalized.
|
||||
// Generous window: two relays + two nodes + mixnet round trips.
|
||||
let finalized = wait_until("payment finalized", 900, || {
|
||||
if let Some(err) = sender_svc.last_send_error() {
|
||||
println!("[e2e] sender last_send_error: {err}");
|
||||
}
|
||||
sender_svc
|
||||
.store
|
||||
.all_tx_meta()
|
||||
.iter()
|
||||
.any(|m| matches!(m.status, NostrSendStatus::Finalized))
|
||||
});
|
||||
|
||||
println!(
|
||||
"[e2e] send->finalize elapsed {}s; finalized={finalized}",
|
||||
t_send.elapsed().as_secs()
|
||||
);
|
||||
// Dump both stores for the record.
|
||||
for (who, svc) in [("sender", sender_svc), ("receiver", recv_svc)] {
|
||||
for m in svc.store.all_tx_meta() {
|
||||
println!("[e2e] {who} meta {} -> {:?}", m.slate_id, m.status);
|
||||
}
|
||||
}
|
||||
|
||||
a.close();
|
||||
b.close();
|
||||
|
||||
assert!(
|
||||
finalized,
|
||||
"payment did not reach Finalized within the window (see meta trail above)"
|
||||
);
|
||||
println!("[e2e] SUCCESS: cross-relay + cross-node payment finalized over the floonet path");
|
||||
}
|
||||
}
|
||||
@@ -34,3 +34,6 @@ pub use utils::WalletUtils;
|
||||
|
||||
mod seed;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
mod e2e;
|
||||
|
||||
@@ -53,7 +53,7 @@ use std::io::Write;
|
||||
use std::net::{SocketAddr, TcpListener, ToSocketAddrs};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, AtomicU64, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::thread::Thread;
|
||||
@@ -85,6 +85,14 @@ pub struct Wallet {
|
||||
sync_thread: Arc<RwLock<Option<Thread>>>,
|
||||
/// Flag to check if wallet is syncing.
|
||||
syncing: Arc<AtomicBool>,
|
||||
/// On-demand node polling (Android battery): pause the heavy node sync at
|
||||
/// sync thread while the app is backgrounded and nothing is in flight.
|
||||
/// The relay+Nym nostr service keeps running regardless of this flag.
|
||||
node_polling_paused: Arc<AtomicBool>,
|
||||
/// Resume-signal counter closing the receipt-vs-pause race: bumped by
|
||||
/// [`Wallet::resume_node_polling`]; the sync thread only pauses when no
|
||||
/// resume arrived during the node sync it just completed.
|
||||
node_polling_resume_seq: Arc<AtomicU64>,
|
||||
/// Info loading progress in percents.
|
||||
info_sync_progress: Arc<AtomicU8>,
|
||||
/// Error on wallet loading.
|
||||
@@ -161,6 +169,8 @@ impl Wallet {
|
||||
account_time: Arc::new(Default::default()),
|
||||
sync_thread: Arc::from(RwLock::new(None)),
|
||||
syncing: Arc::new(AtomicBool::new(false)),
|
||||
node_polling_paused: Arc::new(AtomicBool::new(false)),
|
||||
node_polling_resume_seq: Arc::new(AtomicU64::new(0)),
|
||||
info_sync_progress: Arc::from(AtomicU8::new(0)),
|
||||
sync_error: Arc::from(AtomicBool::new(false)),
|
||||
sync_attempts: Arc::new(AtomicU8::new(0)),
|
||||
@@ -478,6 +488,27 @@ impl Wallet {
|
||||
/// shares nothing with it), atomically moving the registered username
|
||||
/// (if any) to the new key via the name server. Blocking (network I/O):
|
||||
/// call from a worker thread. Returns the new bech32 npub.
|
||||
/// The nostr secret key (nsec, bech32) for this wallet, gated on the wallet
|
||||
/// password. Used by Advanced → "Nostr key" so the user can copy it or show
|
||||
/// it as a QR to log in to nostr apps (e.g. magick.market). Unlocking the
|
||||
/// stored identity both verifies the password and yields the keys, so a
|
||||
/// wrong password can never leak the key. The value is derived on demand and
|
||||
/// never persisted.
|
||||
pub fn get_nostr_nsec(&self, password: String) -> Result<String, String> {
|
||||
let svc = self
|
||||
.nostr_service()
|
||||
.ok_or_else(|| "nostr identity not ready".to_string())?;
|
||||
use nostr_sdk::ToBech32;
|
||||
let keys = svc
|
||||
.identity
|
||||
.read()
|
||||
.unlock(&password)
|
||||
.map_err(|_| "wrong password".to_string())?;
|
||||
keys.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|e| format!("nsec encode failed: {e}"))
|
||||
}
|
||||
|
||||
pub fn rotate_nostr_identity(&self, password: String) -> Result<String, String> {
|
||||
let svc = self
|
||||
.nostr_service()
|
||||
@@ -573,13 +604,8 @@ impl Wallet {
|
||||
backup-password field."
|
||||
.to_string()
|
||||
})?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(
|
||||
&keys,
|
||||
&password,
|
||||
backup.source,
|
||||
backup.derivation_account,
|
||||
)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
ident.nip05 = backup.nip05.clone();
|
||||
ident.anonymous = backup.anonymous;
|
||||
ident.prev_npubs = backup.prev_npubs.clone();
|
||||
@@ -595,13 +621,8 @@ impl Wallet {
|
||||
field"
|
||||
.to_string()
|
||||
})?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(
|
||||
&keys,
|
||||
&password,
|
||||
backup.source,
|
||||
backup.derivation_account,
|
||||
)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
ident.nip05 = backup.nip05.clone();
|
||||
ident.anonymous = backup.anonymous;
|
||||
ident.prev_npubs = backup.prev_npubs.clone();
|
||||
@@ -915,6 +936,8 @@ impl Wallet {
|
||||
// Retrieve txs from database.
|
||||
let mut txs: Vec<TxLogEntry> = w
|
||||
.tx_log_iter()?
|
||||
.filter(|tx| tx.is_ok())
|
||||
.map(|tx| tx.unwrap())
|
||||
.filter(|tx_entry| tx_entry.parent_key_id == parent_key_id)
|
||||
// Filter transactions to not show txs without slate (usually unspent outputs).
|
||||
.filter(|tx| {
|
||||
@@ -958,6 +981,8 @@ impl Wallet {
|
||||
let parent_key_id = w.parent_key_id();
|
||||
// Retrieve txs from database.
|
||||
w.tx_log_iter()?
|
||||
.filter(|tx| tx.is_ok())
|
||||
.map(|tx| tx.unwrap())
|
||||
.filter(|tx_entry| tx_entry.parent_key_id == parent_key_id)
|
||||
.filter(|tx_entry| {
|
||||
if tx_entry.tx_type == TxLogEntryType::TxSent
|
||||
@@ -1149,6 +1174,25 @@ impl Wallet {
|
||||
self.syncing.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Check if the heavy node polling at sync thread is paused (on-demand
|
||||
/// node polling: Android battery optimization, never set on desktop).
|
||||
pub fn node_polling_paused(&self) -> bool {
|
||||
self.node_polling_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Resume node polling and wake the sync thread. Called when the app is
|
||||
/// foreground again (the user expects a live balance) and when a slatepack
|
||||
/// arrives needing node work (post/confirm). MONEY-SAFETY: bumps the
|
||||
/// resume counter first so a pause decision computed from an older sync
|
||||
/// snapshot can never override this signal (see
|
||||
/// [`maybe_pause_node_polling`]).
|
||||
pub fn resume_node_polling(&self) {
|
||||
self.node_polling_resume_seq.fetch_add(1, Ordering::SeqCst);
|
||||
if self.node_polling_paused.swap(false, Ordering::SeqCst) {
|
||||
self.sync();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get running Foreign API server port.
|
||||
pub fn foreign_api_port(&self) -> Option<u16> {
|
||||
let r_api = self.foreign_api_server.read();
|
||||
@@ -1843,6 +1887,8 @@ impl Wallet {
|
||||
// Find wallet transaction to update or create.
|
||||
let txs = w
|
||||
.tx_log_iter()?
|
||||
.filter(|tx| tx.is_ok())
|
||||
.map(|tx| tx.unwrap())
|
||||
.filter(|entry| {
|
||||
if let Some(excess) = entry.kernel_excess {
|
||||
return excess == proof.excess;
|
||||
@@ -2069,8 +2115,22 @@ fn start_sync(wallet: Wallet) -> Thread {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync wallet from node.
|
||||
sync_wallet_data(&wallet, true);
|
||||
// On-demand node polling (Android battery): while the app is
|
||||
// backgrounded and no transaction is waiting on the node, skip
|
||||
// the heavy node sync. The relay+Nym nostr service started
|
||||
// above keeps running and listening for gift wraps regardless;
|
||||
// a slatepack receipt resumes polling instantly (see
|
||||
// `resume_node_polling`). Foreground always polls.
|
||||
if crate::app_foreground() {
|
||||
wallet.resume_node_polling();
|
||||
}
|
||||
if !wallet.node_polling_paused() {
|
||||
let resume_seq = wallet.node_polling_resume_seq.load(Ordering::SeqCst);
|
||||
// Sync wallet from node.
|
||||
sync_wallet_data(&wallet, true);
|
||||
// Pause polling when it's safe to (Android only).
|
||||
maybe_pause_node_polling(&wallet, resume_seq);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop sync if wallet was closed.
|
||||
@@ -2100,6 +2160,57 @@ fn start_sync(wallet: Wallet) -> Thread {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Pause the heavy node polling after a completed node sync when it's safe
|
||||
/// (Android only — desktop always polls): the app is backgrounded AND the
|
||||
/// fresh sync shows nothing waiting on the node AND no resume signal
|
||||
/// (slatepack receipt / foreground) arrived while that sync ran.
|
||||
/// MONEY-SAFETY (non-negotiable): confirmation tracking is never dropped —
|
||||
/// any unconfirmed send/receive keeps the node polled until it confirms, and
|
||||
/// when in doubt (failed sync, no data, unknown txs) we keep polling.
|
||||
#[allow(unused_variables)]
|
||||
fn maybe_pause_node_polling(wallet: &Wallet, resume_seq_before: u64) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
// Foreground: the user expects a live balance.
|
||||
if crate::app_foreground() {
|
||||
return;
|
||||
}
|
||||
// Only pause after a clean, settled sync from the node.
|
||||
if wallet.sync_error() || wallet.get_sync_attempts() != 0 {
|
||||
return;
|
||||
}
|
||||
let Some(data) = wallet.get_data() else {
|
||||
return;
|
||||
};
|
||||
// Anything unconfirmed — a send awaiting reply/broadcast or a receive
|
||||
// awaiting confirmation — keeps the node polled until it confirms.
|
||||
// Unknown txs count as in flight (when in doubt, poll).
|
||||
let in_flight = data
|
||||
.txs
|
||||
.as_ref()
|
||||
.map(|txs| {
|
||||
txs.iter().any(|tx| {
|
||||
!tx.data.confirmed
|
||||
&& matches!(
|
||||
tx.data.tx_type,
|
||||
TxLogEntryType::TxSent | TxLogEntryType::TxReceived
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if in_flight {
|
||||
return;
|
||||
}
|
||||
wallet.node_polling_paused.store(true, Ordering::SeqCst);
|
||||
// A slatepack receipt (or foreground) may have raced this pause while
|
||||
// the sync above ran — its transaction may not be in the data snapshot
|
||||
// we just inspected. The resume always wins.
|
||||
if wallet.node_polling_resume_seq.load(Ordering::SeqCst) != resume_seq_before {
|
||||
wallet.node_polling_paused.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a wallet error to a short, user-facing reason for the failure screen so
|
||||
/// "Couldn't send" actually explains itself — most often locked/unconfirmed
|
||||
/// funds after a recent payment.
|
||||
@@ -2363,6 +2474,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
nip05: None,
|
||||
nip05_verified_at: None,
|
||||
relays: relay_hints.clone(),
|
||||
nip44_v3: false,
|
||||
hue: crate::gui::views::goblin::data::hue_of(receiver)
|
||||
as u8,
|
||||
unknown: true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||