Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0cdeb6d6b | |||
| e9f3bd453b | |||
| 9b1cec2063 | |||
| c3d6c31aca | |||
| 3237174c2e | |||
| 0c7a1404fa | |||
| d80218a13c | |||
| 472e2f0915 | |||
| 2592eadc9f | |||
| 2acfd46784 | |||
| 51d1675ad6 | |||
| 5d500540c8 | |||
| 6399222908 | |||
| 9e97d532e7 | |||
| 6d0dde523f | |||
| e56d9d05e2 | |||
| 69f647a009 | |||
| 8a382aa308 | |||
| 63d0cb7dee | |||
| 2e2d96016e | |||
| 26da66710e | |||
| 90c294bf26 | |||
| 3a69b72d9d | |||
| bdedcba498 | |||
| 037e727756 | |||
| ce024443ac | |||
| 1141f97b22 | |||
| e036a9692a | |||
| ef58f260e8 | |||
| 210c4ab662 | |||
| c0b622f694 | |||
| 42d70e1a5e | |||
| e9f0c3f0e2 | |||
| a437aad2f8 | |||
| d5d1212a44 | |||
| ab2ac7c3ac | |||
| 3db6375459 | |||
| d5ae136cf1 | |||
| a7c2443f3b | |||
| ae0f36d287 | |||
| 9f019edfeb | |||
| 093c5014ef | |||
| 65d8d0f7bd | |||
| d4dcbb115f | |||
| 0bf87a6cff | |||
| a57121959a | |||
| ba8e81ef5f | |||
| e492703a0c | |||
| 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
+2199
-4486
File diff suppressed because it is too large
Load Diff
+52
-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"
|
||||
|
||||
@@ -108,6 +122,9 @@ nip44 = "0.3.0"
|
||||
## Only to construct the key types the `nip44` crate takes: nostr-sdk pins
|
||||
## secp256k1 0.29, the nip44 crate 0.31 — bridged via byte arrays in wrapv3.rs.
|
||||
secp256k1 = "0.31"
|
||||
## Scrub the NIP-44 conversation-key buffers (raw ECDH secret) from memory on
|
||||
## drop in wrapv3.rs. Already a transitive dep; pulled in directly here.
|
||||
zeroize = "1"
|
||||
async-wsocket = "0.13"
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
regex = "1"
|
||||
@@ -124,18 +141,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"
|
||||
@@ -152,6 +185,11 @@ nokhwa = { version = "0.10.10", default-features = false, features = ["input-msm
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
nokhwa = { version = "0.10.10", default-features = false, features = ["input-avfoundation", "output-threaded"] }
|
||||
## Objective-C runtime shim for the macOS `goblin:` deep-link bridge (src/lib.rs,
|
||||
## mac_deeplink). Already in the macOS build graph via nokhwa/cocoa/metal/wgpu, so
|
||||
## this pulls no new crate — it just lets us name it directly. Not compiled on any
|
||||
## other platform.
|
||||
objc = "0.2"
|
||||
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
env_logger = "0.11.3"
|
||||
@@ -197,9 +235,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"] }
|
||||
|
||||
@@ -4,27 +4,30 @@
|
||||
|
||||
# Goblin
|
||||
|
||||
Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) — confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
|
||||
Goblin is a private, pay-by-username wallet for [GRIN ツ](https://grin.mw) - confidential digital cash on [Mimblewimble](https://github.com/mimblewimble/grin), with no amounts or addresses on the chain.
|
||||
|
||||
Instead of passing slatepack files back and forth, you **pay a `username` (or an `npub`)** and the payment is delivered for you as an **end-to-end encrypted message over [nostr](https://github.com/nostr-protocol/nips), routed through 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.
|
||||
- **Manual slatepacks too** — when you need to pay or get paid without a handle, **Settings → Wallet → Slatepacks** exposes the classic by-hand flow: create a slatepack to send, or paste one to receive, finalize, or pay.
|
||||
- **In-app identity** — a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service.
|
||||
- **Private by construction** — GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through the [Nym mixnet](https://nym.com), so who-pays-whom never touches the clear net. The GRIN node connection — block sync and broadcasting your transaction — is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device.
|
||||
- **Configurable amount pairing** — show balances against a world currency, Bitcoin, or sats (rates fetched over the mixnet), or turn the preview off.
|
||||
- **Cross-platform** — Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
|
||||
- **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.
|
||||
- **Open-to-pay links** - a `goblin:` or `nostr:` pay link, or a scanned checkout QR, opens the wallet straight to a prefilled review screen (recipient, amount and note filled in, ready to hold-to-send) on desktop, macOS and Android.
|
||||
- **Proofs on request** - payments can include a native Grin payment proof when the payment request asks for one, off by default, shown on the review screen. An ordinary person-to-person send carries none.
|
||||
- **In-app identity** - a nostr payment key that is deliberately *not* part of your seed, so you can rotate it any time to stay unlinkable without touching your funds. An optional human-readable `name` comes from the goblin.st identity service.
|
||||
- **Private by construction** - GRIN's address-less, confidential chain; your payments and identity (nostr relays, NIP-05 lookups, price) are routed through [Tor](https://www.torproject.org), so who-pays-whom never touches the clear net. The GRIN node connection - block sync and broadcasting your transaction - is direct: public chain data, the same for everyone, and not tied to your identity. Keys, names and history stay on your device.
|
||||
- **Configurable amount pairing** - show balances against a world currency, Bitcoin, or sats (rates fetched over Tor), or turn the preview off.
|
||||
- **News on Home** - the latest post from the official Goblin news key (a [kind 30023](https://nostrbook.dev/kinds/30023) long-form article) appears on the Home screen in your wallet's language, falling back to English; it stays hidden when there is nothing to show, and only ever shows the newest article.
|
||||
- **Cross-platform** - Linux, macOS, Windows, Android, built in pure Rust on [egui](https://github.com/emilk/egui).
|
||||
|
||||
## How a payment travels
|
||||
|
||||
```
|
||||
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 +36,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 +44,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: every relay, the money-path relay included, is reached over a Tor exit to its ordinary clearnet host. The GRIN node connection (block sync and transaction broadcast) is **not** routed through Tor: it connects directly, as it carries only public chain data that isn't linked to your wallet.
|
||||
|
||||
### Android
|
||||
|
||||
@@ -64,7 +66,7 @@ Install the Android SDK / NDK, then from the repo root:
|
||||
|
||||
## Identity service (`goblin-nip05d`)
|
||||
|
||||
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred — on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable — and fully anonymous — without it. Avatars aren't stored or served — clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
|
||||
The optional `name` service lives in `goblin-nip05d/` (axum + SQLite) and is deployed at [goblin.st](https://goblin.st). It implements [NIP-05](https://nips.nostr.com/5) resolution, [NIP-98](https://nips.nostr.com/98)-authenticated registration and release (names are never transferred - on a key rotation you release the old name and re-register, or import your existing identity). The wallet is fully usable - and fully anonymous - without it. Avatars aren't stored or served - clients render them from the pubkey (an npub gradient with the username's first letter, else the Grin mark).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -59,6 +59,16 @@
|
||||
<data android:pathPattern=".*\\.slatepack" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Goblin payment deep link: web "Open in Goblin" buttons and
|
||||
goblin: QR/links open the wallet straight to the send-review
|
||||
screen. BROWSABLE so a browser/click can resolve it. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="goblin" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="grim" />
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -235,6 +235,16 @@ public class MainActivity extends GameActivity {
|
||||
String action = intent.getAction();
|
||||
// Check if file was open with the application.
|
||||
if (action != null && action.equals(Intent.ACTION_VIEW)) {
|
||||
Uri data = intent.getData();
|
||||
String scheme = data != null ? data.getScheme() : null;
|
||||
// Goblin payment deep link (goblin: / nostr:): pass the URI text
|
||||
// straight to native code, which routes it to the send-review flow.
|
||||
// These are NOT files, so they must skip the file-descriptor path.
|
||||
if (scheme != null && (scheme.equalsIgnoreCase("goblin")
|
||||
|| scheme.equalsIgnoreCase("nostr"))) {
|
||||
onData(data.toString());
|
||||
return;
|
||||
}
|
||||
Intent i = getIntent();
|
||||
i.setData(intent.getData());
|
||||
setIntent(i);
|
||||
|
||||
@@ -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.
|
||||
@@ -18,7 +18,7 @@ A Goblin payment is **a Grin transaction wrapped in a private nostr message**.
|
||||
|
||||
2. **Nostr layer (the delivery).** Instead of making you hand slate files back
|
||||
and forth, Goblin delivers each slate as an **end-to-end-encrypted nostr
|
||||
direct message**, routed through the **Nym mixnet**. You pay a `username` or
|
||||
direct message**, routed through **Tor**. You pay a `username` or
|
||||
`npub`; the recipient's wallet applies the slate automatically.
|
||||
|
||||
The slate is the payload; nostr is the transport. Everything below is about how
|
||||
@@ -85,7 +85,7 @@ Names are kept fresh: see [§11 Name freshness](#11-contacts--name-freshness).
|
||||
|
||||
---
|
||||
|
||||
## 3. Transport: NIP-17 gift wraps over Nym
|
||||
## 3. Transport: NIP-17 gift wraps over Tor
|
||||
|
||||
A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
|
||||
`send_control_dm` (both in `src/nostr/client.rs`). The message structure
|
||||
@@ -106,10 +106,12 @@ A payment DM is built and sent by `send_payment_dm`; control messages (voids) by
|
||||
hints carried by a pasted `nprofile`. Default relays: `relay.goblin.st`,
|
||||
`relay.damus.io`, `nos.lol` (`src/nostr/relays.rs`), capped at `MAX_DM_RELAYS`.
|
||||
|
||||
**How relays are reached:** every relay connection runs through the in-process
|
||||
**Nym mixnet** SOCKS5 proxy (`NymWebSocketTransport`; `run_service` waits for the
|
||||
proxy to be ready before dialing). The mixnet hides who-talks-to-whom at the
|
||||
network layer. The Grin *node* connection (block sync + broadcasting the final tx)
|
||||
**How relays are reached:** every relay connection runs through an in-process
|
||||
**Tor** client (arti, linked directly into the wallet binary — no sidecar), via
|
||||
`TorWebSocketTransport` (`run_service` waits for Tor to be ready before dialing).
|
||||
So the relay never sees your IP: the money-path relay is dialed at its pinned
|
||||
`.onion` address, and any relay without one is reached over a Tor exit to its
|
||||
clearnet host. The Grin *node* connection (block sync + broadcasting the final tx)
|
||||
is direct clearnet — it's public chain data, the same for everyone, not tied to
|
||||
your identity.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Name=Goblin
|
||||
Exec=goblin
|
||||
Exec=goblin %u
|
||||
Icon=goblin
|
||||
Type=Application
|
||||
Categories=Finance
|
||||
MimeType=application/x-slatepack;text/plain;
|
||||
MimeType=application/x-slatepack;text/plain;x-scheme-handler/goblin;
|
||||
@@ -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"
|
||||
|
||||
+55
-16
@@ -359,13 +359,15 @@ 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…"
|
||||
balance_updating: "Guthaben wird aktualisiert…"
|
||||
balance_stale: "Knoten nicht erreichbar · letzter bekannter Saldo"
|
||||
fiat_unavailable: "Kurs nicht verfügbar"
|
||||
listening: "Wartet auf Zahlungen"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Warte auf Chain…"
|
||||
@@ -374,7 +376,9 @@ goblin:
|
||||
nav_activity: "Aktivität"
|
||||
nav_receive: "Empfangen"
|
||||
nav_settings: "Einstellungen"
|
||||
back_again: "Erneut Zurück drücken, um die Wallet zu wechseln"
|
||||
activity: "Aktivität"
|
||||
news: "Neuigkeiten"
|
||||
empty_title: "Noch keine Aktivität"
|
||||
empty_sub: "Sende oder empfange grin, um zu starten."
|
||||
recent: "Zuletzt"
|
||||
@@ -413,10 +417,11 @@ goblin:
|
||||
to: "An"
|
||||
from: "Von"
|
||||
nostr: "nostr"
|
||||
identity: "Identität"
|
||||
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"
|
||||
@@ -466,6 +471,7 @@ goblin:
|
||||
relays: "Relays"
|
||||
nostr_relays: "Nostr-Relays"
|
||||
node: "Node"
|
||||
min_conf: "Mindestbestätigungen"
|
||||
integrated_node: "Einstellungen des integrierten Nodes"
|
||||
node_advanced: "Erweitert"
|
||||
slatepacks: "Slatepacks"
|
||||
@@ -474,7 +480,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"
|
||||
@@ -484,6 +490,10 @@ goblin:
|
||||
requests: "Anfragen"
|
||||
incoming_requests: "Eingehende Anfragen"
|
||||
incoming_requests_sub: "Erlaube anderen, Geld von dir anzufordern"
|
||||
hide_amounts: "Beträge verbergen"
|
||||
hide_amounts_sub: "Empfangene Beträge in Benachrichtigungen verbergen"
|
||||
language: "Sprache"
|
||||
update_available: "Update verfügbar"
|
||||
appearance: "Erscheinungsbild"
|
||||
theme: "Design"
|
||||
theme_light: "Hell"
|
||||
@@ -497,7 +507,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 +627,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: "Benutzernamen"
|
||||
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 +642,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."
|
||||
@@ -669,12 +679,38 @@ goblin:
|
||||
n59_blurb: "Verpackt Nachrichten, sodass Relays nicht sehen können, wer mit wem kommuniziert."
|
||||
n98_title: "HTTP-Auth"
|
||||
n98_blurb: "Signiert die Benutzernamen-Registrierungsanfrage an goblin.st."
|
||||
identities:
|
||||
title: "Identitäten"
|
||||
switch_hint: "Identität wechseln"
|
||||
blurb: "Deine Identitäten sind die Namen, unter denen du bezahlt wirst. Alle zahlen in diese eine Wallet. Füge eine neue hinzu, wechsle zwischen ihnen oder entferne eine, die du nicht mehr brauchst."
|
||||
privacy_note: "Diese Identitäten teilen sich eine Wallet und ein Guthaben — getrennte Eingänge, keine getrennten Gelder. Für wirklich getrenntes Geld nutze eine separate Wallet."
|
||||
held: "Vorhandene Identitäten"
|
||||
add: "Identität hinzufügen"
|
||||
add_title: "Eine Identität hinzufügen"
|
||||
nsec_hint: "Nsec einfügen"
|
||||
generate_note: "Ein neuer, anonymer Schlüssel wird für diese Wallet erstellt."
|
||||
import_instead: "Stattdessen eine bestehende Identität importieren"
|
||||
generate_instead: "Stattdessen einen neuen Schlüssel erstellen"
|
||||
choose_backup: "Eine .backup-Datei wählen"
|
||||
backup_selected: "Backup-Datei ausgewählt"
|
||||
delete_short: "Identität löschen"
|
||||
delete_title: "%{name} löschen?"
|
||||
delete_blurb: "Dies entfernt die Identität dauerhaft aus dieser Wallet. Bereits erhaltenes Geld bleibt in deinem Guthaben."
|
||||
delete_backup_note: "Sichere sie zuerst — ohne ihren nsec oder ihre .backup-Datei ist sie nicht wiederherstellbar."
|
||||
delete_confirm: "Löschen"
|
||||
manage_title: "Identität verwalten"
|
||||
tag_note: "Gib ihr einen privaten Namen. Nur du siehst ihn — er wird nie geteilt."
|
||||
tag_hint: "Privater Name"
|
||||
tag_save: "Speichern"
|
||||
add_confirm: "Hinzufügen"
|
||||
pass_prompt: "Dein Wallet-Passwort verschlüsselt und entsperrt jede Identität. Gib es ein, um fortzufahren."
|
||||
working: "Arbeite…"
|
||||
onboarding:
|
||||
intro:
|
||||
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 +761,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 +770,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."
|
||||
@@ -789,14 +825,17 @@ goblin:
|
||||
row_from: "Von"
|
||||
row_to: "An"
|
||||
row_note: "Notiz"
|
||||
row_proof: "Zahlungsnachweis"
|
||||
row_proof_val: "Enthalten"
|
||||
row_proof_shared: "Nachweis geteilt mit"
|
||||
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"
|
||||
|
||||
+54
-15
@@ -359,13 +359,15 @@ 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…"
|
||||
balance_updating: "Balance updating…"
|
||||
balance_stale: "Can't reach node · last known balance"
|
||||
fiat_unavailable: "Rate unavailable"
|
||||
listening: "Listening for payments"
|
||||
block: "Block %{height}"
|
||||
waiting_for_chain: "Waiting for chain…"
|
||||
@@ -374,7 +376,9 @@ goblin:
|
||||
nav_activity: "Activity"
|
||||
nav_receive: "Receive"
|
||||
nav_settings: "Settings"
|
||||
back_again: "Press back again to switch wallets"
|
||||
activity: "Activity"
|
||||
news: "News"
|
||||
empty_title: "No activity yet"
|
||||
empty_sub: "Send or receive grin to get started."
|
||||
recent: "Recent"
|
||||
@@ -413,10 +417,11 @@ goblin:
|
||||
to: "To"
|
||||
from: "From"
|
||||
nostr: "nostr"
|
||||
identity: "Identity"
|
||||
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"
|
||||
@@ -466,6 +471,7 @@ goblin:
|
||||
relays: "Relays"
|
||||
nostr_relays: "Nostr Relays"
|
||||
node: "Node"
|
||||
min_conf: "Minimum confirmations"
|
||||
integrated_node: "Integrated node settings"
|
||||
node_advanced: "Advanced"
|
||||
slatepacks: "Slatepacks"
|
||||
@@ -474,7 +480,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"
|
||||
@@ -484,6 +490,10 @@ goblin:
|
||||
requests: "Requests"
|
||||
incoming_requests: "Incoming requests"
|
||||
incoming_requests_sub: "Let others request money from you"
|
||||
hide_amounts: "Hide amounts"
|
||||
hide_amounts_sub: "Hide received amounts in notifications"
|
||||
language: "Language"
|
||||
update_available: "Update available"
|
||||
appearance: "Appearance"
|
||||
theme: "Theme"
|
||||
theme_light: "Light"
|
||||
@@ -497,7 +507,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 +627,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 +642,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."
|
||||
@@ -669,12 +679,38 @@ goblin:
|
||||
n59_blurb: "Wraps messages so relays can't see who is talking to whom."
|
||||
n98_title: "HTTP auth"
|
||||
n98_blurb: "Signs the username registration request to goblin.st."
|
||||
identities:
|
||||
title: "Identities"
|
||||
switch_hint: "Switch identity"
|
||||
blurb: "Your identities are the names you get paid under. They all pay into this one wallet. Add a new one, switch between them, or remove one you don't need."
|
||||
privacy_note: "These identities share one wallet and one balance — separate front doors, not separate funds. For truly separate money, use a separate wallet."
|
||||
held: "Held identities"
|
||||
add: "Add identity"
|
||||
add_title: "Add an identity"
|
||||
nsec_hint: "Paste an nsec"
|
||||
generate_note: "A fresh, anonymous key will be created for this wallet."
|
||||
import_instead: "Import an existing identity instead"
|
||||
generate_instead: "Create a new key instead"
|
||||
choose_backup: "Choose a .backup file"
|
||||
backup_selected: "Backup file selected"
|
||||
delete_short: "Delete identity"
|
||||
delete_title: "Delete %{name}?"
|
||||
delete_blurb: "This permanently removes the identity from this wallet. Money already received stays in your balance."
|
||||
delete_backup_note: "Back it up first — without its nsec or .backup file it cannot be recovered."
|
||||
delete_confirm: "Delete"
|
||||
manage_title: "Manage identity"
|
||||
tag_note: "Give it a private name. Only you see it — it is never shared."
|
||||
tag_hint: "Private name"
|
||||
tag_save: "Save"
|
||||
add_confirm: "Add"
|
||||
pass_prompt: "Your wallet password encrypts and unlocks each identity. Enter it to continue."
|
||||
working: "Working…"
|
||||
onboarding:
|
||||
intro:
|
||||
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 +761,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 +770,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."
|
||||
@@ -789,14 +825,17 @@ goblin:
|
||||
row_from: "From"
|
||||
row_to: "To"
|
||||
row_note: "Note"
|
||||
row_proof: "Payment proof"
|
||||
row_proof_val: "Included"
|
||||
row_proof_shared: "Proof shared with"
|
||||
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"
|
||||
|
||||
+55
-16
@@ -359,13 +359,15 @@ 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…"
|
||||
balance_updating: "Solde en cours de mise à jour…"
|
||||
balance_stale: "Nœud injoignable · dernier solde connu"
|
||||
fiat_unavailable: "Taux indisponible"
|
||||
listening: "En attente de paiements"
|
||||
block: "Bloc %{height}"
|
||||
waiting_for_chain: "En attente de la chaîne…"
|
||||
@@ -374,7 +376,9 @@ goblin:
|
||||
nav_activity: "Activité"
|
||||
nav_receive: "Recevoir"
|
||||
nav_settings: "Réglages"
|
||||
back_again: "Appuyez à nouveau sur retour pour changer de portefeuille"
|
||||
activity: "Activité"
|
||||
news: "Actualités"
|
||||
empty_title: "Aucune activité"
|
||||
empty_sub: "Envoyez ou recevez des grin pour commencer."
|
||||
recent: "Récent"
|
||||
@@ -413,10 +417,11 @@ goblin:
|
||||
to: "À"
|
||||
from: "De"
|
||||
nostr: "nostr"
|
||||
identity: "Identité"
|
||||
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"
|
||||
@@ -466,6 +471,7 @@ goblin:
|
||||
relays: "Relais"
|
||||
nostr_relays: "Relais Nostr"
|
||||
node: "Nœud"
|
||||
min_conf: "Confirmations minimales"
|
||||
integrated_node: "Paramètres du nœud intégré"
|
||||
node_advanced: "Avancé"
|
||||
slatepacks: "Slatepacks"
|
||||
@@ -474,7 +480,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"
|
||||
@@ -484,6 +490,10 @@ goblin:
|
||||
requests: "Demandes"
|
||||
incoming_requests: "Demandes entrantes"
|
||||
incoming_requests_sub: "Laisser les autres vous demander de l'argent"
|
||||
hide_amounts: "Masquer les montants"
|
||||
hide_amounts_sub: "Masquer les montants reçus dans les notifications"
|
||||
language: "Langue"
|
||||
update_available: "Mise à jour disponible"
|
||||
appearance: "Apparence"
|
||||
theme: "Thème"
|
||||
theme_light: "Clair"
|
||||
@@ -497,7 +507,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 +627,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: "Noms d'utilisateur"
|
||||
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 +642,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."
|
||||
@@ -669,12 +679,38 @@ goblin:
|
||||
n59_blurb: "Enveloppe les messages pour que les relais ne voient pas qui parle à qui."
|
||||
n98_title: "Auth HTTP"
|
||||
n98_blurb: "Signe la demande d'enregistrement du nom d'utilisateur auprès de goblin.st."
|
||||
identities:
|
||||
title: "Identités"
|
||||
switch_hint: "Changer d'identité"
|
||||
blurb: "Vos identités sont les noms sous lesquels on vous paie. Elles alimentent toutes ce même portefeuille. Ajoutez-en une nouvelle, passez de l'une à l'autre, ou supprimez celle dont vous n'avez plus besoin."
|
||||
privacy_note: "Ces identités partagent un portefeuille et un solde — des portes d'entrée distinctes, pas des fonds distincts. Pour un argent vraiment séparé, utilisez un portefeuille distinct."
|
||||
held: "Identités détenues"
|
||||
add: "Ajouter une identité"
|
||||
add_title: "Ajouter une identité"
|
||||
nsec_hint: "Coller un nsec"
|
||||
generate_note: "Une nouvelle clé anonyme sera créée pour ce portefeuille."
|
||||
import_instead: "Importer plutôt une identité existante"
|
||||
generate_instead: "Créer plutôt une nouvelle clé"
|
||||
choose_backup: "Choisir un fichier .backup"
|
||||
backup_selected: "Fichier de sauvegarde sélectionné"
|
||||
delete_short: "Supprimer l'identité"
|
||||
delete_title: "Supprimer %{name} ?"
|
||||
delete_blurb: "Cela supprime définitivement l'identité de ce portefeuille. L'argent déjà reçu reste dans votre solde."
|
||||
delete_backup_note: "Sauvegardez-la d'abord — sans son nsec ou son fichier .backup, elle est irrécupérable."
|
||||
delete_confirm: "Supprimer"
|
||||
manage_title: "Gérer l'identité"
|
||||
tag_note: "Donnez-lui un nom privé. Vous seul le voyez — il n’est jamais partagé."
|
||||
tag_hint: "Nom privé"
|
||||
tag_save: "Enregistrer"
|
||||
add_confirm: "Ajouter"
|
||||
pass_prompt: "Le mot de passe de votre portefeuille chiffre et déverrouille chaque identité. Saisissez-le pour continuer."
|
||||
working: "En cours…"
|
||||
onboarding:
|
||||
intro:
|
||||
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 +761,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 +770,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."
|
||||
@@ -789,14 +825,17 @@ goblin:
|
||||
row_from: "De"
|
||||
row_to: "À"
|
||||
row_note: "Note"
|
||||
row_proof: "Preuve de paiement"
|
||||
row_proof_val: "Incluse"
|
||||
row_proof_shared: "Preuve partagée avec"
|
||||
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"
|
||||
|
||||
+57
-18
@@ -359,13 +359,15 @@ 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: "Синхронизация…"
|
||||
balance_updating: "Баланс обновляется…"
|
||||
balance_stale: "Узел недоступен · последний известный баланс"
|
||||
fiat_unavailable: "Курс недоступен"
|
||||
listening: "Ожидание платежей"
|
||||
block: "Блок %{height}"
|
||||
waiting_for_chain: "Ожидание цепочки…"
|
||||
@@ -374,7 +376,9 @@ goblin:
|
||||
nav_activity: "Действия"
|
||||
nav_receive: "Получить"
|
||||
nav_settings: "Настройки"
|
||||
back_again: "Нажмите «назад» ещё раз, чтобы сменить кошелёк"
|
||||
activity: "Действия"
|
||||
news: "Новости"
|
||||
empty_title: "Пока нет действий"
|
||||
empty_sub: "Отправьте или получите grin, чтобы начать."
|
||||
recent: "Недавние"
|
||||
@@ -413,10 +417,11 @@ goblin:
|
||||
to: "Кому"
|
||||
from: "От"
|
||||
nostr: "nostr"
|
||||
identity: "Личность"
|
||||
fee_none: "Нет"
|
||||
network_fee: "Сетевая комиссия"
|
||||
privacy: "Приватность"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "Транзакция"
|
||||
cancel_request: "Отменить запрос"
|
||||
cancel_send: "Отменить платёж"
|
||||
@@ -466,6 +471,7 @@ goblin:
|
||||
relays: "Реле"
|
||||
nostr_relays: "Реле Nostr"
|
||||
node: "Узел"
|
||||
min_conf: "Минимум подтверждений"
|
||||
integrated_node: "Настройки встроенного узла"
|
||||
node_advanced: "Дополнительно"
|
||||
slatepacks: "Slatepacks"
|
||||
@@ -474,7 +480,7 @@ goblin:
|
||||
switch_wallet: "Сменить кошелёк"
|
||||
advanced: "Дополнительно"
|
||||
privacy: "Приватность"
|
||||
mixnet_routing: "Маршрутизация через mixnet"
|
||||
mixnet_routing: "Маршрутизация через Tor"
|
||||
messages_lookups: "Сообщения и поиск"
|
||||
auto_accept: "Автоприём"
|
||||
pairing: "Валюта цены"
|
||||
@@ -484,6 +490,10 @@ goblin:
|
||||
requests: "Запросы"
|
||||
incoming_requests: "Входящие запросы"
|
||||
incoming_requests_sub: "Разрешить другим запрашивать у вас деньги"
|
||||
hide_amounts: "Скрыть суммы"
|
||||
hide_amounts_sub: "Скрывать полученные суммы в уведомлениях"
|
||||
language: "Язык"
|
||||
update_available: "Доступно обновление"
|
||||
appearance: "Внешний вид"
|
||||
theme: "Тема"
|
||||
theme_light: "Светлая"
|
||||
@@ -497,7 +507,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 +627,14 @@ goblin:
|
||||
hide_qr: "Скрыть QR"
|
||||
privacy:
|
||||
title: "Сетевая приватность"
|
||||
intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами."
|
||||
intro: "Goblin отправляет приватный трафик через Tor, который скрывает ваш IP от реле — шифрование скрывает остальное, чтобы реле не могло связать платёж с вами."
|
||||
payments: "Платежи"
|
||||
payments_blurb: "Каждое nostr-сообщение, несущее slatepack."
|
||||
usernames: "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 +642,7 @@ goblin:
|
||||
title: "Привязка"
|
||||
intro: "К чему привязаны отображаемые баланс и суммы."
|
||||
pair_with: "Привязать к"
|
||||
rates_note: "Курсы загружаются через mixnet Nym только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||
rates_note: "Курсы загружаются через Tor только при включённой привязке — выключено означает, что запрос курса не покидает устройство."
|
||||
relays:
|
||||
title: "Реле"
|
||||
intro: "Сообщения о платежах дублируются на каждое реле ниже; для получения достаточно одного доступного реле."
|
||||
@@ -665,16 +675,42 @@ goblin:
|
||||
n44_blurb: "Аутентифицированный шифр, используемый внутри этих сообщений."
|
||||
n49_title: "Шифрование ключа"
|
||||
n49_blurb: "Как секретный ключ хранится в покое, защищённый вашим паролем."
|
||||
n59_title: "Gift wrap"
|
||||
n59_title: "Подарочная обёртка"
|
||||
n59_blurb: "Оборачивает сообщения, чтобы реле не видели, кто с кем общается."
|
||||
n98_title: "HTTP-авторизация"
|
||||
n98_blurb: "Подписывает запрос регистрации имени на goblin.st."
|
||||
identities:
|
||||
title: "Личности"
|
||||
switch_hint: "Сменить личность"
|
||||
blurb: "Ваши личности — это имена, под которыми вам платят. Все они пополняют этот один кошелёк. Добавьте новую, переключайтесь между ними или удалите ненужную."
|
||||
privacy_note: "Эти личности используют один кошелёк и один баланс — отдельные двери, а не отдельные средства. Для действительно раздельных денег используйте отдельный кошелёк."
|
||||
held: "Имеющиеся личности"
|
||||
add: "Добавить личность"
|
||||
add_title: "Добавить личность"
|
||||
nsec_hint: "Вставьте nsec"
|
||||
generate_note: "Для этого кошелька будет создан новый анонимный ключ."
|
||||
import_instead: "Вместо этого импортировать существующую личность"
|
||||
generate_instead: "Вместо этого создать новый ключ"
|
||||
choose_backup: "Выбрать файл .backup"
|
||||
backup_selected: "Файл резервной копии выбран"
|
||||
delete_short: "Удалить личность"
|
||||
delete_title: "Удалить %{name}?"
|
||||
delete_blurb: "Это навсегда удалит личность из этого кошелька. Уже полученные деньги останутся на балансе."
|
||||
delete_backup_note: "Сначала сделайте резервную копию — без её nsec или файла .backup её невозможно восстановить."
|
||||
delete_confirm: "Удалить"
|
||||
manage_title: "Управление личностью"
|
||||
tag_note: "Дайте ей приватное имя. Его видите только вы — оно никогда не передаётся."
|
||||
tag_hint: "Приватное имя"
|
||||
tag_save: "Сохранить"
|
||||
add_confirm: "Добавить"
|
||||
pass_prompt: "Пароль кошелька шифрует и разблокирует каждую личность. Введите его, чтобы продолжить."
|
||||
working: "Обработка…"
|
||||
onboarding:
|
||||
intro:
|
||||
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,16 +761,16 @@ 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: "Выберите имя — необязательно"
|
||||
username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент."
|
||||
username_field_hint: "yourname"
|
||||
username_field_hint: "вашеимя"
|
||||
working: "Обработка…"
|
||||
claim_username: "Занять имя"
|
||||
available_when_connected: "Доступно после подключения mixnet — или пропустите и займите позже."
|
||||
available_when_connected: "Доступно после подключения Tor — или пропустите и займите позже."
|
||||
youre: "Вы %{name}"
|
||||
claimed_title: "%{name} теперь ваше"
|
||||
claimed_blurb: "Друзья теперь могут платить вам по имени. Всё готово — откройте кошелёк."
|
||||
@@ -789,14 +825,17 @@ goblin:
|
||||
row_from: "От"
|
||||
row_to: "Кому"
|
||||
row_note: "Заметка"
|
||||
row_proof: "Подтверждение платежа"
|
||||
row_proof_val: "Включено"
|
||||
row_proof_shared: "Подтверждение получит"
|
||||
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: "Удерживайте для отправки"
|
||||
|
||||
+54
-15
@@ -359,13 +359,15 @@ 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…"
|
||||
balance_updating: "Bakiye güncelleniyor…"
|
||||
balance_stale: "Düğüme ulaşılamıyor · son bilinen bakiye"
|
||||
fiat_unavailable: "Kur mevcut değil"
|
||||
listening: "Ödemeler bekleniyor"
|
||||
block: "Blok %{height}"
|
||||
waiting_for_chain: "Zincir bekleniyor…"
|
||||
@@ -374,7 +376,9 @@ goblin:
|
||||
nav_activity: "Etkinlik"
|
||||
nav_receive: "Al"
|
||||
nav_settings: "Ayarlar"
|
||||
back_again: "Cüzdan değiştirmek için tekrar geri bas"
|
||||
activity: "Etkinlik"
|
||||
news: "Haberler"
|
||||
empty_title: "Henüz etkinlik yok"
|
||||
empty_sub: "Başlamak için grin gönder ya da al."
|
||||
recent: "Son işlemler"
|
||||
@@ -413,10 +417,11 @@ goblin:
|
||||
to: "Alıcı"
|
||||
from: "Gönderen"
|
||||
nostr: "nostr"
|
||||
identity: "Kimlik"
|
||||
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"
|
||||
@@ -466,6 +471,7 @@ goblin:
|
||||
relays: "Relaylar"
|
||||
nostr_relays: "Nostr Relayları"
|
||||
node: "Düğüm"
|
||||
min_conf: "Asgari onay sayısı"
|
||||
integrated_node: "Tümleşik düğüm ayarları"
|
||||
node_advanced: "Gelişmiş"
|
||||
slatepacks: "Slatepackler"
|
||||
@@ -474,7 +480,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"
|
||||
@@ -484,6 +490,10 @@ goblin:
|
||||
requests: "İstekler"
|
||||
incoming_requests: "Gelen istekler"
|
||||
incoming_requests_sub: "Başkalarının senden para istemesine izin ver"
|
||||
hide_amounts: "Tutarları gizle"
|
||||
hide_amounts_sub: "Bildirimlerde alınan tutarları gizle"
|
||||
language: "Dil"
|
||||
update_available: "Güncelleme mevcut"
|
||||
appearance: "Görünüm"
|
||||
theme: "Tema"
|
||||
theme_light: "Açık"
|
||||
@@ -497,7 +507,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 +627,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 +642,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."
|
||||
@@ -669,12 +679,38 @@ goblin:
|
||||
n59_blurb: "Mesajları sarar, böylece relaylar kimin kiminle konuştuğunu göremez."
|
||||
n98_title: "HTTP kimlik doğrulama"
|
||||
n98_blurb: "goblin.st'ye gönderilen kullanıcı adı kayıt isteğini imzalar."
|
||||
identities:
|
||||
title: "Kimlikler"
|
||||
switch_hint: "Kimlik değiştir"
|
||||
blurb: "Kimliklerin, ödeme aldığın adlardır. Hepsi bu tek cüzdana öder. Yeni bir tane ekle, aralarında geçiş yap veya ihtiyacın olmayanı kaldır."
|
||||
privacy_note: "Bu kimlikler tek cüzdanı ve tek bakiyeyi paylaşır — ayrı kapılar, ayrı paralar değil. Gerçekten ayrı para için ayrı bir cüzdan kullan."
|
||||
held: "Mevcut kimlikler"
|
||||
add: "Kimlik ekle"
|
||||
add_title: "Bir kimlik ekle"
|
||||
nsec_hint: "Bir nsec yapıştır"
|
||||
generate_note: "Bu cüzdan için yeni, anonim bir anahtar oluşturulacak."
|
||||
import_instead: "Bunun yerine mevcut bir kimliği içe aktar"
|
||||
generate_instead: "Bunun yerine yeni bir anahtar oluştur"
|
||||
choose_backup: "Bir .backup dosyası seç"
|
||||
backup_selected: "Yedek dosyası seçildi"
|
||||
delete_short: "Kimliği sil"
|
||||
delete_title: "%{name} silinsin mi?"
|
||||
delete_blurb: "Bu, kimliği bu cüzdandan kalıcı olarak kaldırır. Zaten alınan para bakiyende kalır."
|
||||
delete_backup_note: "Önce yedeğini al — nsec’i veya .backup dosyası olmadan geri getirilemez."
|
||||
delete_confirm: "Sil"
|
||||
manage_title: "Kimliği yönet"
|
||||
tag_note: "Ona özel bir ad ver. Sadece sen görürsün — asla paylaşılmaz."
|
||||
tag_hint: "Özel ad"
|
||||
tag_save: "Kaydet"
|
||||
add_confirm: "Ekle"
|
||||
pass_prompt: "Cüzdan parolan her kimliği şifreler ve açar. Devam etmek için gir."
|
||||
working: "Çalışıyor…"
|
||||
onboarding:
|
||||
intro:
|
||||
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 +761,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 +770,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ç."
|
||||
@@ -789,14 +825,17 @@ goblin:
|
||||
row_from: "Gönderen"
|
||||
row_to: "Alıcı"
|
||||
row_note: "Not"
|
||||
row_proof: "Ödeme kanıtı"
|
||||
row_proof_val: "Dahil"
|
||||
row_proof_shared: "Kanıt paylaşılır"
|
||||
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"
|
||||
|
||||
+54
-15
@@ -359,13 +359,15 @@ 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: "同步中…"
|
||||
balance_updating: "余额更新中…"
|
||||
balance_stale: "无法连接节点 · 上次已知余额"
|
||||
fiat_unavailable: "汇率不可用"
|
||||
listening: "正在监听付款"
|
||||
block: "区块 %{height}"
|
||||
waiting_for_chain: "等待链数据…"
|
||||
@@ -374,7 +376,9 @@ goblin:
|
||||
nav_activity: "动态"
|
||||
nav_receive: "收款"
|
||||
nav_settings: "设置"
|
||||
back_again: "再按一次返回以切换钱包"
|
||||
activity: "动态"
|
||||
news: "新闻"
|
||||
empty_title: "暂无动态"
|
||||
empty_sub: "收发 grin 即可开始。"
|
||||
recent: "最近"
|
||||
@@ -413,10 +417,11 @@ goblin:
|
||||
to: "收款方"
|
||||
from: "付款方"
|
||||
nostr: "nostr"
|
||||
identity: "身份"
|
||||
fee_none: "无"
|
||||
network_fee: "网络费用"
|
||||
privacy: "隐私"
|
||||
privacy_value: "Mimblewimble + Nym"
|
||||
privacy_value: "Mimblewimble + Tor"
|
||||
transaction: "交易"
|
||||
cancel_request: "取消请求"
|
||||
cancel_send: "取消付款"
|
||||
@@ -466,6 +471,7 @@ goblin:
|
||||
relays: "中继"
|
||||
nostr_relays: "Nostr 中继"
|
||||
node: "节点"
|
||||
min_conf: "最少确认数"
|
||||
integrated_node: "集成节点设置"
|
||||
node_advanced: "高级"
|
||||
slatepacks: "Slatepack"
|
||||
@@ -474,7 +480,7 @@ goblin:
|
||||
switch_wallet: "切换钱包"
|
||||
advanced: "高级"
|
||||
privacy: "隐私"
|
||||
mixnet_routing: "mixnet 路由"
|
||||
mixnet_routing: "Tor 路由"
|
||||
messages_lookups: "消息和查询"
|
||||
auto_accept: "自动接受"
|
||||
pairing: "价格货币"
|
||||
@@ -484,6 +490,10 @@ goblin:
|
||||
requests: "请求"
|
||||
incoming_requests: "收到的请求"
|
||||
incoming_requests_sub: "允许他人向你请求付款"
|
||||
hide_amounts: "隐藏金额"
|
||||
hide_amounts_sub: "在通知中隐藏收到的金额"
|
||||
language: "语言"
|
||||
update_available: "有可用更新"
|
||||
appearance: "外观"
|
||||
theme: "主题"
|
||||
theme_light: "浅色"
|
||||
@@ -497,7 +507,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 +627,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 +642,7 @@ goblin:
|
||||
title: "配对"
|
||||
intro: "你的余额和金额以何种货币显示。"
|
||||
pair_with: "配对货币"
|
||||
rates_note: "汇率仅在开启配对时通过 Nym mixnet 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||
rates_note: "汇率仅在开启配对时通过 Tor 获取 — 关闭后不会有任何汇率请求离开你的设备。"
|
||||
relays:
|
||||
title: "中继"
|
||||
intro: "付款消息会镜像到下方每个中继;只要有一个可达的中继即可收款。"
|
||||
@@ -669,12 +679,38 @@ goblin:
|
||||
n59_blurb: "包装消息,使中继无法看到通信双方是谁。"
|
||||
n98_title: "HTTP 认证"
|
||||
n98_blurb: "为向 goblin.st 注册用户名的请求签名。"
|
||||
identities:
|
||||
title: "身份"
|
||||
switch_hint: "切换身份"
|
||||
blurb: "你的身份就是你收款时使用的名称。它们都汇入这一个钱包。添加新身份、在它们之间切换,或移除不再需要的身份。"
|
||||
privacy_note: "这些身份共用一个钱包和一个余额——是不同的入口,而不是不同的资金。若需真正分离的资金,请使用另一个钱包。"
|
||||
held: "已持有的身份"
|
||||
add: "添加身份"
|
||||
add_title: "添加一个身份"
|
||||
nsec_hint: "粘贴 nsec"
|
||||
generate_note: "将为此钱包创建一个全新的匿名密钥。"
|
||||
import_instead: "改为导入现有身份"
|
||||
generate_instead: "改为创建新密钥"
|
||||
choose_backup: "选择 .backup 文件"
|
||||
backup_selected: "已选择备份文件"
|
||||
delete_short: "删除身份"
|
||||
delete_title: "删除 %{name}?"
|
||||
delete_blurb: "这将从此钱包中永久移除该身份。已收到的钱仍保留在你的余额中。"
|
||||
delete_backup_note: "请先备份——没有它的 nsec 或 .backup 文件将无法恢复。"
|
||||
delete_confirm: "删除"
|
||||
manage_title: "管理身份"
|
||||
tag_note: "给它起一个私密名称。只有你能看到——绝不会被分享。"
|
||||
tag_hint: "私密名称"
|
||||
tag_save: "保存"
|
||||
add_confirm: "添加"
|
||||
pass_prompt: "钱包密码用于加密和解锁每个身份。输入以继续。"
|
||||
working: "处理中…"
|
||||
onboarding:
|
||||
intro:
|
||||
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 +761,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 +770,7 @@ goblin:
|
||||
username_field_hint: "你的用户名"
|
||||
working: "处理中…"
|
||||
claim_username: "注册用户名"
|
||||
available_when_connected: "mixnet 连接后可用 — 或跳过,稍后注册。"
|
||||
available_when_connected: "Tor 连接后可用 — 或跳过,稍后注册。"
|
||||
youre: "你是 %{name}"
|
||||
claimed_title: "%{name} 已归你所有"
|
||||
claimed_blurb: "朋友现在可以用你的用户名向你付款。一切就绪——打开钱包吧。"
|
||||
@@ -789,14 +825,17 @@ goblin:
|
||||
row_from: "付款方"
|
||||
row_to: "收款方"
|
||||
row_note: "备注"
|
||||
row_proof: "支付证明"
|
||||
row_proof_val: "已包含"
|
||||
row_proof_shared: "证明分享给"
|
||||
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: "长按发送"
|
||||
|
||||
@@ -57,6 +57,19 @@
|
||||
<string>Document</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>st.goblin.pay</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>goblin</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.finance</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
||||
+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
|
||||
|
||||
@@ -39,6 +39,17 @@ pub struct Desktop {
|
||||
|
||||
/// Flag to check if attention required after window focusing.
|
||||
attention_required: Arc<AtomicBool>,
|
||||
|
||||
/// Long-lived clipboard owner. On Linux (X11 AND Wayland) the clipboard
|
||||
/// selection is owned by the live `arboard::Clipboard` instance: the prior
|
||||
/// code created a fresh instance per call and dropped it the moment the
|
||||
/// function returned, so the selection ownership was released immediately and
|
||||
/// copied text (e.g. a recovery phrase) vanished before it could be pasted —
|
||||
/// the "Paste does nothing on desktop" bug. Keeping ONE instance alive for
|
||||
/// the app lifetime makes our process the durable selection owner, so a copy
|
||||
/// survives long enough to paste (in-app or into another window). Held behind
|
||||
/// a Mutex because the trait methods take `&self` and arboard's take `&mut`.
|
||||
clipboard: Arc<parking_lot::Mutex<Option<arboard::Clipboard>>>,
|
||||
}
|
||||
|
||||
impl Desktop {
|
||||
@@ -49,9 +60,28 @@ impl Desktop {
|
||||
camera_index: Arc::new(AtomicUsize::new(0)),
|
||||
stop_camera: Arc::new(AtomicBool::new(false)),
|
||||
attention_required: Arc::new(AtomicBool::new(false)),
|
||||
clipboard: Arc::new(parking_lot::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` against the process-wide, lazily-created clipboard instance,
|
||||
/// returning `default` if the clipboard backend can't be opened. Reusing one
|
||||
/// instance (rather than `Clipboard::new()` per call) is what keeps a copied
|
||||
/// selection alive on Linux — see the `clipboard` field.
|
||||
fn with_clipboard<R>(&self, f: impl FnOnce(&mut arboard::Clipboard) -> R, default: R) -> R {
|
||||
let mut guard = self.clipboard.lock();
|
||||
if guard.is_none() {
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(c) => *guard = Some(c),
|
||||
Err(e) => {
|
||||
log::error!("clipboard: failed to open: {e}");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
f(guard.as_mut().unwrap())
|
||||
}
|
||||
|
||||
// #[allow(dead_code)]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn start_camera_capture(
|
||||
@@ -211,13 +241,24 @@ impl PlatformCallbacks for Desktop {
|
||||
}
|
||||
|
||||
fn copy_string_to_buffer(&self, data: String) {
|
||||
let mut clipboard = arboard::Clipboard::new().unwrap();
|
||||
clipboard.set_text(data).unwrap();
|
||||
// Reuse the long-lived instance so the selection survives the call (see
|
||||
// the `clipboard` field). A backend error is logged, never a panic — a
|
||||
// failed copy must not crash the wallet.
|
||||
self.with_clipboard(
|
||||
|clipboard| {
|
||||
if let Err(e) = clipboard.set_text(data) {
|
||||
log::error!("clipboard: set_text failed: {e}");
|
||||
}
|
||||
},
|
||||
(),
|
||||
);
|
||||
}
|
||||
|
||||
fn get_string_from_buffer(&self) -> String {
|
||||
let mut clipboard = arboard::Clipboard::new().unwrap();
|
||||
clipboard.get_text().unwrap_or("".to_string())
|
||||
self.with_clipboard(
|
||||
|clipboard| clipboard.get_text().unwrap_or_default(),
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn start_camera(&self) {
|
||||
@@ -359,3 +400,28 @@ lazy_static! {
|
||||
/// Last captured image from started camera.
|
||||
static ref LAST_CAMERA_IMAGE: Arc<RwLock<Option<(Vec<u8>, u32)>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clipboard_tests {
|
||||
use super::*;
|
||||
use crate::gui::platform::PlatformCallbacks;
|
||||
|
||||
/// Round-trips a copy then paste through the REAL `Desktop` platform impl on
|
||||
/// the live session clipboard. Ignored by default (needs a display/clipboard
|
||||
/// backend); run manually with a Wayland/X11 session:
|
||||
/// cargo test --lib clipboard_roundtrip -- --ignored --nocapture
|
||||
/// Proves the persistent-instance fix: with the old fresh-instance-per-call
|
||||
/// pattern this read back empty on Wayland/X11.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn clipboard_roundtrip() {
|
||||
let d = Desktop::new();
|
||||
let phrase = "abandon ability able about above absent absorb abstract \
|
||||
absurd abuse access accident account accuse achieve acid acoustic \
|
||||
acquire across act action actor actress";
|
||||
d.copy_string_to_buffer(phrase.to_string());
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
let got = d.get_string_from_buffer();
|
||||
assert_eq!(got, phrase, "clipboard round-trip lost the copied text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use grin_wallet_libwallet::TxLogEntryType;
|
||||
|
||||
use crate::nostr::{Contact, NostrSendStatus, NostrStore, TxNostrMeta};
|
||||
use crate::nostr::{Contact, NewsItem, NostrSendStatus, NostrStore, TxNostrMeta};
|
||||
use crate::wallet::Wallet;
|
||||
use crate::wallet::types::WalletTx;
|
||||
|
||||
@@ -34,6 +34,10 @@ pub struct ActivityItem {
|
||||
pub time: i64,
|
||||
/// Counterparty npub hex, when known.
|
||||
pub npub: Option<String>,
|
||||
/// The wallet's OWN nostr identity (pubkey hex) that was active when this tx
|
||||
/// happened — the front door it used. Empty/None on pre-feature rows (treated
|
||||
/// as the primary identity). Drives the subtle per-identity row cue.
|
||||
pub owner_pubkey: Option<String>,
|
||||
}
|
||||
|
||||
/// Full detail for the receipt / transaction-detail screen: GRIM tx data
|
||||
@@ -316,6 +320,10 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0);
|
||||
let canceled = is_canceled(tx, meta.as_ref());
|
||||
let owner_pubkey = meta
|
||||
.as_ref()
|
||||
.map(|m| m.recipient_pubkey.clone())
|
||||
.filter(|h| !h.is_empty());
|
||||
|
||||
ActivityItem {
|
||||
tx_id: tx.data.id,
|
||||
@@ -328,6 +336,7 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
system,
|
||||
time,
|
||||
npub: meta.map(|m| m.npub),
|
||||
owner_pubkey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,3 +387,363 @@ pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(Strin
|
||||
hits.truncate(limit);
|
||||
hits
|
||||
}
|
||||
|
||||
/// The news post to show in the Home panel for the wallet's active language, or
|
||||
/// `None` (panel hides). Selection is language-aware: the newest article whose
|
||||
/// detected language matches the app locale, falling back to the newest English
|
||||
/// article. The returned item's title has any `[xx]` language marker stripped
|
||||
/// for display. `GOBLIN_FAKE_NEWS=1` injects a fixed multilingual set in debug
|
||||
/// builds so the panel can be screenshotted without a live relay feed.
|
||||
pub fn news_latest(wallet: &Wallet) -> Option<NewsItem> {
|
||||
let items = news_pool(wallet);
|
||||
let mut item = select_news(&items, &news_locale_code())?;
|
||||
item.title = news_display_title(&item.title);
|
||||
Some(item)
|
||||
}
|
||||
|
||||
/// The candidate news set (all cached posts), or a fixed multilingual sample
|
||||
/// under `GOBLIN_FAKE_NEWS` in debug builds. Kept separate from selection so the
|
||||
/// selection logic stays a pure, unit-testable function.
|
||||
fn news_pool(wallet: &Wallet) -> Vec<NewsItem> {
|
||||
#[cfg(debug_assertions)]
|
||||
if std::env::var("GOBLIN_FAKE_NEWS").is_ok() {
|
||||
return vec![
|
||||
NewsItem {
|
||||
d: "welcome-en".to_string(),
|
||||
created_at: 100,
|
||||
title: "Welcome to Goblin".to_string(),
|
||||
summary: "Private grin payments over Tor. Read more: https://docs.goblin.st"
|
||||
.to_string(),
|
||||
lang: None,
|
||||
published_at: Some(1_782_864_000), // 2026-07-01 UTC
|
||||
},
|
||||
NewsItem {
|
||||
d: "welcome-de".to_string(),
|
||||
created_at: 100,
|
||||
title: "Willkommen bei Goblin [de]".to_string(),
|
||||
summary: "Private Grin-Zahlungen über Tor. Mehr dazu: https://docs.goblin.st"
|
||||
.to_string(),
|
||||
lang: None,
|
||||
published_at: Some(1_782_864_000), // 2026-07-01 UTC
|
||||
},
|
||||
];
|
||||
}
|
||||
wallet
|
||||
.nostr_service()
|
||||
.map(|s| s.store.all_news())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The app's active locale folded to the ISO 639-1 code used to match news
|
||||
/// articles. The shipped locales are `en/de/fr/ru/tr/zh-CN`; only `zh-CN` needs
|
||||
/// folding to its 639-1 primary `zh`, and every other locale already is a
|
||||
/// two-letter primary. Region and separator (`-`/`_`) are dropped.
|
||||
fn news_locale_code() -> String {
|
||||
let loc = rust_i18n::locale().to_string().to_lowercase();
|
||||
loc.split(['-', '_']).next().unwrap_or("en").to_string()
|
||||
}
|
||||
|
||||
/// Detect an article's language as a lower-case ISO 639-1 code. Priority: the
|
||||
/// stored event language tag, then a trailing `[xx]` marker on the title, else
|
||||
/// English (`None`). Pure — the unit tests exercise it directly.
|
||||
pub fn news_language(item: &NewsItem) -> Option<String> {
|
||||
if let Some(l) = &item.lang {
|
||||
let l = l.trim().to_lowercase();
|
||||
if is_lang_code(&l) {
|
||||
return Some(l);
|
||||
}
|
||||
}
|
||||
title_lang_marker(&item.title)
|
||||
}
|
||||
|
||||
/// The trailing `[xx]` marker on a title (case-insensitive, `xx` = two ASCII
|
||||
/// letters), as a lower-case code, or `None`. Only a marker at the very end of
|
||||
/// the (trimmed) title counts, so a `[link]` mid-sentence is never mistaken for
|
||||
/// a language.
|
||||
fn title_lang_marker(title: &str) -> Option<String> {
|
||||
let t = title.trim();
|
||||
let inner = t.strip_suffix(']')?.rsplit_once('[')?.1;
|
||||
let code = inner.trim().to_lowercase();
|
||||
if is_lang_code(&code) {
|
||||
Some(code)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A displayable title with any trailing `[xx]` language marker removed.
|
||||
pub fn news_display_title(title: &str) -> String {
|
||||
match title_lang_marker(title) {
|
||||
Some(_) => {
|
||||
let t = title.trim_end();
|
||||
// Drop the `[xx]` token and the whitespace that preceded it.
|
||||
match t.rfind('[') {
|
||||
Some(idx) => t[..idx].trim_end().to_string(),
|
||||
None => t.to_string(),
|
||||
}
|
||||
}
|
||||
None => title.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a unix timestamp (seconds) as an ISO-8601 calendar date in UTC
|
||||
/// (`YYYY-MM-DD`), never a US `M/D/Y`, and date only (no time-of-day, unlike the
|
||||
/// activity feed). Dates the Home news panel. An out-of-range stamp falls back to
|
||||
/// the epoch date rather than panicking.
|
||||
pub fn news_date_iso(ts: i64) -> String {
|
||||
use chrono::{TimeZone, Utc};
|
||||
Utc.timestamp_opt(ts, 0)
|
||||
.single()
|
||||
.map(|dt| dt.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_else(|| "1970-01-01".to_string())
|
||||
}
|
||||
|
||||
/// The hard character budget for a Home news title before it ellipsizes. Titles
|
||||
/// up to ~34 chars sit at the full 16pt on a 390px phone; between there and this
|
||||
/// cap the panel shrinks the font to keep one line; past it they are ellipsized.
|
||||
/// This is the author's predictable writing budget.
|
||||
pub const NEWS_TITLE_MAX_CHARS: usize = 48;
|
||||
|
||||
/// A news title clamped to [`NEWS_TITLE_MAX_CHARS`], ellipsizing (`…`) past it so
|
||||
/// an over-long title is handled predictably rather than relying on layout alone.
|
||||
/// The shrink-to-fit font sizing in the panel is the second, screen-width-aware
|
||||
/// half of the guardrail. Clamps on `char` boundaries so multi-byte titles are
|
||||
/// never split mid-codepoint.
|
||||
pub fn news_title_clamped(title: &str) -> String {
|
||||
let chars: Vec<char> = title.chars().collect();
|
||||
if chars.len() > NEWS_TITLE_MAX_CHARS {
|
||||
let keep = NEWS_TITLE_MAX_CHARS.saturating_sub(1);
|
||||
format!("{}…", chars[..keep].iter().collect::<String>())
|
||||
} else {
|
||||
title.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a two-letter ASCII-alphabetic language code.
|
||||
fn is_lang_code(s: &str) -> bool {
|
||||
s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Select the news article to show for `target` (an ISO 639-1 code): the newest
|
||||
/// article in that language, else the newest English article (English = an
|
||||
/// explicit `en` OR no detected language). Pure so it is unit-testable without a
|
||||
/// wallet/store. Ties on `created_at` resolve to the last such article.
|
||||
pub fn select_news(items: &[NewsItem], target: &str) -> Option<NewsItem> {
|
||||
let target = if target.is_empty() { "en" } else { target };
|
||||
let lang_of = |it: &NewsItem| news_language(it).unwrap_or_else(|| "en".to_string());
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| lang_of(it) == target)
|
||||
.max_by_key(|it| it.created_at)
|
||||
.or_else(|| {
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| lang_of(it) == "en")
|
||||
.max_by_key(|it| it.created_at)
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Split a plain-text summary into (segment, is_url) runs so http(s) URLs render
|
||||
/// as tappable links and the rest as plain labels. Trailing sentence
|
||||
/// punctuation is trimmed off a URL so "…goblin.st." doesn't link the dot.
|
||||
pub fn split_urls(s: &str) -> Vec<(String, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let mut rest = s;
|
||||
while let Some(idx) = rest.find("http") {
|
||||
let candidate = &rest[idx..];
|
||||
if candidate.starts_with("http://") || candidate.starts_with("https://") {
|
||||
if idx > 0 {
|
||||
out.push((rest[..idx].to_string(), false));
|
||||
}
|
||||
let end = candidate
|
||||
.find(char::is_whitespace)
|
||||
.unwrap_or(candidate.len());
|
||||
let mut url = &candidate[..end];
|
||||
while let Some(last) = url.chars().last() {
|
||||
if matches!(last, '.' | ',' | ')' | ']' | '}' | '!' | '?' | ';' | ':') {
|
||||
url = &url[..url.len() - last.len_utf8()];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push((url.to_string(), true));
|
||||
rest = &candidate[url.len()..];
|
||||
} else {
|
||||
// A bare "http" that isn't a scheme; emit it as text and move past it.
|
||||
let split_at = idx + 4;
|
||||
out.push((rest[..split_at].to_string(), false));
|
||||
rest = &rest[split_at..];
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
out.push((rest.to_string(), false));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_urls_isolates_links() {
|
||||
let segs = split_urls("Tor is live. Read more: https://docs.goblin.st now");
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![
|
||||
("Tor is live. Read more: ".to_string(), false),
|
||||
("https://docs.goblin.st".to_string(), true),
|
||||
(" now".to_string(), false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_urls_trims_trailing_punctuation_and_handles_no_url() {
|
||||
let segs = split_urls("See https://x.io.");
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![
|
||||
("See ".to_string(), false),
|
||||
("https://x.io".to_string(), true),
|
||||
(".".to_string(), false),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
split_urls("plain text"),
|
||||
vec![("plain text".to_string(), false)]
|
||||
);
|
||||
}
|
||||
|
||||
fn news(d: &str, created_at: i64, title: &str, lang: Option<&str>) -> NewsItem {
|
||||
NewsItem {
|
||||
d: d.to_string(),
|
||||
created_at,
|
||||
title: title.to_string(),
|
||||
summary: String::new(),
|
||||
lang: lang.map(|s| s.to_string()),
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_from_event_tag_wins() {
|
||||
// A stored event language tag is authoritative, even if the title has no
|
||||
// marker (bare `["l","de"]`) or a differing marker.
|
||||
let it = news("a", 1, "Neuigkeiten", Some("de"));
|
||||
assert_eq!(news_language(&it).as_deref(), Some("de"));
|
||||
// NIP-32-style tag is stored the same way (code already extracted upstream).
|
||||
let it = news("b", 1, "News", Some("FR"));
|
||||
assert_eq!(news_language(&it).as_deref(), Some("fr"));
|
||||
// A non-code tag value is ignored, falling through to the title (English).
|
||||
let it = news("c", 1, "News", Some("english"));
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_from_title_suffix_marker() {
|
||||
let it = news("a", 1, "2026-07-05 Welcome to Goblin [de]", None);
|
||||
assert_eq!(news_language(&it).as_deref(), Some("de"));
|
||||
// Case-insensitive.
|
||||
let it = news("b", 1, "Bonjour [FR]", None);
|
||||
assert_eq!(news_language(&it).as_deref(), Some("fr"));
|
||||
// Only a marker at the very end counts; a bracketed word mid-title does not.
|
||||
let it = news("c", 1, "Read the [guide] today", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_means_english() {
|
||||
let it = news("a", 1, "Welcome to Goblin", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
// A non-two-letter bracket suffix is not a language marker.
|
||||
let it = news("b", 1, "Build 137 [beta]", None);
|
||||
assert_eq!(news_language(&it), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_strips_marker() {
|
||||
assert_eq!(
|
||||
news_display_title("2026-07-05 Welcome to Goblin [de]"),
|
||||
"2026-07-05 Welcome to Goblin"
|
||||
);
|
||||
assert_eq!(news_display_title("Bonjour [FR]"), "Bonjour");
|
||||
// No marker: unchanged.
|
||||
assert_eq!(news_display_title("Welcome to Goblin"), "Welcome to Goblin");
|
||||
// Non-language bracket suffix: left intact.
|
||||
assert_eq!(news_display_title("Build 137 [beta]"), "Build 137 [beta]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_is_iso_utc_day_only() {
|
||||
// 2026-07-01 00:00:00 UTC.
|
||||
assert_eq!(news_date_iso(1_782_864_000), "2026-07-01");
|
||||
// A within-the-day stamp still yields the same calendar date (no time).
|
||||
assert_eq!(news_date_iso(1_782_864_000 + 3600 * 13 + 59), "2026-07-01");
|
||||
// Epoch.
|
||||
assert_eq!(news_date_iso(0), "1970-01-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_clamped_ellipsizes_past_max() {
|
||||
// Short titles pass through untouched.
|
||||
let short = "News in Your Language";
|
||||
assert_eq!(news_title_clamped(short), short);
|
||||
// Exactly the cap is untouched.
|
||||
let at_cap = "x".repeat(NEWS_TITLE_MAX_CHARS);
|
||||
assert_eq!(news_title_clamped(&at_cap), at_cap);
|
||||
// One over the cap ellipsizes to exactly the cap length (… included).
|
||||
let over = "y".repeat(NEWS_TITLE_MAX_CHARS + 10);
|
||||
let clamped = news_title_clamped(&over);
|
||||
assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS);
|
||||
assert!(clamped.ends_with('…'));
|
||||
// Multi-byte titles clamp on char boundaries (no panic / no split codepoint).
|
||||
let cjk = "语".repeat(NEWS_TITLE_MAX_CHARS + 5);
|
||||
let clamped = news_title_clamped(&cjk);
|
||||
assert_eq!(clamped.chars().count(), NEWS_TITLE_MAX_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_matches_locale_then_falls_back_to_english() {
|
||||
let pool = vec![
|
||||
news("en", 100, "Welcome to Goblin", None),
|
||||
news("de", 90, "Willkommen bei Goblin [de]", None),
|
||||
news("fr", 80, "Bonjour", Some("fr")),
|
||||
];
|
||||
// German locale → the German article.
|
||||
assert_eq!(select_news(&pool, "de").unwrap().d, "de");
|
||||
// French locale (via event tag) → the French article.
|
||||
assert_eq!(select_news(&pool, "fr").unwrap().d, "fr");
|
||||
// English locale → the English (unmarked) article.
|
||||
assert_eq!(select_news(&pool, "en").unwrap().d, "en");
|
||||
// A locale with no article → fall back to the newest English article.
|
||||
assert_eq!(select_news(&pool, "ru").unwrap().d, "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_picks_newest_within_language_slice() {
|
||||
let pool = vec![
|
||||
news("de-old", 50, "Alt [de]", None),
|
||||
news("de-new", 150, "Neu [de]", None),
|
||||
news("en", 200, "Newest overall", None),
|
||||
];
|
||||
// Within German, the newest German article wins — NOT the newer English one.
|
||||
assert_eq!(select_news(&pool, "de").unwrap().d, "de-new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_folding_maps_zh_cn_to_zh() {
|
||||
rust_i18n::set_locale("zh-CN");
|
||||
assert_eq!(news_locale_code(), "zh");
|
||||
rust_i18n::set_locale("de");
|
||||
assert_eq!(news_locale_code(), "de");
|
||||
rust_i18n::set_locale("en");
|
||||
assert_eq!(news_locale_code(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_selects_nothing() {
|
||||
assert!(select_news(&[], "de").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,6 @@ pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
(to(r), to(g), to(b))
|
||||
}
|
||||
|
||||
/// Standard HSL → RGB → `#rrggbb`.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
let (r, g, b) = hsl_rgb8(h, s, l);
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical
|
||||
/// lowercase hex pubkey used as the seed everywhere.
|
||||
pub fn to_hex_seed(id: &str) -> String {
|
||||
@@ -70,21 +64,48 @@ pub fn to_hex_seed(id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient stop colors (`#rrggbb`) + rotation angle derived from the seed `hex`.
|
||||
/// Shared by the Grin-mark avatar and the bare-background variant so both draw
|
||||
/// the byte-identical gradient for one key. Keep this math in lockstep with the
|
||||
/// shared reference port.
|
||||
fn gradient_params(hex: &str) -> (String, String, f64) {
|
||||
/// Gradient stop colors (RGB bytes) + rotation angle derived from the seed `hex`.
|
||||
/// The single source of the per-identity gradient math; both the `#rrggbb`
|
||||
/// string form (for the SVG avatar) and the byte form (for the small egui cue)
|
||||
/// come from here, so a dot/edge cue matches the avatar exactly. Keep this in
|
||||
/// lockstep with the shared reference port.
|
||||
fn gradient_rgb(hex: &str) -> ((u8, u8, u8), (u8, u8, u8), f64) {
|
||||
let hash = Sha256::digest(hex.as_bytes());
|
||||
let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0;
|
||||
let offset = 40.0 + (hash[2] as f64 / 255.0) * 120.0;
|
||||
let h2 = (base + offset) % 360.0;
|
||||
let angle = (hash[3] as f64 / 255.0) * 360.0;
|
||||
let c1 = hsl_to_rgb(base, 0.62, 0.55);
|
||||
let c2 = hsl_to_rgb(h2, 0.62, 0.42);
|
||||
let c1 = hsl_rgb8(base, 0.62, 0.55);
|
||||
let c2 = hsl_rgb8(h2, 0.62, 0.42);
|
||||
(c1, c2, angle)
|
||||
}
|
||||
|
||||
/// Gradient stop colors (`#rrggbb`) + rotation angle for the SVG avatar.
|
||||
fn gradient_params(hex: &str) -> (String, String, f64) {
|
||||
let (c1, c2, angle) = gradient_rgb(hex);
|
||||
let hex_of = |(r, g, b): (u8, u8, u8)| format!("#{r:02x}{g:02x}{b:02x}");
|
||||
(hex_of(c1), hex_of(c2), angle)
|
||||
}
|
||||
|
||||
/// The two gradient stop colors (RGB bytes) for an identity, seeded by the same
|
||||
/// pubkey math as its gradient avatar, so a small dot or edge cue drawn from
|
||||
/// these matches that identity's avatar. `id` may be an npub or raw hex.
|
||||
pub fn gradient_rgb8(id: &str) -> ((u8, u8, u8), (u8, u8, u8)) {
|
||||
let hex = to_hex_seed(id);
|
||||
let (c1, c2, _angle) = gradient_rgb(&hex);
|
||||
(c1, c2)
|
||||
}
|
||||
|
||||
/// The identity's gradient stops PLUS the rotation angle (degrees), so a small
|
||||
/// egui-drawn badge can reproduce the same rotated linear gradient the SVG
|
||||
/// avatar uses (`gradient_avatar_svg`: `x1=0 y1=0 x2=1 y2=1` then
|
||||
/// `rotate(angle, 0.5, 0.5)`). `id` may be an npub or raw hex.
|
||||
pub fn gradient_stops(id: &str) -> ((u8, u8, u8), (u8, u8, u8), f32) {
|
||||
let hex = to_hex_seed(id);
|
||||
let (c1, c2, angle) = gradient_rgb(&hex);
|
||||
(c1, c2, angle as f32)
|
||||
}
|
||||
|
||||
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
|
||||
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
|
||||
/// are inlined into ONE html document; for a standalone document (how egui
|
||||
|
||||
+1401
-99
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
+320
-26
@@ -145,6 +145,13 @@ pub struct SendFlow {
|
||||
fee_requested_for: Option<u64>,
|
||||
/// Draft note held while the editor modal is open, so Cancel discards it.
|
||||
note_draft: String,
|
||||
/// Proof-on-request context parsed from the pay URI (frozen contract 4.1).
|
||||
/// `proof` is the merchant's grin1 proof address (presence => proof mode),
|
||||
/// `order` the opaque invoice handle, `notify` the watcher npub. All `None`
|
||||
/// for a normal send, so person-to-person payments carry nothing extra.
|
||||
proof: Option<String>,
|
||||
order: Option<String>,
|
||||
notify: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SendFlow {
|
||||
@@ -171,6 +178,9 @@ impl Default for SendFlow {
|
||||
receipt_npub: None,
|
||||
fee_requested_for: None,
|
||||
note_draft: String::new(),
|
||||
proof: None,
|
||||
order: None,
|
||||
notify: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,6 +220,87 @@ impl SendFlow {
|
||||
self.stage = Stage::Recipient;
|
||||
}
|
||||
|
||||
/// Build a send flow from a scanned/deep-linked payment payload and land it
|
||||
/// on the SAME screen a scanned QR would: a full checkout (recipient key +
|
||||
/// amount) opens the prefilled review directly; anything else drops into the
|
||||
/// recipient search with any amount/memo prefilled. Used by the `goblin:` /
|
||||
/// `nostr:` deep-link handler so a web "Open in Goblin" button reaches the
|
||||
/// exact QR-to-review destination. `now` is the egui input time (debounce seed
|
||||
/// for the search path).
|
||||
pub fn from_deeplink(uri: &str, wallet: &Wallet, now: f64) -> Self {
|
||||
let mut flow = Self::default();
|
||||
flow.apply_scan(uri, wallet, now);
|
||||
flow
|
||||
}
|
||||
|
||||
/// Apply a scanned/deep-linked payment payload (npub / `nostr:` / `goblin:`
|
||||
/// URI / @handle, with optional amount+memo), mutating this flow exactly as
|
||||
/// the camera scan path does. Shared by the QR scanner and the deep-link
|
||||
/// handler so both spell the same destination. `now` seeds the search-box
|
||||
/// debounce.
|
||||
fn apply_scan(&mut self, text: &str, wallet: &Wallet, now: f64) {
|
||||
// Proof-on-request context (frozen contract 4.1) rides alongside the
|
||||
// recipient/amount/memo routing and is independent of whether we land on
|
||||
// Review or Search, so pull it from the same pure parse. `order` is a
|
||||
// non-editable routing key, never shown as the memo. Absent params leave
|
||||
// proof mode off, so a normal payment is byte-identical to before.
|
||||
let pay = crate::nostr::payuri::parse(text);
|
||||
self.proof = pay.proof;
|
||||
self.order = pay.order;
|
||||
self.notify = pay.notify;
|
||||
match recognize_scan(text) {
|
||||
// GoblinPay checkout / full pay link: recipient key AND amount are
|
||||
// both known, so skip discovery and open review directly, prefilled.
|
||||
// (Owner-ordered: a merchant checkout shouldn't re-walk the recipient
|
||||
// search.) A cheap LOCAL contact lookup supplies a friendly name when
|
||||
// we already know them; otherwise the short npub — no network
|
||||
// resolution on this path.
|
||||
ScanPrefill::Review {
|
||||
hex,
|
||||
relay_hints,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
let name = wallet
|
||||
.nostr_service()
|
||||
.and_then(|s| s.store.contact(&hex).map(|c| display_name(&c)))
|
||||
.unwrap_or_else(|| short_npub(&hex));
|
||||
self.recipient = Some(Recipient {
|
||||
name,
|
||||
npub: hex,
|
||||
relay_hints,
|
||||
});
|
||||
self.amount = amount;
|
||||
if let Some(memo) = memo {
|
||||
self.note = memo;
|
||||
}
|
||||
self.error = None;
|
||||
self.stage = Stage::Review;
|
||||
}
|
||||
// Only a recipient (or a name/@handle needing resolution): drop it
|
||||
// into the search box so the picker's debounced lookup resolves +
|
||||
// verifies it like typed input, exactly as before. Any amount/memo
|
||||
// still prefill.
|
||||
ScanPrefill::Search {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
self.search = recipient;
|
||||
self.input_changed_at = now;
|
||||
self.lookup_query.clear();
|
||||
self.net_candidate = None;
|
||||
if let Some(amount) = amount {
|
||||
self.amount = amount;
|
||||
}
|
||||
if let Some(memo) = memo {
|
||||
self.note = memo;
|
||||
}
|
||||
self.stage = Stage::Recipient;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the flow. Returns true when the flow is finished (close it).
|
||||
pub fn ui(
|
||||
&mut self,
|
||||
@@ -488,6 +579,7 @@ impl SendFlow {
|
||||
ui,
|
||||
&name,
|
||||
&data::full_npub(&npub),
|
||||
"",
|
||||
&npub,
|
||||
"",
|
||||
false,
|
||||
@@ -550,6 +642,7 @@ impl SendFlow {
|
||||
ui,
|
||||
&c.name,
|
||||
&tag,
|
||||
"",
|
||||
&c.npub,
|
||||
"",
|
||||
false,
|
||||
@@ -680,31 +773,10 @@ impl SendFlow {
|
||||
// seed words or slatepack contents into the search box.
|
||||
match &result {
|
||||
QrScanResult::Text(text) => {
|
||||
// Parse as a (possibly amount-bearing) pay-URI. UNTRUSTED
|
||||
// input: the parser is pure, fail-closed, and only ever
|
||||
// PREFILLS — the recipient still resolves + verifies via the
|
||||
// picker and the amount/review screens still gate the send.
|
||||
// A bad amount/memo is dropped; a bare `nostr:<nprofile>`
|
||||
// behaves exactly as before.
|
||||
let pay = crate::nostr::payuri::parse(text);
|
||||
// Drop the scanned key into the search box; the picker's
|
||||
// debounced lookup resolves + verifies it like typed input.
|
||||
self.search = pay.recipient;
|
||||
self.input_changed_at = ui.input(|i| i.time);
|
||||
self.lookup_query.clear();
|
||||
self.net_candidate = None;
|
||||
// Prefill the amount only when the wallet's own parser
|
||||
// accepted it (strictly positive). We stay on the normal
|
||||
// picker -> amount/review flow; nothing auto-advances.
|
||||
if let Some(amount) = pay.amount {
|
||||
self.amount = amount;
|
||||
}
|
||||
// Prefill the send note from the (already sanitized) memo;
|
||||
// it rides along into the tx message via `dispatch`.
|
||||
if let Some(memo) = pay.memo {
|
||||
self.note = memo;
|
||||
}
|
||||
let _ = wallet;
|
||||
// Classify + apply the scan. UNTRUSTED input: the parser is pure
|
||||
// and fail-closed, and every path still gates the send on the
|
||||
// review screen (the user confirms) — nothing auto-SENDS.
|
||||
self.apply_scan(text, wallet, ui.input(|i| i.time));
|
||||
}
|
||||
_ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()),
|
||||
}
|
||||
@@ -976,7 +1048,7 @@ impl SendFlow {
|
||||
w::amount_text_centered(ui, &display, 64.0);
|
||||
}
|
||||
if let Ok(grin) = display.parse::<f64>() {
|
||||
if let Some(preview) = super::pairing_preview(grin) {
|
||||
if let Some(preview) = super::pairing_preview(grin, ui.ctx()) {
|
||||
ui.add_space(6.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
@@ -1151,6 +1223,34 @@ impl SendFlow {
|
||||
&format!("\u{201C}{}\u{201D}", self.note.trim()),
|
||||
);
|
||||
}
|
||||
// Proof-on-request indicator (frozen contract W3): a small, informational
|
||||
// row shown only when this payment will return a receiver-signed Grin
|
||||
// payment proof. Seeing it is the whole interaction: nothing to accept or
|
||||
// configure. Absent for every ordinary send.
|
||||
if self.proof.is_some() && !self.request {
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_proof"),
|
||||
&t!("goblin.send.row_proof_val"),
|
||||
);
|
||||
// Make the proof's hidden delivery target visible and consented: when
|
||||
// a `notify` npub rode in on the scanned URI, the wallet gift-wraps
|
||||
// the full signed proof (with the buyer's sender address + kernel) to
|
||||
// that key at finalize. Show exactly where it goes so it is a seen,
|
||||
// chosen recipient rather than a silent watcher: if you scanned it,
|
||||
// you asked for it. Display only; nothing here changes what is sent.
|
||||
if let Some(notify) = self.notify.as_deref() {
|
||||
// Truncate the bech32 npub for a single-line row (npub1abc…wxyz).
|
||||
// `notify` is a validated npub (ASCII, well over 18 chars), so the
|
||||
// byte slices land on char boundaries.
|
||||
let short = if notify.len() > 18 {
|
||||
format!("{}…{}", ¬ify[..12], ¬ify[notify.len() - 6..])
|
||||
} else {
|
||||
notify.to_string()
|
||||
};
|
||||
w::info_row(ui, &t!("goblin.send.row_proof_shared"), &short);
|
||||
}
|
||||
}
|
||||
if self.request {
|
||||
w::info_row(
|
||||
ui,
|
||||
@@ -1273,6 +1373,9 @@ impl SendFlow {
|
||||
recipient.npub.clone(),
|
||||
note,
|
||||
recipient.relay_hints.clone(),
|
||||
self.proof.clone(),
|
||||
self.order.clone(),
|
||||
self.notify.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1509,3 +1612,194 @@ fn resolve_nip05_blocking(name: &str, domain: &str) -> Option<nip05::Nip05Resolu
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// What a scanned QR resolves to for the send flow.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum ScanPrefill {
|
||||
/// A GoblinPay payment: the recipient key AND a validated amount are both
|
||||
/// already known, so the flow skips the recipient search/discovery step and
|
||||
/// opens the review screen directly. The user still confirms on review.
|
||||
Review {
|
||||
/// Recipient pubkey hex.
|
||||
hex: String,
|
||||
/// nprofile relay hints (delivery targets for an undiscoverable 10050).
|
||||
relay_hints: Vec<String>,
|
||||
/// Validated decimal-GRIN amount string.
|
||||
amount: String,
|
||||
/// Optional sanitized memo → send note.
|
||||
memo: Option<String>,
|
||||
},
|
||||
/// Only a recipient string (a bare key/name, or a payload without a usable
|
||||
/// amount): drop it into the search box and let the picker resolve it, the
|
||||
/// pre-existing behavior. Any amount/memo still prefill.
|
||||
Search {
|
||||
recipient: String,
|
||||
amount: Option<String>,
|
||||
memo: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Decode a recipient string (`npub` / `nprofile` / 64-char hex, with an
|
||||
/// optional `nostr:` prefix) to a `(pubkey hex, relay hints)` pair. `None` for a
|
||||
/// name / `@handle` (which needs a NIP-05 network resolution) or garbage — those
|
||||
/// still go through the search box. Pure: no I/O.
|
||||
fn decode_recipient_key(input: &str) -> Option<(String, Vec<String>)> {
|
||||
use nostr_sdk::nips::nip19::Nip19Profile;
|
||||
use nostr_sdk::{FromBech32, PublicKey};
|
||||
let key = input.trim().strip_prefix("nostr:").unwrap_or(input.trim());
|
||||
if let Ok(pk) = PublicKey::from_bech32(key) {
|
||||
Some((pk.to_hex(), vec![]))
|
||||
} else if let Ok(p) = Nip19Profile::from_bech32(key) {
|
||||
let hints = p.relays.iter().map(|r| r.to_string()).collect();
|
||||
Some((p.public_key.to_hex(), hints))
|
||||
} else if key.len() == 64 && key.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
Some((key.to_lowercase(), vec![]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a scanned QR payload (see [`ScanPrefill`]). A GoblinPay checkout QR
|
||||
/// carries the recipient AND amount, so when the recipient decodes to a concrete
|
||||
/// key locally and the amount validates, we can skip discovery and go straight
|
||||
/// to review. Every other payload (name/@handle, amount-less key, non-`nostr:`
|
||||
/// text) degrades to the search-box path. Pure and total, so it is unit-testable
|
||||
/// without a wallet or camera.
|
||||
fn recognize_scan(scanned: &str) -> ScanPrefill {
|
||||
let pay = crate::nostr::payuri::parse(scanned);
|
||||
match (decode_recipient_key(&pay.recipient), pay.amount.clone()) {
|
||||
(Some((hex, relay_hints)), Some(amount)) => ScanPrefill::Review {
|
||||
hex,
|
||||
relay_hints,
|
||||
amount,
|
||||
memo: pay.memo,
|
||||
},
|
||||
_ => ScanPrefill::Search {
|
||||
recipient: pay.recipient,
|
||||
amount: pay.amount,
|
||||
memo: pay.memo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A real, valid bech32 npub for decode tests (the Goblin news key).
|
||||
const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely";
|
||||
|
||||
#[test]
|
||||
fn goblinpay_uri_with_amount_goes_straight_to_review() {
|
||||
let uri = format!("nostr:{NPUB}?amount=1.5&memo=MM-ABC123");
|
||||
match recognize_scan(&uri) {
|
||||
ScanPrefill::Review {
|
||||
hex,
|
||||
amount,
|
||||
memo,
|
||||
relay_hints,
|
||||
} => {
|
||||
assert_eq!(hex.len(), 64);
|
||||
assert_eq!(amount, "1.5");
|
||||
assert_eq!(memo.as_deref(), Some("MM-ABC123"));
|
||||
assert!(relay_hints.is_empty());
|
||||
}
|
||||
other => panic!("expected Review, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_scheme_pay_link_goes_straight_to_review() {
|
||||
// The `goblin:` deep-link scheme (web "Open in Goblin" buttons) must
|
||||
// classify identically to the `nostr:` QR payload: a full pay link
|
||||
// (key + amount) lands on the prefilled review.
|
||||
let uri = format!("goblin:{NPUB}?amount=1.5&memo=MM-ABC123");
|
||||
match recognize_scan(&uri) {
|
||||
ScanPrefill::Review {
|
||||
hex,
|
||||
amount,
|
||||
memo,
|
||||
relay_hints,
|
||||
} => {
|
||||
assert_eq!(hex.len(), 64);
|
||||
assert_eq!(amount, "1.5");
|
||||
assert_eq!(memo.as_deref(), Some("MM-ABC123"));
|
||||
assert!(relay_hints.is_empty());
|
||||
}
|
||||
other => panic!("expected Review, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_and_nostr_schemes_classify_equivalently() {
|
||||
// Same recipient + amount + memo under either scheme yields the same
|
||||
// classification and the same extracted values.
|
||||
for scheme in ["goblin", "nostr"] {
|
||||
match recognize_scan(&format!("{scheme}:{NPUB}?amount=2&memo=Hi")) {
|
||||
ScanPrefill::Review { amount, memo, .. } => {
|
||||
assert_eq!(amount, "2");
|
||||
assert_eq!(memo.as_deref(), Some("Hi"));
|
||||
}
|
||||
other => panic!("{scheme}: expected Review, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_without_amount_uses_search_box() {
|
||||
// A bare recipient key with no amount is NOT a full payment; the picker
|
||||
// resolves it as before (search box), no straight-to-review.
|
||||
match recognize_scan(&format!("nostr:{NPUB}")) {
|
||||
ScanPrefill::Search {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
assert_eq!(recipient, NPUB);
|
||||
assert_eq!(amount, None);
|
||||
assert_eq!(memo, None);
|
||||
}
|
||||
other => panic!("expected Search, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_handle_with_amount_still_uses_search_box() {
|
||||
// A NIP-05 name needs a network resolution, so even with an amount it goes
|
||||
// through the search box (which prefills the amount along the way).
|
||||
match recognize_scan("nostr:alice@goblin.st?amount=2") {
|
||||
ScanPrefill::Search {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
} => {
|
||||
assert_eq!(recipient, "alice@goblin.st");
|
||||
assert_eq!(amount.as_deref(), Some("2"));
|
||||
assert_eq!(memo, None);
|
||||
}
|
||||
other => panic!("expected Search, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_nostr_payload_uses_search_box() {
|
||||
match recognize_scan("just some text") {
|
||||
ScanPrefill::Search { recipient, .. } => assert_eq!(recipient, "just some text"),
|
||||
other => panic!("expected Search, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_key_variants() {
|
||||
// Bech32 npub, with and without the scheme prefix.
|
||||
assert!(decode_recipient_key(NPUB).is_some());
|
||||
assert!(decode_recipient_key(&format!("nostr:{NPUB}")).is_some());
|
||||
// 64-char hex is accepted and lower-cased.
|
||||
let hex = "AB".repeat(32);
|
||||
let (out, _) = decode_recipient_key(&hex).unwrap();
|
||||
assert_eq!(out, hex.to_lowercase());
|
||||
// A name / @handle is not a concrete key.
|
||||
assert!(decode_recipient_key("alice@goblin.st").is_none());
|
||||
assert!(decode_recipient_key("@alice").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+315
-58
@@ -464,13 +464,59 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) {
|
||||
/// A balance hero block: kicker, big number + ツ, optional fiat line.
|
||||
/// `updating` marks a zero balance that is only zero because funds are in
|
||||
/// flight or the first sync is still running.
|
||||
/// Honest subline shown under the balance figure. A wallet that can't reach a
|
||||
/// node must never present a bare `0` (or a silently-stale number) as if it were
|
||||
/// a live, confirmed balance.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum BalanceSubline {
|
||||
/// Nothing to add: the shown balance is live and non-zero.
|
||||
None,
|
||||
/// Balance reads 0 while a sync/first-scan is in progress or funds are in
|
||||
/// flight — say "updating", not "empty".
|
||||
Updating,
|
||||
/// Balance reads 0 and the node is unreachable with nothing cached — say
|
||||
/// "can't reach node", never a bare 0.
|
||||
Unreachable,
|
||||
/// A cached (last-known) balance is shown but the node is currently
|
||||
/// unreachable — flag it as possibly stale.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// Pure decision for the balance subline. `updating` means a sync is in progress
|
||||
/// (or funds are in flight); `error` means the wallet currently can't reach a
|
||||
/// node. Priority: updating > unreachable > stale.
|
||||
pub fn balance_subline(total: u64, updating: bool, error: bool) -> BalanceSubline {
|
||||
if total == 0 && updating {
|
||||
BalanceSubline::Updating
|
||||
} else if total == 0 && error {
|
||||
BalanceSubline::Unreachable
|
||||
} else if error {
|
||||
BalanceSubline::Stale
|
||||
} else {
|
||||
BalanceSubline::None
|
||||
}
|
||||
}
|
||||
|
||||
/// What the fiat subline should render under the balance. `None` (pairing off)
|
||||
/// draws no line at all; otherwise the line is honest about its state and never
|
||||
/// paints a stale rate as if current.
|
||||
pub enum FiatLine {
|
||||
/// A ready "≈ … · 1ツ = …" line built from a fresh rate.
|
||||
Text(String),
|
||||
/// A live fetch is in flight; show a subtle placeholder, not a number.
|
||||
Loading,
|
||||
/// The rate could not be fetched; say so rather than show an old value.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub fn balance_hero(
|
||||
ui: &mut Ui,
|
||||
total: u64,
|
||||
spendable: u64,
|
||||
updating: bool,
|
||||
error: bool,
|
||||
sync_pct: u8,
|
||||
fiat: Option<&str>,
|
||||
fiat: Option<FiatLine>,
|
||||
size: f32,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
@@ -498,30 +544,70 @@ pub fn balance_hero(
|
||||
);
|
||||
});
|
||||
}
|
||||
// A fresh sync or funds in flight leave a stark 0 that reads as "funds
|
||||
// vanished" — say the balance is still updating, with the scan % when the
|
||||
// node reports one (1..99), so the user sees progress without opening the
|
||||
// wallet switcher.
|
||||
if total == 0 && updating {
|
||||
let label = if (1..100).contains(&sync_pct) {
|
||||
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
|
||||
} else {
|
||||
t!("goblin.home.balance_updating").to_string()
|
||||
// A stark 0 (or a stale number) reads as "funds vanished". Pick the honest
|
||||
// subline: still-updating, node-unreachable, or last-known-balance. See
|
||||
// [`balance_subline`] for the pure state machine.
|
||||
match balance_subline(total, updating, error) {
|
||||
BalanceSubline::Updating => {
|
||||
let label = if (1..100).contains(&sync_pct) {
|
||||
format!("{} {sync_pct}%", t!("goblin.home.balance_updating"))
|
||||
} else {
|
||||
t!("goblin.home.balance_updating").to_string()
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::Unreachable => {
|
||||
// Node unreachable and nothing cached yet: a bare 0 would claim the
|
||||
// wallet is empty. Say the truth so the user switches nodes instead
|
||||
// of assuming funds vanished.
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.home.cant_reach_node"))
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.neg),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::Stale => {
|
||||
// A cached balance is shown but we can't currently reach a node:
|
||||
// flag it as possibly stale rather than presenting it as live.
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.home.balance_stale"))
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
BalanceSubline::None => {}
|
||||
}
|
||||
if let Some(fiat) = fiat {
|
||||
// Loading and Unavailable both render a subtle dim line — never a stale
|
||||
// number. While loading, nudge egui to re-poll so the rate pops in once the
|
||||
// live fetch lands even if the view is otherwise idle (bounded to the time
|
||||
// the balance is actually on screen — not a background timer).
|
||||
let text = match fiat {
|
||||
FiatLine::Text(s) => s,
|
||||
FiatLine::Loading => {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(300));
|
||||
"≈ …".to_string()
|
||||
}
|
||||
FiatLine::Unavailable => t!("goblin.home.fiat_unavailable").to_string(),
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(12.5, fonts::medium()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
});
|
||||
}
|
||||
if let Some(fiat) = fiat {
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(fiat)
|
||||
RichText::new(text)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
@@ -529,12 +615,15 @@ pub fn balance_hero(
|
||||
}
|
||||
}
|
||||
|
||||
/// An activity row: avatar, title, subtitle, signed amount.
|
||||
/// An activity row: avatar, a left title/message column that truncates, and a
|
||||
/// right column with the signed amount over the date/time. `time` is the
|
||||
/// right-side timestamp (empty draws no time line — e.g. a canceled tx).
|
||||
/// Returns the row click response.
|
||||
pub fn activity_row(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
note: &str,
|
||||
time: &str,
|
||||
id: &str,
|
||||
amount: &str,
|
||||
incoming: bool,
|
||||
@@ -543,7 +632,10 @@ pub fn activity_row(
|
||||
tex: Option<&egui::TextureHandle>,
|
||||
) -> Response {
|
||||
let t = theme::tokens();
|
||||
let row_h = 60.0;
|
||||
// A touch taller than a single-line row so the amount can sit centered
|
||||
// against the two-line title/subtitle stack with clear breathing room
|
||||
// above and below instead of colliding with the title baseline.
|
||||
let row_h = 64.0;
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click());
|
||||
let mut content = ui.new_child(
|
||||
@@ -572,41 +664,77 @@ pub fn activity_row(
|
||||
avatar_any(ui, title, id, 40.0, tex);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(title)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.text),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
// Single-line, truncated: keeps the fixed-height row tidy even when
|
||||
// the subtitle is a long value (e.g. a full npub in the picker).
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(subtitle)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
});
|
||||
// Right column FIRST so the left title/message column is bounded to the
|
||||
// remaining width and truncates cleanly. The amount sits on top with the
|
||||
// date/time right-aligned directly beneath it; a row with no timestamp
|
||||
// (a canceled tx) draws no time line at all.
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
// A canceled tx delivered no funds: mute the amount so it never
|
||||
// reads as a completed green credit (or a real debit).
|
||||
ui.label(
|
||||
RichText::new(amount)
|
||||
.font(FontId::new(15.0, fonts::mono_semibold()))
|
||||
.color(if canceled {
|
||||
t.text_dim
|
||||
} else if incoming {
|
||||
t.pos
|
||||
} else {
|
||||
t.text
|
||||
}),
|
||||
);
|
||||
let amt_ink = if canceled {
|
||||
t.text_dim
|
||||
} else if incoming {
|
||||
t.pos
|
||||
} else {
|
||||
t.text
|
||||
};
|
||||
let amt_font = FontId::new(15.0, fonts::mono_semibold());
|
||||
let time_font = FontId::new(13.0, fonts::regular());
|
||||
let amt_g = (!amount.is_empty()).then(|| {
|
||||
ui.painter()
|
||||
.layout_no_wrap(amount.to_string(), amt_font, amt_ink)
|
||||
});
|
||||
let time_g = (!time.is_empty()).then(|| {
|
||||
ui.painter()
|
||||
.layout_no_wrap(time.to_string(), time_font, t.text_dim)
|
||||
});
|
||||
let col_w = amt_g
|
||||
.as_ref()
|
||||
.map(|g| g.size().x)
|
||||
.unwrap_or(0.0)
|
||||
.max(time_g.as_ref().map(|g| g.size().x).unwrap_or(0.0));
|
||||
if col_w > 0.0 {
|
||||
let amt_h = amt_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
|
||||
let time_h = time_g.as_ref().map(|g| g.size().y).unwrap_or(0.0);
|
||||
let col_h = amt_h + if time_h > 0.0 { 2.0 + time_h } else { 0.0 };
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(col_w, col_h),
|
||||
Layout::top_down(Align::Max),
|
||||
|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
if let Some(g) = amt_g {
|
||||
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
|
||||
ui.painter().galley(r.min, g, amt_ink);
|
||||
}
|
||||
if let Some(g) = time_g {
|
||||
let (r, _) = ui.allocate_exact_size(g.size(), Sense::hover());
|
||||
ui.painter().galley(r.min, g, t.text_dim);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
// Remaining width to the left: the counterparty/title on top, the
|
||||
// message pinned left and truncated with an ellipsis beneath it.
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(title)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.text),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
if !note.is_empty() {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(note)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Divider.
|
||||
@@ -938,3 +1066,132 @@ impl HoldToSend {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`info_row`], but with the per-identity gradient dot drawn just left of
|
||||
/// the value — the transaction-detail legend for which held identity a payment
|
||||
/// used, alongside its name/npub.
|
||||
pub fn info_row_dot(ui: &mut Ui, label: &str, value: &str, seed: &str) {
|
||||
let t = theme::tokens();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(value)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.text),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
ui.add_space(7.0);
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
|
||||
identity_dot(ui.painter(), rect.center(), 4.0, seed);
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.painter().hline(
|
||||
ui.min_rect().left()..=ui.min_rect().right(),
|
||||
ui.cursor().top(),
|
||||
Stroke::new(1.0, t.line),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// A per-identity cue: a small disc filled with an identity's OWN gradient — the
|
||||
/// same pubkey-seeded, rotated two-stop linear gradient its avatar uses
|
||||
/// (`identicon`), so the disc reads as a color legend for that identity and
|
||||
/// matches its avatar everywhere it appears (activity-row corner badge, switcher,
|
||||
/// transaction detail). The fill is a true smooth gradient (an egui mesh, not a
|
||||
/// flat chip) at 0.9 opacity, with a 1px theme-aware hairline ring that keeps it
|
||||
/// legible on both a pure-white and a pure-black background. `seed` is the
|
||||
/// identity's npub or pubkey hex. Single shared renderer, so the look is tuned in
|
||||
/// exactly one place. Matches the owner-approved cue mock precisely.
|
||||
pub fn identity_dot(painter: &egui::Painter, center: egui::Pos2, radius: f32, seed: &str) {
|
||||
let t = theme::tokens();
|
||||
let ((r1, g1, b1), (r2, g2, b2), angle) = super::identicon::gradient_stops(seed);
|
||||
// 0.9 fill opacity per the mock.
|
||||
const FILL_A: u8 = 230;
|
||||
let lerp = |a: u8, b: u8, f: f32| (a as f32 + (b as f32 - a as f32) * f).round() as u8;
|
||||
let col = |f: f32| {
|
||||
let f = f.clamp(0.0, 1.0);
|
||||
Color32::from_rgba_unmultiplied(lerp(r1, r2, f), lerp(g1, g2, f), lerp(b1, b2, f), FILL_A)
|
||||
};
|
||||
// Reproduce the SVG's rotated linear gradient (base axis 0,0 -> 1,1 in the
|
||||
// unit bounding box, then rotate(angle) about the centre). For a rim point in
|
||||
// direction (cv, sv) on the unit circle, its bounding-box offset from centre
|
||||
// is (0.5·cv, 0.5·sv); inverse-rotating by the gradient angle and projecting
|
||||
// onto the (1,1) axis gives the stop parameter t.
|
||||
let (sin_a, cos_a) = angle.to_radians().sin_cos();
|
||||
let t_at = |cv: f32, sv: f32| -> f32 {
|
||||
let dx = 0.5 * cv;
|
||||
let dy = 0.5 * sv;
|
||||
let rx = cos_a * dx + sin_a * dy;
|
||||
let ry = -sin_a * dx + cos_a * dy;
|
||||
(rx + ry) * 0.5 + 0.5
|
||||
};
|
||||
// Gradient-filled disc as a triangle fan; egui interpolates the per-vertex
|
||||
// colours, so the fill is a smooth gradient at this size.
|
||||
let n = 28usize;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
mesh.colored_vertex(center, col(t_at(0.0, 0.0)));
|
||||
for i in 0..=n {
|
||||
let a = std::f32::consts::TAU * (i as f32 / n as f32);
|
||||
let (sv, cv) = a.sin_cos();
|
||||
mesh.colored_vertex(center + radius * egui::vec2(cv, sv), col(t_at(cv, sv)));
|
||||
}
|
||||
for i in 1..=n as u32 {
|
||||
mesh.add_triangle(0, i, i + 1);
|
||||
}
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
// 1px theme-aware hairline ring (matches the mock): near-black on light,
|
||||
// near-white on dark, so the disc has a defined edge on either background and
|
||||
// against the avatar it badges.
|
||||
let ring = if t.dark_base {
|
||||
Color32::from_rgba_unmultiplied(250, 250, 247, 82)
|
||||
} else {
|
||||
Color32::from_rgba_unmultiplied(14, 14, 12, 71)
|
||||
};
|
||||
painter.circle_stroke(center, radius, Stroke::new(1.0, ring));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BalanceSubline, balance_subline};
|
||||
|
||||
// A live, non-zero balance needs no subline.
|
||||
#[test]
|
||||
fn live_balance_has_no_subline() {
|
||||
assert_eq!(balance_subline(1_000, false, false), BalanceSubline::None);
|
||||
}
|
||||
|
||||
// Zero while syncing / funds in flight is "updating", not "empty".
|
||||
#[test]
|
||||
fn zero_while_updating_says_updating() {
|
||||
assert_eq!(balance_subline(0, true, false), BalanceSubline::Updating);
|
||||
}
|
||||
|
||||
// Zero with an unreachable node and nothing cached must say so, never a
|
||||
// bare 0 that reads as "wallet empty" (the silent-zero incident).
|
||||
#[test]
|
||||
fn zero_with_node_error_says_unreachable() {
|
||||
assert_eq!(balance_subline(0, false, true), BalanceSubline::Unreachable);
|
||||
}
|
||||
|
||||
// A cached balance shown during a node outage is flagged stale, not passed
|
||||
// off as a live figure.
|
||||
#[test]
|
||||
fn cached_balance_with_error_is_stale() {
|
||||
assert_eq!(balance_subline(500, false, true), BalanceSubline::Stale);
|
||||
}
|
||||
|
||||
// Updating wins over error while the balance is still zero: a fresh switch
|
||||
// to a new node shows progress, not a scary red banner, until it errors.
|
||||
#[test]
|
||||
fn updating_takes_priority_over_error_at_zero() {
|
||||
assert_eq!(balance_subline(0, true, true), BalanceSubline::Updating);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::gui::views::wallets::wallet::RecoverySettings;
|
||||
use crate::gui::views::wallets::wallet::types::{WalletContentContainer, wallet_status_text};
|
||||
use crate::gui::views::{Content, Modal, TitlePanel, View};
|
||||
use crate::http::{ReleaseInfo, retrieve_release};
|
||||
use crate::node::Node;
|
||||
use crate::settings::AppUpdate;
|
||||
use crate::wallet::types::{ConnectionMethod, WalletTask};
|
||||
use crate::wallet::{Wallet, WalletList};
|
||||
@@ -52,6 +53,10 @@ pub struct WalletsContent {
|
||||
/// List of wallets.
|
||||
wallets: WalletList,
|
||||
|
||||
/// When Android back was last swallowed at the wallet Home, arming the
|
||||
/// standard double-back-to-exit window (the first press shows a hint).
|
||||
back_exit_at: Option<std::time::Instant>,
|
||||
|
||||
/// Initial wallet creation [`Modal`] content.
|
||||
add_wallet_modal_content: AddWalletModal,
|
||||
/// Wallet opening [`Modal`] content.
|
||||
@@ -92,6 +97,7 @@ impl Default for WalletsContent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wallets: WalletList::default(),
|
||||
back_exit_at: None,
|
||||
wallet_selection_content: WalletListModal::new(None, None, true),
|
||||
open_wallet_content: OpenWalletModal::new(),
|
||||
add_wallet_modal_content: AddWalletModal::default(),
|
||||
@@ -411,7 +417,26 @@ impl WalletsContent {
|
||||
// never a back fallback to the chooser.
|
||||
if self.wallet_content.can_back() {
|
||||
self.wallet_content.back(cb);
|
||||
return false;
|
||||
}
|
||||
// Wallet Home with nothing to pop: double-back to the WALLET
|
||||
// SWITCHER. The first press arms a short window and shows a "press
|
||||
// back again" hint; a second press inside it DESELECTS the wallet —
|
||||
// it stays UNLOCKED (no close, no logout; its nostr listener keeps
|
||||
// running) and the chooser shows. Quitting the app lives only at
|
||||
// the switcher, via GRIM's native exit-confirmation on back.
|
||||
// Sub-screens above kept normal back navigation, no hint.
|
||||
if self
|
||||
.back_exit_at
|
||||
.map(|at| at.elapsed() <= Duration::from_secs(2))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.back_exit_at = None;
|
||||
self.wallets.select(None);
|
||||
return false;
|
||||
}
|
||||
self.back_exit_at = Some(std::time::Instant::now());
|
||||
self.wallet_content.show_back_hint();
|
||||
return false;
|
||||
}
|
||||
true
|
||||
@@ -466,6 +491,17 @@ impl WalletsContent {
|
||||
if !Content::is_dual_panel_mode(ui.ctx()) && Content::is_network_panel_open() {
|
||||
Content::toggle_network_panel();
|
||||
}
|
||||
// Route a Goblin payment deep link (`goblin:` / `nostr:` pay URI) to the
|
||||
// send-review flow instead of the slatepack message handler: stash it for
|
||||
// the Goblin surface to open, and select/open the wallet with NO message
|
||||
// so the surface shows. Same destination as scanning a checkout QR.
|
||||
let data = match data {
|
||||
Some(d) if crate::nostr::payuri::is_pay_uri(&d) => {
|
||||
crate::set_pending_pay_uri(d);
|
||||
None
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
// Pass data to single wallet or show wallets selection.
|
||||
if wallets_size == 1 {
|
||||
let w = self.wallets.list()[0].clone();
|
||||
@@ -825,7 +861,7 @@ impl WalletsContent {
|
||||
// Show button to see update information.
|
||||
View::item_button(ui, CornerRadius::default(), NOTEPAD, None, || {
|
||||
self.changelog_content = Some(ChangelogContent::new(update.changelog.clone()));
|
||||
let title = format!("Grim {}", update.version);
|
||||
let title = format!("Goblin {}", update.version);
|
||||
Modal::new(ChangelogContent::MODAL_ID)
|
||||
.position(ModalPosition::Center)
|
||||
.title(title)
|
||||
|
||||
@@ -16,7 +16,7 @@ use egui::scroll_area::ScrollBarVisibility;
|
||||
use egui::{Id, OpenUrl, RichText, ScrollArea};
|
||||
|
||||
use crate::gui::Colors;
|
||||
use crate::gui::icons::{BRACKETS_CURLY, GITHUB_LOGO, TELEGRAM_LOGO};
|
||||
use crate::gui::icons::{BOOK, GITHUB_LOGO, TELEGRAM_LOGO};
|
||||
use crate::gui::views::{Modal, View};
|
||||
|
||||
/// Application release changelog content.
|
||||
@@ -26,11 +26,11 @@ pub struct ChangelogContent {
|
||||
}
|
||||
|
||||
/// Endpoint for GitHub repository.
|
||||
const GITHUB_URL: &'static str = "https://github.com/GetGrin/grim";
|
||||
const GITHUB_URL: &'static str = "https://github.com/2ro/goblin";
|
||||
/// Endpoint for Telegram releases channel.
|
||||
const TELEGRAM_URL: &'static str = "https://t.me/grim_releases";
|
||||
/// Endpoint for git repository.
|
||||
const GIT_URL: &'static str = "https://code.gri.mw/GUI/grim";
|
||||
const TELEGRAM_URL: &'static str = "https://t.me/goblinfamily";
|
||||
/// Endpoint for project website.
|
||||
const GIT_URL: &'static str = "https://docs.goblin.st";
|
||||
|
||||
impl ChangelogContent {
|
||||
/// Create new content instance.
|
||||
@@ -114,7 +114,7 @@ impl ChangelogContent {
|
||||
columns[2].vertical_centered_justified(|ui| {
|
||||
// Draw button to open repository link.
|
||||
let mut git_clicked = false;
|
||||
View::button(ui, BRACKETS_CURLY, Colors::white_or_black(false), || {
|
||||
View::button(ui, BOOK, Colors::white_or_black(false), || {
|
||||
git_clicked = true;
|
||||
});
|
||||
if git_clicked {
|
||||
|
||||
@@ -325,6 +325,12 @@ impl WalletContent {
|
||||
self.goblin.take_switch_request()
|
||||
}
|
||||
|
||||
/// Show the goblin surface's brief "press back again" hint (first back of
|
||||
/// the double-back-to-switcher at the wallet Home).
|
||||
pub fn show_back_hint(&mut self) {
|
||||
self.goblin.show_back_hint();
|
||||
}
|
||||
|
||||
/// Navigate back on navigation stack. Returns true if not consumed.
|
||||
pub fn back(&mut self, cb: &dyn PlatformCallbacks) -> bool {
|
||||
if self.goblin.overlay_active() {
|
||||
|
||||
+1
-1
@@ -19,4 +19,4 @@ mod release;
|
||||
pub use release::*;
|
||||
|
||||
pub(crate) mod price;
|
||||
pub use price::grin_rate;
|
||||
pub use price::{RateState, grin_rate};
|
||||
|
||||
+163
-54
@@ -12,11 +12,17 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! GRIN price preview, cached per currency. Off by default (no pairing → no
|
||||
//! fetch); only once the user opts into a pairing is the rate fetched — and it
|
||||
//! goes over the Nym mixnet like everything else, never the clear net. The rate
|
||||
//! barely moves and CoinGecko's free tier rate-limits frequent polling (the
|
||||
//! shared exit IP all the more), so it refreshes on a slow cache, not live.
|
||||
//! GRIN price preview. Off by default (no pairing → no fetch); only once the
|
||||
//! user opts into a pairing is the rate fetched — and it goes over Tor like
|
||||
//! everything else, never the clear net.
|
||||
//!
|
||||
//! The rate is fetched LIVE, on view: the fiat subline asks for the rate only
|
||||
//! while the balance is actually on screen, and a fetch is kicked whenever the
|
||||
//! last one aged past a short freshness window ([`FRESH_SECS`]). There is no
|
||||
//! disk cache and no background timer — an idle or payment-listening wallet
|
||||
//! never polls, so it costs nothing on battery. The rate lives in memory for
|
||||
//! the freshness window so flipping between screens does not refetch, and a
|
||||
//! fresh session starts blank and fetches on the first view.
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
@@ -24,19 +30,19 @@ 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;
|
||||
/// How long an in-memory rate is considered current (seconds). Viewing the
|
||||
/// balance with a rate older than this kicks a live refetch, and until a fresh
|
||||
/// rate lands the stale value is NOT painted — the line shows loading, then the
|
||||
/// new rate (or unavailable on failure). Short enough to track the market, long
|
||||
/// enough that screen flips within the window reuse the same fetch.
|
||||
const FRESH_SECS: i64 = 180;
|
||||
|
||||
/// Minimum delay between fetch attempts for a currency, so a failing fetch
|
||||
/// (e.g. no network) does not respawn a thread every frame.
|
||||
const RETRY_SECS: i64 = 30;
|
||||
|
||||
/// How stale a disk-cached rate may be and still be worth painting on cold start
|
||||
/// (48h). Older than this and we start blank rather than show a very wrong price.
|
||||
const SEED_MAX_AGE_SECS: i64 = 48 * 3600;
|
||||
|
||||
/// Eager-probe per-try timeout. The eager fetch on tunnel-ready doubles as the
|
||||
/// end-to-end exit probe: a healthy warm fetch is ~800ms, a dead exit hangs until
|
||||
/// timeout, so a short cap lets us fail fast and condemn a bad exit in seconds.
|
||||
@@ -47,12 +53,29 @@ const PROBE_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
const PROBE_ATTEMPTS: u32 = 3;
|
||||
|
||||
lazy_static! {
|
||||
/// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at).
|
||||
/// In-session GRIN rates per `vs_currency`: code -> (rate, fetched_at). Memory
|
||||
/// only — never persisted, so a fresh session starts empty.
|
||||
static ref RATES: RwLock<HashMap<String, (f64, i64)>> = RwLock::new(HashMap::new());
|
||||
/// Currencies with a fetch currently in flight.
|
||||
static ref FETCHING: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
|
||||
/// Last fetch attempt per currency (unix secs).
|
||||
static ref LAST_TRY: RwLock<HashMap<String, i64>> = RwLock::new(HashMap::new());
|
||||
/// Currencies whose most recent completed attempt failed (with no fresh rate),
|
||||
/// so the line can honestly say "unavailable" instead of spinning forever.
|
||||
static ref FAILED: RwLock<HashSet<String>> = RwLock::new(HashSet::new());
|
||||
}
|
||||
|
||||
/// What the fiat line should render for a currency right now.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum RateState {
|
||||
/// A rate fetched within the freshness window — safe to paint as current.
|
||||
Fresh(f64),
|
||||
/// No current rate yet, but a fetch is in flight (or just kicked): show a
|
||||
/// subtle placeholder, not a stale number.
|
||||
Loading,
|
||||
/// The last fetch failed and nothing fresh is available: say so (or hide),
|
||||
/// never fall back to an old value.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
@@ -62,22 +85,51 @@ fn now() -> i64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Get the cached GRIN rate against `vs` (e.g. "usd", "eur", "btc") if fresh,
|
||||
/// triggering a background refresh otherwise. Returns `None` until the first
|
||||
/// successful fetch for that currency.
|
||||
pub fn grin_rate(vs: &str) -> Option<f64> {
|
||||
let cached = { RATES.read().get(vs).cloned() };
|
||||
let needs_refresh = match cached {
|
||||
Some((_, ts)) => now() - ts > REFRESH_SECS,
|
||||
None => true,
|
||||
};
|
||||
if needs_refresh {
|
||||
trigger_refresh(vs.to_string());
|
||||
}
|
||||
cached.map(|(rate, _)| rate)
|
||||
/// True if a rate fetched at `fetched_at` is still current as of `now`.
|
||||
fn is_fresh(fetched_at: i64, now: i64) -> bool {
|
||||
now - fetched_at <= FRESH_SECS
|
||||
}
|
||||
|
||||
/// Spawn a background refresh for one currency (deduped per code).
|
||||
/// Pure state decision, factored out for testing: given the (optional) cached
|
||||
/// rate and the current fetch bookkeeping, decide what to render. A cached rate
|
||||
/// older than the freshness window is deliberately NOT returned as `Fresh`.
|
||||
fn classify(cached: Option<(f64, i64)>, now: i64, fetching: bool, failed: bool) -> RateState {
|
||||
if let Some((rate, ts)) = cached {
|
||||
if is_fresh(ts, now) {
|
||||
return RateState::Fresh(rate);
|
||||
}
|
||||
}
|
||||
// No fresh rate. A fetch in flight (or just kicked) reads as loading; a
|
||||
// recently failed attempt with nothing fresh reads as unavailable.
|
||||
if fetching {
|
||||
RateState::Loading
|
||||
} else if failed {
|
||||
RateState::Unavailable
|
||||
} else {
|
||||
RateState::Loading
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the render state of the GRIN rate against `vs` (e.g. "usd", "eur",
|
||||
/// "btc"). Called from the fiat line while the balance is on screen: if the
|
||||
/// in-session rate is missing or stale it kicks a live refetch over Tor, and it
|
||||
/// never reports a rate older than the freshness window as current.
|
||||
pub fn grin_rate(vs: &str) -> RateState {
|
||||
let cached = { RATES.read().get(vs).cloned() };
|
||||
let fresh = cached.map(|(_, ts)| is_fresh(ts, now())).unwrap_or(false);
|
||||
if !fresh {
|
||||
trigger_refresh(vs.to_string());
|
||||
}
|
||||
classify(
|
||||
cached,
|
||||
now(),
|
||||
FETCHING.read().contains(vs),
|
||||
FAILED.read().contains(vs),
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a background refresh for one currency (deduped per code). Kicked only
|
||||
/// from a view that is actually asking for the rate — never on a timer.
|
||||
fn trigger_refresh(vs: String) {
|
||||
let t = now();
|
||||
{
|
||||
@@ -99,33 +151,28 @@ fn trigger_refresh(vs: String) {
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async {
|
||||
let ok = rt.block_on(async {
|
||||
if let Some(rate) = fetch_rate(&vs).await {
|
||||
record_rate(&vs, rate);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if ok {
|
||||
FAILED.write().remove(&vs);
|
||||
} else {
|
||||
FAILED.write().insert(vs.clone());
|
||||
}
|
||||
FETCHING.write().remove(&vs);
|
||||
});
|
||||
}
|
||||
|
||||
/// Record a freshly fetched rate: into the in-memory cache (with `now()`) AND to
|
||||
/// disk, so the next cold start can paint it instantly (see [`seed_from_disk`]).
|
||||
/// Record a freshly fetched rate into the in-memory cache (with `now()`) and
|
||||
/// clear any prior failure flag. Nothing is written to disk.
|
||||
fn record_rate(vs: &str, rate: f64) {
|
||||
let t = now();
|
||||
RATES.write().insert(vs.to_string(), (rate, t));
|
||||
AppConfig::set_last_rate(vs, rate, t);
|
||||
}
|
||||
|
||||
/// Seed the in-memory cache from the disk-persisted last rate, if it is fresh
|
||||
/// enough (< 48h). Inserted with its ORIGINAL timestamp so it reads as stale —
|
||||
/// [`grin_rate`] returns it immediately for an instant preview, yet `needs_refresh`
|
||||
/// stays true so a live refresh is still kicked. Called once, early in start().
|
||||
pub fn seed_from_disk() {
|
||||
if let Some((vs, rate, at)) = AppConfig::last_rate() {
|
||||
if now() - at <= SEED_MAX_AGE_SECS {
|
||||
RATES.write().entry(vs).or_insert((rate, at));
|
||||
}
|
||||
}
|
||||
RATES.write().insert(vs.to_string(), (rate, now()));
|
||||
FAILED.write().remove(vs);
|
||||
}
|
||||
|
||||
/// Kick a refresh for the current pairing's currency the moment the tunnel is
|
||||
@@ -133,7 +180,8 @@ pub fn seed_from_disk() {
|
||||
/// It doubles as the end-to-end exit probe: if every attempt fails while the
|
||||
/// tunnel still reports ready, the exit is blackholing HTTP despite passing the
|
||||
/// cheap liveness probe, so we condemn it (bounded: at most one condemnation per
|
||||
/// tunnel generation) rather than let it stall the wallet for minutes.
|
||||
/// tunnel generation) rather than let it stall the wallet for minutes. This is a
|
||||
/// one-shot per tunnel connection, not a poll loop.
|
||||
pub fn eager_refresh() {
|
||||
let vs = match AppConfig::pairing().vs_currency() {
|
||||
Some(vs) => vs.to_string(),
|
||||
@@ -154,7 +202,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 {
|
||||
@@ -171,27 +219,32 @@ pub fn eager_refresh() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
FAILED.write().remove(&vs);
|
||||
} else {
|
||||
FAILED.write().insert(vs.clone());
|
||||
}
|
||||
// Every attempt failed AND the tunnel still claims ready on the SAME
|
||||
// generation we probed: the exit is up but blackholing our HTTP. Condemn
|
||||
// it so a fresh exit is selected in seconds, not minutes. Guarded to the
|
||||
// probed generation so a reselect that already happened is never hit.
|
||||
if !ok && nym::is_ready() && nym::tunnel_generation() == generation {
|
||||
nym::condemn_exit(generation);
|
||||
if !ok && tor::is_ready() && tor::tunnel_generation() == generation {
|
||||
tor::condemn_exit(generation);
|
||||
}
|
||||
});
|
||||
FETCHING.write().remove(&vs);
|
||||
}
|
||||
|
||||
/// Fetch the GRIN/`vs` rate from CoinGecko over the Nym mixnet.
|
||||
/// Fetch the GRIN/`vs` rate from CoinGecko over Tor.
|
||||
async fn fetch_rate(vs: &str) -> Option<f64> {
|
||||
let url = format!(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies={}",
|
||||
vs
|
||||
);
|
||||
// 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?;
|
||||
// CoinGecko rejects requests without a User-Agent (403); the Tor client sets a
|
||||
// browser-like default UA on every request, so we pass no extra headers here
|
||||
// (passing one again would send the header twice).
|
||||
let body = tor::http_request("GET", url, None, vec![]).await?;
|
||||
let parsed: Option<f64> = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|doc| doc.get("grin")?.get(vs)?.as_f64());
|
||||
@@ -203,3 +256,59 @@ async fn fetch_rate(vs: &str) -> Option<f64> {
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn freshness_window_is_a_few_minutes() {
|
||||
// Guards the ruling: a short window in minutes, not the old 48h cache.
|
||||
assert!(
|
||||
(120..=300).contains(&FRESH_SECS),
|
||||
"FRESH_SECS = {FRESH_SECS}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fresh_at_boundary() {
|
||||
let now = 1_000_000;
|
||||
assert!(is_fresh(now, now)); // just fetched
|
||||
assert!(is_fresh(now - FRESH_SECS, now)); // exactly on the edge is still fresh
|
||||
assert!(!is_fresh(now - FRESH_SECS - 1, now)); // one second past → stale
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_fresh_rate_is_painted() {
|
||||
let now = 1_000_000;
|
||||
let cached = Some((1.23, now - 10));
|
||||
// Even mid-fetch, a fresh rate wins.
|
||||
assert_eq!(classify(cached, now, true, false), RateState::Fresh(1.23));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_stale_rate_never_painted_shows_loading_while_fetching() {
|
||||
let now = 1_000_000;
|
||||
let cached = Some((1.23, now - FRESH_SECS - 60));
|
||||
assert_eq!(classify(cached, now, true, false), RateState::Loading);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_stale_rate_after_failure_is_unavailable() {
|
||||
let now = 1_000_000;
|
||||
let cached = Some((1.23, now - FRESH_SECS - 60));
|
||||
// Not fetching, last attempt failed → honest "unavailable", not the old value.
|
||||
assert_eq!(classify(cached, now, false, true), RateState::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_missing_rate_loads_then_reports_failure() {
|
||||
let now = 1_000_000;
|
||||
// First view: nothing cached, fetch just kicked.
|
||||
assert_eq!(classify(None, now, true, false), RateState::Loading);
|
||||
// Fetch finished and failed, nothing fresh → unavailable.
|
||||
assert_eq!(classify(None, now, false, true), RateState::Unavailable);
|
||||
// Freshly triggered, not yet marked fetching or failed → still loading.
|
||||
assert_eq!(classify(None, now, false, false), RateState::Loading);
|
||||
}
|
||||
}
|
||||
|
||||
+131
-8
@@ -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,20 +123,25 @@ pub fn start(options: NativeOptions, app_creator: eframe::AppCreator) -> eframe:
|
||||
// would panic on the first TLS handshake. nym uses its own explicit provider,
|
||||
// so this only steers our relay/HTTP TLS. Idempotent (Err if already set).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
// Pre-warm the in-process Nym mixnet tunnel FIRST, before i18n/node setup, so
|
||||
// the mixnet bootstrap (the long pole on cold start) overlaps everything else
|
||||
// and price/NIP-05/nostr are ready at first use. All of Goblin's outbound
|
||||
// traffic egresses through it; nothing clearnet.
|
||||
nym::warm_up();
|
||||
// Seed the price cache from disk so the amount preview can paint an instant
|
||||
// (stale-marked) fiat value while the first live fetch is still in flight.
|
||||
crate::http::price::seed_from_disk();
|
||||
// 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();
|
||||
// Setup translations.
|
||||
setup_i18n();
|
||||
// Start integrated node if needed.
|
||||
if AppConfig::autostart_node() {
|
||||
Node::start();
|
||||
}
|
||||
// macOS delivers `goblin:` link clicks as a kAEGetURL Apple Event, not on argv
|
||||
// and not through any path winit/eframe surface. Install a handler for it here,
|
||||
// BEFORE the event loop starts, so both a cold launch (event queued at start-up)
|
||||
// and a warm click (app already running) route the URL into the same argv entry
|
||||
// (`on_data`). No-op / not compiled on every other platform.
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_deeplink::install();
|
||||
// Launch graphical interface.
|
||||
eframe::run_native("Goblin", options, app_creator)
|
||||
}
|
||||
@@ -382,6 +393,99 @@ pub fn on_data(data: String) {
|
||||
*w_data = Some(data);
|
||||
}
|
||||
|
||||
/// macOS-only bridge that turns a `goblin:` URL click into an [`on_data`] call.
|
||||
///
|
||||
/// On Linux/Windows the OS hands the URL to the app on argv (cold) or through the
|
||||
/// single-instance socket (warm); on Android it arrives as an Intent. macOS uses
|
||||
/// neither: it dispatches scheme clicks as a Carbon/Apple Event (`kAEGetURL`) that
|
||||
/// winit + eframe never surface, so the click would otherwise vanish. We install a
|
||||
/// handler for that event straight on the shared `NSAppleEventManager`, extract the
|
||||
/// URL string, and feed it to [`on_data`] — the exact same entry point argv uses,
|
||||
/// so the Goblin surface's per-frame router lands the pay URI on the prefilled
|
||||
/// review screen just as a scanned QR or a Linux argv link does.
|
||||
///
|
||||
/// This talks to the Objective-C runtime through the classic `objc` crate, which is
|
||||
/// already in the macOS build graph (nokhwa/cocoa/metal/wgpu all pull it), so it
|
||||
/// adds no new dependency and only a few KB of handler code. The Apple Event route
|
||||
/// deliberately avoids the `NSApplicationDelegate` that winit owns — registering our
|
||||
/// own `kAEGetURL` handler neither subclasses nor swizzles winit's delegate.
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_deeplink {
|
||||
use objc::declare::ClassDecl;
|
||||
use objc::runtime::{Object, Sel};
|
||||
use objc::{class, msg_send, sel, sel_impl};
|
||||
use std::ffi::CStr;
|
||||
use std::os::raw::c_char;
|
||||
use std::sync::Once;
|
||||
|
||||
// Four-char codes (OSType) for the URL-open Apple Event.
|
||||
const K_INTERNET_EVENT_CLASS: u32 = 0x4755_524c; // 'GURL'
|
||||
const K_AE_GET_URL: u32 = 0x4755_524c; // 'GURL'
|
||||
const KEY_DIRECT_OBJECT: u32 = 0x2d2d_2d2d; // '----'
|
||||
|
||||
/// `- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
|
||||
/// withReplyEvent:(NSAppleEventDescriptor *)reply`.
|
||||
extern "C" fn handle_get_url(
|
||||
_this: &Object,
|
||||
_cmd: Sel,
|
||||
event: *mut Object,
|
||||
_reply: *mut Object,
|
||||
) {
|
||||
if event.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
// [[event paramDescriptorForKeyword:keyDirectObject] stringValue] -> NSString*.
|
||||
let desc: *mut Object = msg_send![event, paramDescriptorForKeyword: KEY_DIRECT_OBJECT];
|
||||
if desc.is_null() {
|
||||
return;
|
||||
}
|
||||
let url: *mut Object = msg_send![desc, stringValue];
|
||||
if url.is_null() {
|
||||
return;
|
||||
}
|
||||
let utf8: *const c_char = msg_send![url, UTF8String];
|
||||
if utf8.is_null() {
|
||||
return;
|
||||
}
|
||||
let s = CStr::from_ptr(utf8).to_string_lossy().into_owned();
|
||||
if !s.is_empty() {
|
||||
crate::on_data(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the `kAEGetURL` handler on the shared `NSAppleEventManager`. Idempotent
|
||||
/// (the handler class is built once); call before the event loop starts.
|
||||
pub fn install() {
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| unsafe {
|
||||
// Build a tiny NSObject subclass carrying the handler method.
|
||||
let superclass = class!(NSObject);
|
||||
let mut decl = match ClassDecl::new("GoblinAppleEventHandler", superclass) {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
decl.add_method(
|
||||
sel!(handleGetURLEvent:withReplyEvent:),
|
||||
handle_get_url as extern "C" fn(&Object, Sel, *mut Object, *mut Object),
|
||||
);
|
||||
let cls = decl.register();
|
||||
|
||||
// One instance, intentionally leaked: it must outlive every event for the
|
||||
// whole process lifetime, and the app never unregisters.
|
||||
let handler: *mut Object = msg_send![cls, new];
|
||||
let manager: *mut Object =
|
||||
msg_send![class!(NSAppleEventManager), sharedAppleEventManager];
|
||||
let _: () = msg_send![manager,
|
||||
setEventHandler: handler
|
||||
andSelector: sel!(handleGetURLEvent:withReplyEvent:)
|
||||
forEventClass: K_INTERNET_EVENT_CLASS
|
||||
andEventID: K_AE_GET_URL];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix-seconds timestamp of the most recent GUI frame. Background workers read
|
||||
/// it to tell whether the app is actually on-screen: while the app is
|
||||
/// backgrounded, eframe stops calling the per-frame draw and this stops
|
||||
@@ -448,6 +552,25 @@ pub fn notify_payment_requested(name: &str, amount: &str) {
|
||||
lazy_static! {
|
||||
/// Data provided from deeplink or opened file.
|
||||
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
/// A pending `goblin:` / `nostr:` payment deep link, waiting for the Goblin
|
||||
/// wallet surface to open its send-review flow. Separate from
|
||||
/// [`INCOMING_DATA`] (slatepack messages / opened files): a payment link is
|
||||
/// routed here so it lands on the prefilled review screen rather than the
|
||||
/// slatepack message handler. Consumed by the Goblin view once a wallet is
|
||||
/// open and showing.
|
||||
static ref PENDING_PAY_URI: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Stash a payment deep link for the Goblin surface to open (see
|
||||
/// [`take_pending_pay_uri`]). The most recent link wins.
|
||||
pub fn set_pending_pay_uri(uri: String) {
|
||||
*PENDING_PAY_URI.write() = Some(uri);
|
||||
}
|
||||
|
||||
/// Take (and clear) a pending payment deep link, if any. The Goblin wallet view
|
||||
/// polls this each frame and opens a prefilled send-review flow for it.
|
||||
pub fn take_pending_pay_uri() -> Option<String> {
|
||||
PENDING_PAY_URI.write().take()
|
||||
}
|
||||
|
||||
/// Callback from Java code with passed data.
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ pub fn init_logger() {
|
||||
/// Get information about application build.
|
||||
fn build_info() -> String {
|
||||
format!(
|
||||
"This is Grim version {}, built for {} by {}.",
|
||||
"This is Goblin version {}, built for {} by {}.",
|
||||
built_info::PKG_VERSION,
|
||||
built_info::TARGET,
|
||||
built_info::RUSTC_VERSION,
|
||||
|
||||
+846
-176
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,574 @@
|
||||
// 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.
|
||||
|
||||
//! One wallet, one grin seed / one balance, but MANY nostr identities (nsecs),
|
||||
//! exactly one of which is ACTIVE at a time. This module is the wallet-level
|
||||
//! held-identity INDEX: it owns which identities the wallet holds, their display
|
||||
//! order, and which is active. It stores NO secrets. Each held identity is a
|
||||
//! full [`NostrIdentity`] on disk (its own NIP-49 ncryptsec, see
|
||||
//! [`crate::nostr::identity`]); this index only points at those files.
|
||||
//!
|
||||
//! On-disk layout under `<base_data>/nostr/`:
|
||||
//! ```text
|
||||
//! nostr/
|
||||
//! identities.json # this index (0600, no secrets): active + order + entries
|
||||
//! identity.json # identity #1 (the legacy file, NEVER overwritten by a switch)
|
||||
//! identities/<hex>/identity.json # each additional held identity
|
||||
//! db/ # shared rkv store (dedup, contacts, meta) — one for all identities
|
||||
//! ```
|
||||
//!
|
||||
//! Migration is trivial and fund-safe: a pre-feature wallet has only a bare
|
||||
//! `identity.json`. On first load the index adopts it as the single, active
|
||||
//! identity #1 — no key regeneration, no rewrite of the legacy file, and the
|
||||
//! grin seed/balance are never touched (this module cannot reach them).
|
||||
//!
|
||||
//! A switch only moves the `active` pointer here and rebinds the running service
|
||||
//! to the target's key (the wallet does the teardown + bring-up + catch-up). The
|
||||
//! legacy `identity.json` is deliberately never overwritten, so an older build
|
||||
//! that ignores this index still opens the wallet on identity #1 (clean rollback).
|
||||
|
||||
use crate::nostr::identity::NostrIdentity;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Index file name inside the nostr directory.
|
||||
const INDEX_FILE: &str = "identities.json";
|
||||
/// The legacy single-identity file, which becomes identity #1.
|
||||
const LEGACY_FILE: &str = "identity.json";
|
||||
/// Sub-directory holding each additional (non-legacy) identity.
|
||||
const SUBDIR: &str = "identities";
|
||||
|
||||
/// Cap on how many identities one wallet may hold. Bounds the on-disk key files
|
||||
/// and the switcher list, and stops a hostile import loop from ballooning either.
|
||||
pub const MAX_IDENTITIES: usize = 8;
|
||||
|
||||
/// One held identity, referenced by the index. No secret material.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeldEntry {
|
||||
/// Public key, lowercase hex — the stable id of this identity.
|
||||
pub pubkey: String,
|
||||
/// Path to the identity's `identity.json`, RELATIVE to the nostr dir:
|
||||
/// `"identity.json"` for the legacy identity #1, else
|
||||
/// `"identities/<hex>/identity.json"`.
|
||||
pub path: String,
|
||||
/// A short human label: the identity's claimed name (local part of its NIP-05)
|
||||
/// when it has one, else empty. NOT rendered — the UI derives its display from
|
||||
/// the name or a truncated npub — kept only as a convenience field in the
|
||||
/// index. Plaintext by design (this index carries no secret). Never a
|
||||
/// placeholder word.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl HeldEntry {
|
||||
/// Absolute path to this entry's identity file under `nostr_dir`.
|
||||
pub fn abs_path(&self, nostr_dir: &PathBuf) -> PathBuf {
|
||||
let mut p = nostr_dir.clone();
|
||||
for seg in self.path.split('/') {
|
||||
p.push(seg);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Load the full [`NostrIdentity`] this entry points at.
|
||||
pub fn load(&self, nostr_dir: &PathBuf) -> Option<NostrIdentity> {
|
||||
NostrIdentity::load_at(&self.abs_path(nostr_dir))
|
||||
}
|
||||
}
|
||||
|
||||
/// The held-identity index: which identities the wallet holds and which is
|
||||
/// active. Persisted as `identities.json`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct HeldIdentities {
|
||||
pub ver: u8,
|
||||
/// Active identity, lowercase hex. Drives the single live subscription and
|
||||
/// all display; the only pointer a switch moves.
|
||||
pub active: String,
|
||||
/// Display order, lowercase hex.
|
||||
pub order: Vec<String>,
|
||||
/// Entry metadata (no secrets).
|
||||
pub identities: Vec<HeldEntry>,
|
||||
}
|
||||
|
||||
impl HeldIdentities {
|
||||
/// Index file path inside the nostr dir.
|
||||
pub fn index_path(nostr_dir: &PathBuf) -> PathBuf {
|
||||
let mut p = nostr_dir.clone();
|
||||
p.push(INDEX_FILE);
|
||||
p
|
||||
}
|
||||
|
||||
/// Relative path an additional identity's file lives at.
|
||||
fn rel_path_for(hex: &str) -> String {
|
||||
format!("{SUBDIR}/{hex}/{LEGACY_FILE}")
|
||||
}
|
||||
|
||||
/// Load the index if present and parseable.
|
||||
pub fn load(nostr_dir: &PathBuf) -> Option<HeldIdentities> {
|
||||
let raw = fs::read_to_string(Self::index_path(nostr_dir)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
/// Persist the index with owner-only (0600) permissions. It carries no
|
||||
/// secret, but a consistent 0700/0600 posture across the nostr dir is
|
||||
/// simplest to reason about.
|
||||
pub fn save(&self, nostr_dir: &PathBuf) -> std::io::Result<()> {
|
||||
fs::create_dir_all(nostr_dir)?;
|
||||
let raw = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
write_private_0600(&Self::index_path(nostr_dir), raw.as_bytes())
|
||||
}
|
||||
|
||||
/// The active entry, if the pointer resolves to a held identity.
|
||||
pub fn active_entry(&self) -> Option<&HeldEntry> {
|
||||
self.identities.iter().find(|e| e.pubkey == self.active)
|
||||
}
|
||||
|
||||
/// Look up an entry by hex.
|
||||
pub fn entry(&self, hex: &str) -> Option<&HeldEntry> {
|
||||
self.identities.iter().find(|e| e.pubkey == hex)
|
||||
}
|
||||
|
||||
/// Whether the wallet already holds this pubkey (dedupe guard on add/import).
|
||||
pub fn contains(&self, hex: &str) -> bool {
|
||||
self.identities.iter().any(|e| e.pubkey == hex)
|
||||
}
|
||||
|
||||
/// Held-identity count.
|
||||
pub fn len(&self) -> usize {
|
||||
self.identities.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.identities.is_empty()
|
||||
}
|
||||
|
||||
/// True if another identity may still be added under the cap.
|
||||
pub fn has_room(&self) -> bool {
|
||||
self.identities.len() < MAX_IDENTITIES
|
||||
}
|
||||
|
||||
/// Build a fresh single-identity index from the legacy identity #1. This is
|
||||
/// the migration shape: exactly one held identity, active, referencing the
|
||||
/// legacy `identity.json` in place (never rewritten).
|
||||
pub fn from_legacy(legacy: &NostrIdentity) -> Option<HeldIdentities> {
|
||||
let hex = legacy.pubkey_hex()?;
|
||||
Some(HeldIdentities {
|
||||
ver: 1,
|
||||
active: hex.clone(),
|
||||
order: vec![hex.clone()],
|
||||
identities: vec![HeldEntry {
|
||||
pubkey: hex,
|
||||
path: LEGACY_FILE.to_string(),
|
||||
label: label_for(legacy),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the index, migrating a legacy single-identity wallet in place, and
|
||||
/// self-healing an index whose `active` pointer no longer resolves. Returns
|
||||
/// the index plus the ACTIVE identity to run. `legacy` is the identity loaded
|
||||
/// from the bare `identity.json` (identity #1), used for migration/repair.
|
||||
///
|
||||
/// Never touches funds and never regenerates a key. Writes only the index
|
||||
/// (`identities.json`) — the identity files themselves are left as they are.
|
||||
pub fn load_or_migrate(
|
||||
nostr_dir: &PathBuf,
|
||||
legacy: &NostrIdentity,
|
||||
) -> Option<(HeldIdentities, NostrIdentity)> {
|
||||
let legacy_hex = legacy.pubkey_hex()?;
|
||||
match Self::load(nostr_dir) {
|
||||
Some(mut idx) => {
|
||||
// Repair: ensure identity #1 is always represented (it is the
|
||||
// rollback anchor), without disturbing the active pointer.
|
||||
if !idx.contains(&legacy_hex) {
|
||||
idx.identities.push(HeldEntry {
|
||||
pubkey: legacy_hex.clone(),
|
||||
path: LEGACY_FILE.to_string(),
|
||||
label: label_for(legacy),
|
||||
});
|
||||
if !idx.order.contains(&legacy_hex) {
|
||||
idx.order.push(legacy_hex.clone());
|
||||
}
|
||||
let _ = idx.save(nostr_dir);
|
||||
}
|
||||
// Resolve the active identity; if its file is missing/corrupt,
|
||||
// fall back to identity #1 so the wallet always has a running
|
||||
// identity rather than none.
|
||||
let active = idx
|
||||
.active_entry()
|
||||
.and_then(|e| e.load(nostr_dir))
|
||||
.or_else(|| {
|
||||
idx.active = legacy_hex.clone();
|
||||
let _ = idx.save(nostr_dir);
|
||||
Some(legacy.clone())
|
||||
})?;
|
||||
Some((idx, active))
|
||||
}
|
||||
None => {
|
||||
// Legacy layout: adopt identity.json as the sole, active identity.
|
||||
let idx = Self::from_legacy(legacy)?;
|
||||
let _ = idx.save(nostr_dir);
|
||||
Some((idx, legacy.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an already-built identity to the set (does NOT change the active
|
||||
/// pointer). Writes the identity's file under `identities/<hex>/` and records
|
||||
/// the entry. Enforces the cap and dedupe. Returns the new entry's hex.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
nostr_dir: &PathBuf,
|
||||
identity: &NostrIdentity,
|
||||
) -> Result<String, HeldError> {
|
||||
let hex = identity.pubkey_hex().ok_or(HeldError::BadPubkey)?;
|
||||
if self.contains(&hex) {
|
||||
return Err(HeldError::AlreadyHeld);
|
||||
}
|
||||
if !self.has_room() {
|
||||
return Err(HeldError::AtCapacity);
|
||||
}
|
||||
let rel = Self::rel_path_for(&hex);
|
||||
let mut abs = nostr_dir.clone();
|
||||
for seg in rel.split('/') {
|
||||
abs.push(seg);
|
||||
}
|
||||
identity
|
||||
.save_at(&abs)
|
||||
.map_err(|e| HeldError::Io(e.to_string()))?;
|
||||
self.identities.push(HeldEntry {
|
||||
pubkey: hex.clone(),
|
||||
path: rel,
|
||||
label: label_for(identity),
|
||||
});
|
||||
self.order.push(hex.clone());
|
||||
self.save(nostr_dir)
|
||||
.map_err(|e| HeldError::Io(e.to_string()))?;
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Move the active pointer to a held identity. The caller is responsible for
|
||||
/// tearing down and re-standing the service on the new key; this only records
|
||||
/// the choice so the next open lands on it too.
|
||||
pub fn set_active(&mut self, nostr_dir: &PathBuf, hex: &str) -> Result<(), HeldError> {
|
||||
if !self.contains(hex) {
|
||||
return Err(HeldError::NotHeld);
|
||||
}
|
||||
self.active = hex.to_string();
|
||||
self.save(nostr_dir)
|
||||
.map_err(|e| HeldError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Re-encrypt every held identity's ncryptsec from `old` to `new`, in place
|
||||
/// on disk. Used by the wallet-password change so all front doors follow the
|
||||
/// one password. Best-effort per file; returns the first error encountered
|
||||
/// after attempting the rest.
|
||||
pub fn reencrypt_all(
|
||||
&self,
|
||||
nostr_dir: &PathBuf,
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<(), HeldError> {
|
||||
let mut first_err = None;
|
||||
for entry in &self.identities {
|
||||
let abs = entry.abs_path(nostr_dir);
|
||||
match NostrIdentity::load_at(&abs) {
|
||||
Some(mut id) => {
|
||||
if let Err(e) = id.reencrypt(old, new) {
|
||||
first_err.get_or_insert(HeldError::Io(e.to_string()));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = id.save_at(&abs) {
|
||||
first_err.get_or_insert(HeldError::Io(e.to_string()));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
first_err.get_or_insert(HeldError::Io(format!(
|
||||
"identity file unreadable: {}",
|
||||
entry.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
match first_err {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from held-identity index operations. Carries no secret material.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HeldError {
|
||||
#[error("that identity is already in this wallet")]
|
||||
AlreadyHeld,
|
||||
#[error("this wallet already holds the maximum number of identities")]
|
||||
AtCapacity,
|
||||
#[error("identity not held by this wallet")]
|
||||
NotHeld,
|
||||
#[error("identity has a malformed public key")]
|
||||
BadPubkey,
|
||||
#[error("identity store error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// A convenience label for an identity: the local part of its claimed NIP-05
|
||||
/// name when it has one, else EMPTY (never a placeholder word — an unnamed
|
||||
/// identity is shown by its truncated npub in the UI, not by a label). Never
|
||||
/// includes secret material.
|
||||
fn label_for(id: &NostrIdentity) -> String {
|
||||
id.nip05
|
||||
.as_deref()
|
||||
.and_then(|n| n.split('@').next())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The catch-up `since` (unix seconds) for the identity we are bringing up. We
|
||||
/// want to cover "since THIS identity last listened", not "since the wallet last
|
||||
/// connected on any identity", so a payment that arrived while this identity was
|
||||
/// dormant is fetched and redeemed on switch. Falls back to the wallet-wide last
|
||||
/// connection, then to now, and always subtracts the same generous lookback so a
|
||||
/// boundary payment is never missed. Pure — unit tested.
|
||||
pub fn catchup_since(
|
||||
identity_last_active: Option<i64>,
|
||||
wallet_last_connected: Option<i64>,
|
||||
now: i64,
|
||||
lookback: i64,
|
||||
) -> i64 {
|
||||
let base = identity_last_active
|
||||
.or(wallet_last_connected)
|
||||
.unwrap_or(now);
|
||||
(base - lookback).max(0)
|
||||
}
|
||||
|
||||
/// Write a file with owner-only (0600) permissions on Unix.
|
||||
fn write_private_0600(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
f.write_all(data)?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, data)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!(
|
||||
"goblin-held-test-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_adopts_legacy_as_single_active_identity() {
|
||||
let dir = tmpdir("migrate");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap(); // writes identity.json
|
||||
let legacy_hex = legacy.pubkey_hex().unwrap();
|
||||
|
||||
// No index yet -> migrate.
|
||||
assert!(HeldIdentities::load(&dir).is_none());
|
||||
let (idx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
assert_eq!(idx.len(), 1);
|
||||
assert_eq!(idx.active, legacy_hex);
|
||||
assert_eq!(active.npub, legacy.npub);
|
||||
// Legacy entry points at the untouched identity.json.
|
||||
assert_eq!(idx.active_entry().unwrap().path, "identity.json");
|
||||
// Index now persisted and reloads identically.
|
||||
let reloaded = HeldIdentities::load(&dir).unwrap();
|
||||
assert_eq!(reloaded.active, legacy_hex);
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_switch_and_cap() {
|
||||
let dir = tmpdir("addswitch");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
let legacy_hex = legacy.pubkey_hex().unwrap();
|
||||
|
||||
// Add a second identity; active stays on #1 until we switch.
|
||||
let (second, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
let second_hex = idx.add(&dir, &second).unwrap();
|
||||
assert_eq!(idx.len(), 2);
|
||||
assert_eq!(idx.active, legacy_hex);
|
||||
// It has its own file under identities/<hex>/.
|
||||
assert!(idx.entry(&second_hex).unwrap().load(&dir).is_some());
|
||||
|
||||
// Dedupe: adding the same pubkey again is refused.
|
||||
assert!(matches!(
|
||||
idx.add(&dir, &second),
|
||||
Err(HeldError::AlreadyHeld)
|
||||
));
|
||||
|
||||
// Switch active pointer.
|
||||
idx.set_active(&dir, &second_hex).unwrap();
|
||||
assert_eq!(idx.active, second_hex);
|
||||
// Persisted.
|
||||
assert_eq!(HeldIdentities::load(&dir).unwrap().active, second_hex);
|
||||
// Switching to an unknown identity is refused.
|
||||
assert!(matches!(
|
||||
idx.set_active(&dir, "deadbeef"),
|
||||
Err(HeldError::NotHeld)
|
||||
));
|
||||
|
||||
// Fill to the cap and assert the (N+1)th is rejected.
|
||||
while idx.has_room() {
|
||||
let (extra, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
idx.add(&dir, &extra).unwrap();
|
||||
}
|
||||
assert_eq!(idx.len(), MAX_IDENTITIES);
|
||||
let (overflow, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
assert!(matches!(
|
||||
idx.add(&dir, &overflow),
|
||||
Err(HeldError::AtCapacity)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_resolves_after_reload_and_survives_switch() {
|
||||
let dir = tmpdir("resolve");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
let (second, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
let second_hex = idx.add(&dir, &second).unwrap();
|
||||
idx.set_active(&dir, &second_hex).unwrap();
|
||||
|
||||
// Reopen: load_or_migrate must return the ACTIVE identity (#2), and the
|
||||
// legacy identity.json must be UNCHANGED (still identity #1 for rollback).
|
||||
let (reidx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
assert_eq!(reidx.active, second_hex);
|
||||
assert_eq!(active.npub, second.npub);
|
||||
let legacy_on_disk = NostrIdentity::load(&dir).unwrap();
|
||||
assert_eq!(legacy_on_disk.npub, legacy.npub);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_index_active_falls_back_to_legacy() {
|
||||
let dir = tmpdir("fallback");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let legacy_hex = legacy.pubkey_hex().unwrap();
|
||||
// Index points active at an identity whose file does not exist.
|
||||
let idx = HeldIdentities {
|
||||
ver: 1,
|
||||
active: "00ff00ff".repeat(8),
|
||||
order: vec![legacy_hex.clone()],
|
||||
identities: vec![HeldEntry {
|
||||
pubkey: legacy_hex.clone(),
|
||||
path: "identity.json".to_string(),
|
||||
label: String::new(),
|
||||
}],
|
||||
};
|
||||
idx.save(&dir).unwrap();
|
||||
let (repaired, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
// Falls back to identity #1 rather than leaving no running identity.
|
||||
assert_eq!(active.npub, legacy.npub);
|
||||
assert_eq!(repaired.active, legacy_hex);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reencrypt_all_moves_every_identity_to_new_password() {
|
||||
let dir = tmpdir("reencrypt");
|
||||
let (legacy, _) = NostrIdentity::create_random("old").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
let (second, _) = NostrIdentity::create_random("old").unwrap();
|
||||
idx.add(&dir, &second).unwrap();
|
||||
|
||||
idx.reencrypt_all(&dir, "old", "new").unwrap();
|
||||
for entry in &idx.identities {
|
||||
let id = entry.load(&dir).unwrap();
|
||||
assert!(id.unlock("new").is_ok(), "must open under the new password");
|
||||
assert!(id.unlock("old").is_err(), "must not open under the old one");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_nsec_adds_as_held_identity_and_unlocks() {
|
||||
// The multi-identity import path: a bare nsec becomes a held identity via
|
||||
// the same NIP-49 encrypted store, keyed by its own pubkey, openable under
|
||||
// the wallet password. (Regression guard for the add-import flow.)
|
||||
use nostr_sdk::{Keys, ToBech32};
|
||||
let dir = tmpdir("import");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
|
||||
// A distinct external key, as an nsec string.
|
||||
let ext = Keys::generate();
|
||||
let nsec = ext.secret_key().to_bech32().unwrap();
|
||||
let (imported, _) = NostrIdentity::create_imported(&nsec, "pw").unwrap();
|
||||
let hex = idx.add(&dir, &imported).unwrap();
|
||||
|
||||
// Held, active pointer unchanged (add never switches), file openable.
|
||||
assert_eq!(idx.len(), 2);
|
||||
assert_eq!(idx.active, legacy.pubkey_hex().unwrap());
|
||||
let stored = idx.entry(&hex).unwrap().load(&dir).unwrap();
|
||||
assert_eq!(stored.source, crate::nostr::IdentitySource::Imported);
|
||||
assert_eq!(stored.unlock("pw").unwrap().public_key(), ext.public_key());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catchup_since_prefers_identity_then_wallet_then_now() {
|
||||
let lookback = 3 * 86_400;
|
||||
let now = 1_000_000;
|
||||
// Per-identity value wins: cover from when THIS identity last listened.
|
||||
assert_eq!(
|
||||
catchup_since(Some(500_000), Some(900_000), now, lookback),
|
||||
500_000 - lookback
|
||||
);
|
||||
// Falls back to the wallet-wide last connection when the identity has none.
|
||||
assert_eq!(
|
||||
catchup_since(None, Some(900_000), now, lookback),
|
||||
900_000 - lookback
|
||||
);
|
||||
// Falls back to now when nothing is known (never worse than a fresh start).
|
||||
assert_eq!(catchup_since(None, None, now, lookback), now - lookback);
|
||||
// Never negative.
|
||||
assert_eq!(catchup_since(Some(10), None, now, lookback), 0);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,12 @@ pub struct NostrIdentity {
|
||||
/// the first service start selects them.
|
||||
#[serde(default)]
|
||||
pub dm_relays: Vec<String>,
|
||||
/// PRIVATE, app-only label the user sets to name this identity for
|
||||
/// themselves. Stays local: it lives in this 0600 file (and rides inside the
|
||||
/// NIP-44-sealed .backup envelope, which serializes the whole struct), and is
|
||||
/// NEVER published — not in kind-0 metadata, not in any event.
|
||||
#[serde(default)]
|
||||
pub private_tag: Option<String>,
|
||||
}
|
||||
|
||||
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
|
||||
@@ -146,6 +152,38 @@ impl NostrIdentity {
|
||||
let _ = fs::remove_file(Self::path(nostr_dir));
|
||||
}
|
||||
|
||||
/// Load an identity from an explicit file path — a member of the held
|
||||
/// identity set (see [`crate::nostr::identities`]), which stores each
|
||||
/// additional identity in its own `identities/<hex>/identity.json`.
|
||||
pub fn load_at(path: &PathBuf) -> Option<NostrIdentity> {
|
||||
let raw = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
/// Persist this identity to an explicit file path with owner-only (0600)
|
||||
/// permissions, creating (and 0700-restricting) the parent directory. Used
|
||||
/// by the held identity set for the non-legacy identities; the ncryptsec
|
||||
/// blob must never be world-readable.
|
||||
pub fn save_at(&self, path: &PathBuf) -> Result<(), IdentityError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
restrict_dir(&dir.to_path_buf());
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(self)?;
|
||||
write_private(path, raw.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The identity's public key as lowercase hex — the stable id used to key it
|
||||
/// in the held-identity index and on disk. `None` if the stored npub is
|
||||
/// malformed (never expected for an identity we wrote).
|
||||
pub fn pubkey_hex(&self) -> Option<String> {
|
||||
use nostr_sdk::PublicKey;
|
||||
PublicKey::from_bech32(&self.npub)
|
||||
.ok()
|
||||
.map(|pk| pk.to_hex())
|
||||
}
|
||||
|
||||
/// Build an identity from already-unlocked keys under a (possibly
|
||||
/// different) password — used when importing a backup that was exported
|
||||
/// under another wallet's password.
|
||||
@@ -204,6 +242,7 @@ impl NostrIdentity {
|
||||
anonymous: true,
|
||||
prev_npubs: Vec::new(),
|
||||
dm_relays: Vec::new(),
|
||||
private_tag: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,13 @@ mod tests {
|
||||
received_rumor_id: None,
|
||||
created_at: unix_time(),
|
||||
updated_at: unix_time(),
|
||||
proof_mode: false,
|
||||
proof_order: None,
|
||||
proof_notify: None,
|
||||
proof_amount: None,
|
||||
proof_delivered: false,
|
||||
receipt_sent: false,
|
||||
recipient_pubkey: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -31,6 +31,9 @@ pub use store::NostrStore;
|
||||
mod identity;
|
||||
pub use identity::{IdentitySource, NostrIdentity};
|
||||
|
||||
pub mod identities;
|
||||
pub use identities::{HeldError, HeldIdentities, MAX_IDENTITIES, catchup_since};
|
||||
|
||||
pub mod protocol;
|
||||
pub use protocol::*;
|
||||
|
||||
@@ -40,7 +43,7 @@ pub mod ingest;
|
||||
pub use ingest::*;
|
||||
|
||||
mod client;
|
||||
pub use client::{NostrProfile, NostrService, send_phase};
|
||||
pub use client::{HeldIdentityKeys, NostrProfile, NostrService, send_phase};
|
||||
|
||||
pub mod avatar;
|
||||
pub mod nip05;
|
||||
|
||||
+11
-11
@@ -13,8 +13,8 @@
|
||||
// limitations under the License.
|
||||
|
||||
//! NIP-05 username resolution/verification and goblin.st registration,
|
||||
//! all HTTP routed through the Nym mixnet (the in-process smolmix tunnel). Nothing
|
||||
//! here touches clearnet.
|
||||
//! all HTTP routed over Tor (the in-process arti tunnel). Nothing here touches
|
||||
//! clearnet.
|
||||
|
||||
use base64::Engine;
|
||||
use nostr_sdk::{EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+375
-13
@@ -12,12 +12,16 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Pay-URI parser for scanned payment QRs.
|
||||
//! Pay-URI parser for scanned payment QRs and payment deep links.
|
||||
//!
|
||||
//! A GoblinPay checkout QR extends the plain `nostr:` URI with an optional
|
||||
//! amount (and memo):
|
||||
//! A GoblinPay checkout QR (or an "Open in Goblin" web button) carries an
|
||||
//! optional amount (and memo) on the recipient, under either the `goblin:`
|
||||
//! scheme (Goblin's own registered deep-link scheme, so the OS routes it to the
|
||||
//! wallet) or the plain `nostr:` scheme (what a QR/social payload spells). Both
|
||||
//! are the SAME payload — only the scheme differs:
|
||||
//!
|
||||
//! ```text
|
||||
//! goblin:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
|
||||
//! nostr:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
|
||||
//! ```
|
||||
//!
|
||||
@@ -38,6 +42,21 @@ use grin_core::core::amount_from_hr_string;
|
||||
const MAX_URI_LEN: usize = 4096;
|
||||
/// Memo byte cap (post control-strip), display / tx-message only.
|
||||
const MAX_MEMO_BYTES: usize = 256;
|
||||
/// Order-handle byte cap (post percent-decode + control-strip). The order
|
||||
/// param is an opaque routing key (magick's `MM-<hex>` invoice number), never
|
||||
/// free text, so it is capped tight per the frozen contract (section 4.1).
|
||||
const MAX_ORDER_BYTES: usize = 64;
|
||||
/// Upper bound on the whole-GRIN part of an accepted amount. It sits far below
|
||||
/// the point where `amount_from_hr_string`'s `grins * GRIN_BASE` would overflow
|
||||
/// u64 (which in release wraps to a small atomic value while the review screen
|
||||
/// still shows the giant figure), yet comfortably above Grin's real circulating
|
||||
/// supply (~10^8 GRIN). A scanned amount above this is abuse, not a payment.
|
||||
const MAX_WHOLE_GRIN: u64 = 1_000_000_000;
|
||||
|
||||
/// Schemes that unlock amount/memo parsing. `goblin:` is Goblin's registered
|
||||
/// deep-link scheme (web "Open in Goblin" buttons, so the OS opens the wallet);
|
||||
/// `nostr:` is the equivalent QR/social payload. Both spell the same payment.
|
||||
const PAY_SCHEMES: [&str; 2] = ["goblin:", "nostr:"];
|
||||
|
||||
/// A parsed pay-URI. `recipient` is fed to the existing resolver as-is (the
|
||||
/// bech32/name that used to go straight into the search box). `amount` is the
|
||||
@@ -49,6 +68,21 @@ pub struct PayUri {
|
||||
pub recipient: String,
|
||||
pub amount: Option<String>,
|
||||
pub memo: Option<String>,
|
||||
/// Proof-on-request: the merchant's Grin slatepack (`grin1`/`tgrin1`) proof
|
||||
/// address. Presence turns proof mode ON for this one transaction; the value
|
||||
/// is threaded verbatim as `payment_proof_recipient_address` on the send.
|
||||
/// Fail-closed: an unparseable value is dropped to `None` (a proof-less
|
||||
/// send), never blocking the payment. Frozen contract section 4.1.
|
||||
pub proof: Option<String>,
|
||||
/// The opaque order handle (magick's `MM-<hex>` invoice number) the wallet
|
||||
/// echoes verbatim into the delivery events' `payment-request` tag. Distinct
|
||||
/// from `memo`: `order` is a non-editable routing key, `memo` is editable
|
||||
/// display text. Dropped if empty after sanitization.
|
||||
pub order: Option<String>,
|
||||
/// The watcher's Nostr pubkey (`npub…`) the wallet gift-wraps the full proof
|
||||
/// delivery to. Dropped if it is not a valid npub; absence simply means no
|
||||
/// encrypted delivery target (the plain receipt still publishes).
|
||||
pub notify: Option<String>,
|
||||
}
|
||||
|
||||
impl PayUri {
|
||||
@@ -58,6 +92,9 @@ impl PayUri {
|
||||
recipient,
|
||||
amount: None,
|
||||
memo: None,
|
||||
proof: None,
|
||||
order: None,
|
||||
notify: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,11 +111,11 @@ pub fn parse(scanned: &str) -> PayUri {
|
||||
return PayUri::bare(String::new());
|
||||
}
|
||||
|
||||
// Strict scheme: only the `nostr:` prefix (case-insensitive) unlocks
|
||||
// Strict scheme: only a `goblin:`/`nostr:` prefix (case-insensitive) unlocks
|
||||
// amount/memo parsing, matching the scanner's existing strip logic. Any
|
||||
// other payload (a bare npub, or some other scheme) is returned verbatim,
|
||||
// exactly as the scanner treated it before pay-URIs existed.
|
||||
let rest = match strip_nostr_prefix(text) {
|
||||
let rest = match strip_pay_scheme(text) {
|
||||
Some(rest) => rest,
|
||||
None => return PayUri::bare(text.to_string()),
|
||||
};
|
||||
@@ -92,6 +129,9 @@ pub fn parse(scanned: &str) -> PayUri {
|
||||
|
||||
let mut amount = None;
|
||||
let mut memo = None;
|
||||
let mut proof = None;
|
||||
let mut order = None;
|
||||
let mut notify = None;
|
||||
if let Some(query) = query {
|
||||
for pair in query.split('&') {
|
||||
let Some((key, val)) = pair.split_once('=') else {
|
||||
@@ -102,6 +142,12 @@ pub fn parse(scanned: &str) -> PayUri {
|
||||
// second `amount=` can't override a validated one.
|
||||
"amount" if amount.is_none() => amount = validate_amount(val),
|
||||
"memo" if memo.is_none() => memo = validate_memo(val),
|
||||
// Proof-on-request params (frozen contract section 4.1). Each is
|
||||
// fail-closed: an invalid value is dropped to `None`, degrading to
|
||||
// a normal proof-less payment rather than blocking the send.
|
||||
"proof" if proof.is_none() => proof = validate_proof(val),
|
||||
"order" if order.is_none() => order = validate_order(val),
|
||||
"notify" if notify.is_none() => notify = validate_notify(val),
|
||||
// Unknown params are ignored for forward-compat.
|
||||
_ => {}
|
||||
}
|
||||
@@ -112,18 +158,33 @@ pub fn parse(scanned: &str) -> PayUri {
|
||||
recipient,
|
||||
amount,
|
||||
memo,
|
||||
proof,
|
||||
order,
|
||||
notify,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip a case-insensitive `nostr:` scheme prefix, returning the remainder.
|
||||
/// Byte-safe against a leading multibyte char (no `[..6]` slice panic).
|
||||
fn strip_nostr_prefix(text: &str) -> Option<&str> {
|
||||
let head = text.get(..6)?;
|
||||
if head.eq_ignore_ascii_case("nostr:") {
|
||||
Some(&text[6..])
|
||||
} else {
|
||||
None
|
||||
/// Strip a case-insensitive payment scheme prefix (`goblin:` or `nostr:`),
|
||||
/// returning the remainder. Byte-safe against a leading multibyte char (the
|
||||
/// `text.get(..n)` guards against a `[..n]` slice panic).
|
||||
fn strip_pay_scheme(text: &str) -> Option<&str> {
|
||||
for scheme in PAY_SCHEMES {
|
||||
let n = scheme.len();
|
||||
if let Some(head) = text.get(..n) {
|
||||
if head.eq_ignore_ascii_case(scheme) {
|
||||
return Some(&text[n..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True when `scanned` (once trimmed) carries a Goblin payment scheme
|
||||
/// (`goblin:` or `nostr:`) — i.e. a payment deep link rather than a slatepack
|
||||
/// message or opened file. Used to route an incoming argv/intent payload to the
|
||||
/// send-review flow instead of the slatepack message handler.
|
||||
pub fn is_pay_uri(scanned: &str) -> bool {
|
||||
strip_pay_scheme(scanned.trim()).is_some()
|
||||
}
|
||||
|
||||
/// Validate an `amount` value: percent-decode, then accept it ONLY if the
|
||||
@@ -132,6 +193,24 @@ fn strip_nostr_prefix(text: &str) -> Option<&str> {
|
||||
/// manual entry). Returns the clean decoded decimal string on success.
|
||||
fn validate_amount(raw: &str) -> Option<String> {
|
||||
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
|
||||
// A Grin amount is only ASCII `[0-9.]`. Reject any non-ASCII up front:
|
||||
// `amount_from_hr_string` slices the fractional tail at a fixed byte index,
|
||||
// which panics if that index lands inside a multibyte UTF-8 char. The scan /
|
||||
// deep-link thread has no catch_unwind, so a crafted amount like `0.€€€€`
|
||||
// would crash the wallet. Fail closed to manual entry instead.
|
||||
if !decoded.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
// Cap the whole-GRIN part below the u64 overflow point. Without this a giant
|
||||
// amount wraps (in release) to a small atomic value that is what actually
|
||||
// gets dispatched, while the review screen shows the giant figure. A whole
|
||||
// part that does not even fit u64 is left for `amount_from_hr_string` to
|
||||
// reject (its own `parse::<u64>` errors, so no wrap can occur).
|
||||
if let Ok(whole) = decoded.split('.').next().unwrap_or("").parse::<u64>() {
|
||||
if whole > MAX_WHOLE_GRIN {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
match amount_from_hr_string(&decoded) {
|
||||
Ok(atomic) if atomic > 0 => Some(decoded),
|
||||
_ => None,
|
||||
@@ -155,6 +234,81 @@ fn validate_memo(raw: &str) -> Option<String> {
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
/// Validate a `proof` value: percent-decode, then accept it ONLY if it has the
|
||||
/// shape of a Grin slatepack address (`grin1…`/`tgrin1…`, bech32 charset, long
|
||||
/// enough to be a real key). This is a fail-closed SHAPE check; the send path
|
||||
/// re-parses it authoritatively via `SlatepackAddress::try_from` and drops it
|
||||
/// again if that fails, so an almost-valid string can never turn into a bad
|
||||
/// proof recipient. Returns the clean decoded address on success.
|
||||
fn validate_proof(raw: &str) -> Option<String> {
|
||||
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
|
||||
let decoded = decoded.trim();
|
||||
if looks_like_slatepack_address(decoded) {
|
||||
Some(decoded.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Loose shape test for a Grin slatepack address: a `grin1`/`tgrin1` bech32
|
||||
/// human-readable prefix followed by a bech32-charset data part of plausible
|
||||
/// length. Mirrors magick's `isValidGoblinPayAddress` grin1 arm; authoritative
|
||||
/// decode happens at send time.
|
||||
fn looks_like_slatepack_address(s: &str) -> bool {
|
||||
let lower = s.to_ascii_lowercase();
|
||||
let data = if let Some(d) = lower.strip_prefix("tgrin1") {
|
||||
d
|
||||
} else if let Some(d) = lower.strip_prefix("grin1") {
|
||||
d
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
// bech32 data charset excludes `1`, `b`, `i`, `o`; a real key is well over
|
||||
// 20 data chars. We only guard obvious garbage here.
|
||||
data.len() >= 20
|
||||
&& data
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() && !matches!(c, '1' | 'b' | 'i' | 'o'))
|
||||
}
|
||||
|
||||
/// Validate an `order` value: percent-decode, strip ASCII control chars (it is a
|
||||
/// routing key, never display text or a path), hard-cap at [`MAX_ORDER_BYTES`]
|
||||
/// on a UTF-8 boundary, trim. Empty → `None`. The wallet echoes this verbatim
|
||||
/// into the `payment-request` tag of every delivery event, so it must survive
|
||||
/// the round trip unchanged.
|
||||
fn validate_order(raw: &str) -> Option<String> {
|
||||
let decoded = percent_decode(raw);
|
||||
let cleaned: Vec<u8> = decoded
|
||||
.into_iter()
|
||||
.filter(|&b| b >= 0x20 && b != 0x7f)
|
||||
.collect();
|
||||
let text = String::from_utf8_lossy(&cleaned).into_owned();
|
||||
let text = truncate_on_char_boundary(text, MAX_ORDER_BYTES);
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() { None } else { Some(text) }
|
||||
}
|
||||
|
||||
/// Validate a `notify` value: percent-decode, then accept it ONLY if it has the
|
||||
/// shape of an `npub` (bech32 `npub1…`). Fail-closed SHAPE check; the delivery
|
||||
/// path re-decodes it authoritatively via `PublicKey::from_bech32` and drops it
|
||||
/// again on failure. Returns the clean decoded npub on success.
|
||||
fn validate_notify(raw: &str) -> Option<String> {
|
||||
let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned();
|
||||
let decoded = decoded.trim();
|
||||
let lower = decoded.to_ascii_lowercase();
|
||||
if let Some(data) = lower.strip_prefix("npub1") {
|
||||
// A bech32-encoded 32-byte key is ~59 data chars; guard obvious garbage.
|
||||
if data.len() >= 50
|
||||
&& data
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() && !matches!(c, '1' | 'b' | 'i' | 'o'))
|
||||
{
|
||||
return Some(decoded.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max` bytes without splitting a UTF-8 char.
|
||||
fn truncate_on_char_boundary(s: String, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
@@ -231,6 +385,46 @@ mod tests {
|
||||
assert_eq!(out.amount.as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_scheme_equivalent_to_nostr() {
|
||||
// The `goblin:` deep-link scheme is the same payload as `nostr:` — it
|
||||
// MUST parse to the identical recipient / amount / memo. This is the
|
||||
// contract behind the web "Open in Goblin" buttons.
|
||||
let nostr = parse(&format!("nostr:{NPROFILE}?amount=1.5&memo=Coffee"));
|
||||
let goblin = parse(&format!("goblin:{NPROFILE}?amount=1.5&memo=Coffee"));
|
||||
assert_eq!(goblin, nostr);
|
||||
assert_eq!(goblin.recipient, NPROFILE);
|
||||
assert_eq!(goblin.amount.as_deref(), Some("1.5"));
|
||||
assert_eq!(goblin.memo.as_deref(), Some("Coffee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_scheme_case_insensitive() {
|
||||
let out = parse(&format!("GOBLIN:{NPROFILE}?amount=2"));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_goblin_nprofile_unchanged() {
|
||||
let out = parse(&format!("goblin:{NPROFILE}"));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount, None);
|
||||
assert_eq!(out.memo, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pay_uri_recognizes_both_schemes() {
|
||||
assert!(is_pay_uri(&format!("goblin:{NPROFILE}?amount=1")));
|
||||
assert!(is_pay_uri(&format!("nostr:{NPROFILE}")));
|
||||
assert!(is_pay_uri(&format!(" GOBLIN:{NPROFILE} ")));
|
||||
// A slatepack message / bare key / other scheme is NOT a pay URI.
|
||||
assert!(!is_pay_uri("BEGINSLATEPACK. abc DEFG. ENDSLATEPACK."));
|
||||
assert!(!is_pay_uri("npub1abcdef"));
|
||||
assert!(!is_pay_uri("bitcoin:bc1qxyz?amount=1"));
|
||||
assert!(!is_pay_uri(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_amount() {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=1.5"));
|
||||
@@ -268,6 +462,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_amount_rejected_no_panic() {
|
||||
// A crafted multibyte amount must never reach grin_core's fixed-index
|
||||
// fractional-tail slice (which panics on a non-char-boundary). It is
|
||||
// dropped to None and the payment degrades to manual entry; no panic on
|
||||
// the scan/deep-link thread. The `0.€€€€` case slices mid-char in
|
||||
// grin_core without the ASCII guard.
|
||||
for bad in [
|
||||
"0.\u{20ac}\u{20ac}\u{20ac}\u{20ac}", // 3-byte euro signs
|
||||
"0.\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}", // 2-byte accents past WIDTH
|
||||
"\u{20ac}",
|
||||
"1\u{e9}",
|
||||
"0.5\u{1f600}", // 4-byte emoji
|
||||
] {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount={bad}"));
|
||||
assert_eq!(out.amount, None, "expected {bad:?} to be dropped");
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_amount_rejected() {
|
||||
// Above the whole-GRIN ceiling → dropped before the grin parse, so a huge
|
||||
// display value can't overflow-wrap into a small dispatched atomic amount.
|
||||
for bad in [
|
||||
"2000000000", // 2e9 GRIN, over the 1e9 ceiling
|
||||
"99999999999", // ~1e11 GRIN
|
||||
"1000000001.5", // just over the ceiling, with a fraction
|
||||
"18446744073709551615", // u64::MAX whole grins (would wrap grins*BASE)
|
||||
] {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount={bad}"));
|
||||
assert_eq!(out.amount, None, "expected {bad:?} to be rejected");
|
||||
}
|
||||
// The ceiling itself and just under it still parse.
|
||||
assert_eq!(
|
||||
parse(&format!("nostr:{NPROFILE}?amount=1000000000"))
|
||||
.amount
|
||||
.as_deref(),
|
||||
Some("1000000000")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&format!("nostr:{NPROFILE}?amount=999999999.5"))
|
||||
.amount
|
||||
.as_deref(),
|
||||
Some("999999999.5")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_memo_truncated() {
|
||||
let long = "a".repeat(500);
|
||||
@@ -382,4 +624,124 @@ mod tests {
|
||||
assert_eq!(smallest.amount.as_deref(), Some("0.000000001"));
|
||||
assert_eq!(smallest.memo.as_deref(), Some("MM-ABC123"));
|
||||
}
|
||||
|
||||
// --- proof-on-request params (frozen contract section 4.1) --------------
|
||||
|
||||
/// A shape-valid grin1 slatepack address (charset excludes 1/b/i/o).
|
||||
const GRIN1: &str = "grin1qqvqzqzpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsz";
|
||||
/// A shape-valid npub (the Goblin news key).
|
||||
const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely";
|
||||
|
||||
#[test]
|
||||
fn parses_all_three_proof_params() {
|
||||
let uri = format!(
|
||||
"nostr:{NPROFILE}?amount=1.5&memo=Coffee&proof={GRIN1}&order=MM-ABC123¬ify={NPUB}"
|
||||
);
|
||||
let out = parse(&uri);
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("1.5"));
|
||||
assert_eq!(out.memo.as_deref(), Some("Coffee"));
|
||||
assert_eq!(out.proof.as_deref(), Some(GRIN1));
|
||||
assert_eq!(out.order.as_deref(), Some("MM-ABC123"));
|
||||
assert_eq!(out.notify.as_deref(), Some(NPUB));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_scheme_carries_proof_params() {
|
||||
// The clickable `goblin:` deep link must parse identically to `nostr:`.
|
||||
let query = format!("amount=2&proof={GRIN1}&order=MM-1¬ify={NPUB}");
|
||||
let nostr = parse(&format!("nostr:{NPROFILE}?{query}"));
|
||||
let goblin = parse(&format!("goblin:{NPROFILE}?{query}"));
|
||||
assert_eq!(goblin, nostr);
|
||||
assert_eq!(goblin.proof.as_deref(), Some(GRIN1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_absent_leaves_none() {
|
||||
// A normal magick / p2p payment carries no proof params: all three None.
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=1&memo=MM-1"));
|
||||
assert_eq!(out.proof, None);
|
||||
assert_eq!(out.order, None);
|
||||
assert_eq!(out.notify, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tgrin1_proof_accepted() {
|
||||
let tgrin1 = format!("t{GRIN1}");
|
||||
let out = parse(&format!("nostr:{NPROFILE}?proof={tgrin1}"));
|
||||
assert_eq!(out.proof.as_deref(), Some(tgrin1.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_proof_dropped_fail_closed() {
|
||||
// Not a slatepack address (wrong hrp, too short, or plain garbage) → the
|
||||
// param is dropped and the payment degrades to a normal proof-less send.
|
||||
for bad in [
|
||||
"npub1abcdef",
|
||||
"grin1", // hrp only, no data
|
||||
"grin1short", // data too short
|
||||
"bc1qxyz", // wrong network
|
||||
"notanaddress",
|
||||
"",
|
||||
] {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=1&proof={bad}"));
|
||||
assert_eq!(out.proof, None, "expected {bad:?} proof to be dropped");
|
||||
// Dropping proof never blocks the rest of the payment.
|
||||
assert_eq!(out.amount.as_deref(), Some("1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_is_control_stripped_and_capped() {
|
||||
// Percent-encoded control chars are removed; a routing key survives verbatim.
|
||||
let out = parse(&format!("nostr:{NPROFILE}?order=MM-A%00B%0AC"));
|
||||
assert_eq!(out.order.as_deref(), Some("MM-ABC"));
|
||||
// Over-cap orders truncate at 64 bytes.
|
||||
let long = "M".repeat(200);
|
||||
let out = parse(&format!("nostr:{NPROFILE}?order={long}"));
|
||||
assert_eq!(out.order.as_deref().map(|s| s.len()), Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_order_dropped() {
|
||||
assert_eq!(parse(&format!("nostr:{NPROFILE}?order=")).order, None);
|
||||
// Whitespace-only after decode is also empty.
|
||||
assert_eq!(parse(&format!("nostr:{NPROFILE}?order=%20%20")).order, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_notify_dropped_fail_closed() {
|
||||
for bad in [
|
||||
"nprofile1abc",
|
||||
"npub1",
|
||||
"hex0123",
|
||||
"grin1qqqqqqqqqqqqqqqqqqqqqq",
|
||||
"",
|
||||
] {
|
||||
let out = parse(&format!("nostr:{NPROFILE}?amount=1¬ify={bad}"));
|
||||
assert_eq!(out.notify, None, "expected {bad:?} notify to be dropped");
|
||||
assert_eq!(out.amount.as_deref(), Some("1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proof_params_first_occurrence_wins() {
|
||||
let out = parse(&format!(
|
||||
"nostr:{NPROFILE}?order=MM-1&order=MM-EVIL&proof={GRIN1}&proof=grin1short"
|
||||
));
|
||||
assert_eq!(out.order.as_deref(), Some("MM-1"));
|
||||
assert_eq!(out.proof.as_deref(), Some(GRIN1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_wallet_forward_compat_unaffected() {
|
||||
// The pre-existing recipient/amount/memo contract is unchanged when the
|
||||
// new params ride alongside (a shipped old wallet just ignores them).
|
||||
let out = parse(&format!(
|
||||
"nostr:{NPROFILE}?amount=1.5&memo=MM-1&proof={GRIN1}&order=MM-1¬ify={NPUB}"
|
||||
));
|
||||
assert_eq!(out.recipient, NPROFILE);
|
||||
assert_eq!(out.amount.as_deref(), Some("1.5"));
|
||||
assert_eq!(out.memo.as_deref(), Some("MM-1"));
|
||||
}
|
||||
}
|
||||
|
||||
+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.
|
||||
|
||||
@@ -29,6 +29,10 @@ use crate::nostr::types::*;
|
||||
/// Keys are processed-event markers older than this get pruned (30 days).
|
||||
const PROCESSED_TTL_SECS: i64 = 30 * 86_400;
|
||||
|
||||
/// Cap on stored news posts (newest kept, older pruned) — the panel only ever
|
||||
/// shows the latest, so this is just a small archive bound.
|
||||
const NEWS_CAP: usize = 8;
|
||||
|
||||
/// Nostr metadata archive for a wallet.
|
||||
pub struct NostrStore {
|
||||
env: Arc<RwLock<Rkv<SafeModeEnvironment>>>,
|
||||
@@ -42,6 +46,8 @@ pub struct NostrStore {
|
||||
processed: SingleStore<SafeModeDatabase>,
|
||||
/// Service settings (last connected time etc).
|
||||
settings: SingleStore<SafeModeDatabase>,
|
||||
/// Cached news posts by `d` tag.
|
||||
news: SingleStore<SafeModeDatabase>,
|
||||
}
|
||||
|
||||
impl NostrStore {
|
||||
@@ -75,6 +81,7 @@ impl NostrStore {
|
||||
let settings = k
|
||||
.open_single("nostr_settings", StoreOptions::create())
|
||||
.unwrap();
|
||||
let news = k.open_single("nostr_news", StoreOptions::create()).unwrap();
|
||||
Self {
|
||||
env,
|
||||
tx_meta,
|
||||
@@ -82,6 +89,7 @@ impl NostrStore {
|
||||
requests,
|
||||
processed,
|
||||
settings,
|
||||
news,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +276,30 @@ impl NostrStore {
|
||||
let _ = writer.commit();
|
||||
}
|
||||
|
||||
/// Unix time this identity (by pubkey hex) was last the ACTIVE, live-listening
|
||||
/// identity. Held per identity in the one shared settings store so that a
|
||||
/// switch back to a dormant identity can catch up "since it last listened"
|
||||
/// rather than "since the wallet last connected". `None` for an identity that
|
||||
/// has never been active (fresh/imported), which the catch-up handles by
|
||||
/// falling back to the wallet-wide last connection.
|
||||
pub fn last_active_at(&self, pubkey_hex: &str) -> Option<i64> {
|
||||
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
|
||||
let reader = env.read().unwrap();
|
||||
let key = format!("last_active_at:{pubkey_hex}");
|
||||
if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, &key) {
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn set_last_active_at(&self, pubkey_hex: &str, ts: i64) {
|
||||
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
|
||||
let mut writer = env.write().unwrap();
|
||||
let key = format!("last_active_at:{pubkey_hex}");
|
||||
let _ = self.settings.put(&mut writer, &key, &Value::I64(ts));
|
||||
let _ = writer.commit();
|
||||
}
|
||||
|
||||
/// Unix time of the last contact-name re-verify sweep (persisted across
|
||||
/// restarts so a fresh launch only re-sweeps if it's been a while).
|
||||
pub fn last_name_sweep_at(&self) -> Option<i64> {
|
||||
@@ -288,6 +320,28 @@ impl NostrStore {
|
||||
let _ = writer.commit();
|
||||
}
|
||||
|
||||
// ── news ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn all_news(&self) -> Vec<NewsItem> {
|
||||
self.all_json(&self.news)
|
||||
}
|
||||
|
||||
/// The latest news post overall (newest `created_at`).
|
||||
pub fn latest_news(&self) -> Option<NewsItem> {
|
||||
self.all_news().into_iter().max_by_key(|n| n.created_at)
|
||||
}
|
||||
|
||||
/// Store a news post: newest-`created_at`-per-`d` wins, capped to the newest
|
||||
/// `NEWS_CAP` entries (older pruned). Keyed by `d`, so the store holds one
|
||||
/// row per addressable post.
|
||||
pub fn save_news(&self, item: NewsItem) {
|
||||
let merged = reconcile_news(self.all_news(), item, NEWS_CAP);
|
||||
self.clear(&self.news);
|
||||
for n in &merged {
|
||||
self.put_json(&self.news, &n.d, n);
|
||||
}
|
||||
}
|
||||
|
||||
// ── archive control (user-facing) ───────────────────────────────────────
|
||||
|
||||
/// Export the whole archive as a JSON document.
|
||||
@@ -309,3 +363,59 @@ impl NostrStore {
|
||||
self.clear(&self.processed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge an incoming news post into the stored set: newest `created_at` wins
|
||||
/// per `d`, then keep only the newest `cap` overall. Pure so it's unit-testable
|
||||
/// without the rkv env.
|
||||
fn reconcile_news(mut all: Vec<NewsItem>, incoming: NewsItem, cap: usize) -> Vec<NewsItem> {
|
||||
if let Some(existing) = all.iter_mut().find(|n| n.d == incoming.d) {
|
||||
if incoming.created_at >= existing.created_at {
|
||||
*existing = incoming;
|
||||
}
|
||||
} else {
|
||||
all.push(incoming);
|
||||
}
|
||||
all.sort_by_key(|n| std::cmp::Reverse(n.created_at));
|
||||
all.truncate(cap);
|
||||
all
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(d: &str, created_at: i64) -> NewsItem {
|
||||
NewsItem {
|
||||
d: d.to_string(),
|
||||
created_at,
|
||||
title: format!("t{created_at}"),
|
||||
summary: String::new(),
|
||||
lang: None,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_per_d_wins_and_cap_prunes() {
|
||||
// Same d, newer created_at replaces (and carries the newer title).
|
||||
let start = vec![item("a", 100)];
|
||||
let merged = reconcile_news(start, item("a", 200), 8);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].created_at, 200);
|
||||
assert_eq!(merged[0].title, "t200");
|
||||
|
||||
// Same d, OLDER created_at is ignored.
|
||||
let merged = reconcile_news(merged, item("a", 150), 8);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].created_at, 200);
|
||||
|
||||
// Distinct d accumulate, newest first, capped to `cap`.
|
||||
let mut all = vec![];
|
||||
for i in 0..10 {
|
||||
all = reconcile_news(all, item(&format!("d{i}"), i as i64), 3);
|
||||
}
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].created_at, 9);
|
||||
assert_eq!(all[2].created_at, 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,51 @@ pub struct TxNostrMeta {
|
||||
pub received_rumor_id: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
/// Proof-on-request (frozen contract section 4.3): a proof address was
|
||||
/// threaded into this send, so on finalize the wallet must produce and
|
||||
/// deliver a real Grin payment proof. `false` for every ordinary send, so a
|
||||
/// person-to-person payment carries and delivers nothing extra.
|
||||
#[serde(default)]
|
||||
pub proof_mode: bool,
|
||||
/// The opaque order handle (the `order=` URI param, magick's `MM-<hex>`
|
||||
/// invoice number). Echoed verbatim into the delivery events'
|
||||
/// `payment-request` tag. The wallet never learns magick's internal orderId.
|
||||
#[serde(default)]
|
||||
pub proof_order: Option<String>,
|
||||
/// The watcher's npub (the `notify=` URI param): the gift-wrap target for the
|
||||
/// full proof delivery. `None` = no encrypted delivery target (plain receipt
|
||||
/// still publishes).
|
||||
#[serde(default)]
|
||||
pub proof_notify: Option<String>,
|
||||
/// The payment amount in integer nanogrin, persisted so the crash-safe
|
||||
/// reconcile pass can rebuild the delivery-event `amount` tag without the
|
||||
/// slate.
|
||||
#[serde(default)]
|
||||
pub proof_amount: Option<u64>,
|
||||
/// Set once the encrypted proof delivery (frozen contract 4.3.2) has been
|
||||
/// accepted by a relay. The plain receipt is tracked separately by
|
||||
/// `receipt_sent`, since the two now publish at different lifecycle points
|
||||
/// (receipt at dispatch, proof at finalize). Until then the reconcile pass
|
||||
/// retries the proof delivery.
|
||||
#[serde(default)]
|
||||
pub proof_delivered: bool,
|
||||
/// Set once the plain "payment sent" receipt (frozen contract 4.3.1) has been
|
||||
/// accepted by a relay. Published at S1 DISPATCH, the moment the payment
|
||||
/// envelope is accepted and the wallet UI flips to "sent", NOT at finalize,
|
||||
/// so the buyer's order page loses its scannable QR the instant they pay and
|
||||
/// the double-send window closes. One receipt per tx: this flag is the guard
|
||||
/// against a duplicate at finalize. `false` for every ordinary (non-order)
|
||||
/// send, which publishes no receipt at all.
|
||||
#[serde(default)]
|
||||
pub receipt_sent: bool,
|
||||
/// The wallet's OWN nostr identity (pubkey hex) that was ACTIVE when this row
|
||||
/// was created — the "front door" the payment came in / went out on. One
|
||||
/// wallet can hold several identities that all redeem into the single shared
|
||||
/// grin balance; this tags which one so activity can be shown per identity and
|
||||
/// a later per-identity accounting split has the data. Serde-default empty on
|
||||
/// pre-feature rows, which are treated as identity #1 (the primary).
|
||||
#[serde(default)]
|
||||
pub recipient_pubkey: String,
|
||||
}
|
||||
|
||||
/// A contact: another nostr user we can pay.
|
||||
@@ -146,6 +191,43 @@ pub struct PaymentRequest {
|
||||
pub status: RequestStatus,
|
||||
}
|
||||
|
||||
/// A cached news post (kind 30023 long-form) from the Goblin news key, shown
|
||||
/// in the Home news panel. Only the fields the panel needs are persisted.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct NewsItem {
|
||||
/// The addressable `d` tag (replaceable-event identifier); dedupe key.
|
||||
pub d: String,
|
||||
/// Event `created_at` (seconds); newest per `d` wins, newest overall shows.
|
||||
pub created_at: i64,
|
||||
/// The post `title` tag. May carry a trailing `[xx]` language marker, which
|
||||
/// the Home panel strips for display (see `data::news_display_title`).
|
||||
pub title: String,
|
||||
/// Plain-text summary (the `summary` tag, or a stripped content fallback).
|
||||
pub summary: String,
|
||||
/// Article language as a lower-case ISO 639-1 code, taken from an event
|
||||
/// language tag (`l` / `lang`) when present. `None` falls back to the
|
||||
/// title-suffix marker, then to English. `#[serde(default)]` so posts cached
|
||||
/// before this field existed still deserialize.
|
||||
#[serde(default)]
|
||||
pub lang: Option<String>,
|
||||
/// Optional NIP-23 `published_at` tag (unix seconds). When present the Home
|
||||
/// panel dates the article by this rather than `created_at` (which tracks the
|
||||
/// event's last edit). `#[serde(default)]` so posts cached before this field
|
||||
/// existed still deserialize.
|
||||
#[serde(default)]
|
||||
pub published_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Whether the plain "payment sent" receipt (frozen contract 4.3.1) is due at
|
||||
/// S1 dispatch for this send. True only for an order-carrying send in proof
|
||||
/// mode: a person-to-person send (no `order=` context) publishes no receipt at
|
||||
/// all, at any lifecycle point. The receipt is the buyer's routing key back to
|
||||
/// the market (the `payment-request` tag echoes the order handle), so without an
|
||||
/// order handle there is nothing the market could match and nothing to publish.
|
||||
pub fn receipt_due_at_dispatch(proof_mode: bool, order: Option<&str>) -> bool {
|
||||
proof_mode && order.is_some_and(|o| !o.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Current unix time in seconds.
|
||||
pub fn unix_time() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
+33
-5
@@ -30,6 +30,7 @@ use nostr_sdk::nips::nip59::{self, UnwrappedGift};
|
||||
use nostr_sdk::{
|
||||
Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// The capability Goblin advertises in its kind 10050 `encryption` tag,
|
||||
/// space-separated best-first (NIP-17 backward-compat extension).
|
||||
@@ -56,12 +57,17 @@ pub fn peer_supports_v3(encryption: Option<&str>) -> bool {
|
||||
/// Derive the v3 conversation key between our secret key and a peer's
|
||||
/// public key, bridging nostr-sdk's key types (secp256k1 0.29) to the nip44
|
||||
/// crate's (0.31) via their byte serializations.
|
||||
fn conversation_key(secret: &nostr_sdk::SecretKey, public: &PublicKey) -> Result<[u8; 32], String> {
|
||||
fn conversation_key(
|
||||
secret: &nostr_sdk::SecretKey,
|
||||
public: &PublicKey,
|
||||
) -> Result<Zeroizing<[u8; 32]>, String> {
|
||||
let sk = secp256k1::SecretKey::from_byte_array(secret.to_secret_bytes())
|
||||
.map_err(|e| format!("invalid secret key: {e}"))?;
|
||||
let pk = secp256k1::XOnlyPublicKey::from_byte_array(*public.as_bytes())
|
||||
.map_err(|e| format!("invalid public key: {e}"))?;
|
||||
Ok(nip44::get_conversation_key_v3(sk, pk))
|
||||
// The raw ECDH conversation key is secret material: wrap it so it is scrubbed
|
||||
// from memory on drop. It derefs to `&[u8; 32]` for the nip44 calls below.
|
||||
Ok(Zeroizing::new(nip44::get_conversation_key_v3(sk, pk)))
|
||||
}
|
||||
|
||||
/// Build a NIP-17 private-message gift wrap encrypted with NIP-44 v3.
|
||||
@@ -73,9 +79,31 @@ pub fn wrap(
|
||||
content: String,
|
||||
rumor_extra_tags: Vec<Tag>,
|
||||
) -> Result<Event, String> {
|
||||
// Rumor: kind 14, receiver p-tag first, then the extra tags — the same
|
||||
// shape `EventBuilder::private_msg_rumor` builds. Never signed (NIP-59).
|
||||
let mut rumor: UnsignedEvent = EventBuilder::new(Kind::PrivateDirectMessage, content)
|
||||
wrap_kind(
|
||||
sender,
|
||||
receiver,
|
||||
Kind::PrivateDirectMessage,
|
||||
content,
|
||||
rumor_extra_tags,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a NIP-59 gift wrap around a rumor of an ARBITRARY `rumor_kind`,
|
||||
/// encrypted with NIP-44 v3. `wrap` is the kind-14 private-message case; the
|
||||
/// proof-on-request delivery (frozen contract 4.3.2) uses this with a kind-17
|
||||
/// rumor to hand the watcher the full proof JSON privately. The watcher unwraps
|
||||
/// with the same `unwrap` below (it reuses the wallet's crypto), so v3 here is
|
||||
/// consistent end to end.
|
||||
pub fn wrap_kind(
|
||||
sender: &Keys,
|
||||
receiver: &PublicKey,
|
||||
rumor_kind: Kind,
|
||||
content: String,
|
||||
rumor_extra_tags: Vec<Tag>,
|
||||
) -> Result<Event, String> {
|
||||
// Rumor: receiver p-tag first, then the extra tags, the same shape
|
||||
// `EventBuilder::private_msg_rumor` builds. Never signed (NIP-59).
|
||||
let mut rumor: UnsignedEvent = EventBuilder::new(rumor_kind, content)
|
||||
.tag(Tag::public_key(*receiver))
|
||||
.tags(rumor_extra_tags)
|
||||
.build(sender.public_key());
|
||||
|
||||
+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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
-35
@@ -94,6 +94,10 @@ pub struct AppConfig {
|
||||
/// Application update information.
|
||||
app_update: Option<AppUpdate>,
|
||||
|
||||
/// Hide received grin amounts in payment notifications/alerts. Default false
|
||||
/// so existing configs (and new wallets) keep showing the amount.
|
||||
hide_amounts: Option<bool>,
|
||||
|
||||
/// Last-known-good Nym ENTRY gateway (base58 identity). Only the gateway
|
||||
/// CHOICE is remembered — the mixnet keys stay ephemeral — so a warm reconnect
|
||||
/// can skip re-picking a (possibly dead) random first hop.
|
||||
@@ -101,15 +105,6 @@ pub struct AppConfig {
|
||||
/// Last-known-good Nym IPR exit recipient (the `<id>.<enc>@<gw>` string), so a
|
||||
/// warm reconnect can try the exit that worked last time before auto-selecting.
|
||||
nym_last_ipr: Option<String>,
|
||||
|
||||
/// Last successfully fetched GRIN rate, so the amount preview can paint an
|
||||
/// instant (stale-marked) fiat value on cold start instead of a blank until the
|
||||
/// first mixnet fetch lands.
|
||||
last_rate: Option<f64>,
|
||||
/// The `vs_currency` the cached [`last_rate`] was priced against.
|
||||
last_rate_vs: Option<String>,
|
||||
/// Unix-seconds timestamp the cached [`last_rate`] was fetched at.
|
||||
last_rate_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin.
|
||||
@@ -221,11 +216,9 @@ impl Default for AppConfig {
|
||||
// update check — payments, relays and identity still stay mixnet-only.
|
||||
check_updates: Some(true),
|
||||
app_update: None,
|
||||
hide_amounts: None,
|
||||
nym_entry_gateway: None,
|
||||
nym_last_ipr: None,
|
||||
last_rate: None,
|
||||
last_rate_vs: None,
|
||||
last_rate_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,29 +528,6 @@ impl AppConfig {
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Get the cached GRIN rate as `(vs_currency, rate, fetched_at)`, if one was
|
||||
/// ever persisted. Callers decide whether it is fresh enough to use.
|
||||
pub fn last_rate() -> Option<(String, f64, i64)> {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
match (
|
||||
r_config.last_rate_vs.clone(),
|
||||
r_config.last_rate,
|
||||
r_config.last_rate_at,
|
||||
) {
|
||||
(Some(vs), Some(rate), Some(at)) => Some((vs, rate, at)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the most recent GRIN rate for `vs`, fetched at `at` (unix secs).
|
||||
pub fn set_last_rate(vs: &str, rate: f64, at: i64) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.last_rate_vs = Some(vs.to_string());
|
||||
w_config.last_rate = Some(rate);
|
||||
w_config.last_rate_at = Some(at);
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Check if proxy for network requests is needed.
|
||||
pub fn use_proxy() -> bool {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
@@ -659,4 +629,37 @@ impl AppConfig {
|
||||
}
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Whether received grin amounts are hidden in payment notifications/alerts.
|
||||
pub fn hide_amounts() -> bool {
|
||||
let r_config = Settings::app_config_to_read();
|
||||
r_config.hide_amounts.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set whether received grin amounts are hidden in notifications/alerts.
|
||||
pub fn set_hide_amounts(hide: bool) {
|
||||
let mut w_config = Settings::app_config_to_update();
|
||||
w_config.hide_amounts = Some(hide);
|
||||
w_config.save();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppConfig;
|
||||
|
||||
/// An old config carrying the now-removed price-cache fields (last_rate /
|
||||
/// last_rate_vs / last_rate_at) must still load: serde ignores unknown keys,
|
||||
/// so no migration is needed and the dead fields are simply dropped on the
|
||||
/// next save.
|
||||
#[test]
|
||||
fn loads_config_with_removed_price_cache_fields() {
|
||||
let mut toml = toml::to_string(&AppConfig::default()).expect("serialize default");
|
||||
toml.push_str("\nlast_rate = 1.23\nlast_rate_vs = \"usd\"\nlast_rate_at = 1700000000\n");
|
||||
let parsed = toml::from_str::<AppConfig>(&toml);
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
"old config with removed price-cache fields should still load"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
// 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;
|
||||
|
||||
/// Default `User-Agent` for every Tor-carried HTTP request. A common desktop
|
||||
/// browser string (not "goblin-wallet"), so the wallet's traffic is not trivially
|
||||
/// classifiable as Goblin at the destination. Callers may still override it by
|
||||
/// passing their own `User-Agent` in the headers list; the endpoints Goblin talks
|
||||
/// to (GitHub API, CoinGecko, name authorities) all accept a generic UA.
|
||||
const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||
Chrome/131.0.0.0 Safari/537.36";
|
||||
|
||||
// --- 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, DEFAULT_USER_AGENT);
|
||||
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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_user_agent_is_browser_like_not_goblin() {
|
||||
// The default UA must read as a common desktop browser, so Goblin's Tor
|
||||
// traffic is not trivially fingerprintable at the destination.
|
||||
assert!(DEFAULT_USER_AGENT.starts_with("Mozilla/5.0"));
|
||||
assert!(DEFAULT_USER_AGENT.contains("Chrome/"));
|
||||
assert!(DEFAULT_USER_AGENT.contains("Safari/"));
|
||||
// No line-continuation whitespace leaked into the literal.
|
||||
assert!(!DEFAULT_USER_AGENT.contains(" "));
|
||||
assert!(!DEFAULT_USER_AGENT.contains('\n'));
|
||||
// The old identifying string is gone.
|
||||
assert!(!DEFAULT_USER_AGENT.to_lowercase().contains("goblin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// 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()))
|
||||
}
|
||||
|
||||
/// Per-message / per-frame ceiling for every relay websocket. The relay pool
|
||||
/// only ever requires `max_message_length >= 131072` (128 KiB) from a relay,
|
||||
/// and the wallet's own events (gift wraps, kind-0/10050) are far smaller.
|
||||
/// tungstenite otherwise defaults to a 64 MiB message / 16 MiB frame ceiling;
|
||||
/// tighten it to a few MiB so a hostile or buggy relay can't stream a giant
|
||||
/// frame into wallet memory, while keeping ample headroom above any legitimate
|
||||
/// event.
|
||||
const WS_MAX_MESSAGE_SIZE: usize = 4 << 20; // 4 MiB
|
||||
const WS_MAX_FRAME_SIZE: usize = 4 << 20; // 4 MiB
|
||||
|
||||
/// The tightened websocket config applied to every relay dial.
|
||||
fn ws_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig {
|
||||
tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
|
||||
.max_message_size(Some(WS_MAX_MESSAGE_SIZE))
|
||||
.max_frame_size(Some(WS_MAX_FRAME_SIZE))
|
||||
}
|
||||
|
||||
/// 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_with_config(
|
||||
url.as_str(),
|
||||
stream,
|
||||
Some(ws_config()),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ws_config_caps_message_and_frame_size() {
|
||||
let cfg = ws_config();
|
||||
// Tightened to the constants, well below tungstenite's 64 MiB / 16 MiB
|
||||
// defaults so a hostile relay can't stream a giant frame into memory...
|
||||
assert_eq!(cfg.max_message_size, Some(WS_MAX_MESSAGE_SIZE));
|
||||
assert_eq!(cfg.max_frame_size, Some(WS_MAX_FRAME_SIZE));
|
||||
assert!(WS_MAX_MESSAGE_SIZE < 64 << 20);
|
||||
assert!(WS_MAX_FRAME_SIZE < 16 << 20);
|
||||
// ...yet with ample headroom above the pool's 128 KiB minimum event size,
|
||||
// so no legitimate relay traffic is ever truncated.
|
||||
assert!(WS_MAX_MESSAGE_SIZE >= 131_072);
|
||||
assert!(WS_MAX_FRAME_SIZE >= 131_072);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+14
-1
@@ -433,7 +433,20 @@ pub enum WalletTask {
|
||||
/// * amount
|
||||
/// * receiver public key (hex)
|
||||
/// * optional note (subject line)
|
||||
NostrSend(u64, String, Option<String>, Vec<String>),
|
||||
/// * relay hints
|
||||
/// * optional proof address (grin1/tgrin1): proof-on-request turns ON when
|
||||
/// present (frozen contract W2); threaded as `payment_proof_recipient_address`
|
||||
/// * optional order handle (the `order=` URI param, echoed into delivery events)
|
||||
/// * optional watcher npub (the `notify=` URI param, gift-wrap target)
|
||||
NostrSend(
|
||||
u64,
|
||||
String,
|
||||
Option<String>,
|
||||
Vec<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
),
|
||||
/// Re-dispatch the pending nostr message for transaction.
|
||||
/// * tx id
|
||||
NostrResend(u32),
|
||||
|
||||
+604
-24
@@ -14,7 +14,9 @@
|
||||
|
||||
use crate::AppConfig;
|
||||
use crate::node::{Node, NodeConfig};
|
||||
use crate::nostr::{NostrConfig, NostrIdentity, NostrService, NostrStore};
|
||||
use crate::nostr::{
|
||||
HeldIdentities, HeldIdentityKeys, NostrConfig, NostrIdentity, NostrService, NostrStore,
|
||||
};
|
||||
use crate::wallet::seed::WalletSeed;
|
||||
use crate::wallet::store::TxHeightStore;
|
||||
use crate::wallet::types::{
|
||||
@@ -44,7 +46,7 @@ use grin_wallet_libwallet::{
|
||||
SlatepackAddress, StatusMessage, StoredProofInfo, TxLogEntry, TxLogEntryType, WalletBackend,
|
||||
WalletInitStatus, WalletInst, WalletLCProvider, address,
|
||||
};
|
||||
use log::{error, info};
|
||||
use log::{error, info, warn};
|
||||
use num_bigint::BigInt;
|
||||
use parking_lot::RwLock;
|
||||
use rand::Rng;
|
||||
@@ -61,6 +63,41 @@ use std::time::Duration;
|
||||
use std::{fs, path, thread};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A held nostr identity as the identity switcher needs to render it. Carries no
|
||||
/// secret material — the nsec stays encrypted at rest as its own NIP-49 ncryptsec.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HeldIdentitySummary {
|
||||
/// Public key, lowercase hex (the stable id passed to `switch_nostr_identity`).
|
||||
pub pubkey_hex: String,
|
||||
/// Public key, bech32 npub (for display/copy).
|
||||
pub npub: String,
|
||||
/// Claimed @name without the domain, if this identity has one.
|
||||
pub name: Option<String>,
|
||||
/// Full NIP-05 identifier ("user@domain") when this identity has a claimed
|
||||
/// name, for the transaction detail view.
|
||||
pub nip05: Option<String>,
|
||||
/// PRIVATE, app-only label the user set for this identity. Local metadata
|
||||
/// only, never published. Display precedence: tag, else claimed name, else
|
||||
/// truncated npub.
|
||||
pub tag: Option<String>,
|
||||
/// Convenience label from the index (claimed name's local part, or empty).
|
||||
pub label: String,
|
||||
/// Whether this is the currently active identity.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl HeldIdentitySummary {
|
||||
/// What the UI shows for this identity: the private tag if set, else the
|
||||
/// claimed name (bare, no leading @), else the truncated npub.
|
||||
pub fn display(&self) -> String {
|
||||
self.tag
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| self.name.clone())
|
||||
.unwrap_or_else(|| crate::gui::views::goblin::data::short_npub(&self.pubkey_hex))
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains wallet instance, configuration and state, handles wallet commands.
|
||||
#[derive(Clone)]
|
||||
pub struct Wallet {
|
||||
@@ -250,14 +287,17 @@ impl Wallet {
|
||||
None
|
||||
}
|
||||
|
||||
/// Create [`HTTPNodeClient`] from provided config.
|
||||
fn create_node_client(config: &WalletConfig) -> Result<HTTPNodeClient, Error> {
|
||||
/// Resolve the node API URL and secret for the connection saved in `config`.
|
||||
/// Shared by [`Wallet::create_node_client`] (wallet open) and
|
||||
/// [`Wallet::reconnect_node`] (live node switch) so both always target the
|
||||
/// same node for a given config.
|
||||
fn node_url_secret(config: &WalletConfig) -> (String, Option<String>) {
|
||||
let integrated = || {
|
||||
let api_url = format!("http://{}", NodeConfig::get_api_address());
|
||||
let api_secret = NodeConfig::get_api_secret(true);
|
||||
(api_url, api_secret)
|
||||
};
|
||||
let (node_api_url, node_secret) = if let Some(id) = config.ext_conn_id {
|
||||
if let Some(id) = config.ext_conn_id {
|
||||
if let Some(conn) = ConnectionsConfig::ext_conn(id) {
|
||||
(conn.url, conn.secret)
|
||||
} else {
|
||||
@@ -265,7 +305,12 @@ impl Wallet {
|
||||
}
|
||||
} else {
|
||||
integrated()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Create [`HTTPNodeClient`] from provided config.
|
||||
fn create_node_client(config: &WalletConfig) -> Result<HTTPNodeClient, Error> {
|
||||
let (node_api_url, node_secret) = Self::node_url_secret(config);
|
||||
let client = if AppConfig::use_proxy() {
|
||||
let socks = AppConfig::use_socks_proxy();
|
||||
let url = if socks {
|
||||
@@ -447,7 +492,7 @@ impl Wallet {
|
||||
// is deliberately independent of the wallet seed: the seed must never
|
||||
// double as identity evidence, and a rotation must not be derivable
|
||||
// from it. The nsec backup is therefore the identity backup.
|
||||
let identity = match NostrIdentity::load(&nostr_dir) {
|
||||
let legacy = match NostrIdentity::load(&nostr_dir) {
|
||||
Some(identity) => identity,
|
||||
None => match NostrIdentity::create_random(password) {
|
||||
Ok((identity, _)) => {
|
||||
@@ -463,20 +508,68 @@ impl Wallet {
|
||||
}
|
||||
},
|
||||
};
|
||||
let keys = match identity.unlock(password) {
|
||||
Ok(keys) => keys,
|
||||
Err(e) => {
|
||||
error!("nostr: identity unlock failed: {e}");
|
||||
// Adopt the held-identity index (migrating a pre-feature wallet's bare
|
||||
// identity.json into it) and UNLOCK EVERY held identity now: with the wallet
|
||||
// open, all identities' keys live in memory so the wallet listens for all of
|
||||
// them at once (same trust boundary as the grin seed already in memory).
|
||||
if HeldIdentities::load_or_migrate(&nostr_dir, &legacy).is_none() {
|
||||
error!("nostr: held-identity index unreadable");
|
||||
}
|
||||
let (recv, active_hex) = match self.unlock_all_identities(&nostr_dir, password) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
error!("nostr: no identity could be unlocked; nostr disabled this session");
|
||||
return;
|
||||
}
|
||||
};
|
||||
info!("nostr: identity ready: {}", identity.npub);
|
||||
info!(
|
||||
"nostr: {} identit(ies) unlocked; active {}",
|
||||
recv.len(),
|
||||
active_hex
|
||||
);
|
||||
let store = NostrStore::new(config.get_nostr_db_path());
|
||||
let service = NostrService::new(keys, identity, nostr_config, store, nostr_dir);
|
||||
let service = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
|
||||
let mut w_nostr = self.nostr.write();
|
||||
*w_nostr = Some(service);
|
||||
}
|
||||
|
||||
/// Unlock EVERY held identity with the wallet password, returning the decrypted
|
||||
/// set plus the active identity's pubkey hex. All held nsecs share the one
|
||||
/// wallet password. Identities that fail to unlock (corrupt file) are skipped
|
||||
/// with a warning; `None` only if not a single one could be unlocked. Used at
|
||||
/// wallet-open and when rebuilding the service after add/import/rotate.
|
||||
fn unlock_all_identities(
|
||||
&self,
|
||||
nostr_dir: &PathBuf,
|
||||
password: &str,
|
||||
) -> Option<(Vec<HeldIdentityKeys>, String)> {
|
||||
let index = HeldIdentities::load(nostr_dir)?;
|
||||
let mut recv = Vec::new();
|
||||
for entry in &index.identities {
|
||||
let Some(identity) = entry.load(nostr_dir) else {
|
||||
warn!("nostr: identity file unreadable: {}", entry.path);
|
||||
continue;
|
||||
};
|
||||
match identity.unlock(password) {
|
||||
Ok(keys) => recv.push(HeldIdentityKeys { keys, identity }),
|
||||
Err(e) => warn!("nostr: identity {} failed to unlock: {e}", entry.pubkey),
|
||||
}
|
||||
}
|
||||
if recv.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Prefer the recorded active pointer; fall back to the first that unlocked.
|
||||
let active_hex = if recv
|
||||
.iter()
|
||||
.any(|h| h.identity.pubkey_hex().as_deref() == Some(index.active.as_str()))
|
||||
{
|
||||
index.active.clone()
|
||||
} else {
|
||||
recv[0].identity.pubkey_hex().unwrap_or_default()
|
||||
};
|
||||
Some((recv, active_hex))
|
||||
}
|
||||
|
||||
/// Get the nostr service when available.
|
||||
pub fn nostr_service(&self) -> Option<Arc<NostrService>> {
|
||||
let r_nostr = self.nostr.read();
|
||||
@@ -521,7 +614,7 @@ impl Wallet {
|
||||
.map_err(|_| "Wrong password".to_string())?;
|
||||
|
||||
// Generate the replacement identity.
|
||||
let (mut new_identity, new_keys) = NostrIdentity::create_random(&password)
|
||||
let (mut new_identity, _new_keys) = NostrIdentity::create_random(&password)
|
||||
.map_err(|e| format!("key generation failed: {e}"))?;
|
||||
|
||||
// Release the username first (the server also deletes its avatar);
|
||||
@@ -561,7 +654,13 @@ impl Wallet {
|
||||
let nostr_config = NostrConfig::load(wallet_dir);
|
||||
let store = NostrStore::new(config.get_nostr_db_path());
|
||||
let new_npub = new_identity.npub.clone();
|
||||
let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir);
|
||||
// Rebuild the service holding ALL held identities (the primary entry now
|
||||
// resolves to the new identity), with the new one active.
|
||||
let (recv, _) = self
|
||||
.unlock_all_identities(&nostr_dir, &password)
|
||||
.ok_or_else(|| "identity unlock failed".to_string())?;
|
||||
let active_hex = new_identity.pubkey_hex().unwrap_or_default();
|
||||
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
|
||||
{
|
||||
let mut w_nostr = self.nostr.write();
|
||||
*w_nostr = Some(new_svc.clone());
|
||||
@@ -609,6 +708,7 @@ impl Wallet {
|
||||
ident.nip05 = backup.nip05.clone();
|
||||
ident.anonymous = backup.anonymous;
|
||||
ident.prev_npubs = backup.prev_npubs.clone();
|
||||
ident.private_tag = backup.private_tag.clone();
|
||||
(ident, keys)
|
||||
} else if input.starts_with('{') {
|
||||
// Legacy plaintext identity-backup JSON (pre-.backup-file): decrypt
|
||||
@@ -626,6 +726,7 @@ impl Wallet {
|
||||
ident.nip05 = backup.nip05.clone();
|
||||
ident.anonymous = backup.anonymous;
|
||||
ident.prev_npubs = backup.prev_npubs.clone();
|
||||
ident.private_tag = backup.private_tag.clone();
|
||||
(ident, keys)
|
||||
} else {
|
||||
NostrIdentity::create_imported(input, &password)
|
||||
@@ -650,7 +751,14 @@ impl Wallet {
|
||||
let nostr_config = NostrConfig::load(wallet_dir);
|
||||
let store = NostrStore::new(config.get_nostr_db_path());
|
||||
let new_npub = new_identity.npub.clone();
|
||||
let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir);
|
||||
// Rebuild the service holding ALL held identities, with the imported one
|
||||
// active (the primary entry now resolves to it).
|
||||
let _ = new_keys;
|
||||
let (recv, _) = self
|
||||
.unlock_all_identities(&nostr_dir, &password)
|
||||
.ok_or_else(|| "identity unlock failed".to_string())?;
|
||||
let active_hex = new_identity.pubkey_hex().unwrap_or_default();
|
||||
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
|
||||
{
|
||||
let mut w_nostr = self.nostr.write();
|
||||
*w_nostr = Some(new_svc.clone());
|
||||
@@ -675,6 +783,324 @@ impl Wallet {
|
||||
.map_err(|e| format!("backup failed: {e}"))
|
||||
}
|
||||
|
||||
// ── Held nostr identities (one wallet, one balance, many front doors) ──────
|
||||
//
|
||||
// One grin seed / one balance, but the wallet can HOLD several nostr
|
||||
// identities and present a different one at will. Exactly one is ACTIVE: it
|
||||
// drives the single live gift-wrap subscription and all display, and every
|
||||
// identity redeems into the SAME shared grin balance. Only the active nsec is
|
||||
// ever decrypted into memory; the rest rest as ncryptsec on disk. Switching is
|
||||
// mechanically identical to `rotate_nostr_identity` (stop the service, rebuild
|
||||
// it on the target key, restart), plus a per-identity catch-up so payments that
|
||||
// arrived while an identity was dormant are redeemed on switch-in.
|
||||
|
||||
/// The held identities for the identity switcher (no secrets). Empty if nostr
|
||||
/// is disabled/not running.
|
||||
pub fn nostr_identities(&self) -> Vec<HeldIdentitySummary> {
|
||||
let nostr_dir = self.get_config().get_nostr_path();
|
||||
let Some(index) = HeldIdentities::load(&nostr_dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
index
|
||||
.identities
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let identity = entry.load(&nostr_dir)?;
|
||||
let name = identity
|
||||
.nip05
|
||||
.as_deref()
|
||||
.and_then(|n| n.split('@').next())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
Some(HeldIdentitySummary {
|
||||
pubkey_hex: entry.pubkey.clone(),
|
||||
npub: identity.npub.clone(),
|
||||
name,
|
||||
nip05: identity.nip05.clone(),
|
||||
tag: identity.private_tag.clone(),
|
||||
label: entry.label.clone(),
|
||||
active: entry.pubkey == index.active,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The active identity's pubkey (hex), or `None` if nostr is not running.
|
||||
pub fn active_nostr_pubkey(&self) -> Option<String> {
|
||||
self.nostr_service().map(|s| s.public_key().to_hex())
|
||||
}
|
||||
|
||||
/// Verify a candidate wallet password against the active nostr identity
|
||||
/// (cheap, in-memory NIP-49 unlock). Lets the identity password modal reject a
|
||||
/// wrong password up front — before spawning an add/switch worker — the way the
|
||||
/// wallet-open modal does. `false` when nostr is not running.
|
||||
pub fn verify_nostr_password(&self, password: &str) -> bool {
|
||||
self.nostr_service()
|
||||
.map(|s| s.identity.read().unlock(password).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Add a nostr identity to this wallet WITHOUT switching to it: generate a
|
||||
/// fresh random nsec (`import` is `None`) or import an existing one (`import`
|
||||
/// is `Some(nsec)`). The new key is encrypted under the wallet password like
|
||||
/// every held identity, then recorded in the index. Returns the new npub. The
|
||||
/// caller may follow with `switch_nostr_identity` to make it active.
|
||||
///
|
||||
/// The wallet password is proven against the CURRENT identity first, so a
|
||||
/// wrong password can neither add a mis-encrypted key nor disturb anything.
|
||||
pub fn add_nostr_identity(
|
||||
&self,
|
||||
import: Option<String>,
|
||||
password: String,
|
||||
) -> Result<String, String> {
|
||||
let svc = self
|
||||
.nostr_service()
|
||||
.ok_or_else(|| "nostr is not running".to_string())?;
|
||||
// Prove the wallet password against the current identity before writing a
|
||||
// new key, so all held identities share the one wallet password.
|
||||
svc.identity
|
||||
.read()
|
||||
.unlock(&password)
|
||||
.map_err(|_| "Wrong password".to_string())?;
|
||||
// The import blob may be a bare nsec OR a sealed GOBLIN-*.backup file
|
||||
// (reusing the same open path as `import_nostr_identity`). Either becomes a
|
||||
// held identity re-encrypted under THIS wallet's password.
|
||||
let (identity, _keys) = match import {
|
||||
Some(blob) => {
|
||||
let blob = blob.trim();
|
||||
if NostrIdentity::is_encrypted_backup(blob) {
|
||||
// A .backup: open it (same wallet password, since a backup made
|
||||
// by this wallet is sealed under it), then re-encrypt under the
|
||||
// wallet password and restore its name/history.
|
||||
let (backup, keys) = NostrIdentity::from_encrypted_backup(blob, &password)
|
||||
.map_err(|_| {
|
||||
"Couldn't open the backup — it may have been made under a \
|
||||
different wallet password."
|
||||
.to_string()
|
||||
})?;
|
||||
let mut ident =
|
||||
NostrIdentity::from_unlocked_keys(&keys, &password, backup.source)
|
||||
.map_err(|e| format!("re-encryption failed: {e}"))?;
|
||||
ident.nip05 = backup.nip05.clone();
|
||||
ident.anonymous = backup.anonymous;
|
||||
ident.prev_npubs = backup.prev_npubs.clone();
|
||||
ident.private_tag = backup.private_tag.clone();
|
||||
(ident, keys)
|
||||
} else {
|
||||
NostrIdentity::create_imported(blob, &password)
|
||||
.map_err(|_| "Invalid nsec".to_string())?
|
||||
}
|
||||
}
|
||||
None => NostrIdentity::create_random(&password)
|
||||
.map_err(|e| format!("key generation failed: {e}"))?,
|
||||
};
|
||||
let config = self.get_config();
|
||||
let nostr_dir = config.get_nostr_path();
|
||||
let mut index = HeldIdentities::load(&nostr_dir)
|
||||
.ok_or_else(|| "identity index unavailable".to_string())?;
|
||||
index
|
||||
.add(&nostr_dir, &identity)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let new_npub = identity.npub.clone();
|
||||
info!("nostr: added held identity {new_npub}");
|
||||
// Bring the new identity ONLINE: rebuild the service holding ALL identities
|
||||
// (including the one just added) so it starts listening immediately. The
|
||||
// active identity is unchanged — adding never switches.
|
||||
let active_hex = svc.public_key().to_hex();
|
||||
let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path()));
|
||||
let store = NostrStore::new(config.get_nostr_db_path());
|
||||
let (recv, _) = self
|
||||
.unlock_all_identities(&nostr_dir, &password)
|
||||
.ok_or_else(|| "identity unlock failed".to_string())?;
|
||||
svc.stop();
|
||||
for _ in 0..100 {
|
||||
if !svc.is_running() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
|
||||
{
|
||||
let mut w_nostr = self.nostr.write();
|
||||
*w_nostr = Some(new_svc.clone());
|
||||
}
|
||||
new_svc.start(self.clone());
|
||||
Ok(new_npub)
|
||||
}
|
||||
|
||||
/// INSTANT identity switch: a purely-local change of which held identity is
|
||||
/// presented and used for sending. Every held identity is already unlocked and
|
||||
/// already listening, so there is NO password prompt, NO service teardown, and
|
||||
/// NO catch-up — payments were never missed. Just re-points the running
|
||||
/// service's active keys/identity and persists the active pointer. Returns the
|
||||
/// target npub.
|
||||
pub fn switch_nostr_identity(&self, target_hex: String) -> Result<String, String> {
|
||||
let svc = self
|
||||
.nostr_service()
|
||||
.ok_or_else(|| "nostr is not running".to_string())?;
|
||||
// Already active? Nothing to do.
|
||||
if svc.public_key().to_hex() == target_hex {
|
||||
return Ok(svc.npub());
|
||||
}
|
||||
// Re-point the active identity in memory (the target is already unlocked and
|
||||
// listening); fails only if it isn't a held identity of this wallet.
|
||||
if !svc.set_active_by_pubkey(&target_hex) {
|
||||
return Err("identity not held by this wallet".to_string());
|
||||
}
|
||||
// Persist the active pointer so the next open lands on it too. The legacy
|
||||
// identity.json is never overwritten (an older build still opens on #1).
|
||||
let nostr_dir = self.get_config().get_nostr_path();
|
||||
if let Some(mut index) = HeldIdentities::load(&nostr_dir) {
|
||||
let _ = index.set_active(&nostr_dir, &target_hex);
|
||||
}
|
||||
let new_npub = svc.npub();
|
||||
info!("nostr: switched active identity to {}", new_npub);
|
||||
Ok(new_npub)
|
||||
}
|
||||
|
||||
/// Set (or clear, with an empty string) a held identity's PRIVATE tag — the
|
||||
/// local, app-only name the user gives it. Persisted in its 0600 identity
|
||||
/// file (and thus inside future sealed .backups) and updated in the running
|
||||
/// service so the switcher re-renders immediately. NEVER published to nostr.
|
||||
/// Local metadata only, so no password is required (the ncryptsec is untouched).
|
||||
pub fn rename_nostr_identity(&self, target_hex: String, tag: String) -> Result<(), String> {
|
||||
let svc = self
|
||||
.nostr_service()
|
||||
.ok_or_else(|| "nostr is not running".to_string())?;
|
||||
let nostr_dir = self.get_config().get_nostr_path();
|
||||
let index = HeldIdentities::load(&nostr_dir)
|
||||
.ok_or_else(|| "identity index unavailable".to_string())?;
|
||||
let entry = index
|
||||
.entry(&target_hex)
|
||||
.ok_or_else(|| "identity not held by this wallet".to_string())?;
|
||||
let mut identity = entry
|
||||
.load(&nostr_dir)
|
||||
.ok_or_else(|| "identity file unreadable".to_string())?;
|
||||
let tag = tag.trim().to_string();
|
||||
let tag = if tag.is_empty() { None } else { Some(tag) };
|
||||
identity.private_tag = tag.clone();
|
||||
identity
|
||||
.save_at(&entry.abs_path(&nostr_dir))
|
||||
.map_err(|e| format!("save failed: {e}"))?;
|
||||
svc.set_private_tag(&target_hex, tag);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PERMANENTLY delete a held identity: drop it from the held-identity index,
|
||||
/// delete its on-disk encrypted file, and rebuild the running service without
|
||||
/// it (so its pubkey leaves the multi-pubkey gift-wrap subscription and its key
|
||||
/// leaves the in-memory set). The one shared grin balance and every other
|
||||
/// identity are untouched. Unrecoverable unless its nsec/.backup was saved.
|
||||
///
|
||||
/// Edge cases:
|
||||
/// - Refuses to delete the LAST identity (a wallet must keep >= 1).
|
||||
/// - Deleting the ACTIVE identity first switches active to a survivor.
|
||||
/// - Deleting the legacy primary (the `identity.json` entry, which `init_nostr`
|
||||
/// falls back to and an older build opens) PROMOTES a survivor into
|
||||
/// `identity.json` so that fallback still resolves — never leaving a hole
|
||||
/// that would spawn a fresh identity on next open.
|
||||
pub fn delete_nostr_identity(
|
||||
&self,
|
||||
target_hex: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let svc = self
|
||||
.nostr_service()
|
||||
.ok_or_else(|| "nostr is not running".to_string())?;
|
||||
if !self.verify_nostr_password(&password) {
|
||||
return Err("Wrong password".to_string());
|
||||
}
|
||||
let config = self.get_config();
|
||||
let nostr_dir = config.get_nostr_path();
|
||||
let mut index = HeldIdentities::load(&nostr_dir)
|
||||
.ok_or_else(|| "identity index unavailable".to_string())?;
|
||||
// (a) Never delete the only identity.
|
||||
if index.len() <= 1 {
|
||||
return Err("You must keep at least one identity".to_string());
|
||||
}
|
||||
let entry = index
|
||||
.entry(&target_hex)
|
||||
.cloned()
|
||||
.ok_or_else(|| "identity not held by this wallet".to_string())?;
|
||||
// A survivor to take over the active pointer / primary role.
|
||||
let survivor = index
|
||||
.identities
|
||||
.iter()
|
||||
.find(|e| e.pubkey != target_hex)
|
||||
.map(|e| e.pubkey.clone())
|
||||
.ok_or_else(|| "no other identity".to_string())?;
|
||||
|
||||
// (c) Deleting the legacy primary: promote the survivor into identity.json
|
||||
// so init_nostr's fallback/rollback anchor still resolves. Otherwise just
|
||||
// delete the target's own file.
|
||||
if entry.path == "identity.json" {
|
||||
let survivor_entry = index
|
||||
.entry(&survivor)
|
||||
.cloned()
|
||||
.ok_or_else(|| "survivor missing".to_string())?;
|
||||
let survivor_id = survivor_entry
|
||||
.load(&nostr_dir)
|
||||
.ok_or_else(|| "survivor unreadable".to_string())?;
|
||||
// Overwrite identity.json with the survivor (its old content = the
|
||||
// deleted key is gone), and repoint the survivor entry to identity.json.
|
||||
survivor_id
|
||||
.save(&nostr_dir)
|
||||
.map_err(|e| format!("promote failed: {e}"))?;
|
||||
if survivor_entry.path != "identity.json" {
|
||||
let old_abs = survivor_entry.abs_path(&nostr_dir);
|
||||
for e in index.identities.iter_mut() {
|
||||
if e.pubkey == survivor {
|
||||
e.path = "identity.json".to_string();
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(old_abs);
|
||||
}
|
||||
} else {
|
||||
let _ = std::fs::remove_file(entry.abs_path(&nostr_dir));
|
||||
}
|
||||
|
||||
// Remove the target from the index (entries + order); repoint active if it
|
||||
// was the deleted one.
|
||||
index.identities.retain(|e| e.pubkey != target_hex);
|
||||
index.order.retain(|h| h != &target_hex);
|
||||
if index.active == target_hex {
|
||||
index.active = survivor.clone();
|
||||
}
|
||||
index
|
||||
.save(&nostr_dir)
|
||||
.map_err(|e| format!("index save failed: {e}"))?;
|
||||
|
||||
// (b) If the deleted identity was active, the survivor becomes active.
|
||||
let active_hex = if svc.public_key().to_hex() == target_hex {
|
||||
survivor
|
||||
} else {
|
||||
svc.public_key().to_hex()
|
||||
};
|
||||
|
||||
// Rebuild the service WITHOUT the deleted identity: unlock_all_identities now
|
||||
// excludes it, so it leaves both the subscription and the in-memory key set.
|
||||
let nostr_config = NostrConfig::load(PathBuf::from(config.get_data_path()));
|
||||
let store = NostrStore::new(config.get_nostr_db_path());
|
||||
let (recv, _) = self
|
||||
.unlock_all_identities(&nostr_dir, &password)
|
||||
.ok_or_else(|| "identity unlock failed".to_string())?;
|
||||
svc.stop();
|
||||
for _ in 0..100 {
|
||||
if !svc.is_running() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let new_svc = NostrService::new(recv, &active_hex, nostr_config, store, nostr_dir);
|
||||
{
|
||||
let mut w_nostr = self.nostr.write();
|
||||
*w_nostr = Some(new_svc.clone());
|
||||
}
|
||||
new_svc.start(self.clone());
|
||||
info!("nostr: deleted held identity {target_hex}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get keychain mask [`SecretKey`].
|
||||
pub fn keychain_mask(&self) -> Option<SecretKey> {
|
||||
let r_key = self.keychain_mask.read();
|
||||
@@ -780,6 +1206,57 @@ impl Wallet {
|
||||
w_config.save();
|
||||
}
|
||||
|
||||
/// Apply the saved connection to the RUNNING wallet session immediately.
|
||||
///
|
||||
/// A node selection persisted by [`Wallet::update_connection`] only reaches
|
||||
/// an open wallet on the next open, because the node client is baked into the
|
||||
/// wallet instance at open time. This swaps the live node client's URL and
|
||||
/// secret in place (no close/reopen, so the password is not needed again),
|
||||
/// updates the runtime connection so the UI reflects the switch at once, then
|
||||
/// wakes the sync thread to refresh the balance against the new node.
|
||||
///
|
||||
/// If the new node is unreachable the sync simply errors and the honest
|
||||
/// "can't reach node" state surfaces (with the last-known balance retained),
|
||||
/// so the user is never stranded on a silent zero.
|
||||
pub fn reconnect_node(&self) {
|
||||
// Reflect the switch in the runtime connection right away so the picker
|
||||
// and status cards stop showing the previous node.
|
||||
let conn = self.get_config().connection();
|
||||
{
|
||||
let mut w_conn = self.connection.write();
|
||||
*w_conn = conn.clone();
|
||||
}
|
||||
// Clear the stale sync error/progress so the surface shows the honest
|
||||
// "updating" state while the new node is contacted.
|
||||
self.set_sync_error(false);
|
||||
self.reset_sync_attempts();
|
||||
self.info_sync_progress.store(0, Ordering::Relaxed);
|
||||
|
||||
// Swap the live node client, then kick a fresh sync. Done on a worker
|
||||
// thread: locking the instance can briefly contend with an in-flight
|
||||
// sync, and this is called from the UI thread.
|
||||
let wallet = self.clone();
|
||||
thread::spawn(move || {
|
||||
let has_instance = { wallet.instance.read().is_some() };
|
||||
if wallet.is_open() && has_instance {
|
||||
let (url, secret) = Self::node_url_secret(&wallet.get_config());
|
||||
let instance = { wallet.instance.read().clone().unwrap() };
|
||||
let mut wallet_lock = instance.lock();
|
||||
if let Ok(lc) = wallet_lock.lc_provider() {
|
||||
if let Ok(backend) = lc.wallet_inst() {
|
||||
let client = backend.w2n_client();
|
||||
client.set_node_url(&url);
|
||||
client.set_node_api_secret(secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resume node polling (may be paused on Android) and wake the sync
|
||||
// thread so the balance refreshes against the new node now.
|
||||
wallet.resume_node_polling();
|
||||
wallet.sync();
|
||||
});
|
||||
}
|
||||
|
||||
/// Get external connection URL applied to [`WalletInstance`]
|
||||
/// after wallet opening if sync is running or get it from config.
|
||||
pub fn get_current_connection(&self) -> ConnectionMethod {
|
||||
@@ -1756,11 +2233,30 @@ impl Wallet {
|
||||
|
||||
/// Change wallet password.
|
||||
pub fn change_password(&self, old: String, new: String) -> Result<(), Error> {
|
||||
let r_inst = self.instance.as_ref().read();
|
||||
let instance = r_inst.clone().unwrap();
|
||||
let mut wallet_lock = instance.lock();
|
||||
let lc = wallet_lock.lc_provider()?;
|
||||
lc.change_password(None, ZeroingString::from(old), ZeroingString::from(new))
|
||||
{
|
||||
let r_inst = self.instance.as_ref().read();
|
||||
let instance = r_inst.clone().unwrap();
|
||||
let mut wallet_lock = instance.lock();
|
||||
let lc = wallet_lock.lc_provider()?;
|
||||
lc.change_password(
|
||||
None,
|
||||
ZeroingString::from(old.clone()),
|
||||
ZeroingString::from(new.clone()),
|
||||
)?;
|
||||
}
|
||||
// The grin seed password changed; re-encrypt EVERY held nostr identity's
|
||||
// ncryptsec from old to new through the same NIP-49 path, so all front
|
||||
// doors follow the one wallet password. Best-effort and non-fatal: a
|
||||
// failure is logged (never swallowed as success), and the grin password
|
||||
// change already committed above. Takes effect for the running service on
|
||||
// the next wallet open (it reloads the active identity from disk).
|
||||
let nostr_dir = self.get_config().get_nostr_path();
|
||||
if let Some(index) = HeldIdentities::load(&nostr_dir)
|
||||
&& let Err(e) = index.reencrypt_all(&nostr_dir, &old, &new)
|
||||
{
|
||||
error!("nostr: re-encrypting held identities after password change: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initiate wallet repair by scanning its outputs.
|
||||
@@ -1871,6 +2367,23 @@ impl Wallet {
|
||||
Ok(proof)
|
||||
}
|
||||
|
||||
/// Retrieve a finalized send's payment proof as the `(json, kernel_excess_hex)`
|
||||
/// pair the proof-on-request delivery needs (frozen contract 4.3.2). `json` is
|
||||
/// the owner API's verbatim serialization of the proof
|
||||
/// (`{amount, excess, recipient_address, recipient_sig, sender_address,
|
||||
/// sender_sig}`); `kernel_excess_hex` is a convenience copy of the excess so
|
||||
/// the watcher can query the chain before parsing the proof. `None` when no
|
||||
/// proof exists yet (e.g. a proof-less send, or called before finalize).
|
||||
pub fn payment_proof_delivery(&self, slate_id: Uuid) -> Option<(String, String)> {
|
||||
let proof = self
|
||||
.get_payment_proof(None, Some(slate_id))
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let json = serde_json::to_string(&proof).ok()?;
|
||||
let kernel_hex = proof.excess.to_hex();
|
||||
Some((json, kernel_hex))
|
||||
}
|
||||
|
||||
/// Verify payment proof.
|
||||
fn verify_payment_proof(&self, proof: &PaymentProof) -> Result<(u32, bool, bool), Error> {
|
||||
let r_inst = self.instance.as_ref().read();
|
||||
@@ -2422,19 +2935,31 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
w.on_tx_error(*id, Some(e));
|
||||
}
|
||||
},
|
||||
WalletTask::NostrSend(a, receiver, note, relay_hints) => {
|
||||
WalletTask::NostrSend(a, receiver, note, relay_hints, proof, order, notify) => {
|
||||
let Some(service) = w.nostr_service() else {
|
||||
error!("nostr send: service not available");
|
||||
return;
|
||||
};
|
||||
w.send_creating.store(true, Ordering::Relaxed);
|
||||
service.set_send_phase(crate::nostr::send_phase::WORKING);
|
||||
match w.send(*a, None) {
|
||||
// Proof-on-request (frozen contract W2): when the payment context
|
||||
// carries a `proof=` slatepack address, re-parse it authoritatively
|
||||
// here (a second, fail-closed gate after the URI parser's shape check)
|
||||
// and thread it as the native `payment_proof_recipient_address`. Absent
|
||||
// or unparseable → `None`, a normal proof-less send. This is the ONLY
|
||||
// path that sets a proof recipient over Nostr.
|
||||
let proof_addr: Option<SlatepackAddress> = proof
|
||||
.as_deref()
|
||||
.and_then(|p| SlatepackAddress::try_from(p.trim()).ok());
|
||||
let proof_mode = proof_addr.is_some();
|
||||
match w.send(*a, proof_addr) {
|
||||
Ok(s) => {
|
||||
sync_wallet_data(&w, false);
|
||||
let now = crate::nostr::unix_time();
|
||||
// Record intent BEFORE the network dispatch so a crash
|
||||
// is recovered by the service reconcile pass.
|
||||
// is recovered by the service reconcile pass. The proof context
|
||||
// (order handle + watcher npub + amount) is persisted now so a
|
||||
// crash between send and finalize loses nothing.
|
||||
service.store.save_tx_meta(&crate::nostr::TxNostrMeta {
|
||||
ver: 1,
|
||||
slate_id: s.id.to_string(),
|
||||
@@ -2446,6 +2971,13 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
received_rumor_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
proof_mode,
|
||||
proof_order: if proof_mode { order.clone() } else { None },
|
||||
proof_notify: if proof_mode { notify.clone() } else { None },
|
||||
proof_amount: if proof_mode { Some(*a) } else { None },
|
||||
proof_delivered: false,
|
||||
receipt_sent: false,
|
||||
recipient_pubkey: service.public_key().to_hex(),
|
||||
});
|
||||
let tx = w.retrieve_tx_by_id(None, Some(s.id));
|
||||
w.send_creating.store(false, Ordering::Relaxed);
|
||||
@@ -2455,9 +2987,34 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
.await
|
||||
{
|
||||
Ok(event_id) => {
|
||||
// Proof-on-request (frozen contract 4.3.1): publish the
|
||||
// plain "payment sent" receipt HERE at S1 dispatch, the
|
||||
// same moment the payment envelope was accepted by a relay
|
||||
// and the wallet UI flips to "sent", not at finalize. This
|
||||
// closes the buyer's double-send window: an offline merchant
|
||||
// leaves finalize hours away, and until this receipt lands
|
||||
// the order page still shows a scannable QR for a payment
|
||||
// already made. The encrypted PROOF stays at finalize (it
|
||||
// does not exist before then). On failure we leave
|
||||
// receipt_sent=false so the reconcile pass retries it.
|
||||
let mut receipt_sent = false;
|
||||
if crate::nostr::receipt_due_at_dispatch(
|
||||
proof_mode,
|
||||
order.as_deref(),
|
||||
) {
|
||||
let ord = order.as_deref().unwrap_or_default();
|
||||
match service.publish_receipt_sent(ord, *a).await {
|
||||
Ok(()) => receipt_sent = true,
|
||||
Err(e) => log::warn!(
|
||||
"nostr: dispatch receipt publish failed for {}: {e}",
|
||||
s.id
|
||||
),
|
||||
}
|
||||
}
|
||||
if let Some(mut meta) = service.store.tx_meta(&s.id.to_string()) {
|
||||
meta.status = crate::nostr::NostrSendStatus::AwaitingS2;
|
||||
meta.sent_event_id = Some(event_id);
|
||||
meta.receipt_sent = receipt_sent;
|
||||
meta.updated_at = crate::nostr::unix_time();
|
||||
service.store.save_tx_meta(&meta);
|
||||
}
|
||||
@@ -2553,6 +3110,15 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
received_rumor_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
// Grin's invoice flow carries no payment proofs (Fact 3), so a
|
||||
// request we issued never enters proof mode.
|
||||
proof_mode: false,
|
||||
proof_order: None,
|
||||
proof_notify: None,
|
||||
proof_amount: None,
|
||||
proof_delivered: false,
|
||||
receipt_sent: false,
|
||||
recipient_pubkey: service.public_key().to_hex(),
|
||||
});
|
||||
let tx = w.retrieve_tx_by_id(None, Some(s.id));
|
||||
w.invoice_creating.store(false, Ordering::Relaxed);
|
||||
@@ -2798,6 +3364,13 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
received_rumor_id: Some(request.rumor_id.clone()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
proof_mode: false,
|
||||
proof_order: None,
|
||||
proof_notify: None,
|
||||
proof_amount: None,
|
||||
proof_delivered: false,
|
||||
receipt_sent: false,
|
||||
recipient_pubkey: service.public_key().to_hex(),
|
||||
});
|
||||
match service
|
||||
.send_payment_dm(&request.npub, &text, None, &[])
|
||||
@@ -2842,6 +3415,13 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
received_rumor_id: Some(request.rumor_id.clone()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
proof_mode: false,
|
||||
proof_order: None,
|
||||
proof_notify: None,
|
||||
proof_amount: None,
|
||||
proof_delivered: false,
|
||||
receipt_sent: false,
|
||||
recipient_pubkey: service.public_key().to_hex(),
|
||||
});
|
||||
match service
|
||||
.send_payment_dm(&request.npub, &text, None, &[])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -59,6 +59,22 @@
|
||||
</Extension>
|
||||
</ProgId>
|
||||
</Component>
|
||||
|
||||
<!-- Register the goblin: URL protocol so web "Open in Goblin" pay
|
||||
buttons launch the wallet with the payment link as argv[1],
|
||||
which routes to the prefilled send-review screen. -->
|
||||
<Component Id="GoblinUrlProtocol" Guid="a4f1c2e8-9b3d-4e6a-8c7f-2d1b5e9a0c34">
|
||||
<RegistryKey Root="HKCR" Key="goblin">
|
||||
<RegistryValue Type="string" Value="URL:Goblin Protocol" KeyPath="yes"/>
|
||||
<RegistryValue Name="URL Protocol" Type="string" Value=""/>
|
||||
<RegistryKey Key="shell\open\command">
|
||||
<RegistryValue Type="string" Value=""[#goblin.exe]" "%1""/>
|
||||
</RegistryKey>
|
||||
<RegistryKey Key="DefaultIcon">
|
||||
<RegistryValue Type="string" Value="[#goblin.exe]"/>
|
||||
</RegistryKey>
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<!-- Step 2: Add the shortcut to your installer package -->
|
||||
@@ -77,6 +93,7 @@
|
||||
|
||||
<Feature Id="MainApplication" Title="Goblin" Level="1">
|
||||
<ComponentRef Id="goblin.exe" />
|
||||
<ComponentRef Id="GoblinUrlProtocol" />
|
||||
<ComponentRef Id="License" />
|
||||
<!-- Step 3: Tell WiX to install the shortcut -->
|
||||
<ComponentRef Id="ApplicationShortcutDesktop" />
|
||||
|
||||
Reference in New Issue
Block a user