Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89791793ed | |||
| 278a946980 | |||
| e8d71afc7e | |||
| 1f36631777 | |||
| 1e55ef5dfb | |||
| 03c1770892 | |||
| 3c32474f75 | |||
| 30c0ed9a12 | |||
| 22bf3359f5 | |||
| 53e18f06c7 | |||
| c78d7b0e60 |
@@ -1,23 +0,0 @@
|
||||
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,12 +1,6 @@
|
||||
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
|
||||
@@ -15,8 +9,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
# nym-sdk is a path dep on ../nym; materialize it before building.
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
# nip44 is a path dep on ../nip44; materialize it before building.
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
@@ -29,7 +21,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
@@ -41,7 +32,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
|
||||
@@ -25,8 +25,6 @@ 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:
|
||||
@@ -39,7 +37,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build
|
||||
shell: bash
|
||||
@@ -65,7 +62,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build
|
||||
shell: bash
|
||||
@@ -113,7 +109,6 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
submodules: recursive
|
||||
- uses: ./.github/actions/fetch-nym
|
||||
- uses: ./.github/actions/fetch-nip44
|
||||
- name: Build both architectures
|
||||
run: |
|
||||
|
||||
@@ -7,6 +7,9 @@ android/keystore
|
||||
android/keystore.asc
|
||||
android/keystore.properties
|
||||
android/*.apk
|
||||
android/*.apk.sha256
|
||||
android/*.AppImage
|
||||
android/*.AppImage.sha256
|
||||
android/*sha256sum.txt
|
||||
/.idea
|
||||
.DS_Store
|
||||
@@ -29,3 +32,7 @@ screenshots/
|
||||
.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
|
||||
|
||||
Generated
+2197
-4486
File diff suppressed because it is too large
Load Diff
+44
-15
@@ -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 the Nym mixnet handled for you."
|
||||
description = "Goblin: a peer-to-peer wallet for Grin. Send and receive instantly with a handle - slatepacks and Tor handled for you."
|
||||
license = "Apache-2.0"
|
||||
repository = "https://code.gri.mw/GUI/grim"
|
||||
keywords = [ "crypto", "grin", "mimblewimble", "nostr" ]
|
||||
@@ -31,6 +31,20 @@ 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"
|
||||
|
||||
@@ -124,18 +138,34 @@ rustls = { version = "0.23", features = ["ring"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] }
|
||||
webpki-roots = "1"
|
||||
|
||||
## Nym mixnet, linked IN-PROCESS (no sidecar subprocess, no bundled binary).
|
||||
## 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: it carries the load-bearing local
|
||||
## commit "http-api-client: preconfigured webpki roots on Android". Do not
|
||||
## float the checkout past that rev without re-verifying the Android build.
|
||||
nym-sdk = { path = "../nym/sdk/rust/nym-sdk" }
|
||||
## smolmix: TCP/UDP tunnel over the mixnet with an AUTO-SELECTED IPR exit —
|
||||
## the single-network-requester SPOF is structurally gone (plan G14).
|
||||
smolmix = { path = "../nym/smolmix/core" }
|
||||
## mix-dns wire codec. Already in the dependency graph via nym-http-api-client
|
||||
## (Cargo.lock), so we reuse it instead of vendoring a DNS encode/parse.
|
||||
hickory-proto = { version = "0.26", default-features = false, features = ["std"] }
|
||||
## 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"] }
|
||||
|
||||
## NIP-98 payload hashing
|
||||
sha2 = "0.10.8"
|
||||
@@ -197,9 +227,8 @@ base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
serde_yaml = "0.9"
|
||||
## G14 transport-validation harness (tests/xrelay_smoke.rs): re-expose deps that
|
||||
## already live in the main graph so the smolmix transport can be exercised and
|
||||
## its tunnel/mix-dns logs captured. No new compiles — same versions unify.
|
||||
## 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"] }
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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 the Nym mixnet and is applied automatically by the recipient's wallet. No files to swap, no need to both be online at once.
|
||||
- **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 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.
|
||||
- **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.
|
||||
- **Cross-platform** — Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
|
||||
|
||||
## How a payment travels
|
||||
@@ -24,7 +24,7 @@ Goblin is a fork of the **Grim** egui GRIN wallet: it keeps Grim's full GRIN nod
|
||||
```
|
||||
you ──slatepack──▶ NIP-17 gift wrap (kind 1059, NIP-44 encrypted)
|
||||
│
|
||||
Nym mixnet (5-hop)
|
||||
Tor
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
your relays recipient's DM relays (kind 10050)
|
||||
@@ -33,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)).
|
||||
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.
|
||||
|
||||
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**.
|
||||
|
||||
@@ -41,16 +41,15 @@ Both parties only need one relay in common. The default set is the Goblin relay
|
||||
|
||||
### Desktop (Linux / macOS / Windows)
|
||||
|
||||
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):
|
||||
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:
|
||||
|
||||
```
|
||||
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 — 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.
|
||||
Goblin's identity and payment traffic — nostr relays, NIP-05 lookups and price fetches — rides Tor: the money-path relay is dialed directly at its pinned `.onion` address, and any relay without one (e.g. a recipient's arbitrary DM relay) is reached over a Tor exit to its 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.
|
||||
|
||||
### Android
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ fn main() {
|
||||
.expect("failed to execute git config for hooks");
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# 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.
|
||||
@@ -1,105 +0,0 @@
|
||||
// Local network measurement for the Nym read tunnel. Uses the wallet's REAL
|
||||
// transport (warm_up + tuned tunnel + reselect + DNS cache + HTTP keep-alive
|
||||
// pool), then fetches the live price API over the mixnet on a fixed interval
|
||||
// so we can see (a) cold connect time, (b) whether the connection stays warm,
|
||||
// (c) per-fetch latency over time.
|
||||
//
|
||||
// cargo run --release --example tunnel_measure -- <seconds> [interval_secs]
|
||||
//
|
||||
// e.g. `-- 300` (5 min) or `-- 600 15` (10 min, every 15s).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let total_secs: u64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(300);
|
||||
let interval_secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(15);
|
||||
|
||||
let run_start = Instant::now();
|
||||
println!("[t=0.0s] warm_up(): starting the tunnel");
|
||||
grim::nym::warm_up();
|
||||
|
||||
// Cold connect time: poll is_ready().
|
||||
let mut connect_ms = None;
|
||||
let t_connect = Instant::now();
|
||||
while t_connect.elapsed() < Duration::from_secs(120) {
|
||||
if grim::nym::is_ready() {
|
||||
connect_ms = Some(t_connect.elapsed().as_millis());
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
match connect_ms {
|
||||
Some(ms) => println!(
|
||||
"[t={:.1}s] TUNNEL READY (cold connect {} ms)",
|
||||
run_start.elapsed().as_secs_f64(),
|
||||
ms
|
||||
),
|
||||
None => {
|
||||
println!("tunnel never became ready in 120s; aborting");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Warm-loop: fetch price over the mixnet every interval, record latency.
|
||||
let mut lats: Vec<u128> = vec![];
|
||||
let mut fails = 0u32;
|
||||
let deadline = run_start + Duration::from_secs(total_secs);
|
||||
let mut n = 0u32;
|
||||
while Instant::now() < deadline {
|
||||
n += 1;
|
||||
let t = Instant::now();
|
||||
let ok = grim::nym::http_request("GET", PRICE_URL.to_string(), None, vec![]).await;
|
||||
let ms = t.elapsed().as_millis();
|
||||
match ok {
|
||||
Some(body) if body.contains("grin") => {
|
||||
lats.push(ms);
|
||||
println!(
|
||||
"[t={:.1}s] fetch #{n}: {} ms ready={}",
|
||||
run_start.elapsed().as_secs_f64(),
|
||||
ms,
|
||||
grim::nym::is_ready()
|
||||
);
|
||||
}
|
||||
other => {
|
||||
fails += 1;
|
||||
println!(
|
||||
"[t={:.1}s] fetch #{n}: FAIL after {} ms (ready={}, body={:?})",
|
||||
run_start.elapsed().as_secs_f64(),
|
||||
ms,
|
||||
grim::nym::is_ready(),
|
||||
other.map(|b| b.chars().take(40).collect::<String>())
|
||||
);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(interval_secs)).await;
|
||||
}
|
||||
|
||||
// Summary.
|
||||
lats.sort_unstable();
|
||||
let n_ok = lats.len();
|
||||
let sum: u128 = lats.iter().sum();
|
||||
let median = lats.get(n_ok / 2).copied().unwrap_or(0);
|
||||
println!(
|
||||
"\n==== SUMMARY ({}s run, {}s interval) ====",
|
||||
total_secs, interval_secs
|
||||
);
|
||||
println!("cold connect: {} ms", connect_ms.unwrap());
|
||||
println!("fetches: {} ok, {} failed", n_ok, fails);
|
||||
if n_ok > 0 {
|
||||
println!(
|
||||
"warm fetch latency ms: min {} / median {} / max {} / mean {}",
|
||||
lats.first().unwrap(),
|
||||
median,
|
||||
lats.last().unwrap(),
|
||||
sum / n_ok as u128
|
||||
);
|
||||
let head: Vec<u128> = lats.iter().take(3).copied().collect();
|
||||
println!("(sorted sample) fastest 3: {:?}", head);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
# Usage: linux/build_release.sh [platform]
|
||||
# platform: 'x86_64' (default) or 'arm'
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -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 (Nym SDK linked in), plus the
|
||||
# Assemble the AppDir: AppRun IS the goblin binary (Tor/arti linked in), plus the
|
||||
# icon + desktop entry. Nothing else.
|
||||
appdir="linux/Goblin.AppDir"
|
||||
cp "target/${arch}/release/goblin" "${appdir}/AppRun"
|
||||
|
||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonym"
|
||||
connected_nym: "Über Nym verbunden"
|
||||
nym_ready: "Nym bereit · Relays…"
|
||||
connecting_nym: "Verbinde mit Nym…"
|
||||
connected_nym: "Über Tor verbunden"
|
||||
nym_ready: "Tor bereit · Relays…"
|
||||
connecting_nym: "Verbinde mit Tor…"
|
||||
cant_reach_node: "Node nicht erreichbar"
|
||||
node_synced: "Node synchronisiert"
|
||||
syncing: "Synchronisiere…"
|
||||
@@ -416,7 +416,7 @@ goblin:
|
||||
fee_none: "Keine"
|
||||
network_fee: "Netzwerkgebühr"
|
||||
privacy: "Privatsphäre"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Transaktion"
|
||||
cancel_request: "Anfrage abbrechen"
|
||||
cancel_send: "Zahlung abbrechen"
|
||||
@@ -474,7 +474,7 @@ goblin:
|
||||
switch_wallet: "Wallet wechseln"
|
||||
advanced: "Erweitert"
|
||||
privacy: "Privatsphäre"
|
||||
mixnet_routing: "Mixnet-Routing"
|
||||
mixnet_routing: "Tor-Routing"
|
||||
messages_lookups: "Nachrichten & Abfragen"
|
||||
auto_accept: "Automatisch annehmen"
|
||||
pairing: "Preiswährung"
|
||||
@@ -497,7 +497,7 @@ goblin:
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
network: "Netzwerk"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Drittanbieter"
|
||||
grim: "GRIM (Upstream-Wallet)"
|
||||
grin_node: "Grin-Node"
|
||||
@@ -617,14 +617,14 @@ goblin:
|
||||
hide_qr: "QR ausblenden"
|
||||
privacy:
|
||||
title: "Netzwerk-Privatsphäre"
|
||||
intro: "Goblin sendet seinen privaten Datenverkehr durch das Nym mixnet — ein Netzwerk mit fünf Sprüngen, das verbirgt, wer mit wem kommuniziert, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann."
|
||||
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."
|
||||
payments: "Zahlungen"
|
||||
payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt."
|
||||
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 das mixnet"
|
||||
over_mixnet: "Über Tor"
|
||||
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."
|
||||
@@ -632,7 +632,7 @@ goblin:
|
||||
title: "Kopplung"
|
||||
intro: "Womit dein Guthaben und deine Beträge verglichen werden."
|
||||
pair_with: "Koppeln mit"
|
||||
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."
|
||||
rates_note: "Kurse werden über Tor 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."
|
||||
@@ -674,7 +674,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 das Nym mixnet 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 Tor 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"
|
||||
@@ -725,8 +725,8 @@ goblin:
|
||||
kicker: "SCHRITT 3 VON 3 · IDENTITÄT"
|
||||
title: "Deine Zahlungsidentität"
|
||||
key_being_made: "Schlüssel wird erstellt…"
|
||||
connected_nym: "über Nym verbunden"
|
||||
connecting_nym: "verbinde über Nym…"
|
||||
connected_nym: "über Tor verbunden"
|
||||
connecting_nym: "verbinde über Tor…"
|
||||
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"
|
||||
@@ -734,7 +734,7 @@ goblin:
|
||||
username_field_hint: "deinname"
|
||||
working: "Arbeite…"
|
||||
claim_username: "Benutzernamen sichern"
|
||||
available_when_connected: "Verfügbar, sobald das mixnet verbindet — oder überspringen und später sichern."
|
||||
available_when_connected: "Verfügbar, sobald Tor 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."
|
||||
@@ -792,11 +792,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 Nym"
|
||||
row_delivery_val: "NIP-44-verschlüsselt, über Tor"
|
||||
row_network_fee: "Netzwerkgebühr"
|
||||
row_network_fee_val: "Von deinem Guthaben abgezogen"
|
||||
row_privacy: "Privatsphäre"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Anfrage senden"
|
||||
request_approve_hint: "Sie erhalten eine Anfrage zum Zustimmen"
|
||||
hold_to_send: "Zum Senden halten"
|
||||
|
||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonymous"
|
||||
connected_nym: "Connected over Nym"
|
||||
nym_ready: "Nym ready · relays…"
|
||||
connecting_nym: "Connecting to Nym…"
|
||||
connected_nym: "Connected over Tor"
|
||||
nym_ready: "Tor ready · relays…"
|
||||
connecting_nym: "Connecting to Tor…"
|
||||
cant_reach_node: "Can't reach node"
|
||||
node_synced: "Node synced"
|
||||
syncing: "Syncing…"
|
||||
@@ -416,7 +416,7 @@ goblin:
|
||||
fee_none: "None"
|
||||
network_fee: "Network fee"
|
||||
privacy: "Privacy"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Transaction"
|
||||
cancel_request: "Cancel request"
|
||||
cancel_send: "Cancel payment"
|
||||
@@ -474,7 +474,7 @@ goblin:
|
||||
switch_wallet: "Switch wallet"
|
||||
advanced: "Advanced"
|
||||
privacy: "Privacy"
|
||||
mixnet_routing: "Mixnet routing"
|
||||
mixnet_routing: "Tor routing"
|
||||
messages_lookups: "Messages & lookups"
|
||||
auto_accept: "Auto-accept"
|
||||
pairing: "Price currency"
|
||||
@@ -497,7 +497,7 @@ goblin:
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
network: "Network"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Third party"
|
||||
grim: "GRIM (upstream wallet)"
|
||||
grin_node: "Grin node"
|
||||
@@ -617,14 +617,14 @@ goblin:
|
||||
hide_qr: "Hide QR"
|
||||
privacy:
|
||||
title: "Network privacy"
|
||||
intro: "Goblin sends its private traffic through the Nym mixnet — a five-hop network that hides who is talking to whom, so a relay can't link a payment back to you."
|
||||
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."
|
||||
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 the mixnet"
|
||||
over_mixnet: "Over Tor"
|
||||
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."
|
||||
@@ -632,7 +632,7 @@ goblin:
|
||||
title: "Pairing"
|
||||
intro: "What your balance and amounts are shown against."
|
||||
pair_with: "Pair with"
|
||||
rates_note: "Rates fetch over the Nym mixnet, only while a pairing is on — off means no rate request leaves your device."
|
||||
rates_note: "Rates fetch over Tor, 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."
|
||||
@@ -674,7 +674,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 the Nym mixnet — 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 Tor — 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"
|
||||
@@ -725,8 +725,8 @@ goblin:
|
||||
kicker: "STEP 3 OF 3 · IDENTITY"
|
||||
title: "Your payment identity"
|
||||
key_being_made: "key being made…"
|
||||
connected_nym: "connected over Nym"
|
||||
connecting_nym: "connecting over Nym…"
|
||||
connected_nym: "connected over Tor"
|
||||
connecting_nym: "connecting over Tor…"
|
||||
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"
|
||||
@@ -734,7 +734,7 @@ goblin:
|
||||
username_field_hint: "yourname"
|
||||
working: "Working…"
|
||||
claim_username: "Claim username"
|
||||
available_when_connected: "Available once the mixnet connects — or skip and claim later."
|
||||
available_when_connected: "Available once Tor 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."
|
||||
@@ -792,11 +792,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 Nym"
|
||||
row_delivery_val: "NIP-44 encrypted, over Tor"
|
||||
row_network_fee: "Network fee"
|
||||
row_network_fee_val: "Deducted from your balance"
|
||||
row_privacy: "Privacy"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Send request"
|
||||
request_approve_hint: "They'll get a request to approve"
|
||||
hold_to_send: "Hold to send"
|
||||
|
||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonyme"
|
||||
connected_nym: "Connecté via Nym"
|
||||
nym_ready: "Nym prêt · relais…"
|
||||
connecting_nym: "Connexion à Nym…"
|
||||
connected_nym: "Connecté via Tor"
|
||||
nym_ready: "Tor prêt · relais…"
|
||||
connecting_nym: "Connexion à Tor…"
|
||||
cant_reach_node: "Nœud injoignable"
|
||||
node_synced: "Nœud synchronisé"
|
||||
syncing: "Synchronisation…"
|
||||
@@ -416,7 +416,7 @@ goblin:
|
||||
fee_none: "Aucun"
|
||||
network_fee: "Frais de réseau"
|
||||
privacy: "Confidentialité"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Transaction"
|
||||
cancel_request: "Annuler la demande"
|
||||
cancel_send: "Annuler le paiement"
|
||||
@@ -474,7 +474,7 @@ goblin:
|
||||
switch_wallet: "Changer de portefeuille"
|
||||
advanced: "Avancé"
|
||||
privacy: "Confidentialité"
|
||||
mixnet_routing: "Routage par mixnet"
|
||||
mixnet_routing: "Routage par Tor"
|
||||
messages_lookups: "Messages et recherches"
|
||||
auto_accept: "Acceptation auto"
|
||||
pairing: "Devise des prix"
|
||||
@@ -497,7 +497,7 @@ goblin:
|
||||
goblin: "Goblin"
|
||||
build: "Build %{build}"
|
||||
network: "Réseau"
|
||||
network_value: "MW + mixnet Nym + nostr"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Tiers"
|
||||
grim: "GRIM (portefeuille amont)"
|
||||
grin_node: "Nœud grin"
|
||||
@@ -617,14 +617,14 @@ goblin:
|
||||
hide_qr: "Masquer le QR"
|
||||
privacy:
|
||||
title: "Confidentialité réseau"
|
||||
intro: "Goblin envoie son trafic privé via le mixnet Nym — un réseau à cinq sauts qui masque qui parle à qui, afin qu'un relais ne puisse pas relier un paiement à vous."
|
||||
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."
|
||||
payments: "Paiements"
|
||||
payments_blurb: "Chaque message nostr transportant un slatepack."
|
||||
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 le mixnet"
|
||||
over_mixnet: "Via Tor"
|
||||
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é."
|
||||
@@ -632,7 +632,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 le mixnet Nym, 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 Tor, 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."
|
||||
@@ -674,7 +674,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 le mixnet Nym — 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 Tor — 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"
|
||||
@@ -725,8 +725,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 Nym"
|
||||
connecting_nym: "connexion via Nym…"
|
||||
connected_nym: "connecté via Tor"
|
||||
connecting_nym: "connexion via Tor…"
|
||||
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"
|
||||
@@ -734,7 +734,7 @@ goblin:
|
||||
username_field_hint: "votrenom"
|
||||
working: "En cours…"
|
||||
claim_username: "Réserver le nom d'utilisateur"
|
||||
available_when_connected: "Disponible une fois le mixnet connecté — ou passez et réservez plus tard."
|
||||
available_when_connected: "Disponible une fois Tor 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."
|
||||
@@ -792,11 +792,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 Nym"
|
||||
row_delivery_val: "Chiffré NIP-44, via Tor"
|
||||
row_network_fee: "Frais de réseau"
|
||||
row_network_fee_val: "Déduit de votre solde"
|
||||
row_privacy: "Confidentialité"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Envoyer la demande"
|
||||
request_approve_hint: "Ils recevront une demande à approuver"
|
||||
hold_to_send: "Maintenir pour envoyer"
|
||||
|
||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Аноним"
|
||||
connected_nym: "Подключено через Nym"
|
||||
nym_ready: "Nym готов · реле…"
|
||||
connecting_nym: "Подключение к Nym…"
|
||||
connected_nym: "Подключено через Tor"
|
||||
nym_ready: "Tor готов · реле…"
|
||||
connecting_nym: "Подключение к Tor…"
|
||||
cant_reach_node: "Нет связи с узлом"
|
||||
node_synced: "Узел синхронизирован"
|
||||
syncing: "Синхронизация…"
|
||||
@@ -416,7 +416,7 @@ goblin:
|
||||
fee_none: "Нет"
|
||||
network_fee: "Сетевая комиссия"
|
||||
privacy: "Приватность"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Транзакция"
|
||||
cancel_request: "Отменить запрос"
|
||||
cancel_send: "Отменить платёж"
|
||||
@@ -474,7 +474,7 @@ goblin:
|
||||
switch_wallet: "Сменить кошелёк"
|
||||
advanced: "Дополнительно"
|
||||
privacy: "Приватность"
|
||||
mixnet_routing: "Маршрутизация через mixnet"
|
||||
mixnet_routing: "Маршрутизация через Tor"
|
||||
messages_lookups: "Сообщения и поиск"
|
||||
auto_accept: "Автоприём"
|
||||
pairing: "Валюта цены"
|
||||
@@ -497,7 +497,7 @@ goblin:
|
||||
goblin: "Goblin"
|
||||
build: "Сборка %{build}"
|
||||
network: "Сеть"
|
||||
network_value: "MW + mixnet Nym + nostr"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Сторонние"
|
||||
grim: "GRIM (исходный кошелёк)"
|
||||
grin_node: "Узел Grin"
|
||||
@@ -617,14 +617,14 @@ goblin:
|
||||
hide_qr: "Скрыть QR"
|
||||
privacy:
|
||||
title: "Сетевая приватность"
|
||||
intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами."
|
||||
intro: "Goblin отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
|
||||
payments: "Платежи"
|
||||
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
|
||||
usernames: "usernames"
|
||||
usernames_blurb: "Поиск имён NIP-05 к и от goblin.st."
|
||||
price_avatars: "Цена"
|
||||
price_avatars_blurb: "Текущий курс рядом с суммами."
|
||||
over_mixnet: "Через mixnet"
|
||||
over_mixnet: "Через Tor"
|
||||
direct_connection: "Прямое соединение"
|
||||
grin_node: "Узел Grin"
|
||||
grin_node_blurb: "Синхронизация блоков и трансляция транзакции в сеть. Это публичные данные цепочки, одинаковые для всех, и они не связаны с вашей личностью."
|
||||
@@ -632,7 +632,7 @@ goblin:
|
||||
title: "Привязка"
|
||||
intro: "К чему привязаны отображаемые баланс и суммы."
|
||||
pair_with: "Привязать к"
|
||||
rates_note: "Курсы загружаются через mixnet Nym только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||
rates_note: "Курсы загружаются через Tor только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||
relays:
|
||||
title: "Реле"
|
||||
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
|
||||
@@ -674,7 +674,7 @@ goblin:
|
||||
private_money_head: "Приватные деньги"
|
||||
private_money_body: "Goblin — кошелёк для grin: цифровая наличность без сумм и адресов в её цепочке."
|
||||
send_like_message_head: "Отправляйте как сообщение"
|
||||
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и mixnet Nym — никто посередине не увидит сумму или участников."
|
||||
send_like_message_body: "Заплатите на username или npub, и платёж придёт как сквозно зашифрованное сообщение через nostr и Tor — никто посередине не увидит сумму или участников."
|
||||
yours_alone_head: "Только ваше"
|
||||
yours_alone_body: "Ключи, имена и история живут на этом устройстве. На базе кошелька GRIM."
|
||||
get_started: "Начать"
|
||||
@@ -725,8 +725,8 @@ goblin:
|
||||
kicker: "ШАГ 3 ИЗ 3 · ЛИЧНОСТЬ"
|
||||
title: "Ваша платёжная личность"
|
||||
key_being_made: "ключ создаётся…"
|
||||
connected_nym: "подключено через Nym"
|
||||
connecting_nym: "подключение через Nym…"
|
||||
connected_nym: "подключено через Tor"
|
||||
connecting_nym: "подключение через Tor…"
|
||||
fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства."
|
||||
clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо."
|
||||
pick_username: "Выберите имя — необязательно"
|
||||
@@ -734,7 +734,7 @@ goblin:
|
||||
username_field_hint: "yourname"
|
||||
working: "Обработка…"
|
||||
claim_username: "Занять имя"
|
||||
available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже."
|
||||
available_when_connected: "Доступно после подключения Tor — или пропустите и займите позже."
|
||||
youre: "Вы %{name}"
|
||||
claimed_title: "%{name} теперь ваше"
|
||||
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
|
||||
@@ -792,11 +792,11 @@ goblin:
|
||||
row_they_pay: "Они платят"
|
||||
row_they_pay_val: "Только если они одобрят"
|
||||
row_delivery: "Доставка"
|
||||
row_delivery_val: "Зашифровано NIP-44, через Nym"
|
||||
row_delivery_val: "Зашифровано NIP-44, через Tor"
|
||||
row_network_fee: "Сетевая комиссия"
|
||||
row_network_fee_val: "Списывается с вашего баланса"
|
||||
row_privacy: "Приватность"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "Отправить запрос"
|
||||
request_approve_hint: "Они получат запрос на одобрение"
|
||||
hold_to_send: "Удерживайте для отправки"
|
||||
|
||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "Anonim"
|
||||
connected_nym: "Nym üzerinden bağlı"
|
||||
nym_ready: "Nym hazır · relaylar…"
|
||||
connecting_nym: "Nym'e bağlanılıyor…"
|
||||
connected_nym: "Tor üzerinden bağlı"
|
||||
nym_ready: "Tor hazır · relaylar…"
|
||||
connecting_nym: "Tor'a bağlanılıyor…"
|
||||
cant_reach_node: "Düğüme ulaşılamıyor"
|
||||
node_synced: "Düğüm eşitlendi"
|
||||
syncing: "Eşitleniyor…"
|
||||
@@ -416,7 +416,7 @@ goblin:
|
||||
fee_none: "Yok"
|
||||
network_fee: "Ağ ücreti"
|
||||
privacy: "Gizlilik"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "İşlem"
|
||||
cancel_request: "İsteği iptal et"
|
||||
cancel_send: "Ödemeyi iptal et"
|
||||
@@ -474,7 +474,7 @@ goblin:
|
||||
switch_wallet: "Cüzdan değiştir"
|
||||
advanced: "Gelişmiş"
|
||||
privacy: "Gizlilik"
|
||||
mixnet_routing: "Mixnet yönlendirme"
|
||||
mixnet_routing: "Tor yönlendirme"
|
||||
messages_lookups: "Mesajlar ve aramalar"
|
||||
auto_accept: "Otomatik kabul"
|
||||
pairing: "Fiyat para birimi"
|
||||
@@ -497,7 +497,7 @@ goblin:
|
||||
goblin: "Goblin"
|
||||
build: "Sürüm %{build}"
|
||||
network: "Ağ"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "Üçüncü taraf"
|
||||
grim: "GRIM (üst kaynak cüzdan)"
|
||||
grin_node: "Grin düğümü"
|
||||
@@ -617,14 +617,14 @@ goblin:
|
||||
hide_qr: "QR gizle"
|
||||
privacy:
|
||||
title: "Ağ gizliliği"
|
||||
intro: "Goblin özel trafiğini Nym mixnet üzerinden gönderir — kimin kiminle konuştuğunu gizleyen beş atlamalı bir ağ, böylece bir relay bir ödemeyi sana bağlayamaz."
|
||||
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."
|
||||
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: "Mixnet üzerinden"
|
||||
over_mixnet: "Tor ü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."
|
||||
@@ -632,7 +632,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 Nym mixnet ü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 Tor ü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."
|
||||
@@ -674,7 +674,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 Nym mixnet ü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 Tor ü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"
|
||||
@@ -725,8 +725,8 @@ goblin:
|
||||
kicker: "ADIM 3 / 3 · KİMLİK"
|
||||
title: "Ödeme kimliğin"
|
||||
key_being_made: "anahtar oluşturuluyor…"
|
||||
connected_nym: "Nym üzerinden bağlı"
|
||||
connecting_nym: "Nym üzerinden bağlanılıyor…"
|
||||
connected_nym: "Tor üzerinden bağlı"
|
||||
connecting_nym: "Tor ü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ı"
|
||||
@@ -734,7 +734,7 @@ goblin:
|
||||
username_field_hint: "adınız"
|
||||
working: "Çalışıyor…"
|
||||
claim_username: "Kullanıcı adı al"
|
||||
available_when_connected: "Mixnet bağlandığında müsait — ya da atla ve sonra al."
|
||||
available_when_connected: "Tor 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ç."
|
||||
@@ -792,11 +792,11 @@ goblin:
|
||||
row_they_pay: "Onlar öder"
|
||||
row_they_pay_val: "Yalnızca onaylarlarsa"
|
||||
row_delivery: "Teslimat"
|
||||
row_delivery_val: "NIP-44 şifreli, Nym üzerinden"
|
||||
row_delivery_val: "NIP-44 şifreli, Tor üzerinden"
|
||||
row_network_fee: "Ağ ücreti"
|
||||
row_network_fee_val: "Bakiyenden düşülür"
|
||||
row_privacy: "Gizlilik"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
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"
|
||||
|
||||
+15
-15
@@ -359,9 +359,9 @@ keyboard:
|
||||
goblin:
|
||||
home:
|
||||
anonymous: "匿名"
|
||||
connected_nym: "已通过 Nym 连接"
|
||||
nym_ready: "Nym 就绪 · 连接中继…"
|
||||
connecting_nym: "正在连接 Nym…"
|
||||
connected_nym: "已通过 Tor 连接"
|
||||
nym_ready: "Tor 就绪 · 连接中继…"
|
||||
connecting_nym: "正在连接 Tor…"
|
||||
cant_reach_node: "无法连接节点"
|
||||
node_synced: "节点已同步"
|
||||
syncing: "同步中…"
|
||||
@@ -416,7 +416,7 @@ goblin:
|
||||
fee_none: "无"
|
||||
network_fee: "网络费用"
|
||||
privacy: "隐私"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "交易"
|
||||
cancel_request: "取消请求"
|
||||
cancel_send: "取消付款"
|
||||
@@ -474,7 +474,7 @@ goblin:
|
||||
switch_wallet: "切换钱包"
|
||||
advanced: "高级"
|
||||
privacy: "隐私"
|
||||
mixnet_routing: "mixnet 路由"
|
||||
mixnet_routing: "Tor 路由"
|
||||
messages_lookups: "消息和查询"
|
||||
auto_accept: "自动接受"
|
||||
pairing: "价格货币"
|
||||
@@ -497,7 +497,7 @@ goblin:
|
||||
goblin: "Goblin"
|
||||
build: "构建 %{build}"
|
||||
network: "网络"
|
||||
network_value: "MW + Nym mixnet + nostr"
|
||||
network_value: "MW + Tor + nostr"
|
||||
third_party: "第三方"
|
||||
grim: "GRIM(上游钱包)"
|
||||
grin_node: "Grin 节点"
|
||||
@@ -617,14 +617,14 @@ goblin:
|
||||
hide_qr: "隐藏二维码"
|
||||
privacy:
|
||||
title: "网络隐私"
|
||||
intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。"
|
||||
intro: "Goblin 通过 Tor 发送其私密流量,向中继隐藏你的 IP — 加密隐藏其余部分,使中继无法将付款关联到你。"
|
||||
payments: "付款"
|
||||
payments_blurb: "每条携带 slatepack 的 nostr 消息。"
|
||||
usernames: "用户名"
|
||||
usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。"
|
||||
price_avatars: "价格"
|
||||
price_avatars_blurb: "金额旁显示的实时法币汇率。"
|
||||
over_mixnet: "经由 mixnet"
|
||||
over_mixnet: "经由 Tor"
|
||||
direct_connection: "直接连接"
|
||||
grin_node: "Grin 节点"
|
||||
grin_node_blurb: "区块同步及向网络广播你的交易。这是公开的链上数据,对所有人都一样,且不与你的身份关联。"
|
||||
@@ -632,7 +632,7 @@ goblin:
|
||||
title: "配对"
|
||||
intro: "你的余额和金额以何种货币显示。"
|
||||
pair_with: "配对货币"
|
||||
rates_note: "汇率仅在开启配对时通过 Nym mixnet 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||
rates_note: "汇率仅在开启配对时通过 Tor 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||
relays:
|
||||
title: "中继"
|
||||
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
|
||||
@@ -674,7 +674,7 @@ goblin:
|
||||
private_money_head: "私密货币"
|
||||
private_money_body: "Goblin 是一个 grin 钱包 — 链上无金额、无地址的数字现金。"
|
||||
send_like_message_head: "像发消息一样付款"
|
||||
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Nym mixnet 送达 — 中间任何人都看不到金额或参与者。"
|
||||
send_like_message_body: "向 username 或 npub 付款,款项会作为端到端加密消息通过 nostr 和 Tor 送达 — 中间任何人都看不到金额或参与者。"
|
||||
yours_alone_head: "完全属于你"
|
||||
yours_alone_body: "密钥、用户名和历史记录都存于本设备。基于 GRIM 钱包构建。"
|
||||
get_started: "开始使用"
|
||||
@@ -725,8 +725,8 @@ goblin:
|
||||
kicker: "步骤 3 / 3 · 身份"
|
||||
title: "你的付款身份"
|
||||
key_being_made: "正在生成密钥…"
|
||||
connected_nym: "已通过 Nym 连接"
|
||||
connecting_nym: "正在通过 Nym 连接…"
|
||||
connected_nym: "已通过 Tor 连接"
|
||||
connecting_nym: "正在通过 Tor 连接…"
|
||||
fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。"
|
||||
clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。"
|
||||
pick_username: "选择用户名 — 可选"
|
||||
@@ -734,7 +734,7 @@ goblin:
|
||||
username_field_hint: "你的用户名"
|
||||
working: "处理中…"
|
||||
claim_username: "注册用户名"
|
||||
available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。"
|
||||
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
|
||||
youre: "你是 %{name}"
|
||||
claimed_title: "%{name} 已归你所有"
|
||||
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
|
||||
@@ -792,11 +792,11 @@ goblin:
|
||||
row_they_pay: "对方支付"
|
||||
row_they_pay_val: "仅当对方同意时"
|
||||
row_delivery: "传输"
|
||||
row_delivery_val: "NIP-44 加密,经由 Nym"
|
||||
row_delivery_val: "NIP-44 加密,经由 Tor"
|
||||
row_network_fee: "网络费用"
|
||||
row_network_fee_val: "从你的余额中扣除"
|
||||
row_privacy: "隐私"
|
||||
row_privacy_val: "Mimblewimble + Nym"
|
||||
row_privacy_val: "Mimblewimble + Tor"
|
||||
send_request_btn: "发送请求"
|
||||
request_approve_hint: "对方将收到一条待同意的请求"
|
||||
hold_to_send: "长按发送"
|
||||
|
||||
+2
-2
@@ -54,8 +54,8 @@ function build_lib() {
|
||||
sed -i -e 's/"cdylib","rlib"]/"rlib"]/g' Cargo.toml
|
||||
rm -f Cargo.toml-e
|
||||
|
||||
# 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.
|
||||
# The Tor transport (embedded arti) is linked INTO libgrim.so, so there is no
|
||||
# separate sidecar binary to cross-build or bundle into jniLibs.
|
||||
}
|
||||
|
||||
### Build application
|
||||
|
||||
@@ -795,9 +795,9 @@ impl GoblinWalletView {
|
||||
ui.label(
|
||||
// Relay-gated: "Connected over Nym" only once a
|
||||
// relay is live on the current tunnel generation.
|
||||
RichText::new(if crate::nym::transport_ready() {
|
||||
RichText::new(if crate::tor::transport_ready() {
|
||||
t!("goblin.home.connected_nym")
|
||||
} else if crate::nym::is_ready() {
|
||||
} else if crate::tor::is_ready() {
|
||||
t!("goblin.home.nym_ready")
|
||||
} else {
|
||||
t!("goblin.home.connecting_nym")
|
||||
@@ -2412,9 +2412,9 @@ impl GoblinWalletView {
|
||||
// tunnel being warm is not enough — a relay must actually carry
|
||||
// our traffic on the current exit. Otherwise show the tunnel is
|
||||
// up but relays are still connecting/reconnecting.
|
||||
let mixnet = if crate::nym::transport_ready() {
|
||||
let mixnet = if crate::tor::transport_ready() {
|
||||
t!("goblin.home.connected_nym")
|
||||
} else if crate::nym::is_ready() {
|
||||
} else if crate::tor::is_ready() {
|
||||
t!("goblin.home.nym_ready")
|
||||
} else {
|
||||
t!("goblin.home.connecting_nym")
|
||||
@@ -2435,7 +2435,7 @@ impl GoblinWalletView {
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
);
|
||||
if !crate::nym::transport_ready() || !connected {
|
||||
if !crate::tor::transport_ready() || !connected {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(600));
|
||||
}
|
||||
@@ -2766,8 +2766,8 @@ impl GoblinWalletView {
|
||||
if settings_row_nav(ui, "nostr-sdk", "0.44") {
|
||||
open_url(ui, "https://github.com/rust-nostr/nostr");
|
||||
}
|
||||
if settings_row_nav(ui, "Nym mixnet", "sdk 1.21") {
|
||||
open_url(ui, "https://nym.com");
|
||||
if settings_row_nav(ui, "Tor (arti)", "0.43") {
|
||||
open_url(ui, "https://gitlab.torproject.org/tpo/core/arti");
|
||||
}
|
||||
if settings_row_nav(ui, "egui", "0.33") {
|
||||
open_url(ui, "https://github.com/emilk/egui");
|
||||
|
||||
@@ -804,7 +804,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::nym::transport_ready() {
|
||||
RichText::new(if crate::tor::transport_ready() {
|
||||
t!("goblin.onboarding.identity.connected_nym")
|
||||
} else {
|
||||
t!("goblin.onboarding.identity.connecting_nym")
|
||||
|
||||
+5
-5
@@ -24,7 +24,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::nym;
|
||||
use crate::tor;
|
||||
|
||||
/// Cache refresh interval (seconds).
|
||||
const REFRESH_SECS: i64 = 300;
|
||||
@@ -154,7 +154,7 @@ pub fn eager_refresh() {
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let generation = nym::tunnel_generation();
|
||||
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 {
|
||||
@@ -175,8 +175,8 @@ pub fn eager_refresh() {
|
||||
// generation we probed: the exit is up but blackholing our HTTP. Condemn
|
||||
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
|
||||
// probed generation so a reselect that already happened is never hit.
|
||||
if !ok && nym::is_ready() && nym::tunnel_generation() == generation {
|
||||
nym::condemn_exit(generation);
|
||||
if !ok && tor::is_ready() && tor::tunnel_generation() == generation {
|
||||
tor::condemn_exit(generation);
|
||||
}
|
||||
});
|
||||
FETCHING.write().remove(&vs);
|
||||
@@ -191,7 +191,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 = nym::http_request("GET", url, None, headers).await?;
|
||||
let body = tor::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());
|
||||
|
||||
+12
-5
@@ -38,8 +38,14 @@ 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 settings;
|
||||
pub mod tor;
|
||||
mod wallet;
|
||||
|
||||
/// Upstream GRIM version the fork is based on (third-party credit).
|
||||
@@ -117,11 +123,12 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
|
||||
// would panic on the first TLS handshake. nym uses its own explicit provider,
|
||||
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so
|
||||
// the mixnet bootstrap (the long pole on cold start) overlaps everything else
|
||||
// and price/NIP-05/nostr are ready at first use. All of Goblin's outbound
|
||||
// traffic egresses through it; nothing clearnet.
|
||||
nym::warm_up();
|
||||
// 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();
|
||||
|
||||
+136
-91
@@ -35,7 +35,7 @@ use crate::nostr::relays::MAX_DM_RELAYS;
|
||||
use crate::nostr::types::*;
|
||||
use crate::nostr::wrapv3;
|
||||
use crate::nostr::{NostrConfig, NostrIdentity, NostrStore};
|
||||
use crate::nym::NymWebSocketTransport;
|
||||
use crate::tor::TorWebSocketTransport;
|
||||
use crate::wallet::Wallet;
|
||||
use crate::wallet::types::WalletTask;
|
||||
|
||||
@@ -59,6 +59,19 @@ const LOOKBACK_SECS: i64 = 3 * 86_400;
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Send dispatch timeout.
|
||||
const SEND_TIMEOUT: Duration = Duration::from_secs(40);
|
||||
/// Money-path safety: total budget to read-back-confirm a dispatched wrap on a
|
||||
/// relay the recipient reads (a positive delivery proof — a transport-write
|
||||
/// success alone is not). The confirm retries across transient transport drops
|
||||
/// within this budget; on exhaustion the send is treated as sent-PENDING (the tx
|
||||
/// waits for S2 / expiry), NOT a hard failure — see the confirm loop in
|
||||
/// `dispatch_dm` for why a hard failure here would trigger duplicate re-dispatch.
|
||||
const CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Per-attempt read-back timeout while confirming (short, so one dead relay
|
||||
/// doesn't consume the whole confirm budget in a single poll).
|
||||
const CONFIRM_POLL: Duration = Duration::from_secs(8);
|
||||
/// Gap between confirmation polls — the wrap may still be egressing right after
|
||||
/// the transport returns "sent".
|
||||
const CONFIRM_GAP: Duration = Duration::from_secs(3);
|
||||
/// Rate limit for incoming messages per known contact (events/hour).
|
||||
const RATE_CONTACT_PER_HOUR: usize = 30;
|
||||
/// Rate limit for incoming messages per unknown sender (events/hour).
|
||||
@@ -588,43 +601,108 @@ impl NostrService {
|
||||
) -> Result<String, String> {
|
||||
let sent = if v3 {
|
||||
let wrap = wrapv3::wrap(&self.keys, &receiver, content, tags)?;
|
||||
tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(urls, &wrap)).await
|
||||
tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(urls.clone(), &wrap)).await
|
||||
} else {
|
||||
tokio::time::timeout(
|
||||
SEND_TIMEOUT,
|
||||
client.send_private_msg_to(urls, receiver, content, tags),
|
||||
client.send_private_msg_to(urls.clone(), receiver, content, tags),
|
||||
)
|
||||
.await
|
||||
};
|
||||
let res = sent
|
||||
.map_err(|_| "send timeout".to_string())?
|
||||
.map_err(|e| format!("send failed: {e}"))?;
|
||||
Ok(res.val.to_hex())
|
||||
let event_id = res.val;
|
||||
|
||||
// DELIVERY CONFIRM (money-path safety), reconnect-resilient. `send_*_to`
|
||||
// returned success the moment the wrap was accepted for delivery to the
|
||||
// relays — that IS write-level evidence, but not proof a relay the RECIPIENT
|
||||
// reads has stored it. Confirm the way the recipient's inbox retrieves it:
|
||||
// query {kinds:[1059], "#p":[receiver]} pinned to THIS wrap's id, over the
|
||||
// SAME target set — which now always includes our own advertised relays
|
||||
// (the shared-relay floor the recipient also reads; see `send_targets`).
|
||||
// First-event-wins via `stream_events_from` returns the instant ANY
|
||||
// targeted relay serves the wrap, so the spinner clears as fast as it lands.
|
||||
// The loop retries across transient transport drops within the budget
|
||||
// (arti rebuilds circuits during the CONFIRM_GAP sleeps), so a flapping
|
||||
// onion doesn't defeat a wrap that actually landed.
|
||||
//
|
||||
// Two outcomes, NEVER a hard failure here (we already hold write-evidence):
|
||||
// * confirmed -> Ok, report Sent (strongest evidence).
|
||||
// * unconfirmed within the budget -> Ok as sent-PENDING, logged. Over a
|
||||
// flapping onion the read-back can't complete even though the write
|
||||
// landed; a hard error would mark the tx SendFailed, which `reconcile`
|
||||
// re-dispatches -> DUPLICATE wraps + a stuck "sending" spinner. Instead
|
||||
// the tx moves to AwaitingS2 (which `reconcile` never re-dispatches) and
|
||||
// the normal S2-wait / expiry path resolves it. Only a genuine send
|
||||
// FAILURE (the `send_*_to` error above, BEFORE this loop) ever
|
||||
// re-dispatches — so we never claim Sent with zero evidence.
|
||||
use futures::StreamExt;
|
||||
let confirm_filter = Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(receiver)
|
||||
.id(event_id)
|
||||
.limit(1);
|
||||
let confirm_deadline = tokio::time::Instant::now() + CONFIRM_TIMEOUT;
|
||||
loop {
|
||||
if let Ok(mut stream) = client
|
||||
.stream_events_from(urls.clone(), confirm_filter.clone(), CONFIRM_POLL)
|
||||
.await && stream.next().await.is_some()
|
||||
{
|
||||
return Ok(event_id.to_hex());
|
||||
}
|
||||
if tokio::time::Instant::now() >= confirm_deadline {
|
||||
warn!(
|
||||
"nostr: wrap {} dispatched but not read-back-confirmed within {}s \
|
||||
(likely a transient transport drop); treating as sent-pending — \
|
||||
tx waits for S2 / expiry, NOT re-dispatched",
|
||||
event_id.to_hex(),
|
||||
CONFIRM_TIMEOUT.as_secs()
|
||||
);
|
||||
return Ok(event_id.to_hex());
|
||||
}
|
||||
tokio::time::sleep(CONFIRM_GAP).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish targets for one DM plus the negotiated NIP-44 v3 capability:
|
||||
/// the recipient's advertised 10050 inbox (capped at 3) when they publish
|
||||
/// one; otherwise the pragmatic fallback of nprofile relay hints plus our
|
||||
/// own relay set (most Goblin peers share the Goblin relay). No extra
|
||||
/// targets beyond that — wider fan-out adds metadata surface, not
|
||||
/// deliverability. `true` means the recipient's 10050 `encryption` tag
|
||||
/// advertises `nip44_v3`; no tag (or no 10050 at all) = v2 only.
|
||||
/// one, PLUS the nprofile relay hints, ALWAYS unioned with our OWN advertised
|
||||
/// set. `true` means the recipient's 10050 `encryption` tag advertises
|
||||
/// `nip44_v3`; no tag (or no 10050 at all) = v2 only.
|
||||
///
|
||||
/// MONEY-PATH SAFETY: we must NEVER return a target set that excludes our own
|
||||
/// relays. Our advertised set always begins with the shared relay floor
|
||||
/// (`relay.floonet.dev`, `DEFAULT_RELAYS[0]`, pinned first by
|
||||
/// `ensure_advertised_set`), and every Goblin peer's inbox subscription
|
||||
/// (`{kinds:[1059], "#p":[them]}`, see the service loop) likewise reads that
|
||||
/// same shared relay. The prior code early-returned ONLY the recipient's
|
||||
/// cached 10050 set: if that cache was stale or hint-seeded and missed the
|
||||
/// shared relay, the wrap was published solely to relays the recipient never
|
||||
/// reads — delivered nowhere while the sender saw success. Unioning our own
|
||||
/// set guarantees the wrap always lands on a relay both parties read, even
|
||||
/// when the recipient's cached relays are wrong.
|
||||
async fn send_targets(
|
||||
&self,
|
||||
client: &Client,
|
||||
receiver: &PublicKey,
|
||||
relay_hints: &[String],
|
||||
) -> (Vec<String>, bool) {
|
||||
let (urls, v3) = self.fetch_dm_relays(client, receiver).await;
|
||||
if !urls.is_empty() {
|
||||
return (urls, v3);
|
||||
}
|
||||
let (recipient_relays, v3) = self.fetch_dm_relays(client, receiver).await;
|
||||
let mut urls: Vec<String> = vec![];
|
||||
for r in relay_hints {
|
||||
if !urls.contains(r) {
|
||||
urls.push(r.clone());
|
||||
// The recipient's own advertised inbox first (best delivery target when
|
||||
// fresh), then any nprofile relay hints...
|
||||
for r in recipient_relays
|
||||
.into_iter()
|
||||
.chain(relay_hints.iter().cloned())
|
||||
{
|
||||
if !urls.contains(&r) {
|
||||
urls.push(r);
|
||||
}
|
||||
}
|
||||
// ...and ALWAYS our own advertised set (the shared-relay floor). This is
|
||||
// the load-bearing union: it never lets a stale recipient cache exclude
|
||||
// the relay both parties actually read.
|
||||
for r in self.relays() {
|
||||
if !urls.contains(&r) {
|
||||
urls.push(r);
|
||||
@@ -828,32 +906,27 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
|
||||
let client = Client::builder()
|
||||
.signer(svc.keys.clone())
|
||||
.websocket_transport(NymWebSocketTransport)
|
||||
.websocket_transport(TorWebSocketTransport)
|
||||
.build();
|
||||
// Wait for the in-process Nym mixnet tunnel before any network work
|
||||
// (relay dials, pool refresh, NIP-11 probes). `warm_up()` starts it at
|
||||
// launch, but a fast wallet-open can beat the cold mixnet bootstrap — and
|
||||
// dialing before it's up drops every relay into nostr-sdk's backing-off
|
||||
// reconnect, leaving the wallet on "Connecting…" long after the mixnet is
|
||||
// actually ready. Once it's warm this returns immediately.
|
||||
for i in 0..60u32 {
|
||||
if crate::nym::is_ready() {
|
||||
// Wait for the embedded Tor client before any network work (relay dials, pool
|
||||
// refresh, NIP-11 probes). `warm_up()` starts it at launch, but a fast
|
||||
// wallet-open can beat the cold Tor bootstrap — and dialing before it's up
|
||||
// drops every relay into nostr-sdk's backing-off reconnect, leaving the wallet
|
||||
// on "Connecting…" long after Tor is actually ready. Once it's bootstrapped
|
||||
// this returns immediately.
|
||||
for i in 0..240u32 {
|
||||
if crate::tor::is_ready() {
|
||||
if i > 0 {
|
||||
info!(
|
||||
"nostr: Nym tunnel ready after ~{}ms, dialing relays",
|
||||
i * 500
|
||||
);
|
||||
info!("nostr: Tor ready after ~{}ms, dialing relays", i * 500);
|
||||
}
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
// We are now a relay consumer: arm nymproc's relay-reachability governance of
|
||||
// exit health for our lifetime, so a DNS-ok-but-relay-dead exit gets
|
||||
// condemned. Disarmed when the loop exits (see below), so plain HTTP-only
|
||||
// usage of the tunnel never condemns an otherwise-healthy exit.
|
||||
crate::nym::set_relay_consumer(true);
|
||||
// Refresh the relay candidate pool cache (gist over Nym) when stale.
|
||||
// We are now a relay consumer (API parity with the old transport; inert under
|
||||
// Tor, which manages its own circuit health). Disarmed when the loop exits.
|
||||
crate::tor::set_relay_consumer(true);
|
||||
// Refresh the relay candidate pool cache (gist over Tor) when stale.
|
||||
tokio::spawn(crate::nostr::pool::refresh_if_stale());
|
||||
// Select this identity's advertised relay set if it hasn't one yet.
|
||||
ensure_advertised_set(&svc).await;
|
||||
@@ -864,59 +937,19 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
svc.npub(),
|
||||
relays
|
||||
);
|
||||
// Prewarm mix-dns for the hosts we're about to (or will soon) hit — the
|
||||
// relays being dialed, the NIP-05 name authority (Claim username), and the
|
||||
// price API — so those resolutions are already cached by the time the user
|
||||
// acts, rather than each paying a cold mixnet round trip inline. The node host
|
||||
// is NOT here — it never rides the mixnet.
|
||||
//
|
||||
// Unlike before this no longer silently SKIPS when the tunnel isn't up yet
|
||||
// (the cold-start case that used to leave the first relay dial to a cold DoT
|
||||
// round trip): it WAITS for the tunnel, prewarms, then keeps the entries hot
|
||||
// by re-prewarming on a cadence below the DNS cache TTL floor, so known/stable
|
||||
// hosts are refreshed in the background before they can expire.
|
||||
{
|
||||
let mut hosts: Vec<String> = relays
|
||||
.iter()
|
||||
.filter_map(|r| nostr_sdk::Url::parse(r).ok())
|
||||
.filter_map(|u| u.host_str().map(|h| h.to_string()))
|
||||
.collect();
|
||||
// The name authority, both from this service's config and the process-wide
|
||||
// configured home domain (they're normally the same; dedup below folds it).
|
||||
hosts.push(svc.config.read().home_domain());
|
||||
hosts.push(crate::nostr::nip05::home_domain());
|
||||
hosts.push("api.coingecko.com".to_string());
|
||||
hosts.retain(|h| !h.is_empty());
|
||||
hosts.sort();
|
||||
hosts.dedup();
|
||||
tokio::spawn(async move {
|
||||
// Wait out the cold start rather than skipping the prewarm entirely.
|
||||
let Some(tunnel) = crate::nym::nymproc::wait_for_tunnel(Duration::from_secs(60)).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
crate::nym::dns::prewarm(&tunnel, &hosts).await;
|
||||
// Keep the entries warm: re-prewarm every 45s (below the 60s TTL
|
||||
// floor) so a stable host never expires out of the cache between
|
||||
// uses. Picks up the current tunnel each cycle, so it survives exit
|
||||
// reselects.
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(45)).await;
|
||||
if let Some(t) = crate::nym::nymproc::tunnel() {
|
||||
crate::nym::dns::prewarm(&t, &hosts).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// (No DNS prewarm here: unlike the old mixnet path, arti resolves relay and
|
||||
// HTTP hostnames internally as part of the circuit dial — there is no
|
||||
// separate in-tunnel DoT round trip to warm. The node host was never on this
|
||||
// path and still isn't — it never rides the private transport.)
|
||||
for relay in &relays {
|
||||
if let Err(e) = client.add_relay(relay.clone()).await {
|
||||
warn!("nostr: add relay {relay} failed: {e}");
|
||||
}
|
||||
}
|
||||
// The tunnel generation these relays are being dialed on. If the exit is
|
||||
// later reselected (generation bumped by nymproc), the status loop drops
|
||||
// these now-dead sockets and re-dials through the fresh tunnel.
|
||||
let mut dial_gen = crate::nym::tunnel_generation();
|
||||
// The transport generation these relays are being dialed on. With Tor this is
|
||||
// stable (arti rebuilds circuits transparently), so the reselect-driven
|
||||
// re-dial below simply never fires — the status loop still re-checks liveness.
|
||||
let mut dial_gen = crate::tor::tunnel_generation();
|
||||
let connect_started = std::time::Instant::now();
|
||||
client.connect().await;
|
||||
{
|
||||
@@ -958,7 +991,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
// window as soon as the exit is proven to carry relay traffic,
|
||||
// independent of the up-to-30s catch-up fetch below (a slow
|
||||
// catch-up must not get a good exit wrongly condemned).
|
||||
crate::nym::report_relay_live(report_gen);
|
||||
crate::tor::report_relay_live(report_gen);
|
||||
return;
|
||||
}
|
||||
if svc_probe.shutdown.load(Ordering::SeqCst)
|
||||
@@ -1038,7 +1071,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
// actual connected+subscribed relay on THIS tunnel generation, not merely a
|
||||
// warm tunnel — and so nymproc's relay-readiness window closes successfully.
|
||||
if connected {
|
||||
crate::nym::report_relay_live(dial_gen);
|
||||
crate::tor::report_relay_live(dial_gen);
|
||||
}
|
||||
|
||||
let mut notifications = client.notifications();
|
||||
@@ -1081,7 +1114,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
// subscription — a reselect thus transparently restores
|
||||
// receive+send. (An individual relay bounce with the exit still
|
||||
// healthy is left to nostr-sdk's own auto-reconnect + resubscribe.)
|
||||
let generation = crate::nym::tunnel_generation();
|
||||
let generation = crate::tor::tunnel_generation();
|
||||
if generation != dial_gen {
|
||||
info!("nostr: tunnel reselected (gen {dial_gen} -> {generation}); re-dialing relays over the new exit");
|
||||
redial_on_new_tunnel(&client, &relays, &filter).await;
|
||||
@@ -1093,9 +1126,9 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
// a live relay closes/keeps-open nymproc's readiness window; all
|
||||
// relays down for too long condemns the exit and reselects.
|
||||
if connected {
|
||||
crate::nym::report_relay_live(dial_gen);
|
||||
crate::tor::report_relay_live(dial_gen);
|
||||
} else {
|
||||
crate::nym::report_relay_down(dial_gen);
|
||||
crate::tor::report_relay_down(dial_gen);
|
||||
}
|
||||
let now = unix_time();
|
||||
if now - last_heartbeat >= 30 {
|
||||
@@ -1139,7 +1172,7 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
|
||||
// No longer a relay consumer: disarm relay-reachability governance so the
|
||||
// idle tunnel isn't condemned for "no relay" once we stop dialing.
|
||||
crate::nym::set_relay_consumer(false);
|
||||
crate::tor::set_relay_consumer(false);
|
||||
{
|
||||
let mut w_client = svc.client.write();
|
||||
*w_client = None;
|
||||
@@ -1510,8 +1543,20 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, event: Event) {
|
||||
// nostr-sdk path, 0x03 = the nip44 crate (G4); anything else errors cleanly.
|
||||
let unwrapped = match wrapv3::unwrap(&svc.keys, &event).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
Err(e) => {
|
||||
// A gift wrap that reached here is p-tagged to US (both the catch-up
|
||||
// fetch and the live subscription filter name our pubkey), so a decrypt
|
||||
// failure is a wrap ADDRESSED to us that we could not open — most often a
|
||||
// NIP-44 v2/v3 negotiation mismatch or a decrypt bug, i.e. potentially a
|
||||
// real incoming payment. Do NOT silently drop it: surface the failure,
|
||||
// and do NOT mark it processed, so a corrected build can re-attempt the
|
||||
// unwrap on the next catch-up instead of the dedup cache eating the
|
||||
// payment forever. Total unwrap work stays bounded by the global decrypt
|
||||
// ceiling checked above, which is the designed spam guard.
|
||||
warn!(
|
||||
"nostr: gift wrap {wrap_id} addressed to us failed to unwrap: {e}; \
|
||||
leaving unprocessed for retry"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
+9
-9
@@ -22,7 +22,7 @@ use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::nostr::relays::HOME_NIP05_DOMAIN;
|
||||
use crate::nym;
|
||||
use crate::tor;
|
||||
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 = nym::http_request("GET", url, None, vec![]).await?;
|
||||
let body = tor::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 = nym::http_request("GET", url, None, vec![]).await?;
|
||||
let body = tor::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) = nym::http_request("GET", url, None, vec![]).await else {
|
||||
let Some(body) = tor::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 nym::http_request("GET", url, None, vec![]).await {
|
||||
let body = match tor::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) = nym::http_request("POST", url, Some(body), headers).await else {
|
||||
let Some(resp) = tor::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 nym::http_request("DELETE", url, None, headers).await {
|
||||
match tor::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()
|
||||
@@ -328,7 +328,7 @@ pub async fn unregister(server: &str, name: &str, keys: &Keys) -> Result<(), Str
|
||||
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) = nym::http_request_bytes("GET", url, None, vec![]).await?;
|
||||
let (code, raw) = tor::http_request_bytes("GET", url, None, vec![]).await?;
|
||||
if code == 404 {
|
||||
return Some(None);
|
||||
}
|
||||
@@ -347,7 +347,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) = nym::http_request_bytes("GET", url, None, vec![]).await?;
|
||||
let (code, raw) = tor::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;
|
||||
}
|
||||
|
||||
+23
-43
@@ -62,22 +62,13 @@ const MIN_BACKDATE_SECS: u64 = 172_800;
|
||||
/// offline behave exactly like a fresh fetch.
|
||||
const PINNED_POOL: &str = r#"{
|
||||
"version": 1,
|
||||
"updated": "2026-07-02",
|
||||
"notes": "Goblin wallet relay candidate pool. Clients verify each entry locally (NIP-11 probe) before use. Requirements: max_message_length >= 131072, no payment or auth required for writes, tolerates NIP-59 backdating. The optional per-relay 'exit' is that operator's co-located scoped mixnet exit (Recipient address): a MixnetStream the wallet dials directly to reach the relay with no public DNS and no public IPR — the fast money path.",
|
||||
"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-02", "exit": "EqbUPt7aYkar2CTmjBVnyWaKzb2WT8NdojUGXU4mrfNG.AF5YCD8hgEUqByamrPqZz72h7GE599LbqQrhaew9bBip@HfyUPUv4z8uMQoZYuZGMWf6oe2vaKBVPrfgHk6WvwFPe" },
|
||||
{ "url": "wss://relay.primal.net", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.damus.io", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://nos.lol", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.0xchat.com", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://offchain.pub", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.snort.social", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://nostr.mom", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://nostr.oxtr.dev", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://relay.nostr.net", "roles": ["dm"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://purplepag.es", "roles": ["discovery"], "vetted": "2026-07-01" },
|
||||
{ "url": "wss://indexer.coracle.social", "roles": ["discovery"], "vetted": "2026-07-01" }
|
||||
{ "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" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
@@ -202,12 +193,6 @@ pub fn load() -> RelayPool {
|
||||
std::fs::read_to_string(cache_path())
|
||||
.ok()
|
||||
.and_then(|raw| RelayPool::parse(&raw))
|
||||
// A cache written by a pre-exit build parses fine but hides the
|
||||
// scoped-exit money path (and the current primary relay) for up to
|
||||
// CACHE_MAX_AGE_SECS after an app update — relay connects then ride
|
||||
// the slow public-IPR path for days. The pinned pool is newer than
|
||||
// any exit-less file, so prefer it until the next gist refresh.
|
||||
.filter(RelayPool::has_exit)
|
||||
.unwrap_or_else(|| RelayPool::parse(PINNED_POOL).expect("pinned pool parses"))
|
||||
}
|
||||
|
||||
@@ -225,18 +210,11 @@ pub async fn refresh_if_stale() {
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.map(|age| age.as_secs() < CACHE_MAX_AGE_SECS)
|
||||
.unwrap_or(false)
|
||||
// An exit-less cache predates the current pool shape (see `load`,
|
||||
// which already ignores it) — replace it now instead of serving the
|
||||
// pinned fallback for the rest of the file's 7 days.
|
||||
&& std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|raw| RelayPool::parse(&raw))
|
||||
.is_some_and(|p| p.has_exit());
|
||||
.unwrap_or(false);
|
||||
if fresh {
|
||||
return;
|
||||
}
|
||||
let Some(raw) = crate::nym::http_request("GET", POOL_URL.to_string(), None, vec![]).await
|
||||
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;
|
||||
@@ -305,7 +283,7 @@ pub async fn probe(url: &str) -> bool {
|
||||
let headers = vec![("Accept".to_string(), "application/nostr+json".to_string())];
|
||||
let ok = tokio::time::timeout(
|
||||
PROBE_TIMEOUT,
|
||||
crate::nym::http_request("GET", http_url, None, headers),
|
||||
crate::tor::http_request("GET", http_url, None, headers),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
@@ -380,28 +358,30 @@ mod tests {
|
||||
let pool = RelayPool::parse(PINNED_POOL).expect("pinned pool must parse");
|
||||
assert_eq!(pool.version, 1);
|
||||
assert_eq!(pool.min_message_length, MIN_MESSAGE_LENGTH);
|
||||
assert_eq!(pool.relays.len(), 12);
|
||||
// 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(), 10);
|
||||
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 carries both roles; the two indexers
|
||||
// are discovery-only.
|
||||
assert_eq!(disc.len(), 3);
|
||||
assert!(disc.contains(&"wss://purplepag.es".to_string()));
|
||||
assert!(disc.contains(&"wss://indexer.coracle.social".to_string()));
|
||||
// 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 advertises the money-path relay's co-located scoped
|
||||
// exit (the .AF floonet-mixexit) so it bootstraps OFFLINE, before any
|
||||
// network; every other relay is exit-less (reached over the tunnel).
|
||||
// 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_some());
|
||||
assert!(pinned.exit_for("wss://nos.lol").is_none());
|
||||
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(
|
||||
|
||||
+12
-3
@@ -14,11 +14,20 @@
|
||||
|
||||
//! Default relay set and relay list helpers.
|
||||
|
||||
/// Default DM relays: the Floonet relay plus large public relays for redundancy.
|
||||
/// 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.
|
||||
pub const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.floonet.dev",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.0xchat.com",
|
||||
"wss://offchain.pub",
|
||||
];
|
||||
|
||||
/// Default NIP-05 identity server.
|
||||
|
||||
+64
-17
@@ -474,30 +474,77 @@ const PROBE_ADDRS: [SocketAddr; 2] = [
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 443),
|
||||
];
|
||||
/// Per-target connect wait; a mixnet TCP handshake is a few seconds.
|
||||
/// 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 a tunnel is declared dead. A single lost mixnet packet
|
||||
/// mid-handshake should not condemn a whole tunnel, so an all-miss round is
|
||||
/// retried once (mirrors the DoT/DoH round loop). Only a tunnel that reaches
|
||||
/// NEITHER stable target across BOTH rounds is DEAD — this is what stops a
|
||||
/// 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;
|
||||
|
||||
/// End-to-end exit-liveness probe: try to open a TCP connection THROUGH the tunnel
|
||||
/// to any of a few stable public addresses (raced, retried a round) and drop the
|
||||
/// winner immediately. Because TCP over the mixnet RETRANSMITS, a single lost
|
||||
/// datagram does not spuriously fail a healthy exit; racing several targets over
|
||||
/// two rounds additionally absorbs a momentarily slow single path — together they
|
||||
/// stop the false-DEAD reselect churn the old single-target probe caused. Proves
|
||||
/// the full path (mixnet → IPR exit → internet) and keeps the gateway/IPR session
|
||||
/// from idling out. Used by the fresh-tunnel gate and the watchdog keepalive.
|
||||
/// 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 {
|
||||
for round in 0..PROBE_ROUNDS {
|
||||
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(PROBE_TIMEOUT, tunnel.tcp_connect(addr)).await,
|
||||
tokio::time::timeout(timeout, tunnel.tcp_connect(addr)).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
});
|
||||
@@ -508,11 +555,11 @@ pub async fn probe(tunnel: &Tunnel) -> bool {
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
"probe: no stable target reachable through tunnel (round {}/{PROBE_ROUNDS})",
|
||||
"probe: no stable target reachable through tunnel (round {}/{rounds})",
|
||||
round + 1
|
||||
);
|
||||
}
|
||||
debug!("probe: tunnel failed liveness — reached no stable target in {PROBE_ROUNDS} rounds");
|
||||
debug!("probe: tunnel failed liveness — reached no stable target in {rounds} rounds");
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
+41
-24
@@ -45,7 +45,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{error, info, warn};
|
||||
use log::{debug, error, info, warn};
|
||||
use parking_lot::RwLock;
|
||||
use smolmix::{Recipient, Tunnel};
|
||||
|
||||
@@ -332,11 +332,18 @@ fn run_tunnel() {
|
||||
// publishing such a tunnel would blackhole every consumer
|
||||
// until the watchdog caught it minutes later. Re-select
|
||||
// immediately instead. (This is a CHEAP early signal; relay
|
||||
// reachability below is the authoritative one.)
|
||||
if !probe_fresh(&tunnel).await {
|
||||
// reachability below is the authoritative one.) Uses the FAST
|
||||
// fresh-gate budget (~10s worst case) — NOT the patient
|
||||
// established-tunnel probe (~32s doubled here before) — so a
|
||||
// dead fresh exit no longer dominates the cold-start tail; see
|
||||
// `dns::probe_fresh`.
|
||||
let probe_started = Instant::now();
|
||||
let alive = super::dns::probe_fresh(&tunnel).await;
|
||||
let probe_ms = probe_started.elapsed().as_millis();
|
||||
if !alive {
|
||||
warn!(
|
||||
"[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe after {}ms \
|
||||
(attempt {attempt}); {}",
|
||||
"[timing] nym: DEAD EXIT — fresh {} tunnel failed liveness probe in {probe_ms}ms \
|
||||
({}ms total incl. build; attempt {attempt}); {}",
|
||||
choice.label(),
|
||||
started.elapsed().as_millis(),
|
||||
match choice {
|
||||
@@ -503,18 +510,6 @@ fn run_tunnel() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Two attempts of the (TCP, retransmitting) liveness probe before rejecting a
|
||||
/// fresh tunnel — one transient hiccup while the exit settles must not condemn
|
||||
/// an otherwise healthy exit.
|
||||
async fn probe_fresh(tunnel: &smolmix::Tunnel) -> bool {
|
||||
for _ in 0..2 {
|
||||
if super::dns::probe(tunnel).await {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Exit-liveness keepalive period and the consecutive probe failures that
|
||||
/// declare death (the probe is now a TCP connect through the tunnel, not UDP DNS).
|
||||
const KEEPALIVE_PERIOD: Duration = Duration::from_secs(60);
|
||||
@@ -912,14 +907,16 @@ async fn connect_gateway_racing(
|
||||
|
||||
// Spawn both so the loser can be aborted cleanly. `cfg` is `Copy`, so each task
|
||||
// gets the identical anonymity config.
|
||||
let race_started = Instant::now();
|
||||
let mut a = tokio::spawn(connect_one(cfg));
|
||||
let mut b = tokio::spawn(connect_one(cfg));
|
||||
debug!("[timing] nym: gateway race START — 2 ephemeral draws, first up wins");
|
||||
|
||||
// Whichever finishes first; keep `other` to reap (on a win) or fall back to (if
|
||||
// the first draw errored).
|
||||
let (first, other) = tokio::select! {
|
||||
r = &mut a => (r, b),
|
||||
r = &mut b => (r, a),
|
||||
// the first draw errored). `winner` tags WHICH draw finished first.
|
||||
let (first, other, winner) = tokio::select! {
|
||||
r = &mut a => (r, b, 'A'),
|
||||
r = &mut b => (r, a, 'B'),
|
||||
};
|
||||
// A JoinError (task panic) folds into an error so `other` still gets its turn.
|
||||
let first = first.unwrap_or_else(|e| {
|
||||
@@ -932,12 +929,26 @@ async fn connect_gateway_racing(
|
||||
match first {
|
||||
// First to finish connected — it WINS. Reap the loser off the hot path.
|
||||
Ok(client) => {
|
||||
info!(
|
||||
"[timing] nym: gateway race WON by draw {winner} in {}ms; reaping loser off the hot path",
|
||||
race_started.elapsed().as_millis()
|
||||
);
|
||||
other.abort();
|
||||
tokio::spawn(async move {
|
||||
// If the loser connected before the abort landed, disconnect it so
|
||||
// no live gateway session leaks; a pending connect was just dropped.
|
||||
if let Ok(Ok(loser)) = other.await {
|
||||
loser.disconnect().await;
|
||||
match other.await {
|
||||
Ok(Ok(loser)) => {
|
||||
debug!(
|
||||
"[timing] nym: gateway race loser had connected before abort — \
|
||||
disconnecting so no gateway session leaks"
|
||||
);
|
||||
loser.disconnect().await;
|
||||
}
|
||||
_ => debug!(
|
||||
"[timing] nym: gateway race loser still pending at reap — dropped \
|
||||
(no session to close)"
|
||||
),
|
||||
}
|
||||
});
|
||||
Ok(client)
|
||||
@@ -945,7 +956,13 @@ async fn connect_gateway_racing(
|
||||
// First draw failed — a lone client has no dead-draw tail, so just await the
|
||||
// survivor; if it fails too, surface an error and `run_tunnel` re-selects.
|
||||
Err(first_err) => match other.await {
|
||||
Ok(Ok(client)) => Ok(client),
|
||||
Ok(Ok(client)) => {
|
||||
info!(
|
||||
"[timing] nym: gateway race — draw {winner} errored, survivor connected in {}ms",
|
||||
race_started.elapsed().as_millis()
|
||||
);
|
||||
Ok(client)
|
||||
}
|
||||
Ok(Err(second_err)) => {
|
||||
warn!(
|
||||
"[timing] nym: both raced gateway connects failed \
|
||||
|
||||
@@ -266,4 +266,200 @@ mod tests {
|
||||
"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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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);
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,317 +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.
|
||||
|
||||
//! LIVE two-wallet end-to-end payment over the Floonet path — over the shared
|
||||
//! exit-backed primary relay, CROSS-NODE. Two real Goblin wallets restored from
|
||||
//! mainnet mnemonics (seeds via env, NEVER a file) both run on the shipped
|
||||
//! default relay (`wss://relay.floonet.dev`, each pinned via its own
|
||||
//! `nostr.toml`) but on DIFFERENT Grin nodes (A on grincoin.org, B on
|
||||
//! main.gri.mw). One sends a real gift-wrapped Grin payment to the other,
|
||||
//! asynchronously through the relay. Proves the whole money path a phone would
|
||||
//! use, plus the outbox model: the sender publishes the wrap to the RECIPIENT's
|
||||
//! advertised (kind 10050) relay, and settlement posts through two independent
|
||||
//! nodes.
|
||||
//! mixnet -> exit -> gift wrap -> S2 -> finalize -> post.
|
||||
//!
|
||||
//! Ignored by default (real mainnet funds + a full recovery scan). Run:
|
||||
//! GOBLIN_E2E_SEED_A="word ..." GOBLIN_E2E_SEED_B="word ..." \
|
||||
//! cargo test --lib wallet::e2e::tests::two_goblins_pay_over_floonet -- --ignored --nocapture
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use grin_util::types::ZeroingString;
|
||||
|
||||
use crate::nostr::{Contact, NostrConfig, NostrSendStatus};
|
||||
use crate::wallet::types::{ConnectionMethod, PhraseMode, WalletTask};
|
||||
use crate::wallet::{ConnectionsConfig, ExternalConnection, Mnemonic, Wallet};
|
||||
|
||||
/// 0.1 GRIN, in nanograin. Small on purpose (mainnet, real funds).
|
||||
const AMOUNT: u64 = 100_000_000;
|
||||
/// Wallet A's mainnet node (recovery scan + tx post).
|
||||
const NODE_A: &str = "https://grincoin.org";
|
||||
/// Wallet B's mainnet node — a DIFFERENT operator, so the payment settles
|
||||
/// across two independent nodes.
|
||||
const NODE_B: &str = "https://main.gri.mw";
|
||||
/// Wallet A's relay (pinned via its nostr.toml, advertised in its 10050).
|
||||
/// The new primary money-path relay, reached over its co-located scoped exit.
|
||||
const RELAY_A: &str = "wss://relay.floonet.dev";
|
||||
/// Wallet B's relay — the SAME shared exit-backed primary as A (how the
|
||||
/// shipped product works: every Goblin wallet defaults to relay.floonet.dev).
|
||||
/// Both reach it over the co-located scoped exit, so the gift-wrap round-trip
|
||||
/// rides the fast money path end to end. Nodes still differ (below), so the
|
||||
/// payment still settles across two independent Grin nodes.
|
||||
const RELAY_B: &str = "wss://relay.floonet.dev";
|
||||
|
||||
/// Build + open a wallet from a 24-word mnemonic on its own external node
|
||||
/// and its own single-relay nostr.toml override.
|
||||
fn open_wallet(
|
||||
name: &str,
|
||||
phrase: &str,
|
||||
pw: &ZeroingString,
|
||||
conn_id: i64,
|
||||
node_url: &str,
|
||||
relay: &str,
|
||||
) -> Wallet {
|
||||
let mut m = Mnemonic::default();
|
||||
m.set_mode(PhraseMode::Import);
|
||||
m.import(&ZeroingString::from(phrase));
|
||||
assert!(
|
||||
m.valid(),
|
||||
"{name}: mnemonic did not validate (bad seed words?)"
|
||||
);
|
||||
let conn = ConnectionMethod::External(conn_id, node_url.to_string());
|
||||
let w = Wallet::create(&name.to_string(), pw, &m, &conn)
|
||||
.unwrap_or_else(|e| panic!("{name}: wallet create failed: {e}"));
|
||||
// Pin this wallet to a single relay BEFORE open(): init_nostr loads
|
||||
// nostr.toml from the wallet data dir on open, and a `relays` override
|
||||
// both drives the client's relay set and is advertised as the wallet's
|
||||
// kind 10050 DM inbox (see NostrService::relays / publish_identity).
|
||||
let wallet_dir = PathBuf::from(w.get_config().get_data_path());
|
||||
let mut nostr_cfg = NostrConfig::load(wallet_dir.clone());
|
||||
nostr_cfg.set_relays(vec![relay.to_string()]);
|
||||
println!(
|
||||
"[e2e] {name}: node={node_url} relay={relay} (nostr.toml at {})",
|
||||
wallet_dir.join(NostrConfig::FILE_NAME).display()
|
||||
);
|
||||
w.open(pw.clone())
|
||||
.unwrap_or_else(|e| panic!("{name}: wallet open failed: {e}"));
|
||||
w
|
||||
}
|
||||
|
||||
/// The persisted form of "added this payee from their nprofile": a contact
|
||||
/// carrying their DM relay, so payment routing (send_targets -> fetch_dm_relays)
|
||||
/// uses that relay directly instead of blind kind-10050 discovery over the
|
||||
/// exit-less indexers. BOTH legs of a cross-relay payment need this seeded.
|
||||
fn contact_with_relay(npub_hex: &str, relay: &str) -> Contact {
|
||||
Contact {
|
||||
ver: 1,
|
||||
npub: npub_hex.to_string(),
|
||||
petname: None,
|
||||
nip05: None,
|
||||
nip05_verified_at: None,
|
||||
relays: vec![relay.to_string()],
|
||||
nip44_v3: false,
|
||||
hue: 0,
|
||||
unknown: false,
|
||||
added_at: 0,
|
||||
last_paid_at: None,
|
||||
blocked: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll `cond` until true or `secs` elapse; log progress via `label`.
|
||||
fn wait_until(label: &str, secs: u64, mut cond: impl FnMut() -> bool) -> bool {
|
||||
let start = Instant::now();
|
||||
let mut last = 0u64;
|
||||
while start.elapsed() < Duration::from_secs(secs) {
|
||||
if cond() {
|
||||
println!("[e2e] {label}: OK in {}s", start.elapsed().as_secs());
|
||||
return true;
|
||||
}
|
||||
let el = start.elapsed().as_secs();
|
||||
if el >= last + 15 {
|
||||
last = el;
|
||||
println!("[e2e] {label}: waiting... {el}s");
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
println!("[e2e] {label}: TIMEOUT after {secs}s");
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn two_goblins_pay_over_floonet() {
|
||||
let seed_a = std::env::var("GOBLIN_E2E_SEED_A").unwrap_or_default();
|
||||
let seed_b = std::env::var("GOBLIN_E2E_SEED_B").unwrap_or_default();
|
||||
if seed_a.trim().is_empty() || seed_b.trim().is_empty() {
|
||||
println!("[e2e] SKIP: set GOBLIN_E2E_SEED_A and GOBLIN_E2E_SEED_B");
|
||||
return;
|
||||
}
|
||||
|
||||
// Isolate wallet + nym state under a throwaway HOME. MUST precede any
|
||||
// grim call (Settings roots at $HOME/.goblin on first deref).
|
||||
let home = std::env::var("GOBLIN_E2E_HOME").unwrap_or_else(|_| {
|
||||
std::env::temp_dir()
|
||||
.join("goblin-e2e-home")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
unsafe {
|
||||
std::env::set_var("HOME", &home);
|
||||
}
|
||||
println!("[e2e] HOME = {home}");
|
||||
|
||||
// The app installs these at startup (src/lib.rs); a bare test must too.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
crate::nym::warm_up();
|
||||
assert!(
|
||||
wait_until("nym tunnel is_ready", 180, crate::nym::is_ready),
|
||||
"nym tunnel never came up"
|
||||
);
|
||||
|
||||
// Register a SEPARATE mainnet node per wallet. ExternalConnection ids
|
||||
// are unix seconds, and add_ext_conn dedupes on id — two conns built in
|
||||
// the same second would collide — so bump B's id explicitly.
|
||||
let node_a = ExternalConnection::new(NODE_A.to_string(), Some("grin".to_string()), None);
|
||||
let conn_a = node_a.id;
|
||||
ConnectionsConfig::add_ext_conn(node_a);
|
||||
let mut node_b =
|
||||
ExternalConnection::new(NODE_B.to_string(), Some("grin".to_string()), None);
|
||||
node_b.id = conn_a + 1;
|
||||
let conn_b = node_b.id;
|
||||
ConnectionsConfig::add_ext_conn(node_b);
|
||||
|
||||
let strip = |s: &str| {
|
||||
s.trim_start_matches("https://")
|
||||
.trim_start_matches("wss://")
|
||||
.to_string()
|
||||
};
|
||||
println!(
|
||||
"[e2e] A: node={} relay={} | B: node={} relay={}",
|
||||
strip(NODE_A),
|
||||
strip(RELAY_A),
|
||||
strip(NODE_B),
|
||||
strip(RELAY_B)
|
||||
);
|
||||
|
||||
let pw = ZeroingString::from("e2e-test-pass");
|
||||
|
||||
println!("[e2e] opening wallet A...");
|
||||
let a = open_wallet("goblin-e2e-a", seed_a.trim(), &pw, conn_a, NODE_A, RELAY_A);
|
||||
// Wallet id = unix seconds; two creates in the same second collide.
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
println!("[e2e] opening wallet B...");
|
||||
let b = open_wallet("goblin-e2e-b", seed_b.trim(), &pw, conn_b, NODE_B, RELAY_B);
|
||||
|
||||
// Nostr services connect, each to its OWN relay (over the exit).
|
||||
let a_svc = a.nostr_service().expect("A nostr service");
|
||||
let b_svc = b.nostr_service().expect("B nostr service");
|
||||
let t_a = Instant::now();
|
||||
assert!(
|
||||
wait_until("A nostr connected", 240, || a_svc.is_connected()),
|
||||
"A never connected to its relay ({RELAY_A})"
|
||||
);
|
||||
println!("[e2e] A connected in {}s", t_a.elapsed().as_secs());
|
||||
let t_b = Instant::now();
|
||||
assert!(
|
||||
wait_until("B nostr connected", 240, || b_svc.is_connected()),
|
||||
"B never connected to its relay ({RELAY_B})"
|
||||
);
|
||||
println!("[e2e] B connected in {}s", t_b.elapsed().as_secs());
|
||||
println!("[e2e] A effective relays = {:?}", a_svc.relays());
|
||||
println!("[e2e] B effective relays = {:?}", b_svc.relays());
|
||||
assert_eq!(
|
||||
a_svc.relays(),
|
||||
vec![RELAY_A.to_string()],
|
||||
"A's relay override did not take"
|
||||
);
|
||||
assert_eq!(
|
||||
b_svc.relays(),
|
||||
vec![RELAY_B.to_string()],
|
||||
"B's relay override did not take"
|
||||
);
|
||||
println!("[e2e] A npub = {}", a_svc.npub());
|
||||
println!("[e2e] B npub = {}", b_svc.npub());
|
||||
|
||||
// Pre-seed each wallet's contact store with the other (npub + DM relay) —
|
||||
// the realistic "added the payee from their nprofile" path. Payment
|
||||
// routing then uses the cached DM relay directly, so BOTH legs cross
|
||||
// relays deterministically (A -> B's relay over the tunnel, B -> A's relay
|
||||
// over the exit) without the kind-10050 discovery fetch over the exit-less
|
||||
// indexers that stalled the pure-discovery run.
|
||||
a_svc
|
||||
.store
|
||||
.save_contact(&contact_with_relay(&b_svc.public_key().to_hex(), RELAY_B));
|
||||
b_svc
|
||||
.store
|
||||
.save_contact(&contact_with_relay(&a_svc.public_key().to_hex(), RELAY_A));
|
||||
println!("[e2e] seeded contacts: A knows B @ {RELAY_B}, B knows A @ {RELAY_A}");
|
||||
|
||||
// Recovery scan: concurrent across both wallets, each against its own
|
||||
// node. Sender needs spendable.
|
||||
wait_until("A synced_from_node", 2400, || a.synced_from_node());
|
||||
wait_until("B synced_from_node", 2400, || b.synced_from_node());
|
||||
|
||||
let spendable = |w: &Wallet| -> u64 {
|
||||
w.get_data()
|
||||
.map(|d| d.info.amount_currently_spendable)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let a_bal = spendable(&a);
|
||||
let b_bal = spendable(&b);
|
||||
println!("[e2e] spendable: A={a_bal} nano, B={b_bal} nano (need {AMOUNT})");
|
||||
|
||||
// Sender = whichever wallet actually has the funds. Either way the wrap
|
||||
// crosses relays: the sender fetches the recipient's kind 10050 (from
|
||||
// the recipient's relay + the discovery indexers) and publishes the
|
||||
// gift wrap THERE — the outbox path this test exists to prove.
|
||||
let (sender, sender_svc, recv_svc, sender_name) = if a_bal >= AMOUNT + 20_000_000 {
|
||||
(&a, &a_svc, &b_svc, "A")
|
||||
} else if b_bal >= AMOUNT + 20_000_000 {
|
||||
(&b, &b_svc, &a_svc, "B")
|
||||
} else {
|
||||
panic!(
|
||||
"neither wallet has >= {AMOUNT}+fee spendable (A={a_bal}, B={b_bal}); fund one and retry"
|
||||
);
|
||||
};
|
||||
let receiver_hex = recv_svc.public_key().to_hex();
|
||||
println!("[e2e] sender = {sender_name}; paying {AMOUNT} nano to {receiver_hex}");
|
||||
|
||||
// Fire the async payment across the two relays.
|
||||
let t_send = Instant::now();
|
||||
sender.task(WalletTask::NostrSend(
|
||||
AMOUNT,
|
||||
receiver_hex.clone(),
|
||||
Some("floonet e2e".to_string()),
|
||||
vec![],
|
||||
));
|
||||
|
||||
// Watch the sender's meta walk Created -> AwaitingS2 -> Finalized.
|
||||
// Generous window: two relays + two nodes + mixnet round trips.
|
||||
let finalized = wait_until("payment finalized", 900, || {
|
||||
if let Some(err) = sender_svc.last_send_error() {
|
||||
println!("[e2e] sender last_send_error: {err}");
|
||||
}
|
||||
sender_svc
|
||||
.store
|
||||
.all_tx_meta()
|
||||
.iter()
|
||||
.any(|m| matches!(m.status, NostrSendStatus::Finalized))
|
||||
});
|
||||
|
||||
println!(
|
||||
"[e2e] send->finalize elapsed {}s; finalized={finalized}",
|
||||
t_send.elapsed().as_secs()
|
||||
);
|
||||
// Dump both stores for the record.
|
||||
for (who, svc) in [("sender", sender_svc), ("receiver", recv_svc)] {
|
||||
for m in svc.store.all_tx_meta() {
|
||||
println!("[e2e] {who} meta {} -> {:?}", m.slate_id, m.status);
|
||||
}
|
||||
}
|
||||
|
||||
a.close();
|
||||
b.close();
|
||||
|
||||
assert!(
|
||||
finalized,
|
||||
"payment did not reach Finalized within the window (see meta trail above)"
|
||||
);
|
||||
println!("[e2e] SUCCESS: cross-relay + cross-node payment finalized over the floonet path");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -35,5 +35,5 @@ pub use utils::WalletUtils;
|
||||
mod seed;
|
||||
pub mod store;
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, feature = "e2e-internal"))]
|
||||
mod e2e;
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
// COLD-CONNECT TIMING HARNESS (Build 98 latency investigation). Not part of the
|
||||
// shipped test suite — it exists to MEASURE, on this machine, how long the real
|
||||
// Nym transport takes to go from a cold start to "transport ready" (a relay
|
||||
// connected+subscribed on the current tunnel generation), broken down per stage,
|
||||
// and to detect the exit-reselect LOOP (watchdog condemning a healthy exit
|
||||
// because relays were slow to connect through lossy mix-dns).
|
||||
//
|
||||
// It drives the SAME `NymWebSocketTransport` the app ships with, over the SAME
|
||||
// default relay set, arming the relay-consumer governance exactly like
|
||||
// `client.rs::run_service`, so the watchdog behaves as it does in the app.
|
||||
//
|
||||
// Run BEFORE (reproduce the old UDP mix-dns + legacy-watchdog loop) vs AFTER
|
||||
// (DoT-over-mixnet + robust watchdog), same binary, via env toggles:
|
||||
//
|
||||
// # BEFORE (old behavior): UDP mix-dns on + legacy watchdog
|
||||
// GOBLIN_DNS_UDP=1 GOBLIN_LEGACY_WATCHDOG=1 \
|
||||
// cargo test --test connect_timing -- --ignored --nocapture --test-threads=1
|
||||
//
|
||||
// # AFTER (shipped default): DoT-over-mixnet + robust watchdog
|
||||
// cargo test --test connect_timing -- --ignored --nocapture --test-threads=1
|
||||
//
|
||||
// Grep the captured log for lines tagged "[timing]" and "[TIMELINE]".
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use grim::nym::NymWebSocketTransport;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// The app's default relay set (src/nostr/relays.rs).
|
||||
const DEFAULT_RELAYS: &[&str] = &[
|
||||
"wss://relay.goblin.st",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
];
|
||||
|
||||
/// Overall budget for the measured window. Long enough to observe several
|
||||
/// reselect cycles if the loop is present (BEFORE), short enough to keep the run
|
||||
/// bounded. Overridable with GOBLIN_TIMING_WINDOW_SECS.
|
||||
fn window() -> Duration {
|
||||
let secs = std::env::var("GOBLIN_TIMING_WINDOW_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(180);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
fn init() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = env_logger::builder()
|
||||
.is_test(false)
|
||||
.format_timestamp_millis() // absolute wall-clock ms on every line
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.filter_module("grim::nym", log::LevelFilter::Debug)
|
||||
.parse_default_env()
|
||||
.try_init();
|
||||
}
|
||||
|
||||
/// One cold-connect measurement: bring the tunnel up, dial the default relays
|
||||
/// with the relay-consumer governance armed (as the app does), and record the
|
||||
/// per-stage timeline + any exit reselects over the window.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore]
|
||||
async fn cold_connect_timing() {
|
||||
init();
|
||||
let mode_dns = if std::env::var("GOBLIN_DNS_UDP").as_deref() == Ok("1") {
|
||||
"udp-dns(legacy)"
|
||||
} else {
|
||||
"dot-dns"
|
||||
};
|
||||
let mode_wd = if std::env::var("GOBLIN_LEGACY_WATCHDOG").as_deref() == Ok("1") {
|
||||
"legacy-watchdog"
|
||||
} else {
|
||||
"robust-watchdog"
|
||||
};
|
||||
eprintln!("[TIMELINE] === cold_connect_timing START (dns={mode_dns}, watchdog={mode_wd}) ===");
|
||||
|
||||
let t0 = Instant::now();
|
||||
|
||||
// Stage A: mixnet tunnel bootstrap (select exit + build + liveness probe).
|
||||
grim::nym::warm_up();
|
||||
let mut tunnel_ready_ms = None;
|
||||
for _ in 0..480 {
|
||||
if grim::nym::is_ready() {
|
||||
tunnel_ready_ms = Some(t0.elapsed().as_millis());
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
let gen0 = grim::nym::tunnel_generation();
|
||||
match tunnel_ready_ms {
|
||||
Some(ms) => eprintln!("[TIMELINE] A. tunnel READY at t+{ms}ms (gen {gen0})"),
|
||||
None => {
|
||||
eprintln!(
|
||||
"[TIMELINE] A. tunnel NEVER became ready within {}s — mixnet bootstrap failed on this machine",
|
||||
t0.elapsed().as_secs()
|
||||
);
|
||||
panic!("mixnet never bootstrapped; cannot measure connect timing");
|
||||
}
|
||||
}
|
||||
|
||||
// Stage B: dial the default relays over the mixnet, exactly like run_service:
|
||||
// arm relay-consumer governance so the watchdog treats a relay-dead exit as
|
||||
// condemnable (this is what produces the loop in the BEFORE case).
|
||||
grim::nym::set_relay_consumer(true);
|
||||
let client = Client::builder()
|
||||
.signer(Keys::generate())
|
||||
.websocket_transport(NymWebSocketTransport)
|
||||
.build();
|
||||
for r in DEFAULT_RELAYS {
|
||||
let _ = client.add_relay(*r).await;
|
||||
}
|
||||
let dial_gen = grim::nym::tunnel_generation();
|
||||
let connect_started = Instant::now();
|
||||
client.connect().await;
|
||||
|
||||
// Report relay-live on the current generation as soon as a relay connects,
|
||||
// exactly like run_service's fast-report task — this is what closes the
|
||||
// watchdog's readiness window in the healthy case.
|
||||
let mut first_relay_ms = None;
|
||||
let mut transport_ready_ms = None;
|
||||
let mut reselects = 0u64;
|
||||
let mut last_gen = dial_gen;
|
||||
let mut gen_events: Vec<(u128, u64)> = vec![(t0.elapsed().as_millis(), dial_gen)];
|
||||
|
||||
loop {
|
||||
if connect_started.elapsed() > window() {
|
||||
break;
|
||||
}
|
||||
let gen_now = grim::nym::tunnel_generation();
|
||||
if gen_now != last_gen {
|
||||
reselects += 1;
|
||||
gen_events.push((t0.elapsed().as_millis(), gen_now));
|
||||
eprintln!(
|
||||
"[TIMELINE] !! exit RESELECT #{reselects}: gen {last_gen} -> {gen_now} at t+{}ms (the loop)",
|
||||
t0.elapsed().as_millis()
|
||||
);
|
||||
last_gen = gen_now;
|
||||
// Re-dial on the fresh exit like the status loop does.
|
||||
client.disconnect().await;
|
||||
for r in DEFAULT_RELAYS {
|
||||
let _ = client.add_relay(*r).await;
|
||||
}
|
||||
client.connect().await;
|
||||
}
|
||||
|
||||
let connected = client
|
||||
.relays()
|
||||
.await
|
||||
.values()
|
||||
.any(|r| r.status() == RelayStatus::Connected);
|
||||
if connected {
|
||||
// Feed liveness on the CURRENT generation (what run_service does).
|
||||
grim::nym::report_relay_live(grim::nym::tunnel_generation());
|
||||
if first_relay_ms.is_none() {
|
||||
first_relay_ms = Some(t0.elapsed().as_millis());
|
||||
eprintln!(
|
||||
"[TIMELINE] B. first relay CONNECTED at t+{}ms (~{}ms after connect())",
|
||||
t0.elapsed().as_millis(),
|
||||
connect_started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
} else if first_relay_ms.is_some() {
|
||||
grim::nym::report_relay_down(grim::nym::tunnel_generation());
|
||||
}
|
||||
|
||||
if grim::nym::transport_ready() && transport_ready_ms.is_none() {
|
||||
transport_ready_ms = Some(t0.elapsed().as_millis());
|
||||
eprintln!(
|
||||
"[TIMELINE] C. TRANSPORT READY at t+{}ms (relay live on gen {})",
|
||||
t0.elapsed().as_millis(),
|
||||
grim::nym::tunnel_generation()
|
||||
);
|
||||
// Once ready, watch a little longer to confirm it STAYS ready (no loop),
|
||||
// then finish early rather than burn the whole window.
|
||||
let settle_until = Instant::now() + Duration::from_secs(20);
|
||||
let mut stayed = true;
|
||||
while Instant::now() < settle_until {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if grim::nym::tunnel_generation() != last_gen {
|
||||
stayed = false; // a reselect during settle — loop still live
|
||||
break;
|
||||
}
|
||||
}
|
||||
if stayed {
|
||||
eprintln!("[TIMELINE] transport stayed ready for 20s — no loop");
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
grim::nym::set_relay_consumer(false);
|
||||
client.disconnect().await;
|
||||
|
||||
eprintln!("[TIMELINE] === SUMMARY (dns={mode_dns}, watchdog={mode_wd}) ===");
|
||||
eprintln!(
|
||||
"[TIMELINE] tunnel_ready: {}",
|
||||
tunnel_ready_ms
|
||||
.map(|m| format!("{m}ms"))
|
||||
.unwrap_or("n/a".into())
|
||||
);
|
||||
eprintln!(
|
||||
"[TIMELINE] first_relay: {}",
|
||||
first_relay_ms
|
||||
.map(|m| format!("{m}ms"))
|
||||
.unwrap_or("NEVER".into())
|
||||
);
|
||||
eprintln!(
|
||||
"[TIMELINE] transport_ready: {}",
|
||||
transport_ready_ms
|
||||
.map(|m| format!("{m}ms"))
|
||||
.unwrap_or("NEVER".into())
|
||||
);
|
||||
eprintln!("[TIMELINE] exit reselects during window: {reselects} (0 = no loop)");
|
||||
eprintln!("[TIMELINE] generation timeline: {gen_events:?}");
|
||||
eprintln!("[TIMELINE] === cold_connect_timing END ===");
|
||||
|
||||
// The measurement itself shouldn't fail the suite; it's diagnostic. But a
|
||||
// total failure to ever connect is worth surfacing loudly.
|
||||
assert!(
|
||||
first_relay_ms.is_some(),
|
||||
"no relay ever connected within the window"
|
||||
);
|
||||
}
|
||||
|
||||
/// Prove DNS resolves END TO END over the tunnel (DoT, or DoH fallback) — no
|
||||
/// clearnet. Loops across exit reselects (the mixnet hands out the odd dead
|
||||
/// exit) until a healthy exit resolves a real relay host, then asserts success.
|
||||
/// Watch the log for "dot-dns: resolved" / "doh-dns: resolved".
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn dns_resolve_smoke() {
|
||||
init();
|
||||
grim::nym::warm_up();
|
||||
for _ in 0..480 {
|
||||
if grim::nym::is_ready() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(150);
|
||||
let mut ok = false;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(tunnel) = grim::nym::nymproc::tunnel() {
|
||||
for host in ["relay.damus.io", "goblin.st", "api.coingecko.com"] {
|
||||
let t = Instant::now();
|
||||
match grim::nym::dns::resolve(&tunnel, host, 443).await {
|
||||
Some(addr) => {
|
||||
eprintln!(
|
||||
"[DNSPROOF] resolved {host} -> {addr} in {}ms OVER THE TUNNEL",
|
||||
t.elapsed().as_millis()
|
||||
);
|
||||
ok = true;
|
||||
}
|
||||
None => eprintln!(
|
||||
"[DNSPROOF] {host} unresolved on this exit ({}ms) — waiting for a better exit",
|
||||
t.elapsed().as_millis()
|
||||
),
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
assert!(
|
||||
ok,
|
||||
"DNS never resolved over the tunnel within the window (all exits bad?)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Probe whether the Nym IPR exit policy lets us open TCP to the DoT port (853)
|
||||
/// through the tunnel. 443 is the control (known-open — relays + HTTPS ride it).
|
||||
/// Decides DoT-vs-DoH for the private DNS transport. Run:
|
||||
/// cargo test --test connect_timing probe_dns_ports -- --ignored --nocapture --test-threads=1
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn probe_dns_ports() {
|
||||
init();
|
||||
grim::nym::warm_up();
|
||||
let mut ready = false;
|
||||
for _ in 0..480 {
|
||||
if grim::nym::is_ready() {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
assert!(ready, "tunnel never bootstrapped; cannot probe ports");
|
||||
let tunnel = grim::nym::nymproc::tunnel().expect("tunnel up");
|
||||
let targets = [
|
||||
("cloudflare:853 (DoT)", "1.1.1.1:853"),
|
||||
("quad9:853 (DoT)", "9.9.9.9:853"),
|
||||
("cloudflare:443 (control)", "1.1.1.1:443"),
|
||||
];
|
||||
for (label, addr) in targets {
|
||||
let sa: std::net::SocketAddr = addr.parse().unwrap();
|
||||
let t = Instant::now();
|
||||
match tokio::time::timeout(Duration::from_secs(12), tunnel.tcp_connect(sa)).await {
|
||||
Ok(Ok(_)) => eprintln!(
|
||||
"[PORTPROBE] {label} = CONNECTED in {}ms",
|
||||
t.elapsed().as_millis()
|
||||
),
|
||||
Ok(Err(e)) => eprintln!(
|
||||
"[PORTPROBE] {label} = REFUSED/ERR after {}ms: {e}",
|
||||
t.elapsed().as_millis()
|
||||
),
|
||||
Err(_) => eprintln!(
|
||||
"[PORTPROBE] {label} = TIMEOUT after {}ms",
|
||||
t.elapsed().as_millis()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
// 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}");
|
||||
}
|
||||
@@ -1,517 +0,0 @@
|
||||
// THROWAWAY transport-validation harness (G14). Not part of the shipped test
|
||||
// suite — it exists to prove the migrated transport (in-process smolmix mixnet
|
||||
// tunnel + mandatory mix-dns) actually DELIVERS NIP-17 gift wraps over real
|
||||
// relays, using the SAME `NymWebSocketTransport` the app now ships with as its
|
||||
// only transport. Unlike tests/nostr_e2e.rs (which uses the default clearnet
|
||||
// nostr-sdk client), every websocket here is dialed through the mixnet and
|
||||
// every relay hostname is resolved over the tunnel (mix-dns).
|
||||
//
|
||||
// Network + mixnet dependent — run explicitly:
|
||||
// cargo test --test xrelay_smoke -- --ignored --nocapture --test-threads=1
|
||||
//
|
||||
// What to look for in the logs (proof, not just green):
|
||||
// * "nym: tunnel ready ... (allocated ip ..., probe ok)" — tunnel up, exit auto-selected
|
||||
// * "mix-dns: resolved <host> -> <ip> ..." — each relay resolved OVER the tunnel
|
||||
// * "v3 delivered + decrypted" — a real 0x03 wrap crossed the wire
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use grim::nostr::{protocol, wrapv3};
|
||||
use grim::nym::NymWebSocketTransport;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A small but valid-looking slatepack armor block (same fixture the in-tree
|
||||
/// wrapv3 unit test uses), so extraction is exercised end to end.
|
||||
const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \
|
||||
pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \
|
||||
dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \
|
||||
ENDSLATEPACK.";
|
||||
|
||||
const SUBJECT: &str = "lunch :)";
|
||||
|
||||
/// Install the ring crypto provider (the app does this in `grim::start()`; a
|
||||
/// test binary must do it itself or the first TLS handshake panics — Build
|
||||
/// 65/66 rule) and route logs to stdout at debug so the tunnel + mix-dns lines
|
||||
/// are visible under --nocapture. Both are idempotent.
|
||||
fn init() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = env_logger::builder()
|
||||
.is_test(false)
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.filter_module("grim::nym", log::LevelFilter::Debug)
|
||||
.parse_default_env() // honor RUST_LOG if set
|
||||
.try_init();
|
||||
}
|
||||
|
||||
/// Bring the shared in-process mixnet tunnel up before any relay dial, exactly
|
||||
/// like the real service loop (client.rs `run_service`). Panics if the mixnet
|
||||
/// never bootstraps — that IS the blocker the on-chain test would hit.
|
||||
async fn ensure_tunnel() {
|
||||
grim::nym::warm_up();
|
||||
let started = Instant::now();
|
||||
for _ in 0..240 {
|
||||
if grim::nym::is_ready() {
|
||||
eprintln!(
|
||||
"[harness] mixnet tunnel ready after ~{}ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
panic!(
|
||||
"BLOCKER: mixnet tunnel never became ready after {}s — smolmix bootstrap failed \
|
||||
(see nym: log lines above). On-chain payment test cannot proceed.",
|
||||
started.elapsed().as_secs()
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a Goblin-style client for `keys` over the real mixnet transport —
|
||||
/// byte-for-byte the builder from `src/nostr/client.rs::run_service`.
|
||||
fn goblin_client(keys: &Keys) -> Client {
|
||||
Client::builder()
|
||||
.signer(keys.clone())
|
||||
.websocket_transport(NymWebSocketTransport)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Advertise a kind-10050 DM-relay list for `who` pointing at `inbox_relays`,
|
||||
/// carrying the v3 encryption capability, so the wire shape matches what a real
|
||||
/// Goblin peer publishes (client.rs `publish_identity`). Best-effort.
|
||||
async fn advertise_inbox(client: &Client, inbox_relays: &[&str]) {
|
||||
let mut tags: Vec<Tag> = inbox_relays
|
||||
.iter()
|
||||
.map(|r| Tag::custom(TagKind::custom("relay"), [r.to_string()]))
|
||||
.collect();
|
||||
tags.push(Tag::custom(
|
||||
TagKind::custom("encryption"),
|
||||
[wrapv3::ENCRYPTION_CAPABILITY.to_string()],
|
||||
));
|
||||
let builder = EventBuilder::new(Kind::InboxRelays, "").tags(tags);
|
||||
let targets: Vec<String> = inbox_relays.iter().map(|s| s.to_string()).collect();
|
||||
match client.sign_event_builder(builder).await {
|
||||
Ok(ev) => {
|
||||
if let Err(e) = client.send_event_to(&targets, &ev).await {
|
||||
eprintln!("[harness] warn: advertise 10050 failed: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("[harness] warn: sign 10050 failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` for a kind-1059 gift wrap addressed to `me` on the
|
||||
/// notification stream, unwrap it through Goblin's version-dispatched
|
||||
/// `wrapv3::unwrap` (proves the 0x03 path over the wire), and return the sender
|
||||
/// + rumor. Any other event is ignored.
|
||||
async fn recv_and_unwrap(
|
||||
client: &Client,
|
||||
me: &Keys,
|
||||
timeout: Duration,
|
||||
) -> Result<(PublicKey, UnsignedEvent), String> {
|
||||
let mut notifications = client.notifications();
|
||||
tokio::time::timeout(timeout, async {
|
||||
loop {
|
||||
if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await {
|
||||
if event.kind != Kind::GiftWrap {
|
||||
continue;
|
||||
}
|
||||
match wrapv3::unwrap(me, &event).await {
|
||||
Ok(u) => return (u.sender, u.rumor),
|
||||
// A wrap we cannot open (someone else's) — keep waiting.
|
||||
Err(e) => {
|
||||
eprintln!("[harness] ignoring undecryptable wrap: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "timed out waiting for gift wrap".to_string())
|
||||
}
|
||||
|
||||
/// Assert the received rumor is exactly the payment DM Alice sent.
|
||||
fn assert_payment(sender: PublicKey, alice: &Keys, rumor: &UnsignedEvent, content: &str) {
|
||||
assert_eq!(sender, alice.public_key(), "sender must be Alice");
|
||||
assert_eq!(
|
||||
rumor.pubkey,
|
||||
alice.public_key(),
|
||||
"rumor author == seal signer"
|
||||
);
|
||||
assert_eq!(rumor.kind, Kind::PrivateDirectMessage);
|
||||
assert_eq!(
|
||||
rumor.content, content,
|
||||
"payment content must survive the wire"
|
||||
);
|
||||
let armor = protocol::extract_slatepack(&rumor.content).expect("slatepack must extract");
|
||||
assert!(armor.starts_with("BEGINSLATEPACK.") && armor.ends_with("ENDSLATEPACK."));
|
||||
assert_eq!(
|
||||
protocol::extract_subject(&rumor.tags).as_deref(),
|
||||
Some(SUBJECT)
|
||||
);
|
||||
}
|
||||
|
||||
/// RELAY-GATED READINESS (the point of the G14 hardening): `transport_ready()`
|
||||
/// must be FALSE while only the tunnel is up, and become TRUE only once a relay
|
||||
/// is actually connected+subscribed on the CURRENT tunnel generation — the
|
||||
/// signal that governs the "Connected over Nym" UI and the exit-health window.
|
||||
///
|
||||
/// The bare `nostr_sdk::Client` used here is not the app's `NostrService`, so it
|
||||
/// doesn't feed the readiness signal on its own; we drive the SAME report the
|
||||
/// service loop makes (`report_relay_live(tunnel_generation())`) exactly when a
|
||||
/// relay has connected+subscribed, and assert the gate flips only then. Proves
|
||||
/// the cross-module contract: tunnel-up alone is NOT ready; a live relay on the
|
||||
/// current generation IS.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore]
|
||||
async fn transport_ready_is_relay_gated() {
|
||||
init();
|
||||
ensure_tunnel().await;
|
||||
let generation = grim::nym::tunnel_generation();
|
||||
assert!(
|
||||
generation != 0,
|
||||
"a live tunnel must have a non-zero generation"
|
||||
);
|
||||
|
||||
// Clear any liveness a prior test left on this (process-global) generation,
|
||||
// so the assertion is order-independent.
|
||||
grim::nym::report_relay_down(generation);
|
||||
assert!(
|
||||
grim::nym::is_ready(),
|
||||
"precondition: tunnel (is_ready) must be up"
|
||||
);
|
||||
assert!(
|
||||
!grim::nym::transport_ready(),
|
||||
"BUG: transport_ready must be FALSE on a warm tunnel with no live relay \
|
||||
(this is exactly the false 'Connected over Nym' the hardening fixes)"
|
||||
);
|
||||
|
||||
// Bring one relay to connected+subscribed over the mixnet, like the service.
|
||||
let relay = "wss://relay.damus.io";
|
||||
let bob = Keys::generate();
|
||||
let bob_client = goblin_client(&bob);
|
||||
bob_client.add_relay(relay).await.unwrap();
|
||||
bob_client.connect().await;
|
||||
bob_client
|
||||
.subscribe(
|
||||
Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(bob.public_key())
|
||||
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wait for the websocket handshake to actually complete over Nym, then feed
|
||||
// the readiness signal the way `run_service`'s status tick does. A generous
|
||||
// budget: a relay handshake over the mixnet is variable (seen 10-30s).
|
||||
let mut connected = false;
|
||||
for _ in 0..120 {
|
||||
if bob_client
|
||||
.relays()
|
||||
.await
|
||||
.values()
|
||||
.any(|r| r.status() == RelayStatus::Connected)
|
||||
{
|
||||
connected = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
assert!(connected, "BLOCKER: relay never connected over the mixnet");
|
||||
grim::nym::report_relay_live(generation);
|
||||
|
||||
assert!(
|
||||
grim::nym::transport_ready(),
|
||||
"transport_ready must be TRUE once a relay is live on the current generation"
|
||||
);
|
||||
// A report tagged with an OLDER generation must not keep us 'ready' after a
|
||||
// (hypothetical) reselect: simulate the generation moving on and confirm the
|
||||
// stale report no longer counts.
|
||||
grim::nym::report_relay_live(generation - 1);
|
||||
// Still ready: the current-generation liveness stands (fetch_max floor).
|
||||
assert!(
|
||||
grim::nym::transport_ready(),
|
||||
"a stale-generation report must not lower current readiness"
|
||||
);
|
||||
eprintln!("[harness] relay-gated readiness verified at gen {generation}");
|
||||
|
||||
bob_client.disconnect().await;
|
||||
}
|
||||
|
||||
/// CONDEMN + RESELECT (deterministic simulation of a relay-dead exit): with a
|
||||
/// nostr consumer present but NO relay ever reported live on the current exit,
|
||||
/// nymproc must condemn the exit within its grace window and rebuild on a fresh
|
||||
/// auto-selected one (the generation advances), then recover. Proves the
|
||||
/// exit-health state machine — the whole point of requirement 2 — end to end
|
||||
/// without needing a naturally bad-for-relays exit (which can't be forced
|
||||
/// deterministically). In the real app the NostrService DOES report relay-live,
|
||||
/// so a HEALTHY exit is never condemned (see `v3_cross_relay`).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore]
|
||||
async fn dead_for_relays_exit_is_condemned_and_reselected() {
|
||||
init();
|
||||
ensure_tunnel().await;
|
||||
let gen0 = grim::nym::tunnel_generation();
|
||||
assert!(gen0 != 0, "a live tunnel must have a non-zero generation");
|
||||
eprintln!(
|
||||
"[harness] arming relay consumer at gen {gen0}; withholding relay-live to simulate a relay-dead exit"
|
||||
);
|
||||
// Arm relay-reachability governance but never report a live relay: nymproc
|
||||
// must treat this exit as dead-for-our-purposes and reselect.
|
||||
grim::nym::set_relay_consumer(true);
|
||||
|
||||
// Budget generously: condemnation itself takes RELAY_GRACE (~25s), then a
|
||||
// FRESH mixnet bootstrap follows (variable, seen 5-70s), so allow ~150s for
|
||||
// the generation to advance.
|
||||
let started = Instant::now();
|
||||
let mut advanced = 0u64;
|
||||
for _ in 0..300 {
|
||||
let g = grim::nym::tunnel_generation();
|
||||
if g > gen0 {
|
||||
advanced = g;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
// Disarm FIRST so a failed assert can't leave governance armed for later tests.
|
||||
grim::nym::set_relay_consumer(false);
|
||||
assert!(
|
||||
advanced > gen0,
|
||||
"BLOCKER: a relay-dead exit was not condemned+reselected within {}s (gen stuck at {gen0})",
|
||||
started.elapsed().as_secs()
|
||||
);
|
||||
eprintln!(
|
||||
"[harness] exit condemned + reselected: gen {gen0} -> {advanced} in ~{}s",
|
||||
started.elapsed().as_secs()
|
||||
);
|
||||
|
||||
// Recovery: with governance disarmed, the freshly-built tunnel settles ready.
|
||||
let mut ready = false;
|
||||
for _ in 0..80 {
|
||||
if grim::nym::is_ready() {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
assert!(ready, "tunnel must recover ready after the reselect");
|
||||
eprintln!(
|
||||
"[harness] tunnel recovered ready after reselect at gen {}",
|
||||
grim::nym::tunnel_generation()
|
||||
);
|
||||
}
|
||||
|
||||
/// SINGLE-RELAY: a NIP-44 v3 gift wrap round-trips between two fresh Goblin
|
||||
/// identities over ONE relay, entirely through the smolmix tunnel + mix-dns.
|
||||
/// Proves the migrated transport delivers the v3 path against a real relay.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore]
|
||||
async fn v3_roundtrip_single_relay() {
|
||||
init();
|
||||
ensure_tunnel().await;
|
||||
let relay = "wss://relay.damus.io";
|
||||
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
eprintln!("[harness] single-relay {relay}");
|
||||
eprintln!(
|
||||
"[harness] alice {}",
|
||||
alice.public_key().to_bech32().unwrap()
|
||||
);
|
||||
eprintln!(
|
||||
"[harness] bob {}",
|
||||
bob.public_key().to_bech32().unwrap()
|
||||
);
|
||||
|
||||
let bob_client = goblin_client(&bob);
|
||||
bob_client.add_relay(relay).await.unwrap();
|
||||
bob_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
advertise_inbox(&bob_client, &[relay]).await;
|
||||
bob_client
|
||||
.subscribe(
|
||||
Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(bob.public_key())
|
||||
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let alice_client = goblin_client(&alice);
|
||||
alice_client.add_relay(relay).await.unwrap();
|
||||
alice_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let content = protocol::build_payment_content(SLATEPACK);
|
||||
let tags = protocol::build_rumor_tags(Some(SUBJECT));
|
||||
let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap");
|
||||
assert_eq!(wrap.kind, Kind::GiftWrap);
|
||||
|
||||
let sent = Instant::now();
|
||||
alice_client
|
||||
.send_event_to(vec![relay.to_string()], &wrap)
|
||||
.await
|
||||
.expect("publish v3 wrap over mixnet");
|
||||
eprintln!("[harness] alice published v3 wrap; waiting for delivery...");
|
||||
|
||||
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
|
||||
.await
|
||||
.expect("BLOCKER: v3 gift wrap never delivered single-relay");
|
||||
assert_payment(sender, &alice, &rumor, &content);
|
||||
eprintln!(
|
||||
"[harness] v3 delivered + decrypted single-relay in {} ms over {relay}",
|
||||
sent.elapsed().as_millis()
|
||||
);
|
||||
|
||||
bob_client.disconnect().await;
|
||||
alice_client.disconnect().await;
|
||||
}
|
||||
|
||||
/// SINGLE-RELAY v2: the unchanged nostr-sdk NIP-44 v2 gift-wrap path
|
||||
/// (`send_private_msg_to`) delivered over the SAME smolmix transport, unwrapped
|
||||
/// through Goblin's version-dispatched `wrapv3::unwrap` (which routes 0x02 to
|
||||
/// the sdk). Proves the migrated transport is payload-version agnostic — a
|
||||
/// v2-only peer is unaffected over the mixnet.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore]
|
||||
async fn v2_roundtrip_single_relay() {
|
||||
init();
|
||||
ensure_tunnel().await;
|
||||
let relay = "wss://relay.damus.io";
|
||||
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
eprintln!("[harness] single-relay v2 {relay}");
|
||||
eprintln!(
|
||||
"[harness] alice {}",
|
||||
alice.public_key().to_bech32().unwrap()
|
||||
);
|
||||
eprintln!(
|
||||
"[harness] bob {}",
|
||||
bob.public_key().to_bech32().unwrap()
|
||||
);
|
||||
|
||||
let bob_client = goblin_client(&bob);
|
||||
bob_client.add_relay(relay).await.unwrap();
|
||||
bob_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
advertise_inbox(&bob_client, &[relay]).await;
|
||||
bob_client
|
||||
.subscribe(
|
||||
Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(bob.public_key())
|
||||
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let alice_client = goblin_client(&alice);
|
||||
alice_client.add_relay(relay).await.unwrap();
|
||||
alice_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let content = protocol::build_payment_content(SLATEPACK);
|
||||
let tags = protocol::build_rumor_tags(Some(SUBJECT));
|
||||
// nostr-sdk builds a v2 (0x02) gift wrap here.
|
||||
let sent = Instant::now();
|
||||
alice_client
|
||||
.send_private_msg_to([relay], bob.public_key(), content.clone(), tags)
|
||||
.await
|
||||
.expect("publish v2 wrap over mixnet");
|
||||
eprintln!("[harness] alice published v2 wrap; waiting for delivery...");
|
||||
|
||||
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
|
||||
.await
|
||||
.expect("BLOCKER: v2 gift wrap never delivered single-relay");
|
||||
assert_payment(sender, &alice, &rumor, &content);
|
||||
eprintln!(
|
||||
"[harness] v2 delivered + decrypted single-relay in {} ms over {relay}",
|
||||
sent.elapsed().as_millis()
|
||||
);
|
||||
|
||||
bob_client.disconnect().await;
|
||||
alice_client.disconnect().await;
|
||||
}
|
||||
|
||||
/// CROSS-RELAY (the redundancy direction): Bob's inbox is nos.lol ONLY; Alice's
|
||||
/// home is damus. Alice publishes the SAME v3 wrap redundantly to BOTH relays;
|
||||
/// Bob, subscribed only on nos.lol, still receives + decrypts it. Proves
|
||||
/// delivery does not depend on a single shared relay and that the v3 path works
|
||||
/// over the real mixnet transport across two relays with no overlap in what the
|
||||
/// two identities read.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore]
|
||||
async fn v3_cross_relay() {
|
||||
init();
|
||||
ensure_tunnel().await;
|
||||
let alice_home = "wss://relay.damus.io";
|
||||
let bob_inbox = "wss://nos.lol";
|
||||
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
eprintln!("[harness] cross-relay: alice_home={alice_home} bob_inbox={bob_inbox}");
|
||||
eprintln!(
|
||||
"[harness] alice {}",
|
||||
alice.public_key().to_bech32().unwrap()
|
||||
);
|
||||
eprintln!(
|
||||
"[harness] bob {}",
|
||||
bob.public_key().to_bech32().unwrap()
|
||||
);
|
||||
|
||||
// Bob lives ONLY on nos.lol and advertises it as his inbox.
|
||||
let bob_client = goblin_client(&bob);
|
||||
bob_client.add_relay(bob_inbox).await.unwrap();
|
||||
bob_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
advertise_inbox(&bob_client, &[bob_inbox]).await;
|
||||
bob_client
|
||||
.subscribe(
|
||||
Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.pubkey(bob.public_key())
|
||||
.since(Timestamp::now() - Duration::from_secs(3 * 86_400)),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Alice's home is damus; she also connects to Bob's inbox to deposit there.
|
||||
let alice_client = goblin_client(&alice);
|
||||
alice_client.add_relay(alice_home).await.unwrap();
|
||||
alice_client.add_relay(bob_inbox).await.unwrap();
|
||||
alice_client.connect().await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
let content = protocol::build_payment_content(SLATEPACK);
|
||||
let tags = protocol::build_rumor_tags(Some(SUBJECT));
|
||||
let wrap = wrapv3::wrap(&alice, &bob.public_key(), content.clone(), tags).expect("v3 wrap");
|
||||
|
||||
// Redundant publish to BOTH relays; Bob reads only nos.lol.
|
||||
let sent = Instant::now();
|
||||
alice_client
|
||||
.send_event_to(vec![alice_home.to_string(), bob_inbox.to_string()], &wrap)
|
||||
.await
|
||||
.expect("publish v3 wrap to both relays over mixnet");
|
||||
eprintln!(
|
||||
"[harness] alice published v3 wrap to [{alice_home}, {bob_inbox}]; bob reads only {bob_inbox}"
|
||||
);
|
||||
|
||||
let (sender, rumor) = recv_and_unwrap(&bob_client, &bob, Duration::from_secs(90))
|
||||
.await
|
||||
.expect("BLOCKER: v3 gift wrap never crossed to bob's inbox relay");
|
||||
assert_payment(sender, &alice, &rumor, &content);
|
||||
eprintln!(
|
||||
"[harness] v3 delivered + decrypted CROSS-RELAY in {} ms (alice@{alice_home} -> bob@{bob_inbox})",
|
||||
sent.elapsed().as_millis()
|
||||
);
|
||||
|
||||
bob_client.disconnect().await;
|
||||
alice_client.disconnect().await;
|
||||
}
|
||||
Reference in New Issue
Block a user