Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9faae5722e |
@@ -1,21 +0,0 @@
|
||||
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"
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Fetch patched nym SDK
|
||||
description: >
|
||||
Clone the patched nym workspace from our own mirror
|
||||
(git.us-ea.st/GRIN/nym, branch `goblin` = upstream nymtech/nym @ b6eb391 +
|
||||
Goblin's Android webpki-roots patch) into ../nym, so the
|
||||
`nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }` dependency resolves.
|
||||
Self-hosted: no upstream-GitHub fetch and no patch-apply step.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clone patched nym
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEST="$(dirname "$GITHUB_WORKSPACE")/nym"
|
||||
if [ -e "$DEST/sdk/rust/nym-sdk/Cargo.toml" ]; then
|
||||
echo "nym already present at $DEST"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "$DEST"
|
||||
git clone --branch goblin --depth 1 https://git.us-ea.st/GRIN/nym.git "$DEST"
|
||||
echo "nym cloned from GRIN/nym@goblin -> $DEST"
|
||||
@@ -1,6 +1,12 @@
|
||||
name: Build
|
||||
on: [push, pull_request]
|
||||
|
||||
# aws-lc-sys (pulled in by nym-sdk) builds AWS-LC, which needs NASM on native
|
||||
# Windows. Use the prebuilt NASM objects the crate ships so the runner doesn't
|
||||
# need NASM installed; harmless on Linux/macOS.
|
||||
env:
|
||||
AWS_LC_SYS_PREBUILT_NASM: 1
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Linux Build
|
||||
@@ -9,8 +15,8 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
# nip44 is a path dep on ../nip44; materialize it before building.
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
# nym-sdk is a path dep on ../nym; materialize it before building.
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -21,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -32,6 +38,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -8,12 +8,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# 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]
|
||||
# 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.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -25,6 +23,8 @@ permissions:
|
||||
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
# aws-lc-sys (via nym-sdk) needs NASM on native Windows; use its prebuilt NASM.
|
||||
AWS_LC_SYS_PREBUILT_NASM: 1
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: GOBLIN_BUILD="${TAG#build}" cargo build --release
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- name: Build both architectures
|
||||
run: |
|
||||
export GOBLIN_BUILD="${TAG#build}"
|
||||
|
||||
@@ -7,9 +7,6 @@ android/keystore
|
||||
android/keystore.asc
|
||||
android/keystore.properties
|
||||
android/*.apk
|
||||
android/*.apk.sha256
|
||||
android/*.AppImage
|
||||
android/*.AppImage.sha256
|
||||
android/*sha256sum.txt
|
||||
/.idea
|
||||
.DS_Store
|
||||
@@ -27,12 +24,3 @@ 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
|
||||
# Internal E2E harnesses — reference funded wallets / live relays; kept on disk,
|
||||
# untracked (mod e2e is gated behind the `e2e-internal` feature)
|
||||
src/wallet/e2e.rs
|
||||
tests/nostr_e2e.rs
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "grim"
|
||||
version = "0.3.6"
|
||||
authors = ["Ardocrat <ardocrat@gri.mw>"]
|
||||
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and Tor handled for you."
|
||||
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and the Nym mixnet handled for you."
|
||||
license = "Apache-2.0"
|
||||
repository = "https://code.gri.mw/GUI/grim"
|
||||
keywords = [ "crypto", "grin", "mimblewimble", "nostr" ]
|
||||
@@ -31,20 +31,6 @@ lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[features]
|
||||
## Default build uses the Tor transport only. The `nym` feature gates the dormant
|
||||
## mixnet path (src/nym/). Cargo resolves OPTIONAL deps into the graph too, so
|
||||
## nym-sdk cannot merely be `optional` — it links a different libsqlite3-sys than
|
||||
## arti (a native-lib `links` conflict Cargo rejects at resolution). The nym
|
||||
## path-deps are therefore commented out below; the module code is retained on
|
||||
## disk but building `--features nym` requires restoring them (and drops arti —
|
||||
## the two transports cannot coexist in one binary, which is why Tor replaced Nym).
|
||||
default = []
|
||||
nym = []
|
||||
## Compiles the internal E2E harness (src/wallet/e2e.rs); off by default and the
|
||||
## file is untracked, so a fresh clone builds without it.
|
||||
e2e-internal = []
|
||||
|
||||
[dependencies]
|
||||
log = "0.4.27"
|
||||
|
||||
@@ -101,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", "tokio"] }
|
||||
hyper-util = { version = "0.1.19", features = ["http1", "client", "client-legacy"] }
|
||||
http-body-util = "0.1.3"
|
||||
bytes = "1.11.0"
|
||||
hyper-socks2 = "0.9.1"
|
||||
@@ -114,58 +100,29 @@ 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"
|
||||
## rustls is pulled by both our TLS (tungstenite, ring) and nym-sdk
|
||||
## 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
|
||||
## (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. NOTHING here may pull
|
||||
## rustls-platform-verifier — it panics on Android outside a full app context.
|
||||
## `rustls::crypto::ring::default_provider()` reachable.
|
||||
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"
|
||||
|
||||
## Tor — embedded arti (the DIALING half only: connect OUT to the relay's .onion,
|
||||
## and to clearnet HTTP hosts through a Tor exit). Copied from our sister wallet
|
||||
## GRIM's proven, shipping engine. Two choices inherited VERBATIM from GRIM: arti
|
||||
## 0.43 across the family, and the native-tls Tor runtime (TokioNativeTlsRuntime),
|
||||
## NOT rustls — this deliberately sidesteps the rustls/ring crypto-provider
|
||||
## conflict fought during the Nym era (our relay/HTTP rustls still uses ring, see
|
||||
## lib.rs; arti's own TLS is native-tls and never touches the rustls provider).
|
||||
## `static` vendors openssl (self-contained Android/cross builds, as GRIM ships);
|
||||
## `onion-service-client` enables dialing .onion. We drop GRIM's `pt-client`
|
||||
## (bridges) and `onion-service-service` (hosting) — Goblin only dials.
|
||||
arti-client = { version = "0.43.0", features = ["static", "onion-service-client"] }
|
||||
tor-rtcompat = { version = "0.43.0", features = ["static"] }
|
||||
|
||||
## Nym mixnet — DORMANT since the Tor transport swap. The mixnet path (src/nym/)
|
||||
## is retained on disk but its deps are COMMENTED OUT, because arti's `tor-dirmgr`
|
||||
## needs libsqlite3-sys 0.34 (rusqlite 0.36) while nym-sdk's credential-storage
|
||||
## needs libsqlite3-sys 0.30 (sqlx) and BOTH link the native `sqlite3` library —
|
||||
## Cargo forbids two packages linking the same native lib, and it rejects this at
|
||||
## RESOLUTION even for optional/unused deps. The two transports therefore cannot
|
||||
## coexist in one binary (exactly why Tor replaced Nym). To build the old path,
|
||||
## restore these three deps and build `--features nym` (which then drops arti).
|
||||
## Full deletion is a later phase; for now the code stays on disk for reference.
|
||||
## Path deps into the local nym checkout, PINNED at rev
|
||||
## f6ed17d949cc19fee0fb51db3cb65771fd510d5b ("http-api-client: preconfigured
|
||||
## webpki roots on Android").
|
||||
# nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
|
||||
# smolmix = { path = "../nym/smolmix/core" }
|
||||
# hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
|
||||
## 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-sdk = { path = "../nym/sdk/rust/nym-sdk" }
|
||||
|
||||
## NIP-98 payload hashing
|
||||
sha2 = "0.10.8"
|
||||
@@ -227,8 +184,3 @@ base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
serde_yaml = "0.9"
|
||||
## Re-expose deps that already live in the main graph so the E2E harness can be
|
||||
## exercised and its logs captured. No new compiles — same versions unify.
|
||||
log = "0.4.27"
|
||||
env_logger = "0.11.3"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
|
||||
@@ -4,28 +4,27 @@
|
||||
|
||||
# Goblin
|
||||
|
||||
Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) - confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
|
||||
Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) — confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
|
||||
|
||||
Instead of passing slatepack files back and forth, you **pay a `username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through [Tor](https://www.torproject.org)**. Relays only ever see ciphertext - never the amount, the sender, or the recipient. Tor hides your IP from the relay; the relay and encryption hide the rest - content, sender, timing.
|
||||
Instead of passing slatepack files back and forth, you **pay a `username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through the [Nym mixnet](https://nym.com)**. Relays only ever see ciphertext — never the amount, the sender, or the recipient — and the mixnet hides who is talking to whom at the network layer.
|
||||
|
||||
Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN node/wallet engine and layers a Nostr-native, mobile-first payments experience on top.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Send to people** - pay a `username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over Tor and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
|
||||
- **Manual slatepacks too** - when you need to pay or get paid without a handle, **Settings → Wallet → Slatepacks** exposes the classic by-hand flow: create a slatepack to send, or paste one to receive, finalize, or pay.
|
||||
- **In-app identity** - a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service.
|
||||
- **Private by construction** - GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through [Tor](https://www.torproject.org), so who-pays-whom never touches the clear net. The GRIN node connection - block sync and broadcasting your transaction - is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device.
|
||||
- **Configurable amount pairing** - show balances against a world currency, Bitcoin, or sats (rates fetched over Tor), or turn the preview off.
|
||||
- **News on Home** - the latest post from the official Goblin news key (a [kind 30023](https://nostrbook.dev/kinds/30023) long-form article) appears on the Home screen; it stays hidden when there is nothing to show, and only ever shows the newest article.
|
||||
- **Cross-platform** - Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
|
||||
- **Send to people** — pay a `username` or `npub`; the GRIN slatepack travels as a [NIP-17](https://nips.nostr.com/17) gift-wrapped DM ([kind 1059](https://nostrbook.dev/kinds/1059)) over the Nym mixnet and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
|
||||
- **Manual slatepacks too** — when you need to pay or get paid without a handle, **Settings → Wallet → Slatepacks** exposes the classic by-hand flow: create a slatepack to send, or paste one to receive, finalize, or pay.
|
||||
- **In-app identity** — a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service.
|
||||
- **Private by construction** — GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through the [Nym mixnet](https://nym.com), so who-pays-whom never touches the clear net. The GRIN node connection — block sync and broadcasting your transaction — is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device.
|
||||
- **Configurable amount pairing** — show balances against a world currency, Bitcoin, or sats (rates fetched over the mixnet), or turn the preview off.
|
||||
- **Cross-platform** — Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
|
||||
|
||||
## How a payment travels
|
||||
|
||||
```
|
||||
you ──slatepack──▶ NIP-17 gift wrap (kind 1059, NIP-44 encrypted)
|
||||
│
|
||||
Tor
|
||||
Nym mixnet (5-hop)
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
your relays recipient's DM relays (kind 10050)
|
||||
@@ -34,7 +33,7 @@ Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN nod
|
||||
recipient ◀──unwrap, verify seal author, apply slatepack
|
||||
```
|
||||
|
||||
The wrap is [NIP-44](https://nips.nostr.com/44)-encrypted, and delivery uses the recipient's DM relay list ([kind 10050](https://nostrbook.dev/kinds/10050)). Tor hides your IP from the relay; the relay and the encryption above hide the rest - content, sender, timing.
|
||||
The wrap is [NIP-44](https://nips.nostr.com/44)-encrypted, and delivery uses the recipient's DM relay list ([kind 10050](https://nostrbook.dev/kinds/10050)).
|
||||
|
||||
Both parties only need one relay in common. The default set is the Goblin relay plus large public relays (`relay.damus.io`, `nos.lol`), and the set is editable in **Settings → Relays**.
|
||||
|
||||
@@ -42,15 +41,16 @@ Both parties only need one relay in common. The default set is the Goblin relay
|
||||
|
||||
### Desktop (Linux / macOS / Windows)
|
||||
|
||||
Goblin links [Tor](https://www.torproject.org) **in-process** via [arti](https://gitlab.torproject.org/tpo/core/arti) - the wallet is a single self-contained binary, no sidecar, nothing separate to install:
|
||||
Goblin links the [Nym mixnet](https://nym.com) SDK **in-process** — the wallet is a single self-contained binary, no sidecar. The SDK builds from a sibling `../nym` checkout (a pinned nym tree with a small Android TLS patch):
|
||||
|
||||
```
|
||||
git clone --branch goblin https://git.us-ea.st/GRIN/nym ../nym
|
||||
git submodule update --init --recursive
|
||||
cargo build --release
|
||||
./target/release/goblin
|
||||
```
|
||||
|
||||
Goblin's identity and payment traffic (nostr relays, NIP-05 lookups and price fetches) rides Tor: every relay, the money-path relay included, is reached over a Tor exit to its ordinary clearnet host. The GRIN node connection (block sync and transaction broadcast) is **not** routed through Tor: it connects directly, as it carries only public chain data that isn't linked to your wallet.
|
||||
Goblin's identity and payment traffic — nostr relays, NIP-05 lookups and price fetches — is routed over the mixnet through a network requester (the default is baked into `NETWORK_REQUESTER` in `src/nym/sidecar.rs`); the SDK's SOCKS5 listener is run in-process on `127.0.0.1:1080`. If something is already listening there, Goblin reuses it. The GRIN node connection (block sync and transaction broadcast) is **not** mixed — it connects directly, as it carries only public chain data that isn't linked to your wallet.
|
||||
|
||||
### Android
|
||||
|
||||
@@ -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 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).
|
||||
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).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -11,15 +11,8 @@ android {
|
||||
applicationId "st.goblin.wallet"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
// 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
|
||||
versionCode 5
|
||||
versionName "0.3.6"
|
||||
}
|
||||
|
||||
lint {
|
||||
|
||||
@@ -23,14 +23,6 @@ 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 = "";
|
||||
@@ -78,13 +70,12 @@ public class BackgroundService extends Service {
|
||||
if (Build.VERSION.SDK_INT > 25) {
|
||||
startStopIntent.putExtra(EXTRA_NOTIFICATION_ID, NOTIFICATION_ID);
|
||||
}
|
||||
// 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) {
|
||||
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) {
|
||||
startStopIntent.setAction(ACTION_STOP_NODE);
|
||||
PendingIntent i = PendingIntent
|
||||
.getBroadcast(BackgroundService.this, 1, startStopIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT);
|
||||
@@ -198,74 +189,6 @@ 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,18 +421,6 @@ 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);
|
||||
@@ -631,12 +619,7 @@ public class MainActivity extends GameActivity {
|
||||
// Called from native code to pick the file.
|
||||
public void pickFile() {
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
// 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);
|
||||
intent.setType("text/*");
|
||||
try {
|
||||
mFilePickResult.launch(Intent.createChooser(intent, "Pick file"));
|
||||
} catch (android.content.ActivityNotFoundException ex) {
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 965 B After Width: | Height: | Size: 615 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -46,19 +46,19 @@ 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");
|
||||
}
|
||||
|
||||
// Goblin's private transport is Tor via embedded arti (see src/tor/), linked
|
||||
// in-process — no sidecar subprocess and no bundled/embedded helper binary.
|
||||
// There is nothing transport-related to build or embed here.
|
||||
// Goblin links the Nym mixnet SDK in-process (see src/nym/) — no sidecar
|
||||
// subprocess, no bundled/embedded helper binary, and no Tor/webtunnel. There
|
||||
// is nothing transport-related to build or embed here.
|
||||
|
||||
// Embed the Goblin icon into goblin.exe so Explorer, the taskbar and Alt-Tab
|
||||
// show it even for the bare exe (the .msi shortcuts already carry it). No-op
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# Goblin Privacy Transport Redesign
|
||||
|
||||
**Status:** Decided — implementation plan · **Date:** 2026-07-04 · **Author:** Architecture
|
||||
**Scope:** Replace the Nym-mixnet transport that carries Goblin's Nostr traffic with embedded Tor, GRIM-style, and rebuild the one privacy property Tor lacks (send/receive timing unlinkability) on our own relay.
|
||||
**Hard constraints (owner):** No clearnet money path · No NYM tokens · No rented/metered privacy bandwidth · In-process only (no sidecar daemon, no system VPN/TUN — must work on Android and iOS).
|
||||
|
||||
---
|
||||
|
||||
## The decision (read this first)
|
||||
|
||||
**Goblin is dropping the Nym mixnet and returning to Tor, embedded in-process, exactly the way our sister wallet GRIM already ships it.** The wallet keeps everything else it does today — payments are still gift-wrapped Nostr messages that our own relay stores and forwards, so a recipient can be paid while offline. The only thing that changes is the private pipe *underneath* the relay connection: instead of tunnelling to the relay across the Nym mixnet, the wallet dials the relay's **pinned `.onion` address over embedded arti** (Tor written in Rust).
|
||||
|
||||
We do not invent our own Tor engine. We **copy GRIM's** — a proven ~1,300-line engine in four files (`grim/src/tor/`) that already runs in production on desktop and Android. And because a mixnet's real gift was hiding the *timing* link between a sender uploading a payment and a recipient downloading it — something Tor alone does not do — **we rebuild that timing privacy on our own relay**: it holds each incoming gift-wrap and releases it to the recipient after a short randomized (Poisson) delay. Same fuzzing a mixnet performs, done by the one server we fully control, unmetered, and unable to collapse the way rented mixnet bandwidth did.
|
||||
|
||||
This trades exactly one thing: a real distributed mixnet resists a *global passive adversary* who can watch the whole internet at once. A single relay plus Tor does not. That adversary is out of scope for a low-value payments wallet, and Tor never claimed to stop it either. In return we get a transport that is mature, free, unmetered, has a huge anonymity set, is lighter on the battery, and — measured where the user actually waits — **faster**.
|
||||
|
||||
---
|
||||
|
||||
## Why we are leaving the mixnet: confirmed root cause
|
||||
|
||||
This is not a transient outage we can wait out. It is **removal by design**, traced to Nym's own source:
|
||||
|
||||
- A credential-less Nym client is granted a hard-coded `FREE_TESTNET_BANDWIDTH_VALUE = 64 GiB`, and that grant **expires at the next UTC-midnight rollover** (`ecash_today()`).
|
||||
- An entry gateway only honours that free grant while `enforce_zk_nyms = false`.
|
||||
- Public entry gateways are flipping `enforce_zk_nyms = true` **network-wide** as Nym rolls out paid, NYM-token-gated "zk-nym ticketbook" bandwidth (≈ 225 NYM for ~25 GB).
|
||||
|
||||
So the free tier the wallet floated on is **testnet scaffolding Nym is actively deleting**, and its failure recurs *on a schedule*. It went dark on us more than once. The only supported replacement — paid ticketbooks — **requires holding NYM tokens** (an owner red line) and offers no sats/Lightning purchase path. A payments wallet cannot stand on a foundation that disappears at midnight and can only be rented back with a specific speculative token. The mixnet, as we were able to consume it, is a dead end.
|
||||
|
||||
Tor has none of these properties. It is free, unmetered, has no token, no bonding, no grant to expire, and the largest anonymity set of any deployed privacy network. It runs in-process on a phone, and GRIM has already proven the whole embedded path.
|
||||
|
||||
---
|
||||
|
||||
## Threat model: what we are actually protecting
|
||||
|
||||
We reason about leakage at six levels, sender → relay → receiver:
|
||||
|
||||
| Level | Leak | Status under the Tor plan |
|
||||
|---|---|---|
|
||||
| **L1 network** | sender/receiver IP as seen by the relay and on-path observers | **Covered by Tor** — an onion connection has no exit node, so the relay and every hop see a Tor address, never the phone's IP. |
|
||||
| **L2 timing** | correlation of send-time with receive-time (the mixnet's core property) | **Rebuilt on our relay** — Poisson release delay (see below), plus NIP-59 timestamp backdating already in the wallet. |
|
||||
| **L3 volume** | message-size correlation | NIP-44 v2 padding blunts; gift-wraps are already near-uniform at payments volume. |
|
||||
| **L4 relay-visible** | recipient p-tags, kinds, subscription filters, connection cadence | NIP-59 gift-wrap blunts; unchanged by the transport swap. |
|
||||
| **L5 content** | message plaintext | **Already solved** — NIP-44 v2 inside NIP-59 gift-wrap (kind 1059). |
|
||||
| **L6 long-term intersection** | repeated-pattern deanonymization | NIP-59 blunts; unchanged. |
|
||||
|
||||
Our **realistic adversary** is the relay operator, ISPs, near-endpoint observers, and chain-analysts — **not a global passive adversary (GPA)**. GPA-resistance is explicitly out of scope for a low-value payments wallet; Tor itself states plainly that it "does not defend against" an attacker who can watch **both ends** of a circuit. We inherit that boundary knowingly and, for the adversary that actually matters, we cover every level.
|
||||
|
||||
---
|
||||
|
||||
## The architecture
|
||||
|
||||
The whole change is contained because Goblin already isolates its transport behind one trait. Payments still ride the relay-mediated, store-and-forward model unchanged. Four things move:
|
||||
|
||||
**1. The relay hosts an onion service.** Plain, mature **system Tor** runs on the relay's own server (`torrc`: `HiddenServiceDir` + `HiddenServicePort 443 127.0.0.1:443`) and forwards the onion straight to the relay's existing secure-websocket port. This is battle-tested C-Tor doing the hosting half — the strongest, most-audited part of the Tor codebase — and it replaces the custom `floonet-mixexit` byte-pipe binary entirely.
|
||||
|
||||
**2. The wallet dials that onion over embedded arti.** Goblin only needs the **dialing half** of Tor — connect *out* to the relay's onion. It never hosts a service (that is what makes it simpler than GRIM, which hosts an onion to *receive*). `arti-client`'s `TorClient::connect()` returns a `DataStream` that implements `AsyncRead + AsyncWrite` — a drop-in for the byte source the wallet feeds to its websocket layer today.
|
||||
|
||||
**3. We copy GRIM's Tor engine rather than write our own.** GRIM's `src/tor/` is ~1,300 lines across four files (`config.rs`, `mod.rs`, `tor.rs`, `types.rs`) and is already in production. Two technical choices we inherit verbatim because GRIM already paid for them:
|
||||
- **arti 0.43 across the whole arti family** (`arti-client`, `tor-rtcompat`, `tor-config`, `tor-hsservice`, `tor-hsrproxy`, `tor-keymgr`, `tor-llcrypto`, `tor-hscrypto` — all `0.43.0`).
|
||||
- **The native-tls Tor runtime** (`TokioNativeTlsRuntime`), **not rustls**. This deliberately sidesteps the rustls crypto-provider (ring) conflict we fought all through the Nym era. We take GRIM's known-good TLS path and never re-open that wound.
|
||||
|
||||
**4. The two code seams that carry private traffic switch to Tor; the money node does not.** Exactly two paths leave the device with anything sensitive on them: the **relay websocket**, and the **one HTTP helper** that carries all the small lookups (names at `goblin.st`, relay hints, the pinned-pool refresh, price, avatars). Both re-route through arti. The **Grin blockchain node stays on the clear internet exactly as today, unchanged** — it never sees who is paying whom, only opaque transaction data, and Tor-wrapping it would buy nothing but latency.
|
||||
|
||||
**The pin lives where the old Nym exit pin lived.** Our relay-pool gist already carries a per-relay field for exactly this, and its parser is deliberately tolerant of unknown fields (no `deny_unknown_fields`, `version` stays `1`). So we add an `onion` address next to each relay entry and **older builds simply ignore it** — no flag day, no schema break. The plumbing that already resolves the co-located Nym `exit` for a relay URL generalizes one-for-one to resolve its `onion`.
|
||||
|
||||
---
|
||||
|
||||
## Recovering timing privacy: Poisson on the relay
|
||||
|
||||
Here is the one property we cannot get from Tor for free, and how we rebuild it.
|
||||
|
||||
A mixnet's genuine value was **timing unlinkability**: even someone who can see traffic near both ends cannot match "this sender uploaded at 10:01:03" to "that recipient downloaded at 10:01:04," because the mixnet deliberately shuffles and delays messages so the two events do not line up. Tor is low-latency by design and does *not* do this — a payment flows through as fast as the circuit allows.
|
||||
|
||||
We rebuild it in the one place we fully own: **our relay holds each incoming gift-wrap and releases it to the recipient after a randomized, exponentially-distributed (Poisson) delay.** This is the same fuzzing a mixnet performs, collapsed onto a single hop we operate ourselves — unmetered, always on, and immune to the rented-bandwidth failure that just killed Nym for us.
|
||||
|
||||
**The elegant part: this costs the user nothing they can see.** The sender's on-screen "Sent" clears the moment the relay **confirms it holds the message** — not when the recipient receives it. Delivery to the recipient is already asynchronous and invisible (they might be offline for hours). The Poisson delay lands *entirely inside that already-invisible gap.* We buy back the mixnet's timing privacy at **zero visible latency cost**. And it stacks on top of a fuzz the wallet already applies: NIP-59 backdates every gift-wrap's timestamp by a random offset of up to two days, so even the timestamps on the wire are decorrelated from real send time.
|
||||
|
||||
**The honest trade-off, stated plainly:** a real distributed mixnet spreads its mixing across many independent nodes, which is what lets it resist a global passive adversary watching the entire network at once. Our single relay plus Tor does not — a party who could simultaneously observe our relay *and* the recipient's Tor guard *and* correlate the Poisson-delayed release could, in principle, still link the two ends. That is the global-adversary threat, it is out of scope for a Grin payments wallet, and Tor never defended against it either. For the adversary who actually exists — the relay operator, an ISP, a network snoop, a chain-analyst — relay-side Poisson closes the timing gap. We give up one theoretical guarantee and gain reliability, speed, and battery.
|
||||
|
||||
---
|
||||
|
||||
## What we must preserve (load-bearing, transport-agnostic)
|
||||
|
||||
Three things in today's wallet are not Nym-specific and must survive the swap intact.
|
||||
|
||||
**1. The "connection is genuinely live and carrying traffic" readiness signal.** The UI carefully refuses to show "Connected" until a relay is *actually subscribed and carrying traffic on the current tunnel*, not merely until the pipe opened (`transport_ready()` / `warm_up()` / relay-gated generation counter in `src/nym/nymproc.rs`). This is what prevents a reassuring-but-false "Connected" over a tunnel that cannot yet deliver. **Keep the mechanism; re-point it at Tor** — readiness becomes "arti has bootstrapped, the onion circuit is up, and a required relay is subscribed on it."
|
||||
|
||||
**2. The read-back confirm on sending (keep verbatim).** The wallet does **not** report a payment "Sent" on a transport-write success. It performs a genuine read-back: after publishing, it polls the target relays for the event id until one confirms it actually **holds the gift-wrap**, or a timeout is hit — in which case it surfaces failure so the caller retries instead of silently dropping money (`src/nostr/client.rs`, the SILENT-LOSS GUARD loop). This is pure money-safety and is completely transport-agnostic. **Keep the logic exactly as written.** Only the *comments* that say "over the scoped Nym exit / mixnet transport" need their wording generalized to "the transport"; the guard itself does not change a line, and it is in fact the very mechanism that makes relay-side Poisson safe (the sender is told "Sent" precisely when the relay confirms it holds the message).
|
||||
|
||||
**3. User-facing copy that says "Nym" / "Mixnet."** The strings need rewording to Tor across all six locales (`locales/en.yml` plus `tr`, `fr`, `de`, `ru`, `zh-CN`): `connected_nym` ("Connected over Nym" → "Connected over Tor"), `connecting_nym`, `nym_ready`, `mixnet_routing`, `network_value` ("MW + Nym mixnet + nostr"), `privacy_value` ("Mimblewimble + Nym"), `over_mixnet`, `rates_note`, `send_like_message_body`, `row_delivery_val`, `row_privacy_val`, and the onboarding `intro` that describes "a five-hop network." These become plain, honest Tor language — see the forum post at the end for the tone.
|
||||
|
||||
---
|
||||
|
||||
## Mobile reality (told straight)
|
||||
|
||||
**Android is solved.** GRIM already ships embedded Tor on Android and the recipe is copyable. It comes down to two things: **build Tor into the app's native library** (arti compiles into the same `.so` the rest of the Rust already lives in — no separate process, no sidecar), and **set a few environment variables before the Rust runtime starts.** The critical one is `ARTI_FS_DISABLE_PERMISSION_CHECKS=true` (GRIM sets it in `MainActivity.java` before loading native code): arti's `fs-mistrust` layer normally refuses to start if its state directory has "too-open" Unix permissions, and Android's app-sandbox filesystem always trips that check — so without this flag Tor simply never boots on a phone. This is a known, small, copy-paste fix, not a research problem.
|
||||
|
||||
**iOS is green-field and needs its own spike.** The arti library should compile for iOS — nothing about it is Android-specific — but nobody on our side has shipped it there yet, so we treat it as unproven until we have. Two things to flag going in: (a) we run **without pluggable-transport bridges** on iOS, because the platform won't let an app spawn the helper processes those need — plain Tor only, which is fine for our reach-the-relay use case; and (b) the same `fs-mistrust` and state-directory questions need answering against iOS's sandbox. **Action: an iOS Tor spike is a named prerequisite before we claim iOS support.** Android does not wait on it.
|
||||
|
||||
---
|
||||
|
||||
## Integration surface (code anchors)
|
||||
|
||||
The transport is isolated behind one trait, so this is a contained swap, not a rewrite.
|
||||
|
||||
| Seam | Location today | Change |
|
||||
|---|---|---|
|
||||
| Transport trait | `nostr-relay-pool` `WebSocketTransport::connect(url, mode, timeout) -> (WebSocketSink, WebSocketStream)`; impl `NymWebSocketTransport` at **`src/nym/transport.rs:47`** | New arti-backed impl in a new `src/tor/`, engine copied from GRIM. |
|
||||
| Byte source | Nym `open_stream` at **`src/nym/streamexit.rs`** (returns `AsyncRead + AsyncWrite`) | Swap for arti `TorClient::connect(<onion>:443)` → `DataStream`. Keep the rest of the path. |
|
||||
| TLS + ws wrap | `client_async_tls(url, stream)` at **`src/nym/transport.rs:126` / `:158`** (SNI = relay host) | **Unchanged.** |
|
||||
| Injection point | `Client::builder().websocket_transport(...)` in **`src/nostr/client.rs`** | Inject `TorWebSocketTransport`. |
|
||||
| HTTP chokepoint | `http_request_bytes()` **`src/nym/mod.rs:71`** / `http_request()` **`src/nym/mod.rs:111`** — carries NIP-05 / name authority (`src/nostr/nip05.rs`), NIP-11 hints, pool gist, price, avatars | Re-route through arti: Tor→onion for the `goblin.st` name authority (pin a second onion); Tor→clearnet for the gist + price + avatars. **Grin node stays clearnet as today.** |
|
||||
| Pin plumbing (forward-safe) | `PoolRelay` at **`src/nostr/pool.rs:86`**; `exit: Option<String>` at **`:105`**; serde tolerant (no `deny_unknown_fields`); `exit_for()` **`:157`**, `exit_for_host()` **`:170`**, `has_exit()` **`:187`** | Add an `onion` field beside `exit` in the gist **without breaking old builds**; keep `version:1`. `exit_for*`/`has_exit` generalize to `onion_for*`/`has_onion`. |
|
||||
| Readiness lifecycle (**preserve**) | `warm_up()` **`src/nym/nymproc.rs:107`**, `transport_ready()` relay-gated readiness **`:127`–`:135`** | Re-point at Tor: bootstrapped + onion up + relay subscribed. Keep the gating semantics. |
|
||||
| Confirm-before-sent (**preserve verbatim**) | SILENT-LOSS GUARD read-back loop, doc at **`src/nostr/client.rs:63`** | Unchanged logic. Only reword the Nym-specific comments to "the transport." |
|
||||
| Footprint retired | `src/nym/*` = **2,842 lines** (`nymproc` 1073, `dns` 662, `streamexit` 465, `mod` 411, `transport` 231) + `nym-sdk`/`smolmix` path-deps + `floonet-mixexit` | Deleted once Tor is proven. Net code likely **shrinks** — most of `nymproc` exists only to fight Nym flakiness (gateway race / probe / condemn). |
|
||||
|
||||
The live pool already ships the floonet relay inline (`pool.rs:69` pins `wss://relay.floonet.dev` with its Nym `exit`). We add its `onion` alongside, in the same gist, and roll forward.
|
||||
|
||||
---
|
||||
|
||||
## What the wallet will feel like on Tor
|
||||
|
||||
An honest, moment-by-moment read on whether each everyday interaction gets faster, stays the same, or gets slower than it was on the mixnet. Short version: on pure speed, Tor is a lateral-to-favorable move everywhere the user actually waits.
|
||||
|
||||
**Receiving / listening — same-to-better, and never a spinner.** The wallet does not poll for incoming payments. It holds a **live, open subscription** to the relay, and payments are *pushed* down that already-open connection and processed with no human watching. That is invisible on either transport. The difference is that on Tor each pushed message arrives quicker (no per-hop mixing delay), and the deliberate Poisson privacy-delay hides inside the already-invisible delivery gap. Nothing the user sees changes; what is behind it is faster. **Verdict: same-to-better.**
|
||||
|
||||
**Sending — faster where it matters most.** The only part of a send the user actually watches is the marquee moment: find the recipient's relay, publish the wrapped payment, and wait for the relay to confirm it holds it. On the mixnet that moment paid three separate taxes — a per-message mixing delay, a fixed ~3-second stream-settle, and multi-fragment delivery lag. On Tor all three are gone. So this spinner should feel **shorter**, not longer. Everything after it — building the reply, finalizing, posting the finished transaction to the Grin node — is background work, and the node post is plain clear-internet exactly as before. **Verdict: faster where it matters most.**
|
||||
|
||||
**Invoices / requests — same-to-faster, dominated by the human.** There is no separate "invoice" message type on the wire; a request is the same gift-wrapped DM as a payment, just with a human approval gate in front of it. Transport latency matters even less here because a person is tapping "approve." **Verdict: same-to-faster, dominated by the human step.**
|
||||
|
||||
**Name and identity lookups — fast, unchanged in feel.** Resolving `name@goblin.st` is a single HTTP GET behind a short "Searching…" spinner — the one genuinely blocking lookup in the app. Everything else (reverse name lookups, avatars, relay hints, pool refresh, price) is cached, quiet background work the user never waits on. A warm keep-alive connection pool over Tor makes repeat lookups cheap. **Verdict: fast and unchanged in feel — provided we keep the connection warm.**
|
||||
|
||||
**Cold start — simpler, comparable, warm-able.** The mixnet needed *two* separate mixnet clients racing each other for bandwidth grants, plus a whole sequencer, just to get connected "in seconds instead of a minute." Tor is **one** bootstrap — dramatically simpler. It overlaps with app launch, so it is mostly invisible; its only visible edge is the very first send of a session if Tor has not finished bootstrapping yet, which would surface as a slightly longer first "Sending…." Warming the circuit at launch hides even that. **Verdict: simpler, comparable, warm-able.**
|
||||
|
||||
**Battery / always-listening — improves.** A persistent Tor circuit is lighter than a live mixnet client: there is no continuous cover-traffic machinery to run and no per-hop delay work to perform. The wallet's existing "the connection died, rebuild it" logic and its background/foreground handling map cleanly onto Tor circuits. **Verdict: battery should improve.**
|
||||
|
||||
**Net verdict.** On pure speed and feel, Tor is lateral-to-favorable everywhere the user actually waits — and better in the two places they wait most (sending and cold start). What we trade is not speed. It is the mixnet's global-adversary timing guarantee, and we largely rebuild even that, for the realistic adversary, with relay-side Poisson.
|
||||
|
||||
---
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — iOS + mobile spike (de-risk before commit)
|
||||
**Goal:** prove the copied engine drops into the seam on the platforms we are less sure of.
|
||||
**Tasks:** (a) stand up `arti-client` → connect a test `.onion:443` → `client_async_tls` → Nostr ws handshake, proving `DataStream` satisfies the transport seam on desktop; (b) confirm the Android recipe holds in a Goblin dev build (native-lib link + `ARTI_FS_DISABLE_PERMISSION_CHECKS`); (c) **the iOS spike** — does arti compile and bootstrap inside the iOS sandbox, plain-Tor (no PT bridges), and where does `fs-mistrust` land.
|
||||
**Exit criteria:** a Nostr ws session to the relay over an onion, in acceptable time, on a real Android device; a clear yes/no + punch-list for iOS.
|
||||
**Risk:** low on desktop/Android (GRIM shipped it); iOS unknown, which is why it is Phase 0.
|
||||
|
||||
### Phase 1 — Onion service on the relay
|
||||
**Goal:** the relay is reachable over a stable `.onion`.
|
||||
**Tasks:** system-Tor `torrc` onion service on the floonet box fronting the relay's ws port; publish and pin the `onion` in the gist beside the existing entry; add a second onion for the `goblin.st` name authority. Enable Vanguards on the service side.
|
||||
**Validation:** `torify` a plain Nostr client to the onion and complete a handshake.
|
||||
**Risk:** low — mature C-Tor doing the hosting half.
|
||||
|
||||
### Phase 2 — Wallet transport swap
|
||||
**Goal:** all Nostr + HTTP traffic rides Tor; Nym is off the live path.
|
||||
**Tasks:** copy GRIM's `src/tor/` into Goblin; implement `WebSocketTransport` against arti `DataStream`; re-route `http_request*` (Tor→onion for the authority, Tor→clearnet for gist/price/avatars); re-point `warm_up()`/`transport_ready()` at Tor readiness; keep the confirm-before-sent guard; **no clearnet fallback for the relay path — fail loudly**.
|
||||
**Validation:** a real payment between two devices completes over the onion, with the read-back confirm firing.
|
||||
**Risk:** low-medium — mitigated by copying a proven engine and native-tls.
|
||||
|
||||
### Phase 3 — Relay-side Poisson (timing privacy)
|
||||
**Goal:** send-time and receive-time are decorrelated for the realistic adversary.
|
||||
**Tasks:** the relay holds each inbound gift-wrap and releases it to the recipient after an exponentially-distributed delay (mean tuned so it stays inside the invisible delivery gap); confirm the sender's "Sent" still fires on *hold*, not on delivery, so the delay is free to the user.
|
||||
**Validation:** relay logs + on-path capture show release timing decorrelated from arrival timing; sender UX latency unchanged.
|
||||
**Risk:** low — it is a delay queue on a server we own.
|
||||
|
||||
### Phase 4 — Copy reword + iOS follow-through
|
||||
**Goal:** the product says "Tor," honestly, everywhere; iOS is either shipped or explicitly deferred.
|
||||
**Tasks:** reword all Nym/Mixnet strings across the six locales; publish the forum post; land iOS per the Phase-0 punch-list or file it as a tracked follow-up.
|
||||
**Validation:** locale drift test green; no "Nym"/"Mixnet" left in user-facing copy.
|
||||
**Risk:** low.
|
||||
|
||||
### Phase 5 — Retire Nym
|
||||
**Goal:** shed the dead dependency and shrink the binary.
|
||||
**Tasks:** delete `src/nym/*`, the `nym-sdk`/`smolmix` path-deps, and `floonet-mixexit`; shrink the Android native lib; simplify the all-in-one floonet package to **relay + torrc-onion + name-authority-onion**.
|
||||
**Validation:** clean build, smaller Android lib, green E2E.
|
||||
**Risk:** low — deletion after the replacement is proven.
|
||||
|
||||
---
|
||||
|
||||
## Migration & rollout
|
||||
|
||||
The pin format is forward-safe (serde-tolerant, `version:1` preserved), so rollout is graceful:
|
||||
|
||||
1. Ship the **Tor-capable build** via the in-app updater (GitHub releases).
|
||||
2. Run the **system-Tor onion service alongside** the existing relay through the transition.
|
||||
3. As users update, they pick up the `onion` pin from the gist and connect over Tor.
|
||||
4. **Old Nym pins go dark gracefully** — Nym is failing on its own schedule anyway, so there is no working state to "cut over" from and no regression to manage.
|
||||
|
||||
No coordinated flag day. The network is already in the failure state the new build fixes.
|
||||
|
||||
---
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| arti **onion-client** maturity ("not yet as secure as C-Tor") | Mature **system-Tor** hosts the service side; Vanguards on the service; onion-client is actively hardening and we are only the dialer. |
|
||||
| **iOS** unproven | Named Phase-0 spike; Android does not wait on it; plain-Tor (no PT bridges) is the accepted iOS mode. |
|
||||
| **Android build** friction | Direct copy of GRIM's working recipe — native-lib link + `ARTI_FS_DISABLE_PERMISSION_CHECKS`; native-tls dodges the rustls/ring provider conflict. |
|
||||
| **Bootstrap latency** on first send | Warm the circuit at launch; keep the "Connecting…" readiness UX; the confirm-before-sent guard makes a slow first send safe, never lost. |
|
||||
| **Poisson delay tuned too long** (feels laggy to recipient) | Tune mean to sit inside the already-async delivery gap; sender "Sent" fires on relay-hold regardless, so it is never on the user's critical path. |
|
||||
| **Single-relay availability** | The relay was always a required component; onion descriptor HA via a 24/7 host (and onionbalance if needed). |
|
||||
|
||||
---
|
||||
|
||||
## Far-future option (not part of this plan)
|
||||
|
||||
If we ever want to close the one remaining gap — resistance to a global passive adversary — a self-run, tokenless, unbonded mixnet layer could in principle sit *on top of* this Tor foundation (wallet → Tor → our own entry → mixnodes → relay). We are **not building it.** It leans on exactly the free Nym grant that is being deleted, it adds latency, and it defends against a threat a Grin payments wallet does not face. It is noted here only so the door is documented, not opened. **Tor is the plan.**
|
||||
|
||||
---
|
||||
|
||||
## Bottom line
|
||||
|
||||
Return to Tor, embedded in-process, copied straight from GRIM's proven engine (arti 0.43, native-tls), dialing the relay's pinned onion — and rebuild the mixnet's one real advantage, timing unlinkability, as a Poisson release delay on the relay we already own and fully control. Keep the relay-mediated store-and-forward model, the genuine-readiness signal, and the read-back confirm-before-sent guard exactly as they are. Android is solved today; iOS gets a named spike. We give up one theoretical guarantee against a global adversary that is out of scope, and in exchange the wallet becomes more reliable, lighter on the battery, faster where users wait — and free of any token or rented bandwidth that can go dark at midnight.
|
||||
|
||||
---
|
||||
|
||||
## Forum post: why we're moving from the mixnet to Tor
|
||||
|
||||
*(Ready to publish, owner's voice.)*
|
||||
|
||||
**We're switching Goblin's private plumbing from the Nym mixnet back to Tor. Here's the honest why.**
|
||||
|
||||
When I built Goblin's privacy layer on the Nym mixnet, I meant it. A mixnet is the strongest metadata-privacy tool we have — it doesn't just hide your IP, it deliberately shuffles the timing of messages so nobody can even tell that *you sending* and *someone receiving* are the same payment. For a money wallet, that's exactly the property you want. I wasn't hedging when I picked it.
|
||||
|
||||
But a payments wallet has to stand on ground that doesn't move, and the ground moved. The free bandwidth tier that Goblin relied on turns out to be temporary testnet scaffolding that Nym is actively removing — it's written right into their code to expire, and their public gateways are switching over to a paid model. And the paid model means holding a specific crypto token to buy bandwidth. That's not a foundation I'm willing to build your money on. It went dark on us more than once, on a schedule, and "your payments work unless it's the wrong time of day" is not something I'll ship.
|
||||
|
||||
So we're going back to **Tor** — the most battle-tested privacy network on the internet, with no token, no rented bandwidth, and nothing to expire. And we're embedding it **right inside the app**, the same way our sibling wallet GRIM already does. No separate program to install, no server in the middle you have to trust. It just works, on your phone, out of the box.
|
||||
|
||||
Here's what that means for you, in plain terms. Tor hides your IP address — from our own relay and from your internet provider — so nobody watching the network can see it's *you* sending a payment. And the one thing Tor doesn't do on its own — that clever timing-shuffle a mixnet does — **we rebuild ourselves**: our relay briefly holds each payment and releases it on a randomized delay, so nobody can match "you sent" to "they received." You won't feel that delay, because it happens in the gap where a payment is already in flight. Your screen says "Sent" the instant our relay has your payment safely in hand.
|
||||
|
||||
And honestly? It's **better** day to day. Tor is faster for the moments you actually wait on — sending a payment, opening the app — because it skips the mixnet's built-in delays. It's easier on your battery. And it's simpler and more reliable, because there's no metered bandwidth left to run out.
|
||||
|
||||
I'll be straight about the one thing we give up: a full mixnet can resist an adversary powerful enough to watch the *entire* internet at once. Tor doesn't claim to stop that, and neither do we — it's not the threat a Grin payments wallet realistically faces. For every attacker that actually exists, you're covered.
|
||||
|
||||
Faster, more reliable, works on your phone, no tokens, no rented bandwidth to fail. That's the trade, and I'm glad to make it.
|
||||
@@ -18,7 +18,7 @@ A Goblin payment is **a Grin transaction wrapped in a private nostr message**.
|
||||
|
||||
2. **Nostr layer (the delivery).** Instead of making you hand slate files back
|
||||
and forth, Goblin delivers each slate as an **end-to-end-encrypted nostr
|
||||
direct message**, routed through **Tor**. You pay a `username` or
|
||||
direct message**, routed through the **Nym mixnet**. You pay a `username` or
|
||||
`npub`; the recipient's wallet applies the slate automatically.
|
||||
|
||||
The slate is the payload; nostr is the transport. Everything below is about how
|
||||
@@ -85,7 +85,7 @@ Names are kept fresh: see [§11 Name freshness](#11-contacts--name-freshness).
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport: NIP-17 gift wraps over Tor
|
||||
## 3. Transport: NIP-17 gift wraps over Nym
|
||||
|
||||
A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
|
||||
`send_control_dm` (both in `src/nostr/client.rs`). The message structure
|
||||
@@ -106,12 +106,10 @@ A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
|
||||
hints carried by a pasted `nprofile`. Default relays: `relay.goblin.st`,
|
||||
`relay.damus.io`, `nos.lol` (`src/nostr/relays.rs`), capped at `MAX_DM_RELAYS`.
|
||||
|
||||
**How relays are reached:** every relay connection runs through an in-process
|
||||
**Tor** client (arti, linked directly into the wallet binary — no sidecar), via
|
||||
`TorWebSocketTransport` (`run_service` waits for Tor to be ready before dialing).
|
||||
So the relay never sees your IP: the money-path relay is dialed at its pinned
|
||||
`.onion` address, and any relay without one is reached over a Tor exit to its
|
||||
clearnet host. The Grin *node* connection (block sync + broadcasting the final tx)
|
||||
**How relays are reached:** every relay connection runs through the in-process
|
||||
**Nym mixnet** SOCKS5 proxy (`NymWebSocketTransport`; `run_service` waits for the
|
||||
proxy to be ready before dialing). The mixnet hides who-talks-to-whom at the
|
||||
network layer. The Grin *node* connection (block sync + broadcasting the final tx)
|
||||
is direct clearnet — it's public chain data, the same for everyone, not tied to
|
||||
your identity.
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
//! 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)))),
|
||||
)
|
||||
}
|
||||
@@ -5,15 +5,15 @@
|
||||
# Usage: linux/build_release.sh [platform]
|
||||
# platform: 'x86_64' (default) or 'arm'
|
||||
#
|
||||
# Goblin links the Tor transport (embedded arti) IN-PROCESS, so the AppImage is
|
||||
# one self-contained binary with no sidecar to embed or ship beside it.
|
||||
# Goblin links the Nym SDK IN-PROCESS (src/nym/), so the AppImage is one
|
||||
# self-contained binary with no sidecar to embed or ship beside it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
platform="${1:-x86_64}"
|
||||
case "${platform}" in
|
||||
x86_64) arch="x86_64-unknown-linux-gnu"; appimage_arch="x86_64" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu"; appimage_arch="aarch64" ;;
|
||||
x86_64) arch="x86_64-unknown-linux-gnu" ;;
|
||||
arm) arch="aarch64-unknown-linux-gnu" ;;
|
||||
*) echo "Usage: build_release.sh [platform] (platform: 'x86_64' | 'arm')" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -41,7 +41,7 @@ export CXXFLAGS_x86_64_unknown_linux_gnu="-DCROARING_COMPILER_SUPPORTS_AVX512=0"
|
||||
export BINDGEN_EXTRA_CLANG_ARGS="${BINDGEN_EXTRA_CLANG_ARGS:-} -I/usr/include"
|
||||
cargo zigbuild --release --target "${arch}.2.17"
|
||||
|
||||
# Assemble the AppDir: AppRun IS the goblin binary (Tor/arti linked in), plus the
|
||||
# Assemble the AppDir: AppRun IS the goblin binary (Nym SDK linked in), plus the
|
||||
# icon + desktop entry. Nothing else.
|
||||
appdir="linux/Goblin.AppDir"
|
||||
cp "target/${arch}/release/goblin" "${appdir}/AppRun"
|
||||
@@ -51,13 +51,7 @@ 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 "${runtime_file}" ] && [ -e "${runtime_file}" ] && runtime_arg=(--runtime-file "${runtime_file}")
|
||||
ARCH="${appimage_arch}" "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
[ -n "${GOBLIN_APPIMAGE_RUNTIME:-}" ] && runtime_arg=(--runtime-file "${GOBLIN_APPIMAGE_RUNTIME}")
|
||||
ARCH=x86_64 "${appimagetool_bin}" "${runtime_arg[@]}" "${appdir}" "${out}"
|
||||
echo "built: ${out}"
|
||||
|
||||
@@ -359,14 +359,12 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonym"
|
||||
connected_nym: "Über Tor verbunden"
|
||||
nym_ready: "Tor bereit · Relays…"
|
||||
connecting_nym: "Verbinde mit Tor…"
|
||||
connected_nym: "Über Nym verbunden"
|
||||
nym_ready: "Nym bereit · Relays…"
|
||||
connecting_nym: "Verbinde mit Nym…"
|
||||
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"
|
||||
@@ -375,7 +373,6 @@ goblin:
|
||||
nav_receive: "Empfangen"
|
||||
nav_settings: "Einstellungen"
|
||||
activity: "Aktivität"
|
||||
news: "Neuigkeiten"
|
||||
empty_title: "Noch keine Aktivität"
|
||||
empty_sub: "Sende oder empfange grin, um zu starten."
|
||||
recent: "Zuletzt"
|
||||
@@ -417,7 +414,7 @@ goblin:
|
||||
fee_none: "Keine"
|
||||
network_fee: "Netzwerkgebühr"
|
||||
privacy: "Privatsphäre"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
transaction: "Transaktion"
|
||||
cancel_request: "Anfrage abbrechen"
|
||||
cancel_send: "Zahlung abbrechen"
|
||||
@@ -437,14 +434,12 @@ 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"
|
||||
@@ -465,20 +460,17 @@ 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"
|
||||
switch_wallet: "Wallet wechseln"
|
||||
advanced: "Erweitert"
|
||||
privacy: "Privatsphäre"
|
||||
mixnet_routing: "Tor-Routing"
|
||||
mixnet_routing: "Mixnet-Routing"
|
||||
messages_lookups: "Nachrichten & Abfragen"
|
||||
auto_accept: "Automatisch annehmen"
|
||||
pairing: "Preiswährung"
|
||||
pairing: "Kopplung"
|
||||
accept_anyone: "Jeder"
|
||||
accept_contacts: "Nur Kontakte"
|
||||
accept_ask: "Immer fragen"
|
||||
@@ -493,12 +485,11 @@ 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}"
|
||||
network: "Netzwerk"
|
||||
network_value: "MW + Tor + nostr"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
third_party: "Drittanbieter"
|
||||
grim: "GRIM (Upstream-Wallet)"
|
||||
grin_node: "Grin-Node"
|
||||
@@ -570,7 +561,7 @@ goblin:
|
||||
keep_it: "Behalten"
|
||||
release_it: "Freigeben"
|
||||
username: "Benutzername"
|
||||
username_note: "Wird als dein Name angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt."
|
||||
username_note: "Wird als you angezeigt. Öffentlich auf goblin.st. Zahlungen bleiben verschlüsselt."
|
||||
release_username: "Benutzername freigeben"
|
||||
pick_username: "Benutzernamen wählen — optional"
|
||||
working: "Arbeite…"
|
||||
@@ -610,22 +601,16 @@ 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 über Tor und verbirgt so deine IP vor dem Relay — die Verschlüsselung verbirgt den Rest, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
|
||||
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."
|
||||
payments: "Zahlungen"
|
||||
payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt."
|
||||
usernames: "Benutzernamen"
|
||||
usernames: "usernames"
|
||||
usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st."
|
||||
price_avatars: "Preis"
|
||||
price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen."
|
||||
over_mixnet: "Über Tor"
|
||||
over_mixnet: "Über das mixnet"
|
||||
direct_connection: "Direkte Verbindung"
|
||||
grin_node: "Grin-Node"
|
||||
grin_node_blurb: "Block-Synchronisierung und Übertragung deiner Transaktion ins Netzwerk. Dies sind öffentliche Chain-Daten, für alle gleich, und nicht mit deiner Identität verknüpft."
|
||||
@@ -633,7 +618,7 @@ goblin:
|
||||
title: "Kopplung"
|
||||
intro: "Womit dein Guthaben und deine Beträge verglichen werden."
|
||||
pair_with: "Koppeln mit"
|
||||
rates_note: "Kurse werden über Tor abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt."
|
||||
rates_note: "Kurse werden über das Nym mixnet abgerufen, nur solange eine Kopplung aktiv ist — aus bedeutet, dass keine Kursanfrage dein Gerät verlässt."
|
||||
relays:
|
||||
title: "Relays"
|
||||
intro: "Zahlungsnachrichten werden an jedes Relay unten gespiegelt; ein erreichbares Relay genügt zum Empfangen."
|
||||
@@ -675,7 +660,7 @@ goblin:
|
||||
private_money_head: "Privates Geld"
|
||||
private_money_body: "Goblin ist ein Wallet für grin — digitales Bargeld ohne Beträge oder Adressen auf seiner Chain."
|
||||
send_like_message_head: "Senden wie eine Nachricht"
|
||||
send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und Tor an — niemand dazwischen sieht den Betrag oder die Beteiligten."
|
||||
send_like_message_body: "Zahle an einen username oder npub und es kommt als Ende-zu-Ende-verschlüsselte Nachricht über nostr und das Nym mixnet an — niemand dazwischen sieht den Betrag oder die Beteiligten."
|
||||
yours_alone_head: "Nur deins"
|
||||
yours_alone_body: "Schlüssel, Namen und Verlauf bleiben auf diesem Gerät. Basiert auf dem GRIM-Wallet."
|
||||
get_started: "Loslegen"
|
||||
@@ -726,8 +711,8 @@ goblin:
|
||||
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
|
||||
title: "Deine Zahlungsidentität"
|
||||
key_being_made: "Schlüssel wird erstellt…"
|
||||
connected_nym: "über Tor verbunden"
|
||||
connecting_nym: "verbinde über Tor…"
|
||||
connected_nym: "über Nym verbunden"
|
||||
connecting_nym: "verbinde über Nym…"
|
||||
fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren."
|
||||
clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht."
|
||||
pick_username: "Benutzernamen wählen — optional"
|
||||
@@ -735,7 +720,7 @@ goblin:
|
||||
username_field_hint: "deinname"
|
||||
working: "Arbeite…"
|
||||
claim_username: "Benutzernamen sichern"
|
||||
available_when_connected: "Verfügbar, sobald Tor verbindet — oder überspringen und später sichern."
|
||||
available_when_connected: "Verfügbar, sobald das mixnet verbindet — oder überspringen und später sichern."
|
||||
youre: "Du bist %{name}"
|
||||
claimed_title: "%{name} gehört dir"
|
||||
claimed_blurb: "Freunde können dich jetzt per Namen bezahlen. Alles bereit — öffne dein Wallet."
|
||||
@@ -793,11 +778,11 @@ goblin:
|
||||
row_they_pay: "Sie zahlen"
|
||||
row_they_pay_val: "Nur wenn sie zustimmen"
|
||||
row_delivery: "Zustellung"
|
||||
row_delivery_val: "NIP-44-verschlüsselt, über Tor"
|
||||
row_delivery_val: "NIP-44-verschlüsselt, über Nym"
|
||||
row_network_fee: "Netzwerkgebühr"
|
||||
row_network_fee_val: "Von deinem Guthaben abgezogen"
|
||||
row_privacy: "Privatsphäre"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
send_request_btn: "Anfrage senden"
|
||||
request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen"
|
||||
hold_to_send: "Zum Senden halten"
|
||||
|
||||
@@ -359,14 +359,12 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonymous"
|
||||
connected_nym: "Connected over Tor"
|
||||
nym_ready: "Tor ready · relays…"
|
||||
connecting_nym: "Connecting to Tor…"
|
||||
connected_nym: "Connected over Nym"
|
||||
nym_ready: "Nym ready · relays…"
|
||||
connecting_nym: "Connecting to Nym…"
|
||||
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"
|
||||
@@ -375,7 +373,6 @@ goblin:
|
||||
nav_receive: "Receive"
|
||||
nav_settings: "Settings"
|
||||
activity: "Activity"
|
||||
news: "News"
|
||||
empty_title: "No activity yet"
|
||||
empty_sub: "Send or receive grin to get started."
|
||||
recent: "Recent"
|
||||
@@ -417,7 +414,7 @@ goblin:
|
||||
fee_none: "None"
|
||||
network_fee: "Network fee"
|
||||
privacy: "Privacy"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
transaction: "Transaction"
|
||||
cancel_request: "Cancel request"
|
||||
cancel_send: "Cancel payment"
|
||||
@@ -437,14 +434,12 @@ 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"
|
||||
@@ -465,20 +460,17 @@ 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"
|
||||
switch_wallet: "Switch wallet"
|
||||
advanced: "Advanced"
|
||||
privacy: "Privacy"
|
||||
mixnet_routing: "Tor routing"
|
||||
mixnet_routing: "Mixnet routing"
|
||||
messages_lookups: "Messages & lookups"
|
||||
auto_accept: "Auto-accept"
|
||||
pairing: "Price currency"
|
||||
pairing: "Pairing"
|
||||
accept_anyone: "Anyone"
|
||||
accept_contacts: "Contacts only"
|
||||
accept_ask: "Always ask"
|
||||
@@ -493,12 +485,11 @@ 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}"
|
||||
network: "Network"
|
||||
network_value: "MW + Tor + nostr"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
third_party: "Third party"
|
||||
grim: "GRIM (upstream wallet)"
|
||||
grin_node: "Grin node"
|
||||
@@ -570,7 +561,7 @@ goblin:
|
||||
keep_it: "Keep it"
|
||||
release_it: "Release it"
|
||||
username: "Username"
|
||||
username_note: "Shown as your name. Public on goblin.st. Payments stay encrypted."
|
||||
username_note: "Shown as you. Public on goblin.st. Payments stay encrypted."
|
||||
release_username: "Release username"
|
||||
pick_username: "Pick a username — optional"
|
||||
working: "Working…"
|
||||
@@ -610,22 +601,16 @@ 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 Tor, which hides your IP from the relay — encryption hides the rest, so a relay can't link a payment back to you."
|
||||
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."
|
||||
payments: "Payments"
|
||||
payments_blurb: "Every nostr message carrying a slatepack."
|
||||
usernames: "usernames"
|
||||
usernames_blurb: "NIP-05 name lookups to and from goblin.st."
|
||||
price_avatars: "Price"
|
||||
price_avatars_blurb: "The live fiat rate shown next to amounts."
|
||||
over_mixnet: "Over Tor"
|
||||
over_mixnet: "Over the mixnet"
|
||||
direct_connection: "Direct connection"
|
||||
grin_node: "Grin node"
|
||||
grin_node_blurb: "Block sync and broadcasting your transaction to the network. This is public chain data, the same for everyone, and isn't linked to your identity."
|
||||
@@ -633,7 +618,7 @@ goblin:
|
||||
title: "Pairing"
|
||||
intro: "What your balance and amounts are shown against."
|
||||
pair_with: "Pair with"
|
||||
rates_note: "Rates fetch over Tor, only while a pairing is on — off means no rate request leaves your device."
|
||||
rates_note: "Rates fetch over the Nym mixnet, only while a pairing is on — off means no rate request leaves your device."
|
||||
relays:
|
||||
title: "Relays"
|
||||
intro: "Payment messages are mirrored to every relay below; one reachable relay is enough to receive."
|
||||
@@ -675,7 +660,7 @@ goblin:
|
||||
private_money_head: "Private money"
|
||||
private_money_body: "Goblin is a wallet for grin — digital cash with no amounts or addresses on its chain."
|
||||
send_like_message_head: "Send like a message"
|
||||
send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and Tor — no one in between can see the amount or who's involved."
|
||||
send_like_message_body: "Pay a username or npub and it arrives as an end-to-end encrypted message over nostr and the Nym mixnet — no one in between can see the amount or who's involved."
|
||||
yours_alone_head: "Yours alone"
|
||||
yours_alone_body: "Keys, names and history live on this device. Built on the GRIM wallet."
|
||||
get_started: "Get started"
|
||||
@@ -726,8 +711,8 @@ goblin:
|
||||
kicker: "STEP 3 OF 3 · IDENTITY"
|
||||
title: "Your payment identity"
|
||||
key_being_made: "key being made…"
|
||||
connected_nym: "connected over Tor"
|
||||
connecting_nym: "connecting over Tor…"
|
||||
connected_nym: "connected over Nym"
|
||||
connecting_nym: "connecting over Nym…"
|
||||
fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds."
|
||||
clean_slate_blurb: "Want a clean slate? Swap in a brand-new key any time — the new you isn't linked to the old one. Same wallet, fresh face."
|
||||
pick_username: "Pick a username — optional"
|
||||
@@ -735,7 +720,7 @@ goblin:
|
||||
username_field_hint: "yourname"
|
||||
working: "Working…"
|
||||
claim_username: "Claim username"
|
||||
available_when_connected: "Available once Tor connects — or skip and claim later."
|
||||
available_when_connected: "Available once the mixnet connects — or skip and claim later."
|
||||
youre: "You're %{name}"
|
||||
claimed_title: "%{name} is yours"
|
||||
claimed_blurb: "Friends can now pay you by name. You're all set — open your wallet."
|
||||
@@ -793,11 +778,11 @@ goblin:
|
||||
row_they_pay: "They pay"
|
||||
row_they_pay_val: "Only if they approve"
|
||||
row_delivery: "Delivery"
|
||||
row_delivery_val: "NIP-44 encrypted, over Tor"
|
||||
row_delivery_val: "NIP-44 encrypted, over Nym"
|
||||
row_network_fee: "Network fee"
|
||||
row_network_fee_val: "Deducted from your balance"
|
||||
row_privacy: "Privacy"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
send_request_btn: "Send request"
|
||||
request_approve_hint: "They'll get a request to approve"
|
||||
hold_to_send: "Hold to send"
|
||||
|
||||
@@ -359,14 +359,12 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonyme"
|
||||
connected_nym: "Connecté via Tor"
|
||||
nym_ready: "Tor prêt · relais…"
|
||||
connecting_nym: "Connexion à Tor…"
|
||||
connected_nym: "Connecté via Nym"
|
||||
nym_ready: "Nym prêt · relais…"
|
||||
connecting_nym: "Connexion à Nym…"
|
||||
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"
|
||||
@@ -375,7 +373,6 @@ goblin:
|
||||
nav_receive: "Recevoir"
|
||||
nav_settings: "Réglages"
|
||||
activity: "Activité"
|
||||
news: "Actualités"
|
||||
empty_title: "Aucune activité"
|
||||
empty_sub: "Envoyez ou recevez des grin pour commencer."
|
||||
recent: "Récent"
|
||||
@@ -417,7 +414,7 @@ goblin:
|
||||
fee_none: "Aucun"
|
||||
network_fee: "Frais de réseau"
|
||||
privacy: "Confidentialité"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
transaction: "Transaction"
|
||||
cancel_request: "Annuler la demande"
|
||||
cancel_send: "Annuler le paiement"
|
||||
@@ -437,14 +434,12 @@ 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é"
|
||||
@@ -465,20 +460,17 @@ 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"
|
||||
switch_wallet: "Changer de portefeuille"
|
||||
advanced: "Avancé"
|
||||
privacy: "Confidentialité"
|
||||
mixnet_routing: "Routage par Tor"
|
||||
mixnet_routing: "Routage par mixnet"
|
||||
messages_lookups: "Messages et recherches"
|
||||
auto_accept: "Acceptation auto"
|
||||
pairing: "Devise des prix"
|
||||
pairing: "Appairage"
|
||||
accept_anyone: "Tout le monde"
|
||||
accept_contacts: "Contacts seulement"
|
||||
accept_ask: "Toujours demander"
|
||||
@@ -493,12 +485,11 @@ 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}"
|
||||
network: "Réseau"
|
||||
network_value: "MW + Tor + nostr"
|
||||
network_value: "MW + mixnet Nym + nostr"
|
||||
third_party: "Tiers"
|
||||
grim: "GRIM (portefeuille amont)"
|
||||
grin_node: "Nœud grin"
|
||||
@@ -570,7 +561,7 @@ goblin:
|
||||
keep_it: "Le garder"
|
||||
release_it: "Le libérer"
|
||||
username: "Nom d'utilisateur"
|
||||
username_note: "Affiché comme votre nom. Public sur goblin.st. Les paiements restent chiffrés."
|
||||
username_note: "Affiché comme you. 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…"
|
||||
@@ -610,22 +601,16 @@ 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 Tor, qui masque votre IP au relais — le chiffrement masque le reste, afin qu'un relais ne puisse pas relier un paiement à vous."
|
||||
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."
|
||||
payments: "Paiements"
|
||||
payments_blurb: "Chaque message nostr transportant un slatepack."
|
||||
usernames: "Noms d'utilisateur"
|
||||
usernames: "usernames"
|
||||
usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st."
|
||||
price_avatars: "Prix"
|
||||
price_avatars_blurb: "Le taux en temps réel affiché à côté des montants."
|
||||
over_mixnet: "Via Tor"
|
||||
over_mixnet: "Via le mixnet"
|
||||
direct_connection: "Connexion directe"
|
||||
grin_node: "Nœud grin"
|
||||
grin_node_blurb: "Synchronisation des blocs et diffusion de votre transaction sur le réseau. Ce sont des données de chaîne publiques, identiques pour tous, et non liées à votre identité."
|
||||
@@ -633,7 +618,7 @@ goblin:
|
||||
title: "Appairage"
|
||||
intro: "Ce à quoi votre solde et vos montants sont comparés."
|
||||
pair_with: "Apparier avec"
|
||||
rates_note: "Les cours sont récupérés via Tor, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil."
|
||||
rates_note: "Les cours sont récupérés via le mixnet Nym, uniquement tant qu'un appairage est actif — désactivé, aucune requête de cours ne quitte votre appareil."
|
||||
relays:
|
||||
title: "Relais"
|
||||
intro: "Les messages de paiement sont répliqués sur tous les relais ci-dessous ; un seul relais joignable suffit pour recevoir."
|
||||
@@ -675,7 +660,7 @@ goblin:
|
||||
private_money_head: "Argent privé"
|
||||
private_money_body: "Goblin est un portefeuille pour grin — de l'argent numérique sans montants ni adresses sur sa chaîne."
|
||||
send_like_message_head: "Envoyer comme un message"
|
||||
send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et Tor — personne entre les deux ne voit le montant ni les personnes impliquées."
|
||||
send_like_message_body: "Payez un username ou un npub et cela arrive comme un message chiffré de bout en bout via nostr et le mixnet Nym — personne entre les deux ne voit le montant ni les personnes impliquées."
|
||||
yours_alone_head: "À vous seul"
|
||||
yours_alone_body: "Clés, noms et historique vivent sur cet appareil. Construit sur le portefeuille GRIM."
|
||||
get_started: "Commencer"
|
||||
@@ -726,8 +711,8 @@ goblin:
|
||||
kicker: "ÉTAPE 3 SUR 3 · IDENTITÉ"
|
||||
title: "Votre identité de paiement"
|
||||
key_being_made: "clé en cours de création…"
|
||||
connected_nym: "connecté via Tor"
|
||||
connecting_nym: "connexion via Tor…"
|
||||
connected_nym: "connecté via Nym"
|
||||
connecting_nym: "connexion via Nym…"
|
||||
fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds."
|
||||
clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage."
|
||||
pick_username: "Choisir un nom d'utilisateur — facultatif"
|
||||
@@ -735,7 +720,7 @@ goblin:
|
||||
username_field_hint: "votrenom"
|
||||
working: "En cours…"
|
||||
claim_username: "Réserver le nom d'utilisateur"
|
||||
available_when_connected: "Disponible une fois Tor connecté — ou passez et réservez plus tard."
|
||||
available_when_connected: "Disponible une fois le mixnet connecté — ou passez et réservez plus tard."
|
||||
youre: "Vous êtes %{name}"
|
||||
claimed_title: "%{name} est à vous"
|
||||
claimed_blurb: "Vos amis peuvent désormais vous payer par votre nom. Tout est prêt — ouvrez votre portefeuille."
|
||||
@@ -793,11 +778,11 @@ goblin:
|
||||
row_they_pay: "Ils paient"
|
||||
row_they_pay_val: "Seulement s'ils approuvent"
|
||||
row_delivery: "Livraison"
|
||||
row_delivery_val: "Chiffré NIP-44, via Tor"
|
||||
row_delivery_val: "Chiffré NIP-44, via Nym"
|
||||
row_network_fee: "Frais de réseau"
|
||||
row_network_fee_val: "Déduit de votre solde"
|
||||
row_privacy: "Confidentialité"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
send_request_btn: "Envoyer la demande"
|
||||
request_approve_hint: "Ils recevront une demande à approuver"
|
||||
hold_to_send: "Maintenir pour envoyer"
|
||||
|
||||
@@ -359,14 +359,12 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Аноним"
|
||||
connected_nym: "Подключено через Tor"
|
||||
nym_ready: "Tor готов · реле…"
|
||||
connecting_nym: "Подключение к Tor…"
|
||||
connected_nym: "Подключено через Nym"
|
||||
nym_ready: "Nym готов · реле…"
|
||||
connecting_nym: "Подключение к Nym…"
|
||||
cant_reach_node: "Нет связи с узлом"
|
||||
node_synced: "Узел синхронизирован"
|
||||
syncing: "Синхронизация…"
|
||||
balance_updating: "Баланс обновляется…"
|
||||
listening: "Ожидание платежей"
|
||||
block: "Блок %{height}"
|
||||
waiting_for_chain: "Ожидание цепочки…"
|
||||
nav_wallet: "Кошелёк"
|
||||
@@ -375,7 +373,6 @@ goblin:
|
||||
nav_receive: "Получить"
|
||||
nav_settings: "Настройки"
|
||||
activity: "Действия"
|
||||
news: "Новости"
|
||||
empty_title: "Пока нет действий"
|
||||
empty_sub: "Отправьте или получите grin, чтобы начать."
|
||||
recent: "Недавние"
|
||||
@@ -417,7 +414,7 @@ goblin:
|
||||
fee_none: "Нет"
|
||||
network_fee: "Сетевая комиссия"
|
||||
privacy: "Приватность"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
transaction: "Транзакция"
|
||||
cancel_request: "Отменить запрос"
|
||||
cancel_send: "Отменить платёж"
|
||||
@@ -437,14 +434,12 @@ 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: "Действия"
|
||||
@@ -465,20 +460,17 @@ goblin:
|
||||
wallet: "Кошелёк"
|
||||
display_unit: "Единица отображения"
|
||||
relays: "Реле"
|
||||
nostr_relays: "Реле Nostr"
|
||||
node: "Узел"
|
||||
integrated_node: "Настройки встроенного узла"
|
||||
node_advanced: "Дополнительно"
|
||||
slatepacks: "Slatepacks"
|
||||
slatepacks_value: "Ручная транзакция"
|
||||
lock_wallet: "Заблокировать кошелёк"
|
||||
switch_wallet: "Сменить кошелёк"
|
||||
advanced: "Дополнительно"
|
||||
privacy: "Приватность"
|
||||
mixnet_routing: "Маршрутизация через Tor"
|
||||
mixnet_routing: "Маршрутизация через mixnet"
|
||||
messages_lookups: "Сообщения и поиск"
|
||||
auto_accept: "Автоприём"
|
||||
pairing: "Валюта цены"
|
||||
pairing: "Привязка"
|
||||
accept_anyone: "Любой"
|
||||
accept_contacts: "Только контакты"
|
||||
accept_ask: "Всегда спрашивать"
|
||||
@@ -493,12 +485,11 @@ goblin:
|
||||
archive: "Архив"
|
||||
export_archive: "Экспорт архива"
|
||||
wipe_history: "Стереть историю платежей"
|
||||
wipe_history_confirm: "Нажмите ещё раз, чтобы стереть — это нельзя отменить"
|
||||
about: "О приложении"
|
||||
goblin: "Goblin"
|
||||
build: "Сборка %{build}"
|
||||
network: "Сеть"
|
||||
network_value: "MW + Tor + nostr"
|
||||
network_value: "MW + mixnet Nym + nostr"
|
||||
third_party: "Сторонние"
|
||||
grim: "GRIM (исходный кошелёк)"
|
||||
grin_node: "Узел Grin"
|
||||
@@ -570,7 +561,7 @@ goblin:
|
||||
keep_it: "Оставить"
|
||||
release_it: "Освободить"
|
||||
username: "Имя пользователя"
|
||||
username_note: "Отображается как ваше имя. Публично на goblin.st. Платежи остаются зашифрованными."
|
||||
username_note: "Показывается как you. Публично на goblin.st. Платежи остаются зашифрованными."
|
||||
release_username: "Освободить имя"
|
||||
pick_username: "Выберите имя — необязательно"
|
||||
working: "Обработка…"
|
||||
@@ -610,22 +601,16 @@ 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 отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
|
||||
intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами."
|
||||
payments: "Платежи"
|
||||
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
|
||||
usernames: "Имена пользователей"
|
||||
usernames: "usernames"
|
||||
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
|
||||
price_avatars: "Цена"
|
||||
price_avatars_blurb: "Текущий курс рядом с суммами."
|
||||
over_mixnet: "Через Tor"
|
||||
over_mixnet: "Через mixnet"
|
||||
direct_connection: "Прямое соединение"
|
||||
grin_node: "Узел Grin"
|
||||
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
|
||||
@@ -633,7 +618,7 @@ goblin:
|
||||
title: "Привязка"
|
||||
intro: "К чему привязаны отображаемые баланс и суммы."
|
||||
pair_with: "Привязать к"
|
||||
rates_note: "Курсы загружаются через Tor только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||
rates_note: "Курсы загружаются через mixnet Nym только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||
relays:
|
||||
title: "Реле"
|
||||
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
|
||||
@@ -666,7 +651,7 @@ goblin:
|
||||
n44_blurb: "Аутентифицированный шифр, используемый внутри этих сообщений."
|
||||
n49_title: "Шифрование ключа"
|
||||
n49_blurb: "Как секретный ключ хранится в покое, защищённый вашим паролем."
|
||||
n59_title: "Подарочная обёртка"
|
||||
n59_title: "Gift wrap"
|
||||
n59_blurb: "Оборачивает сообщения, чтобы реле не видели, кто с кем общается."
|
||||
n98_title: "HTTP-авторизация"
|
||||
n98_blurb: "Подписывает запрос регистрации имени на goblin.st."
|
||||
@@ -675,7 +660,7 @@ goblin:
|
||||
private_money_head: "Приватные деньги"
|
||||
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
|
||||
send_like_message_head: "Отправляйте как сообщение"
|
||||
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и Tor — никто посередине не увидит сумму или участников."
|
||||
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и mixnet Nym — никто посередине не увидит сумму или участников."
|
||||
yours_alone_head: "Только ваше"
|
||||
yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM."
|
||||
get_started: "Начать"
|
||||
@@ -726,16 +711,16 @@ goblin:
|
||||
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
|
||||
title: "Ваша платёжная личность"
|
||||
key_being_made: "ключ создаётся…"
|
||||
connected_nym: "подключено через Tor"
|
||||
connecting_nym: "подключение через Tor…"
|
||||
connected_nym: "подключено через Nym"
|
||||
connecting_nym: "подключение через Nym…"
|
||||
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
|
||||
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
|
||||
pick_username: "Выберите имя — необязательно"
|
||||
username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент."
|
||||
username_field_hint: "вашеимя"
|
||||
username_field_hint: "yourname"
|
||||
working: "Обработка…"
|
||||
claim_username: "Занять имя"
|
||||
available_when_connected: "Доступно после подключения Tor — или пропустите и займите позже."
|
||||
available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже."
|
||||
youre: "Вы %{name}"
|
||||
claimed_title: "%{name} теперь ваше"
|
||||
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
|
||||
@@ -793,11 +778,11 @@ goblin:
|
||||
row_they_pay: "Они платят"
|
||||
row_they_pay_val: "Только если они одобрят"
|
||||
row_delivery: "Доставка"
|
||||
row_delivery_val: "Зашифровано NIP-44, через Tor"
|
||||
row_delivery_val: "Зашифровано NIP-44, через Nym"
|
||||
row_network_fee: "Сетевая комиссия"
|
||||
row_network_fee_val: "Списывается с вашего баланса"
|
||||
row_privacy: "Приватность"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
send_request_btn: "Отправить запрос"
|
||||
request_approve_hint: "Они получат запрос на одобрение"
|
||||
hold_to_send: "Удерживайте для отправки"
|
||||
|
||||
@@ -359,14 +359,12 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonim"
|
||||
connected_nym: "Tor üzerinden bağlı"
|
||||
nym_ready: "Tor hazır · relaylar…"
|
||||
connecting_nym: "Tor'a bağlanılıyor…"
|
||||
connected_nym: "Nym üzerinden bağlı"
|
||||
nym_ready: "Nym hazır · relaylar…"
|
||||
connecting_nym: "Nym'e bağlanılıyor…"
|
||||
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"
|
||||
@@ -375,7 +373,6 @@ goblin:
|
||||
nav_receive: "Al"
|
||||
nav_settings: "Ayarlar"
|
||||
activity: "Etkinlik"
|
||||
news: "Haberler"
|
||||
empty_title: "Henüz etkinlik yok"
|
||||
empty_sub: "Başlamak için grin gönder ya da al."
|
||||
recent: "Son işlemler"
|
||||
@@ -417,7 +414,7 @@ goblin:
|
||||
fee_none: "Yok"
|
||||
network_fee: "Ağ ücreti"
|
||||
privacy: "Gizlilik"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
transaction: "İşlem"
|
||||
cancel_request: "İsteği iptal et"
|
||||
cancel_send: "Ödemeyi iptal et"
|
||||
@@ -437,14 +434,12 @@ 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"
|
||||
@@ -465,20 +460,17 @@ 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"
|
||||
switch_wallet: "Cüzdan değiştir"
|
||||
advanced: "Gelişmiş"
|
||||
privacy: "Gizlilik"
|
||||
mixnet_routing: "Tor yönlendirme"
|
||||
mixnet_routing: "Mixnet yönlendirme"
|
||||
messages_lookups: "Mesajlar ve aramalar"
|
||||
auto_accept: "Otomatik kabul"
|
||||
pairing: "Fiyat para birimi"
|
||||
pairing: "Eşleştirme"
|
||||
accept_anyone: "Herkes"
|
||||
accept_contacts: "Yalnızca kişiler"
|
||||
accept_ask: "Her zaman sor"
|
||||
@@ -493,12 +485,11 @@ 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}"
|
||||
network: "Ağ"
|
||||
network_value: "MW + Tor + nostr"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
third_party: "Üçüncü taraf"
|
||||
grim: "GRIM (üst kaynak cüzdan)"
|
||||
grin_node: "Grin düğümü"
|
||||
@@ -570,7 +561,7 @@ goblin:
|
||||
keep_it: "Vazgeç"
|
||||
release_it: "Bırak"
|
||||
username: "Kullanıcı adı"
|
||||
username_note: "Adınız olarak gösterilir. goblin.st'de herkese açık. Ödemeler şifreli kalır."
|
||||
username_note: "you 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…"
|
||||
@@ -610,22 +601,16 @@ 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 Tor üzerinden gönderir ve senin IP adresini relaydan gizler — şifreleme de gerisini gizler, böylece bir relay bir ödemeyi sana bağlayamaz."
|
||||
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."
|
||||
payments: "Ödemeler"
|
||||
payments_blurb: "Slatepack taşıyan her nostr mesajı."
|
||||
usernames: "usernamelar"
|
||||
usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları."
|
||||
price_avatars: "Fiyat"
|
||||
price_avatars_blurb: "Tutarların yanında gösterilen anlık kur."
|
||||
over_mixnet: "Tor üzerinden"
|
||||
over_mixnet: "Mixnet üzerinden"
|
||||
direct_connection: "Doğrudan bağlantı"
|
||||
grin_node: "Grin düğümü"
|
||||
grin_node_blurb: "Blok eşitleme ve işlemini ağa yayma. Bu, herkes için aynı olan genel zincir verisidir ve kimliğinle ilişkilendirilmez."
|
||||
@@ -633,7 +618,7 @@ goblin:
|
||||
title: "Eşleştirme"
|
||||
intro: "Bakiyenin ve tutarların neye göre gösterildiği."
|
||||
pair_with: "Eşleştir"
|
||||
rates_note: "Kurlar yalnızca bir eşleştirme açıkken Tor üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz."
|
||||
rates_note: "Kurlar yalnızca bir eşleştirme açıkken Nym mixnet üzerinden alınır — kapalıysa cihazından hiçbir kur isteği çıkmaz."
|
||||
relays:
|
||||
title: "Relaylar"
|
||||
intro: "Ödeme mesajları aşağıdaki her relay'e yansıtılır; almak için ulaşılabilir tek bir relay yeterlidir."
|
||||
@@ -675,7 +660,7 @@ goblin:
|
||||
private_money_head: "Özel para"
|
||||
private_money_body: "Goblin, grin için bir cüzdan — zincirinde tutar ya da adres bulunmayan dijital nakit."
|
||||
send_like_message_head: "Mesaj gibi gönder"
|
||||
send_like_message_body: "Bir username ya da npub'a öde, nostr ve Tor üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez."
|
||||
send_like_message_body: "Bir username ya da npub'a öde, nostr ve Nym mixnet üzerinden uçtan uca şifreli bir mesaj olarak ulaşır — aradaki hiç kimse tutarı ya da kimlerin dahil olduğunu göremez."
|
||||
yours_alone_head: "Yalnızca senin"
|
||||
yours_alone_body: "Anahtarlar, adlar ve geçmiş bu cihazda yaşar. GRIM cüzdanı üzerine kuruludur."
|
||||
get_started: "Başla"
|
||||
@@ -726,8 +711,8 @@ goblin:
|
||||
kicker: "ADIM 3 / 3 · KİMLİK"
|
||||
title: "Ödeme kimliğin"
|
||||
key_being_made: "anahtar oluşturuluyor…"
|
||||
connected_nym: "Tor üzerinden bağlı"
|
||||
connecting_nym: "Tor üzerinden bağlanılıyor…"
|
||||
connected_nym: "Nym üzerinden bağlı"
|
||||
connecting_nym: "Nym üzerinden bağlanılıyor…"
|
||||
fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür."
|
||||
clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz."
|
||||
pick_username: "Bir kullanıcı adı seç — isteğe bağlı"
|
||||
@@ -735,7 +720,7 @@ goblin:
|
||||
username_field_hint: "adınız"
|
||||
working: "Çalışıyor…"
|
||||
claim_username: "Kullanıcı adı al"
|
||||
available_when_connected: "Tor bağlandığında müsait — ya da atla ve sonra al."
|
||||
available_when_connected: "Mixnet bağlandığında müsait — ya da atla ve sonra al."
|
||||
youre: "Sen %{name}'sin"
|
||||
claimed_title: "%{name} artık senin"
|
||||
claimed_blurb: "Arkadaşların artık sana adınla ödeme yapabilir. Her şey hazır — cüzdanını aç."
|
||||
@@ -793,11 +778,11 @@ goblin:
|
||||
row_they_pay: "Onlar öder"
|
||||
row_they_pay_val: "Yalnızca onaylarlarsa"
|
||||
row_delivery: "Teslimat"
|
||||
row_delivery_val: "NIP-44 şifreli, Tor üzerinden"
|
||||
row_delivery_val: "NIP-44 şifreli, Nym üzerinden"
|
||||
row_network_fee: "Ağ ücreti"
|
||||
row_network_fee_val: "Bakiyenden düşülür"
|
||||
row_privacy: "Gizlilik"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
send_request_btn: "İstek gönder"
|
||||
request_approve_hint: "Onaylamaları için bir istek alacaklar"
|
||||
hold_to_send: "Göndermek için basılı tut"
|
||||
|
||||
@@ -359,14 +359,12 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "匿名"
|
||||
connected_nym: "已通过 Tor 连接"
|
||||
nym_ready: "Tor 就绪 · 连接中继…"
|
||||
connecting_nym: "正在连接 Tor…"
|
||||
connected_nym: "已通过 Nym 连接"
|
||||
nym_ready: "Nym 就绪 · 连接中继…"
|
||||
connecting_nym: "正在连接 Nym…"
|
||||
cant_reach_node: "无法连接节点"
|
||||
node_synced: "节点已同步"
|
||||
syncing: "同步中…"
|
||||
balance_updating: "余额更新中…"
|
||||
listening: "正在监听付款"
|
||||
block: "区块 %{height}"
|
||||
waiting_for_chain: "等待链数据…"
|
||||
nav_wallet: "钱包"
|
||||
@@ -375,7 +373,6 @@ goblin:
|
||||
nav_receive: "收款"
|
||||
nav_settings: "设置"
|
||||
activity: "动态"
|
||||
news: "新闻"
|
||||
empty_title: "暂无动态"
|
||||
empty_sub: "收发 grin 即可开始。"
|
||||
recent: "最近"
|
||||
@@ -417,7 +414,7 @@ goblin:
|
||||
fee_none: "无"
|
||||
network_fee: "网络费用"
|
||||
privacy: "隐私"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
transaction: "交易"
|
||||
cancel_request: "取消请求"
|
||||
cancel_send: "取消付款"
|
||||
@@ -437,14 +434,12 @@ 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: "动态"
|
||||
@@ -465,20 +460,17 @@ goblin:
|
||||
wallet: "钱包"
|
||||
display_unit: "显示单位"
|
||||
relays: "中继"
|
||||
nostr_relays: "Nostr 中继"
|
||||
node: "节点"
|
||||
integrated_node: "集成节点设置"
|
||||
node_advanced: "高级"
|
||||
slatepacks: "Slatepack"
|
||||
slatepacks_value: "手动交易"
|
||||
lock_wallet: "锁定钱包"
|
||||
switch_wallet: "切换钱包"
|
||||
advanced: "高级"
|
||||
privacy: "隐私"
|
||||
mixnet_routing: "Tor 路由"
|
||||
mixnet_routing: "mixnet 路由"
|
||||
messages_lookups: "消息和查询"
|
||||
auto_accept: "自动接受"
|
||||
pairing: "价格货币"
|
||||
pairing: "配对"
|
||||
accept_anyone: "任何人"
|
||||
accept_contacts: "仅联系人"
|
||||
accept_ask: "每次询问"
|
||||
@@ -493,12 +485,11 @@ goblin:
|
||||
archive: "存档"
|
||||
export_archive: "导出存档"
|
||||
wipe_history: "清除付款记录"
|
||||
wipe_history_confirm: "再次点按以清除 — 无法撤销"
|
||||
about: "关于"
|
||||
goblin: "Goblin"
|
||||
build: "构建 %{build}"
|
||||
network: "网络"
|
||||
network_value: "MW + Tor + nostr"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
third_party: "第三方"
|
||||
grim: "GRIM(上游钱包)"
|
||||
grin_node: "Grin 节点"
|
||||
@@ -570,7 +561,7 @@ goblin:
|
||||
keep_it: "保留"
|
||||
release_it: "释放"
|
||||
username: "用户名"
|
||||
username_note: "显示为你的名字。在 goblin.st 上公开。付款保持加密。"
|
||||
username_note: "显示为 you。在 goblin.st 上公开。付款保持加密。"
|
||||
release_username: "释放用户名"
|
||||
pick_username: "选择用户名 — 可选"
|
||||
working: "处理中…"
|
||||
@@ -610,22 +601,16 @@ 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 通过 Tor 发送其私密流量,向中继隐藏你的 IP — 加密隐藏其余部分,使中继无法将付款关联到你。"
|
||||
intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。"
|
||||
payments: "付款"
|
||||
payments_blurb: "每条携带 slatepack 的 nostr 消息。"
|
||||
usernames: "用户名"
|
||||
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
|
||||
price_avatars: "价格"
|
||||
price_avatars_blurb: "金额旁显示的实时法币汇率。"
|
||||
over_mixnet: "经由 Tor"
|
||||
over_mixnet: "经由 mixnet"
|
||||
direct_connection: "直接连接"
|
||||
grin_node: "Grin 节点"
|
||||
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
|
||||
@@ -633,7 +618,7 @@ goblin:
|
||||
title: "配对"
|
||||
intro: "你的余额和金额以何种货币显示。"
|
||||
pair_with: "配对货币"
|
||||
rates_note: "汇率仅在开启配对时通过 Tor 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||
rates_note: "汇率仅在开启配对时通过 Nym mixnet 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||
relays:
|
||||
title: "中继"
|
||||
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
|
||||
@@ -675,7 +660,7 @@ goblin:
|
||||
private_money_head: "私密货币"
|
||||
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
|
||||
send_like_message_head: "像发消息一样付款"
|
||||
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Tor 送达 — 中间任何人都看不到金额或参与者。"
|
||||
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Nym mixnet 送达 — 中间任何人都看不到金额或参与者。"
|
||||
yours_alone_head: "完全属于你"
|
||||
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
|
||||
get_started: "开始使用"
|
||||
@@ -726,8 +711,8 @@ goblin:
|
||||
kicker: "步骤 3 / 3 · 身份"
|
||||
title: "你的付款身份"
|
||||
key_being_made: "正在生成密钥…"
|
||||
connected_nym: "已通过 Tor 连接"
|
||||
connecting_nym: "正在通过 Tor 连接…"
|
||||
connected_nym: "已通过 Nym 连接"
|
||||
connecting_nym: "正在通过 Nym 连接…"
|
||||
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
|
||||
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
|
||||
pick_username: "选择用户名 — 可选"
|
||||
@@ -735,7 +720,7 @@ goblin:
|
||||
username_field_hint: "你的用户名"
|
||||
working: "处理中…"
|
||||
claim_username: "注册用户名"
|
||||
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
|
||||
available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。"
|
||||
youre: "你是 %{name}"
|
||||
claimed_title: "%{name} 已归你所有"
|
||||
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
|
||||
@@ -793,11 +778,11 @@ goblin:
|
||||
row_they_pay: "对方支付"
|
||||
row_they_pay_val: "仅当对方同意时"
|
||||
row_delivery: "传输"
|
||||
row_delivery_val: "NIP-44 加密,经由 Tor"
|
||||
row_delivery_val: "NIP-44 加密,经由 Nym"
|
||||
row_network_fee: "网络费用"
|
||||
row_network_fee_val: "从你的余额中扣除"
|
||||
row_privacy: "隐私"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
send_request_btn: "发送请求"
|
||||
request_approve_hint: "对方将收到一条待同意的请求"
|
||||
hold_to_send: "长按发送"
|
||||
|
||||
@@ -54,8 +54,8 @@ function build_lib() {
|
||||
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
|
||||
rm -f Cargo.toml-e
|
||||
|
||||
# The Tor transport (embedded arti) is linked INTO libgrim.so, so there is no
|
||||
# separate sidecar binary to cross-build or bundle into jniLibs.
|
||||
# The Nym mixnet is linked INTO libgrim.so (nym-sdk is a regular dependency),
|
||||
# so there is no separate sidecar binary to cross-build or bundle into jniLibs.
|
||||
}
|
||||
|
||||
### Build application
|
||||
@@ -80,15 +80,12 @@ function build_apk() {
|
||||
fi
|
||||
|
||||
if [[ $1 == "" ]] && [ $success -eq 1 ]; then
|
||||
# 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)
|
||||
# Launch application at all connected devices.
|
||||
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 "${app_id}/mw.gri.android.MainActivity";
|
||||
adb -s "$SERIAL" shell am start -n mw.gri.android/.MainActivity;
|
||||
done
|
||||
elif [ $success -eq 1 ]; then
|
||||
# Get version
|
||||
|
||||
@@ -36,18 +36,6 @@ 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,12 +49,11 @@ fetch_zig() {
|
||||
}
|
||||
|
||||
fetch_appimage() {
|
||||
[ -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)…"
|
||||
[ -x "${TC}/appimagetool" ] && [ -e "${TC}/runtime-x86_64" ] && { echo "appimagetool ${AT_VER}: present"; return; }
|
||||
echo "appimage: fetching appimagetool ${AT_VER} + type2 runtime…"
|
||||
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"
|
||||
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"
|
||||
chmod +x "${TC}/appimagetool" "${TC}/runtime-x86_64"
|
||||
}
|
||||
|
||||
# Assemble a minimal Android SDK (build-tools + platform + platform-tools) from
|
||||
|
||||
@@ -330,38 +330,14 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Paint the title.
|
||||
let title_text = format!("Goblin ツ · Build {}", crate::BUILD);
|
||||
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,
|
||||
painter.text(
|
||||
title_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
title_text,
|
||||
egui::FontId::proportional(15.0),
|
||||
Colors::title(true),
|
||||
);
|
||||
|
||||
ui.scope_builder(UiBuilder::new().max_rect(title_rect), |ui| {
|
||||
|
||||
@@ -40,12 +40,6 @@ 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)),
|
||||
@@ -273,89 +267,6 @@ 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.
|
||||
|
||||
@@ -39,17 +39,6 @@ pub struct Desktop {
|
||||
|
||||
/// Flag to check if attention required after window focusing.
|
||||
attention_required: Arc<AtomicBool>,
|
||||
|
||||
/// Long-lived clipboard owner. On Linux (X11 AND Wayland) the clipboard
|
||||
/// selection is owned by the live `arboard::Clipboard` instance: the prior
|
||||
/// code created a fresh instance per call and dropped it the moment the
|
||||
/// function returned, so the selection ownership was released immediately and
|
||||
/// copied text (e.g. a recovery phrase) vanished before it could be pasted —
|
||||
/// the "Paste does nothing on desktop" bug. Keeping ONE instance alive for
|
||||
/// the app lifetime makes our process the durable selection owner, so a copy
|
||||
/// survives long enough to paste (in-app or into another window). Held behind
|
||||
/// a Mutex because the trait methods take `&self` and arboard's take `&mut`.
|
||||
clipboard: Arc<parking_lot::Mutex<Option<arboard::Clipboard>>>,
|
||||
}
|
||||
|
||||
impl Desktop {
|
||||
@@ -60,28 +49,9 @@ impl Desktop {
|
||||
camera_index: Arc::new(AtomicUsize::new(0)),
|
||||
stop_camera: Arc::new(AtomicBool::new(false)),
|
||||
attention_required: Arc::new(AtomicBool::new(false)),
|
||||
clipboard: Arc::new(parking_lot::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` against the process-wide, lazily-created clipboard instance,
|
||||
/// returning `default` if the clipboard backend can't be opened. Reusing one
|
||||
/// instance (rather than `Clipboard::new()` per call) is what keeps a copied
|
||||
/// selection alive on Linux — see the `clipboard` field.
|
||||
fn with_clipboard<R>(&self, f: impl FnOnce(&mut arboard::Clipboard) -> R, default: R) -> R {
|
||||
let mut guard = self.clipboard.lock();
|
||||
if guard.is_none() {
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(c) => *guard = Some(c),
|
||||
Err(e) => {
|
||||
log::error!("clipboard: failed to open: {e}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
f(guard.as_mut().unwrap())
|
||||
}
|
||||
|
||||
// #[allow(dead_code)]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_camera_capture(
|
||||
@@ -241,24 +211,13 @@ impl PlatformCallbacks for Desktop {
|
||||
}
|
||||
|
||||
fn copy_string_to_buffer(&self, data: String) {
|
||||
// Reuse the long-lived instance so the selection survives the call (see
|
||||
// the `clipboard` field). A backend error is logged, never a panic — a
|
||||
// failed copy must not crash the wallet.
|
||||
self.with_clipboard(
|
||||
|clipboard| {
|
||||
if let Err(e) = clipboard.set_text(data) {
|
||||
log::error!("clipboard: set_text failed: {e}");
|
||||
}
|
||||
},
|
||||
(),
|
||||
);
|
||||
let mut clipboard = arboard::Clipboard::new().unwrap();
|
||||
clipboard.set_text(data).unwrap();
|
||||
}
|
||||
|
||||
fn get_string_from_buffer(&self) -> String {
|
||||
self.with_clipboard(
|
||||
|clipboard| clipboard.get_text().unwrap_or_default(),
|
||||
String::new(),
|
||||
)
|
||||
let mut clipboard = arboard::Clipboard::new().unwrap();
|
||||
clipboard.get_text().unwrap_or("".to_string())
|
||||
}
|
||||
|
||||
fn start_camera(&self) {
|
||||
@@ -400,28 +359,3 @@ lazy_static! {
|
||||
/// Last captured image from started camera.
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clipboard_tests {
|
||||
use super::*;
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
|
||||
/// Round-trips a copy then paste through the REAL `Desktop` platform impl on
|
||||
/// the live session clipboard. Ignored by default (needs a display/clipboard
|
||||
/// backend); run manually with a Wayland/X11 session:
|
||||
/// cargo test --lib clipboard_roundtrip -- --ignored --nocapture
|
||||
/// Proves the persistent-instance fix: with the old fresh-instance-per-call
|
||||
/// pattern this read back empty on Wayland/X11.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn clipboard_roundtrip() {
|
||||
let d = Desktop::new();
|
||||
let phrase = "abandon ability able about above absent absorb abstract \
|
||||
absurd abuse access accident account accuse achieve acid acoustic \
|
||||
acquire across act action actor actress";
|
||||
d.copy_string_to_buffer(phrase.to_string());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
let got = d.get_string_from_buffer();
|
||||
assert_eq!(got, phrase, "clipboard round-trip lost the copied text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +351,12 @@ 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,9 +144,6 @@ 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,6 +115,15 @@ 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();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use grin_wallet_libwallet::TxLogEntryType;
|
||||
|
||||
use crate::nostr::{Contact, NewsItem, NostrSendStatus, NostrStore, TxNostrMeta};
|
||||
use crate::nostr::{Contact, NostrSendStatus, NostrStore, TxNostrMeta};
|
||||
use crate::wallet::Wallet;
|
||||
use crate::wallet::types::WalletTx;
|
||||
|
||||
@@ -31,6 +31,7 @@ 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>,
|
||||
@@ -43,6 +44,7 @@ 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,
|
||||
@@ -77,16 +79,18 @@ 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 = if system {
|
||||
"Mining reward".to_string()
|
||||
let (title, hue) = if system {
|
||||
("Mining reward".to_string(), 5)
|
||||
} else if let Some(m) = &meta {
|
||||
store_ref
|
||||
.map(|s| contact_title(s, &m.npub))
|
||||
.unwrap_or_else(|| short_npub(&m.npub))
|
||||
} else if incoming {
|
||||
"Received".to_string()
|
||||
.unwrap_or_else(|| (short_npub(&m.npub), 0))
|
||||
} else {
|
||||
"Sent".to_string()
|
||||
let label = if incoming { "Received" } else { "Sent" };
|
||||
(
|
||||
label.to_string(),
|
||||
(tx.data.id as usize) % crate::gui::theme::avatar_pairs_len(),
|
||||
)
|
||||
};
|
||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||
let time = tx
|
||||
@@ -129,6 +133,7 @@ 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,
|
||||
@@ -179,11 +184,12 @@ 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 {
|
||||
pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) {
|
||||
if let Some(contact) = store.contact(npub) {
|
||||
display_name(&contact)
|
||||
(display_name(&contact), contact.hue as usize)
|
||||
} else {
|
||||
short_npub(npub)
|
||||
let hue = hue_of(&npub);
|
||||
(short_npub(npub), hue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,9 +229,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). Only fills the persisted
|
||||
/// `Contact.hue` field these days — nothing reads it for rendering anymore.
|
||||
/// across the full color-pair palette).
|
||||
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()
|
||||
@@ -243,17 +249,16 @@ 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) {
|
||||
// `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..]);
|
||||
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;
|
||||
}
|
||||
return npub;
|
||||
}
|
||||
format!("{}…", &hex[..8.min(hex.len())])
|
||||
}
|
||||
@@ -295,17 +300,23 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
.as_ref()
|
||||
.and_then(|sid| store.and_then(|s| s.tx_meta(sid)));
|
||||
|
||||
let title = if system {
|
||||
"Mining reward".to_string()
|
||||
let (title, hue) = if system {
|
||||
("Mining reward".to_string(), 5)
|
||||
} else if let Some(meta) = &meta {
|
||||
store
|
||||
.map(|s| contact_title(s, &meta.npub))
|
||||
.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()
|
||||
.unwrap_or_else(|| (short_npub(&meta.npub), 0))
|
||||
} else {
|
||||
"Sent".to_string()
|
||||
// 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(),
|
||||
)
|
||||
};
|
||||
|
||||
let note = meta.as_ref().and_then(|m| m.note.clone());
|
||||
@@ -326,14 +337,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), as
|
||||
/// `(display name, npub hex)`.
|
||||
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, String)> {
|
||||
/// Recent unique peers for the home strip (most recent first).
|
||||
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> {
|
||||
let store = match wallet.nostr_service() {
|
||||
Some(s) => s.store.clone(),
|
||||
None => return vec![],
|
||||
@@ -343,14 +354,13 @@ pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, String)> {
|
||||
contacts
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|c| (display_name(&c), c.npub))
|
||||
.map(|c| (display_name(&c), c.hue as usize, c.npub))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Local contacts whose petname / nip05 / npub contains `query` (case-
|
||||
/// insensitive) — the instant, no-network half of the recipient search.
|
||||
/// Returns `(display name, npub hex)` pairs.
|
||||
pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, String)> {
|
||||
pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, usize, String)> {
|
||||
let store = match wallet.nostr_service() {
|
||||
Some(s) => s.store.clone(),
|
||||
None => return vec![],
|
||||
@@ -359,7 +369,7 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
||||
if q.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut hits: Vec<(String, String)> = store
|
||||
let mut hits: Vec<(String, usize, String)> = store
|
||||
.all_contacts()
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
@@ -373,303 +383,8 @@ 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.npub))
|
||||
.map(|c| (display_name(&c), c.hue as usize, c.npub))
|
||||
.collect();
|
||||
hits.truncate(limit);
|
||||
hits
|
||||
}
|
||||
|
||||
/// The news post to show in the Home panel for the wallet's active language, or
|
||||
/// `None` (panel hides). Selection is language-aware: the newest article whose
|
||||
/// detected language matches the app locale, falling back to the newest English
|
||||
/// article. The returned item's title has any `[xx]` language marker stripped
|
||||
/// for display. `GOBLIN_FAKE_NEWS=1` injects a fixed multilingual set in debug
|
||||
/// builds so the panel can be screenshotted without a live relay feed.
|
||||
pub fn news_latest(wallet: &Wallet) -> Option<NewsItem> {
|
||||
let items = news_pool(wallet);
|
||||
let mut item = select_news(&items, &news_locale_code())?;
|
||||
item.title = news_display_title(&item.title);
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// The candidate news set (all cached posts), or a fixed multilingual sample
|
||||
/// under `GOBLIN_FAKE_NEWS` in debug builds. Kept separate from selection so the
|
||||
/// selection logic stays a pure, unit-testable function.
|
||||
fn news_pool(wallet: &Wallet) -> Vec<NewsItem> {
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var("GOBLIN_FAKE_NEWS").is_ok() {
|
||||
return vec![
|
||||
NewsItem {
|
||||
d: "welcome-en".to_string(),
|
||||
created_at: 100,
|
||||
title: "Welcome to Goblin".to_string(),
|
||||
summary: "Private grin payments over Tor. Read more: https://docs.goblin.st"
|
||||
.to_string(),
|
||||
lang: None,
|
||||
},
|
||||
NewsItem {
|
||||
d: "welcome-de".to_string(),
|
||||
created_at: 100,
|
||||
title: "Willkommen bei Goblin [de]".to_string(),
|
||||
summary: "Private Grin-Zahlungen über Tor. Mehr dazu: https://docs.goblin.st"
|
||||
.to_string(),
|
||||
lang: None,
|
||||
},
|
||||
];
|
||||
}
|
||||
wallet
|
||||
.nostr_service()
|
||||
.map(|s| s.store.all_news())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The app's active locale folded to the ISO 639-1 code used to match news
|
||||
/// articles. The shipped locales are `en/de/fr/ru/tr/zh-CN`; only `zh-CN` needs
|
||||
/// folding to its 639-1 primary `zh`, and every other locale already is a
|
||||
/// two-letter primary. Region and separator (`-`/`_`) are dropped.
|
||||
fn news_locale_code() -> String {
|
||||
let loc = rust_i18n::locale().to_string().to_lowercase();
|
||||
loc.split(['-', '_']).next().unwrap_or("en").to_string()
|
||||
}
|
||||
|
||||
/// Detect an article's language as a lower-case ISO 639-1 code. Priority: the
|
||||
/// stored event language tag, then a trailing `[xx]` marker on the title, else
|
||||
/// English (`None`). Pure — the unit tests exercise it directly.
|
||||
pub fn news_language(item: &NewsItem) -> Option<String> {
|
||||
if let Some(l) = &item.lang {
|
||||
let l = l.trim().to_lowercase();
|
||||
if is_lang_code(&l) {
|
||||
return Some(l);
|
||||
}
|
||||
}
|
||||
title_lang_marker(&item.title)
|
||||
}
|
||||
|
||||
/// The trailing `[xx]` marker on a title (case-insensitive, `xx` = two ASCII
|
||||
/// letters), as a lower-case code, or `None`. Only a marker at the very end of
|
||||
/// the (trimmed) title counts, so a `[link]` mid-sentence is never mistaken for
|
||||
/// a language.
|
||||
fn title_lang_marker(title: &str) -> Option<String> {
|
||||
let t = title.trim();
|
||||
let inner = t.strip_suffix(']')?.rsplit_once('[')?.1;
|
||||
let code = inner.trim().to_lowercase();
|
||||
if is_lang_code(&code) {
|
||||
Some(code)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A displayable title with any trailing `[xx]` language marker removed.
|
||||
pub fn news_display_title(title: &str) -> String {
|
||||
match title_lang_marker(title) {
|
||||
Some(_) => {
|
||||
let t = title.trim_end();
|
||||
// Drop the `[xx]` token and the whitespace that preceded it.
|
||||
match t.rfind('[') {
|
||||
Some(idx) => t[..idx].trim_end().to_string(),
|
||||
None => t.to_string(),
|
||||
}
|
||||
}
|
||||
None => title.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a two-letter ASCII-alphabetic language code.
|
||||
fn is_lang_code(s: &str) -> bool {
|
||||
s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Select the news article to show for `target` (an ISO 639-1 code): the newest
|
||||
/// article in that language, else the newest English article (English = an
|
||||
/// explicit `en` OR no detected language). Pure so it is unit-testable without a
|
||||
/// wallet/store. Ties on `created_at` resolve to the last such article.
|
||||
pub fn select_news(items: &[NewsItem], target: &str) -> Option<NewsItem> {
|
||||
let target = if target.is_empty() { "en" } else { target };
|
||||
let lang_of = |it: &NewsItem| news_language(it).unwrap_or_else(|| "en".to_string());
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| lang_of(it) == target)
|
||||
.max_by_key(|it| it.created_at)
|
||||
.or_else(|| {
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| lang_of(it) == "en")
|
||||
.max_by_key(|it| it.created_at)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Split a plain-text summary into (segment, is_url) runs so http(s) URLs render
|
||||
/// as tappable links and the rest as plain labels. Trailing sentence
|
||||
/// punctuation is trimmed off a URL so "…goblin.st." doesn't link the dot.
|
||||
pub fn split_urls(s: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(idx) = rest.find("http") {
|
||||
let candidate = &rest[idx..];
|
||||
if candidate.starts_with("http://") || candidate.starts_with("https://") {
|
||||
if idx > 0 {
|
||||
out.push((rest[..idx].to_string(), false));
|
||||
}
|
||||
let end = candidate
|
||||
.find(char::is_whitespace)
|
||||
.unwrap_or(candidate.len());
|
||||
let mut url = &candidate[..end];
|
||||
while let Some(last) = url.chars().last() {
|
||||
if matches!(last, '.' | ',' | ')' | ']' | '}' | '!' | '?' | ';' | ':') {
|
||||
url = &url[..url.len() - last.len_utf8()];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push((url.to_string(), true));
|
||||
rest = &candidate[url.len()..];
|
||||
} else {
|
||||
// A bare "http" that isn't a scheme; emit it as text and move past it.
|
||||
let split_at = idx + 4;
|
||||
out.push((rest[..split_at].to_string(), false));
|
||||
rest = &rest[split_at..];
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
out.push((rest.to_string(), false));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_urls_isolates_links() {
|
||||
let segs = split_urls("Tor is live. Read more: https://docs.goblin.st now");
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![
|
||||
("Tor is live. Read more: ".to_string(), false),
|
||||
("https://docs.goblin.st".to_string(), true),
|
||||
(" now".to_string(), false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_urls_trims_trailing_punctuation_and_handles_no_url() {
|
||||
let segs = split_urls("See https://x.io.");
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![
|
||||
("See ".to_string(), false),
|
||||
("https://x.io".to_string(), true),
|
||||
(".".to_string(), false),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
split_urls("plain text"),
|
||||
vec![("plain text".to_string(), false)]
|
||||
);
|
||||
}
|
||||
|
||||
fn news(d: &str, created_at: i64, title: &str, lang: Option<&str>) -> NewsItem {
|
||||
NewsItem {
|
||||
d: d.to_string(),
|
||||
created_at,
|
||||
title: title.to_string(),
|
||||
summary: String::new(),
|
||||
lang: lang.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_from_event_tag_wins() {
|
||||
// A stored event language tag is authoritative, even if the title has no
|
||||
// marker (bare `["l","de"]`) or a differing marker.
|
||||
let it = news("a", 1, "Neuigkeiten", Some("de"));
|
||||
assert_eq!(news_language(&it).as_deref(), Some("de"));
|
||||
// NIP-32-style tag is stored the same way (code already extracted upstream).
|
||||
let it = news("b", 1, "News", Some("FR"));
|
||||
assert_eq!(news_language(&it).as_deref(), Some("fr"));
|
||||
// A non-code tag value is ignored, falling through to the title (English).
|
||||
let it = news("c", 1, "News", Some("english"));
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_from_title_suffix_marker() {
|
||||
let it = news("a", 1, "2026-07-05 Welcome to Goblin [de]", None);
|
||||
assert_eq!(news_language(&it).as_deref(), Some("de"));
|
||||
// Case-insensitive.
|
||||
let it = news("b", 1, "Bonjour [FR]", None);
|
||||
assert_eq!(news_language(&it).as_deref(), Some("fr"));
|
||||
// Only a marker at the very end counts; a bracketed word mid-title does not.
|
||||
let it = news("c", 1, "Read the [guide] today", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_means_english() {
|
||||
let it = news("a", 1, "Welcome to Goblin", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
// A non-two-letter bracket suffix is not a language marker.
|
||||
let it = news("b", 1, "Build 137 [beta]", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_strips_marker() {
|
||||
assert_eq!(
|
||||
news_display_title("2026-07-05 Welcome to Goblin [de]"),
|
||||
"2026-07-05 Welcome to Goblin"
|
||||
);
|
||||
assert_eq!(news_display_title("Bonjour [FR]"), "Bonjour");
|
||||
// No marker: unchanged.
|
||||
assert_eq!(news_display_title("Welcome to Goblin"), "Welcome to Goblin");
|
||||
// Non-language bracket suffix: left intact.
|
||||
assert_eq!(news_display_title("Build 137 [beta]"), "Build 137 [beta]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_matches_locale_then_falls_back_to_english() {
|
||||
let pool = vec![
|
||||
news("en", 100, "Welcome to Goblin", None),
|
||||
news("de", 90, "Willkommen bei Goblin [de]", None),
|
||||
news("fr", 80, "Bonjour", Some("fr")),
|
||||
];
|
||||
// German locale → the German article.
|
||||
assert_eq!(select_news(&pool, "de").unwrap().d, "de");
|
||||
// French locale (via event tag) → the French article.
|
||||
assert_eq!(select_news(&pool, "fr").unwrap().d, "fr");
|
||||
// English locale → the English (unmarked) article.
|
||||
assert_eq!(select_news(&pool, "en").unwrap().d, "en");
|
||||
// A locale with no article → fall back to the newest English article.
|
||||
assert_eq!(select_news(&pool, "ru").unwrap().d, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_picks_newest_within_language_slice() {
|
||||
let pool = vec![
|
||||
news("de-old", 50, "Alt [de]", None),
|
||||
news("de-new", 150, "Neu [de]", None),
|
||||
news("en", 200, "Newest overall", None),
|
||||
];
|
||||
// Within German, the newest German article wins — NOT the newer English one.
|
||||
assert_eq!(select_news(&pool, "de").unwrap().d, "de-new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_folding_maps_zh_cn_to_zh() {
|
||||
rust_i18n::set_locale("zh-CN");
|
||||
assert_eq!(news_locale_code(), "zh");
|
||||
rust_i18n::set_locale("de");
|
||||
assert_eq!(news_locale_code(), "de");
|
||||
rust_i18n::set_locale("en");
|
||||
assert_eq!(news_locale_code(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_selects_nothing() {
|
||||
assert!(select_news(&[], "de").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 bytes. f64 throughout for cross-port byte-identity.
|
||||
pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
/// Standard HSL → RGB → `#rrggbb`. f64 throughout for cross-port byte-identity.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
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,13 +51,7 @@ pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
let to = |v: f64| ((v + m) * 255.0).round() as u8;
|
||||
(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}")
|
||||
format!("#{:02x}{:02x}{:02x}", to(r), to(g), to(b))
|
||||
}
|
||||
|
||||
/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical
|
||||
@@ -85,6 +79,17 @@ 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
|
||||
|
||||
@@ -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, CHECK};
|
||||
use crate::gui::icons::ARROW_LEFT;
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
use crate::gui::theme::{self, fonts};
|
||||
use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult};
|
||||
@@ -75,8 +75,6 @@ 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
|
||||
@@ -106,7 +104,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://grincoin.org".to_string(),
|
||||
ext_url: "https://api.grin.money".to_string(),
|
||||
restore: false,
|
||||
name: "Main wallet".to_string(),
|
||||
pass: String::new(),
|
||||
@@ -117,7 +115,6 @@ impl Default for OnboardingContent {
|
||||
wallet: None,
|
||||
claim: ClaimState::default(),
|
||||
import: None,
|
||||
words_copied: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -564,24 +561,9 @@ impl OnboardingContent {
|
||||
);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
} 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());
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
if !restore {
|
||||
ui.add_space(14.0);
|
||||
@@ -764,8 +746,7 @@ 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() {
|
||||
// Key still generating: a fixed-seed gradient placeholder.
|
||||
w::gradient_avatar(ui, "goblin", 44.0);
|
||||
w::avatar(ui, "N", 44.0, 6);
|
||||
} else {
|
||||
w::gradient_avatar(ui, &npub, 44.0);
|
||||
}
|
||||
@@ -802,9 +783,7 @@ impl OnboardingContent {
|
||||
);
|
||||
}
|
||||
ui.label(
|
||||
// Relay-gated readiness: "connected over Nym" only once a
|
||||
// relay is actually live, not merely when the tunnel is warm.
|
||||
RichText::new(if crate::tor::transport_ready() {
|
||||
RichText::new(if connected {
|
||||
t!("goblin.onboarding.identity.connected_nym")
|
||||
} else {
|
||||
t!("goblin.onboarding.identity.connecting_nym")
|
||||
|
||||
@@ -76,6 +76,7 @@ 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>,
|
||||
@@ -86,6 +87,7 @@ 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,
|
||||
@@ -178,9 +180,11 @@ 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;
|
||||
@@ -424,7 +428,6 @@ 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.
|
||||
@@ -470,7 +473,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])
|
||||
@@ -483,16 +486,16 @@ impl SendFlow {
|
||||
.color(t.text_dim),
|
||||
);
|
||||
}
|
||||
for ((name, npub), tex) in peers.into_iter().zip(texs.iter()) {
|
||||
for ((name, hue, 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()
|
||||
@@ -500,6 +503,7 @@ impl SendFlow {
|
||||
self.pick(Candidate {
|
||||
name,
|
||||
npub,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "",
|
||||
relay_hints: vec![],
|
||||
@@ -513,9 +517,10 @@ 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, npub)| Candidate {
|
||||
.map(|(name, hue, npub)| Candidate {
|
||||
name,
|
||||
npub,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "contact",
|
||||
relay_hints: vec![],
|
||||
@@ -550,11 +555,11 @@ impl SendFlow {
|
||||
ui,
|
||||
&c.name,
|
||||
&tag,
|
||||
c.hue,
|
||||
&c.npub,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
tex.as_ref(),
|
||||
)
|
||||
.clicked()
|
||||
@@ -594,6 +599,7 @@ 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)
|
||||
@@ -680,59 +686,18 @@ impl SendFlow {
|
||||
// seed words or slatepack contents into the search box.
|
||||
match &result {
|
||||
QrScanResult::Text(text) => {
|
||||
// Classify the scan. UNTRUSTED input: the parser is pure and
|
||||
// fail-closed, and every path still gates the send on the review
|
||||
// screen (the user confirms) — nothing auto-SENDS.
|
||||
match recognize_scan(text) {
|
||||
// GoblinPay checkout QR: recipient key AND amount are both
|
||||
// known, so skip discovery and open review directly, prefilled.
|
||||
// (Owner-ordered: a merchant checkout shouldn't re-walk the
|
||||
// recipient search.) A cheap LOCAL contact lookup supplies a
|
||||
// friendly name when we already know them; otherwise the short
|
||||
// npub — no network resolution on this path.
|
||||
ScanPrefill::Review {
|
||||
hex,
|
||||
relay_hints,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
let name = wallet
|
||||
.nostr_service()
|
||||
.and_then(|s| s.store.contact(&hex).map(|c| display_name(&c)))
|
||||
.unwrap_or_else(|| short_npub(&hex));
|
||||
self.recipient = Some(Recipient {
|
||||
name,
|
||||
npub: hex,
|
||||
relay_hints,
|
||||
});
|
||||
self.amount = amount;
|
||||
if let Some(memo) = memo {
|
||||
self.note = memo;
|
||||
}
|
||||
self.error = None;
|
||||
self.stage = Stage::Review;
|
||||
}
|
||||
// Only a recipient (or a name/@handle needing resolution):
|
||||
// drop it into the search box so the picker's debounced lookup
|
||||
// resolves + verifies it like typed input, exactly as before.
|
||||
// Any amount/memo still prefill.
|
||||
ScanPrefill::Search {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
self.search = recipient;
|
||||
self.input_changed_at = ui.input(|i| i.time);
|
||||
self.lookup_query.clear();
|
||||
self.net_candidate = None;
|
||||
if let Some(amount) = amount {
|
||||
self.amount = amount;
|
||||
}
|
||||
if let Some(memo) = memo {
|
||||
self.note = memo;
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = text.trim();
|
||||
let text = text
|
||||
.strip_prefix("nostr:")
|
||||
.or_else(|| text.strip_prefix("NOSTR:"))
|
||||
.unwrap_or(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.input_changed_at = ui.input(|i| i.time);
|
||||
self.lookup_query.clear();
|
||||
self.net_candidate = None;
|
||||
let _ = wallet;
|
||||
}
|
||||
_ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()),
|
||||
}
|
||||
@@ -861,16 +826,20 @@ 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)));
|
||||
let known = wallet.nostr_service().and_then(|s| {
|
||||
s.store
|
||||
.contact(&hex)
|
||||
.map(|c| (display_name(&c), c.hue as usize))
|
||||
});
|
||||
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), _) => LookupResult::Found(Candidate {
|
||||
(Some((name, hue)), _) => LookupResult::Found(Candidate {
|
||||
name,
|
||||
npub: hex,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "contact",
|
||||
relay_hints: key_hints,
|
||||
@@ -885,6 +854,7 @@ impl SendFlow {
|
||||
LookupResult::Found(Candidate {
|
||||
name,
|
||||
npub: hex,
|
||||
hue,
|
||||
verified: true,
|
||||
tag: "on nostr",
|
||||
relay_hints: key_hints,
|
||||
@@ -893,6 +863,7 @@ impl SendFlow {
|
||||
(None, None) => LookupResult::Unverified(Candidate {
|
||||
name: short_npub(&hex),
|
||||
npub: hex,
|
||||
hue,
|
||||
verified: false,
|
||||
tag: "",
|
||||
relay_hints: key_hints,
|
||||
@@ -922,6 +893,7 @@ 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.
|
||||
@@ -981,6 +953,7 @@ impl SendFlow {
|
||||
&recipient.name,
|
||||
&recipient.npub,
|
||||
28.0,
|
||||
recipient.hue,
|
||||
chip_tex.as_ref(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
@@ -1033,16 +1006,13 @@ 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));
|
||||
// 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 {
|
||||
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 {
|
||||
// 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);
|
||||
@@ -1084,12 +1054,13 @@ impl SendFlow {
|
||||
let valid = amount_from_hr_string(&self.amount)
|
||||
.map(|a| a > 0)
|
||||
.unwrap_or(false);
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
false
|
||||
@@ -1151,6 +1122,7 @@ impl SendFlow {
|
||||
&recipient.name,
|
||||
&recipient.npub,
|
||||
40.0,
|
||||
recipient.hue,
|
||||
hero_tex.as_ref(),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
@@ -1200,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()
|
||||
@@ -1520,174 +1492,9 @@ fn resolve_nip05_blocking(name: &str, domain: &str) -> Option<nip05::Nip05Resolu
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()?;
|
||||
// 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()
|
||||
})
|
||||
rt.block_on(nip05::resolve(&name, &domain))
|
||||
})
|
||||
.join()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// What a scanned QR resolves to for the send flow.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ScanPrefill {
|
||||
/// A GoblinPay payment: the recipient key AND a validated amount are both
|
||||
/// already known, so the flow skips the recipient search/discovery step and
|
||||
/// opens the review screen directly. The user still confirms on review.
|
||||
Review {
|
||||
/// Recipient pubkey hex.
|
||||
hex: String,
|
||||
/// nprofile relay hints (delivery targets for an undiscoverable 10050).
|
||||
relay_hints: Vec<String>,
|
||||
/// Validated decimal-GRIN amount string.
|
||||
amount: String,
|
||||
/// Optional sanitized memo → send note.
|
||||
memo: Option<String>,
|
||||
},
|
||||
/// Only a recipient string (a bare key/name, or a payload without a usable
|
||||
/// amount): drop it into the search box and let the picker resolve it, the
|
||||
/// pre-existing behavior. Any amount/memo still prefill.
|
||||
Search {
|
||||
recipient: String,
|
||||
amount: Option<String>,
|
||||
memo: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Decode a recipient string (`npub` / `nprofile` / 64-char hex, with an
|
||||
/// optional `nostr:` prefix) to a `(pubkey hex, relay hints)` pair. `None` for a
|
||||
/// name / `@handle` (which needs a NIP-05 network resolution) or garbage — those
|
||||
/// still go through the search box. Pure: no I/O.
|
||||
fn decode_recipient_key(input: &str) -> Option<(String, Vec<String>)> {
|
||||
use nostr_sdk::nips::nip19::Nip19Profile;
|
||||
use nostr_sdk::{FromBech32, PublicKey};
|
||||
let key = input.trim().strip_prefix("nostr:").unwrap_or(input.trim());
|
||||
if let Ok(pk) = PublicKey::from_bech32(key) {
|
||||
Some((pk.to_hex(), vec![]))
|
||||
} else if let Ok(p) = Nip19Profile::from_bech32(key) {
|
||||
let hints = p.relays.iter().map(|r| r.to_string()).collect();
|
||||
Some((p.public_key.to_hex(), hints))
|
||||
} else if key.len() == 64 && key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
Some((key.to_lowercase(), vec![]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a scanned QR payload (see [`ScanPrefill`]). A GoblinPay checkout QR
|
||||
/// carries the recipient AND amount, so when the recipient decodes to a concrete
|
||||
/// key locally and the amount validates, we can skip discovery and go straight
|
||||
/// to review. Every other payload (name/@handle, amount-less key, non-`nostr:`
|
||||
/// text) degrades to the search-box path. Pure and total, so it is unit-testable
|
||||
/// without a wallet or camera.
|
||||
fn recognize_scan(scanned: &str) -> ScanPrefill {
|
||||
let pay = crate::nostr::payuri::parse(scanned);
|
||||
match (decode_recipient_key(&pay.recipient), pay.amount.clone()) {
|
||||
(Some((hex, relay_hints)), Some(amount)) => ScanPrefill::Review {
|
||||
hex,
|
||||
relay_hints,
|
||||
amount,
|
||||
memo: pay.memo,
|
||||
},
|
||||
_ => ScanPrefill::Search {
|
||||
recipient: pay.recipient,
|
||||
amount: pay.amount,
|
||||
memo: pay.memo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A real, valid bech32 npub for decode tests (the Goblin news key).
|
||||
const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely";
|
||||
|
||||
#[test]
|
||||
fn goblinpay_uri_with_amount_goes_straight_to_review() {
|
||||
let uri = format!("nostr:{NPUB}?amount=1.5&memo=MM-ABC123");
|
||||
match recognize_scan(&uri) {
|
||||
ScanPrefill::Review {
|
||||
hex,
|
||||
amount,
|
||||
memo,
|
||||
relay_hints,
|
||||
} => {
|
||||
assert_eq!(hex.len(), 64);
|
||||
assert_eq!(amount, "1.5");
|
||||
assert_eq!(memo.as_deref(), Some("MM-ABC123"));
|
||||
assert!(relay_hints.is_empty());
|
||||
}
|
||||
other => panic!("expected Review, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_without_amount_uses_search_box() {
|
||||
// A bare recipient key with no amount is NOT a full payment; the picker
|
||||
// resolves it as before (search box), no straight-to-review.
|
||||
match recognize_scan(&format!("nostr:{NPUB}")) {
|
||||
ScanPrefill::Search {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
assert_eq!(recipient, NPUB);
|
||||
assert_eq!(amount, None);
|
||||
assert_eq!(memo, None);
|
||||
}
|
||||
other => panic!("expected Search, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_handle_with_amount_still_uses_search_box() {
|
||||
// A NIP-05 name needs a network resolution, so even with an amount it goes
|
||||
// through the search box (which prefills the amount along the way).
|
||||
match recognize_scan("nostr:alice@goblin.st?amount=2") {
|
||||
ScanPrefill::Search {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
assert_eq!(recipient, "alice@goblin.st");
|
||||
assert_eq!(amount.as_deref(), Some("2"));
|
||||
assert_eq!(memo, None);
|
||||
}
|
||||
other => panic!("expected Search, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_nostr_payload_uses_search_box() {
|
||||
match recognize_scan("just some text") {
|
||||
ScanPrefill::Search { recipient, .. } => assert_eq!(recipient, "just some text"),
|
||||
other => panic!("expected Search, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_key_variants() {
|
||||
// Bech32 npub, with and without the scheme prefix.
|
||||
assert!(decode_recipient_key(NPUB).is_some());
|
||||
assert!(decode_recipient_key(&format!("nostr:{NPUB}")).is_some());
|
||||
// 64-char hex is accepted and lower-cased.
|
||||
let hex = "AB".repeat(32);
|
||||
let (out, _) = decode_recipient_key(&hex).unwrap();
|
||||
assert_eq!(out, hex.to_lowercase());
|
||||
// A name / @handle is not a concrete key.
|
||||
assert!(decode_recipient_key("alice@goblin.st").is_none());
|
||||
assert!(decode_recipient_key("@alice").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +27,34 @@ pub fn amount_str(atomic: u64) -> String {
|
||||
grin_core::core::amount_to_hr_string(atomic, true)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Draw a colored avatar puck with the contact initial.
|
||||
pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||||
let rounding = eframe::epaint::CornerRadius::same((rect.width() / 2.0) as u8);
|
||||
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);
|
||||
egui::Image::new(tex)
|
||||
.corner_radius(rounding)
|
||||
.fit_to_exact_size(rect.size())
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.paint_at(ui, rect);
|
||||
resp
|
||||
}
|
||||
@@ -45,42 +65,80 @@ pub fn avatar_tex(ui: &mut Ui, tex: &egui::TextureHandle, _name: &str, size: f32
|
||||
/// 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());
|
||||
paint_gradient(ui, id, rect);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Paint the pubkey-seeded grinmark gradient into `rect` (rasterized at 2x,
|
||||
/// cached by egui via the `uri`).
|
||||
fn paint_gradient(ui: &mut Ui, id: &str, rect: egui::Rect) {
|
||||
let hex = super::identicon::to_hex_seed(id);
|
||||
let px = (rect.width() * 2.0) as u32;
|
||||
let svg = super::identicon::gradient_avatar_svg(&hex, px, "");
|
||||
let uri = format!("bytes://gobavatar-{}-{}.svg", hex, rect.width() as u32);
|
||||
// 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((rect.width() / 2.0) as u8))
|
||||
.fit_to_exact_size(rect.size())
|
||||
.corner_radius(CornerRadius::same((size / 2.0) as u8))
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.paint_at(ui, 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());
|
||||
let hex = super::identicon::to_hex_seed(id);
|
||||
let svg = super::identicon::gradient_bg_svg(&hex, (size * 2.0) as u32);
|
||||
let uri = format!("bytes://gobavatarbg-{}-{}.svg", hex, size as u32);
|
||||
egui::Image::new(egui::ImageSource::Bytes {
|
||||
uri: uri.into(),
|
||||
bytes: svg.into_bytes().into(),
|
||||
})
|
||||
.corner_radius(CornerRadius::same((size / 2.0) as u8))
|
||||
.fit_to_exact_size(Vec2::splat(size))
|
||||
.paint_at(ui, rect);
|
||||
// Initial — first alphanumeric of the name, never the @ prefix.
|
||||
let initial = name
|
||||
.chars()
|
||||
.find(|c| c.is_alphanumeric())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let font = FontId::new(size * 0.46, fonts::bold());
|
||||
let c = rect.center();
|
||||
ui.painter().text(
|
||||
c + Vec2::splat(size * 0.03),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&initial,
|
||||
font.clone(),
|
||||
Color32::from_black_alpha(80),
|
||||
);
|
||||
ui.painter().text(
|
||||
c,
|
||||
egui::Align2::CENTER_CENTER,
|
||||
&initial,
|
||||
font,
|
||||
Color32::from_rgb(0xFA, 0xFA, 0xF7),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Picture avatar when a texture exists; otherwise the deterministic
|
||||
/// 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.
|
||||
/// 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.
|
||||
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, name, size),
|
||||
None if !id.is_empty() => gradient_avatar(ui, id, size),
|
||||
None => gradient_avatar(ui, name, size),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,20 +309,12 @@ 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 (mut fill, mut ink, mut stroke) = if secondary {
|
||||
let (fill, ink, stroke) = if secondary {
|
||||
(Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line))
|
||||
} else {
|
||||
(t.accent, t.accent_ink, Stroke::NONE)
|
||||
};
|
||||
// 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 {
|
||||
let visual_fill = if resp.hovered() && !secondary {
|
||||
t.accent_dark
|
||||
} else {
|
||||
fill
|
||||
@@ -396,6 +446,30 @@ 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.
|
||||
@@ -462,17 +536,7 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
|
||||
}
|
||||
|
||||
/// A balance hero block: kicker, big number + ツ, optional fiat line.
|
||||
/// `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,
|
||||
) {
|
||||
pub fn balance_hero(ui: &mut Ui, total: u64, spendable: u64, 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.
|
||||
@@ -498,25 +562,6 @@ pub fn balance_hero(
|
||||
);
|
||||
});
|
||||
}
|
||||
// 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| {
|
||||
@@ -535,10 +580,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 {
|
||||
@@ -569,7 +614,7 @@ pub fn activity_row(
|
||||
t.text,
|
||||
);
|
||||
} else {
|
||||
avatar_any(ui, title, id, 40.0, tex);
|
||||
avatar_any(ui, title, id, 40.0, hue, tex);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
@@ -594,18 +639,10 @@ 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 canceled {
|
||||
t.text_dim
|
||||
} else if incoming {
|
||||
t.pos
|
||||
} else {
|
||||
t.text
|
||||
}),
|
||||
.color(if incoming { t.pos } else { t.text }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -847,6 +884,12 @@ 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
|
||||
@@ -938,3 +981,11 @@ 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.
|
||||
pub fn disabled_node_ui(ui: &mut egui::Ui) {
|
||||
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; 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.
|
||||
// Go back at stack or close wallet.
|
||||
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!("{} build{} > {}", BOOKMARKS, crate::BUILD, update.version)
|
||||
format!("{} {} > {}", BOOKMARKS, crate::VERSION, update.version)
|
||||
};
|
||||
View::ellipsize_text(ui, ver_text, 15.0, Colors::text(false));
|
||||
ui.add_space(1.0);
|
||||
|
||||
@@ -83,13 +83,6 @@ 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);
|
||||
@@ -312,11 +305,9 @@ impl WalletContent {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Check if it's possible to go back at navigation stack.
|
||||
pub fn can_back(&self) -> bool {
|
||||
self.goblin.can_back() || self.account_content.can_back()
|
||||
self.goblin.overlay_active() || 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::*;
|
||||
|
||||
pub(crate) mod price;
|
||||
mod price;
|
||||
pub use price::grin_rate;
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::tor;
|
||||
use crate::nym;
|
||||
|
||||
/// Cache refresh interval (seconds).
|
||||
const REFRESH_SECS: i64 = 300;
|
||||
@@ -33,19 +32,6 @@ 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());
|
||||
@@ -101,87 +87,13 @@ fn trigger_refresh(vs: String) {
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
if let Some(rate) = fetch_rate(&vs).await {
|
||||
record_rate(&vs, rate);
|
||||
RATES.write().insert(vs.clone(), (rate, now()));
|
||||
}
|
||||
});
|
||||
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 = tor::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 && tor::is_ready() && tor::tunnel_generation() == generation {
|
||||
tor::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!(
|
||||
@@ -191,7 +103,7 @@ async fn fetch_rate(vs: &str) -> Option<f64> {
|
||||
// CoinGecko rejects requests without a User-Agent (403). A static,
|
||||
// non-identifying UA is fine over the mixnet.
|
||||
let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())];
|
||||
let body = tor::http_request("GET", url, None, headers).await?;
|
||||
let body = nym::http_request("GET", url, None, headers).await?;
|
||||
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|doc| doc.get("grin")?.get(vs)?.as_f64());
|
||||
|
||||
@@ -75,7 +75,7 @@ impl ReleaseInfo {
|
||||
if ARCH == ARM_ARCH {
|
||||
None
|
||||
} else {
|
||||
Some(format!("goblin-{}-linux-x86_64.tar.gz", self.tag_name))
|
||||
Some(format!("goblin-{}-linux-x86_64.AppImage", self.tag_name))
|
||||
}
|
||||
}
|
||||
OperatingSystem::Mac => None,
|
||||
|
||||
@@ -38,14 +38,8 @@ mod http;
|
||||
pub mod logger;
|
||||
mod node;
|
||||
pub mod nostr;
|
||||
/// The old Nym-mixnet transport, DORMANT since the Tor swap. Retained on disk but
|
||||
/// only compiled with `--features nym` (its nym-sdk deps link a different
|
||||
/// libsqlite3-sys than arti and cannot coexist with Tor in one binary). Deletion
|
||||
/// is a later phase.
|
||||
#[cfg(feature = "nym")]
|
||||
pub mod nym;
|
||||
mod nym;
|
||||
mod settings;
|
||||
pub mod tor;
|
||||
mod wallet;
|
||||
|
||||
/// Upstream GRIM version the fork is based on (third-party credit).
|
||||
@@ -123,21 +117,16 @@ 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 embedded Tor client FIRST, before i18n/node setup, so the Tor
|
||||
// bootstrap (the long pole on cold start) overlaps everything else and
|
||||
// price/NIP-05/nostr are ready at first use. All of Goblin's relay + HTTP
|
||||
// traffic egresses through Tor; the Grin node stays on the clear internet
|
||||
// exactly as before (its lazy warm-on-activity polling is untouched).
|
||||
tor::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)
|
||||
}
|
||||
@@ -421,37 +410,6 @@ 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,15 +721,7 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncStatusText(
|
||||
_class: jni::objects::JObject,
|
||||
_activity: jni::objects::JObject,
|
||||
) -> jni::sys::jstring {
|
||||
// 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 status_text = Node::get_sync_status_text();
|
||||
let j_text = _env.new_string(status_text);
|
||||
return j_text.unwrap().into_raw();
|
||||
}
|
||||
@@ -744,14 +736,7 @@ pub extern "C" fn Java_mw_gri_android_BackgroundService_getSyncTitle(
|
||||
_class: jni::objects::JObject,
|
||||
_activity: jni::objects::JObject,
|
||||
) -> jni::sys::jstring {
|
||||
// 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);
|
||||
let j_text = _env.new_string(t!("network.node"));
|
||||
return j_text.unwrap().into_raw();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,77 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Client-side avatar handling: a small disk cache of fetched avatars keyed
|
||||
//! by username.
|
||||
//! 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.
|
||||
|
||||
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 {
|
||||
@@ -132,6 +196,33 @@ 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() {
|
||||
|
||||
@@ -32,7 +32,7 @@ pub enum AcceptPolicy {
|
||||
}
|
||||
|
||||
/// Per-wallet nostr configuration.
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NostrConfig {
|
||||
/// Whether the nostr subsystem runs for this wallet.
|
||||
enabled: Option<bool>,
|
||||
@@ -59,6 +59,21 @@ 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";
|
||||
@@ -89,16 +104,12 @@ impl NostrConfig {
|
||||
}
|
||||
|
||||
pub fn relays(&self) -> Vec<String> {
|
||||
self.relays_override()
|
||||
self.relays
|
||||
.clone()
|
||||
.filter(|r| !r.is_empty())
|
||||
.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: 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.
|
||||
//! Per-wallet nostr identity: NIP-06 derived from the wallet mnemonic by
|
||||
//! default (one seed restores money AND identity) or imported from an nsec.
|
||||
//! 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. 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).
|
||||
/// Where the keys came from.
|
||||
#[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,6 +42,8 @@ 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).
|
||||
@@ -53,12 +55,6 @@ 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).
|
||||
@@ -146,6 +142,24 @@ 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.
|
||||
@@ -153,14 +167,15 @@ impl NostrIdentity {
|
||||
keys: &Keys,
|
||||
password: &str,
|
||||
source: IdentitySource,
|
||||
account: u32,
|
||||
) -> Result<NostrIdentity, IdentityError> {
|
||||
Self::from_keys(keys, password, source)
|
||||
Self::from_keys(keys, password, source, account)
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
@@ -172,7 +187,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)?;
|
||||
let identity = Self::from_keys(&keys, password, IdentitySource::Imported, 0)?;
|
||||
Ok((identity, keys))
|
||||
}
|
||||
|
||||
@@ -180,6 +195,7 @@ impl NostrIdentity {
|
||||
keys: &Keys,
|
||||
password: &str,
|
||||
source: IdentitySource,
|
||||
account: u32,
|
||||
) -> Result<NostrIdentity, IdentityError> {
|
||||
let encrypted = EncryptedSecretKey::new(
|
||||
keys.secret_key(),
|
||||
@@ -198,12 +214,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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -302,7 +318,13 @@ 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).unwrap();
|
||||
let b = NostrIdentity::from_unlocked_keys(
|
||||
&keys,
|
||||
"new-pw",
|
||||
parsed.source,
|
||||
parsed.derivation_account,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(b.npub, a.npub);
|
||||
assert!(b.unlock("new-pw").is_ok());
|
||||
assert!(b.unlock("old-pw").is_err());
|
||||
@@ -342,10 +364,21 @@ 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_random("hunter2").unwrap();
|
||||
assert_eq!(identity.source, IdentitySource::Random);
|
||||
let (identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "hunter2", 0).unwrap();
|
||||
assert_eq!(identity.source, IdentitySource::Derived);
|
||||
assert!(identity.anonymous);
|
||||
let unlocked = identity.unlock("hunter2").unwrap();
|
||||
assert_eq!(unlocked.public_key(), keys.public_key());
|
||||
@@ -368,7 +401,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_random("pw").unwrap();
|
||||
let (identity, _) = NostrIdentity::create_derived(NIP06_MNEMONIC, "pw", 0).unwrap();
|
||||
identity.save(&dir).unwrap();
|
||||
let meta = std::fs::metadata(NostrIdentity::path(&dir)).unwrap();
|
||||
// The ncryptsec blob must never be group/world readable.
|
||||
@@ -382,7 +415,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reencrypt_changes_password() {
|
||||
let (mut identity, keys) = NostrIdentity::create_random("old").unwrap();
|
||||
let (mut identity, keys) = NostrIdentity::create_derived(NIP06_MNEMONIC, "old", 0).unwrap();
|
||||
identity.reencrypt("old", "new").unwrap();
|
||||
assert!(identity.unlock("old").is_err());
|
||||
assert_eq!(
|
||||
|
||||
@@ -22,7 +22,6 @@ pub use types::*;
|
||||
pub mod config;
|
||||
pub use config::{AcceptPolicy, NostrConfig};
|
||||
|
||||
pub mod pool;
|
||||
pub mod relays;
|
||||
|
||||
mod store;
|
||||
@@ -34,8 +33,6 @@ pub use identity::{IdentitySource, NostrIdentity};
|
||||
pub mod protocol;
|
||||
pub use protocol::*;
|
||||
|
||||
pub mod wrapv3;
|
||||
|
||||
pub mod ingest;
|
||||
pub use ingest::*;
|
||||
|
||||
@@ -44,5 +41,3 @@ 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 in-process smolmix tunnel). Nothing
|
||||
//! all HTTP routed through the Nym mixnet (the local SOCKS5 proxy). Nothing
|
||||
//! here touches clearnet.
|
||||
|
||||
use base64::Engine;
|
||||
@@ -22,7 +22,7 @@ use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::nostr::relays::HOME_NIP05_DOMAIN;
|
||||
use crate::tor;
|
||||
use crate::nym;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
/// The active name-authority "home" domain, mirrored here from the wallet config
|
||||
@@ -102,7 +102,7 @@ pub async fn resolve(name: &str, domain: &str) -> Option<Nip05Resolution> {
|
||||
domain,
|
||||
urlencode(name)
|
||||
);
|
||||
let body = tor::http_request("GET", url, None, vec![]).await?;
|
||||
let body = nym::http_request("GET", url, None, vec![]).await?;
|
||||
parse_well_known(&body, name)
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ pub async fn name_by_pubkey(domain: &str, pubkey_hex: &str) -> Option<String> {
|
||||
domain,
|
||||
urlencode(pubkey_hex)
|
||||
);
|
||||
let body = tor::http_request("GET", url, None, vec![]).await?;
|
||||
let body = nym::http_request("GET", url, None, vec![]).await?;
|
||||
let doc: Value = serde_json::from_str(&body).ok()?;
|
||||
doc.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -159,7 +159,7 @@ pub async fn check(pubkey: &PublicKey, name: &str, domain: &str) -> Nip05Check {
|
||||
domain,
|
||||
urlencode(name)
|
||||
);
|
||||
let Some(body) = tor::http_request("GET", url, None, vec![]).await else {
|
||||
let Some(body) = nym::http_request("GET", url, None, vec![]).await else {
|
||||
return Nip05Check::Unreachable;
|
||||
};
|
||||
check_body(&body, pubkey, name)
|
||||
@@ -218,7 +218,7 @@ pub async fn check_availability(server: &str, name: &str) -> Availability {
|
||||
server.trim_end_matches('/'),
|
||||
urlencode(name)
|
||||
);
|
||||
let body = match tor::http_request("GET", url, None, vec![]).await {
|
||||
let body = match nym::http_request("GET", url, None, vec![]).await {
|
||||
Some(b) => b,
|
||||
None => return Availability::Unknown,
|
||||
};
|
||||
@@ -284,7 +284,7 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult {
|
||||
("Authorization".to_string(), auth),
|
||||
("Content-Type".to_string(), "application/json".to_string()),
|
||||
];
|
||||
let Some(resp) = tor::http_request("POST", url, Some(body), headers).await else {
|
||||
let Some(resp) = nym::http_request("POST", url, Some(body), headers).await else {
|
||||
return RegisterResult::Network;
|
||||
};
|
||||
let Ok(doc) = serde_json::from_str::<Value>(&resp) else {
|
||||
@@ -313,7 +313,7 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str
|
||||
return Err("couldn't sign the request".to_string());
|
||||
};
|
||||
let headers = vec![("Authorization".to_string(), auth)];
|
||||
match tor::http_request("DELETE", url, None, headers).await {
|
||||
match nym::http_request("DELETE", url, None, headers).await {
|
||||
Some(resp) if resp.contains("\"released\":true") => Ok(()),
|
||||
Some(resp) => Err(serde_json::from_str::<serde_json::Value>(&resp)
|
||||
.ok()
|
||||
@@ -323,12 +323,63 @@ 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>> {
|
||||
let server = server.trim_end_matches('/');
|
||||
let url = format!("{}/api/v1/profile/{}", server, urlencode(name));
|
||||
let (code, raw) = tor::http_request_bytes("GET", url, None, vec![]).await?;
|
||||
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
|
||||
if code == 404 {
|
||||
return Some(None);
|
||||
}
|
||||
@@ -347,7 +398,7 @@ pub async fn fetch_avatar(server: &str, hash: &str) -> Option<Vec<u8>> {
|
||||
}
|
||||
let server = server.trim_end_matches('/');
|
||||
let url = format!("{}/api/v1/avatar/{}.png", server, hash);
|
||||
let (code, raw) = tor::http_request_bytes("GET", url, None, vec![]).await?;
|
||||
let (code, raw) = nym::http_request_bytes("GET", url, None, vec![]).await?;
|
||||
if code != 200 || raw.len() > 1_048_576 || !raw.starts_with(&[0x89, b'P', b'N', b'G']) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,385 +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.
|
||||
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,538 +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.
|
||||
|
||||
//! 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-04",
|
||||
"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. Every relay is reached over a Tor exit to its clearnet host, so the wallet's IP stays hidden behind Tor.",
|
||||
"min_message_length": 131072,
|
||||
"relays": [
|
||||
{ "url": "wss://relay.floonet.dev", "roles": ["dm", "discovery"], "vetted": "2026-07-04" },
|
||||
{ "url": "wss://relay.0xchat.com", "roles": ["dm", "discovery"], "vetted": "2026-07-04" },
|
||||
{ "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-04" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// 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))
|
||||
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
|
||||
}
|
||||
|
||||
/// Refresh the disk cache from the gist — over the Nym mixnet, like all other
|
||||
/// HTTP — when it is absent or older than 7 days. At most one attempt per app
|
||||
/// run; call only once the Nym tunnel is up.
|
||||
pub async fn refresh_if_stale() {
|
||||
static TRIED: AtomicBool = AtomicBool::new(false);
|
||||
if TRIED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let path = cache_path();
|
||||
let fresh = std::fs::metadata(&path)
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.map(|age| age.as_secs() < CACHE_MAX_AGE_SECS)
|
||||
.unwrap_or(false);
|
||||
if fresh {
|
||||
return;
|
||||
}
|
||||
let Some(raw) = crate::tor::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::tor::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);
|
||||
// Three Tor-friendly relays matching the live gist; no relay pins an onion
|
||||
// any more (the onion money path was dropped in build134 — every relay is
|
||||
// reached over a Tor exit to its clearnet host).
|
||||
assert_eq!(pool.relays.len(), 3);
|
||||
let dm = pool.dm_relays();
|
||||
assert_eq!(dm.len(), 3);
|
||||
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 and relay.0xchat.com carry the discovery role too.
|
||||
assert_eq!(disc.len(), 2);
|
||||
assert!(disc.contains(&"wss://relay.floonet.dev".to_string()));
|
||||
assert!(disc.contains(&"wss://relay.0xchat.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_field_is_optional_and_looked_up_by_url() {
|
||||
// The pinned pool no longer carries any co-located Nym exit — every relay
|
||||
// is reached over the Tor exit now — so has_exit() is false for it. The
|
||||
// exit_for / exit_for_host LOOKUP logic below still works for a pool that
|
||||
// DOES advertise one, and the (dormant) src/nym transport still reads it.
|
||||
let pinned = RelayPool::parse(PINNED_POOL).unwrap();
|
||||
assert!(!pinned.has_exit());
|
||||
assert!(pinned.exit_for("wss://relay.floonet.dev").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,20 +14,11 @@
|
||||
|
||||
//! Default relay set and relay list helpers.
|
||||
|
||||
/// Default DM relays: the Floonet relay (the pinned shared floor) plus
|
||||
/// Tor-reachable public relays for redundancy.
|
||||
///
|
||||
/// TRANSPORT CONSTRAINT: Goblin dials every relay over Tor, so the defaults MUST
|
||||
/// be relays that accept Tor-exit connections. `relay.damus.io` and `nos.lol`
|
||||
/// throttle/block Tor exits — a wallet left on the raw defaults (e.g. when pool
|
||||
/// selection hasn't run or found nothing) then had NO working fallback whenever
|
||||
/// the Floonet onion flapped, so its payments stopped flowing. `relay.0xchat.com`
|
||||
/// and `offchain.pub` are Tor-friendly (and are also probe-vetted pool `dm`
|
||||
/// candidates), giving a real fallback that survives an onion drop.
|
||||
/// Default DM relays: the Goblin relay plus large public relays for redundancy.
|
||||
pub const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.floonet.dev",
|
||||
"wss://relay.0xchat.com",
|
||||
"wss://offchain.pub",
|
||||
"wss://relay.goblin.st",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
/// Default NIP-05 identity server.
|
||||
|
||||
@@ -29,10 +29,6 @@ use crate::nostr::types::*;
|
||||
/// Keys are processed-event markers older than this get pruned (30 days).
|
||||
const PROCESSED_TTL_SECS: i64 = 30 * 86_400;
|
||||
|
||||
/// Cap on stored news posts (newest kept, older pruned) — the panel only ever
|
||||
/// shows the latest, so this is just a small archive bound.
|
||||
const NEWS_CAP: usize = 8;
|
||||
|
||||
/// Nostr metadata archive for a wallet.
|
||||
pub struct NostrStore {
|
||||
env: Arc<RwLock<Rkv<SafeModeEnvironment>>>,
|
||||
@@ -46,8 +42,6 @@ pub struct NostrStore {
|
||||
processed: SingleStore<SafeModeDatabase>,
|
||||
/// Service settings (last connected time etc).
|
||||
settings: SingleStore<SafeModeDatabase>,
|
||||
/// Cached news posts by `d` tag.
|
||||
news: SingleStore<SafeModeDatabase>,
|
||||
}
|
||||
|
||||
impl NostrStore {
|
||||
@@ -81,7 +75,6 @@ impl NostrStore {
|
||||
let settings = k
|
||||
.open_single("nostr_settings", StoreOptions::create())
|
||||
.unwrap();
|
||||
let news = k.open_single("nostr_news", StoreOptions::create()).unwrap();
|
||||
Self {
|
||||
env,
|
||||
tx_meta,
|
||||
@@ -89,7 +82,6 @@ impl NostrStore {
|
||||
requests,
|
||||
processed,
|
||||
settings,
|
||||
news,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +170,10 @@ 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)
|
||||
}
|
||||
@@ -296,28 +292,6 @@ impl NostrStore {
|
||||
let _ = writer.commit();
|
||||
}
|
||||
|
||||
// ── news ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn all_news(&self) -> Vec<NewsItem> {
|
||||
self.all_json(&self.news)
|
||||
}
|
||||
|
||||
/// The latest news post overall (newest `created_at`).
|
||||
pub fn latest_news(&self) -> Option<NewsItem> {
|
||||
self.all_news().into_iter().max_by_key(|n| n.created_at)
|
||||
}
|
||||
|
||||
/// Store a news post: newest-`created_at`-per-`d` wins, capped to the newest
|
||||
/// `NEWS_CAP` entries (older pruned). Keyed by `d`, so the store holds one
|
||||
/// row per addressable post.
|
||||
pub fn save_news(&self, item: NewsItem) {
|
||||
let merged = reconcile_news(self.all_news(), item, NEWS_CAP);
|
||||
self.clear(&self.news);
|
||||
for n in &merged {
|
||||
self.put_json(&self.news, &n.d, n);
|
||||
}
|
||||
}
|
||||
|
||||
// ── archive control (user-facing) ───────────────────────────────────────
|
||||
|
||||
/// Export the whole archive as a JSON document.
|
||||
@@ -338,59 +312,11 @@ impl NostrStore {
|
||||
self.clear(&self.requests);
|
||||
self.clear(&self.processed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge an incoming news post into the stored set: newest `created_at` wins
|
||||
/// per `d`, then keep only the newest `cap` overall. Pure so it's unit-testable
|
||||
/// without the rkv env.
|
||||
fn reconcile_news(mut all: Vec<NewsItem>, incoming: NewsItem, cap: usize) -> Vec<NewsItem> {
|
||||
if let Some(existing) = all.iter_mut().find(|n| n.d == incoming.d) {
|
||||
if incoming.created_at >= existing.created_at {
|
||||
*existing = incoming;
|
||||
}
|
||||
} else {
|
||||
all.push(incoming);
|
||||
}
|
||||
all.sort_by_key(|n| std::cmp::Reverse(n.created_at));
|
||||
all.truncate(cap);
|
||||
all
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(d: &str, created_at: i64) -> NewsItem {
|
||||
NewsItem {
|
||||
d: d.to_string(),
|
||||
created_at,
|
||||
title: format!("t{created_at}"),
|
||||
summary: String::new(),
|
||||
lang: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_per_d_wins_and_cap_prunes() {
|
||||
// Same d, newer created_at replaces (and carries the newer title).
|
||||
let start = vec![item("a", 100)];
|
||||
let merged = reconcile_news(start, item("a", 200), 8);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].created_at, 200);
|
||||
assert_eq!(merged[0].title, "t200");
|
||||
|
||||
// Same d, OLDER created_at is ignored.
|
||||
let merged = reconcile_news(merged, item("a", 150), 8);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].created_at, 200);
|
||||
|
||||
// Distinct d accumulate, newest first, capped to `cap`.
|
||||
let mut all = vec![];
|
||||
for i in 0..10 {
|
||||
all = reconcile_news(all, item(&format!("d{i}"), i as i64), 3);
|
||||
}
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].created_at, 9);
|
||||
assert_eq!(all[2].created_at, 7);
|
||||
/// Wipe everything including contacts.
|
||||
pub fn wipe_all(&self) {
|
||||
self.wipe_archive();
|
||||
self.clear(&self.contacts);
|
||||
self.clear(&self.settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +97,6 @@ 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.
|
||||
@@ -146,27 +141,6 @@ pub struct PaymentRequest {
|
||||
pub status: RequestStatus,
|
||||
}
|
||||
|
||||
/// A cached news post (kind 30023 long-form) from the Goblin news key, shown
|
||||
/// in the Home news panel. Only the fields the panel needs are persisted.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NewsItem {
|
||||
/// The addressable `d` tag (replaceable-event identifier); dedupe key.
|
||||
pub d: String,
|
||||
/// Event `created_at` (seconds); newest per `d` wins, newest overall shows.
|
||||
pub created_at: i64,
|
||||
/// The post `title` tag. May carry a trailing `[xx]` language marker, which
|
||||
/// the Home panel strips for display (see `data::news_display_title`).
|
||||
pub title: String,
|
||||
/// Plain-text summary (the `summary` tag, or a stripped content fallback).
|
||||
pub summary: String,
|
||||
/// Article language as a lower-case ISO 639-1 code, taken from an event
|
||||
/// language tag (`l` / `lang`) when present. `None` falls back to the
|
||||
/// title-suffix marker, then to English. `#[serde(default)]` so posts cached
|
||||
/// before this field existed still deserialize.
|
||||
#[serde(default)]
|
||||
pub lang: Option<String>,
|
||||
}
|
||||
|
||||
/// Current unix time in seconds.
|
||||
pub fn unix_time() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
@@ -1,296 +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.
|
||||
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,662 +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.
|
||||
|
||||
//! 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 for the PATIENT probe of an ESTABLISHED tunnel
|
||||
/// (watchdog keepalive + condemnation). A mixnet TCP handshake is a few seconds,
|
||||
/// and an exit already in service must NEVER be thrown away over a momentary load
|
||||
/// spike, so this stays deliberately generous at 8s — the pre-existing budget.
|
||||
/// (The just-built-tunnel GATE uses the tighter [`FRESH_PROBE_TIMEOUT`]; the two
|
||||
/// budgets are asymmetric on purpose — see [`probe_fresh`].)
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
/// Probe rounds before an ESTABLISHED 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;
|
||||
|
||||
/// Per-target connect wait for the FAST GATE of a FRESH, just-built tunnel (before
|
||||
/// it is published). Tighter than the established [`PROBE_TIMEOUT`] because a
|
||||
/// healthy fresh probe connects FAST: across 15 cold-start trials the SUCCESSFUL
|
||||
/// exit probe completed in 465–1197ms (median 774ms), so 5s is >4x the measured
|
||||
/// worst case — ample headroom to never false-condemn a slow-but-healthy fresh
|
||||
/// exit (the build130 single-shot regression we must not reintroduce). The point
|
||||
/// of the asymmetry: a genuinely DEAD fresh exit (accepts the IPR handshake but
|
||||
/// delivers nothing) is now condemned in ~10s instead of the ~32s the doubled
|
||||
/// patient probe cost on this path, which dominated the cold-start latency tail.
|
||||
const FRESH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// Probe rounds for the fresh-tunnel gate. SAME 2-round retry as the established
|
||||
/// path: a single lost mixnet datagram mid-handshake still gets a second chance
|
||||
/// before the tunnel is condemned — the transient-loss protection the original
|
||||
/// trigger-happy single-shot probe lacked. Worst-case fresh-gate budget is
|
||||
/// therefore FRESH_PROBE_ROUNDS × FRESH_PROBE_TIMEOUT = 10s (vs the old ~32s).
|
||||
const FRESH_PROBE_ROUNDS: usize = 2;
|
||||
|
||||
/// PATIENT end-to-end liveness probe of an ESTABLISHED tunnel, on the generous
|
||||
/// [`PROBE_TIMEOUT`]/[`PROBE_ROUNDS`] budget (worst case ~16s). Used by the
|
||||
/// watchdog keepalive and the condemnation exit-DNS check — an exit already in
|
||||
/// service must never be false-condemned over a momentary hiccup. The FRESH,
|
||||
/// just-built-tunnel gate uses [`probe_fresh`] instead (a tighter budget). See
|
||||
/// [`probe_with_budget`] for the shared mechanics.
|
||||
pub async fn probe(tunnel: &Tunnel) -> bool {
|
||||
probe_with_budget(tunnel, PROBE_TIMEOUT, PROBE_ROUNDS).await
|
||||
}
|
||||
|
||||
/// FAST end-to-end liveness GATE for a FRESH, just-built tunnel, run BEFORE it is
|
||||
/// published, on the tighter [`FRESH_PROBE_TIMEOUT`]/[`FRESH_PROBE_ROUNDS`] budget
|
||||
/// (worst case ~10s vs the ~32s the doubled patient probe cost on this path). A
|
||||
/// fresh exit that accepts the IPR handshake yet delivers nothing (a DEAD EXIT) is
|
||||
/// condemned quickly instead of dominating the cold-start tail — WITHOUT
|
||||
/// reintroducing the false-condemn of a healthy exit (build130): the 5s per-target
|
||||
/// timeout is >4x the measured worst-case healthy fresh probe (1197ms) and the
|
||||
/// 2-round retry still absorbs a single lost datagram. See [`probe_with_budget`].
|
||||
pub async fn probe_fresh(tunnel: &Tunnel) -> bool {
|
||||
probe_with_budget(tunnel, FRESH_PROBE_TIMEOUT, FRESH_PROBE_ROUNDS).await
|
||||
}
|
||||
|
||||
/// Shared raced-targets liveness probe on an explicit per-target `timeout` /
|
||||
/// `rounds` budget: 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 multiple
|
||||
/// 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. Callers pick the budget: [`probe`] (patient, established tunnels)
|
||||
/// vs [`probe_fresh`] (fast, fresh-tunnel gate) — the racing + multi-round
|
||||
/// structure is identical, only the timeout/rounds differ.
|
||||
async fn probe_with_budget(tunnel: &Tunnel, timeout: Duration, rounds: usize) -> bool {
|
||||
for round in 0..rounds {
|
||||
let mut inflight = FuturesUnordered::new();
|
||||
for addr in PROBE_ADDRS {
|
||||
inflight.push(async move {
|
||||
matches!(
|
||||
tokio::time::timeout(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 {}/{rounds})",
|
||||
round + 1
|
||||
);
|
||||
}
|
||||
debug!("probe: tunnel failed liveness — reached no stable target in {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,98 +13,65 @@
|
||||
// limitations under the License.
|
||||
|
||||
//! Nym mixnet transport. Everything Goblin sends — nostr relay traffic and
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
pub mod dns;
|
||||
pub mod nymproc;
|
||||
pub mod streamexit;
|
||||
pub mod sidecar;
|
||||
pub mod transport;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
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 sidecar::{is_ready, warm_up};
|
||||
pub use transport::NymWebSocketTransport;
|
||||
|
||||
/// 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);
|
||||
/// 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 to wait for the shared tunnel before giving up on a request.
|
||||
const TUNNEL_WAIT: Duration = Duration::from_secs(30);
|
||||
/// `socks5h://127.0.0.1:1080` proxy URL for reqwest.
|
||||
pub fn proxy_url() -> String {
|
||||
format!("socks5h://{SOCKS5_HOST}:{SOCKS5_PORT}")
|
||||
}
|
||||
|
||||
/// Redirect hops to follow before giving up (matches the old client, which
|
||||
/// followed redirects transparently).
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
/// `127.0.0.1:1080` for the raw SOCKS5 TCP dialer (relay websockets).
|
||||
pub fn socks5_addr() -> String {
|
||||
format!("{SOCKS5_HOST}:{SOCKS5_PORT}")
|
||||
}
|
||||
|
||||
/// 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)`.
|
||||
/// An HTTP request routed over the Nym mixnet via the in-process SOCKS5 client.
|
||||
/// 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 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)),
|
||||
}
|
||||
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);
|
||||
}
|
||||
warn!("nym http: too many redirects for {}", redacted(&url));
|
||||
None
|
||||
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))
|
||||
}
|
||||
|
||||
/// String-bodied convenience wrapper around [`http_request_bytes`].
|
||||
@@ -118,294 +85,3 @@ 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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,465 +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.
|
||||
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// INCIDENT REPRO / VERIFICATION harness: publish a ~2.5KB and a ~66KB
|
||||
/// kind-1059 EVENT over a SCRATCH scoped exit (address from env
|
||||
/// `GOBLIN_SCRATCH_EXIT`) to relay.floonet.dev, plus a clearnet control, and
|
||||
/// report which land (clearnet oracle = ground truth, waits past EOSE so a
|
||||
/// LATE arrival is still caught). Proves whether the exit pump forwards
|
||||
/// multi-fragment writes. Run:
|
||||
/// GOBLIN_SCRATCH_EXIT=<addr> cargo test --lib \
|
||||
/// nym::streamexit::tests::scratch_exit_publish_bytes -- --ignored --nocapture
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore]
|
||||
async fn scratch_exit_publish_bytes() {
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use nostr_sdk::JsonUtil;
|
||||
use nostr_sdk::prelude::*;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
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)
|
||||
.try_init();
|
||||
|
||||
let exit = std::env::var("GOBLIN_SCRATCH_EXIT")
|
||||
.expect("set GOBLIN_SCRATCH_EXIT to the scratch exit's nym address");
|
||||
let relay_url = "wss://relay.floonet.dev";
|
||||
|
||||
let keys = Keys::generate();
|
||||
let mk = |n: usize| -> Event {
|
||||
let nonce = format!("{:016x}", rand::random::<u64>());
|
||||
EventBuilder::new(Kind::GiftWrap, format!("{nonce}{}", "x".repeat(n)))
|
||||
.tag(Tag::public_key(keys.public_key()))
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign event")
|
||||
};
|
||||
let small = mk(2_000);
|
||||
let big = mk(64_000);
|
||||
let clear = mk(2_000);
|
||||
println!(
|
||||
"[repro] small id={} wire={}B | big id={} wire={}B | clear id={} wire={}B",
|
||||
small.id.to_hex(),
|
||||
small.as_json().len(),
|
||||
big.id.to_hex(),
|
||||
big.as_json().len(),
|
||||
clear.id.to_hex(),
|
||||
clear.as_json().len()
|
||||
);
|
||||
|
||||
// Clearnet control FIRST (proves the events + relay are fine end to end).
|
||||
let clear_ok = clearnet_publish(relay_url, &clear).await;
|
||||
println!("[repro] clearnet publish OK-frame for clear = {clear_ok}");
|
||||
|
||||
// Open the SCRATCH scoped exit and run the SAME TLS+ws the wallet uses.
|
||||
let mut stream = None;
|
||||
for attempt in 1..=6 {
|
||||
match open_stream(&exit, Duration::from_secs(90)).await {
|
||||
Ok(s) => {
|
||||
println!("[repro] open_stream OK on attempt {attempt}");
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(e) => println!("[repro] open_stream attempt {attempt} failed: {e}"),
|
||||
}
|
||||
}
|
||||
let stream = stream.expect("scratch exit stream opened within retries");
|
||||
let (mut ws, _resp) = tokio::time::timeout(
|
||||
Duration::from_secs(45),
|
||||
tokio_tungstenite::client_async_tls(relay_url, stream),
|
||||
)
|
||||
.await
|
||||
.expect("TLS+ws handshake timed out (dead exit?)")
|
||||
.expect("TLS+ws handshake through scratch exit failed");
|
||||
println!("[repro] TLS+ws through scratch exit OK");
|
||||
|
||||
for (label, ev) in [("small", &small), ("big", &big)] {
|
||||
let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
|
||||
println!("[repro] EXIT sending {label} ({} B ws frame)", frame.len());
|
||||
ws.send(Message::Text(frame.into()))
|
||||
.await
|
||||
.expect("ws send over exit");
|
||||
}
|
||||
|
||||
// Keep draining the exit ws in the background so the relay->client OK path
|
||||
// keeps moving while we measure landing time.
|
||||
let drainer = tokio::spawn(async move {
|
||||
let end = tokio::time::Instant::now() + Duration::from_secs(300);
|
||||
while tokio::time::Instant::now() < end {
|
||||
match tokio::time::timeout(Duration::from_secs(5), ws.next()).await {
|
||||
Ok(Some(Ok(Message::Text(t)))) => {
|
||||
println!("[repro] EXIT relay -> {}", t.as_str())
|
||||
}
|
||||
Ok(Some(Ok(_))) => {}
|
||||
Ok(Some(Err(_))) | Ok(None) => break,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Measure delivery LATENCY via the clearnet oracle (waits past EOSE).
|
||||
let t0 = tokio::time::Instant::now();
|
||||
let probe = Duration::from_secs(180);
|
||||
let small_id = small.id.to_hex();
|
||||
let big_id = big.id.to_hex();
|
||||
let small_fut = async {
|
||||
let ok = oracle_landed(relay_url, &small_id, probe).await;
|
||||
println!(
|
||||
"[repro] ===== EXIT small landed={ok} after {}s =====",
|
||||
t0.elapsed().as_secs()
|
||||
);
|
||||
ok
|
||||
};
|
||||
let big_fut = async {
|
||||
let ok = oracle_landed(relay_url, &big_id, probe).await;
|
||||
println!(
|
||||
"[repro] ===== EXIT big landed={ok} after {}s =====",
|
||||
t0.elapsed().as_secs()
|
||||
);
|
||||
ok
|
||||
};
|
||||
let (_s, _b) = tokio::join!(small_fut, big_fut);
|
||||
|
||||
let clear_landed =
|
||||
oracle_landed(relay_url, &clear.id.to_hex(), Duration::from_secs(20)).await;
|
||||
println!("[repro] ===== CLEARNET control clear landed={clear_landed} =====");
|
||||
drainer.abort();
|
||||
}
|
||||
|
||||
/// Clearnet publish `ev`; returns true on relay `OK ... true`. Positive control.
|
||||
#[cfg(test)]
|
||||
async fn clearnet_publish(url: &str, ev: &nostr_sdk::Event) -> bool {
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use nostr_sdk::JsonUtil;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
println!("[oracle] clearnet connect err: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let frame = format!(r#"["EVENT",{}]"#, ev.as_json());
|
||||
if ws.send(Message::Text(frame.into())).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
let id = ev.id.to_hex();
|
||||
for _ in 0..20 {
|
||||
match tokio::time::timeout(Duration::from_secs(10), ws.next()).await {
|
||||
Ok(Some(Ok(Message::Text(t)))) => {
|
||||
let t = t.as_str();
|
||||
if t.starts_with("[\"OK\"") {
|
||||
println!("[oracle] clearnet OK-frame: {t}");
|
||||
return t.contains(&id) && t.contains("true");
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Clearnet oracle: REQ for `id_hex`; true iff the relay returns the stored
|
||||
/// EVENT within `timeout`. Ignores EOSE and keeps the sub OPEN so a LATE
|
||||
/// arrival (the slow-exit case) is caught the instant the relay stores it.
|
||||
#[cfg(test)]
|
||||
async fn oracle_landed(url: &str, id_hex: &str, timeout: Duration) -> bool {
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
let (mut ws, _) = match tokio_tungstenite::connect_async(url).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
println!("[oracle] connect err: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let req = format!(r#"["REQ","oracle",{{"ids":["{id_hex}"]}}]"#);
|
||||
if ws.send(Message::Text(req.into())).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return false;
|
||||
}
|
||||
match tokio::time::timeout(remaining, ws.next()).await {
|
||||
Ok(Some(Ok(Message::Text(t)))) => {
|
||||
if t.as_str().starts_with("[\"EVENT\"") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Ok(Some(Ok(_))) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,14 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
use std::fmt;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
@@ -34,15 +30,26 @@ 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;
|
||||
|
||||
/// A backend transport error (failures outside the websocket layer) carrying
|
||||
/// `msg` as its display text.
|
||||
fn terr(msg: impl Into<String>) -> TransportError {
|
||||
TransportError::backend(std::io::Error::other(msg.into()))
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr websocket transport over the in-process Nym mixnet tunnel.
|
||||
impl std::error::Error for NymTransportError {}
|
||||
|
||||
fn terr(msg: impl Into<String>) -> TransportError {
|
||||
TransportError::backend(NymTransportError(msg.into()))
|
||||
}
|
||||
|
||||
/// Nostr websocket transport over the local Nym SOCKS5 proxy.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NymWebSocketTransport;
|
||||
|
||||
@@ -67,60 +74,17 @@ impl WebSocketTransport for NymWebSocketTransport {
|
||||
_ => 443,
|
||||
});
|
||||
|
||||
// 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();
|
||||
// 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}")))?;
|
||||
|
||||
// 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),
|
||||
@@ -128,61 +92,22 @@ 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()
|
||||
);
|
||||
|
||||
Ok(split_ws(ws))
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,23 +93,6 @@ 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.
|
||||
@@ -221,11 +204,6 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -509,55 +487,6 @@ 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();
|
||||
|
||||
@@ -1,282 +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.
|
||||
|
||||
//! Embedded Tor (arti) client — the DIALING half only. Copied from our sister
|
||||
//! wallet GRIM's proven, shipping engine (`grim/src/tor/`), stripped to what
|
||||
//! Goblin needs: connect OUT to the relay's `.onion` (and to clearnet HTTP hosts
|
||||
//! through a Tor exit). Goblin never HOSTS an onion service (GRIM's receiving
|
||||
//! half), so the onion-service hosting, keystore-seeding and reverse-proxy code
|
||||
//! is dropped.
|
||||
//!
|
||||
//! Two technical choices are inherited VERBATIM from GRIM because it already paid
|
||||
//! for them: **arti 0.43** across the arti family, and the **native-tls Tor
|
||||
//! runtime** ([`TokioNativeTlsRuntime`]) — deliberately NOT rustls, to sidestep
|
||||
//! the rustls/ring crypto-provider conflict Goblin fought during the Nym era.
|
||||
//!
|
||||
//! The arti client runs on its OWN dedicated tokio runtime (created once, kept
|
||||
//! alive for the process). `TorClient::connect()` returns a [`DataStream`] that
|
||||
//! is `AsyncRead + AsyncWrite`; that byte source is handed to the websocket layer
|
||||
//! ([`super::transport`]) and the HTTP layer ([`super`]), each driven by their
|
||||
//! own caller runtime — a `DataStream` is runtime-agnostic once the client's
|
||||
//! circuit tasks are running on the arti runtime.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{fs, thread};
|
||||
|
||||
use arti_client::config::TorClientConfigBuilder;
|
||||
use arti_client::{DataStream, TorClient, TorClientConfig};
|
||||
use lazy_static::lazy_static;
|
||||
use log::{error, info, warn};
|
||||
use parking_lot::RwLock;
|
||||
use tor_rtcompat::SpawnExt;
|
||||
use tor_rtcompat::tokio::TokioNativeTlsRuntime;
|
||||
|
||||
/// The Tor runtime type — native-tls, matching GRIM (never rustls).
|
||||
type Runtime = TokioNativeTlsRuntime;
|
||||
/// The concrete arti client type.
|
||||
pub type Client = TorClient<Runtime>;
|
||||
|
||||
/// How long a single cold Tor bootstrap may take before we declare it failed and
|
||||
/// let a later `warm_up()`/`wait_ready()` retry. A cold bootstrap with no cached
|
||||
/// consensus can take tens of seconds; a warm one (cached dir) is a few.
|
||||
const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
|
||||
lazy_static! {
|
||||
/// Process-lifetime Tor state. The dedicated arti runtime lives here so its
|
||||
/// worker threads (which drive every circuit) persist for the whole process.
|
||||
static ref TOR: Tor = Tor::new();
|
||||
}
|
||||
|
||||
struct Tor {
|
||||
/// The dedicated arti runtime (native-tls). All arti tasks run on this.
|
||||
runtime: Runtime,
|
||||
/// The bootstrapped client, once it is up.
|
||||
client: RwLock<Option<Arc<Client>>>,
|
||||
/// Guards the background bootstrap so `warm_up()` is idempotent.
|
||||
launching: AtomicBool,
|
||||
}
|
||||
|
||||
impl Tor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
runtime: TokioNativeTlsRuntime::create().expect("create tor runtime"),
|
||||
client: RwLock::new(None),
|
||||
launching: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Readiness signals (re-pointed from `nym::nymproc`, same semantics) -------
|
||||
|
||||
/// Set once arti has bootstrapped (mirrors `TUNNEL_GEN != 0`); cheap to poll.
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Monotonic "transport generation". With Tor there is no exit-reselect churn —
|
||||
/// arti rebuilds circuits transparently under the `DataStream` — so this simply
|
||||
/// becomes 1 once bootstrapped and stays there. The relay-gated readiness logic
|
||||
/// (copied from nym) still works: a relay-liveness report tagged with an older
|
||||
/// generation can never mark a newer transport ready.
|
||||
static TUNNEL_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The generation on which the nostr client currently has a relay connected AND
|
||||
/// subscribed, or 0 for "no relay live". A single atomic so [`transport_ready`]
|
||||
/// can compare it to `TUNNEL_GEN` in one shot.
|
||||
static RELAY_LIVE_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Whether a nostr consumer currently wants relays over Tor. Kept for API parity
|
||||
/// with the nym transport (the UI/service bracket it); Tor needs no exit-health
|
||||
/// governance, so it is otherwise inert.
|
||||
static RELAY_CONSUMER: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Pre-warm the embedded Tor client in the background so relays / NIP-05 / price
|
||||
/// are ready by first use. Idempotent — a call while a bootstrap is in flight, or
|
||||
/// once one has succeeded, is a no-op.
|
||||
pub fn warm_up() {
|
||||
if TOR.client.read().is_some() {
|
||||
return;
|
||||
}
|
||||
if TOR.launching.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
thread::spawn(|| {
|
||||
bootstrap_once();
|
||||
TOR.launching.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the embedded Tor client has bootstrapped. Cheap and cached — safe to
|
||||
/// poll from the UI each frame. Distinct from a relay being connected (see
|
||||
/// [`transport_ready`]): Tor can be up while no relay yet rides it.
|
||||
pub fn is_ready() -> bool {
|
||||
READY.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The current transport generation. The nostr client reads this right before it
|
||||
/// dials so it can tag its relay-liveness reports.
|
||||
pub fn tunnel_generation() -> u64 {
|
||||
TUNNEL_GEN.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// Relay-gated readiness — the AUTHORITATIVE "ready to receive/send over Tor"
|
||||
/// signal, distinct from the bootstrap-only [`is_ready`]. True only when Tor is
|
||||
/// bootstrapped AND a required relay is connected+subscribed on the CURRENT
|
||||
/// generation, so the UI never shows a false "Connected".
|
||||
pub fn transport_ready() -> bool {
|
||||
let generation = TUNNEL_GEN.load(Ordering::Acquire);
|
||||
generation != 0 && RELAY_LIVE_GEN.load(Ordering::Acquire) == generation && is_ready()
|
||||
}
|
||||
|
||||
/// Client → transport report: a relay is connected+subscribed on `generation`.
|
||||
/// `fetch_max` so a late report for an older generation can never move liveness
|
||||
/// backwards over a newer one.
|
||||
pub fn report_relay_live(generation: u64) {
|
||||
RELAY_LIVE_GEN.fetch_max(generation, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// Client → transport report: no relay is currently live on `generation`. Clears
|
||||
/// liveness only when `generation` is still the live one.
|
||||
pub fn report_relay_down(generation: u64) {
|
||||
let _ = RELAY_LIVE_GEN.compare_exchange(generation, 0, Ordering::AcqRel, Ordering::Acquire);
|
||||
}
|
||||
|
||||
/// Bracket a nostr consumer's lifetime (API parity with the nym transport). Inert
|
||||
/// for Tor — arti manages its own circuit health — but kept so the service's
|
||||
/// existing calls compile unchanged.
|
||||
pub fn set_relay_consumer(active: bool) {
|
||||
RELAY_CONSUMER.store(active, Ordering::Release);
|
||||
}
|
||||
|
||||
/// External condemnation request (API parity with the nym transport). Under Tor
|
||||
/// there is no exit to abandon — arti rebuilds circuits itself — so this is a
|
||||
/// logged no-op rather than triggering a reselect.
|
||||
pub fn condemn_exit(generation: u64) {
|
||||
if generation != 0 {
|
||||
warn!("tor: condemn_exit(gen {generation}) is a no-op (arti rebuilds circuits itself)");
|
||||
}
|
||||
}
|
||||
|
||||
/// The bootstrapped client, if it is up. Cloning the `Arc` is cheap.
|
||||
pub fn client() -> Option<Arc<Client>> {
|
||||
TOR.client.read().clone()
|
||||
}
|
||||
|
||||
/// Wait until the embedded Tor client has bootstrapped, starting it if nothing
|
||||
/// has yet (lazy init on first use). Returns `false` once `timeout` lapses.
|
||||
pub async fn wait_ready(timeout: Duration) -> bool {
|
||||
warm_up();
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if is_ready() {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a Tor stream to `host:port`. `host` may be a `.onion` address (dialed as
|
||||
/// a real onion connection — no exit node) or a clearnet host (dialed through a
|
||||
/// Tor exit). Returns a [`DataStream`] (`AsyncRead + AsyncWrite`) — the byte
|
||||
/// source the websocket / HTTP layers wrap. The caller is responsible for its own
|
||||
/// connect timeout.
|
||||
pub async fn connect(host: &str, port: u16) -> Result<DataStream, String> {
|
||||
let client = client().ok_or_else(|| "tor client not bootstrapped".to_string())?;
|
||||
client
|
||||
.connect((host, port))
|
||||
.await
|
||||
.map_err(|e| format!("tor connect to {host}:{port} failed: {e}"))
|
||||
}
|
||||
|
||||
/// Build the arti client config: fs-backed state + cache in Goblin's base dir,
|
||||
/// and — crucially — `allow_onion_addrs(true)` so `.onion` targets are dialable
|
||||
/// (this plus the `onion-service-client` cargo feature is what enables onion
|
||||
/// connections). Matches GRIM's `build_config`, minus the bridge plumbing Goblin
|
||||
/// does not use.
|
||||
fn build_config() -> TorClientConfig {
|
||||
let mut builder =
|
||||
TorClientConfigBuilder::from_directories(super::state_path(), super::cache_path());
|
||||
builder.address_filter().allow_onion_addrs(true);
|
||||
builder.build().expect("build tor client config")
|
||||
}
|
||||
|
||||
/// One bootstrap attempt, driven on the arti runtime (GRIM's proven pattern:
|
||||
/// spawn the bootstrap on arti's runtime, poll a flag from this thread). On
|
||||
/// success the client is published and the readiness signals flip.
|
||||
fn bootstrap_once() {
|
||||
// Ensure the state/cache dirs exist (arti creates them, but on a fresh device
|
||||
// the parent must be present first).
|
||||
let _ = fs::create_dir_all(super::state_path());
|
||||
let _ = fs::create_dir_all(super::cache_path());
|
||||
|
||||
let config = build_config();
|
||||
let client = match TorClient::with_runtime(TOR.runtime.clone())
|
||||
.config(config)
|
||||
.create_unbootstrapped()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("tor: could not create client: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let started = Instant::now();
|
||||
let bootstrapping = Arc::new(AtomicBool::new(true));
|
||||
let success = Arc::new(AtomicBool::new(false));
|
||||
let bootstrapping_t = bootstrapping.clone();
|
||||
let success_t = success.clone();
|
||||
let c = client.clone();
|
||||
let spawned = TOR.runtime.spawn(async move {
|
||||
match tokio::time::timeout(BOOTSTRAP_TIMEOUT, c.bootstrap()).await {
|
||||
Ok(Ok(())) => success_t.store(true, Ordering::Relaxed),
|
||||
Ok(Err(e)) => error!("tor: bootstrap error: {e}"),
|
||||
Err(_) => error!(
|
||||
"tor: bootstrap timed out after {}s",
|
||||
BOOTSTRAP_TIMEOUT.as_secs()
|
||||
),
|
||||
}
|
||||
bootstrapping_t.store(false, Ordering::Relaxed);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
error!("tor: could not spawn bootstrap task");
|
||||
return;
|
||||
}
|
||||
// Wait for the bootstrap task to finish.
|
||||
while bootstrapping.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
if !success.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `create_unbootstrapped()` already hands back an `Arc<TorClient>`, so store it
|
||||
// as-is (no extra wrapping).
|
||||
TOR.client.write().replace(client);
|
||||
// A NEW transport is live: publish generation 1 (relay-liveness left over from
|
||||
// a prior generation is instantly stale) and flip the bootstrap-ready flag.
|
||||
TUNNEL_GEN.store(1, Ordering::Release);
|
||||
READY.store(true, Ordering::Release);
|
||||
info!(
|
||||
"tor: bootstrapped and ready in {}ms (gen 1)",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
// Eager price fetch the moment Tor is ready (mirrors what the old mixnet
|
||||
// bootstrap did): prefetch the pairing's rate so the amount preview has a live
|
||||
// value by first use. One-shot — bootstrap_once only reaches here once.
|
||||
std::thread::spawn(crate::http::price::eager_refresh);
|
||||
}
|
||||
@@ -1,261 +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.
|
||||
|
||||
//! Embedded-Tor transport. Everything Goblin sends over the network — nostr relay
|
||||
//! websockets and every HTTP request (NIP-05, price, relay pool, avatars) — rides
|
||||
//! Tor, embedded in-process (arti), copied from our sister wallet GRIM's proven,
|
||||
//! shipping engine. Every relay is reached over a Tor exit to its clearnet host,
|
||||
//! with the usual hostname-validated TLS for `wss://`: the wallet's own IP is
|
||||
//! never exposed, while the relay stays a normal public endpoint. (Earlier builds
|
||||
//! could pin a per-relay `.onion` for a direct onion-circuit money path; that was
|
||||
//! dropped in build134 — onion services flapped — in favour of Tor-exit only.)
|
||||
//!
|
||||
//! This replaces the Nym-mixnet transport (`crate::nym`, left dormant): Tor is
|
||||
//! free, unmetered, has no token or grant to expire, and GRIM has already proven
|
||||
//! the whole embedded path on desktop and Android.
|
||||
//!
|
||||
//! The Grin blockchain node is NOT routed here — it stays on the clear internet
|
||||
//! exactly as before; it never sees who pays whom.
|
||||
|
||||
mod engine;
|
||||
mod transport;
|
||||
|
||||
pub use engine::{
|
||||
Client, client, condemn_exit, connect, is_ready, report_relay_down, report_relay_live,
|
||||
set_relay_consumer, transport_ready, tunnel_generation, wait_ready, warm_up,
|
||||
};
|
||||
pub use transport::TorWebSocketTransport;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use log::{debug, warn};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
use crate::Settings;
|
||||
|
||||
/// How long a single HTTP exchange (one redirect hop) may take end to end.
|
||||
const HTTP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long to wait for the embedded Tor client to bootstrap before giving up on
|
||||
/// a request. A cold Tor bootstrap can take tens of seconds; a warm one is fast.
|
||||
const TUNNEL_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Redirect hops to follow before giving up.
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
|
||||
// --- Tor data directories -----------------------------------------------------
|
||||
|
||||
/// Base Tor data directory (`<base>/tor`).
|
||||
fn base_path() -> PathBuf {
|
||||
Settings::base_path(Some("tor".to_string()))
|
||||
}
|
||||
|
||||
/// Tor state directory (consensus, guards, …). Used by [`engine`].
|
||||
pub(crate) fn state_path() -> String {
|
||||
let mut base = base_path();
|
||||
base.push("state");
|
||||
base.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// Tor cache directory (directory documents). Used by [`engine`].
|
||||
pub(crate) fn cache_path() -> String {
|
||||
let mut base = base_path();
|
||||
base.push("cache");
|
||||
base.to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
// --- HTTP over Tor ------------------------------------------------------------
|
||||
|
||||
/// An HTTP request routed over Tor: dial the host over Tor (an onion via a real
|
||||
/// onion circuit, a clearnet host via a Tor exit — arti resolves the name
|
||||
/// internally, so nothing leaks a clearnet DNS lookup), then rustls (webpki
|
||||
/// roots) for https, then HTTP/1.1. Follows redirects. Returns `(status, body)`.
|
||||
///
|
||||
/// For now clearnet-over-Tor is fine for the small lookups (names at goblin.st,
|
||||
/// relay hints, pool refresh, price, avatars); pinning those behind onions is a
|
||||
/// later pass.
|
||||
pub async fn http_request_bytes(
|
||||
method: &str,
|
||||
url: String,
|
||||
body: Option<Vec<u8>>,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Option<(u16, Vec<u8>)> {
|
||||
if !wait_ready(TUNNEL_WAIT).await {
|
||||
warn!("tor http: client not bootstrapped, dropping request");
|
||||
return None;
|
||||
}
|
||||
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(&method, &url, body.clone(), &headers),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| warn!("tor http: request to {} timed out", redacted(&url)))
|
||||
.ok()??;
|
||||
match location {
|
||||
Some(loc) => {
|
||||
url = url.join(&loc).ok()?;
|
||||
// 303 (and legacy 301/302) turn into a bodiless GET; 307/308 replay.
|
||||
if matches!(status, 301..=303) {
|
||||
method = "GET".to_string();
|
||||
body = None;
|
||||
}
|
||||
debug!(
|
||||
"tor http: following {status} redirect to {}",
|
||||
redacted(&url)
|
||||
);
|
||||
}
|
||||
None => return Some((status, resp_body)),
|
||||
}
|
||||
}
|
||||
warn!("tor http: too many redirects for {}", redacted(&url));
|
||||
None
|
||||
}
|
||||
|
||||
/// String-bodied convenience wrapper around [`http_request_bytes`].
|
||||
pub async fn http_request(
|
||||
method: &str,
|
||||
url: String,
|
||||
body: Option<String>,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Option<String> {
|
||||
http_request_bytes(method, url, body.map(|b| b.into_bytes()), headers)
|
||||
.await
|
||||
.map(|(_, raw)| String::from_utf8_lossy(&raw).to_string())
|
||||
}
|
||||
|
||||
/// Host without path/query, for logs (never log full URLs).
|
||||
fn redacted(url: &url::Url) -> String {
|
||||
url.host_str().unwrap_or("<no-host>").to_string()
|
||||
}
|
||||
|
||||
/// A single HTTP/1.1 exchange over Tor. Returns the status, the collected body
|
||||
/// and, for 3xx responses, the `Location` target.
|
||||
async fn request_once(
|
||||
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 tcp = connect(&host, port)
|
||||
.await
|
||||
.map_err(|e| warn!("tor http: connect to {host} failed: {e}"))
|
||||
.ok()?;
|
||||
let io: Box<dyn Stream> = if https {
|
||||
Box::new(tls_connect(&host, tcp).await?)
|
||||
} else {
|
||||
Box::new(tcp)
|
||||
};
|
||||
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(io))
|
||||
.await
|
||||
.map_err(|e| warn!("tor http: handshake with {host} failed: {e}"))
|
||||
.ok()?;
|
||||
// Drive the connection in the background for this one exchange.
|
||||
tokio::spawn(async move {
|
||||
let _ = conn.await;
|
||||
});
|
||||
|
||||
let m = hyper::Method::from_bytes(method.as_bytes()).ok()?;
|
||||
let path = match url.query() {
|
||||
Some(q) => format!("{}?{q}", url.path()),
|
||||
None => url.path().to_string(),
|
||||
};
|
||||
let host_header = if (https && port == 443) || (!https && port == 80) {
|
||||
host.clone()
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
let mut req = hyper::Request::builder()
|
||||
.method(m)
|
||||
.uri(path)
|
||||
.header(hyper::header::HOST, host_header)
|
||||
.header(hyper::header::USER_AGENT, "goblin-wallet");
|
||||
for (k, v) in headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
let req = req
|
||||
.body(Full::new(Bytes::from(body.unwrap_or_default())))
|
||||
.ok()?;
|
||||
|
||||
let resp = sender
|
||||
.send_request(req)
|
||||
.await
|
||||
.map_err(|e| warn!("tor 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))
|
||||
}
|
||||
|
||||
/// Everything hyper (and the TLS layer) needs from a Tor-carried stream, boxable
|
||||
/// for the plain-http / https split.
|
||||
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 — see lib.rs), reused by every clearnet-over-Tor https handshake.
|
||||
/// Never the platform verifier — it panics on Android outside a full app
|
||||
/// context.
|
||||
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 Tor-carried TCP stream with rustls + webpki roots. The certificate
|
||||
/// is validated against the HOSTNAME, so a hostile Tor exit cannot MITM a
|
||||
/// clearnet https fetch.
|
||||
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!("tor http: tls handshake with {host} failed: {e}"))
|
||||
.ok()
|
||||
}
|
||||
@@ -1,162 +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.
|
||||
|
||||
//! WebSocket transport for the Nostr relay pool routed over embedded Tor. Every
|
||||
//! relay is dialed the same way: reach its clearnet host over a Tor exit and run
|
||||
//! the usual hostname-validated TLS + websocket
|
||||
//! ([`tokio_tungstenite::client_async_tls`]) for `wss://`. The payload and the
|
||||
//! in-flight destination never touch the clear, and the wallet's own IP is never
|
||||
//! exposed. (Earlier builds could dial a relay's pinned `.onion` directly over a
|
||||
//! real onion circuit as a money-path anchor; that was dropped in build134 — the
|
||||
//! onion services flapped — leaving the Tor-exit path as the only one.)
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_wsocket::futures_util::{Sink, SinkExt, StreamExt};
|
||||
use async_wsocket::{ConnectionMode, Message};
|
||||
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_tungstenite::tungstenite::Message as TgMessage;
|
||||
|
||||
/// A backend transport error (failures outside the websocket layer) carrying
|
||||
/// `msg` as its display text.
|
||||
fn terr(msg: impl Into<String>) -> TransportError {
|
||||
TransportError::backend(std::io::Error::other(msg.into()))
|
||||
}
|
||||
|
||||
/// Nostr websocket transport over embedded Tor.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct TorWebSocketTransport;
|
||||
|
||||
impl WebSocketTransport for TorWebSocketTransport {
|
||||
fn support_ping(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn connect<'a>(
|
||||
&'a self,
|
||||
url: &'a Url,
|
||||
_mode: &'a ConnectionMode,
|
||||
timeout: Duration,
|
||||
) -> BoxedFuture<'a, Result<(WebSocketSink, WebSocketStream), TransportError>> {
|
||||
Box::pin(async move {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| terr("relay url has no host"))?
|
||||
.to_string();
|
||||
|
||||
// The embedded Tor client must be bootstrapped before any dial.
|
||||
if !crate::tor::wait_ready(timeout).await {
|
||||
return Err(terr("tor client not bootstrapped"));
|
||||
}
|
||||
|
||||
// Reach the relay's clearnet host over a Tor exit, with the usual
|
||||
// hostname-validated TLS + websocket for wss (SNI = the relay host).
|
||||
// This is the single dial path: the wallet's IP never leaves Tor, and a
|
||||
// send fans out to a recipient's arbitrary public DM relays exactly the
|
||||
// way it reaches the shared floor relay (relay.floonet.dev).
|
||||
let port = url.port().unwrap_or(match url.scheme() {
|
||||
"ws" => 80,
|
||||
_ => 443,
|
||||
});
|
||||
let t = Instant::now();
|
||||
let stream = tokio::time::timeout(timeout, crate::tor::connect(&host, port))
|
||||
.await
|
||||
.map_err(|_| terr("tor connect timeout"))?
|
||||
.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"))?
|
||||
.map_err(|e| terr(format!("websocket handshake failed: {e}")))?;
|
||||
log::info!(
|
||||
"[timing] tor: relay {host} CONNECTED via exit — tls+ws {}ms",
|
||||
t.elapsed().as_millis()
|
||||
);
|
||||
Ok(split_ws(ws))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a websocket into the pool's boxed sink/stream halves, so everything above
|
||||
/// the byte transport is identical regardless of which Tor circuit carried it.
|
||||
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(TorSink(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> {
|
||||
match msg {
|
||||
TgMessage::Text(text) => Some(Message::Text(text.to_string())),
|
||||
TgMessage::Binary(data) => Some(Message::Binary(data.to_vec())),
|
||||
TgMessage::Ping(data) => Some(Message::Ping(data.to_vec())),
|
||||
TgMessage::Pong(data) => Some(Message::Pong(data.to_vec())),
|
||||
TgMessage::Close(_) => Some(Message::Close(None)),
|
||||
TgMessage::Frame(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sink adapter converting pool messages into tungstenite messages.
|
||||
struct TorSink<S>(S);
|
||||
|
||||
impl<S> Sink<Message> for TorSink<S>
|
||||
where
|
||||
S: Sink<TgMessage, Error = tokio_tungstenite::tungstenite::Error> + Send + Unpin,
|
||||
{
|
||||
type Error = TransportError;
|
||||
|
||||
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.0)
|
||||
.poll_ready_unpin(cx)
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
|
||||
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
|
||||
Pin::new(&mut self.0)
|
||||
.start_send_unpin(TgMessage::from(item))
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.0)
|
||||
.poll_flush_unpin(cx)
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
|
||||
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
Pin::new(&mut self.0)
|
||||
.poll_close_unpin(cx)
|
||||
.map_err(TransportError::backend)
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,17 @@ pub struct ExternalConnection {
|
||||
pub available: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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] = [
|
||||
/// 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",
|
||||
"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
|
||||
|
||||
@@ -34,6 +34,3 @@ pub use utils::WalletUtils;
|
||||
|
||||
mod seed;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(all(test, feature = "e2e-internal"))]
|
||||
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, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, mpsc};
|
||||
use std::thread::Thread;
|
||||
@@ -85,14 +85,6 @@ 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.
|
||||
@@ -169,8 +161,6 @@ 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)),
|
||||
@@ -488,27 +478,6 @@ 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()
|
||||
@@ -604,8 +573,13 @@ impl Wallet {
|
||||
backup-password field."
|
||||
.to_string()
|
||||
})?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(
|
||||
&keys,
|
||||
&password,
|
||||
backup.source,
|
||||
backup.derivation_account,
|
||||
)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
ident.nip05 = backup.nip05.clone();
|
||||
ident.anonymous = backup.anonymous;
|
||||
ident.prev_npubs = backup.prev_npubs.clone();
|
||||
@@ -621,8 +595,13 @@ impl Wallet {
|
||||
field"
|
||||
.to_string()
|
||||
})?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
let mut ident = NostrIdentity::from_unlocked_keys(
|
||||
&keys,
|
||||
&password,
|
||||
backup.source,
|
||||
backup.derivation_account,
|
||||
)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
ident.nip05 = backup.nip05.clone();
|
||||
ident.anonymous = backup.anonymous;
|
||||
ident.prev_npubs = backup.prev_npubs.clone();
|
||||
@@ -936,8 +915,6 @@ 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| {
|
||||
@@ -981,8 +958,6 @@ 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
|
||||
@@ -1174,25 +1149,6 @@ 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();
|
||||
@@ -1887,8 +1843,6 @@ 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;
|
||||
@@ -2115,22 +2069,8 @@ fn start_sync(wallet: Wallet) -> Thread {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Sync wallet from node.
|
||||
sync_wallet_data(&wallet, true);
|
||||
}
|
||||
|
||||
// Stop sync if wallet was closed.
|
||||
@@ -2160,57 +2100,6 @@ 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.
|
||||
@@ -2474,7 +2363,6 @@ 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,454 @@
|
||||
// End-to-end Nostr exchange test against the live Goblin relay.
|
||||
//
|
||||
// Proves the NIP-17 payment-message path: gift-wrap send, subscribe, unwrap,
|
||||
// seal-author verification, subject tag, and Goblin slatepack extraction.
|
||||
// Network-dependent — run explicitly:
|
||||
// cargo test --test nostr_e2e -- --ignored --nocapture
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use grim::nostr::protocol;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
const RELAY: &str = "wss://nrelay.us-ea.st";
|
||||
|
||||
/// A small but valid-looking slatepack armor block for extraction testing.
|
||||
const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
|
||||
pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \
|
||||
dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \
|
||||
ENDSLATEPACK.";
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn nip17_slatepack_roundtrip() {
|
||||
nip17_roundtrip_over(RELAY).await;
|
||||
}
|
||||
|
||||
/// Same NIP-17 payment roundtrip over relay.damus.io — proves Goblin gift
|
||||
/// wraps transit a top public relay, not only the relay we run.
|
||||
/// Run: cargo test --test nostr_e2e nip17_roundtrip_damus -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn nip17_roundtrip_damus() {
|
||||
nip17_roundtrip_over("wss://relay.damus.io").await;
|
||||
}
|
||||
|
||||
/// And over nos.lol, the other large public relay in DEFAULT_RELAYS.
|
||||
/// Run: cargo test --test nostr_e2e nip17_roundtrip_nos_lol -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn nip17_roundtrip_nos_lol() {
|
||||
nip17_roundtrip_over("wss://nos.lol").await;
|
||||
}
|
||||
|
||||
/// The shared roundtrip, parameterized by relay: Bob advertises a kind-10050
|
||||
/// DM relay and subscribes to gift wraps; Alice sends a NIP-17 payment DM; Bob
|
||||
/// unwraps it, verifies the seal author, and extracts the slatepack + subject.
|
||||
async fn nip17_roundtrip_over(relay: &str) {
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
println!("alice: {}", alice.public_key().to_bech32().unwrap());
|
||||
println!("bob: {}", bob.public_key().to_bech32().unwrap());
|
||||
|
||||
// Bob's client: connect, advertise DM relays, subscribe to gift wraps.
|
||||
let bob_client = Client::new(bob.clone());
|
||||
bob_client.add_relay(relay).await.unwrap();
|
||||
bob_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Publish Bob's kind-10050 DM relay list so senders find this relay.
|
||||
let dm_relays = EventBuilder::new(Kind::InboxRelays, "")
|
||||
.tag(Tag::custom(TagKind::custom("relay"), [relay.to_string()]));
|
||||
bob_client.send_event_builder(dm_relays).await.unwrap();
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(bob.public_key())
|
||||
.since(Timestamp::now() - Duration::from_secs(3 * 86_400));
|
||||
bob_client.subscribe(filter, None).await.unwrap();
|
||||
|
||||
// Alice's client: connect and send a NIP-17 payment DM to Bob.
|
||||
let alice_client = Client::new(alice.clone());
|
||||
alice_client.add_relay(relay).await.unwrap();
|
||||
alice_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
let content = protocol::build_payment_content(SLATEPACK);
|
||||
let tags = protocol::build_rumor_tags(Some("lunch :)"));
|
||||
alice_client
|
||||
.send_private_msg_to([relay], bob.public_key(), content, tags)
|
||||
.await
|
||||
.unwrap();
|
||||
println!("alice sent gift-wrapped payment DM");
|
||||
|
||||
// Bob waits for the gift wrap, unwraps and validates it.
|
||||
let mut notifications = bob_client.notifications();
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await {
|
||||
if event.kind != Kind::GiftWrap {
|
||||
continue;
|
||||
}
|
||||
let unwrapped = match bob_client.unwrap_gift_wrap(&event).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// Seal-author check (the NIP-17 invariant our ingest enforces).
|
||||
assert_eq!(
|
||||
unwrapped.rumor.pubkey, unwrapped.sender,
|
||||
"rumor author must equal seal signer"
|
||||
);
|
||||
assert_eq!(unwrapped.sender, alice.public_key(), "sender must be Alice");
|
||||
assert_eq!(unwrapped.rumor.kind, Kind::PrivateDirectMessage);
|
||||
return unwrapped;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for gift wrap");
|
||||
|
||||
// The slatepack must round-trip intact, and the subject must survive.
|
||||
let armor = protocol::extract_slatepack(&result.rumor.content)
|
||||
.expect("slatepack must extract from rumor");
|
||||
assert!(armor.starts_with("BEGINSLATEPACK."));
|
||||
assert!(armor.ends_with("ENDSLATEPACK."));
|
||||
let subject = protocol::extract_subject(&result.rumor.tags);
|
||||
assert_eq!(subject.as_deref(), Some("lunch :)"));
|
||||
|
||||
println!("✓ NIP-17 slatepack roundtrip verified over {relay}");
|
||||
bob_client.disconnect().await;
|
||||
alice_client.disconnect().await;
|
||||
}
|
||||
|
||||
/// Register a fresh name on goblin.st with a real NIP-98 signature, then
|
||||
/// resolve it back — proves the live identity server end-to-end.
|
||||
/// Run: cargo test --test nostr_e2e nip05 -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn nip05_registration_roundtrip() {
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::process::Command;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
// Unique-ish name from the pubkey suffix (lowercase alnum).
|
||||
let name = format!("t{}", &pubkey[..8]);
|
||||
let server = "https://goblin.st";
|
||||
let url = format!("{server}/api/v1/register");
|
||||
let body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string();
|
||||
|
||||
// Build the NIP-98 kind-27235 auth event (same shape as the client).
|
||||
let payload_hash = hex::encode(Sha256::digest(body.as_bytes()));
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tag(Tag::custom(TagKind::custom("u"), [url.clone()]))
|
||||
.tag(Tag::custom(TagKind::custom("method"), ["POST".to_string()]))
|
||||
.tag(Tag::custom(TagKind::custom("payload"), [payload_hash]))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let auth = format!(
|
||||
"Nostr {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(event.as_json())
|
||||
);
|
||||
|
||||
// POST the registration via curl (avoids pulling an HTTP client dep).
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
&url,
|
||||
"-H",
|
||||
&format!("Authorization: {auth}"),
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-d",
|
||||
&body,
|
||||
])
|
||||
.output()
|
||||
.expect("curl register");
|
||||
let resp = String::from_utf8_lossy(&out.stdout);
|
||||
println!("register response: {resp}");
|
||||
assert!(
|
||||
resp.contains("\"nip05\""),
|
||||
"registration should return a nip05 identifier, got: {resp}"
|
||||
);
|
||||
assert!(resp.contains(&format!("{name}@goblin.st")));
|
||||
|
||||
// Resolve it back from the well-known endpoint.
|
||||
let wk = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
&format!("{server}/.well-known/nostr.json?name={name}"),
|
||||
])
|
||||
.output()
|
||||
.expect("curl well-known");
|
||||
let wk_body = String::from_utf8_lossy(&wk.stdout);
|
||||
println!("well-known response: {wk_body}");
|
||||
let resolved = protocol_parse_pubkey(&wk_body, &name);
|
||||
assert_eq!(resolved.as_deref(), Some(pubkey.as_str()));
|
||||
|
||||
// Clean up: release the test name.
|
||||
let del_url = format!("{server}/api/v1/register/{name}");
|
||||
let del_event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tag(Tag::custom(TagKind::custom("u"), [del_url.clone()]))
|
||||
.tag(Tag::custom(
|
||||
TagKind::custom("method"),
|
||||
["DELETE".to_string()],
|
||||
))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let del_auth = format!(
|
||||
"Nostr {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(del_event.as_json())
|
||||
);
|
||||
let _ = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-X",
|
||||
"DELETE",
|
||||
&del_url,
|
||||
"-H",
|
||||
&format!("Authorization: {del_auth}"),
|
||||
])
|
||||
.output();
|
||||
|
||||
println!("✓ NIP-05 registration + resolution verified on {server}");
|
||||
}
|
||||
|
||||
/// Minimal well-known pubkey extractor for the test.
|
||||
fn protocol_parse_pubkey(body: &str, name: &str) -> Option<String> {
|
||||
let doc: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
doc.get("names")?.get(name)?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Live avatar pipeline e2e against goblin.st: register → upload a processed
|
||||
/// PNG (NIP-98 by the owner) → profile shows the hash → GET serves a 256px
|
||||
/// PNG with the hardened headers → 6th change is rate-limited → release
|
||||
/// purges both the name and its avatar.
|
||||
/// Run: cargo test --test nostr_e2e avatar -- --ignored --nocapture
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn avatar_upload_roundtrip() {
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::process::Command;
|
||||
|
||||
let server = "https://goblin.st";
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let name = format!("a{}", &pubkey[..8]);
|
||||
|
||||
let nip98 = |url: &str, method: &str, body: &[u8]| -> String {
|
||||
let mut b = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tag(Tag::custom(TagKind::custom("u"), [url.to_string()]))
|
||||
.tag(Tag::custom(TagKind::custom("method"), [method.to_string()]));
|
||||
if !body.is_empty() {
|
||||
b = b.tag(Tag::custom(
|
||||
TagKind::custom("payload"),
|
||||
[hex::encode(Sha256::digest(body))],
|
||||
));
|
||||
}
|
||||
let ev = b.sign_with_keys(&keys).unwrap();
|
||||
format!(
|
||||
"Nostr {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(ev.as_json())
|
||||
)
|
||||
};
|
||||
|
||||
// Register the name first.
|
||||
let reg_url = format!("{server}/api/v1/register");
|
||||
let reg_body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string();
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
®_url,
|
||||
"-H",
|
||||
&format!(
|
||||
"Authorization: {}",
|
||||
nip98(®_url, "POST", reg_body.as_bytes())
|
||||
),
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-d",
|
||||
®_body,
|
||||
])
|
||||
.output()
|
||||
.expect("curl register");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("\"nip05\""),
|
||||
"register failed: {}",
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
|
||||
// Build a real PNG via the client pipeline (also strips metadata).
|
||||
let raw = {
|
||||
use ::image::{ImageEncoder, RgbaImage};
|
||||
let img = RgbaImage::from_fn(640, 480, |x, y| {
|
||||
::image::Rgba([(x % 256) as u8, (y % 256) as u8, 90, 255])
|
||||
});
|
||||
let mut v = Vec::new();
|
||||
::image::DynamicImage::ImageRgba8(img)
|
||||
.write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v))
|
||||
.unwrap();
|
||||
v
|
||||
};
|
||||
let png = grim::nostr::avatar::process_avatar_bytes(&raw).expect("process");
|
||||
let png_path = std::env::temp_dir().join(format!("{name}.png"));
|
||||
std::fs::write(&png_path, &png).unwrap();
|
||||
let av_url = format!("{server}/api/v1/avatar/{name}");
|
||||
|
||||
// Upload (raw bytes; payload hash over the PNG).
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-X",
|
||||
"POST",
|
||||
&av_url,
|
||||
"-H",
|
||||
&format!("Authorization: {}", nip98(&av_url, "POST", &png)),
|
||||
"-H",
|
||||
"Content-Type: application/octet-stream",
|
||||
"--data-binary",
|
||||
&format!("@{}", png_path.display()),
|
||||
])
|
||||
.output()
|
||||
.expect("curl upload");
|
||||
let resp = String::from_utf8_lossy(&out.stdout);
|
||||
println!("upload: {resp}");
|
||||
let hash = serde_json::from_str::<serde_json::Value>(&resp)
|
||||
.ok()
|
||||
.and_then(|v| v.get("avatar").and_then(|h| h.as_str()).map(String::from))
|
||||
.expect("upload should return a hash");
|
||||
|
||||
// Profile exposes the hash.
|
||||
let prof = Command::new("curl")
|
||||
.args(["-s", &format!("{server}/api/v1/profile/{name}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&prof.stdout).contains(&hash),
|
||||
"profile should carry the avatar hash"
|
||||
);
|
||||
|
||||
// GET serves a 256px PNG with hardened headers.
|
||||
let head = Command::new("curl")
|
||||
.args(["-sI", &format!("{server}/api/v1/avatar/{hash}.png")])
|
||||
.output()
|
||||
.unwrap();
|
||||
let head = String::from_utf8_lossy(&head.stdout).to_lowercase();
|
||||
assert!(head.contains("content-type: image/png"), "headers: {head}");
|
||||
assert!(head.contains("nosniff"), "missing nosniff: {head}");
|
||||
assert!(
|
||||
head.contains("immutable"),
|
||||
"missing immutable cache: {head}"
|
||||
);
|
||||
let got = Command::new("curl")
|
||||
.args(["-s", &format!("{server}/api/v1/avatar/{hash}.png")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(got.stdout.starts_with(&[0x89, b'P', b'N', b'G']));
|
||||
let served = ::image::load_from_memory(&got.stdout).expect("served bytes decode");
|
||||
assert_eq!((served.width(), served.height()), (256, 256));
|
||||
|
||||
// Daily limit: 4 more changes succeed (1 done = 5 total), the 6th is 429.
|
||||
for i in 0..4 {
|
||||
// Vary the pixels so each upload is a distinct hash.
|
||||
let raw = {
|
||||
use ::image::{ImageEncoder, RgbaImage};
|
||||
let img = RgbaImage::from_pixel(64, 64, ::image::Rgba([i as u8 * 40, 10, 10, 255]));
|
||||
let mut v = Vec::new();
|
||||
::image::DynamicImage::ImageRgba8(img)
|
||||
.write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v))
|
||||
.unwrap();
|
||||
v
|
||||
};
|
||||
let png = grim::nostr::avatar::process_avatar_bytes(&raw).unwrap();
|
||||
std::fs::write(&png_path, &png).unwrap();
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-X",
|
||||
"POST",
|
||||
&av_url,
|
||||
"-H",
|
||||
&format!("Authorization: {}", nip98(&av_url, "POST", &png)),
|
||||
"--data-binary",
|
||||
&format!("@{}", png_path.display()),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
println!("change {}: {}", i + 2, String::from_utf8_lossy(&out.stdout));
|
||||
}
|
||||
// 6th change → 429.
|
||||
let png = grim::nostr::avatar::process_avatar_bytes(&{
|
||||
use ::image::{ImageEncoder, RgbaImage};
|
||||
let img = RgbaImage::from_pixel(48, 48, ::image::Rgba([200, 200, 0, 255]));
|
||||
let mut v = Vec::new();
|
||||
::image::DynamicImage::ImageRgba8(img)
|
||||
.write_with_encoder(::image::codecs::png::PngEncoder::new(&mut v))
|
||||
.unwrap();
|
||||
v
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(&png_path, &png).unwrap();
|
||||
let out = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-X",
|
||||
"POST",
|
||||
&av_url,
|
||||
"-H",
|
||||
&format!("Authorization: {}", nip98(&av_url, "POST", &png)),
|
||||
"--data-binary",
|
||||
&format!("@{}", png_path.display()),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
"429",
|
||||
"6th avatar change in 24h must be rate-limited"
|
||||
);
|
||||
|
||||
// Release the name → avatar purged.
|
||||
let del_url = format!("{server}/api/v1/register/{name}");
|
||||
let _ = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-X",
|
||||
"DELETE",
|
||||
&del_url,
|
||||
"-H",
|
||||
&format!("Authorization: {}", nip98(&del_url, "DELETE", &[])),
|
||||
])
|
||||
.output();
|
||||
let after = Command::new("curl")
|
||||
.args([
|
||||
"-s",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
&format!("{server}/api/v1/profile/{name}"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&after.stdout),
|
||||
"404",
|
||||
"profile should 404 after release"
|
||||
);
|
||||
let _ = std::fs::remove_file(&png_path);
|
||||
println!("✓ avatar upload/serve/limit/release-purge verified on {server}");
|
||||
}
|
||||