diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27b2d19eb..95846ed4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ env: # pure-Rust jobs below and built on their own; everything else gets full coverage. WORKSPACE_EXCLUDES: --workspace --exclude wraith-wallet-gui --exclude ghost-tap-desktop # System packages the Rust workspace needs: Cap'n Proto (bitcoin-core-sv2 build - # script) and SQLite (ghost-storage / ghost-pay). + # script) and SQLite (ghost-storage). LINUX_DEPS: libsqlite3-dev capnproto libcapnp-dev # What the Tauri desktop crates need on top: webkit2gtk is the webview, and `patchelf` is # used by the bundler. A superset of LINUX_DEPS so the one install script covers both. @@ -185,8 +185,6 @@ jobs: run: .github/scripts/install-linux-deps.sh - name: Run integration tests run: | - # bond_e2e execs the real ghost-pay binary and asserts it exists. - cargo build -p ghost-pay cargo test $WORKSPACE_EXCLUDES --tests \ --exclude ghost-mpc \ --exclude mpc-xproc-harness \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 201e88d22..4dd15afe9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,7 +80,7 @@ jobs: if: runner.os == 'Linux' timeout-minutes: 10 env: - # ghost-pool/ghost-pay (Rust) need libsqlite3 + capnproto (the SV2 + # ghost-pool (Rust) needs libsqlite3 + capnproto (the SV2 # stratum-core dep). The Linux job ALSO builds ghostd (the C++ Bitcoin # Core daemon) below, which needs the standard Core build deps: a # compiler + CMake + pkgconf, libevent, boost, and libzmq. WITH_ZMQ is @@ -138,23 +138,20 @@ jobs: # release artifacts here. - name: Build run: | - # CRITICAL: BOTH ghost-pool AND ghost-pay must be built with their own - # zk-production feature so the released binaries can run on mainnet. - # Without it, is_production_mode() is false and the binary aborts at - # startup ("MAINNET SECURITY: ZK consensus on mainnet requires trusted - # setup parameters"), so every curl|bash install crash-loops. ghost-pay - # has a SEPARATE `zk-production` feature (ghost-zkp/zk-production) — it is - # NOT enabled transitively by ghost-pool/zk-production, so it must be - # listed explicitly. zk-production is a compile-time flag with no - # build-time artefacts (params load at runtime), so this is safe. + # CRITICAL: ghost-pool must be built with its zk-production feature so + # the released binary can run on mainnet. Without it, + # is_production_mode() is false and the binary aborts at startup + # ("MAINNET SECURITY: ZK consensus on mainnet requires trusted setup + # parameters"), so every curl|bash install crash-loops. zk-production + # is a compile-time flag with no build-time artefacts (params load at + # runtime), so this is safe. cargo build --release --target ${{ matrix.target }} \ - --features ghost-pool/zk-production,ghost-pay/zk-production \ - -p ghost-pool -p ghost-cli -p ghost-pay -p ghost-gsp-bin \ + --features ghost-pool/zk-production \ + -p ghost-pool -p ghost-cli \ -p translator_sv2 -p pool_sv2 -p jd_client_sv2 -p jd_server_sv2 - # Build ghostd (the C++ Bitcoin Core daemon) so all THREE node binaries - # (ghost-pool + ghost-pay + ghostd) ship in ONE signed release at ONE - # version and can never drift apart. Linux only — the pool/elder fleet is + # Build ghostd (the C++ Bitcoin Core daemon) so the node binaries ship in + # ONE signed release at ONE version and can never drift apart. Linux only — the pool/elder fleet is # Linux, and a Bitcoin Core build is neither needed nor attempted on the # macOS targets. # @@ -203,7 +200,7 @@ jobs: mkdir -p dist # Copy binaries (check actual names from target directory) - for bin in ghost-pool ghost-cli ghost-pay ghost-gsp translator_sv2 pool_sv2 jd_client_sv2 ghost-light-wallet ghost-light-wallet-tui; do + for bin in ghost-pool ghost-cli translator_sv2 pool_sv2 jd_client_sv2 ghost-light-wallet ghost-light-wallet-tui; do if [ -f "target/${{ matrix.target }}/release/${bin}${{ matrix.suffix }}" ]; then cp "target/${{ matrix.target }}/release/${bin}${{ matrix.suffix }}" dist/ fi @@ -211,8 +208,8 @@ jobs: # ghostd (C++) is built only on the Linux target (step above). Stage it # into the SAME dist/ so it lands inside the linux tarball alongside - # ghost-pool + ghost-pay and is covered — with zero extra signing logic - # — by the existing SHA256SUMS + GPG signature over the tarball. + # ghost-pool and is covered — with zero extra signing logic — by the + # existing SHA256SUMS + GPG signature over the tarball. if [ -f "ghost-core/build/bin/ghostd" ]; then cp "ghost-core/build/bin/ghostd" dist/ fi diff --git a/.gitignore b/.gitignore index 055cb64d3..152c170ca 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,9 @@ docker/regtest-cluster/bin/ # were committed by accident in 8b77fb352 because the tests populate this directory and a # `git add -A` swept it in. They are build products, not source: the harness fetches them. tests/integration-sv2/template-provider/ + +# Coordinator Lock co-signing state. `--lock-ledger-dir` defaults to the +# working directory, so running a coordinator from the repo root drops these +# here. They are runtime state and must never be committed. +lock-cosigned-coins.json +lock-cosign-spends.json diff --git a/Cargo.lock b/Cargo.lock index a87ac8fa0..28d88be00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -854,9 +854,7 @@ dependencies = [ "ghost-buds", "ghost-common", "ghost-consensus", - "ghost-gsp-proto", "ghost-keys", - "ghost-locks", "ghost-policy", "ghost-pool", "ghost-reconciliation", @@ -3283,116 +3281,76 @@ dependencies = [ ] [[package]] -name = "ghost-glyph" +name = "ghost-entropy" version = "1.11.38" dependencies = [ "sha2 0.10.9", "thiserror 1.0.69", + "zeroize", ] [[package]] -name = "ghost-gsp" +name = "ghost-glyph" version = "1.11.38" dependencies = [ - "axum 0.7.9", - "bitcoin", - "chrono", - "futures", - "getrandom 0.2.17", - "ghost-common", - "ghost-consensus", - "ghost-gsp-proto", - "governor", - "hex", - "hyper-util", - "jsonwebtoken", - "parking_lot", - "rand 0.8.6", - "reqwest 0.12.28", - "rusqlite", - "rustls", - "serde", - "serde_json", "sha2 0.10.9", - "subtle", - "tempfile", "thiserror 1.0.69", - "tokio", - "tokio-rustls", - "tokio-test", - "tower 0.4.13", - "tower-http 0.5.2", - "tower_governor", - "tracing", -] - -[[package]] -name = "ghost-gsp-bin" -version = "1.11.38" -dependencies = [ - "anyhow", - "bitcoin", - "clap", - "getrandom 0.2.17", - "ghost-common", - "ghost-gsp", - "rustls", - "serde", - "tempfile", - "tokio", - "toml 0.8.2", - "tracing", - "tracing-subscriber", ] [[package]] -name = "ghost-gsp-proto" +name = "ghost-keys" version = "1.11.38" dependencies = [ - "bitcoin", - "chrono", + "bech32", + "chacha20poly1305", "getrandom 0.2.17", - "ghost-common", "hex", + "hkdf", "rand 0.8.6", + "rayon", + "secp256k1 0.29.1", "serde", "serde_json", "sha2 0.10.9", + "subtle", "thiserror 1.0.69", + "tracing", + "zeroize", ] [[package]] -name = "ghost-keys" +name = "ghost-lock" version = "1.11.38" dependencies = [ - "bech32", - "chacha20poly1305", - "getrandom 0.2.17", + "base64 0.22.1", + "bip32", + "bip39", + "bitcoin", + "ghost-entropy", "hex", - "hkdf", + "musig2", "rand 0.8.6", - "rayon", "secp256k1 0.29.1", "serde", "serde_json", - "sha2 0.10.9", - "subtle", + "tempfile", "thiserror 1.0.69", - "tracing", "zeroize", ] [[package]] -name = "ghost-locks" +name = "ghost-lock-signer" version = "1.11.38" dependencies = [ + "base64 0.22.1", "bitcoin", - "getrandom 0.2.17", + "clap", + "ghost-entropy", + "ghost-lock", "hex", - "rand 0.8.6", "serde", - "thiserror 1.0.69", - "tracing", + "serde_json", + "tempfile", ] [[package]] @@ -3439,51 +3397,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ghost-pay" -version = "1.11.38" -dependencies = [ - "aes-gcm", - "anyhow", - "axum 0.7.9", - "bellperson", - "bitcoin", - "blstrs", - "chrono", - "clap", - "getrandom 0.2.17", - "ghost-common", - "ghost-glyph", - "ghost-keys", - "ghost-locks", - "ghost-mpc", - "ghost-reconciliation", - "ghost-storage", - "ghost-zkp", - "hex", - "hmac", - "http", - "hyper-util", - "parking_lot", - "reqwest 0.12.28", - "rusqlite", - "rustls", - "scrypt", - "secp256k1 0.29.1", - "serde", - "serde_json", - "sha2 0.10.9", - "tokio", - "tokio-rustls", - "tower 0.4.13", - "tower-http 0.5.2", - "tower_governor", - "tracing", - "tracing-subscriber", - "uuid", - "wraith-coordinator", -] - [[package]] name = "ghost-policy" version = "1.11.38" @@ -3691,7 +3604,6 @@ dependencies = [ name = "ghost-tap-integration" version = "1.11.38" dependencies = [ - "ghost-gsp-proto", "ghost-tap-core", "reqwest 0.12.28", "secp256k1 0.29.1", @@ -4821,21 +4733,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "jwalk" version = "0.8.1" @@ -5237,6 +5134,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "musig2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183312a1f782d0e27c2e9aa4d68c151301caa39b38f48ea125c1d55ab1ff29f0" +dependencies = [ + "base16ct", + "hmac", + "secp", + "secp256k1 0.31.1", + "sha2 0.10.9", + "subtle", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -5351,16 +5262,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-conv" version = "0.2.0" @@ -5377,15 +5278,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -7361,6 +7253,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ef0ffc3b1b2720b00b919f6c697b1e64aca7bba54b93e0ef4b8560fac6f946" +dependencies = [ + "base16ct", + "secp256k1 0.31.1", + "subtle", +] + [[package]] name = "secp256k1" version = "0.27.0" @@ -7393,6 +7296,17 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.9.3", + "secp256k1-sys 0.11.0", +] + [[package]] name = "secp256k1-sys" version = "0.8.2" @@ -7420,6 +7334,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "secrecy" version = "0.8.0" @@ -7842,18 +7765,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "simple_asn1" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "siphasher" version = "0.3.11" @@ -8960,18 +8871,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-socks" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" -dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.18" @@ -10672,6 +10571,7 @@ dependencies = [ "chrono", "clap", "getrandom 0.2.17", + "ghost-lock", "ghost-storage", "hex", "hmac", @@ -10691,14 +10591,17 @@ dependencies = [ "tracing-subscriber", "ureq", "wraith-protocol", + "zeroize", ] [[package]] name = "wraith-protocol" version = "1.11.38" dependencies = [ + "base64 0.22.1", "bitcoin", "getrandom 0.2.17", + "ghost-lock", "hex", "parking_lot", "rand 0.8.6", @@ -10707,6 +10610,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "subtle", + "tempfile", "thiserror 1.0.69", "tracing", "zeroize", @@ -10741,10 +10645,9 @@ dependencies = [ "bip322", "bip39", "bitcoin", - "futures-util", - "ghost-gsp-proto", + "ghost-entropy", "ghost-keys", - "ghost-locks", + "ghost-lock", "hex", "rand 0.8.6", "reqwest 0.12.28", @@ -10756,11 +10659,8 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", - "tokio-socks", - "tokio-tungstenite 0.21.0", "tracing", "ureq", - "url", "wraith-coordinator", "wraith-protocol", "zeroize", @@ -10772,9 +10672,10 @@ version = "1.11.38" dependencies = [ "async-trait", "axum 0.7.9", + "base64 0.22.1", "bitcoin", - "ghost-gsp-proto", "ghost-keys", + "ghost-lock", "hex", "interprocess", "rand 0.8.6", @@ -10782,7 +10683,6 @@ dependencies = [ "secrecy", "serde", "serde_json", - "sha2 0.10.9", "tempfile", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 44d5ce62c..ef8ccd798 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,9 @@ members = [ "crates/ghost-verification", # Ghost Pay L2 "crates/ghost-keys", - "crates/ghost-locks", + "crates/ghost-lock", + "bins/ghost-lock-signer", + "crates/ghost-entropy", "crates/wraith-protocol", "crates/ghost-reconciliation", # ZK-BFT Infrastructure @@ -46,14 +48,10 @@ members = [ # Visual Identity "crates/ghost-glyph", # GSP - "crates/ghost-gsp-proto", - "crates/ghost-gsp", # Node Binaries "bins/ghost-pool", "bins/ghost-stats", - "bins/ghost-pay", "bins/ghost-cli", - "bins/ghost-gsp", # Wraith coordinator (Wraith Lite v1, single-round atomic CoinJoin) "bins/wraith-coordinator", # Decentralised mining-discovery shim (miner-side; verifies the signed node-list checkpoint) @@ -111,7 +109,8 @@ ghost-pool = { path = "bins/ghost-pool" } ghost-verification = { path = "crates/ghost-verification" } # Ghost Pay L2 crates ghost-keys = { path = "crates/ghost-keys" } -ghost-locks = { path = "crates/ghost-locks" } +ghost-entropy = { path = "crates/ghost-entropy" } +ghost-lock = { path = "crates/ghost-lock" } wraith-protocol = { path = "crates/wraith-protocol" } wraith-coordinator = { path = "bins/wraith-coordinator" } ghost-reconciliation = { path = "crates/ghost-reconciliation" } @@ -120,8 +119,6 @@ ghost-zkp = { path = "crates/ghost-zkp" } ghost-mpc = { path = "crates/ghost-mpc" } ghost-glyph = { path = "crates/ghost-glyph" } # GSP crates -ghost-gsp-proto = { path = "crates/ghost-gsp-proto" } -ghost-gsp = { path = "crates/ghost-gsp" } # Mobile Apps ghost-tap-core = { path = "apps/ghost-tap/core" } # Wraith Wallet @@ -263,10 +260,8 @@ ghost-policy = { workspace = true } ghost-consensus = { workspace = true, features = ["test-utils", "zk-consensus"] } ghost-pool = { workspace = true } ghost-keys = { workspace = true } -ghost-locks = { workspace = true } ghost-reconciliation = { workspace = true } ghost-storage = { workspace = true } -ghost-gsp-proto = { workspace = true } ghost-zkp = { workspace = true } # Test dependencies diff --git a/README.md b/README.md index 8677a3eea..a6d038b5d 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ Bitcoin Ghost is a Bitcoin mainnet project built around a Bitcoin Core fork (`ghostd`) and a decentralised mining pool (`ghost-pool`). Nodes form a peer-to-peer mesh, reach BFT consensus on payouts, and are rewarded for the -infrastructure they actually provide — full-node storage, mining, L2 payments, -and mempool filtering — through a verified-capability share system. It also -includes an L2 payments layer (`ghost-pay`) and a CoinJoin privacy protocol -(Wraith). No separate token, no altcoin — it settles in Bitcoin. +infrastructure they actually provide — full-node storage, mining, and mempool +filtering — through a verified-capability share system. It also includes a +self-custody wallet with Ghost Locks and a CoinJoin privacy protocol (Wraith). +No separate token, no altcoin — it settles in Bitcoin. - **Website:** - **Documentation:** @@ -27,8 +27,9 @@ includes an L2 payments layer (`ghost-pay`) and a CoinJoin privacy protocol |-----------|------|-------------| | `ghostd` (Ghost Core) | `ghost-core/` | A fork of Bitcoin Core v30 running on Bitcoin mainnet, with Reaper mempool filtering and Ghost Haze block stripping. Separate C++/CMake build. | | `ghost-pool` | `bins/ghost-pool/` | Decentralised mining pool node. Runs the P2P consensus mesh, tracks shares, and computes coinbase payouts. | -| `ghost-pay` | `bins/ghost-pay/` | L2 payment service with off-chain transfers proven by zero-knowledge proofs. | -| Wraith | `crates/wraith-protocol/` | Blind-signature CoinJoin mixing at L2 entry. | +| Ghost Lock | `crates/ghost-lock/` | Four-lane Taproot accounts with escape, inheritance and quorum co-signing. | +| Wraith wallet | `apps/wraith-wallet/` | Self-custody wallet — daemon, CLI and desktop app — talking to your own node. | +| Wraith | `crates/wraith-protocol/` | Blind-signature CoinJoin mixing. | | Light wallet | `bins/ghost-cli/`, `crates/ghost-light-wallet/` | CLI/TUI wallet with BIP-352 Silent Payments. | | SV2 mining apps | `bins/translator-sv2/`, `bins/pool-sv2/` | Stratum V2 translator and pool, amalgamated in-tree. | @@ -45,7 +46,7 @@ challenge-response probes issued by random peers every five minutes. | Capability | Shares | Verification | |------------|:------:|--------------| | Archive node | +5 | Peers request arbitrary historical blocks. | -| Ghost Pay | +4 | Random L2 state-lookup challenges. | +| Ghost Pay | +4 | Random L2 state-lookup challenges. **Being retired** — the service is no longer built or installed, so new nodes cannot claim it. Removing it from the weighting is a consensus change and lands with its own height gate. | | Public mining | +3 | Peers probe the Stratum port for accessibility. | | Reaper | +2 | Mempool policy classification challenges. | | Elder | +1 | Contributed to the MPC ceremony (first 101 nodes; permanent). | @@ -99,7 +100,7 @@ should be paid to: curl -sSL https://get.bitcoinghost.org | sudo bash -s -- --payout-address bc1q... ``` -Run `sudo bash -s -- --help` to see options such as `--archive`, `--ghost-pay`, +Run `sudo bash -s -- --help` to see options such as `--archive`, `--wraith`, `--mining-mode`, and `--sync`. Full setup guidance is at . @@ -132,7 +133,7 @@ Requirements: ```sh git clone https://github.com/bitcoin-ghost/ghost.git cd ghost -cargo build --release # builds the Rust workspace (ghost-pool, ghost-pay, wallets, ...) +cargo build --release # builds the Rust workspace (ghost-pool, wallets, ...) ``` `ghost-core` has its own build; see [`ghost-core/INSTALL.md`](ghost-core/INSTALL.md). diff --git a/apps/ghost-tap/tests/integration/Cargo.toml b/apps/ghost-tap/tests/integration/Cargo.toml index 20025ef83..082e1601f 100644 --- a/apps/ghost-tap/tests/integration/Cargo.toml +++ b/apps/ghost-tap/tests/integration/Cargo.toml @@ -9,7 +9,6 @@ live-tests = ["reqwest"] [dependencies] ghost-tap-core.workspace = true -ghost-gsp-proto.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } serde_json.workspace = true secrecy.workspace = true diff --git a/apps/ghost-tap/tests/integration/src/lib.rs b/apps/ghost-tap/tests/integration/src/lib.rs index 008b2830a..fedf79f50 100644 --- a/apps/ghost-tap/tests/integration/src/lib.rs +++ b/apps/ghost-tap/tests/integration/src/lib.rs @@ -1347,34 +1347,22 @@ mod gsp_type_sync_tests { } } - /// Verify the serde tag format matches expectations. + /// Pin ghost-tap's own wire format. /// - /// ghost-tap uses `#[serde(tag = "type", content = "payload")]` which - /// produces `{"type": "VariantName", "payload": {...}}`. - /// ghost-gsp-proto uses `#[serde(tag = "type", rename_all = "snake_case")]` - /// which produces `{"type": "variant_name", ...fields}`. - /// - /// This documents the known wire format divergence. The GSP WebSocket - /// adapter layer must handle the translation. + /// It uses `#[serde(tag = "type", content = "payload")]`, producing + /// `{"type": "VariantName", "payload": {...}}`. This used to sit beside an + /// assertion about ghost-gsp-proto's differing format, documenting a + /// divergence an adapter had to bridge; that server is gone, and with it + /// the other half of the comparison. What is left is worth keeping on its + /// own: a silent change to this tag shape breaks every reader. #[test] fn test_gsp_serde_format_documented() { let req = GspRequest::GetBalance; let json = serde_json::to_string(&req).unwrap(); - // ghost-tap uses externally tagged with content assert!( json.contains("\"type\":\"GetBalance\""), "Expected PascalCase tag format, got: {}", json ); - - // ghost-gsp-proto uses internally tagged with snake_case - // If we ever align these, both should produce the same format - let server_msg = ghost_gsp_proto::ClientMessage::GetBalance { max_k: None }; - let server_json = serde_json::to_string(&server_msg).unwrap(); - assert!( - server_json.contains("\"type\":\"get_balance\""), - "Expected snake_case tag format, got: {}", - server_json - ); } } diff --git a/apps/wraith-wallet/cli/src/main.rs b/apps/wraith-wallet/cli/src/main.rs index d21115c3b..14c519be3 100644 --- a/apps/wraith-wallet/cli/src/main.rs +++ b/apps/wraith-wallet/cli/src/main.rs @@ -26,7 +26,7 @@ struct Cli { enum Command { /// Round-trip a health request to wraithd. Health, - /// One-shot summary of daemon + ghost-pay + ghost-gsp + active wallet + session. + /// One-shot summary: daemon, node, active wallet and balance. Doctor, /// Print the daemon's configured environment (URLs, network, paths). Env, @@ -39,10 +39,10 @@ enum Command { #[command(subcommand)] sub: ChainCommand, }, - /// GSP WebSocket commands. - Gsp { + /// Point the wallet at your node. + Node { #[command(subcommand)] - sub: GspCommand, + sub: NodeCommand, }, /// Wallet (keystore) commands. Wallet { @@ -54,11 +54,6 @@ enum Command { #[command(subcommand)] sub: LightCommand, }, - /// Ghost Locks (custody primitive) commands. - Locks { - #[command(subcommand)] - sub: LocksCommand, - }, /// Release / update commands. Update { #[command(subcommand)] @@ -73,6 +68,25 @@ enum Command { #[command(subcommand)] sub: MixCommand, }, + /// Sign a PSBT with the active wallet. + /// + /// Signs every input the keystore owns and leaves the rest alone. Covers + /// the plain receive chain and Lock owner keys, so a Cash lane coin — + /// which spends with the owner's key alone — signs here like any other + /// single-sig input. + Psbt { + #[command(subcommand)] + sub: PsbtCommand, + }, + /// Ghost Lock: one account, four compartments. + /// + /// Savings needs your backup device; Spending co-signs with the Wraith + /// quorum; Cash is yours alone and deliberately not private; Investments + /// is the one lane the quorum can move without you. + Lock { + #[command(subcommand)] + sub: LockCommand, + }, /// Print a shell-completion script to stdout. Pipe into your shell's /// completion location, e.g.: /// wraith completions bash > /etc/bash_completion.d/wraith @@ -222,6 +236,248 @@ enum MixCommand { }, } +#[derive(Subcommand)] +enum PsbtCommand { + /// Sign every input the active wallet owns. + Sign { + /// The PSBT, base64 or hex. + #[arg(long)] + psbt: String, + /// Bound on the derivation search per input. + #[arg(long)] + bip86_scan_max: Option, + }, +} + +#[derive(Subcommand)] +enum LockCommand { + /// Every Lock this wallet remembers. + List, + /// Remember a Lock's definition. + /// + /// Stores the keys it is built from, not the coins. The lanes are derived + /// from these every time, so forgetting a Lock never moves money. + Save { + /// Optional name, so a list of ids is a list of things. + #[arg(long)] + label: Option, + /// Backup device's x-only key, hex. + #[arg(long)] + backup_pubkey: String, + /// Heir's x-only key, hex. + #[arg(long)] + heir_pubkey: String, + /// Wraith quorum's x-only key, hex. + #[arg(long)] + quorum_pubkey: String, + /// Height the Lock is anchored at — normally the current tip. + #[arg(long)] + anchor_height: u32, + /// Absolute height the inheritance leaf matures at. + #[arg(long)] + inherit_height: u32, + /// BIP86 index for the owner key. Defaults to 0. + #[arg(long)] + bip86_index: Option, + }, + /// Forget a Lock's definition. The funds stay exactly where they are. + Forget { + #[arg(long)] + lock_id: String, + }, + /// Show a Lock's four lanes, their addresses and balances. + Lanes { + #[arg(long)] + backup_pubkey: String, + #[arg(long)] + heir_pubkey: String, + #[arg(long)] + quorum_pubkey: String, + #[arg(long)] + anchor_height: u32, + #[arg(long)] + inherit_height: u32, + #[arg(long)] + bip86_index: Option, + }, + /// Where a round should pay to fund one lane privately. + /// + /// Prints the address without running anything, for when you want to + /// drive the round yourself. `lock fund` does both in one step. + Destination { + #[arg(long)] + lock_id: String, + /// savings, spending or investments. Cash is refused. + #[arg(long)] + lane: String, + }, + /// Spend the Spending lane with the quorum. + /// + /// One command: the wallet does both MuSig2 rounds against the coordinator + /// itself, because the counterparty is a service and nobody has to carry + /// anything. + /// + /// The quorum may refuse — a ceiling, a spending window, or a coin it has + /// already signed for. That is what makes it a second factor rather than a + /// rubber stamp, and the refusal says which rule applied. + /// The id a quorum derives this Lock's co-signing key from. + /// + /// Run this BEFORE building the Lock. Hand the id to whoever holds the + /// quorum seed, and put the key they return in `--quorum-pubkey`. The id + /// cannot be the Lock's own id, because the Lock's id is a hash over the + /// quorum key you are asking them to produce. + QuorumId { + #[arg(long)] + backup_pubkey: String, + #[arg(long)] + heir_pubkey: String, + #[arg(long)] + anchor_height: u32, + #[arg(long)] + inherit_height: u32, + /// Must match the index the Lock is built at. + #[arg(long)] + bip86_index: Option, + }, + QuorumSign { + #[arg(long)] + lock_id: String, + /// Only `spending` is co-signed by the quorum. + #[arg(long, default_value = "spending")] + lane: String, + #[arg(long)] + psbt: String, + #[arg(long)] + input_index: u32, + /// Base URL of the coordinator to ask. + #[arg(long)] + coordinator: String, + }, + /// What leaving alone needs, and whether the coins are old enough. + /// + /// Ask before building the transaction: the input's `nSequence` is fixed by + /// the leaf's delay, and a wrong one is rejected as non-final. + EscapePlan { + #[arg(long)] + lock_id: String, + /// savings, spending or investments. + #[arg(long)] + lane: String, + }, + /// Sign a lane's escape leaf — leaving alone, after the delay. + /// + /// No quorum, no backup device, no ceremony. This is the path that stops a + /// silent quorum from being the end of the money. + Escape { + #[arg(long)] + lock_id: String, + #[arg(long)] + lane: String, + /// The spend, base64 PSBT, with the nSequence `escape-plan` reported. + #[arg(long)] + psbt: String, + #[arg(long)] + input_index: u32, + }, + /// Air-gapped key-path signing, in three steps. + /// + /// MuSig2 needs two rounds, so a spend is: begin (carry a request to the + /// device), nonce (carry the device's reply back, then a second request + /// out), complete (carry the device's signature back). The device never + /// receives a bare hash — it gets the whole transaction and derives the + /// hash itself, so what it shows you and what it signs cannot differ. + Sign { + #[command(subcommand)] + sub: LockSignCommand, + }, + /// Fund one lane through a round — private entry. + /// + /// The round's output IS the lane, so on-chain the deposit looks like any + /// other round output rather than a transfer from a wallet you are known + /// to control. Funding a lane directly works too; it just publishes the + /// link between those coins and the Lock. + /// + /// You name the lane, never an address: the daemon derives it from the + /// remembered Lock, so a typo cannot send a round's proceeds to a + /// stranger. Cash is refused — it is public by design, so a round would + /// buy unlinkability the lane discards on arrival. + Fund { + /// `lock_id` from `wraith lock list`. + #[arg(long)] + lock_id: String, + /// Lane to fund: savings, spending or investments. + #[arg(long)] + lane: String, + #[arg(long)] + coordinator: String, + /// Optional fallback coordinator URLs. Repeatable. + #[arg(long = "coordinator-peer")] + coordinator_peers: Vec, + #[arg(long)] + socks5_proxy: Option, + #[arg(long)] + tier: String, + #[arg(long)] + ghost_id: String, + #[arg(long)] + utxo: String, + #[arg(long)] + utxo_value: u64, + #[arg(long)] + utxo_scriptpubkey: String, + #[arg(long)] + bip86_index: Option, + #[arg(long)] + bip86_scan_max: Option, + /// Smallest anonymity set, in distinct entities, worth signing into. + /// Stating a floor is a decision; dismissing a dialog is a reflex. + #[arg(long)] + min_entities: Option, + }, +} + +#[derive(Subcommand)] +enum LockSignCommand { + /// Round 1. Review the spend and commit this wallet's nonce. + /// + /// Prints what the spend does — check it — and the JSON to carry to the + /// backup device. + Begin { + #[arg(long)] + lock_id: String, + /// savings or spending. Cash signs as ordinary single-sig; + /// Investments has no owner key path. + #[arg(long)] + lane: String, + /// The unsigned spend, base64 PSBT. + #[arg(long)] + psbt: String, + /// Which input belongs to the lane. + #[arg(long)] + input_index: u32, + }, + /// Round 1 reply. Hand back the device's public nonce. + /// + /// This wallet signs its own share here, so its nonce is burned durably + /// before the second payload goes out — no secret nonce is held while you + /// walk to the device again. + Nonce { + #[arg(long)] + session: String, + /// The device's public nonce, hex. + #[arg(long)] + device_nonce: String, + }, + /// Round 2 reply. Hand back the device's partial signature. + Complete { + #[arg(long)] + session: String, + /// The device's partial signature, hex. + #[arg(long)] + device_partial: String, + }, +} + #[derive(Subcommand)] enum ChainCommand { /// Query ghost-pay's `/api/v1/status` via wraithd. @@ -229,16 +485,36 @@ enum ChainCommand { } #[derive(Subcommand)] -enum GspCommand { - /// Open a WebSocket to GSP, send Ping, wait for Pong. - Ping, - /// Register the active wallet with GSP (idempotent) and create a session. - Auth, - /// Show the daemon's stored GSP session token. - SessionStatus, - /// Register the active wallet's BIP-352 scan public key with the GSP so the - /// server can detect incoming silent payments on its behalf. - RegisterScanKey, +enum NodeCommand { + /// Set the node the wallet reads and writes the chain through. + /// + /// A cookie is preferred over a username and password: it rotates with + /// the node and never has to be typed anywhere. + Set { + /// RPC URL, e.g. http://127.0.0.1:8332. + url: String, + /// Path to the node's `.cookie` file. + #[arg(long, conflicts_with_all = ["user", "pass"])] + cookie: Option, + #[arg(long, requires = "pass")] + user: Option, + #[arg(long, requires = "user")] + pass: Option, + /// A Ghost pool node, consulted only for the Wraith coordinator + /// election — which coordinator holds a tier's seat this epoch. + /// + /// Optional. Without it, mixing needs a coordinator URL supplied per + /// round, which works but never rotates. What comes back is verified + /// against your own node rather than believed; what the pool still + /// learns is that your IP asked, so route it through Tor if that + /// matters. + #[arg(long, value_name = "URL")] + pool_url: Option, + }, + /// Forget the node. The wallet will refuse chain operations until one is + /// set again — which is the point: it will not quietly use somebody + /// else's. + Clear, } #[derive(Subcommand)] @@ -256,8 +532,8 @@ enum LightCommand { #[arg(short = 'c', long, default_value_t = 1)] min_confirmations: u32, }, - /// Scan ghost-pay's bitcoind for unspent L1 outputs at the - /// active wallet's BIP86 receive addresses 0..`scan_max_index`. + /// Scan the node for unspent outputs at the active wallet's BIP86 + /// receive addresses 0..`scan_max_index`. /// Each row comes back tagged with the BIP86 derivation index /// that produced its address — drop straight into a Wraith mix /// request to skip the daemon-side address scan. @@ -270,12 +546,12 @@ enum LightCommand { #[arg(short = 'c', long, default_value_t = 0)] min_confirmations: u32, }, - /// Show BIP-352 silent-payment matches detected by the persistent - /// session's local scanner since `wraith gsp auth` ran. + /// Show silent payments the block scanner has found. + /// + /// These do not show up in `utxos`: a silent payment lands on a key + /// derived from the sender's ephemeral key and this wallet's Ghost ID, + /// not on an address the wallet published. Detected, - /// Stream BIP-352 detections live as they arrive. Holds the connection - /// open and prints each detection on a new line. Ctrl-C to exit. - Watch, /// Show the active wallet's transaction history. History { /// Maximum number of transactions to return. @@ -285,26 +561,33 @@ enum LightCommand { #[arg(short, long, default_value_t = 0)] offset: u32, }, - /// Send an instant L2 payment. Mode is `ghostpay` (the only accepted - /// value; default). For unlinkable L1 spends use the Mix flow instead. - Send { - /// Recipient: a Bitcoin address or a Ghost ID. - recipient: String, - /// Amount in satoshis. + /// Pay someone on-chain: build, sign and broadcast in one step. + /// + /// This spends the wallet's own coins through the configured node. No + /// operator is involved and nobody else has to be online. + Pay { + /// Recipient: a Bitcoin address, or a Ghost ID for a silent payment. + /// + /// A Ghost ID pays a fresh taproot output only the recipient can + /// find, announced by an OP_RETURN carrying the ephemeral key. That + /// hides *who* was paid, not *that* a payment happened — the + /// OP_RETURN is visible to anyone looking. + recipient_address: String, + /// Amount in satoshis. The miner fee is charged on top. amount_sats: u64, - /// Payment mode. Only `ghostpay` (instant L2) is supported. - #[arg(long, default_value = "ghostpay")] - mode: String, - /// Optional memo, included with the payment metadata. + /// Fee rate in satoshis per virtual byte. + #[arg(long, default_value_t = 5)] + fee_rate: u64, + /// Highest BIP86 receive index to scan for spendable coins. + #[arg(long, default_value_t = 32)] + scan_max: u32, + /// Note kept in the local history. Never goes on-chain. #[arg(long)] memo: Option, - /// Skip the wallet's outbound-broadcast shroud delay for this send. - /// Equivalent to --shroud-max-ms=0. Use only when latency matters - /// more than origin-timing privacy. + /// Skip the outbound-broadcast shroud delay for this payment. #[arg(long, conflicts_with = "shroud_max_ms")] immediate: bool, - /// Override the daemon's default shroud window (ms) for this send. - /// `0` disables; `n` picks a uniform random delay in `[0, n]`. + /// Override the daemon's default shroud window (ms). #[arg(long, value_name = "MS")] shroud_max_ms: Option, }, @@ -321,91 +604,6 @@ enum UpdateCommand { }, } -#[derive(Subcommand)] -enum LocksCommand { - /// List all Ghost Locks for the active wallet. - List, - /// Ask GSP to prepare a new ghost lock — returns a funding address. - Prepare { - /// Capacity of the lock in satoshis. - capacity_sats: u64, - }, - /// Confirm that a prepared lock has been funded on-chain. - Confirm { - lock_id: String, - funding_txid: String, - }, - /// Initiate a jump (key rotation) for an existing lock. - Jump { - lock_id: String, - /// Target address for the new lock. - target_address: String, - /// Priority: normal (default), high, or urgent. - #[arg(long, default_value = "normal")] - priority: String, - }, - /// Prepare a lock AND fund it through a Wraith CoinJoin in one - /// shot. The mix's denom output is the lock's funding output, so - /// chain analysts see a CoinJoin — they cannot tell which output - /// became which user's L2 entry. This is the private-entry path - /// that distinguishes Ghost Locks from typical L2 funding (where - /// the channel-open tx is a chain-analysis goldmine). - /// - /// CLI chains three IPC calls under the hood: - /// 1. LocksPrepare(capacity_sats) → funding_address - /// 2. WraithMixOneShot(mix_output=funding_addr) → broadcast_txid - /// 3. LocksConfirm(lock_id, broadcast_txid) → block_height - /// - PrepareViaWraith { - /// Lock capacity in satoshis. Must match a Wraith Lite tier - /// denomination (100k_sats / 1m_sats / 10m_sats / 100m_sats). - #[arg(long)] - capacity_sats: u64, - /// HTTP URL of the wraith-coordinator endpoint. - #[arg(long)] - coordinator: String, - /// Wraith Lite tier id matching `capacity_sats`. - #[arg(long)] - tier: String, - /// Wallet's per-round identity for the wraith coordinator. - #[arg(long)] - ghost_id: String, - /// Optional SOCKS5 proxy for the /outputs anonymous step - /// (e.g. `socks5h://127.0.0.1:9050` for Tor). - #[arg(long)] - socks5_proxy: Option, - /// UTXO outpoint feeding the mix as `txid:vout`. - #[arg(long)] - utxo: String, - /// UTXO value in satoshis. - #[arg(long)] - utxo_value: u64, - /// UTXO scriptPubKey, hex. - #[arg(long)] - utxo_scriptpubkey: String, - /// Optional BIP86 derivation index of the wallet key that - /// owns the input UTXO. None → daemon scans 0..1024. - #[arg(long)] - bip86_index: Option, - }, - /// Unilateral exit — spend a Ghost Lock via the timelock recovery - /// branch to a wallet-controlled L1 destination, without any - /// operator cooperation. Daemon talks straight to bitcoind. Only - /// works once the lock's CSV timelock has matured (current height - /// >= creation_height + recovery_blocks). - Recover { - /// Lock to recover. - #[arg(long)] - lock_id: String, - /// L1 destination address for the recovered funds. - #[arg(long, value_name = "ADDR")] - to: String, - /// Mining fee in sats. Subtracted from the lock value. - #[arg(long, default_value_t = 1000)] - fee_sats: u64, - }, -} - #[derive(Subcommand)] enum WalletCommand { /// Create a fresh wallet under the given name (generates a new BIP39 mnemonic). @@ -425,7 +623,20 @@ enum WalletCommand { /// Import a wallet from an existing BIP-39 mnemonic. Prompts for the words /// and a new passphrase. Refuses to overwrite an existing wallet of the /// same name. - Import { name: String }, + Import { + name: String, + /// The chain height this seed was first used at. + /// + /// The block scanner reads forward from here to rebuild the wallet's + /// history. Without it, scanning starts at the tip and nothing this + /// seed did before now appears in the history — the coins are still + /// all found, but what they did is not. + /// + /// Guessing low is safe and slow; guessing high loses history + /// silently, so when in doubt pick a height before the seed existed. + #[arg(long, value_name = "HEIGHT")] + birth_height: Option, + }, /// Unlock the named wallet (becomes active). Unlock { name: String }, /// Lock a wallet by name, or the active one if no name is given. @@ -509,8 +720,8 @@ mod client { } use crate::{ - ChainCommand, Command, GspCommand, LightCommand, LocksCommand, MixCommand, UpdateCommand, - WalletCommand, + ChainCommand, Command, LightCommand, LockCommand, LockSignCommand, MixCommand, NodeCommand, + PsbtCommand, UpdateCommand, WalletCommand, }; pub async fn run(command: Command, json: bool, no_spawn: bool) -> std::process::ExitCode { @@ -531,59 +742,59 @@ mod client { } } - // Streaming subcommand: handed off to its own code path so we don't - // try to render it as a single Response. - if let Command::Light { - sub: LightCommand::Watch, - } = &command - { - return run_watch(json).await; - } - - // Multi-call summary: aggregate several IPC round-trips into one - // terminal-friendly view. - if matches!(&command, Command::Status) { - return run_status(json).await; - } - - // Multi-call entry-via-wraith flow: chains LocksPrepare → - // WraithMixOneShot → LocksConfirm under the hood, so the - // user sees one command. - if let Command::Locks { - sub: LocksCommand::PrepareViaWraith { .. }, + // Multi-call: private entry chains the lane lookup and the round so + // the user issues one command. The lookup must come first — the round + // needs the lane's address as its output, and the daemon refuses a + // lane a round must not pay into before any coin is committed. + if let Command::Lock { + sub: LockCommand::Fund { .. }, } = &command { - if let Command::Locks { + if let Command::Lock { sub: - LocksCommand::PrepareViaWraith { - capacity_sats, + LockCommand::Fund { + lock_id, + lane, coordinator, + coordinator_peers, + socks5_proxy, tier, ghost_id, - socks5_proxy, utxo, utxo_value, utxo_scriptpubkey, bip86_index, + bip86_scan_max, + min_entities, }, } = command { - return run_prepare_via_wraith( + return run_fund_lock( json, - capacity_sats, + lock_id, + lane, coordinator, + coordinator_peers, + socks5_proxy, tier, ghost_id, - socks5_proxy, utxo, utxo_value, utxo_scriptpubkey, bip86_index, + bip86_scan_max, + min_entities, ) .await; } } + // Multi-call summary: aggregate several IPC round-trips into one + // terminal-friendly view. + if matches!(&command, Command::Status) { + return run_status(json).await; + } + let request = match command { Command::Health => Request::Health, Command::Doctor => Request::Doctor, @@ -591,11 +802,27 @@ mod client { Command::Chain { sub } => match sub { ChainCommand::Status => Request::ChainStatus, }, - Command::Gsp { sub } => match sub { - GspCommand::Ping => Request::GspPing, - GspCommand::Auth => Request::GspAuth, - GspCommand::SessionStatus => Request::GspSessionStatus, - GspCommand::RegisterScanKey => Request::GspRegisterScanKey, + Command::Node { sub } => match sub { + NodeCommand::Set { + url, + cookie, + user, + pass, + pool_url, + } => Request::SetNode { + ghostd_url: Some(url), + cookie_path: cookie, + user, + pass, + pool_url, + }, + NodeCommand::Clear => Request::SetNode { + ghostd_url: None, + cookie_path: None, + user: None, + pass: None, + pool_url: None, + }, }, Command::Light { sub } => match sub { LightCommand::Receive { index } => Request::LightReceive { index }, @@ -610,59 +837,27 @@ mod client { scan_max_index, min_confirmations, }, - LightCommand::History { limit, offset } => Request::LightHistory { limit, offset }, LightCommand::Detected => Request::LightDetected, - LightCommand::Watch => unreachable!("Watch handled above"), - LightCommand::Send { - recipient, + LightCommand::History { limit, offset } => Request::LightHistory { limit, offset }, + LightCommand::Pay { + recipient_address, amount_sats, - mode, + fee_rate, + scan_max, memo, immediate, shroud_max_ms, - } => Request::LightSend { - recipient, + } => Request::L1Send { + recipient_address, amount_sats, - mode, + fee_rate_sats_per_vb: fee_rate, + change_index: None, + bip86_scan_max: scan_max, + selected_outpoints: Vec::new(), memo, shroud_max_ms: if immediate { Some(0) } else { shroud_max_ms }, }, }, - Command::Locks { sub } => match sub { - LocksCommand::List => Request::LocksList, - LocksCommand::Prepare { capacity_sats } => Request::LocksPrepare { capacity_sats }, - LocksCommand::Confirm { - lock_id, - funding_txid, - } => Request::LocksConfirm { - lock_id, - funding_txid, - }, - LocksCommand::Jump { - lock_id, - target_address, - priority, - } => Request::LocksJump { - lock_id, - target_address, - priority, - }, - LocksCommand::Recover { - lock_id, - to, - fee_sats, - } => Request::LocksRecover { - lock_id, - destination_address: to, - fee_sats, - }, - // Multi-call flow — handled before this match in - // run() proper. The arm exists only so the match - // is exhaustive. - LocksCommand::PrepareViaWraith { .. } => { - unreachable!("PrepareViaWraith handled by run_prepare_via_wraith") - } - }, Command::Update { sub } => match sub { UpdateCommand::Check { manifest_url } => Request::CheckForUpdate { manifest_url }, }, @@ -685,7 +880,7 @@ mod client { Err(e) => return io_err(e), } } - WalletCommand::Import { name } => { + WalletCommand::Import { name, birth_height } => { let mnemonic = match prompt_mnemonic() { Ok(m) => m, Err(e) => return io_err(e), @@ -698,6 +893,7 @@ mod client { name, mnemonic, passphrase: pass, + birth_height, } } WalletCommand::Unlock { name } => match prompt_passphrase("passphrase: ") { @@ -727,6 +923,123 @@ mod client { from_path: from, }, }, + Command::Psbt { sub } => match sub { + PsbtCommand::Sign { + psbt, + bip86_scan_max, + } => Request::PsbtSign { + psbt, + bip86_scan_max, + }, + }, + Command::Lock { sub } => match sub { + LockCommand::List => Request::GhostLockList, + LockCommand::Save { + label, + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + } => Request::GhostLockSave { + label, + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + }, + LockCommand::Forget { lock_id } => Request::GhostLockForget { lock_id }, + LockCommand::Lanes { + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + } => Request::GhostLockLanes { + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + }, + LockCommand::Destination { lock_id, lane } => { + Request::GhostLockRoundDestination { lock_id, lane } + } + LockCommand::QuorumSign { + lock_id, + lane, + psbt, + input_index, + coordinator, + } => Request::GhostLockQuorumSign { + lock_id, + lane, + psbt, + input_index, + coordinator_url: coordinator, + }, + LockCommand::QuorumId { + backup_pubkey, + heir_pubkey, + anchor_height, + inherit_height, + bip86_index, + } => Request::GhostLockQuorumBindingId { + backup_pubkey, + heir_pubkey, + anchor_height, + inherit_height, + bip86_index, + }, + LockCommand::EscapePlan { lock_id, lane } => { + Request::GhostLockEscapePlan { lock_id, lane } + } + LockCommand::Escape { + lock_id, + lane, + psbt, + input_index, + } => Request::GhostLockEscapeSign { + lock_id, + lane, + psbt, + input_index, + }, + LockCommand::Sign { sub } => match sub { + LockSignCommand::Begin { + lock_id, + lane, + psbt, + input_index, + } => Request::GhostLockSignBegin { + lock_id, + lane, + psbt, + input_index, + }, + LockSignCommand::Nonce { + session, + device_nonce, + } => Request::GhostLockSignNonce { + session, + device_nonce, + }, + LockSignCommand::Complete { + session, + device_partial, + } => Request::GhostLockSignComplete { + session, + device_partial, + }, + }, + // Intercepted above: private entry is two calls, not one. + LockCommand::Fund { .. } => unreachable!("lock fund handled above"), + }, Command::Mix { sub } => match sub { MixCommand::PrepareCoin { coordinator, @@ -764,6 +1077,7 @@ mod client { } }; Request::WraithMixPrepare { + min_entities: None, coordinator_url: coordinator, coordinator_peers, socks5_proxy, @@ -813,6 +1127,7 @@ mod client { } }; Request::WraithMixOneShot { + min_entities: None, coordinator_url: coordinator, coordinator_peers, socks5_proxy, @@ -909,68 +1224,13 @@ mod client { std::process::ExitCode::SUCCESS } Ok(Response::ChainStatus(s)) => { - println!("ghost-pay {} ({})", s.backend_version, s.network); - println!( - " keys: {} locks: {} active sessions: {}", - if s.has_keys { "yes" } else { "no" }, - s.lock_count, - s.active_sessions, - ); - std::process::ExitCode::SUCCESS - } - Ok(Response::GspPing(p)) => { - match p.round_trip_ms { - Some(rtt) => println!( - "gsp ok — server_time {} — round-trip {}ms", - p.server_time, rtt - ), - None => println!("gsp ok — server_time {}", p.server_time), - } - std::process::ExitCode::SUCCESS - } - Ok(Response::GspAuth(a)) => { - if a.already_registered { - println!("(already registered) — session created"); - } else { - println!("registered + session created"); - } - println!(" wallet_id: {}", a.wallet_id); - println!(" token (prefix): {}...", a.token_prefix); - println!(" expires_at: {}", a.expires_at); - std::process::ExitCode::SUCCESS - } - Ok(Response::GspScanKeyRegistered { - wallet_id, - scan_pubkey_hex, - }) => { - println!("scan key registered with GSP"); - println!(" wallet_id: {wallet_id}"); - println!(" scan_pubkey: {scan_pubkey_hex}"); - std::process::ExitCode::SUCCESS - } - Ok(Response::GspSessionStatus(s)) => { - if !s.have_token { - println!("(no session — run `wraith gsp auth`)"); - } else { - println!("session active"); - if let Some(n) = s.wallet_name { - println!(" wallet: {n}"); - } - if let Some(id) = s.wallet_id { - println!(" wallet_id: {id}"); - } - if let Some(p) = s.phase { - let cnt = s.connect_count.unwrap_or(0); - println!(" ws phase: {p} (connects: {cnt})"); - } - if let Some(err) = s.last_error { - println!(" last error: {err}"); - } - if let Some(rem) = s.remaining_secs { - let hours = rem / 3600; - let mins = (rem % 3600) / 60; - println!(" expires in: {hours}h {mins}m ({rem}s)"); + println!("{} ({})", s.backend_version, s.network); + match (s.chain_height, s.chain_headers) { + (Some(h), Some(t)) if h < t => { + println!(" height: {h} of {t} (syncing)") } + (Some(h), _) => println!(" height: {h}"), + (None, _) => println!(" height: unknown"), } std::process::ExitCode::SUCCESS } @@ -1024,47 +1284,168 @@ mod client { } std::process::ExitCode::SUCCESS } - Ok(Response::LightDetected(d)) => { - if d.detections.is_empty() { - println!("(no detections — server scanner may not be wired yet,"); - println!(" or no incoming silent payments since auth)"); + Ok(Response::GhostLockQuorumSigned(r)) => { + println!("the quorum co-signed {}", r.lock_id); + println!( + " it read the spend as {} sats out, {} sats fee", + r.quorum_saw_input_sats, r.quorum_saw_fee_sats + ); + println!(" compare that with what you meant before broadcasting."); + println!("\nsignature: {}", r.signature); + if r.tx_hex.is_empty() { + println!("\nsigned, but the transaction still needs other inputs;"); + println!("pass this psbt on:\n{}", r.psbt); } else { - for det in &d.detections { - let amt = det - .amount_sats - .map(|a| format!("{a} sats")) - .unwrap_or_else(|| "?".into()); - let height = det - .block_height - .map(|h| h.to_string()) - .unwrap_or_else(|| "(mempool)".into()); - println!( - "{}:{} {amt} k={} height {height}", - det.txid, det.vout, det.k - ); + println!("\ntransaction (hex) — broadcast this:"); + println!("{}", r.tx_hex); + } + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockQuorumBindingId(r)) => { + println!("quorum binding id: {}", r.binding_id); + println!(); + println!("Give this to whoever holds the quorum seed. They run:"); + println!(" {}", r.derive_with); + println!(); + println!("Put the key they return in --quorum-pubkey when you build the Lock."); + println!("This is not the Lock's id: the Lock's id is a hash over the very key"); + println!("you are asking them for, so it cannot be what derives it."); + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockEscapePlan(r)) => { + println!("{} — {} lane of {}", r.escape, r.lane, r.lock_id); + println!(" wait: {} blocks", r.delay_blocks); + println!( + " nSequence every input must carry: {}", + r.required_sequence + ); + println!(" lane: {}", r.lane_address); + if r.coins.is_empty() { + println!("\n(no coins in this lane)"); + } else { + println!("\ncoins:"); + for c in &r.coins { + if c.blocks_remaining == 0 { + println!( + " {}:{} {} sats ready now ({} confirmations)", + c.txid, c.vout, c.sats, c.confirmations + ); + } else { + println!( + " {}:{} {} sats {} more blocks (~{:.1} days)", + c.txid, + c.vout, + c.sats, + c.blocks_remaining, + f64::from(c.blocks_remaining) / 144.0 + ); + } } - println!("\n{} detection(s)", d.detections.len()); } std::process::ExitCode::SUCCESS } + Ok(Response::GhostLockEscapeSigned(r)) => { + println!( + "{} signed for the {} lane of {}", + r.escape, r.lane, r.lock_id + ); + println!("\ntransaction (hex) — broadcast this:"); + println!("{}", r.tx_hex); + println!("\npsbt:"); + println!("{}", r.psbt); + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockSignBegun(r)) => { + let s = &r.summary; + println!("CHECK THIS BEFORE YOU CARRY ANYTHING ANYWHERE"); + println!( + " spending {} sats from {}", + s.input_sats, + s.input_address + .as_deref() + .unwrap_or("(unrenderable script)") + ); + if s.input_count > 1 { + println!( + " ⚠ this transaction has {} inputs; you are signing input {}", + s.input_count, s.input_index + ); + } + for o in &s.outputs { + println!( + " paying {} sats to {}", + o.sats, + o.address.as_deref().unwrap_or("(unrenderable script)") + ); + } + println!(" fee {} sats", s.fee_sats); + println!("\nsession: {}", r.session); + println!("our nonce: {}", r.our_nonce); + println!("\n--- carry this to the backup device ---"); + println!("{}", r.device_request); + println!( + "--- then: wraith lock sign nonce --session {} --device-nonce ", + r.session + ); + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockSignNonced(r)) => { + println!("this wallet has signed its share; its nonce is burned."); + println!("\n--- carry this to the backup device ---"); + println!("{}", r.device_request); + println!( + "--- then: wraith lock sign complete --session {} --device-partial ", + r.session + ); + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockSigned(r)) => { + println!("signed. the signature verifies against the lane's output key."); + println!("signature: {}", r.signature); + println!("\nsigned psbt:"); + println!("{}", r.psbt); + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockRoundDestination(d)) => { + println!("lock: {}", d.lock_id); + println!("lane: {} ({})", d.label, d.lane); + println!("address: {}", d.address); + println!("\nfund it with `wraith lock fund`, which runs a round whose"); + println!("output is this address. Paying it directly also works, and"); + println!("publishes the link between those coins and the Lock."); + std::process::ExitCode::SUCCESS + } Ok(Response::LightHistory(h)) => { if h.transactions.is_empty() { println!("(no transactions)"); } else { for t in &h.transactions { - let dir = if t.amount_sats >= 0 { "+" } else { "" }; + // An unrecorded amount prints as `?`, never as 0 — + // "the wallet did not note what moved" and "nothing + // moved" are different things to read off a receipt. + let amount = match t.amount_sats { + Some(a) if a >= 0 => format!("+{a}"), + Some(a) => a.to_string(), + None => "?".to_string(), + }; let height = t .block_height .map(|h| h.to_string()) - .unwrap_or_else(|| "(mempool)".into()); + .unwrap_or_else(|| "(unknown)".into()); + // Same rule: a node that cannot answer says so, + // rather than reporting a settled payment as pending. + let confs = t + .confirmations + .map(|c| c.to_string()) + .unwrap_or_else(|| "?".into()); let memo = t.memo.as_deref().unwrap_or(""); println!( - "{} {dir}{} {} height {} ({} confs){}", + "{} {} {} height {} ({} confs){}", t.txid, - t.amount_sats, + amount, t.tx_type, height, - t.confirmations, + confs, if memo.is_empty() { String::new() } else { @@ -1080,88 +1461,44 @@ mod client { } std::process::ExitCode::SUCCESS } - Ok(Response::LightSent(s)) => { - println!("payment submitted"); - println!(" payment_id: {}", s.payment_id); - if let Some(tx) = &s.txid { - println!(" txid: {tx}"); + Ok(Response::LightDetected(d)) => { + if d.detections.is_empty() { + println!("(no silent payments detected)"); } else { - println!(" txid: (L2 — no on-chain txid)"); + for x in &d.detections { + let amount = match x.amount_sats { + Some(a) => a.to_string(), + None => "?".into(), + }; + let height = match x.block_height { + Some(h) => h.to_string(), + None => "(unconfirmed)".into(), + }; + println!( + "{}:{} {} sats height {} k={}", + x.txid, x.vout, amount, height, x.k + ); + } + println!("\n{} silent payment(s)", d.detections.len()); } + std::process::ExitCode::SUCCESS + } + Ok(Response::L1Sent(s)) => { + println!("broadcast"); + println!(" txid: {}", s.txid); println!(" recipient: {}", s.recipient); println!(" amount: {} sats", s.amount_sats); + // Stated separately because the sender pays it on top: the + // balance drops by amount + fee, not by amount. println!(" fee: {} sats", s.fee_sats); - println!(" mode: {}", s.mode); + println!(" change: {} sats", s.change_sats); + println!(" inputs: {}", s.input_count); match s.shroud_delay_ms { Some(ms) => println!(" shroud: held {ms} ms before broadcast"), None => println!(" shroud: disabled (immediate)"), } std::process::ExitCode::SUCCESS } - Ok(Response::LocksPrepared(r)) => { - println!("lock prepared"); - println!(" lock_id: {}", r.lock_id); - println!(" funding address: {}", r.funding_address); - println!(" required: {} sats", r.required_sats); - println!(); - println!( - "Send {} sats to the address above, then run:", - r.required_sats - ); - println!(" wraith locks confirm {} ", r.lock_id); - std::process::ExitCode::SUCCESS - } - Ok(Response::LocksConfirmed(r)) => { - println!("lock confirmed"); - println!(" lock_id: {}", r.lock_id); - println!(" funding txid: {}", r.txid); - println!(" block height: {}", r.block_height); - std::process::ExitCode::SUCCESS - } - Ok(Response::LocksJumped(r)) => { - println!("jump initiated"); - println!(" lock_id: {}", r.lock_id); - match r.jump_txid { - Some(tx) => println!(" jump txid: {tx}"), - None => println!(" jump txid: (queued — not yet broadcast)"), - } - std::process::ExitCode::SUCCESS - } - Ok(Response::LocksRecovered(r)) => { - println!("✓ unilateral exit broadcast — funds back on L1"); - println!(" lock_id: {}", r.lock_id); - println!(" broadcast_txid: {}", r.broadcast_txid); - println!(" destination: {}", r.destination_address); - println!(" recovered_sats: {}", r.recovered_sats); - println!(" fee_sats: {}", r.fee_sats); - std::process::ExitCode::SUCCESS - } - Ok(Response::LocksList(r)) => { - if r.locks.is_empty() { - println!("(no locks)"); - } else { - for l in &r.locks { - println!( - "{} {} {} / {} sats ({})", - &l.lock_id[..16.min(l.lock_id.len())], - l.status, - l.balance_sats, - l.capacity_sats, - l.denomination, - ); - println!(" funding: {}", l.funding_address); - if let (Some(txid), Some(vout)) = (&l.funding_txid, l.funding_vout) { - println!(" outpoint: {txid}:{vout}"); - } - } - println!( - "\n{} locks total: {} sats", - r.locks.len(), - r.total_locked_sats - ); - } - std::process::ExitCode::SUCCESS - } Ok(Response::LightBalance(b)) => { match b.confirmed_sats { None => println!("(balance not yet known — session still authenticating?)"), @@ -1180,13 +1517,93 @@ mod client { } std::process::ExitCode::SUCCESS } - Ok(Response::NodeEndpointsSet(r)) => { - println!("node endpoints updated (preset: {})", r.preset); - if !r.ghost_pay_urls.is_empty() { - println!(" ghost-pay: {}", r.ghost_pay_urls.join(", ")); + Ok(Response::GhostLockLanes(r)) => { + println!( + "ghost lock: {} sats settled, {} pending (block {})", + r.total_sats, r.total_pending_sats, r.chain_height + ); + if r.custodial_sats > 0 { + println!( + " {} sats in Investments — the quorum can move these without you", + r.custodial_sats + ); + } + for l in &r.lanes { + let mut flags = String::new(); + if l.quorum_can_spend_alone { + flags.push_str(" [custodial]"); + } + if !l.round_eligible { + flags.push_str(" [not private]"); + } + println!(" {:<12} {} sats{}", l.label, l.balance_sats, flags); + if l.pending_sats > 0 { + println!(" +{} pending", l.pending_sats); + } + println!(" {}", l.address); + } + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockSaved(r)) => { + println!( + "{} {}", + if r.created { "saved" } else { "updated" }, + r.lock.label.as_deref().unwrap_or(&r.lock.lock_id) + ); + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockList(r)) => { + if r.locks.is_empty() { + println!("(no Ghost Locks)"); + } + for l in &r.locks { + println!( + "{} {}", + l.lock_id, + l.label.as_deref().unwrap_or("(unnamed)") + ); } - if !r.gsp_urls.is_empty() { - println!(" gsp: {}", r.gsp_urls.join(", ")); + std::process::ExitCode::SUCCESS + } + Ok(Response::GhostLockForgotten(r)) => { + // Says what it did NOT do: forgetting a definition leaves the + // funds exactly where they were. + if r.existed { + println!("forgot {} — funds untouched", r.lock_id); + } else { + println!("no such lock: {}", r.lock_id); + } + std::process::ExitCode::SUCCESS + } + Ok(Response::WraithMixRefused(r)) => { + println!( + "refused: {} distinct entities across {} seats (floor {})", + r.report.entities, r.report.seats, r.min_entities + ); + for reason in &r.reasons { + println!(" {reason}"); + } + if !r.lowering_the_floor_would_help { + println!(" the coordinator claimed more than its coins support"); + } + std::process::ExitCode::FAILURE + } + Ok(Response::NodeSet(r)) => { + match r.ghostd_url.as_deref() { + Some(u) => { + println!("node set"); + println!(" url: {u}"); + println!(" auth: {}", r.auth); + match r.pool_url.as_deref() { + Some(p) => println!(" pool: {p} (coordinator election)"), + None => println!(" pool: (none — mixing needs a coordinator URL)"), + } + } + // Said plainly, because it is a state the wallet cannot + // work in and the user has just chosen it. + None => { + println!("node cleared — chain operations will refuse until one is set") + } } std::process::ExitCode::SUCCESS } @@ -1289,30 +1706,6 @@ mod client { println!(" spend_pubkey: {}", g.spend_public_key_hex); std::process::ExitCode::SUCCESS } - Ok(Response::WalletGlyph(g)) => { - println!("ghost_id: {}", g.ghost_id); - println!("status: {}", g.status); - println!("bitmap_hash: {}", g.bitmap_hash); - println!("commitment: {}", g.commitment); - if let Some(txid) = &g.funding_txid { - println!("funding_txid: {txid}"); - } - if let Some(at) = g.registered_at { - println!("registered_at:{at}"); - } - println!("pixels: {} bytes", g.pixels.len()); - std::process::ExitCode::SUCCESS - } - Ok(Response::WalletGlyphClaimed(r)) => { - println!("status: {}", r.status); - println!("bitmap_hash: {}", r.bitmap_hash); - println!("commitment: {}", r.commitment); - std::process::ExitCode::SUCCESS - } - Ok(Response::WalletGlyphChecked { available }) => { - println!("available: {available}"); - std::process::ExitCode::SUCCESS - } Ok(Response::WalletShowMnemonic(m)) => { println!("WARNING: anyone with these 24 words owns the wallet.\n"); println!("{}\n", m.mnemonic); @@ -1397,8 +1790,14 @@ mod client { println!("network: {}", e.network); println!("socket: {}", e.socket_path); println!("wallets dir: {}", e.wallets_dir); - println!("ghost-pay: {}", e.ghost_pay_urls.join(", ")); - println!("gsp: {}", e.gsp_urls.join(", ")); + match e.ghostd_url.as_deref() { + Some(u) => println!("node: {u} (auth: {})", e.ghostd_auth), + None => println!("node: (none configured)"), + } + match e.pool_url.as_deref() { + Some(u) => println!("pool: {u} (coordinator election)"), + None => println!("pool: (none)"), + } if let Some(p) = &e.tor_proxy { println!("tor proxy: {p}"); } else { @@ -1424,27 +1823,26 @@ mod client { } Ok(Response::ConnectionStatus(s)) => { println!("network: {}", s.network); - println!( - "ghost-pay: {}{}", - if s.ghost_pay_reachable { - "reachable" - } else { - "unreachable" - }, - s.ghost_pay_version - .as_deref() - .map(|v| format!(" (v{v})")) - .or_else(|| s.ghost_pay_error.as_deref().map(|e| format!(" — {e}"))) - .unwrap_or_default() - ); - println!( - "gsp: {}", - if s.gsp_connected { - "connected" - } else { - s.gsp_phase.as_deref().unwrap_or("disconnected") - } - ); + // "not configured" and "configured but not answering" are + // different problems with different fixes, so they get + // different words rather than one shared "unreachable". + if !s.node_configured { + println!("node: (none configured)"); + } else { + println!( + "node: {}{}", + if s.node_reachable { + "reachable" + } else { + "unreachable" + }, + s.node_version + .as_deref() + .map(|v| format!(" ({v})")) + .or_else(|| s.node_error.as_deref().map(|e| format!(" — {e}"))) + .unwrap_or_default() + ); + } match s.chain_height { Some(h) if s.chain_synced => println!("chain: synced · #{h}"), Some(h) => println!("chain: syncing · #{h}"), @@ -1452,11 +1850,6 @@ mod client { } std::process::ExitCode::SUCCESS } - // Streaming variants are handled in run_watch() and never reach here. - Ok(Response::Watching) | Ok(Response::PaymentDetected(_)) => { - eprintln!("wraith: unexpected streaming variant on a one-shot request"); - std::process::ExitCode::FAILURE - } // PSBT and multisig-descriptor commands (WIP): bespoke human-readable // output is not wired up yet, so emit the structured response as JSON — // the data is fully usable (`--json` produces the same). Replace with @@ -1756,69 +2149,81 @@ mod client { .read_line(&mut response_line) .await .map_err(|e| format!("read failed: {e}"))?; + // Zero bytes means the daemon closed the connection without answering + // — it did not send a malformed reply, it sent nothing. In practice + // that is a panicking request handler, whose reason is in wraithd's + // log and nowhere else. Saying "malformed response" here sends the + // reader looking for a parse bug in a message that was never written. + if response_line.trim().is_empty() { + return Err(format!( + "wraithd closed the connection without responding \ + (endpoint {}). The reason will be in the daemon's log — \ + a request handler most likely panicked.", + wraith_wallet_ipc::endpoint_display() + )); + } let envelope: Envelope = serde_json::from_str(&response_line).map_err(|e| format!("malformed response: {e}"))?; Ok(envelope.payload) } - /// `wraith locks prepare-via-wraith` flow. Three IPC calls under - /// the hood: - /// 1. LocksPrepare(capacity_sats) → funding_address + lock_id - /// 2. WraithMixOneShot(mix_output=funding_addr) → broadcast_txid - /// 3. LocksConfirm(lock_id, broadcast_txid) → block_height + /// Private entry: fund one lane of a remembered Lock through a round. /// - /// Stops at the first error and surfaces it to the user. The - /// printed step-by-step trail makes it obvious which phase - /// broke if the operator/coordinator misbehaves mid-flight. + /// Two calls, in this order for a reason. `GhostLockRoundDestination` + /// resolves the lane to an address AND applies the compartment rule, so a + /// lane a round must not pay into is refused before a coin is registered + /// anywhere. Only then is the round run, with that address as its output. + /// + /// The address is never taken from the user. The whole property being + /// bought here is that the round's output belongs to the Lock; letting a + /// caller supply the destination would make "fund my Savings privately" + /// and "send my coins to this address" the same command. #[allow(clippy::too_many_arguments)] - pub(crate) async fn run_prepare_via_wraith( + pub(crate) async fn run_fund_lock( json: bool, - capacity_sats: u64, + lock_id: String, + lane: String, coordinator: String, + coordinator_peers: Vec, + socks5_proxy: Option, tier: String, ghost_id: String, - socks5_proxy: Option, utxo: String, utxo_value: u64, utxo_scriptpubkey: String, bip86_index: Option, + bip86_scan_max: Option, + min_entities: Option, ) -> std::process::ExitCode { let (txid, vout) = match parse_outpoint(&utxo) { Ok(v) => v, - Err(e) => { - return io_err(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)); - } + Err(e) => return io_err(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)), }; - // Phase 1: prepare the lock. if !json { - println!("[1/3] preparing lock ({capacity_sats} sats)"); + println!("[1/2] resolving {lane} lane of {lock_id}"); } - let prep = match call(Request::LocksPrepare { capacity_sats }).await { - Ok(Response::LocksPrepared(p)) => p, - Ok(Response::Error(e)) => { - return error_out(json, format!("LocksPrepare: {}", e.message)) - } - Ok(other) => { - return error_out(json, format!("LocksPrepare: unexpected response {other:?}")) - } - Err(e) => return error_out(json, format!("LocksPrepare: {e}")), + let dest = match call(Request::GhostLockRoundDestination { + lock_id: lock_id.clone(), + lane: lane.clone(), + }) + .await + { + Ok(Response::GhostLockRoundDestination(d)) => d, + Ok(Response::Error(e)) => return fund_lock_err(json, e.message), + Ok(other) => return fund_lock_err(json, format!("unexpected response {other:?}")), + Err(e) => return fund_lock_err(json, e), }; if !json { - println!(" lock_id: {}", prep.lock_id); - println!(" funding_address: {}", prep.funding_address); + println!(" {} → {}", dest.label, dest.address); } - // Phase 2: run the wraith mix with the lock's funding address - // as the mix output. The CoinJoin's denom output IS the - // lock's funding output — the on-chain footprint is - // indistinguishable from any other Wraith mix. if !json { - println!("[2/3] running wraith mix → {}", prep.funding_address); + println!("[2/2] running round; its output funds the lane"); } - let mix = match call(Request::WraithMixOneShot { + let mixed = match call(Request::WraithMixOneShot { coordinator_url: coordinator, - coordinator_peers: Vec::new(), + coordinator_peers, socks5_proxy, tier_id: tier, ghost_id, @@ -1826,72 +2231,43 @@ mod client { utxo_vout: vout, utxo_value_sats: utxo_value, utxo_scriptpubkey_hex: utxo_scriptpubkey, - mix_output_address: prep.funding_address.clone(), + mix_output_address: dest.address.clone(), bip86_index, - bip86_scan_max: None, + bip86_scan_max, + min_entities, }) .await { Ok(Response::WraithMixCompleted(m)) => m, - Ok(Response::Error(e)) => { - return error_out(json, format!("WraithMixOneShot: {}", e.message)) - } - Ok(other) => { - return error_out( - json, - format!("WraithMixOneShot: unexpected response {other:?}"), - ) - } - Err(e) => return error_out(json, format!("WraithMixOneShot: {e}")), - }; - if !json { - println!(" session_id: {}", mix.session_id); - println!(" broadcast_txid: {}", mix.broadcast_txid); - } - - // Phase 3: confirm the lock against the broadcast txid. - if !json { - println!("[3/3] confirming lock funding"); - } - let conf = match call(Request::LocksConfirm { - lock_id: prep.lock_id.clone(), - funding_txid: mix.broadcast_txid.clone(), - }) - .await - { - Ok(Response::LocksConfirmed(c)) => c, - Ok(Response::Error(e)) => { - return error_out(json, format!("LocksConfirm: {}", e.message)) - } - Ok(other) => { - return error_out(json, format!("LocksConfirm: unexpected response {other:?}")) - } - Err(e) => return error_out(json, format!("LocksConfirm: {e}")), + Ok(Response::Error(e)) => return fund_lock_err(json, e.message), + Ok(other) => return fund_lock_err(json, format!("unexpected response {other:?}")), + Err(e) => return fund_lock_err(json, e), }; if json { let body = serde_json::json!({ - "lock_id": prep.lock_id, - "funding_address": prep.funding_address, - "broadcast_txid": mix.broadcast_txid, - "block_height": conf.block_height, - "session_id": mix.session_id, + "lock_id": dest.lock_id, + "lane": dest.lane, + "address": dest.address, + "session_id": mixed.session_id, + "broadcast_txid": mixed.broadcast_txid, + "mixed_output_tx_index": mixed.mixed_output_tx_index, }); println!("{body}"); } else { - println!(); - println!("✓ lock funded via Wraith CoinJoin"); - println!(" lock_id: {}", prep.lock_id); - println!(" broadcast_txid: {}", mix.broadcast_txid); - println!(" block_height: {}", conf.block_height); - println!(); - println!("the chain shows a CoinJoin tx — chain analysts cannot tell"); - println!("which output became this lock. that's the privacy property."); + println!(" txid: {}", mixed.broadcast_txid); + println!(" vout: {}", mixed.mixed_output_tx_index); + println!( + "done. {} of {} is funded; the deposit is a round output, not a transfer.", + dest.label, dest.lock_id + ); + println!(" it will show as pending until the round transaction confirms."); } std::process::ExitCode::SUCCESS } - fn error_out(json: bool, msg: String) -> std::process::ExitCode { + /// Failure path for `run_fund_lock`, matching the CLI's `--json` contract. + fn fund_lock_err(json: bool, msg: String) -> std::process::ExitCode { if json { let body = serde_json::json!({ "error": { "message": msg } }); println!("{body}"); @@ -1908,18 +2284,16 @@ mod client { let health = call(Request::Health).await; let env_resp = call(Request::DaemonEnv).await; let wallets = call(Request::WalletList).await; - let session = call(Request::GspSessionStatus).await; + let node = call(Request::ConnectionStatus).await; let balance = call(Request::LightBalance).await; - let locks = call(Request::LocksList).await; if json { let body = serde_json::json!({ "health": result_value(&health), "env": result_value(&env_resp), "wallets": result_value(&wallets), - "session": result_value(&session), + "node": result_value(&node), "balance": result_value(&balance), - "locks": result_value(&locks), }); println!("{body}"); return std::process::ExitCode::SUCCESS; @@ -1961,7 +2335,22 @@ mod client { } _ => println!("wallet: error"), } - // balance row — only meaningful if we have a session + // node row + match &node { + Ok(Response::ConnectionStatus(s)) if !s.node_configured => { + println!("node: (none — `wraith node set --cookie `)") + } + Ok(Response::ConnectionStatus(s)) => { + let where_ = match s.chain_height { + Some(h) if s.chain_synced => format!("synced · #{h}"), + Some(h) => format!("syncing · #{h}"), + None => "unreachable".to_string(), + }; + println!("node: {where_}"); + } + _ => println!("node: unknown"), + } + // balance row match &balance { Ok(Response::LightBalance(b)) => { let confirmed = b.confirmed_sats.unwrap_or(0); @@ -1972,41 +2361,11 @@ mod client { println!("balance: {confirmed} sat"); } } - Ok(Response::Error(_)) | Err(_) => { - println!("balance: (no session — `wraith gsp auth`)"); - } - _ => {} - } - // locks row - match &locks { - Ok(Response::LocksList(l)) => { - println!( - "locks: {} ({} sat capacity)", - l.locks.len(), - l.total_locked_sats - ); - } - Ok(Response::Error(_)) | Err(_) => println!("locks: (no session)"), + // The reason is already on the node row above; repeating it as a + // balance of 0 would be worse than saying nothing. + Ok(Response::Error(_)) | Err(_) => println!("balance: (unavailable)"), _ => {} } - // session row - match &session { - Ok(Response::GspSessionStatus(s)) if s.have_token => { - let remaining = s.remaining_secs.unwrap_or(0).max(0); - let pretty = if remaining < 60 { - format!("{remaining}s") - } else if remaining < 3600 { - format!("{}m {}s", remaining / 60, remaining % 60) - } else { - format!("{}h {}m", remaining / 3600, (remaining % 3600) / 60) - }; - let wallet = s.wallet_name.as_deref().unwrap_or("(unknown)"); - let phase = s.phase.as_deref().unwrap_or("?"); - println!("session: {wallet} — {phase} — expires in {pretty}"); - } - Ok(Response::GspSessionStatus(_)) => println!("session: (none)"), - _ => println!("session: (none)"), - } std::process::ExitCode::SUCCESS } @@ -2016,94 +2375,214 @@ mod client { Err(e) => serde_json::json!({"error": e}), } } +} - /// Streaming subscriber for `Request::WatchPayments`. Connects, sends the - /// request, expects a `Response::Watching` ack, then prints each - /// `Response::PaymentDetected` line until the daemon closes the stream - /// (or the user hits Ctrl-C). With `--json`, every line is the raw - /// envelope JSON exactly as the daemon emits it. - pub(crate) async fn run_watch(json: bool) -> std::process::ExitCode { - let stream = match connect_daemon().await { - Ok(s) => s, - Err(e) => { - if json { - println!( - "{}", - serde_json::json!({"error": {"message": format!("connect: {e}")}}) - ); - } else { - eprintln!( - "wraith: could not connect to wraithd at {}: {e}", - wraith_wallet_ipc::endpoint_display() - ); - } - return std::process::ExitCode::FAILURE; - } - }; - let (reader, mut writer) = stream.split(); - let req = Envelope::new(1, Request::WatchPayments); - let mut line = match serde_json::to_string(&req) { - Ok(s) => s, - Err(e) => { - eprintln!("wraith: serialise: {e}"); - return std::process::ExitCode::FAILURE; - } - }; - line.push('\n'); - if let Err(e) = writer.write_all(line.as_bytes()).await { - eprintln!("wraith: write: {e}"); - return std::process::ExitCode::FAILURE; +#[cfg(test)] +mod cli_tests { + use super::*; + use clap::{CommandFactory, Parser}; + + /// clap's own validity check: duplicate flags, bad names, conflicting + /// shorts. Cheap, and it fails at test time rather than on first run. + #[test] + fn the_command_tree_is_well_formed() { + Cli::command().debug_assert(); + } + + /// Every Lock subcommand parses. + /// + /// This exists because the CLI shipped with four `GhostLock*` response + /// renderers and no commands that could produce them — the wallet could + /// format a lock list it had no way to ask for, and a help string pointed + /// at a `lock-list` command that did not exist. Nothing caught it because + /// nothing parsed the tree. This does. + #[test] + fn every_lock_subcommand_parses() { + let cases: Vec> = vec![ + vec!["wraith", "lock", "list"], + vec!["wraith", "lock", "forget", "--lock-id", "abc"], + vec![ + "wraith", + "lock", + "destination", + "--lock-id", + "abc", + "--lane", + "savings", + ], + vec![ + "wraith", + "lock", + "save", + "--backup-pubkey", + "aa", + "--heir-pubkey", + "bb", + "--quorum-pubkey", + "cc", + "--anchor-height", + "1", + "--inherit-height", + "2", + ], + vec![ + "wraith", + "lock", + "lanes", + "--backup-pubkey", + "aa", + "--heir-pubkey", + "bb", + "--quorum-pubkey", + "cc", + "--anchor-height", + "1", + "--inherit-height", + "2", + ], + vec![ + "wraith", + "lock", + "fund", + "--lock-id", + "abc", + "--lane", + "savings", + "--coordinator", + "http://127.0.0.1:9100", + "--tier", + "1m_sats", + "--ghost-id", + "g", + "--utxo", + "aa:0", + "--utxo-value", + "1000000", + "--utxo-scriptpubkey", + "5120aa", + ], + ]; + for argv in cases { + let joined = argv.join(" "); + Cli::try_parse_from(&argv).unwrap_or_else(|e| panic!("`{joined}` must parse: {e}")); } - let mut reader = BufReader::new(reader); - if !json { - eprintln!("wraith: watching for silent-payment detections (Ctrl-C to stop)"); + } + + /// The quorum-sign command parses. + #[test] + fn the_quorum_sign_command_parses() { + let argv = vec![ + "wraith", + "lock", + "quorum-sign", + "--lock-id", + "a", + "--psbt", + "cHNidP8=", + "--input-index", + "0", + "--coordinator", + "http://127.0.0.1:9100", + ]; + Cli::try_parse_from(&argv).expect("must parse"); + } + + /// The escape subcommands parse. + #[test] + fn the_escape_commands_parse() { + let cases: Vec> = vec![ + vec![ + "wraith", + "lock", + "escape-plan", + "--lock-id", + "a", + "--lane", + "spending", + ], + vec![ + "wraith", + "lock", + "escape", + "--lock-id", + "a", + "--lane", + "investments", + "--psbt", + "cHNidP8=", + "--input-index", + "0", + ], + ]; + for argv in cases { + let joined = argv.join(" "); + Cli::try_parse_from(&argv).unwrap_or_else(|e| panic!("`{joined}` must parse: {e}")); } - loop { - let mut buf = String::new(); - match reader.read_line(&mut buf).await { - Ok(0) => return std::process::ExitCode::SUCCESS, - Ok(_) => { - if json { - print!("{buf}"); - continue; - } - let env: Envelope = match serde_json::from_str(&buf) { - Ok(e) => e, - Err(e) => { - eprintln!("wraith: malformed push: {e}; raw={buf}"); - continue; - } - }; - match env.payload { - Response::Watching => {} // ack — keep waiting - Response::PaymentDetected(d) => { - let height = d - .block_height - .map(|h| h.to_string()) - .unwrap_or_else(|| "—".to_string()); - let amt = d - .amount_sats - .map(|a| a.to_string()) - .unwrap_or_else(|| "?".to_string()); - println!( - "{} sat height={} vout={} k={} txid={}", - amt, height, d.vout, d.k, d.txid - ); - } - Response::Error(e) => { - eprintln!("wraith: daemon error: {}", e.message); - return std::process::ExitCode::FAILURE; - } - other => { - eprintln!("wraith: unexpected push variant: {other:?}"); - } - } - } - Err(e) => { - eprintln!("wraith: read: {e}"); - return std::process::ExitCode::FAILURE; - } - } + } + + /// The three signing steps parse, so the flow cannot ship half-wired. + #[test] + fn every_sign_step_parses() { + let cases: Vec> = vec![ + vec![ + "wraith", + "lock", + "sign", + "begin", + "--lock-id", + "a", + "--lane", + "savings", + "--psbt", + "cHNidP8=", + "--input-index", + "0", + ], + vec![ + "wraith", + "lock", + "sign", + "nonce", + "--session", + "aa", + "--device-nonce", + "bb", + ], + vec![ + "wraith", + "lock", + "sign", + "complete", + "--session", + "aa", + "--device-partial", + "bb", + ], + ]; + for argv in cases { + let joined = argv.join(" "); + Cli::try_parse_from(&argv).unwrap_or_else(|e| panic!("`{joined}` must parse: {e}")); + } + } + + /// The lane names the CLI documents are the ones the daemon accepts. + /// + /// Kept as a literal list rather than derived, because the daemon parses + /// these from a string: if someone renames a lane on one side only, this + /// is the thing that notices. + #[test] + fn the_documented_lanes_are_the_daemon_s_lanes() { + for lane in ["savings", "spending", "investments", "cash"] { + let argv = vec![ + "wraith", + "lock", + "destination", + "--lock-id", + "a", + "--lane", + lane, + ]; + Cli::try_parse_from(&argv).unwrap_or_else(|e| panic!("lane `{lane}`: {e}")); } } } diff --git a/apps/wraith-wallet/core/Cargo.toml b/apps/wraith-wallet/core/Cargo.toml index 64855434f..60f6c8e60 100644 --- a/apps/wraith-wallet/core/Cargo.toml +++ b/apps/wraith-wallet/core/Cargo.toml @@ -8,7 +8,8 @@ repository.workspace = true [dependencies] wraith-protocol = { workspace = true } -ghost-locks = { workspace = true } +ghost-entropy = { workspace = true } +ghost-lock = { workspace = true } aes-gcm = { workspace = true } # Local override to pull in reqwest's SOCKS5 feature for the # Tor-routed /outputs client. Workspace-default reqwest doesn't @@ -20,8 +21,6 @@ bip32 = { workspace = true } bip39 = { workspace = true } bitcoin = { workspace = true } bip322 = { workspace = true } -futures-util = { workspace = true } -ghost-gsp-proto = { workspace = true } ghost-keys = { workspace = true } hex = { workspace = true } rand = { workspace = true } @@ -38,9 +37,6 @@ serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-tungstenite = { workspace = true } -tokio-socks = "0.5" -url = "2" tracing = { workspace = true } zeroize = { workspace = true } diff --git a/apps/wraith-wallet/core/examples/synthetic_candidate.rs b/apps/wraith-wallet/core/examples/synthetic_candidate.rs deleted file mode 100644 index 6d5ea9c8c..000000000 --- a/apps/wraith-wallet/core/examples/synthetic_candidate.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Build a synthetic BIP-352 candidate-transaction payload addressed to a given -//! wallet's scan + spend pubkeys, ready to POST at -//! `/api/v1/admin/inject-candidate-tx` on a dev ghost-gsp. -//! -//! Usage: -//! cargo run -p wraith-wallet-core --example synthetic_candidate -- \ -//! [amount_sats] [vout] -//! -//! Prints a single line of JSON to stdout. Pipe into curl: -//! curl -sS -X POST -H 'content-type: application/json' \ -//! http://127.0.0.1:8900/api/v1/admin/inject-candidate-tx \ -//! -d "$(cargo run -p wraith-wallet-core --example synthetic_candidate -- ...)" - -use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use ghost_keys::{derive_payment_address_v2, derive_shared_secret}; -use rand::RngCore; - -fn main() { - let args: Vec = std::env::args().collect(); - if args.len() < 3 { - eprintln!( - "usage: {} [amount_sats] [vout]", - args[0] - ); - std::process::exit(2); - } - let scan_hex = &args[1]; - let spend_hex = &args[2]; - let amount: u64 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(50_000); - let vout: u32 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(0); - - let scan_bytes = hex::decode(scan_hex).expect("scan_pubkey hex"); - let spend_bytes = hex::decode(spend_hex).expect("spend_pubkey hex"); - let scan_pubkey = PublicKey::from_slice(&scan_bytes).expect("scan_pubkey curve"); - let spend_pubkey = PublicKey::from_slice(&spend_bytes).expect("spend_pubkey curve"); - - let secp = Secp256k1::new(); - let mut eph_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut eph_bytes); - let eph_secret = SecretKey::from_slice(&eph_bytes).expect("nonzero scalar"); - let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); - - // ECDH(eph_secret, scan_pubkey) — same secret the receiver computes via - // ECDH(scan_secret, ephemeral_pub). - let shared_secret = derive_shared_secret(&eph_secret, &scan_pubkey); - - let k: u32 = 0; - let (output_pubkey, _tweak) = - derive_payment_address_v2(&spend_pubkey, &shared_secret, k).expect("derive output"); - - // x-only encoding (taproot output). - let serialized = output_pubkey.serialize(); - let xonly = &serialized[1..]; - - // Random 32-byte txid for the synthetic tx. - let mut txid_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut txid_bytes); - - let payload = serde_json::json!({ - "ephemeral_pubkey": hex::encode(ephemeral_pub.serialize()), - "outputs": [ - { - "output_pubkey": hex::encode(xonly), - "amount_sats": amount, - "vout": vout, - } - ], - "txid": hex::encode(txid_bytes), - "block_height": 250, - }); - println!("{}", payload); -} diff --git a/apps/wraith-wallet/core/src/auth/mod.rs b/apps/wraith-wallet/core/src/auth/mod.rs index 2261d0f54..df58656c8 100644 --- a/apps/wraith-wallet/core/src/auth/mod.rs +++ b/apps/wraith-wallet/core/src/auth/mod.rs @@ -1,8 +1,10 @@ -//! GSP authentication primitives. +//! The wallet's identity key. //! -//! Implements the wallet side of the WalletProof Schnorr challenge-response scheme -//! used to authenticate to a Ghost Service Provider. Mirrors the shape consumed by -//! `crates/ghost-gsp/src/auth/proof.rs` on the server. +//! One keypair at a fixed path, from which the wallet ID and the Ghost ID are +//! derived, plus BIP-340 signing over arbitrary data. This began as the +//! authentication half of the GSP handshake; the handshake is gone and the +//! identity remains, because the wallet ID is how a wallet is recognised +//! across implementations sharing a seed. //! //! Auth keypair derivation path: `m/352'/0'/0'/2'` — matches `ghost-light-wallet`'s //! canonical layout (same seed across implementations → same wallet_id). @@ -11,7 +13,6 @@ //! Signature: BIP-340 Schnorr over `tagged_hash("GhostGSP/proof", message)`. use bitcoin::secp256k1::{Keypair, Message, Secp256k1}; -use ghost_gsp_proto::WalletProof; use sha2::{Digest, Sha256}; use crate::keystore::{Keystore, KeystoreError}; @@ -25,8 +26,6 @@ pub enum AuthError { Keystore(#[from] KeystoreError), #[error(transparent)] Signer(#[from] SignerError), - #[error("gsp proto: {0}")] - GspProto(String), #[error("secp: {0}")] Secp(String), } @@ -62,24 +61,11 @@ fn tagged_hash(tag: &str, msg: &[u8]) -> [u8; 32] { hasher.finalize().into() } -/// Build and Schnorr-sign a `WalletProof` for a given action (e.g. `"register"`, `"session"`). -pub fn make_proof(keypair: &Keypair, action: &str) -> Result { - let pk = xonly_pubkey_bytes(keypair); - let mut proof = - WalletProof::new(action, &pk).map_err(|e| AuthError::GspProto(e.to_string()))?; - let msg_hash = tagged_hash("GhostGSP/proof", proof.message.as_bytes()); - let msg = Message::from_digest(msg_hash); - let secp = Secp256k1::new(); - let sig = secp.sign_schnorr_no_aux_rand(&msg, keypair); - proof.signature = hex::encode(sig.as_ref()); - Ok(proof) -} - /// Sign arbitrary `data` with the auth keypair using BIP-340 Schnorr. /// /// Mirrors `ghost-light-wallet::signing::sign_data`: applies tagged hash /// `"Ghost/Data/v1"` over the input bytes before signing. Used to sign the -/// `sighash` returned by ghost-pay's `PreparePayment` flow. +/// `sighash` in any flow that asks the wallet to endorse bytes. pub fn sign_data(keypair: &Keypair, data: &[u8]) -> [u8; 64] { let h = tagged_hash("Ghost/Data/v1", data); let msg = Message::from_digest(h); @@ -116,21 +102,6 @@ pub fn wallet_id_hex_signer(signer: &dyn Signer) -> Result { Ok(hex::encode(&hash[0..16])) } -/// Build and Schnorr-sign a `WalletProof` for a given action via a signer. -/// -/// On a hardware backing this triggers a "confirm signing on device" prompt; -/// on the software backing it returns near-instantly. Either way the wire -/// shape of the proof is identical to `make_proof()`. -pub fn make_proof_signer(signer: &dyn Signer, action: &str) -> Result { - let pk = xonly_pubkey_signer(signer)?; - let mut proof = - WalletProof::new(action, &pk).map_err(|e| AuthError::GspProto(e.to_string()))?; - let msg_hash = tagged_hash("GhostGSP/proof", proof.message.as_bytes()); - let sig = signer.sign_schnorr_at(AUTH_DERIVATION_PATH, &msg_hash)?; - proof.signature = hex::encode(sig); - Ok(proof) -} - /// Sign arbitrary `data` via the auth signer using BIP-340 Schnorr, /// applying the `"Ghost/Data/v1"` tagged hash like `sign_data` does. pub fn sign_data_signer(signer: &dyn Signer, data: &[u8]) -> Result<[u8; 64], AuthError> { @@ -141,7 +112,6 @@ pub fn sign_data_signer(signer: &dyn Signer, data: &[u8]) -> Result<[u8; 64], Au #[cfg(test)] mod tests { use super::*; - use bitcoin::secp256k1::{schnorr::Signature, XOnlyPublicKey}; const VECTOR_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; @@ -164,38 +134,6 @@ mod tests { assert!(id.chars().all(|c| c.is_ascii_hexdigit())); } - #[test] - fn make_proof_signs_and_verifies() { - let ks = Keystore::from_mnemonic(VECTOR_MNEMONIC).unwrap(); - let kp = auth_keypair(&ks).unwrap(); - let proof = make_proof(&kp, "register").unwrap(); - - // Structural validation per ghost-gsp-proto. - proof.validate_structure().expect("proof structure"); - - // Reconstruct the message hash and verify the Schnorr signature using the - // same code paths the GSP server uses (see ghost-gsp/src/auth/proof.rs). - let msg_hash = tagged_hash("GhostGSP/proof", proof.message.as_bytes()); - let msg = Message::from_digest(msg_hash); - let sig_bytes = hex::decode(&proof.signature).unwrap(); - let sig = Signature::from_slice(&sig_bytes).unwrap(); - let pk_bytes = hex::decode(&proof.public_key).unwrap(); - let pk = XOnlyPublicKey::from_slice(&pk_bytes).unwrap(); - let secp = Secp256k1::verification_only(); - secp.verify_schnorr(&sig, &msg, &pk) - .expect("server-side verification path must accept this proof"); - } - - #[test] - fn each_proof_has_a_unique_nonce() { - let ks = Keystore::from_mnemonic(VECTOR_MNEMONIC).unwrap(); - let kp = auth_keypair(&ks).unwrap(); - let p1 = make_proof(&kp, "register").unwrap(); - let p2 = make_proof(&kp, "register").unwrap(); - assert_ne!(p1.nonce, p2.nonce); - assert_ne!(p1.signature, p2.signature); - } - // Signer-trait path: same outputs as the keypair path, accessed via // &dyn Signer. The point of these tests is to prove the trait is // load-bearing — a hardware backend implementing Signer must produce @@ -221,47 +159,4 @@ mod tests { let kp = auth_keypair(&ks).unwrap(); assert_eq!(wallet_id_hex_signer(signer).unwrap(), wallet_id_hex(&kp)); } - - #[test] - fn signer_path_make_proof_verifies_under_server_path() { - use crate::signer::SoftwareSigner; - use bitcoin::secp256k1::schnorr::Signature; - let ks = Keystore::from_mnemonic(VECTOR_MNEMONIC).unwrap(); - let signer: &dyn Signer = &SoftwareSigner::new(&ks); - let proof = make_proof_signer(signer, "register").unwrap(); - proof.validate_structure().expect("proof structure"); - - // Verify the Schnorr signature using the same code paths the GSP - // server uses — proves the trait-based path produces a proof the - // server will accept. - let msg_hash = tagged_hash("GhostGSP/proof", proof.message.as_bytes()); - let msg = Message::from_digest(msg_hash); - let sig_bytes = hex::decode(&proof.signature).unwrap(); - let sig = Signature::from_slice(&sig_bytes).unwrap(); - let pk_bytes = hex::decode(&proof.public_key).unwrap(); - let pk = XOnlyPublicKey::from_slice(&pk_bytes).unwrap(); - let secp = Secp256k1::verification_only(); - secp.verify_schnorr(&sig, &msg, &pk) - .expect("server-side verification path must accept this proof"); - } - - #[test] - fn signer_path_sign_data_verifies() { - use crate::signer::SoftwareSigner; - use bitcoin::secp256k1::schnorr::Signature; - let ks = Keystore::from_mnemonic(VECTOR_MNEMONIC).unwrap(); - let signer: &dyn Signer = &SoftwareSigner::new(&ks); - let data = b"some sighash bytes"; - let sig_bytes = sign_data_signer(signer, data).unwrap(); - - // Re-derive the digest the same way sign_data_signer does and verify. - let h = tagged_hash("Ghost/Data/v1", data); - let msg = Message::from_digest(h); - let sig = Signature::from_slice(&sig_bytes).unwrap(); - let kp = auth_keypair(&ks).unwrap(); - let pk = kp.x_only_public_key().0; - let secp = Secp256k1::verification_only(); - secp.verify_schnorr(&sig, &msg, &pk) - .expect("sign_data_signer signature must verify"); - } } diff --git a/apps/wraith-wallet/core/src/block_scan.rs b/apps/wraith-wallet/core/src/block_scan.rs new file mode 100644 index 000000000..f2ae8f837 --- /dev/null +++ b/apps/wraith-wallet/core/src/block_scan.rs @@ -0,0 +1,548 @@ +//! Work out what a block did to the wallet. +//! +//! # Why this exists +//! +//! The wallet used to be told. The operator's GSP watched the chain on its +//! behalf and pushed what it found, which meant handing somebody a scan key +//! and trusting the answer. With that gone the wallet has to look for itself, +//! and looking means reading blocks from its own node. +//! +//! This module is the pure half: given one block with its inputs resolved, +//! and the set of scripts the wallet can spend, say what moved. It performs no +//! I/O and holds no keys, so it is testable against hand-built blocks — which +//! matters, because the arithmetic here decides what a balance history says. + +use std::collections::HashSet; + +use crate::candidate_scan::CandidateOutput; +use crate::ghostd::{BlockTx, VerboseBlock}; + +/// What one transaction in a block did to the wallet. +/// +/// Only transactions that touched it are reported. A block of ten thousand +/// strangers' payments produces an empty vector, not ten thousand zeroes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WalletMovement { + pub txid: String, + /// Height of the block it was mined in. + pub height: u64, + /// Block time, unix seconds. The wallet may not have been running when + /// this was mined, so "when I first saw it" would be the wrong date. + pub time: i64, + /// Total paid *to* the wallet by this transaction. + pub received_sats: u64, + /// Total spent *from* the wallet by this transaction: the value of the + /// inputs that were ours. + pub spent_sats: u64, + /// The miner fee, when it can be known. + /// + /// `None` for a coinbase (which has no inputs to compare against) and for + /// any transaction with an input whose previous output the node did not + /// resolve. A fee derived from a subset of the inputs is not a smaller + /// fee, it is a wrong one. + pub fee_sats: Option, + /// Output indices paying the wallet, so a caller can find the coins. + pub vouts_to_us: Vec, + /// True when the wallet supplied no inputs — money arriving rather than + /// moving around inside the wallet. + pub is_incoming: bool, +} + +impl WalletMovement { + /// Net change to the wallet's balance, negative when it lost value. + /// + /// This is what the balance actually moves by, so a history built from it + /// reconciles with a balance built from the UTXO set. For a spend it + /// already includes the fee, because the fee is simply value that left in + /// an input and came back in no output of ours. + pub fn net_sats(&self) -> i64 { + self.received_sats as i64 - self.spent_sats as i64 + } +} + +/// Everything in `block` that touched one of `ours`. +/// +/// `ours` holds raw scriptPubKey bytes, not addresses: bitcoind normalises +/// address descriptors on the way out, and matching on the canonical script +/// avoids depending on which form it chooses to echo back. +pub fn scan_block(block: &VerboseBlock, ours: &HashSet>) -> Vec { + let mut out = Vec::new(); + for tx in &block.tx { + let mut received = 0u64; + let mut vouts = Vec::new(); + for v in &tx.vout { + let Ok(spk) = hex::decode(&v.script_pubkey.hex) else { + // An unparseable script cannot be ours — we only ever hold + // scripts we derived. Skipping it cannot hide our own money. + continue; + }; + if ours.contains(&spk) { + received = received.saturating_add(v.value_sats()); + vouts.push(v.n); + } + } + + let mut spent = 0u64; + let mut inputs_total = 0u64; + let mut every_prevout_known = true; + let mut coinbase = false; + let mut we_supplied_an_input = false; + for i in &tx.vin { + if i.is_coinbase() { + coinbase = true; + continue; + } + let Some(prev) = i.prevout.as_ref() else { + every_prevout_known = false; + continue; + }; + inputs_total = inputs_total.saturating_add(prev.value_sats()); + if let Ok(spk) = hex::decode(&prev.script_pub_key.hex) { + if ours.contains(&spk) { + spent = spent.saturating_add(prev.value_sats()); + we_supplied_an_input = true; + } + } + } + + if received == 0 && spent == 0 { + continue; + } + + // A coinbase has no fee to speak of, and an unresolved input makes any + // figure here a guess. Both report `None` rather than a number that + // would read as measured. + let fee_sats = if coinbase || !every_prevout_known { + None + } else { + let outputs_total: u64 = tx.vout.iter().map(|v| v.value_sats()).sum(); + Some(inputs_total.saturating_sub(outputs_total)) + }; + + out.push(WalletMovement { + txid: tx.txid.clone(), + height: block.height, + time: block.time, + received_sats: received, + spent_sats: spent, + fee_sats, + vouts_to_us: vouts, + is_incoming: !we_supplied_an_input, + }); + } + out +} + +/// The scriptPubKey of a Ghost silent-payment announcement: +/// `OP_RETURN PUSH33 `. +const OP_RETURN: u8 = 0x6a; +const PUSH33: u8 = 0x21; +const ANNOUNCE_LEN: usize = 35; + +/// The scriptPubKey of a taproot output: `OP_1 PUSH32 `. +const OP_1: u8 = 0x51; +const PUSH32: u8 = 0x20; +const P2TR_LEN: usize = 34; + +/// Pull a silent-payment candidate out of one transaction. +/// +/// Returns the sender's ephemeral pubkey (hex, compressed) and every taproot +/// output that could be the payment, ready for +/// [`crate::candidate_scan::scan_candidate`]. `None` when the transaction +/// carries no announcement, which is almost all of them. +/// +/// # The format +/// +/// A Ghost silent payment announces itself with an `OP_RETURN` output holding +/// exactly one 33-byte compressed pubkey, and pays a taproot output derived +/// from it and the receiver's Ghost ID. Both halves are matched on the exact +/// script shape rather than by parsing: an `OP_RETURN` of some other length is +/// somebody else's data, and treating it as a pubkey would feed noise to the +/// scanner on every block. +/// +/// Only the FIRST announcement is taken. A transaction carrying two is not a +/// payment with a spare key, it is malformed — and picking one at random would +/// make detection depend on output ordering. +pub fn candidate_in(tx: &BlockTx) -> Option<(String, Vec)> { + let mut ephemeral: Option = None; + let mut outputs = Vec::new(); + + for v in &tx.vout { + let Ok(spk) = hex::decode(&v.script_pubkey.hex) else { + continue; + }; + if spk.len() == ANNOUNCE_LEN && spk[0] == OP_RETURN && spk[1] == PUSH33 { + if ephemeral.is_none() { + ephemeral = Some(hex::encode(&spk[2..ANNOUNCE_LEN])); + } + continue; + } + if spk.len() == P2TR_LEN && spk[0] == OP_1 && spk[1] == PUSH32 { + outputs.push(CandidateOutput { + // x-only, as it appears on chain. The scanner tries both + // parities, because a taproot output does not record which. + output_pubkey: hex::encode(&spk[2..P2TR_LEN]), + amount_sats: Some(v.value_sats()), + vout: v.n, + }); + } + } + + // An announcement with nothing to pay into is not a candidate. Scanning it + // would cost an ECDH per block for a transaction that cannot match. + let ephemeral = ephemeral?; + if outputs.is_empty() { + return None; + } + Some((ephemeral, outputs)) +} + +/// Every silent-payment candidate in a block, with its txid. +pub fn candidates_in_block(block: &VerboseBlock) -> Vec<(String, String, Vec)> { + block + .tx + .iter() + .filter_map(|tx| candidate_in(tx).map(|(e, o)| (tx.txid.clone(), e, o))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spk_hex(tag: u8) -> String { + let mut v = vec![0x51, 0x20]; + v.extend_from_slice(&[tag; 32]); + hex::encode(v) + } + + fn ours_set(tags: &[u8]) -> HashSet> { + tags.iter() + .map(|t| hex::decode(spk_hex(*t)).unwrap()) + .collect() + } + + /// Build a block from a compact description, so the tests read as the + /// situation rather than as JSON. + fn block(txs: serde_json::Value) -> VerboseBlock { + serde_json::from_value(serde_json::json!({ + "hash": "00".repeat(32), + "height": 900_000, + "time": 1_700_000_000i64, + "tx": txs, + })) + .expect("block fixture") + } + + fn vin(value: f64, tag: u8) -> serde_json::Value { + serde_json::json!({ + "txid": "11".repeat(32), + "vout": 0, + "prevout": { "value": value, "scriptPubKey": { "hex": spk_hex(tag) } } + }) + } + + fn vout(n: u32, value: f64, tag: u8) -> serde_json::Value { + serde_json::json!({ "n": n, "value": value, "scriptPubKey": { "hex": spk_hex(tag) } }) + } + + /// A payment arriving is the case the push used to cover, and the reason + /// history was send-only without it. + #[test] + fn a_payment_to_us_is_found() { + let b = block(serde_json::json!([{ + "txid": "aa", + "vin": [vin(1.0, 0xbb)], + "vout": [vout(0, 0.5, 0xaa), vout(1, 0.499, 0xbb)], + }])); + let moves = scan_block(&b, &ours_set(&[0xaa])); + assert_eq!(moves.len(), 1); + let m = &moves[0]; + assert_eq!(m.received_sats, 50_000_000); + assert_eq!(m.spent_sats, 0); + assert_eq!(m.net_sats(), 50_000_000); + assert!(m.is_incoming, "we supplied no input"); + assert_eq!(m.vouts_to_us, vec![0]); + assert_eq!(m.height, 900_000); + } + + /// A block full of other people's payments must produce nothing, not a + /// row per transaction with zeroes in it. + #[test] + fn a_block_of_strangers_produces_nothing() { + let b = block(serde_json::json!([{ + "txid": "aa", + "vin": [vin(1.0, 0xbb)], + "vout": [vout(0, 0.999, 0xcc)], + }])); + assert!(scan_block(&b, &ours_set(&[0xaa])).is_empty()); + } + + /// A spend nets the payment *and* the fee, with change netted back out — + /// the figure the balance will actually move by. + #[test] + fn a_spend_nets_the_fee_and_the_change() { + let b = block(serde_json::json!([{ + "txid": "aa", + "vin": [vin(1.0, 0xaa)], + "vout": [vout(0, 0.5, 0xcc), vout(1, 0.4999, 0xaa)], + }])); + let m = &scan_block(&b, &ours_set(&[0xaa]))[0]; + assert_eq!(m.spent_sats, 100_000_000); + assert_eq!(m.received_sats, 49_990_000, "the change came back to us"); + assert_eq!(m.net_sats(), -50_010_000, "payment plus fee"); + assert_eq!(m.fee_sats, Some(10_000)); + assert!(!m.is_incoming, "we supplied the input"); + } + + /// Money moved between our own addresses costs only the fee. It is not a + /// zero-value event, and it is not incoming. + #[test] + fn a_self_send_nets_the_fee_only() { + let b = block(serde_json::json!([{ + "txid": "aa", + "vin": [vin(1.0, 0xaa)], + "vout": [vout(0, 0.9999, 0xaa)], + }])); + let m = &scan_block(&b, &ours_set(&[0xaa]))[0]; + assert_eq!(m.net_sats(), -10_000); + assert_eq!(m.fee_sats, Some(10_000)); + assert!(!m.is_incoming); + } + + /// Mining pays the wallet with no inputs to compare against, so there is + /// no fee to report — and reporting one would mean inventing it. + #[test] + fn a_coinbase_credits_us_with_no_fee() { + let b = block(serde_json::json!([{ + "txid": "aa", + "vin": [{ "coinbase": "03aabbcc" }], + "vout": [vout(0, 3.125, 0xaa)], + }])); + let m = &scan_block(&b, &ours_set(&[0xaa]))[0]; + assert_eq!(m.received_sats, 312_500_000); + assert_eq!(m.fee_sats, None, "a coinbase has no fee"); + assert!(m.is_incoming); + } + + /// One unresolved input poisons the fee. The movement is still reported — + /// knowing money moved matters more than knowing what it cost — but the + /// fee is `None`, never a figure derived from the inputs that happened to + /// be present. + #[test] + fn an_unresolved_input_yields_no_fee_rather_than_a_partial_one() { + let b = block(serde_json::json!([{ + "txid": "aa", + "vin": [vin(1.0, 0xaa), { "txid": "22".repeat(32), "vout": 1 }], + "vout": [vout(0, 1.4, 0xcc)], + }])); + let m = &scan_block(&b, &ours_set(&[0xaa]))[0]; + assert_eq!(m.spent_sats, 100_000_000, "the input we could see was ours"); + assert_eq!(m.fee_sats, None, "a partial fee is a wrong fee"); + } + + fn announce_hex(tag: u8) -> String { + // 6a 21 <33 bytes>. The leading 0x02 keeps it a plausible compressed + // key; the extractor does not validate the curve point, the scanner + // does. + let mut v = vec![0x6a, 0x21, 0x02]; + v.extend_from_slice(&[tag; 32]); + hex::encode(v) + } + + fn tx_with_scripts(txid: &str, scripts: &[(u32, f64, String)]) -> crate::ghostd::BlockTx { + let vout: Vec = scripts + .iter() + .map(|(n, value, hex)| { + serde_json::json!({ "n": n, "value": value, "scriptPubKey": { "hex": hex } }) + }) + .collect(); + serde_json::from_value(serde_json::json!({ "txid": txid, "vin": [], "vout": vout })) + .expect("tx fixture") + } + + /// The shape the sender writes: one announcement, one taproot output. + #[test] + fn an_announcement_and_a_taproot_output_make_a_candidate() { + let tx = tx_with_scripts( + "aa", + &[ + (0, 0.0, announce_hex(0x11)), + (1, 0.5, spk_hex(0xaa)), + (2, 0.4, "76a914".to_string() + &"00".repeat(20) + "88ac"), + ], + ); + let (eph, outs) = candidate_in(&tx).expect("a candidate"); + assert_eq!(eph.len(), 66, "33 bytes, compressed"); + assert_eq!(outs.len(), 1, "only the taproot output is a candidate"); + assert_eq!(outs[0].vout, 1); + assert_eq!(outs[0].amount_sats, Some(50_000_000)); + assert_eq!(outs[0].output_pubkey.len(), 64, "x-only, 32 bytes"); + } + + /// Almost every transaction on the chain is not a silent payment, and + /// running an ECDH over each one would make scanning cost real money. + #[test] + fn a_transaction_without_an_announcement_is_not_a_candidate() { + let tx = tx_with_scripts("aa", &[(0, 0.5, spk_hex(0xaa))]); + assert!(candidate_in(&tx).is_none()); + } + + /// An OP_RETURN of the wrong length is somebody else's data. Treating it + /// as a pubkey would feed noise to the scanner on every block. + #[test] + fn an_op_return_of_another_length_is_not_an_announcement() { + let tx = tx_with_scripts( + "aa", + &[ + (0, 0.0, "6a0b68656c6c6f20776f726c64".into()), + (1, 0.5, spk_hex(0xaa)), + ], + ); + assert!(candidate_in(&tx).is_none()); + } + + /// An announcement paying nothing into taproot cannot match anything. + #[test] + fn an_announcement_with_no_taproot_output_is_not_a_candidate() { + let tx = tx_with_scripts( + "aa", + &[ + (0, 0.0, announce_hex(0x11)), + (1, 0.5, "76a914".to_string() + &"00".repeat(20) + "88ac"), + ], + ); + assert!(candidate_in(&tx).is_none()); + } + + /// Two announcements is malformed, not a payment with a spare key. Taking + /// the first makes detection independent of output ordering. + #[test] + fn a_second_announcement_is_ignored_rather_than_replacing_the_first() { + let tx = tx_with_scripts( + "aa", + &[ + (0, 0.0, announce_hex(0x11)), + (1, 0.0, announce_hex(0x22)), + (2, 0.5, spk_hex(0xaa)), + ], + ); + let (eph, _) = candidate_in(&tx).expect("a candidate"); + assert_eq!(eph, announce_hex(0x11)[4..], "the first announcement wins"); + } + + /// The two halves must fit: what a sender writes into a block is what the + /// scanner finds. + /// + /// This is the assumption the whole silent-payment path rests on. The + /// announcement format was recovered from the retired operator service, so + /// a test that only exercised the extractor against fixtures I wrote would + /// prove I am consistent with myself. Here the sender is real + /// `ghost-keys` — ECDH, the v2 address derivation, the taproot x-only + /// truncation — and the receiver is the real scanner. Nothing in between + /// is hand-written except the block encoding, which is the thing under + /// test. + #[test] + fn a_real_silent_payment_survives_the_round_trip_through_a_block() { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use ghost_keys::{derive_payment_address_v2, derive_shared_secret, GhostKeys}; + use rand::RngCore; + + let receiver = GhostKeys::generate(); + + // Sender: a one-shot ephemeral key, ECDH against the receiver's + // published scan key, then the output key at k = 0. + let secp = Secp256k1::new(); + let mut eph = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut eph); + let eph_secret = SecretKey::from_slice(&eph).expect("nonzero scalar"); + let eph_pub = PublicKey::from_secret_key(&secp, &eph_secret); + let shared = derive_shared_secret(&eph_secret, receiver.scan_pubkey()); + let (output_pubkey, _) = + derive_payment_address_v2(receiver.spend_pubkey(), &shared, 0).expect("derive"); + + // On chain: the announcement, and the payment as a taproot output — + // which keeps only the x-coordinate. + let mut announce = vec![0x6a, 0x21]; + announce.extend_from_slice(&eph_pub.serialize()); + let mut p2tr = vec![0x51, 0x20]; + p2tr.extend_from_slice(&output_pubkey.serialize()[1..]); + + let tx = tx_with_scripts( + "feed", + &[ + (0, 0.0, hex::encode(&announce)), + // A decoy taproot output that is not ours, so the scanner has + // to pick rather than accept whatever it is handed. + (1, 0.25, spk_hex(0xcc)), + (2, 0.5, hex::encode(&p2tr)), + ], + ); + + let (eph_hex, outs) = candidate_in(&tx).expect("the announcement is found"); + let found = crate::candidate_scan::scan_candidate( + &receiver, + &eph_hex, + &outs, + "feed", + Some(900_000), + ) + .expect("scan runs"); + + assert_eq!(found.len(), 1, "exactly the one output that was ours"); + assert_eq!(found[0].vout, 2, "and at the right output index"); + assert_eq!(found[0].amount_sats, Some(50_000_000)); + assert_eq!(found[0].k, 0); + assert_eq!(found[0].block_height, Some(900_000)); + } + + /// The same payment addressed to somebody else must not match, or the + /// test above would pass for a scanner that accepted anything. + #[test] + fn a_silent_payment_to_a_stranger_is_not_detected() { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use ghost_keys::{derive_payment_address_v2, derive_shared_secret, GhostKeys}; + use rand::RngCore; + + let us = GhostKeys::generate(); + let them = GhostKeys::generate(); + + let secp = Secp256k1::new(); + let mut eph = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut eph); + let eph_secret = SecretKey::from_slice(&eph).unwrap(); + let eph_pub = PublicKey::from_secret_key(&secp, &eph_secret); + let shared = derive_shared_secret(&eph_secret, them.scan_pubkey()); + let (output_pubkey, _) = + derive_payment_address_v2(them.spend_pubkey(), &shared, 0).unwrap(); + + let mut announce = vec![0x6a, 0x21]; + announce.extend_from_slice(&eph_pub.serialize()); + let mut p2tr = vec![0x51, 0x20]; + p2tr.extend_from_slice(&output_pubkey.serialize()[1..]); + + let tx = tx_with_scripts( + "feed", + &[ + (0, 0.0, hex::encode(&announce)), + (1, 0.5, hex::encode(&p2tr)), + ], + ); + let (eph_hex, outs) = candidate_in(&tx).expect("announcement present"); + let found = + crate::candidate_scan::scan_candidate(&us, &eph_hex, &outs, "feed", None).unwrap(); + assert!(found.is_empty(), "not ours, got {found:?}"); + } + + /// BTC→sats must round, not truncate, on both sides of the ledger. + #[test] + fn an_awkward_float_does_not_lose_a_satoshi() { + let b = block(serde_json::json!([{ + "txid": "aa", + "vin": [vin(0.000_123_449_999_999, 0xbb)], + "vout": [vout(0, 0.000_123_449_999_999, 0xaa)], + }])); + let m = &scan_block(&b, &ours_set(&[0xaa]))[0]; + assert_eq!(m.received_sats, 12_345); + } +} diff --git a/apps/wraith-wallet/core/src/candidate_scan.rs b/apps/wraith-wallet/core/src/candidate_scan.rs new file mode 100644 index 000000000..937a331af --- /dev/null +++ b/apps/wraith-wallet/core/src/candidate_scan.rs @@ -0,0 +1,331 @@ +//! Local BIP-352 silent-payment detection. +//! +//! Given one candidate transaction — an ephemeral pubkey and the taproot +//! outputs that went with it — this works out which of those outputs, if any, +//! were paid to the wallet, and at which derivation index. The keys never +//! leave the machine and no server is told what matched. +//! +//! # Where candidates come from +//! +//! Nowhere, yet. This used to be fed by pushes from the operator's GSP, which +//! filtered the chain on the wallet's behalf; that went with the rest of L2. +//! The detection itself was always local and always correct, so it is kept +//! here whole, waiting on a scanner that reads blocks from the wallet's own +//! node. +//! +//! Keeping it is the cheap half of the decision: re-deriving BIP-352 parity +//! handling from scratch later would be the expensive half. + +use ghost_keys::{GhostKeys, PaymentDetector}; + +/// Seconds since the Unix epoch. +fn now_unix_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// One taproot output of a candidate transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CandidateOutput { + /// The output's x-only pubkey, hex, 32 bytes. + pub output_pubkey: String, + pub amount_sats: Option, + pub vout: u32, +} + +/// One BIP-352 silent-payment detection. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DetectedPayment { + pub txid: String, + pub block_height: Option, + pub vout: u32, + pub amount_sats: Option, + /// Derivation index (k) used by the sender. Recorded so a future + /// "spend this output" call can re-derive the spend key. + pub k: u32, + pub received_at: i64, +} + +/// Run the local BIP-352 scanner against one candidate transaction. Returns +/// any detected payments belonging to `keys`. +pub fn scan_candidate( + keys: &GhostKeys, + ephemeral_pubkey_hex: &str, + outputs: &[CandidateOutput], + txid: &str, + block_height: Option, +) -> Result, String> { + use bitcoin::secp256k1::PublicKey; + + let eph_bytes = hex::decode(ephemeral_pubkey_hex).map_err(|e| format!("ephemeral hex: {e}"))?; + let ephemeral = + PublicKey::from_slice(&eph_bytes).map_err(|e| format!("ephemeral pubkey: {e}"))?; + + // Decode each x-only (32-byte) output pubkey. Stash the raw x-only bytes + // for later — BIP-352 output keys are taproot (x-only on chain), so we + // need to try BOTH parities (0x02 / 0x03) when feeding the scanner, + // since `PaymentDetector` compares full SEC1 byte equality and only + // one of the two parities will be the real BIP-352-derived point. + struct Decoded { + xonly: [u8; 32], + amount: Option, + vout: u32, + } + let mut decoded: Vec = Vec::with_capacity(outputs.len()); + for out in outputs { + let xonly_bytes = + hex::decode(&out.output_pubkey).map_err(|e| format!("output hex: {e}"))?; + if xonly_bytes.len() != 32 { + return Err(format!( + "output_pubkey must be 32 bytes (x-only), got {}", + xonly_bytes.len() + )); + } + let mut xonly = [0u8; 32]; + xonly.copy_from_slice(&xonly_bytes); + decoded.push(Decoded { + xonly, + amount: out.amount_sats, + vout: out.vout, + }); + } + + let detector = PaymentDetector::new(keys); + let now = now_unix_secs(); + let mut detections: Vec = Vec::new(); + + // Scan once with each parity. Dedupe matches by (vout, k) since the same + // real output can never match under both parities (each x-only key + // belongs to exactly one curve point with a defined parity). + for parity in [0x02u8, 0x03u8] { + let mut scan_inputs: Vec<(PublicKey, Option)> = Vec::with_capacity(decoded.len()); + // Which `decoded` entry each scan input came from. + // + // ⚠ These indices are NOT the same, and assuming they were is a bug + // this code shipped with. Not every 32-byte value on a taproot-shaped + // output is a valid curve x-coordinate — an ordinary output whose key + // the wallet has no reason to recognise may be no point at all — and + // each one skipped shifts every later entry down by one. The scanner + // then reports a hit at a slice index that maps to a DIFFERENT output: + // the wrong vout, the wrong amount, and a coin the wallet later cannot + // spend because the key it derived belongs elsewhere. + // + // The old comment called the skip a parse failure that "should never + // happen". It happens whenever a transaction pays anyone else. + let mut origin: Vec = Vec::with_capacity(decoded.len()); + for (i, d) in decoded.iter().enumerate() { + let mut sec1 = [0u8; 33]; + sec1[0] = parity; + sec1[1..].copy_from_slice(&d.xonly); + let pk = match PublicKey::from_slice(&sec1) { + Ok(p) => p, + Err(_) => { + // Off-curve x-only with this parity — skip this input. + continue; + } + }; + scan_inputs.push((pk, d.amount)); + origin.push(i); + } + let scanned = detector.scan_transaction(&ephemeral, &scan_inputs); + for s in scanned { + // Map the scanner's slice index back to the on-chain output it + // actually came from, through the origins recorded above. + let d = match origin + .get(s.output_index as usize) + .and_then(|i| decoded.get(*i)) + { + Some(d) => d, + None => continue, + }; + // Dedupe across parities. + if detections.iter().any(|x| x.vout == d.vout && x.k == s.k) { + continue; + } + detections.push(DetectedPayment { + txid: txid.to_string(), + block_height, + vout: d.vout, + amount_sats: s.amount, + k: s.k, + received_at: now, + }); + } + } + Ok(detections) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// End-to-end synthetic test: sender constructs a BIP-352 payment to a + /// receiver's `GhostKeys`, packages it as a `CandidateTransaction`, and + /// the wallet's `scan_candidate` detects the match. + #[test] + fn scan_candidate_detects_synthetic_match() { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use ghost_keys::{derive_payment_address_v2, derive_shared_secret}; + use rand::RngCore; + + let receiver = GhostKeys::generate(); + + // Sender's role: pick a one-shot ephemeral keypair (in real BIP-352 + // this is derived from the input set; the scanner only sees the pubkey). + let secp = Secp256k1::new(); + let mut eph_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut eph_bytes); + let eph_secret = SecretKey::from_slice(&eph_bytes).expect("nonzero scalar"); + let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); + + // Both sides compute the same shared secret via ECDH (commutativity). + // Sender side: ECDH(eph_secret, receiver.scan_pubkey). + let shared_secret = derive_shared_secret(&eph_secret, receiver.scan_pubkey()); + + // Sender derives the destination output pubkey at index k=0. + let k: u32 = 0; + let (output_pubkey, _tweak) = + derive_payment_address_v2(receiver.spend_pubkey(), &shared_secret, k) + .expect("derive output pubkey"); + + // On chain we'd see the x-only form (taproot output). + let serialized = output_pubkey.serialize(); + let xonly = &serialized[1..]; + + let candidate_outputs = vec![CandidateOutput { + output_pubkey: hex::encode(xonly), + amount_sats: Some(50_000), + vout: 7, + }]; + + let txid = "0".repeat(64); + let detections = scan_candidate( + &receiver, + &hex::encode(ephemeral_pub.serialize()), + &candidate_outputs, + &txid, + Some(123_456), + ) + .expect("scan succeeds"); + + assert_eq!(detections.len(), 1, "expected one match"); + let det = &detections[0]; + assert_eq!(det.k, k); + assert_eq!(det.amount_sats, Some(50_000)); + assert_eq!(det.vout, 7); + assert_eq!(det.block_height, Some(123_456)); + assert_eq!(det.txid, txid); + } + + /// A hit must be attributed to the output it actually came from. + /// + /// Pins the index-drift bug: an output whose 32 bytes are not a valid + /// curve x-coordinate is skipped when building the scan inputs, so the + /// scanner's slice indices stop matching the candidate list. Before the + /// fix this reported the *decoy's* vout and amount, which means a wallet + /// crediting itself with the wrong coin and deriving a spend key for an + /// output that was never its own. + #[test] + fn a_hit_is_attributed_to_the_output_it_came_from() { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use ghost_keys::{derive_payment_address_v2, derive_shared_secret}; + use rand::RngCore; + + let receiver = GhostKeys::generate(); + let secp = Secp256k1::new(); + let mut eph_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut eph_bytes); + let eph_secret = SecretKey::from_slice(&eph_bytes).unwrap(); + let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); + let shared = derive_shared_secret(&eph_secret, receiver.scan_pubkey()); + let (output_pubkey, _) = + derive_payment_address_v2(receiver.spend_pubkey(), &shared, 0).unwrap(); + let ours_xonly = hex::encode(&output_pubkey.serialize()[1..]); + + // 0xcc repeated is not a point on the curve under either parity, so + // both attempts to build a scan input from it are skipped. + let off_curve = "cc".repeat(32); + assert!( + PublicKey::from_slice(&hex::decode(format!("02{off_curve}")).unwrap()).is_err(), + "the decoy must really be off-curve, or this proves nothing" + ); + + let outputs = vec![ + CandidateOutput { + output_pubkey: off_curve, + amount_sats: Some(25_000_000), + vout: 1, + }, + CandidateOutput { + output_pubkey: ours_xonly, + amount_sats: Some(50_000_000), + vout: 2, + }, + ]; + + let found = scan_candidate( + &receiver, + &hex::encode(ephemeral_pub.serialize()), + &outputs, + "feed", + None, + ) + .unwrap(); + + assert_eq!(found.len(), 1); + assert_eq!( + found[0].vout, 2, + "the payment is at vout 2, not the decoy's 1" + ); + assert_eq!(found[0].amount_sats, Some(50_000_000)); + } + + #[test] + fn scan_candidate_returns_empty_on_no_match() { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use rand::RngCore; + + let receiver = GhostKeys::generate(); + + let secp = Secp256k1::new(); + let mut eph_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut eph_bytes); + let eph_secret = SecretKey::from_slice(&eph_bytes).unwrap(); + let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); + + // Output addressed to a DIFFERENT receiver — should not match. + let other = GhostKeys::generate(); + let mut other_eph_bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut other_eph_bytes); + let other_eph_secret = SecretKey::from_slice(&other_eph_bytes).unwrap(); + let shared_secret = + ghost_keys::derive_shared_secret(&other_eph_secret, other.scan_pubkey()); + let (output_pubkey, _tweak) = + ghost_keys::derive_payment_address_v2(other.spend_pubkey(), &shared_secret, 0).unwrap(); + let serialized = output_pubkey.serialize(); + let xonly = &serialized[1..]; + + let candidate_outputs = vec![CandidateOutput { + output_pubkey: hex::encode(xonly), + amount_sats: Some(1_000), + vout: 0, + }]; + + let detections = scan_candidate( + &receiver, + &hex::encode(ephemeral_pub.serialize()), + &candidate_outputs, + "deadbeef", + None, + ) + .expect("scan succeeds"); + + assert!( + detections.is_empty(), + "no match expected, got {:?}", + detections + ); + } +} diff --git a/apps/wraith-wallet/core/src/chain/ghost_pay.rs b/apps/wraith-wallet/core/src/chain/ghost_pay.rs deleted file mode 100644 index a9a4fe773..000000000 --- a/apps/wraith-wallet/core/src/chain/ghost_pay.rs +++ /dev/null @@ -1,533 +0,0 @@ -//! Ghost-pay REST client. - -use async_trait::async_trait; -use reqwest::Client; -use serde::Deserialize; - -use super::{ChainClient, ChainError, ChainStatus}; - -/// REST client for ghost-pay. Holds one or more base URLs and tries them in -/// order on each request — a failure on the first URL automatically falls -/// over to the next. -pub struct GhostPayClient { - base_urls: Vec, - http: Client, - /// Optional shared secret used for the `X-Internal-Auth` bypass - /// on ghost-pay's authenticated routes. When set, every request - /// sends this header and ghost-pay accepts the call without - /// HMAC. The wallet uses this for endpoints behind - /// `authenticated_routes` (e.g. `/api/v1/utxos/scan`). When - /// None, only public routes are reachable. - internal_secret: Option, -} - -impl GhostPayClient { - /// Construct from a single base URL. - pub fn new(base_url: impl Into) -> Self { - Self::with_urls(vec![base_url.into()]) - } - - /// Construct from a list of base URLs. They will be tried in order on - /// each request until one succeeds. - pub fn with_urls(base_urls: Vec) -> Self { - Self::with_urls_and_proxy(base_urls, None).expect("default reqwest client always builds") - } - - /// Same as `with_urls` but routes every request through a SOCKS5 proxy - /// (e.g. `socks5h://127.0.0.1:9050` for Tor). Pass `None` for direct - /// connections. - /// - /// `socks5h://` (note the `h`) does DNS through the proxy — preferred - /// for Tor so hostnames don't leak to your local resolver. - pub fn with_urls_and_proxy( - base_urls: Vec, - proxy_url: Option<&str>, - ) -> Result { - let urls = if base_urls.is_empty() { - vec!["http://127.0.0.1:8800".to_string()] - } else { - base_urls - }; - // Bounded timeouts on every request: - // * connect_timeout = 5 s — TCP handshake to a non-routable IP - // would otherwise hang on the OS-default socket timeout - // (60+ s on Linux). 5 s comfortably covers any real LAN / - // internet round-trip. - // * timeout = 15 s — overall request budget once connected, - // matches the daemon's other reqwest clients. - let mut builder = Client::builder() - .connect_timeout(std::time::Duration::from_secs(5)) - .timeout(std::time::Duration::from_secs(15)); - if let Some(p) = proxy_url { - let proxy = - reqwest::Proxy::all(p).map_err(|e| ChainError::Transport(format!("proxy: {e}")))?; - builder = builder.proxy(proxy); - } - let http = builder - .build() - .map_err(|e| ChainError::Transport(format!("http client: {e}")))?; - Ok(Self { - base_urls: urls, - http, - internal_secret: None, - }) - } - - /// Attach an `X-Internal-Auth` shared secret. After this, calls - /// to ghost-pay's authenticated routes will bypass HMAC and use - /// the bearer header. Without it, those routes return 401. - pub fn with_internal_secret(mut self, secret: impl Into) -> Self { - self.internal_secret = Some(secret.into()); - self - } - - /// Parse a comma-separated URL list, trimming whitespace and dropping - /// empty entries. - pub fn parse_urls(s: &str) -> Vec { - s.split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() - } - - fn endpoint(&self, base_url: &str, path: &str) -> String { - format!("{}{}", base_url.trim_end_matches('/'), path) - } -} - -#[async_trait] -impl ChainClient for GhostPayClient { - async fn status(&self) -> Result { - let mut last_err: Option = None; - for base in &self.base_urls { - match self.try_status(base).await { - Ok(s) => return Ok(s), - Err(e) => { - tracing::debug!(url = %base, error = %e, "ghost-pay endpoint failed, trying next"); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| ChainError::Transport("no endpoints configured".into()))) - } - - async fn scan_utxos( - &self, - addresses: &[String], - min_confirmations: u32, - ) -> Result { - // Concrete impl is on the inherent block; trait method just - // forwards. Splitting the two means the inherent method is - // still discoverable by callers that hold a concrete - // `GhostPayClient` and want full type info. - GhostPayClient::scan_utxos(self, addresses, min_confirmations).await - } - - async fn broadcast_tx(&self, tx_hex: &str) -> Result { - GhostPayClient::broadcast_tx(self, tx_hex).await - } -} - -/// One UTXO row from `POST /api/v1/utxos/scan`. Mirrors ghost-pay's -/// `ScannedUtxo` shape; kept separate so the wallet doesn't depend on -/// ghost-pay internals. -#[derive(Debug, Clone, Deserialize)] -pub struct ScannedL1Utxo { - pub txid: String, - pub vout: u32, - pub amount_sats: u64, - pub scriptpubkey_hex: String, - pub address: Option, - pub confirmations: u32, - pub height: u32, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ScanUtxosResponse { - pub utxos: Vec, - pub total_sats: u64, - pub chain_height: u32, -} - -impl GhostPayClient { - /// Scan ghost-pay's bitcoind UTXO set for outputs matching any of - /// `addresses`. Authenticated route — fails with `Backend(...)` - /// 401 unless `with_internal_secret(...)` was set on the client. - /// - /// The call is bounded by ghost-pay (max 1024 addresses per - /// request) but is still expensive: bitcoind walks the full - /// chain UTXO set on each invocation. Mainnet round-trips in - /// 5-15 s; signet/regtest under 1 s. Caller should chunk and - /// surface progress accordingly. - pub async fn scan_utxos( - &self, - addresses: &[String], - min_confirmations: u32, - ) -> Result { - let mut last_err: Option = None; - for base in &self.base_urls { - match self - .try_scan_utxos(base, addresses, min_confirmations) - .await - { - Ok(r) => return Ok(r), - Err(e) => { - tracing::debug!(url = %base, error = %e, "ghost-pay scan_utxos failed, trying next"); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| ChainError::Transport("no endpoints configured".into()))) - } - - /// Broadcast a fully-signed Bitcoin transaction via ghost-pay's - /// `POST /api/v1/tx/broadcast`. Authenticated; fails with - /// `Backend(...)` 401 unless `with_internal_secret(...)` was set. - /// On success, returns the txid that bitcoind accepted. - pub async fn broadcast_tx(&self, tx_hex: &str) -> Result { - let mut last_err: Option = None; - for base in &self.base_urls { - match self.try_broadcast_tx(base, tx_hex).await { - Ok(txid) => return Ok(txid), - Err(e) => { - tracing::debug!( - url = %base, - error = %e, - "ghost-pay broadcast_tx failed, trying next" - ); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| ChainError::Transport("no endpoints configured".into()))) - } - - /// `GET /api/v1/pool/coordinator` — the node's decentralised-coordinator - /// election view, relayed by ghost-pay (the wallet never hits the node's pool - /// API directly; wallet hard rule). Returns the raw election JSON; the daemon - /// resolves which seat owns a given (tier, epoch) shard from it. ghost-pay - /// returns the inert `{"enabled": false}` shape when the node has the feature - /// off or is unreachable, so the wallet falls back to a manual coordinator URL. - pub async fn coordinator_election(&self) -> Result { - let mut last_err: Option = None; - for base in &self.base_urls { - match self.try_coordinator_election(base).await { - Ok(v) => return Ok(v), - Err(e) => { - tracing::debug!(url = %base, error = %e, "ghost-pay coordinator_election failed, trying next"); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| ChainError::Transport("no endpoints configured".into()))) - } - - async fn try_coordinator_election( - &self, - base_url: &str, - ) -> Result { - let url = self.endpoint(base_url, "/api/v1/pool/coordinator"); - let resp = self - .http - .get(&url) - .send() - .await - .map_err(|e| ChainError::Transport(e.to_string()))? - .error_for_status() - .map_err(|e| ChainError::Backend(e.to_string()))?; - resp.json::() - .await - .map_err(|e| ChainError::Malformed(e.to_string())) - } - - async fn try_broadcast_tx(&self, base_url: &str, tx_hex: &str) -> Result { - let url = self.endpoint(base_url, "/api/v1/tx/broadcast"); - let mut req = self.http.post(&url).json(&serde_json::json!({ - "tx_hex": tx_hex, - })); - if let Some(secret) = &self.internal_secret { - req = req.header("X-Internal-Auth", secret); - } - let resp = req - .send() - .await - .map_err(|e| ChainError::Transport(e.to_string()))?; - let status = resp.status(); - if !status.is_success() { - // ghost-pay forwards bitcoind's error string verbatim, - // so the caller can show the operator's node's actual - // rejection reason ("min relay fee not met", etc.). - let detail = resp.text().await.unwrap_or_default(); - return Err(ChainError::Backend(format!( - "ghost-pay broadcast returned {}: {}", - status, detail - ))); - } - #[derive(Deserialize)] - struct BroadcastResp { - txid: String, - } - let body: BroadcastResp = resp - .json() - .await - .map_err(|e| ChainError::Malformed(e.to_string()))?; - Ok(body.txid) - } - - async fn try_scan_utxos( - &self, - base_url: &str, - addresses: &[String], - min_confirmations: u32, - ) -> Result { - let url = self.endpoint(base_url, "/api/v1/utxos/scan"); - let mut req = self.http.post(&url).json(&serde_json::json!({ - "addresses": addresses, - "min_confirmations": min_confirmations, - })); - if let Some(secret) = &self.internal_secret { - req = req.header("X-Internal-Auth", secret); - } - let resp = req - .send() - .await - .map_err(|e| ChainError::Transport(e.to_string()))?; - let status = resp.status(); - if !status.is_success() { - let detail = resp.text().await.unwrap_or_default(); - return Err(ChainError::Backend(format!( - "ghost-pay returned {}: {}", - status, detail - ))); - } - resp.json::() - .await - .map_err(|e| ChainError::Malformed(e.to_string())) - } - - /// Fetch the registered Ghost Glyph for `ghost_id` via - /// `GET /api/v1/glyph/{ghost_id}`. Public route. Returns the raw - /// `GlyphInfoResponse` JSON; the daemon narrows it into a typed - /// struct. A 404 (no glyph yet) surfaces as `ChainError::Backend`. - pub async fn get_glyph(&self, ghost_id: &str) -> Result { - let mut last_err: Option = None; - for base in &self.base_urls { - match self.try_get_glyph(base, ghost_id).await { - Ok(v) => return Ok(v), - Err(e) => { - tracing::debug!(url = %base, error = %e, "ghost-pay get_glyph failed, trying next"); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| ChainError::Transport("no endpoints configured".into()))) - } - - /// Check whether a glyph bitmap is unclaimed via - /// `GET /api/v1/glyph/check/{bitmap_hash_hex}`. Public route. - /// Returns the raw `{ "available": bool }` JSON. - pub async fn check_glyph( - &self, - bitmap_hash_hex: &str, - ) -> Result { - let mut last_err: Option = None; - for base in &self.base_urls { - match self.try_check_glyph(base, bitmap_hash_hex).await { - Ok(v) => return Ok(v), - Err(e) => { - tracing::debug!(url = %base, error = %e, "ghost-pay check_glyph failed, trying next"); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| ChainError::Transport("no endpoints configured".into()))) - } - - /// Claim a glyph bitmap for `ghost_id` via - /// `POST /api/v1/glyph/claim`. Authenticated route — attaches the - /// `X-Internal-Auth` header when `with_internal_secret(...)` was - /// set, otherwise ghost-pay returns 401. Returns the raw - /// `GlyphClaimResponse` JSON. - pub async fn claim_glyph( - &self, - ghost_id: &str, - pixels: &[u8], - ) -> Result { - let mut last_err: Option = None; - for base in &self.base_urls { - match self.try_claim_glyph(base, ghost_id, pixels).await { - Ok(v) => return Ok(v), - Err(e) => { - tracing::debug!(url = %base, error = %e, "ghost-pay claim_glyph failed, trying next"); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| ChainError::Transport("no endpoints configured".into()))) - } - - async fn try_get_glyph( - &self, - base_url: &str, - ghost_id: &str, - ) -> Result { - let url = self.endpoint(base_url, &format!("/api/v1/glyph/{ghost_id}")); - let resp = self - .http - .get(&url) - .send() - .await - .map_err(|e| ChainError::Transport(e.to_string()))?; - let status = resp.status(); - if !status.is_success() { - let detail = resp.text().await.unwrap_or_default(); - return Err(ChainError::Backend(format!( - "ghost-pay glyph returned {}: {}", - status, detail - ))); - } - resp.json::() - .await - .map_err(|e| ChainError::Malformed(e.to_string())) - } - - async fn try_check_glyph( - &self, - base_url: &str, - bitmap_hash_hex: &str, - ) -> Result { - let url = self.endpoint(base_url, &format!("/api/v1/glyph/check/{bitmap_hash_hex}")); - let resp = self - .http - .get(&url) - .send() - .await - .map_err(|e| ChainError::Transport(e.to_string()))?; - let status = resp.status(); - if !status.is_success() { - let detail = resp.text().await.unwrap_or_default(); - return Err(ChainError::Backend(format!( - "ghost-pay glyph check returned {}: {}", - status, detail - ))); - } - resp.json::() - .await - .map_err(|e| ChainError::Malformed(e.to_string())) - } - - async fn try_claim_glyph( - &self, - base_url: &str, - ghost_id: &str, - pixels: &[u8], - ) -> Result { - let url = self.endpoint(base_url, "/api/v1/glyph/claim"); - let mut req = self.http.post(&url).json(&serde_json::json!({ - "ghost_id": ghost_id, - "pixels": pixels, - })); - if let Some(secret) = &self.internal_secret { - req = req.header("X-Internal-Auth", secret); - } - let resp = req - .send() - .await - .map_err(|e| ChainError::Transport(e.to_string()))?; - let status = resp.status(); - if !status.is_success() { - let detail = resp.text().await.unwrap_or_default(); - return Err(ChainError::Backend(format!( - "ghost-pay glyph claim returned {}: {}", - status, detail - ))); - } - resp.json::() - .await - .map_err(|e| ChainError::Malformed(e.to_string())) - } - - async fn try_status(&self, base_url: &str) -> Result { - let url = self.endpoint(base_url, "/api/v1/status"); - let resp = self - .http - .get(&url) - .send() - .await - .map_err(|e| ChainError::Transport(e.to_string()))? - .error_for_status() - .map_err(|e| ChainError::Backend(e.to_string()))?; - let body: StatusBody = resp - .json() - .await - .map_err(|e| ChainError::Malformed(e.to_string()))?; - Ok(ChainStatus { - backend_version: body.version, - network: body.network, - has_keys: body.has_keys, - lock_count: body.lock_count, - active_sessions: body.active_sessions, - chain_height: body.chain_height, - chain_headers: body.chain_headers, - chain_verification_progress: body.chain_verification_progress, - chain_initial_block_download: body.chain_initial_block_download, - l2_height: body.l2_height, - l2_epoch: body.l2_epoch, - }) - } -} - -#[derive(Deserialize)] -struct StatusBody { - version: String, - has_keys: bool, - lock_count: u64, - #[serde(default)] - active_sessions: u64, - network: String, - #[serde(default)] - chain_height: Option, - #[serde(default)] - chain_headers: Option, - #[serde(default)] - chain_verification_progress: Option, - #[serde(default)] - chain_initial_block_download: Option, - #[serde(default)] - l2_height: Option, - #[serde(default)] - l2_epoch: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_urls_strips_and_drops_empties() { - assert_eq!( - GhostPayClient::parse_urls("http://a, http://b ,, http://c"), - vec!["http://a", "http://b", "http://c"] - ); - assert!(GhostPayClient::parse_urls("").is_empty()); - assert!(GhostPayClient::parse_urls(" , , ").is_empty()); - } - - #[test] - fn parses_ghost_pay_status_body() { - let json = r#"{ - "version": "1.8.0", - "has_keys": true, - "lock_count": 3, - "active_sessions": 0, - "network": "signet" - }"#; - let body: StatusBody = serde_json::from_str(json).unwrap(); - assert_eq!(body.version, "1.8.0"); - assert_eq!(body.network, "signet"); - assert!(body.has_keys); - assert_eq!(body.lock_count, 3); - assert_eq!(body.active_sessions, 0); - } -} diff --git a/apps/wraith-wallet/core/src/chain/ghostd_chain.rs b/apps/wraith-wallet/core/src/chain/ghostd_chain.rs new file mode 100644 index 000000000..08e58f903 --- /dev/null +++ b/apps/wraith-wallet/core/src/chain/ghostd_chain.rs @@ -0,0 +1,181 @@ +//! A [`ChainClient`] that talks to the owner's own node. +//! +//! # Why this exists +//! +//! Until now the wallet's only chain backend was ghost-pay, so every balance +//! and every broadcast went through an operator. That is the arrangement the +//! Ghost Pay L2 is being removed to end: self-custody on ordinary Bitcoin +//! infrastructure, payments through Wraith with Ghost Locks. +//! +//! `ghostd.rs` already made this argument for one path — the unilateral exit, +//! where "no operator cooperation" is the entire point and routing through +//! ghost-pay would defeat it. The same reasoning applies to the rest of the +//! wallet; this generalises it. +//! +//! # Sync client, async trait +//! +//! [`GhostdRpc`] is deliberately synchronous — `reqwest::blocking` panics on +//! drop inside a tokio runtime, so it uses `ureq`. The calls therefore run on +//! `spawn_blocking`: `scantxoutset` walks the whole UTXO set and would +//! otherwise stall the reactor for as long as that takes. +//! +//! # What it deliberately does not report +//! +//! `l2_height` and `l2_epoch` are always `None`. A node has no L2 and inventing +//! a number would be worse than an absent one — the field means "the operator's +//! ledger is here", and with no operator there is no such place. + +use std::sync::Arc; + +use crate::chain::{ChainClient, ChainError, ChainStatus, ScanUtxosResponse, ScannedL1Utxo}; +use crate::ghostd::GhostdRpc; + +/// Chain access straight to the owner's node. +pub struct GhostdChainClient { + rpc: Arc, + network: String, +} + +impl GhostdChainClient { + /// Wrap an RPC connection. `network` is reported by [`ChainClient::status`]. + pub fn new(rpc: GhostdRpc, network: impl Into) -> Self { + Self { + rpc: Arc::new(rpc), + network: network.into(), + } + } + + /// Run a blocking RPC off the reactor. + async fn blocking(&self, f: F) -> Result + where + T: Send + 'static, + F: FnOnce(&GhostdRpc) -> Result + Send + 'static, + { + let rpc = Arc::clone(&self.rpc); + tokio::task::spawn_blocking(move || f(&rpc)) + .await + .map_err(|e| ChainError::Backend(format!("node call panicked: {e}")))? + .map_err(|e| ChainError::Backend(e.to_string())) + } +} + +#[async_trait::async_trait] +impl ChainClient for GhostdChainClient { + async fn status(&self) -> Result { + let height = self.blocking(|rpc| rpc.get_block_count()).await?; + Ok(ChainStatus { + backend_version: "ghostd".into(), + network: self.network.clone(), + chain_height: Some(height), + // `getblockcount` alone cannot distinguish "at tip" from "still + // syncing". Reporting the height as though it were both would + // claim a sync state this call never established. + chain_headers: None, + chain_verification_progress: None, + chain_initial_block_download: None, + }) + } + + async fn scan_utxos( + &self, + addresses: &[String], + min_confirmations: u32, + ) -> Result { + if addresses.is_empty() { + return Ok(ScanUtxosResponse { + utxos: Vec::new(), + total_sats: 0, + chain_height: 0, + }); + } + let addrs = addresses.to_vec(); + let (rows, height) = self + .blocking(move |rpc| rpc.scan_tx_out_set(&addrs)) + .await?; + + let mut utxos = Vec::with_capacity(rows.len()); + let mut total_sats = 0u64; + for r in rows { + // BTC → sats. bitcoind reports a JSON number; rounding rather than + // truncating, because 0.00012345 arriving as 0.000123449999 must + // not lose a satoshi. + let sats = (r.amount * 100_000_000.0).round() as u64; + let confirmations = height.saturating_sub(r.height).saturating_add(1) as u32; + if confirmations < min_confirmations { + continue; + } + total_sats = total_sats.saturating_add(sats); + utxos.push(ScannedL1Utxo { + txid: r.txid, + vout: r.vout, + amount_sats: sats, + scriptpubkey_hex: r.script_pub_key, + // `scantxoutset` returns scripts, not addresses. The caller + // matches on the script it asked for; guessing an address form + // here would be inventing a field bitcoind did not send. + address: None, + confirmations, + height: r.height as u32, + }); + } + Ok(ScanUtxosResponse { + utxos, + total_sats, + chain_height: height as u32, + }) + } + + async fn broadcast_tx(&self, tx_hex: &str) -> Result { + let hex = tx_hex.to_string(); + self.blocking(move |rpc| rpc.send_raw_transaction(&hex)) + .await + } + + /// Ask the node how deep a transaction is. + /// + /// An RPC failure here comes back as `Ok(None)`, not `Err`. Without + /// `txindex` a node genuinely cannot answer for a confirmed transaction it + /// does not hold, and that is a limit of the node rather than a fault in + /// the wallet — failing the whole history because one row is unanswerable + /// would hide the rows that were answerable. The caller renders `None` as + /// "unknown", never as "unconfirmed". + async fn tx_confirmations(&self, txid: &str) -> Result, ChainError> { + let id = txid.to_string(); + match self + .blocking(move |rpc| rpc.get_raw_transaction_verbose(&id)) + .await + { + // A transaction the node holds but has not mined reports no + // `confirmations` field at all; that is a definite zero. + Ok(tx) => Ok(Some(tx.confirmations.unwrap_or(0))), + Err(_) => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + + /// Confirmations are inclusive of the block the output landed in. + /// + /// An output in the tip block has one confirmation, not zero — the + /// off-by-one here decides whether a freshly confirmed coin is spendable + /// or invisible. + #[test] + fn an_output_in_the_tip_block_has_one_confirmation() { + let height = 900_000u64; + let at_tip = height.saturating_sub(900_000).saturating_add(1); + assert_eq!(at_tip, 1); + let ten_deep = height.saturating_sub(899_991).saturating_add(1); + assert_eq!(ten_deep, 10); + } + + /// BTC→sats must round, not truncate. + #[test] + fn a_float_amount_does_not_lose_a_satoshi() { + let awkward = 0.000_123_449_999_999_f64; + assert_eq!((awkward * 100_000_000.0).round() as u64, 12_345); + // Truncation would have lost one. + assert_eq!((awkward * 100_000_000.0) as u64, 12_344); + } +} diff --git a/apps/wraith-wallet/core/src/chain/mod.rs b/apps/wraith-wallet/core/src/chain/mod.rs index 9e5ab949c..2e65a1938 100644 --- a/apps/wraith-wallet/core/src/chain/mod.rs +++ b/apps/wraith-wallet/core/src/chain/mod.rs @@ -1,23 +1,41 @@ -//! Chain client — talks to the wallet's configured ghost-pay backend. +//! Chain client — how the wallet reads and writes the chain. //! -//! Phase 1: a single REST client over HTTPS. Transport layer (clearnet vs. Tor) and -//! GSP WebSocket subscriptions land in subsequent commits. - -mod ghost_pay; +//! One implementation: the owner's own node, over `ghostd`'s RPC. The +//! operator-hosted backend it used to share this trait with went with the rest +//! of L2 — a self-custody wallet that can reach a node has no business asking +//! somebody else where its money is. use async_trait::async_trait; -pub use ghost_pay::{GhostPayClient, ScanUtxosResponse, ScannedL1Utxo}; +pub mod ghostd_chain; +pub use ghostd_chain::GhostdChainClient; + +/// One unspent output found by a scan. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ScannedL1Utxo { + pub txid: String, + pub vout: u32, + pub amount_sats: u64, + pub scriptpubkey_hex: String, + pub address: Option, + pub confirmations: u32, + pub height: u32, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ScanUtxosResponse { + pub utxos: Vec, + pub total_sats: u64, + /// The tip the scan was taken against. Confirmations elsewhere in the + /// response are relative to this, not to whatever the tip is now. + pub chain_height: u32, +} #[derive(Debug, Clone, PartialEq)] pub struct ChainStatus { pub backend_version: String, pub network: String, - pub has_keys: bool, - pub lock_count: u64, - pub active_sessions: u64, - /// Latest verified-block height from the operator's bitcoind. - /// `None` when ghost-pay couldn't reach bitcoind in time. + /// Latest verified-block height the node reports. pub chain_height: Option, /// Highest header bitcoind has seen — equals `chain_height` /// when synced, exceeds it during initial block download. @@ -27,10 +45,6 @@ pub struct ChainStatus { /// Bitcoin Core's IBD flag — true while still syncing the /// initial chain history. Once false, the node is at tip. pub chain_initial_block_download: Option, - /// L2 chain tip — latest finalized ghost-pay block height. - pub l2_height: Option, - /// Current L2 epoch (`l2_height / L2_EPOCH_BLOCKS`). - pub l2_epoch: Option, } #[derive(Debug, thiserror::Error)] @@ -70,4 +84,55 @@ pub trait ChainClient: Send + Sync { "this chain client does not support broadcast".into(), )) } + + /// How deeply a transaction is buried, if the backend can say. + /// + /// `Ok(None)` means "this backend cannot tell you" — a node without + /// `txindex` cannot look up an arbitrary txid once it has left the + /// mempool. That is deliberately distinct from `Ok(Some(0))`, which is + /// the definite answer "seen, and not yet in a block". A history that + /// prints 0 for both would show every settled payment as pending. + async fn tx_confirmations(&self, _txid: &str) -> Result, ChainError> { + Ok(None) + } +} + +/// The chain client for a wallet with no node configured. +/// +/// Every call fails, with the same sentence saying what to do about it. This +/// exists so that state is impossible to mistake for a working wallet: the +/// alternative — quietly routing to somebody else's node — is how a +/// self-custody wallet ends up asking a stranger what it owns. +#[derive(Debug, Default)] +pub struct NoChain; + +impl NoChain { + fn refuse() -> Result { + Err(ChainError::Backend( + "no node configured — the wallet reads and writes the chain through \ + your own ghostd. Set it in Settings, or with WRAITHD_GHOSTD_URL \ + (plus WRAITHD_GHOSTD_COOKIE, or _USER and _PASS)." + .into(), + )) + } +} + +#[async_trait] +impl ChainClient for NoChain { + async fn status(&self) -> Result { + Self::refuse() + } + async fn scan_utxos( + &self, + _addresses: &[String], + _min_confirmations: u32, + ) -> Result { + Self::refuse() + } + async fn broadcast_tx(&self, _tx_hex: &str) -> Result { + Self::refuse() + } + // `tx_confirmations` keeps the trait default: "cannot say" is already the + // honest answer here, and it lets a history render with unknown depth + // instead of failing outright. } diff --git a/apps/wraith-wallet/core/src/detection_store.rs b/apps/wraith-wallet/core/src/detection_store.rs new file mode 100644 index 000000000..92879e183 --- /dev/null +++ b/apps/wraith-wallet/core/src/detection_store.rs @@ -0,0 +1,196 @@ +//! Silent payments the wallet has found. +//! +//! # Why these are not just history entries +//! +//! A silent payment does not land on an address the wallet derived. It lands +//! on a key built from the sender's ephemeral key and the receiver's Ghost ID, +//! at a derivation index `k` only the scan can recover. Lose `k` and the coin +//! is still yours in principle and unspendable in practice — the key that +//! opens it cannot be re-derived without re-scanning the block it arrived in. +//! +//! So the detection is kept, not merely reported. The history entry beside it +//! says money arrived; this says which coin, and how to get at it. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::candidate_scan::DetectedPayment; + +/// A JSON file of [`DetectedPayment`], keyed by outpoint. +#[derive(Debug)] +pub struct DetectionStore { + path: PathBuf, + entries: BTreeMap, +} + +fn key_for(d: &DetectedPayment) -> String { + format!("{}:{}", d.txid, d.vout) +} + +impl DetectionStore { + /// Open (or create) the detections at `path`. + /// + /// A malformed file is an error, not an empty store. These are spending + /// keys in all but name; starting fresh would report "no silent payments" + /// about coins that exist, and the wallet would have no way to know it had + /// forgotten them. + pub fn open(path: impl AsRef) -> std::io::Result { + let path = path.as_ref().to_path_buf(); + let entries = if path.exists() { + let raw = fs::read_to_string(&path)?; + let rows: Vec = serde_json::from_str(&raw).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "detections at {} are unreadable ({e}); refusing to continue with an \ + empty set, which would hide coins the wallet cannot otherwise find", + path.display() + ), + ) + })?; + rows.into_iter().map(|r| (key_for(&r), r)).collect() + } else { + BTreeMap::new() + }; + Ok(Self { path, entries }) + } + + /// Every detection, newest first. + pub fn list(&self) -> Vec { + let mut all: Vec = self.entries.values().cloned().collect(); + all.sort_by(|a, b| { + b.block_height + .cmp(&a.block_height) + .then_with(|| a.txid.cmp(&b.txid)) + .then_with(|| a.vout.cmp(&b.vout)) + }); + all + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Record detections, keyed by outpoint. + /// + /// Re-scanning a block — after a reorg, or a restart mid-catch-up — finds + /// the same coins again. One outpoint is one coin, so this is idempotent + /// rather than accumulating duplicates that would each read as money. + /// + /// Returns how many were new. + pub fn record_all(&mut self, found: Vec) -> std::io::Result { + if found.is_empty() { + return Ok(0); + } + let snapshot = self.entries.clone(); + let mut added = 0; + for d in found { + if self.entries.insert(key_for(&d), d).is_none() { + added += 1; + } + } + if added == 0 && self.entries == snapshot { + return Ok(0); + } + if let Err(e) = self.flush() { + self.entries = snapshot; + return Err(e); + } + Ok(added) + } + + fn flush(&self) -> std::io::Result<()> { + let rows: Vec<&DetectedPayment> = self.entries.values().collect(); + let body = serde_json::to_vec_pretty(&rows).map_err(std::io::Error::other)?; + ghost_lock::atomic_file::write_atomic(&self.path, &body, Some(0o600)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn found(txid: &str, vout: u32, k: u32, height: Option) -> DetectedPayment { + DetectedPayment { + txid: txid.into(), + block_height: height, + vout, + amount_sats: Some(50_000), + k, + received_at: 1_700_000_000, + } + } + + #[test] + fn a_detection_survives_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("detections.json"); + { + let mut d = DetectionStore::open(&path).unwrap(); + assert_eq!( + d.record_all(vec![found("aa", 2, 0, Some(900_000))]) + .unwrap(), + 1 + ); + } + let d = DetectionStore::open(&path).unwrap(); + assert_eq!(d.len(), 1); + assert_eq!(d.list()[0].k, 0, "the index that makes it spendable"); + assert_eq!(d.list()[0].vout, 2); + } + + /// Rescanning a block after a reorg or a restart finds the same coins. + /// One outpoint is one coin. + #[test] + fn rescanning_does_not_duplicate_a_coin() { + let dir = tempfile::tempdir().unwrap(); + let mut d = DetectionStore::open(dir.path().join("d.json")).unwrap(); + d.record_all(vec![found("aa", 2, 0, Some(900_000))]) + .unwrap(); + let added = d + .record_all(vec![found("aa", 2, 0, Some(900_000))]) + .unwrap(); + assert_eq!(added, 0, "already known"); + assert_eq!(d.len(), 1); + } + + /// Two outputs of one transaction are two coins, not one seen twice. + #[test] + fn two_outputs_of_one_transaction_are_two_coins() { + let dir = tempfile::tempdir().unwrap(); + let mut d = DetectionStore::open(dir.path().join("d.json")).unwrap(); + d.record_all(vec![ + found("aa", 1, 0, Some(900_000)), + found("aa", 2, 1, Some(900_000)), + ]) + .unwrap(); + assert_eq!(d.len(), 2); + } + + /// Losing these silently would hide coins nothing else can find. + #[test] + fn a_corrupt_file_is_refused_not_emptied() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("d.json"); + fs::write(&path, "{ not json").unwrap(); + let err = DetectionStore::open(&path).expect_err("must refuse"); + assert!(format!("{err}").contains("hide coins"), "got: {err}"); + } + + #[cfg(unix)] + #[test] + fn the_detections_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("d.json"); + let mut d = DetectionStore::open(&path).unwrap(); + d.record_all(vec![found("aa", 0, 0, None)]).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/apps/wraith-wallet/core/src/ghost_lock_account.rs b/apps/wraith-wallet/core/src/ghost_lock_account.rs new file mode 100644 index 000000000..dae7d5e4f --- /dev/null +++ b/apps/wraith-wallet/core/src/ghost_lock_account.rs @@ -0,0 +1,541 @@ +//! A Ghost Lock as the wallet holds it: four lanes and one balance. +//! +//! `crates/ghost-lock` builds the Taproot output for a single lane. This turns +//! four of them into the thing a person has — one account, four compartments, +//! and a total. +//! +//! # Not `ghost-locks` +//! +//! `ghost-locks` (plural) is Ghost Pay's P2WSH lock. It used to back `LockEntry` +//! and the wallet's Locks screen; Phase 0 demolition removed that, and the wallet +//! no longer depends on the crate at all. The crate itself stays in the workspace +//! because `bins/ghost-pay` — a shipped fleet binary — still builds on it. +//! +//! The demolition was safe to do because nobody ever created one of the old +//! locks: the `ghost_locks` table held zero rows on every fleet node, checked +//! against a control query that saw 22,562 shares in the same database. There +//! was nothing to migrate and nothing to strand. +//! +//! # The lanes are not interchangeable, and the wallet must not pretend they are +//! +//! Each lane makes a different promise, and the difference is the point of +//! having four: +//! +//! | Lane | Normal spend | If the quorum goes silent | Private? | +//! |---|---|---|---| +//! | Savings | you + backup | you alone, after ~14 months | yes | +//! | Spending | you + quorum | you alone, after ~7 days | yes | +//! | Cash | you alone | n/a — no quorum involved | **no** | +//! | Investments | **quorum alone** | you recall, after ~14 days | yes | +//! +//! **Investments is the exception and the wallet has to say so.** It is the one +//! lane where the quorum can move funds without the owner — that is what lets an +//! LP supply liquidity on demand while the owner is offline, and it is a +//! genuinely different risk from the other three. A balance screen that shows +//! four numbers in the same weight misrepresents it. + +use bitcoin::secp256k1::{Secp256k1, Verification}; +use bitcoin::{Network, XOnlyPublicKey}; + +use ghost_lock::{CashPolicy, InvestmentsPolicy, Lane, LockError, SavingsPolicy, SpendingPolicy}; + +/// Which compartment. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LaneKind { + Savings, + Spending, + Cash, + Investments, +} + +impl LaneKind { + /// Every lane, in the order a person reads them: coldest first. + pub const ALL: [LaneKind; 4] = [ + LaneKind::Savings, + LaneKind::Spending, + LaneKind::Cash, + LaneKind::Investments, + ]; + + /// The name a user sees. + pub fn label(self) -> &'static str { + match self { + LaneKind::Savings => "Savings", + LaneKind::Spending => "Spending", + LaneKind::Cash => "Cash", + LaneKind::Investments => "Investments", + } + } + + /// Whether the quorum can move this lane's funds **without** the owner. + /// + /// True only for Investments. A UI that does not surface this shows a + /// custodial balance beside three non-custodial ones and lets the reader + /// assume they are alike. + pub fn quorum_can_spend_alone(self) -> bool { + matches!(self, LaneKind::Investments) + } + + /// Which compartment this lane's coins belong to. + /// + /// The single place the lane-to-compartment mapping lives. Everything that + /// needs a compartment rule goes through here rather than re-matching on + /// `LaneKind`, so adding a lane cannot leave one rule behind. + pub fn compartment(self) -> ghost_lock::Compartment { + match self { + LaneKind::Cash => ghost_lock::Compartment::Cash, + LaneKind::Savings | LaneKind::Spending | LaneKind::Investments => { + ghost_lock::Compartment::Private + } + } + } + + /// Whether coins here can enter a Wraith round as an INPUT. + /// + /// False for Cash: it is already public, so mixing it gains nothing and + /// re-links whatever it is mixed with. Derived from + /// `ghost_lock::check_round_eligible` rather than restated, so the rule has + /// one definition and a UI reading this flag cannot drift from the + /// enforcement. + pub fn round_eligible(self) -> bool { + ghost_lock::check_round_eligible(self.compartment()).is_ok() + } + + /// Whether a Wraith round may pay OUT to this lane — i.e. whether the lane + /// can be funded privately. + /// + /// A different rule from [`Self::round_eligible`], refusing Cash for a + /// different reason. See `ghost_lock::check_round_destination`. + pub fn round_destination_eligible(self) -> bool { + ghost_lock::check_round_destination(self.compartment()).is_ok() + } +} + +/// The keys a Lock is built from. +#[derive(Debug, Clone, Copy)] +pub struct LockKeys { + /// Owner's key. + pub owner: XOnlyPublicKey, + /// Backup device's key. + pub backup: XOnlyPublicKey, + /// Heir's key, for the inheritance leaf. + pub heir: XOnlyPublicKey, + /// MuSig2 aggregate of owner and backup, for the Savings key path. + pub owner_backup_aggregate: XOnlyPublicKey, + /// MuSig2 aggregate of owner and quorum, for the Spending key path. + pub owner_quorum_aggregate: XOnlyPublicKey, + /// The Wraith quorum's key. + pub quorum: XOnlyPublicKey, +} + +/// One built lane: its address and what it promises. +#[derive(Debug, Clone)] +pub struct BuiltLane { + pub kind: LaneKind, + pub lane: Lane, +} + +/// A Ghost Lock: four lanes under one set of keys. +#[derive(Debug, Clone)] +pub struct GhostLockAccount { + pub lanes: Vec, +} + +impl GhostLockAccount { + /// Build all four lanes. + /// + /// All four or none: a Lock missing a lane is not a Lock, and returning a + /// partial one would leave the wallet showing three compartments while + /// funds could still arrive at the fourth's address. + pub fn build( + secp: &Secp256k1, + keys: &LockKeys, + network: Network, + anchor_height: u32, + inherit_height: u32, + ) -> Result { + let savings = SavingsPolicy { + aggregate: keys.owner_backup_aggregate, + owner: keys.owner, + backup: keys.backup, + heir: keys.heir, + inherit_height, + } + .build(secp, anchor_height, network)?; + + let spending = SpendingPolicy { + aggregate: keys.owner_quorum_aggregate, + owner: keys.owner, + } + .build(secp, network)?; + + let cash = CashPolicy { owner: keys.owner }.build(secp, network)?; + + let investments = InvestmentsPolicy { + quorum: keys.quorum, + owner: keys.owner, + } + .build(secp, network)?; + + Ok(Self { + lanes: vec![ + BuiltLane { + kind: LaneKind::Savings, + lane: savings, + }, + BuiltLane { + kind: LaneKind::Spending, + lane: spending, + }, + BuiltLane { + kind: LaneKind::Cash, + lane: cash, + }, + BuiltLane { + kind: LaneKind::Investments, + lane: investments, + }, + ], + }) + } + + /// The lane of a given kind. + pub fn lane(&self, kind: LaneKind) -> Option<&BuiltLane> { + self.lanes.iter().find(|l| l.kind == kind) + } +} + +/// A lane's balance, as shown. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LaneBalance { + pub kind: LaneKind, + pub label: String, + pub address: String, + /// Confirmed. What has settled and can be relied on. + pub balance_sats: u64, + /// Unconfirmed, and reported **separately rather than added**. + /// + /// A user who has just funded a lane needs to see it arriving, or the + /// wallet looks broken for a block. But folding it into the balance would + /// show money that can still vanish as though it were settled, which is the + /// more expensive mistake of the two. + pub pending_sats: u64, + /// True only for Investments. Carried per lane rather than left for the UI + /// to infer, so every client shows the same warning. + pub quorum_can_spend_alone: bool, + /// Whether these coins may enter a round. + pub round_eligible: bool, +} + +/// Every lane's balance, plus the combined total. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct LockBalances { + pub lanes: Vec, + /// The whole Lock, confirmed. What a person means by "how much have I got". + pub total_sats: u64, + /// Unconfirmed across every lane. Beside the total, never inside it. + pub total_pending_sats: u64, + /// Of the total, how much the quorum could move without the owner. + /// + /// Reported alongside the total rather than folded into it: a single figure + /// that silently mixes custodial and non-custodial funds tells the reader + /// less than two figures do. + pub custodial_sats: u64, +} + +/// One scanned coin, already attributed to a lane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LaneCoin { + pub kind: LaneKind, + pub sats: u64, + /// Zero means it is still in the mempool. + pub confirmations: u32, +} + +/// Sum a Lock's lanes, keeping confirmed and pending apart. +/// +/// Saturating, because a total shown to a person must never wrap into a small +/// number. Where arithmetic actually moves coins the checked form is used +/// instead. +/// +/// The custodial figure counts **confirmed** funds only: it answers "how much +/// of my settled money can somebody else move", and unconfirmed coins are not +/// yet anybody's to move. +pub fn balances(account: &GhostLockAccount, coins: &[LaneCoin]) -> LockBalances { + let mut lanes = Vec::with_capacity(account.lanes.len()); + let mut total: u64 = 0; + let mut pending_total: u64 = 0; + let mut custodial: u64 = 0; + + for built in &account.lanes { + let mut settled: u64 = 0; + let mut pending: u64 = 0; + for c in coins.iter().filter(|c| c.kind == built.kind) { + if c.confirmations == 0 { + pending = pending.saturating_add(c.sats); + } else { + settled = settled.saturating_add(c.sats); + } + } + total = total.saturating_add(settled); + pending_total = pending_total.saturating_add(pending); + if built.kind.quorum_can_spend_alone() { + custodial = custodial.saturating_add(settled); + } + lanes.push(LaneBalance { + kind: built.kind, + label: built.kind.label().to_string(), + address: built.lane.address.to_string(), + balance_sats: settled, + pending_sats: pending, + quorum_can_spend_alone: built.kind.quorum_can_spend_alone(), + round_eligible: built.kind.round_eligible(), + }); + } + + LockBalances { + lanes, + total_sats: total, + total_pending_sats: pending_total, + custodial_sats: custodial, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::{Keypair, SecretKey}; + + fn key(b: u8) -> XOnlyPublicKey { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[b.max(1); 32]).unwrap(); + Keypair::from_secret_key(&secp, &sk).x_only_public_key().0 + } + + fn keys() -> LockKeys { + LockKeys { + owner: key(1), + backup: key(2), + heir: key(3), + owner_backup_aggregate: key(4), + owner_quorum_aggregate: key(5), + quorum: key(6), + } + } + + fn settled(kind: LaneKind, sats: u64) -> LaneCoin { + LaneCoin { + kind, + sats, + confirmations: 1, + } + } + + fn pending(kind: LaneKind, sats: u64) -> LaneCoin { + LaneCoin { + kind, + sats, + confirmations: 0, + } + } + + fn account() -> GhostLockAccount { + let secp = Secp256k1::verification_only(); + // Anchor at the current tip; inheritance well beyond it. + GhostLockAccount::build(&secp, &keys(), Network::Regtest, 900_000, 1_000_000).unwrap() + } + + #[test] + fn a_lock_has_all_four_lanes_at_distinct_addresses() { + // Compartments that share an address are not compartments. + let a = account(); + assert_eq!(a.lanes.len(), 4); + let mut addrs: Vec = a.lanes.iter().map(|l| l.lane.address.to_string()).collect(); + addrs.sort(); + addrs.dedup(); + assert_eq!(addrs.len(), 4, "each lane needs its own address"); + } + + #[test] + fn only_investments_is_custodial() { + // The lane where the quorum spends alone. A UI that misses this shows a + // custodial balance beside three non-custodial ones. + for k in LaneKind::ALL { + assert_eq!( + k.quorum_can_spend_alone(), + k == LaneKind::Investments, + "{k:?}" + ); + } + } + + #[test] + fn cash_is_the_only_lane_barred_from_rounds() { + for k in LaneKind::ALL { + assert_eq!(k.round_eligible(), k != LaneKind::Cash, "{k:?}"); + } + } + + /// Private entry: a round may pay into the three private lanes and must + /// not pay into Cash. Distinct from `round_eligible`, which is about a + /// coin leaving a lane INTO a round. + #[test] + fn a_round_may_fund_every_lane_except_cash() { + for k in LaneKind::ALL { + assert_eq!( + k.round_destination_eligible(), + k != LaneKind::Cash, + "{k:?} destination eligibility" + ); + } + } + + /// The two rules agree today and are still two rules. If someone collapses + /// them, this keeps the reason visible: they refuse Cash for different + /// reasons and would diverge the moment either changes. + #[test] + fn the_input_rule_and_the_destination_rule_are_separate() { + let cash = LaneKind::Cash.compartment(); + assert_ne!( + ghost_lock::check_round_eligible(cash).unwrap_err(), + ghost_lock::check_round_destination(cash).unwrap_err(), + ); + } + + /// Every lane maps to exactly one compartment, and only Cash is public. + #[test] + fn only_cash_is_the_public_compartment() { + for k in LaneKind::ALL { + let expected = if k == LaneKind::Cash { + ghost_lock::Compartment::Cash + } else { + ghost_lock::Compartment::Private + }; + assert_eq!(k.compartment(), expected, "{k:?}"); + } + } + + #[test] + fn the_total_is_every_lane_including_cash() { + // "How much have I got" means all of it. Excluding Cash because it is + // not private would answer a different question than the one asked. + let a = account(); + let b = balances( + &a, + &[ + settled(LaneKind::Savings, 1_000_000), + settled(LaneKind::Spending, 200_000), + settled(LaneKind::Cash, 50_000), + settled(LaneKind::Investments, 40_000), + ], + ); + assert_eq!(b.total_sats, 1_290_000); + assert_eq!(b.lanes.len(), 4); + } + + #[test] + fn the_custodial_share_is_reported_separately() { + // Folding it into the total would tell the reader less: they could not + // see how much of their money somebody else can move. + let a = account(); + let b = balances( + &a, + &[ + settled(LaneKind::Savings, 1_000_000), + settled(LaneKind::Investments, 40_000), + ], + ); + assert_eq!(b.total_sats, 1_040_000); + assert_eq!(b.custodial_sats, 40_000); + } + + #[test] + fn a_lane_with_no_coins_is_still_listed() { + // An empty lane must not vanish: it has an address funds can arrive at, + // and a person needs to see it exists. + let a = account(); + let b = balances(&a, &[settled(LaneKind::Spending, 5)]); + assert_eq!(b.lanes.len(), 4); + assert_eq!(b.total_sats, 5); + for l in &b.lanes { + assert!(!l.address.is_empty()); + } + } + + #[test] + fn several_utxos_in_one_lane_sum() { + let a = account(); + let b = balances( + &a, + &[ + settled(LaneKind::Cash, 1_000), + settled(LaneKind::Cash, 2_000), + settled(LaneKind::Cash, 3_000), + ], + ); + assert_eq!(b.total_sats, 6_000); + } + + #[test] + fn the_total_saturates_rather_than_wrapping() { + // A balance shown to a person must never wrap into a small number. + let a = account(); + let b = balances( + &a, + &[ + settled(LaneKind::Savings, u64::MAX), + settled(LaneKind::Spending, u64::MAX), + ], + ); + assert_eq!(b.total_sats, u64::MAX); + } + + #[test] + fn pending_is_reported_beside_settled_never_added_to_it() { + // A user who has just funded a lane must see it arriving, or the wallet + // looks broken for a block. Folding it in would show money that can + // still vanish as though it had settled — the more expensive mistake. + let a = account(); + let b = balances( + &a, + &[ + settled(LaneKind::Savings, 1_000_000), + pending(LaneKind::Savings, 250_000), + ], + ); + assert_eq!(b.total_sats, 1_000_000, "settled must exclude pending"); + assert_eq!(b.total_pending_sats, 250_000); + let sav = b + .lanes + .iter() + .find(|l| l.kind == LaneKind::Savings) + .unwrap(); + assert_eq!(sav.balance_sats, 1_000_000); + assert_eq!(sav.pending_sats, 250_000); + } + + #[test] + fn the_custodial_figure_counts_settled_funds_only() { + // It answers "how much of my settled money can somebody else move". + // Unconfirmed coins are not yet anybody's to move. + let a = account(); + let b = balances( + &a, + &[ + settled(LaneKind::Investments, 40_000), + pending(LaneKind::Investments, 999_000), + ], + ); + assert_eq!(b.custodial_sats, 40_000); + assert_eq!(b.total_pending_sats, 999_000); + } + + #[test] + fn lanes_are_ordered_coldest_first() { + // The order a person reads them in, and the order risk increases. + let a = account(); + let kinds: Vec = a.lanes.iter().map(|l| l.kind).collect(); + assert_eq!(kinds, LaneKind::ALL.to_vec()); + } +} diff --git a/apps/wraith-wallet/core/src/ghost_lock_store.rs b/apps/wraith-wallet/core/src/ghost_lock_store.rs new file mode 100644 index 000000000..569bc58b9 --- /dev/null +++ b/apps/wraith-wallet/core/src/ghost_lock_store.rs @@ -0,0 +1,467 @@ +//! Where a Ghost Lock's definition lives. +//! +//! Until now the wallet's Locks screen was the source of truth: the user typed +//! three keys and two heights into a form, and nothing remembered them. That is +//! fine for viewing and useless for anything else — a Lock the wallet cannot +//! name cannot be spent from, migrated to, or listed. +//! +//! # What is stored, and what is not +//! +//! Public keys and two heights. Nothing secret: the owner's key never appears +//! here, because it is derived from the keystore on demand. Losing this file +//! loses convenience, not funds — the lanes are reconstructible from the same +//! three keys and the keystore. +//! +//! That is worth stating precisely, because it is *not* true of the signing +//! ledger next door, where losing the file re-permits a double-sign. These two +//! files sit in the same directory and mean very different things. +//! +//! # Content-addressed, so the same Lock is the same Lock +//! +//! `lock_id` is a hash of the keys and heights rather than a random string. +//! Entering the same Lock twice therefore updates one record instead of +//! creating a second, and a user who re-enters their keys after losing the file +//! gets their Lock back under its original id rather than a stranger. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use bitcoin::hashes::{sha256, Hash}; + +/// Domain tag for the Lock id. Versioned. +const LOCK_ID_TAG: &str = "ghost-lock/id/v1"; +/// Domain tag for the quorum binding id. Distinct from [`LOCK_ID_TAG`] so the +/// two identifiers can never collide, and so neither can be passed where the +/// other is expected without the difference showing. +const QUORUM_BINDING_TAG: &str = "ghost-lock/quorum-binding/v1"; + +/// A stored Lock definition. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct StoredLock { + /// Content hash of the fields below. Stable for the same Lock. + pub lock_id: String, + /// Optional name the user gave it. + #[serde(default)] + pub label: Option, + pub backup_pubkey: String, + pub heir_pubkey: String, + pub quorum_pubkey: String, + pub anchor_height: u32, + pub inherit_height: u32, + /// BIP86 index the owner key is derived at. + pub bip86_index: u32, +} + +impl StoredLock { + /// The id a quorum derives its key for this Lock from. + /// + /// # Why this is not `lock_id` + /// + /// It cannot be. `lock_id` is a hash **over** `quorum_pubkey`, and the + /// quorum's key is derived **from** the id it is given — so using + /// `lock_id` needs each of the two to exist before the other. A Lock built + /// by the wallet could never carry the key the coordinator would go on to + /// sign with, and the Spending lane's co-signed path was unreachable: the + /// lane could only be emptied through its escape leaf, 1,008 blocks later. + /// + /// This commits to everything `lock_id` does except the quorum key, which + /// is exactly the part that has to be known first. Two Locks that differ in + /// any other field still get different quorum keys, so the per-Lock + /// separation the derivation exists for is unchanged. Two that differ + /// *only* in quorum key would share a binding id — and cannot exist, since + /// that key is the derivation's own output. + /// + /// Note this is a public identifier handed to a coordinator. It is a hash + /// of public keys and heights, and reveals nothing the Lock's addresses do + /// not already. + pub fn quorum_binding_id( + backup: &str, + heir: &str, + anchor_height: u32, + inherit_height: u32, + bip86_index: u32, + ) -> String { + let mut h = sha256::Hash::engine(); + use bitcoin::hashes::HashEngine; + h.input(QUORUM_BINDING_TAG.as_bytes()); + for k in [backup, heir] { + let k = k.trim().to_ascii_lowercase(); + h.input(&(k.len() as u64).to_be_bytes()); + h.input(k.as_bytes()); + } + h.input(&anchor_height.to_be_bytes()); + h.input(&inherit_height.to_be_bytes()); + h.input(&bip86_index.to_be_bytes()); + hex::encode(&sha256::Hash::from_engine(h).to_byte_array()[..16]) + } + + /// The id a Lock with these parameters always has. + /// + /// Derived from every field that changes the resulting addresses — and from + /// none that do not. `label` is excluded deliberately: renaming a Lock must + /// not make it a different Lock. + pub fn derive_id( + backup: &str, + heir: &str, + quorum: &str, + anchor_height: u32, + inherit_height: u32, + bip86_index: u32, + ) -> String { + let mut h = sha256::Hash::engine(); + use bitcoin::hashes::HashEngine; + h.input(LOCK_ID_TAG.as_bytes()); + for k in [backup, heir, quorum] { + let k = k.trim().to_ascii_lowercase(); + h.input(&(k.len() as u64).to_be_bytes()); + h.input(k.as_bytes()); + } + h.input(&anchor_height.to_be_bytes()); + h.input(&inherit_height.to_be_bytes()); + h.input(&bip86_index.to_be_bytes()); + hex::encode(&sha256::Hash::from_engine(h).to_byte_array()[..16]) + } + + /// This Lock's quorum binding id. See [`Self::quorum_binding_id`]. + pub fn binding_id(&self) -> String { + Self::quorum_binding_id( + &self.backup_pubkey, + &self.heir_pubkey, + self.anchor_height, + self.inherit_height, + self.bip86_index, + ) + } + + /// Build a record, computing its id. + pub fn new( + label: Option, + backup_pubkey: String, + heir_pubkey: String, + quorum_pubkey: String, + anchor_height: u32, + inherit_height: u32, + bip86_index: u32, + ) -> Self { + let lock_id = Self::derive_id( + &backup_pubkey, + &heir_pubkey, + &quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + ); + Self { + lock_id, + label, + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + } + } +} + +/// File-backed store of Lock definitions. +#[derive(Debug)] +pub struct GhostLockStore { + path: PathBuf, + locks: BTreeMap, +} + +impl GhostLockStore { + /// Open (or create) the store. + /// + /// A malformed file is an error rather than an empty store. Silently + /// starting fresh would hide every Lock the user has, and they would find + /// out by their balances reading zero — which looks exactly like being + /// robbed. + pub fn open(path: impl AsRef) -> std::io::Result { + let path = path.as_ref().to_path_buf(); + let locks = if path.exists() { + let raw = fs::read_to_string(&path)?; + let list: Vec = serde_json::from_str(&raw).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "Ghost Lock store at {} is unreadable ({e}); refusing to \ + start empty, because that would hide every Lock and read \ + to the user as their money having vanished", + path.display() + ), + ) + })?; + list.into_iter().map(|l| (l.lock_id.clone(), l)).collect() + } else { + BTreeMap::new() + }; + Ok(Self { path, locks }) + } + + /// Every stored Lock, in a stable order. + pub fn list(&self) -> Vec { + self.locks.values().cloned().collect() + } + + /// One Lock by id. + pub fn get(&self, lock_id: &str) -> Option<&StoredLock> { + self.locks.get(lock_id) + } + + /// Store a Lock, replacing any with the same id. + /// + /// Replacing rather than rejecting a duplicate: the id is content-derived, + /// so a "duplicate" is the same Lock re-entered, and the only thing that can + /// differ is its label. Refusing would make renaming impossible. + pub fn put(&mut self, lock: StoredLock) -> std::io::Result<()> { + self.locks.insert(lock.lock_id.clone(), lock); + self.flush() + } + + /// Forget a Lock. Returns whether it was there. + /// + /// The funds are untouched — this removes a definition, not a Lock. The + /// lanes remain spendable by anyone holding the keys. + pub fn remove(&mut self, lock_id: &str) -> std::io::Result { + let had = self.locks.remove(lock_id).is_some(); + if had { + self.flush()?; + } + Ok(had) + } + + /// Write the whole store, durably. + /// + /// Same write-temp, fsync, rename, fsync-dir sequence as the signing ledger. + /// Losing a Lock definition costs less than losing an authorisation, but a + /// half-written file fails the strict parse above and locks the user out of + /// their own list until they fix it by hand. + fn flush(&self) -> std::io::Result<()> { + let list: Vec<&StoredLock> = self.locks.values().collect(); + let body = serde_json::to_vec_pretty(&list)?; + // 0o600: a stored Lock lays out the owner's whole account structure. + ghost_lock::atomic_file::write_atomic(&self.path, &body, Some(0o600)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lock(label: Option<&str>, backup: &str) -> StoredLock { + StoredLock::new( + label.map(str::to_string), + backup.into(), + "bb".into(), + "cc".into(), + 900_000, + 1_000_000, + 0, + ) + } + + #[test] + fn the_same_lock_has_the_same_id() { + // Content-addressed, so re-entering keys after losing the file gives the + // Lock back under its original id rather than as a stranger. + assert_eq!(lock(None, "aa").lock_id, lock(None, "aa").lock_id); + } + + #[test] + fn renaming_does_not_make_it_a_different_lock() { + // `label` is excluded from the id on purpose. + assert_eq!( + lock(Some("Main"), "aa").lock_id, + lock(Some("Renamed"), "aa").lock_id + ); + } + + #[test] + fn changing_anything_that_moves_the_addresses_changes_the_id() { + let base = lock(None, "aa"); + assert_ne!(base.lock_id, lock(None, "ab").lock_id, "backup key"); + + let other_height = StoredLock::new( + None, + "aa".into(), + "bb".into(), + "cc".into(), + 900_001, + 1_000_000, + 0, + ); + assert_ne!(base.lock_id, other_height.lock_id, "anchor height"); + + let other_index = StoredLock::new( + None, + "aa".into(), + "bb".into(), + "cc".into(), + 900_000, + 1_000_000, + 1, + ); + assert_ne!(base.lock_id, other_index.lock_id, "bip86 index"); + } + + #[test] + fn the_id_ignores_case_and_surrounding_space() { + // A key pasted with a trailing newline is the same key. + let a = StoredLock::derive_id("AA", "BB", "CC", 1, 2, 0); + let b = StoredLock::derive_id(" aa ", "bb\n", "cc", 1, 2, 0); + assert_eq!(a, b); + } + + #[test] + fn a_lock_survives_a_restart() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("locks.json"); + let id = { + let mut s = GhostLockStore::open(&path).unwrap(); + let l = lock(Some("Main"), "aa"); + let id = l.lock_id.clone(); + s.put(l).unwrap(); + id + }; + let reopened = GhostLockStore::open(&path).unwrap(); + assert_eq!(reopened.list().len(), 1); + assert_eq!(reopened.get(&id).unwrap().label.as_deref(), Some("Main")); + } + + #[test] + fn re_entering_a_lock_updates_it_rather_than_duplicating() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("locks.json"); + let mut s = GhostLockStore::open(&path).unwrap(); + s.put(lock(Some("Main"), "aa")).unwrap(); + s.put(lock(Some("Renamed"), "aa")).unwrap(); + assert_eq!(s.list().len(), 1, "same keys are the same Lock"); + assert_eq!(s.list()[0].label.as_deref(), Some("Renamed")); + } + + #[test] + fn a_corrupt_store_is_an_error_not_an_empty_list() { + // Starting empty would hide every Lock, and the user would find out by + // their balances reading zero — which looks like being robbed. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("locks.json"); + fs::write(&path, b"not json").unwrap(); + let e = GhostLockStore::open(&path).expect_err("must refuse"); + assert_eq!(e.kind(), std::io::ErrorKind::InvalidData); + assert!(format!("{e}").contains("vanished"), "{e}"); + } + + #[test] + fn forgetting_a_lock_removes_only_the_definition() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("locks.json"); + let mut s = GhostLockStore::open(&path).unwrap(); + let l = lock(None, "aa"); + let id = l.lock_id.clone(); + s.put(l).unwrap(); + assert!(s.remove(&id).unwrap()); + assert!(!s.remove(&id).unwrap(), "already gone"); + assert!(s.list().is_empty()); + } + + #[test] + fn an_absent_file_opens_empty() { + let dir = tempfile::tempdir().unwrap(); + let s = GhostLockStore::open(dir.path().join("new.json")).unwrap(); + assert!(s.list().is_empty()); + } + + /// A Lock can carry the very key the coordinator will sign with. + /// + /// This is the property the whole co-signed Spending path rests on, and it + /// did not hold. `lock_id` is a hash over `quorum_pubkey` while the + /// quorum's key derives from the id it is handed, so each needed the other + /// first: whatever key went into a Lock, the coordinator would derive a + /// different one for that Lock's id and the signature could never + /// aggregate. The lane was reachable only through its escape leaf. + /// + /// Deriving from the binding id — everything except the quorum key — + /// closes the loop, and this test is that closure: build the key the way + /// an operator does, put it in a Lock, then re-derive it the way the + /// coordinator does from that finished Lock. + #[test] + fn a_lock_carries_the_key_the_quorum_will_sign_with() { + let phrase = "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about"; + let backup = "11".repeat(32); + let heir = "22".repeat(32); + let (anchor, inherit, index) = (900_000u32, 950_000u32, 0u32); + + // What the operator does before the Lock exists. + let binding = StoredLock::quorum_binding_id(&backup, &heir, anchor, inherit, index); + let quorum = + ghost_lock::backup_key::quorum_public_key(phrase, "", &binding).expect("quorum key"); + let quorum_hex = hex::encode(quorum.serialize()); + + // The Lock, now complete. + let record = StoredLock::new( + None, + backup, + heir, + quorum_hex.clone(), + anchor, + inherit, + index, + ); + + // What the coordinator does when asked to co-sign it. + let rederived = ghost_lock::backup_key::quorum_public_key(phrase, "", &record.binding_id()) + .expect("re-derive"); + assert_eq!( + hex::encode(rederived.serialize()), + record.quorum_pubkey, + "the Lock does not carry the key the quorum will sign with" + ); + + // And the binding id is not the lock id: passing one where the other + // belongs must not silently work. + assert_ne!( + record.binding_id(), + record.lock_id, + "binding id and lock id must be distinguishable" + ); + } + + /// The binding id separates Locks the way the lock id does. + /// + /// Dropping the quorum key from the hash must not collapse distinct Locks + /// onto one quorum key — that would hand two Locks the same co-signer key. + #[test] + fn distinct_locks_get_distinct_binding_ids() { + let b = "11".repeat(32); + let h = "22".repeat(32); + let base = StoredLock::quorum_binding_id(&b, &h, 900_000, 950_000, 0); + for (label, other) in [ + ( + "backup", + StoredLock::quorum_binding_id(&"33".repeat(32), &h, 900_000, 950_000, 0), + ), + ( + "heir", + StoredLock::quorum_binding_id(&b, &"44".repeat(32), 900_000, 950_000, 0), + ), + ( + "anchor", + StoredLock::quorum_binding_id(&b, &h, 900_001, 950_000, 0), + ), + ( + "inherit", + StoredLock::quorum_binding_id(&b, &h, 900_000, 950_001, 0), + ), + ( + "index", + StoredLock::quorum_binding_id(&b, &h, 900_000, 950_000, 1), + ), + ] { + assert_ne!(base, other, "Locks differing in {label} share a binding id"); + } + } +} diff --git a/apps/wraith-wallet/core/src/ghostd.rs b/apps/wraith-wallet/core/src/ghostd.rs index fe3da928e..12908cf09 100644 --- a/apps/wraith-wallet/core/src/ghostd.rs +++ b/apps/wraith-wallet/core/src/ghostd.rs @@ -142,8 +142,76 @@ impl GhostdRpc { ) } + /// One block, with every input's previous output resolved. + /// + /// `getblock 3` is what makes local scanning possible without an + /// indexer: the node returns each input's `prevout` from its undo data, + /// so the wallet can tell which inputs were its own — and therefore what + /// it *spent* — without a `txindex` or a lookup per input. + /// + /// Verbosity 2 would give outputs only, which finds money arriving but + /// not money leaving. A history that shows credits and no debits is worse + /// than none: it reads like a balance that only ever grows. + pub fn get_block_with_prevouts(&self, hash: &str) -> Result { + self.rpc( + "getblock", + vec![ + serde_json::Value::String(hash.to_string()), + serde_json::Value::from(3u8), + ], + ) + } + + /// `scantxoutset start` over a set of `addr(...)` descriptors. + /// + /// Walks the whole UTXO set, so it is expensive and bitcoind serialises + /// it: one scan at a time per node. That cost is the price of not asking + /// an operator's indexer where your money is. + /// + /// Returns `(unspents, chain_height)`. + pub fn scan_tx_out_set( + &self, + addresses: &[String], + ) -> Result<(Vec, u64), GhostdError> { + let descriptors: Vec = addresses + .iter() + .map(|a| serde_json::Value::String(format!("addr({a})"))) + .collect(); + let res: serde_json::Value = self.rpc( + "scantxoutset", + vec![ + serde_json::Value::String("start".into()), + serde_json::Value::Array(descriptors), + ], + )?; + + // `success: false` is bitcoind saying the scan did not complete — + // usually another scan is already running. Treating that as "no + // coins" would report an empty wallet, which is the worst possible + // way to be wrong about a balance. + if res.get("success").and_then(|v| v.as_bool()) != Some(true) { + return Err(GhostdError::Parse( + "scantxoutset did not complete (another scan may be running); \ + refusing to report a balance from a partial scan" + .into(), + )); + } + let height = res + .get("height") + .and_then(|v| v.as_u64()) + .ok_or_else(|| GhostdError::Parse("scantxoutset returned no height".into()))?; + let unspents: Vec = serde_json::from_value( + res.get("unspents") + .cloned() + .unwrap_or(serde_json::json!([])), + ) + .map_err(|e| GhostdError::Parse(format!("scantxoutset unspents: {e}")))?; + Ok((unspents, height)) + } + /// Push a signed transaction to the mempool. Returns the txid the /// node accepted. Errors map cleanly: + /// /// - bitcoind RPC error → `GhostdError::Rpc { code, message }` /// (e.g. bad-txns-inputs-missingorspent, premature-spend, etc.) /// - transport / connect → `GhostdError::Unreachable` @@ -187,7 +255,7 @@ pub struct RawTransaction { pub confirmations: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RawVout { /// vout index. pub n: u32, @@ -209,7 +277,7 @@ impl RawVout { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RawScriptPubKey { /// Hex-encoded scriptPubKey. pub hex: String, @@ -235,3 +303,82 @@ impl RawScriptPubKey { }) } } + +/// One row of `scantxoutset`. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ScannedOutput { + pub txid: String, + pub vout: u32, + /// BTC as a JSON number, exactly as bitcoind reports it. + pub amount: f64, + #[serde(rename = "scriptPubKey")] + pub script_pub_key: String, + /// Height the output was created at. + pub height: u64, +} + +/// One block from `getblock 3`. +/// +/// Only the fields a wallet scan needs. `#[serde(default)]` is deliberately +/// absent on `height` and `tx`: a block without them is not a block this can +/// scan, and defaulting them would silently scan nothing. +#[derive(Debug, Clone, Deserialize)] +pub struct VerboseBlock { + pub hash: String, + pub height: u64, + /// Block time, unix seconds. What a transaction's history entry is dated + /// by — the wallet may not have been running when it was mined. + pub time: i64, + pub tx: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockTx { + pub txid: String, + #[serde(default)] + pub vin: Vec, + #[serde(default)] + pub vout: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockVin { + /// Absent on a coinbase input, which spends nothing. + #[serde(default)] + pub txid: Option, + #[serde(default)] + pub vout: Option, + /// The output this input spends. Present for every non-coinbase input at + /// verbosity 3; `None` marks an input whose value is not knowable here, + /// which makes any fee computed from this transaction wrong rather than + /// merely approximate. + #[serde(default)] + pub prevout: Option, + /// Set only on the coinbase input. + #[serde(default)] + pub coinbase: Option, +} + +impl BlockVin { + /// Whether this is the coinbase input. + pub fn is_coinbase(&self) -> bool { + self.coinbase.is_some() + } +} + +/// The output an input spends, as the node resolved it from undo data. +#[derive(Debug, Clone, Deserialize)] +pub struct Prevout { + /// Value in BTC, as bitcoind encodes it. + pub value: f64, + #[serde(rename = "scriptPubKey")] + pub script_pub_key: RawScriptPubKey, +} + +impl Prevout { + /// BTC → satoshis, rounded rather than truncated. `0.000_123_45` arriving + /// as `0.000_123_449_999` must not lose a satoshi. + pub fn value_sats(&self) -> u64 { + (self.value * 100_000_000.0).round() as u64 + } +} diff --git a/apps/wraith-wallet/core/src/gsp/mod.rs b/apps/wraith-wallet/core/src/gsp/mod.rs deleted file mode 100644 index 127cdc239..000000000 --- a/apps/wraith-wallet/core/src/gsp/mod.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! GSP client (REST + WebSocket). -//! -//! Phase 1: WebSocket Ping/Pong probe + REST registration / session creation + -//! long-lived authenticated session task (`session` submodule). -//! -//! Reuses message types from `ghost-gsp-proto` so the wire format stays in sync with the server. - -pub mod session; -pub use session::{ - spawn_session, spawn_session_with_bech32, BalanceSnapshot, SessionHandle, SessionPhase, - SessionStatus, -}; - -use std::time::{SystemTime, UNIX_EPOCH}; - -use futures_util::{SinkExt, StreamExt}; -use ghost_gsp_proto::{ - ClientMessage, RegisterRequest, RegisterResponse, ServerMessage, SessionRequest, - SessionResponse, SessionToken, WalletId, WalletProof, -}; -use tokio_tungstenite::{connect_async, tungstenite::Message}; - -#[derive(Debug, thiserror::Error)] -pub enum GspError { - #[error("transport error: {0}")] - Transport(String), - #[error("server returned unexpected message: {0}")] - Unexpected(String), - #[error("encoding error: {0}")] - Encoding(String), - #[error("server returned error: {0}")] - Server(String), - #[error("missing field in response: {0}")] - MissingField(&'static str), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PingResult { - /// GSP server's wall-clock at response time (unix milliseconds). - pub server_time: i64, - /// Round-trip time in milliseconds, if the server echoed our timestamp. - pub round_trip_ms: Option, -} - -pub struct GspClient { - /// Ordered list of WebSocket URLs. Tried in sequence on single-shot calls - /// (ping/register/create_session) and rotated across by the persistent - /// session task on reconnect. - ws_urls: Vec, - /// HTTP base URLs derived 1:1 from `ws_urls`. - /// e.g. `ws://host:port/ws/v1` → `http://host:port`. - http_bases: Vec, - http: reqwest::Client, -} - -impl GspClient { - pub fn new(ws_url: impl Into) -> Self { - Self::with_urls(vec![ws_url.into()]) - } - - pub fn with_urls(ws_urls: Vec) -> Self { - Self::with_urls_and_proxy(ws_urls, None).expect("default reqwest client always builds") - } - - /// Same as `with_urls` but routes REST traffic (register, session) through - /// the given SOCKS5 proxy (e.g. `socks5h://127.0.0.1:9050` for Tor). - /// - /// **Note:** the persistent WebSocket session does **not** currently honour - /// this proxy — `tokio-tungstenite` needs a separate custom connector for - /// SOCKS5. Use Tor for REST today, treat WS as direct. Full WS-over-Tor - /// support is a follow-up. - pub fn with_urls_and_proxy( - ws_urls: Vec, - proxy_url: Option<&str>, - ) -> Result { - let urls = if ws_urls.is_empty() { - vec!["ws://127.0.0.1:8900/ws/v1".to_string()] - } else { - ws_urls - }; - let http_bases = urls.iter().map(|u| derive_http_base(u)).collect(); - let mut builder = reqwest::Client::builder(); - if let Some(p) = proxy_url { - let proxy = - reqwest::Proxy::all(p).map_err(|e| GspError::Transport(format!("proxy: {e}")))?; - builder = builder.proxy(proxy); - } - let http = builder - .build() - .map_err(|e| GspError::Transport(format!("http client: {e}")))?; - Ok(Self { - ws_urls: urls, - http_bases, - http, - }) - } - - /// Parse a comma-separated WS URL list. Trims whitespace and drops empties. - pub fn parse_urls(s: &str) -> Vec { - s.split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() - } - - /// Read-only access to the configured WS URLs (in failover order). - pub fn ws_urls(&self) -> &[String] { - &self.ws_urls - } - - /// Open a WebSocket, send `Ping`, wait for `Pong`, close. Single-shot. - /// - /// Tries each configured WS URL in order; first successful Pong wins. - pub async fn ping(&self) -> Result { - let mut last_err: Option = None; - for url in &self.ws_urls { - match self.try_ping(url).await { - Ok(r) => return Ok(r), - Err(e) => { - tracing::debug!(url = %url, error = %e, "gsp ping endpoint failed, trying next"); - last_err = Some(e); - } - } - } - Err(last_err.unwrap_or_else(|| GspError::Transport("no endpoints configured".into()))) - } - - async fn try_ping(&self, ws_url: &str) -> Result { - // Bound the connect at 5 s; tokio-tungstenite's connect_async would - // otherwise inherit the OS-default socket timeout (60+ s on Linux) - // when the host is unroutable, which blocks doctor for far longer - // than is useful. 5 s comfortably covers any real LAN / internet - // handshake. - let (mut ws, _) = - tokio::time::timeout(std::time::Duration::from_secs(5), connect_async(ws_url)) - .await - .map_err(|_| { - GspError::Transport(format!("connect to {ws_url}: timed out after 5s")) - })? - .map_err(|e| GspError::Transport(e.to_string()))?; - - let sent_ts = now_unix_ms(); - let request = ClientMessage::Ping { - timestamp: Some(sent_ts), - }; - let payload = - serde_json::to_string(&request).map_err(|e| GspError::Encoding(e.to_string()))?; - - ws.send(Message::Text(payload)) - .await - .map_err(|e| GspError::Transport(e.to_string()))?; - - loop { - let frame = ws - .next() - .await - .ok_or_else(|| GspError::Transport("connection closed before Pong".into()))? - .map_err(|e| GspError::Transport(e.to_string()))?; - - let text = match frame { - Message::Text(t) => t, - Message::Close(_) => { - return Err(GspError::Transport("server closed before Pong".into())); - } - _ => continue, - }; - - let parsed: ServerMessage = serde_json::from_str(text.as_ref()) - .map_err(|e| GspError::Encoding(e.to_string()))?; - - if let ServerMessage::Pong { - timestamp: echoed, - server_time, - } = parsed - { - let _ = ws.close(None).await; - let round_trip_ms = echoed.map(|_| (now_unix_ms() - sent_ts).max(0)); - return Ok(PingResult { - server_time, - round_trip_ms, - }); - } - - return Err(GspError::Unexpected(format!("{parsed:?}"))); - } - } - - /// `POST /api/v1/register` — register the wallet identified by the proof's pubkey. - /// Tries each configured HTTP base in order; first 2xx wins. - pub async fn register( - &self, - proof: WalletProof, - display_name: Option, - ) -> Result { - let body = RegisterRequest { - proof, - display_name, - }; - let mut last_err: Option = None; - for base in &self.http_bases { - let url = format!("{base}/api/v1/register"); - match self.try_register(&url, &body).await { - Ok(id) => return Ok(id), - Err(GspError::Transport(t)) => { - tracing::debug!(url = %url, error = %t, "register endpoint transport failed, trying next"); - last_err = Some(GspError::Transport(t)); - } - // 4xx server errors aren't a failover signal — surface immediately. - Err(other) => return Err(other), - } - } - Err(last_err.unwrap_or_else(|| GspError::Transport("no endpoints configured".into()))) - } - - async fn try_register(&self, url: &str, body: &RegisterRequest) -> Result { - let resp = self - .http - .post(url) - .json(body) - .send() - .await - .map_err(|e| GspError::Transport(e.to_string()))?; - let status = resp.status(); - let text = resp - .text() - .await - .map_err(|e| GspError::Encoding(e.to_string()))?; - if !status.is_success() { - return Err(GspError::Server(extract_error(&text, status))); - } - let body: RegisterResponse = - serde_json::from_str(&text).map_err(|e| GspError::Encoding(e.to_string()))?; - if !body.success { - return Err(GspError::Server(body.error.unwrap_or_else(|| { - format!("register failed with status {status}") - }))); - } - body.wallet_id.ok_or(GspError::MissingField("wallet_id")) - } - - /// `POST /api/v1/session` — create a session, returning the JWT. - /// Tries each configured HTTP base in order on transport failure. - pub async fn create_session( - &self, - proof: WalletProof, - session_nonce: Option, - ) -> Result { - let body = SessionRequest { - proof, - session_nonce, - }; - let mut last_err: Option = None; - for base in &self.http_bases { - let url = format!("{base}/api/v1/session"); - match self.try_create_session(&url, &body).await { - Ok(t) => return Ok(t), - Err(GspError::Transport(t)) => { - tracing::debug!(url = %url, error = %t, "session endpoint transport failed, trying next"); - last_err = Some(GspError::Transport(t)); - } - Err(other) => return Err(other), - } - } - Err(last_err.unwrap_or_else(|| GspError::Transport("no endpoints configured".into()))) - } - - async fn try_create_session( - &self, - url: &str, - body: &SessionRequest, - ) -> Result { - let resp = self - .http - .post(url) - .json(body) - .send() - .await - .map_err(|e| GspError::Transport(e.to_string()))?; - let status = resp.status(); - let text = resp - .text() - .await - .map_err(|e| GspError::Encoding(e.to_string()))?; - if !status.is_success() { - return Err(GspError::Server(extract_error(&text, status))); - } - let body: SessionResponse = - serde_json::from_str(&text).map_err(|e| GspError::Encoding(e.to_string()))?; - if !body.success { - return Err(GspError::Server( - body.error - .unwrap_or_else(|| format!("session failed with status {status}")), - )); - } - body.token.ok_or(GspError::MissingField("token")) - } -} - -/// Pull a useful message out of an error response. Handles both -/// the structured `{"error": {"code", "message"}, "success": false}` shape -/// the GSP returns on 4xx, and any unstructured plaintext fallback. -fn extract_error(text: &str, status: reqwest::StatusCode) -> String { - if let Ok(v) = serde_json::from_str::(text) { - if let Some(msg) = v.pointer("/error/message").and_then(|m| m.as_str()) { - return msg.to_string(); - } - if let Some(code) = v.pointer("/error/code").and_then(|c| c.as_str()) { - return code.to_string(); - } - if let Some(s) = v.pointer("/error").and_then(|m| m.as_str()) { - return s.to_string(); - } - } - if text.is_empty() { - format!("status {status}") - } else { - format!("status {status}: {text}") - } -} - -fn derive_http_base(ws_url: &str) -> String { - let (scheme, rest) = if let Some(r) = ws_url.strip_prefix("wss://") { - ("https", r) - } else if let Some(r) = ws_url.strip_prefix("ws://") { - ("http", r) - } else { - // Already an http(s) URL? trim any trailing path. - let r = ws_url - .trim_start_matches("http://") - .trim_start_matches("https://"); - let s = if ws_url.starts_with("https://") { - "https" - } else { - "http" - }; - (s, r) - }; - let host_and_port = rest.split('/').next().unwrap_or(rest); - format!("{scheme}://{host_and_port}") -} - -fn now_unix_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn derive_http_base_strips_path() { - assert_eq!( - derive_http_base("ws://127.0.0.1:8900/ws/v1"), - "http://127.0.0.1:8900" - ); - assert_eq!( - derive_http_base("wss://gsp.example.com/ws/v1"), - "https://gsp.example.com" - ); - assert_eq!( - derive_http_base("ws://localhost:9000"), - "http://localhost:9000" - ); - } -} diff --git a/apps/wraith-wallet/core/src/gsp/session.rs b/apps/wraith-wallet/core/src/gsp/session.rs deleted file mode 100644 index ff54b20df..000000000 --- a/apps/wraith-wallet/core/src/gsp/session.rs +++ /dev/null @@ -1,1686 +0,0 @@ -//! Long-lived authenticated WebSocket session to a GSP. -//! -//! The daemon spawns one of these per active wallet's GSP session token. The task: -//! -//! 1. Opens a WebSocket to the GSP's `/ws/v1` endpoint. -//! 2. Sends `ClientMessage::Authenticate { token }`. -//! 3. Waits for `ServerMessage::AuthResult { success: true }`. -//! 4. Issues `ClientMessage::GetBalance` so the cache populates immediately. -//! 5. Reads incoming messages — `BalanceUpdate` updates the cached balance, -//! other pushes are logged and ignored for now. -//! 6. Sends a `Ping` keepalive every 30 s. -//! 7. On any IO/protocol error, marks the session as `Backoff`, sleeps with -//! exponential backoff (1 s, 2 s, 4 s, ..., capped at 60 s), and reconnects. -//! -//! Shutdown: the daemon drops the `SessionHandle`, which closes a `watch` channel -//! the task listens on. The task exits at the next opportunity. - -use std::collections::VecDeque; -use std::sync::Arc; -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use ghost_gsp_proto::{ - CandidateOutput, ClientMessage, PaymentMode, PreparedPayment, ServerMessage, TransactionInfo, - UtxoInfo, WalletProof, -}; -use ghost_keys::{GhostKeys, PaymentDetector}; - -#[derive(Debug, Clone)] -pub struct LockPreparedResult { - pub lock_id: String, - pub funding_address: String, - pub required_sats: u64, - /// Operator-derived lock public key (cooperative-path key). - pub lock_pubkey: String, - /// Echo of the wallet-supplied recovery_pubkey. Caller MUST verify - /// it equals the value sent — substitution by the operator would - /// silently break unilateral exit. - pub recovery_pubkey: String, - /// Echo of the wallet's recovery derivation index. - pub recovery_index: u32, - /// CSV blocks the recovery branch waits before becoming spendable. - pub recovery_blocks: u32, - /// Block height the lock was created at. - pub creation_height: u32, -} - -#[derive(Debug, Clone)] -pub struct LockConfirmedResult { - pub lock_id: String, - pub txid: String, - pub block_height: u32, -} - -#[derive(Debug, Clone)] -pub struct JumpRequestedResult { - pub lock_id: String, - pub jump_txid: Option, -} -use tokio::sync::{broadcast, mpsc, oneshot, watch, RwLock}; -use tokio::task::JoinHandle; -use tokio_tungstenite::tungstenite::Message; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SessionPhase { - #[default] - Disconnected, - Connecting, - Authenticating, - Authenticated, - Backoff, -} - -/// Snapshot of a session's runtime state. Cheap to clone. -#[derive(Debug, Clone, Default)] -pub struct SessionStatus { - pub phase: SessionPhase, - /// Confirmed satoshi balance from the most recent `BalanceUpdate`. - pub last_balance: Option, - /// Last error message, set on transport / protocol failure. - pub last_error: Option, - /// Successful WS connection count (1 = connected first time, 2 = one reconnect, ...). - pub connect_count: u64, - /// Silent-payment detections accumulated client-side from CandidateTransaction - /// pushes. Cleared on session restart (re-population needs server-side rescan). - pub detections: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BalanceSnapshot { - pub confirmed_sats: u64, - pub unconfirmed_sats: u64, - pub locked_sats: u64, - /// Unix seconds when this snapshot was received. - pub received_at: i64, -} - -/// One BIP-352 silent-payment detection from a `CandidateTransaction` push. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DetectedPayment { - pub txid: String, - pub block_height: Option, - pub vout: u32, - pub amount_sats: Option, - /// Derivation index (k) used by the sender. Recorded so a future - /// "spend this output" call can re-derive the spend key. - pub k: u32, - pub received_at: i64, -} - -/// Daemon-side handle to a running session task. Drop to stop the task. -pub struct SessionHandle { - status: Arc>, - cmd_tx: mpsc::Sender, - shutdown_tx: watch::Sender, - events_tx: broadcast::Sender, - task: Option>, -} - -impl SessionHandle { - pub async fn snapshot(&self) -> SessionStatus { - self.status.read().await.clone() - } - - /// Subscribe to a live stream of newly-detected silent payments. Each - /// receiver gets every event published after it subscribes; lagging - /// receivers will see RecvError::Lagged and are expected to recover by - /// re-snapshotting `SessionStatus.detections`. - pub fn subscribe_payments(&self) -> broadcast::Receiver { - self.events_tx.subscribe() - } - - /// Issue `GetUtxos` over the persistent session and await the matching `Utxos` reply. - pub async fn get_utxos(&self, min_confirmations: u32) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::GetUtxos { - min_confirmations, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `GetTransactions` and await the matching `Transactions` reply. - pub async fn get_transactions( - &self, - limit: u32, - offset: u32, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::GetTransactions { - limit, - offset, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `GetGhostLocks` and await the matching `GhostLocks` reply. - pub async fn get_ghost_locks(&self) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::GetGhostLocks { reply: tx }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `PreparePayment` and await the matching `PaymentPrepared` reply. - pub async fn prepare_payment( - &self, - recipient: String, - amount_sats: u64, - mode: PaymentMode, - proof: WalletProof, - memo: Option, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::PreparePayment { - recipient, - amount_sats, - mode, - proof, - memo, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `SubmitSignedPayment` and await the matching `PaymentSubmitted` reply. - pub async fn submit_signed_payment( - &self, - payment_id: String, - signature_hex: String, - public_key_hex: String, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::SubmitSignedPayment { - payment_id, - signature: signature_hex, - public_key: public_key_hex, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// One-shot L2 payment. Issues `SendL2Payment` and awaits the - /// matching `PaymentSent` reply. Replaces the prepare/sign/ - /// submit dance for the new wallet path — L2 transfers are - /// session-authenticated ledger ops, not Bitcoin txs. - pub async fn send_l2_payment( - &self, - recipient: String, - amount_sats: u64, - proof: WalletProof, - memo: Option, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::SendL2Payment { - recipient, - amount_sats, - proof, - memo, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `PrepareGhostLock` and await the matching `LockPrepared` reply. - /// - /// `recovery_pubkey_hex` is the user-derived recovery pubkey - /// (33-byte SEC1 compressed) that will go into the lock script's - /// recovery branch. The wallet keeps the matching secret locally so - /// the timelock recovery path is genuinely unilateral. - pub async fn prepare_ghost_lock( - &self, - owner_pubkey_hex: String, - capacity_sats: u64, - recovery_pubkey_hex: String, - recovery_index: u32, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::PrepareGhostLock { - owner_pubkey: owner_pubkey_hex, - capacity_sats, - recovery_pubkey: recovery_pubkey_hex, - recovery_index, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `ConfirmGhostLockFunding` and await the matching `LockConfirmed` reply. - pub async fn confirm_ghost_lock_funding( - &self, - lock_id: String, - funding_txid: String, - proof: WalletProof, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::ConfirmGhostLockFunding { - lock_id, - funding_txid, - proof, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `RegisterScanKey` and await the matching `ScanKeyRegistered` reply. - pub async fn register_scan_key( - &self, - scan_pubkey_hex: String, - proof: WalletProof, - ) -> Result<(), String> { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::RegisterScanKey { - scan_pubkey: scan_pubkey_hex, - proof, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } - - /// Issue `RequestJump` and await the matching `JumpRequested` reply. - pub async fn request_jump( - &self, - lock_id: String, - priority: String, - target_address: String, - proof: WalletProof, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(SessionCommand::RequestJump { - lock_id, - priority, - target_address, - proof, - reply: tx, - }) - .await - .map_err(|_| "session task closed".to_string())?; - rx.await.map_err(|_| "reply dropped".to_string())? - } -} - -impl Drop for SessionHandle { - fn drop(&mut self) { - let _ = self.shutdown_tx.send(true); - if let Some(t) = self.task.take() { - t.abort(); - } - } -} - -/// Bag of replies the session task is waiting to deliver. Pending requests are -/// matched FIFO against incoming server messages of the corresponding shape. -enum PendingReply { - Utxos(oneshot::Sender>), - Transactions(oneshot::Sender>), - GhostLocks(oneshot::Sender>), - PaymentPrepared(oneshot::Sender>), - PaymentSubmitted(oneshot::Sender>), - LockPrepared(oneshot::Sender>), - LockConfirmed(oneshot::Sender>), - JumpRequested(oneshot::Sender>), - ScanKeyRegistered(oneshot::Sender>), - PaymentSent(oneshot::Sender>), -} - -/// Commands the daemon can send into the session task. -pub enum SessionCommand { - GetUtxos { - min_confirmations: u32, - reply: oneshot::Sender>, - }, - GetTransactions { - limit: u32, - offset: u32, - reply: oneshot::Sender>, - }, - GetGhostLocks { - reply: oneshot::Sender>, - }, - PreparePayment { - recipient: String, - amount_sats: u64, - mode: PaymentMode, - proof: WalletProof, - memo: Option, - reply: oneshot::Sender>, - }, - SubmitSignedPayment { - payment_id: String, - signature: String, - public_key: String, - reply: oneshot::Sender>, - }, - PrepareGhostLock { - owner_pubkey: String, - capacity_sats: u64, - recovery_pubkey: String, - recovery_index: u32, - reply: oneshot::Sender>, - }, - ConfirmGhostLockFunding { - lock_id: String, - funding_txid: String, - proof: WalletProof, - reply: oneshot::Sender>, - }, - RequestJump { - lock_id: String, - priority: String, - target_address: String, - proof: WalletProof, - reply: oneshot::Sender>, - }, - RegisterScanKey { - scan_pubkey: String, - proof: WalletProof, - reply: oneshot::Sender>, - }, - SendL2Payment { - recipient: String, - amount_sats: u64, - proof: WalletProof, - memo: Option, - reply: oneshot::Sender>, - }, -} - -#[derive(Debug, Clone)] -pub struct SentL2PaymentResult { - pub payment_id: String, - pub status: String, - pub recipient: String, - pub amount_sats: u64, -} - -#[derive(Debug, Clone)] -pub struct SubmittedPaymentResult { - pub payment_id: String, - pub txid: Option, -} - -#[derive(Debug, Clone)] -pub struct UtxosResult { - pub utxos: Vec, - pub total_sats: u64, -} - -#[derive(Debug, Clone)] -pub struct TransactionsResult { - pub transactions: Vec, - pub total_count: u32, -} - -#[derive(Debug, Clone)] -pub struct GhostLocksResult { - pub locks: Vec, - pub total_locked_sats: u64, -} - -/// Spawn a long-lived authenticated session task. Returns a handle. -/// -/// `ws_urls` is the failover list — the task tries them in order and rotates -/// on each reconnect attempt. Pass `vec![single_url]` for the single-endpoint case. -/// -/// `scan_keys` enables BIP-352 silent-payment detection. When provided, the task -/// runs the local scanner against every `CandidateTransaction` push from the -/// server and caches matches in `SessionStatus.detections`. Pass `None` to -/// disable client-side detection (saves CPU on session-bootstrap failure paths). -/// -/// `tor_proxy` (e.g. `Some("socks5h://127.0.0.1:9050")`) routes the WebSocket -/// connection through the given SOCKS5 proxy. Currently supports plain `ws://` -/// only — wss-over-Tor needs a separate TLS-aware connector. Pass `None` for -/// direct connections. -pub fn spawn_session( - ws_urls: Vec, - jwt_token: String, - scan_keys: Option, - tor_proxy: Option, -) -> SessionHandle { - spawn_session_with_bech32(ws_urls, jwt_token, scan_keys, None, tor_proxy) -} - -/// Same as [`spawn_session`] but accepts an explicit bech32 ghost-id -/// string. Required for non-mainnet wallets where -/// `GhostKeys::ghost_id().to_string()` (which encodes for mainnet) -/// produces the wrong HRP — the daemon knows the wallet's network -/// and computes the right `ghost1q...` form before -/// spawning the session. Forwarded with each `GetTransactions` so -/// ghost-pay can match recipient-side rows. -pub fn spawn_session_with_bech32( - ws_urls: Vec, - jwt_token: String, - scan_keys: Option, - ghost_id_bech32: Option, - tor_proxy: Option, -) -> SessionHandle { - let status = Arc::new(RwLock::new(SessionStatus::default())); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - let (cmd_tx, cmd_rx) = mpsc::channel::(32); - let (events_tx, _) = broadcast::channel::(256); - - let task = tokio::spawn(run( - ws_urls, - jwt_token, - scan_keys, - ghost_id_bech32, - tor_proxy, - status.clone(), - events_tx.clone(), - shutdown_rx, - cmd_rx, - )); - - SessionHandle { - status, - cmd_tx, - shutdown_tx, - events_tx, - task: Some(task), - } -} - -/// Connect a WebSocket, optionally routing the underlying TCP through a -/// SOCKS5 proxy (`socks5://host:port` or `socks5h://host:port`). The `h` -/// variant does DNS through the proxy — preferred for Tor. -/// -/// Returns the `(stream, response)` pair `tokio_tungstenite::connect_async` -/// would have returned. -async fn ws_connect( - ws_url: &str, - proxy: Option<&str>, -) -> Result< - ( - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - tokio_tungstenite::tungstenite::http::Response>>, - ), - String, -> { - let proxy_url = match proxy { - Some(p) if !p.is_empty() => p, - _ => { - // No proxy → direct connect. - return tokio_tungstenite::connect_async(ws_url) - .await - .map_err(|e| e.to_string()); - } - }; - - // Parse the WS URL to get host + port. - let parsed = url::Url::parse(ws_url).map_err(|e| format!("ws url parse: {e}"))?; - let host = parsed - .host_str() - .ok_or_else(|| "ws url has no host".to_string())? - .to_string(); - let port = parsed - .port_or_known_default() - .ok_or_else(|| "ws url has no port".to_string())?; - let scheme = parsed.scheme(); - if scheme != "ws" { - // wss-over-Tor needs an additional TLS layer that we haven't wired yet. - return Err(format!( - "wss-over-tor not yet supported (got scheme '{scheme}'); use ws:// or run an arti onion-service to a plain ws GSP" - )); - } - - // Strip the scheme so tokio_socks gets a host:port target. - let target = format!("{host}:{port}"); - - // Parse the proxy URL — only socks5/socks5h are supported. - let proxy_parsed = url::Url::parse(proxy_url).map_err(|e| format!("proxy url parse: {e}"))?; - let proxy_scheme = proxy_parsed.scheme(); - if proxy_scheme != "socks5" && proxy_scheme != "socks5h" { - return Err(format!( - "unsupported proxy scheme '{proxy_scheme}'; only socks5 and socks5h are supported" - )); - } - let proxy_host = proxy_parsed - .host_str() - .ok_or_else(|| "proxy url has no host".to_string())?; - let proxy_port = proxy_parsed - .port_or_known_default() - .ok_or_else(|| "proxy url has no port".to_string())?; - let proxy_target = format!("{proxy_host}:{proxy_port}"); - - // socks5h:// resolves the destination hostname inside the proxy (Tor), - // socks5:// resolves locally first (leaks DNS — discouraged with Tor). - let tcp = tokio_socks::tcp::Socks5Stream::connect(proxy_target.as_str(), target) - .await - .map_err(|e| format!("{proxy_scheme} connect: {e}"))? - .into_inner(); - - // Wrap as MaybeTlsStream::Plain BEFORE the WS handshake so the resulting - // stream type matches the non-proxy path (which goes through connect_async). - let plain = tokio_tungstenite::MaybeTlsStream::Plain(tcp); - tokio_tungstenite::client_async(ws_url, plain) - .await - .map_err(|e| format!("ws handshake over socks5: {e}")) -} - -const KEEPALIVE_SECS: u64 = 30; -const BACKOFF_INITIAL: Duration = Duration::from_secs(1); -const BACKOFF_MAX: Duration = Duration::from_secs(60); - -#[allow(clippy::too_many_arguments)] -async fn run( - ws_urls: Vec, - jwt_token: String, - scan_keys: Option, - ghost_id_bech32: Option, - tor_proxy: Option, - status: Arc>, - events_tx: broadcast::Sender, - mut shutdown: watch::Receiver, - mut cmd_rx: mpsc::Receiver, -) { - if ws_urls.is_empty() { - tracing::error!("gsp session: no ws urls configured, exiting"); - return; - } - let mut backoff = BACKOFF_INITIAL; - let mut url_idx: usize = 0; - loop { - if *shutdown.borrow() { - tracing::debug!("gsp session: shutdown requested, exiting"); - return; - } - - let ws_url = &ws_urls[url_idx % ws_urls.len()]; - - // === connect === - set_phase(&status, SessionPhase::Connecting).await; - let (mut ws, _) = match ws_connect(ws_url, tor_proxy.as_deref()).await { - Ok(p) => p, - Err(e) => { - let msg = e.to_string(); - tracing::warn!( - url = %ws_url, - error = %msg, - backoff = ?backoff, - "gsp session: connect failed, will rotate endpoint" - ); - set_error(&status, SessionPhase::Backoff, msg).await; - // Advance to next URL for the next attempt. - url_idx = url_idx.wrapping_add(1); - if sleep_or_shutdown(backoff, &mut shutdown).await { - return; - } - backoff = (backoff * 2).min(BACKOFF_MAX); - continue; - } - }; - // On a successful connect, reset backoff AND prefer the primary URL again - // (so failover is sticky-during-outage, not permanent). - backoff = BACKOFF_INITIAL; - url_idx = 0; - - // === authenticate === - set_phase(&status, SessionPhase::Authenticating).await; - let auth_msg = ClientMessage::Authenticate { - token: jwt_token.clone(), - }; - if let Err(e) = send_client(&mut ws, &auth_msg).await { - set_error(&status, SessionPhase::Backoff, format!("send auth: {e}")).await; - sleep_or_shutdown(backoff, &mut shutdown).await; - continue; - } - - // wait for AuthResult - match read_until_auth_result(&mut ws).await { - Ok(true) => {} // authenticated - Ok(false) => { - set_error( - &status, - SessionPhase::Backoff, - "server rejected authentication".into(), - ) - .await; - let _ = ws.close(None).await; - if sleep_or_shutdown(backoff, &mut shutdown).await { - return; - } - continue; - } - Err(e) => { - set_error(&status, SessionPhase::Backoff, format!("auth read: {e}")).await; - let _ = ws.close(None).await; - if sleep_or_shutdown(backoff, &mut shutdown).await { - return; - } - continue; - } - } - - { - let mut s = status.write().await; - s.phase = SessionPhase::Authenticated; - s.connect_count += 1; - s.last_error = None; - } - - // bootstrap: ask for current balance + (if we have scan keys) subscribe - // to silent-payment candidate-transaction pushes. - let _ = send_client(&mut ws, &ClientMessage::GetBalance { max_k: None }).await; - if scan_keys.is_some() { - let _ = send_client(&mut ws, &ClientMessage::SubscribeSilentPayments).await; - } - - // === main loop: drain messages + keepalive + commands === - let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_SECS)); - keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - keepalive.tick().await; // discard immediate tick - - let mut pending: VecDeque = VecDeque::new(); - let outcome = run_main_loop( - &mut ws, - &status, - &mut shutdown, - &mut keepalive, - &mut cmd_rx, - &mut pending, - scan_keys.as_ref(), - ghost_id_bech32.as_deref(), - &events_tx, - ) - .await; - - // On disconnect, fail any pending replies so callers don't hang. - for p in pending.drain(..) { - match p { - PendingReply::Utxos(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::Transactions(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::GhostLocks(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::PaymentPrepared(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::PaymentSubmitted(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::LockPrepared(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::LockConfirmed(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::JumpRequested(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::ScanKeyRegistered(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - PendingReply::PaymentSent(tx) => { - let _ = tx.send(Err("session disconnected".into())); - } - } - } - - let _ = ws.close(None).await; - match outcome { - MainLoopOutcome::Shutdown => return, - MainLoopOutcome::Disconnect(reason) => { - tracing::warn!(reason = %reason, "gsp session: disconnected, will reconnect"); - set_error(&status, SessionPhase::Backoff, reason).await; - if sleep_or_shutdown(backoff, &mut shutdown).await { - return; - } - backoff = (backoff * 2).min(BACKOFF_MAX); - } - } - } -} - -enum MainLoopOutcome { - Shutdown, - Disconnect(String), -} - -#[allow(clippy::too_many_arguments)] -async fn run_main_loop( - ws: &mut tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - status: &Arc>, - shutdown: &mut watch::Receiver, - keepalive: &mut tokio::time::Interval, - cmd_rx: &mut mpsc::Receiver, - pending: &mut VecDeque, - scan_keys: Option<&GhostKeys>, - ghost_id_bech32: Option<&str>, - events_tx: &broadcast::Sender, -) -> MainLoopOutcome { - loop { - tokio::select! { - biased; - _ = shutdown.changed() => { - if *shutdown.borrow() { - return MainLoopOutcome::Shutdown; - } - } - _ = keepalive.tick() => { - let ping = ClientMessage::Ping { - timestamp: Some(now_unix_ms()), - }; - if let Err(e) = send_client(ws, &ping).await { - return MainLoopOutcome::Disconnect(format!("send keepalive: {e}")); - } - } - cmd = cmd_rx.recv() => { - let Some(cmd) = cmd else { - // Sender dropped (shouldn't happen — handle owns it). Treat as shutdown. - return MainLoopOutcome::Shutdown; - }; - match cmd { - SessionCommand::GetUtxos { min_confirmations, reply } => { - let msg = ClientMessage::GetUtxos { min_confirmations }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send GetUtxos: {e}"))); - return MainLoopOutcome::Disconnect(format!("send GetUtxos: {e}")); - } - pending.push_back(PendingReply::Utxos(reply)); - } - SessionCommand::GetTransactions { limit, offset, reply } => { - // Forward the wallet's own bech32 ghost-id so - // ghost-pay can match L2 ledger rows where THIS - // wallet is the recipient. The daemon supplies - // the network-correct bech32 (mainnet/testnet/ - // signet/regtest HRP) at session-spawn time; - // we just forward it. - let msg = ClientMessage::GetTransactions { - limit, - offset, - wallet_bech32: ghost_id_bech32.map(|s| s.to_string()), - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send GetTransactions: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send GetTransactions: {e}" - )); - } - pending.push_back(PendingReply::Transactions(reply)); - } - SessionCommand::GetGhostLocks { reply } => { - let msg = ClientMessage::GetGhostLocks; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send GetGhostLocks: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send GetGhostLocks: {e}" - )); - } - pending.push_back(PendingReply::GhostLocks(reply)); - } - SessionCommand::PreparePayment { - recipient, - amount_sats, - mode, - proof, - memo, - reply, - } => { - let msg = ClientMessage::PreparePayment { - recipient, - amount_sats, - mode, - proof, - memo, - encrypted_metadata: None, - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send PreparePayment: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send PreparePayment: {e}" - )); - } - pending.push_back(PendingReply::PaymentPrepared(reply)); - } - SessionCommand::SubmitSignedPayment { - payment_id, - signature, - public_key, - reply, - } => { - let msg = ClientMessage::SubmitSignedPayment { - payment_id, - signature, - public_key, - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send SubmitSignedPayment: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send SubmitSignedPayment: {e}" - )); - } - pending.push_back(PendingReply::PaymentSubmitted(reply)); - } - SessionCommand::PrepareGhostLock { - owner_pubkey, - capacity_sats, - recovery_pubkey, - recovery_index, - reply, - } => { - let msg = ClientMessage::PrepareGhostLock { - owner_pubkey, - capacity_sats, - recovery_pubkey, - recovery_index, - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send PrepareGhostLock: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send PrepareGhostLock: {e}" - )); - } - pending.push_back(PendingReply::LockPrepared(reply)); - } - SessionCommand::ConfirmGhostLockFunding { - lock_id, - funding_txid, - proof, - reply, - } => { - let msg = ClientMessage::ConfirmGhostLockFunding { - lock_id, - funding_txid, - proof, - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send ConfirmGhostLockFunding: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send ConfirmGhostLockFunding: {e}" - )); - } - pending.push_back(PendingReply::LockConfirmed(reply)); - } - SessionCommand::RequestJump { - lock_id, - priority, - target_address, - proof, - reply, - } => { - let msg = ClientMessage::RequestJump { - lock_id, - priority, - target_address, - proof, - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send RequestJump: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send RequestJump: {e}" - )); - } - pending.push_back(PendingReply::JumpRequested(reply)); - } - SessionCommand::RegisterScanKey { - scan_pubkey, - proof, - reply, - } => { - let msg = ClientMessage::RegisterScanKey { - scan_pubkey, - proof, - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send RegisterScanKey: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send RegisterScanKey: {e}" - )); - } - pending.push_back(PendingReply::ScanKeyRegistered(reply)); - } - SessionCommand::SendL2Payment { - recipient, - amount_sats, - proof, - memo, - reply, - } => { - let msg = ClientMessage::SendL2Payment { - recipient, - amount_sats, - proof, - memo, - }; - if let Err(e) = send_client(ws, &msg).await { - let _ = reply.send(Err(format!("send SendL2Payment: {e}"))); - return MainLoopOutcome::Disconnect(format!( - "send SendL2Payment: {e}" - )); - } - pending.push_back(PendingReply::PaymentSent(reply)); - } - } - } - frame = ws.next() => { - let frame = match frame { - Some(Ok(f)) => f, - Some(Err(e)) => { - return MainLoopOutcome::Disconnect(format!("read: {e}")); - } - None => return MainLoopOutcome::Disconnect("server closed".into()), - }; - let text = match frame { - Message::Text(t) => t, - Message::Close(_) => { - return MainLoopOutcome::Disconnect("server closed".into()); - } - _ => continue, - }; - let parsed: ServerMessage = match serde_json::from_str(text.as_ref()) { - Ok(p) => p, - Err(e) => { - tracing::warn!(error = %e, raw = %text, "gsp session: bad server message"); - continue; - } - }; - handle_message(parsed, status, pending, scan_keys, events_tx).await; - } - } - } -} - -async fn handle_message( - msg: ServerMessage, - status: &Arc>, - pending: &mut VecDeque, - scan_keys: Option<&GhostKeys>, - events_tx: &broadcast::Sender, -) { - match msg { - // Push: balance update. - ServerMessage::BalanceUpdate { - confirmed, - unconfirmed, - locked, - } => { - let snap = BalanceSnapshot { - confirmed_sats: confirmed, - unconfirmed_sats: unconfirmed, - locked_sats: locked, - received_at: now_unix_secs(), - }; - tracing::debug!(?snap, "gsp session: balance update"); - status.write().await.last_balance = Some(snap); - } - - // Response to GetUtxos. - ServerMessage::Utxos { utxos, total_sats } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::Utxos(_))) - { - if let Some(PendingReply::Utxos(tx)) = pending.remove(idx) { - let _ = tx.send(Ok(UtxosResult { utxos, total_sats })); - return; - } - } - tracing::debug!("gsp session: unmatched Utxos message (no pending)"); - } - - // Response to GetTransactions. - ServerMessage::Transactions { - transactions, - total_count, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::Transactions(_))) - { - if let Some(PendingReply::Transactions(tx)) = pending.remove(idx) { - let _ = tx.send(Ok(TransactionsResult { - transactions, - total_count, - })); - return; - } - } - tracing::debug!("gsp session: unmatched Transactions message"); - } - - // Response to GetGhostLocks. - ServerMessage::GhostLocks { - locks, - total_locked_sats, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::GhostLocks(_))) - { - if let Some(PendingReply::GhostLocks(tx)) = pending.remove(idx) { - let _ = tx.send(Ok(GhostLocksResult { - locks, - total_locked_sats, - })); - return; - } - } - tracing::debug!("gsp session: unmatched GhostLocks message"); - } - - // Response to PreparePayment. - ServerMessage::PaymentPrepared { - success, - payment, - error, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::PaymentPrepared(_))) - { - if let Some(PendingReply::PaymentPrepared(tx)) = pending.remove(idx) { - let result = if success { - match payment { - Some(p) => Ok(p), - None => Err("server reported success but no payment".into()), - } - } else { - Err(error.unwrap_or_else(|| "PaymentPrepared failed".into())) - }; - let _ = tx.send(result); - return; - } - } - tracing::debug!("gsp session: unmatched PaymentPrepared message"); - } - - // Response to SubmitSignedPayment. - ServerMessage::PaymentSubmitted { - success, - payment_id, - txid, - error, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::PaymentSubmitted(_))) - { - if let Some(PendingReply::PaymentSubmitted(tx)) = pending.remove(idx) { - let result = if success { - Ok(SubmittedPaymentResult { payment_id, txid }) - } else { - Err(error.unwrap_or_else(|| "PaymentSubmitted failed".into())) - }; - let _ = tx.send(result); - return; - } - } - tracing::debug!("gsp session: unmatched PaymentSubmitted message"); - } - - // Response to SendL2Payment. - ServerMessage::PaymentSent { - success, - payment_id, - amount_sats, - recipient, - status, - error, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::PaymentSent(_))) - { - if let Some(PendingReply::PaymentSent(tx)) = pending.remove(idx) { - let result = if success { - match payment_id { - Some(pid) => Ok(SentL2PaymentResult { - payment_id: pid, - status: status.unwrap_or_else(|| "pending".into()), - recipient, - amount_sats, - }), - None => Err("server reported success but no payment_id".into()), - } - } else { - Err(error.unwrap_or_else(|| "PaymentSent failed".into())) - }; - let _ = tx.send(result); - return; - } - } - tracing::debug!("gsp session: unmatched PaymentSent message"); - } - - // Response to PrepareGhostLock. - ServerMessage::LockPrepared { - success, - lock_id, - funding_address, - required_sats, - lock_pubkey, - recovery_pubkey, - recovery_index, - recovery_blocks, - creation_height, - error, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::LockPrepared(_))) - { - if let Some(PendingReply::LockPrepared(tx)) = pending.remove(idx) { - let result = if success { - match ( - lock_id, - funding_address, - required_sats, - lock_pubkey, - recovery_pubkey, - recovery_index, - recovery_blocks, - creation_height, - ) { - ( - Some(id), - Some(addr), - Some(sats), - Some(lpk), - Some(rpk), - Some(ridx), - Some(rblocks), - Some(height), - ) => Ok(LockPreparedResult { - lock_id: id, - funding_address: addr, - required_sats: sats, - lock_pubkey: lpk, - recovery_pubkey: rpk, - recovery_index: ridx, - recovery_blocks: rblocks, - creation_height: height, - }), - _ => Err("server reported success but missing lock-script fields \ - — refusing to consider lock prepared" - .into()), - } - } else { - Err(error.unwrap_or_else(|| "LockPrepared failed".into())) - }; - let _ = tx.send(result); - return; - } - } - tracing::debug!("gsp session: unmatched LockPrepared message"); - } - - // Response to ConfirmGhostLockFunding. - ServerMessage::LockConfirmed { - lock_id, - txid, - block_height, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::LockConfirmed(_))) - { - if let Some(PendingReply::LockConfirmed(tx)) = pending.remove(idx) { - let _ = tx.send(Ok(LockConfirmedResult { - lock_id, - txid, - block_height, - })); - return; - } - } - tracing::debug!("gsp session: unmatched LockConfirmed message"); - } - - // Response to RequestJump. - ServerMessage::JumpRequested { - success, - lock_id, - jump_txid, - error, - } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::JumpRequested(_))) - { - if let Some(PendingReply::JumpRequested(tx)) = pending.remove(idx) { - let result = if success { - Ok(JumpRequestedResult { lock_id, jump_txid }) - } else { - Err(error.unwrap_or_else(|| "JumpRequested failed".into())) - }; - let _ = tx.send(result); - return; - } - } - tracing::debug!("gsp session: unmatched JumpRequested message"); - } - - // Response to RegisterScanKey. - ServerMessage::ScanKeyRegistered { success, error } => { - if let Some(idx) = pending - .iter() - .position(|p| matches!(p, PendingReply::ScanKeyRegistered(_))) - { - if let Some(PendingReply::ScanKeyRegistered(tx)) = pending.remove(idx) { - let result = if success { - Ok(()) - } else { - Err(error.unwrap_or_else(|| "ScanKeyRegistered failed".into())) - }; - let _ = tx.send(result); - return; - } - } - tracing::debug!("gsp session: unmatched ScanKeyRegistered message"); - } - - // Server-side error — surface it on the head of the pending queue. - ServerMessage::Error { - code, - message, - request_id, - } => { - tracing::warn!(%code, %message, ?request_id, "gsp session: server error"); - if let Some(p) = pending.pop_front() { - let err = format!("{code}: {message}"); - match p { - PendingReply::Utxos(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::Transactions(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::GhostLocks(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::PaymentPrepared(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::PaymentSubmitted(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::LockPrepared(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::LockConfirmed(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::JumpRequested(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::ScanKeyRegistered(tx) => { - let _ = tx.send(Err(err)); - } - PendingReply::PaymentSent(tx) => { - let _ = tx.send(Err(err)); - } - } - } - } - - // Push: BIP-352 silent-payment candidate. Run local scanner. - ServerMessage::CandidateTransaction { - ephemeral_pubkey, - outputs, - txid, - block_height, - } => { - let Some(keys) = scan_keys else { - tracing::trace!("gsp session: candidate tx but no scan keys; ignoring"); - return; - }; - match scan_candidate(keys, &ephemeral_pubkey, &outputs, &txid, block_height) { - Ok(detected) if !detected.is_empty() => { - tracing::info!( - matches = detected.len(), - %txid, - ?block_height, - "gsp session: silent-payment match detected" - ); - // Fan out to live subscribers BEFORE we drop the values into - // the status cache. send() returning Err just means no live - // subscribers — fine, the detection is still cached. - for d in &detected { - let _ = events_tx.send(d.clone()); - } - let mut s = status.write().await; - s.detections.extend(detected); - } - Ok(_) => { - tracing::trace!(%txid, "gsp session: candidate tx scanned, no match"); - } - Err(e) => { - tracing::debug!(%txid, error = %e, "gsp session: candidate scan error"); - } - } - } - - ServerMessage::Pong { .. } => { - tracing::trace!("gsp session: pong"); - } - - other => { - tracing::trace!(?other, "gsp session: unhandled push"); - } - } -} - -/// Run the local BIP-352 scanner against one candidate transaction. Returns -/// any detected payments belonging to `keys`. -fn scan_candidate( - keys: &GhostKeys, - ephemeral_pubkey_hex: &str, - outputs: &[CandidateOutput], - txid: &str, - block_height: Option, -) -> Result, String> { - use bitcoin::secp256k1::PublicKey; - - let eph_bytes = hex::decode(ephemeral_pubkey_hex).map_err(|e| format!("ephemeral hex: {e}"))?; - let ephemeral = - PublicKey::from_slice(&eph_bytes).map_err(|e| format!("ephemeral pubkey: {e}"))?; - - // Decode each x-only (32-byte) output pubkey. Stash the raw x-only bytes - // for later — BIP-352 output keys are taproot (x-only on chain), so we - // need to try BOTH parities (0x02 / 0x03) when feeding the scanner, - // since `PaymentDetector` compares full SEC1 byte equality and only - // one of the two parities will be the real BIP-352-derived point. - struct Decoded { - xonly: [u8; 32], - amount: Option, - vout: u32, - } - let mut decoded: Vec = Vec::with_capacity(outputs.len()); - for out in outputs { - let xonly_bytes = - hex::decode(&out.output_pubkey).map_err(|e| format!("output hex: {e}"))?; - if xonly_bytes.len() != 32 { - return Err(format!( - "output_pubkey must be 32 bytes (x-only), got {}", - xonly_bytes.len() - )); - } - let mut xonly = [0u8; 32]; - xonly.copy_from_slice(&xonly_bytes); - decoded.push(Decoded { - xonly, - amount: out.amount_sats, - vout: out.vout, - }); - } - - let detector = PaymentDetector::new(keys); - let now = now_unix_secs(); - let mut detections: Vec = Vec::new(); - - // Scan once with each parity. Dedupe matches by (vout, k) since the same - // real output can never match under both parities (each x-only key - // belongs to exactly one curve point with a defined parity). - for parity in [0x02u8, 0x03u8] { - let mut scan_inputs: Vec<(PublicKey, Option)> = Vec::with_capacity(decoded.len()); - for d in &decoded { - let mut sec1 = [0u8; 33]; - sec1[0] = parity; - sec1[1..].copy_from_slice(&d.xonly); - let pk = match PublicKey::from_slice(&sec1) { - Ok(p) => p, - Err(_) => { - // Off-curve x-only with this parity — skip this input. - continue; - } - }; - scan_inputs.push((pk, d.amount)); - } - let scanned = detector.scan_transaction(&ephemeral, &scan_inputs); - for s in scanned { - // Map the scanner's slice-index back to our on-chain vout. - // Note: the slice index can drift if any inputs were skipped above; - // we only skip on parse failure which should never happen for valid - // x-only bytes, so this is safe in practice. - let d = match decoded.get(s.output_index as usize) { - Some(d) => d, - None => continue, - }; - // Dedupe across parities. - if detections.iter().any(|x| x.vout == d.vout && x.k == s.k) { - continue; - } - detections.push(DetectedPayment { - txid: txid.to_string(), - block_height, - vout: d.vout, - amount_sats: s.amount, - k: s.k, - received_at: now, - }); - } - } - Ok(detections) -} - -/// Read frames until we see an `AuthResult`. Drops anything else. -async fn read_until_auth_result( - ws: &mut tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, -) -> Result { - let timeout = tokio::time::sleep(Duration::from_secs(15)); - tokio::pin!(timeout); - loop { - tokio::select! { - _ = &mut timeout => return Err("timeout waiting for AuthResult".into()), - frame = ws.next() => { - let frame = frame - .ok_or_else(|| "server closed during auth".to_string())? - .map_err(|e| format!("ws error during auth: {e}"))?; - let text = match frame { - Message::Text(t) => t, - Message::Close(_) => return Err("server closed during auth".into()), - _ => continue, - }; - let parsed: ServerMessage = serde_json::from_str(text.as_ref()) - .map_err(|e| format!("decoding ServerMessage: {e}"))?; - match parsed { - ServerMessage::AuthResult { success, error, .. } => { - if !success { - tracing::warn!(error = ?error, "gsp session: auth rejected"); - } - return Ok(success); - } - // Server may push errors before we authenticate; surface and keep waiting. - ServerMessage::Error { message, .. } => { - return Err(format!("server error during auth: {message}")); - } - _ => continue, - } - } - } - } -} - -async fn send_client( - ws: &mut tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - msg: &ClientMessage, -) -> Result<(), String> { - let json = serde_json::to_string(msg).map_err(|e| format!("encode: {e}"))?; - ws.send(Message::Text(json)) - .await - .map_err(|e| format!("send: {e}")) -} - -async fn set_phase(status: &Arc>, phase: SessionPhase) { - let mut s = status.write().await; - s.phase = phase; -} - -async fn set_error(status: &Arc>, phase: SessionPhase, msg: String) { - let mut s = status.write().await; - s.phase = phase; - s.last_error = Some(msg); -} - -/// Sleep for `dur`, but return true early if shutdown was signalled. -async fn sleep_or_shutdown(dur: Duration, shutdown: &mut watch::Receiver) -> bool { - tokio::select! { - _ = tokio::time::sleep(dur) => false, - _ = shutdown.changed() => *shutdown.borrow(), - } -} - -fn now_unix_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -fn now_unix_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// End-to-end synthetic test: sender constructs a BIP-352 payment to a - /// receiver's `GhostKeys`, packages it as a `CandidateTransaction`, and - /// the wallet's `scan_candidate` detects the match. - #[test] - fn scan_candidate_detects_synthetic_match() { - use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; - use ghost_keys::{derive_payment_address_v2, derive_shared_secret}; - use rand::RngCore; - - let receiver = GhostKeys::generate(); - - // Sender's role: pick a one-shot ephemeral keypair (in real BIP-352 - // this is derived from the input set; the scanner only sees the pubkey). - let secp = Secp256k1::new(); - let mut eph_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut eph_bytes); - let eph_secret = SecretKey::from_slice(&eph_bytes).expect("nonzero scalar"); - let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); - - // Both sides compute the same shared secret via ECDH (commutativity). - // Sender side: ECDH(eph_secret, receiver.scan_pubkey). - let shared_secret = derive_shared_secret(&eph_secret, receiver.scan_pubkey()); - - // Sender derives the destination output pubkey at index k=0. - let k: u32 = 0; - let (output_pubkey, _tweak) = - derive_payment_address_v2(receiver.spend_pubkey(), &shared_secret, k) - .expect("derive output pubkey"); - - // On chain we'd see the x-only form (taproot output). - let serialized = output_pubkey.serialize(); - let xonly = &serialized[1..]; - - let candidate_outputs = vec![CandidateOutput { - output_pubkey: hex::encode(xonly), - amount_sats: Some(50_000), - vout: 7, - }]; - - let txid = "0".repeat(64); - let detections = scan_candidate( - &receiver, - &hex::encode(ephemeral_pub.serialize()), - &candidate_outputs, - &txid, - Some(123_456), - ) - .expect("scan succeeds"); - - assert_eq!(detections.len(), 1, "expected one match"); - let det = &detections[0]; - assert_eq!(det.k, k); - assert_eq!(det.amount_sats, Some(50_000)); - assert_eq!(det.vout, 7); - assert_eq!(det.block_height, Some(123_456)); - assert_eq!(det.txid, txid); - } - - #[test] - fn scan_candidate_returns_empty_on_no_match() { - use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; - use rand::RngCore; - - let receiver = GhostKeys::generate(); - - let secp = Secp256k1::new(); - let mut eph_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut eph_bytes); - let eph_secret = SecretKey::from_slice(&eph_bytes).unwrap(); - let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); - - // Output addressed to a DIFFERENT receiver — should not match. - let other = GhostKeys::generate(); - let mut other_eph_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut other_eph_bytes); - let other_eph_secret = SecretKey::from_slice(&other_eph_bytes).unwrap(); - let shared_secret = - ghost_keys::derive_shared_secret(&other_eph_secret, other.scan_pubkey()); - let (output_pubkey, _tweak) = - ghost_keys::derive_payment_address_v2(other.spend_pubkey(), &shared_secret, 0).unwrap(); - let serialized = output_pubkey.serialize(); - let xonly = &serialized[1..]; - - let candidate_outputs = vec![CandidateOutput { - output_pubkey: hex::encode(xonly), - amount_sats: Some(1_000), - vout: 0, - }]; - - let detections = scan_candidate( - &receiver, - &hex::encode(ephemeral_pub.serialize()), - &candidate_outputs, - "deadbeef", - None, - ) - .expect("scan succeeds"); - - assert!( - detections.is_empty(), - "no match expected, got {:?}", - detections - ); - } -} diff --git a/apps/wraith-wallet/core/src/history_store.rs b/apps/wraith-wallet/core/src/history_store.rs new file mode 100644 index 000000000..c68e3d8b4 --- /dev/null +++ b/apps/wraith-wallet/core/src/history_store.rs @@ -0,0 +1,379 @@ +//! The wallet's own record of what it has done. +//! +//! # Why this exists +//! +//! Transaction history came from the GSP session — the operator kept the +//! ledger and the wallet asked. Ghost Pay is being removed, so the wallet has +//! to remember for itself, and it has nowhere to remember *to*: the daemon has +//! never had local storage of any kind. +//! +//! # Two writers +//! +//! Entries arrive from two places and meet on the txid. A broadcast writes one +//! the moment the node accepts it, knowing the memo and the exact fee. The +//! block scanner writes one when it sees the transaction mined, knowing the +//! height and the block time. Neither knows what the other knows, so +//! [`HistoryStore::record`] merges rather than replaces: a field the newcomer +//! left empty keeps the value already there. +//! +//! Getting that wrong is not an abstract concern — the scanner runs after +//! every broadcast, so a replacing write would erase the memo and the fee on +//! every payment the wallet made, a few minutes after making it. +//! +//! # Durability +//! +//! Same shape as the other stores: write-temp, fsync, rename, fsync-dir, mode +//! 0600. A history entry is not money, so a lost write costs a record rather +//! than a coin — but the record is written *before* the broadcast is reported, +//! because the case that matters is a crash between sending and remembering. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// One thing the wallet did. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct HistoryEntry { + pub txid: String, + /// When it happened, unix seconds: the block time once it is mined, and + /// the moment of broadcast until then. + /// + /// The alias keeps files written before incoming payments were recorded + /// readable — back then every entry was a broadcast, so the name was + /// accurate and is now too narrow. + #[serde(alias = "broadcast_at")] + pub at: i64, + /// The block it was mined in. `None` while it is unconfirmed — which is + /// distinct from height zero, and is why a caller must not compute + /// confirmations by subtracting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub block_height: Option, + /// Net change to the wallet, negative for a spend. + /// + /// `None` when the wallet could not work it out — broadcasting a finished + /// PSBT does not require an unlocked wallet, and without the keys there is + /// no way to tell which outputs are ours. `None` and `Some(0)` are + /// different facts and are kept apart: one is "not recorded", the other is + /// "moved nothing". + pub amount_sats: Option, + /// Miner fee, when the wallet built the transaction and therefore knows it. + pub fee_sats: Option, + /// What happened: `receive`, `send`, `lock_fund`, `lock_escape`, `mix`. + pub kind: String, + pub memo: Option, +} + +/// A JSON file of [`HistoryEntry`], keyed by txid. +#[derive(Debug)] +pub struct HistoryStore { + path: PathBuf, + entries: BTreeMap, +} + +impl HistoryStore { + /// Open (or create) the history at `path`. + /// + /// A malformed file is an error rather than an empty history. Silently + /// starting fresh would present "you have never sent anything" as a fact, + /// and somebody checking whether a payment went out would believe it. + pub fn open(path: impl AsRef) -> std::io::Result { + let path = path.as_ref().to_path_buf(); + let entries = if path.exists() { + let raw = fs::read_to_string(&path)?; + let rows: Vec = serde_json::from_str(&raw).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "history at {} is unreadable ({e}); refusing to continue with an \ + empty one, which would read as 'you have never sent anything'", + path.display() + ), + ) + })?; + rows.into_iter().map(|r| (r.txid.clone(), r)).collect() + } else { + BTreeMap::new() + }; + Ok(Self { path, entries }) + } + + /// Every entry, newest first. + pub fn list(&self) -> Vec { + let mut all: Vec = self.entries.values().cloned().collect(); + // Tie-broken by txid so the order is stable across calls. Two + // transactions in one block share a timestamp, and a list that + // reshuffles between refreshes is a list nobody can point at. + all.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.txid.cmp(&b.txid))); + all + } + + /// How many are held. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the history is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Record what one writer knows about a transaction. + /// + /// Keyed by txid, so a transaction seen twice is one entry — a rebroadcast + /// is the same payment, and showing it twice would read as having paid + /// twice. Where both writers have something to say, the newcomer wins on + /// what it actually measured and leaves the rest alone: + /// + /// * a confirmed height and block time replace an unconfirmed guess, but + /// an entry that is already mined is not un-mined by a later mempool + /// sighting; + /// * a memo, a fee or an amount is never overwritten with `None`, because + /// the block scanner cannot see a memo and must not delete one. + pub fn record(&mut self, entry: HistoryEntry) -> std::io::Result<()> { + let key = entry.txid.clone(); + let merged = match self.entries.get(&key) { + None => entry, + Some(old) => HistoryEntry { + txid: entry.txid, + // A mined entry keeps its block time; nothing later is a + // better answer to "when did this happen". + at: if old.block_height.is_some() && entry.block_height.is_none() { + old.at + } else { + entry.at + }, + block_height: entry.block_height.or(old.block_height), + amount_sats: entry.amount_sats.or(old.amount_sats), + fee_sats: entry.fee_sats.or(old.fee_sats), + // The broadcast path knows what a spend was *for* + // (`lock_fund`, `mix`); the scanner only ever sees `send` or + // `receive`. Keep the more specific label. + kind: if entry.kind == "send" && old.kind != "send" { + old.kind.clone() + } else { + entry.kind + }, + memo: entry.memo.or_else(|| old.memo.clone()), + }, + }; + let previous = self.entries.insert(key.clone(), merged); + if let Err(e) = self.flush() { + match previous { + Some(p) => { + self.entries.insert(key, p); + } + None => { + self.entries.remove(&key); + } + } + return Err(e); + } + Ok(()) + } + + /// Forget the confirmations of everything mined at `height` or above. + /// + /// Called when the scanner finds the chain has moved out from under it. + /// The entries stay — the wallet really did make those payments, and a + /// reorg does not unmake a broadcast — but their heights came from blocks + /// that are no longer in the chain, so keeping them would state a fact + /// about a chain nobody is on. They go back to unconfirmed and the rescan + /// re-confirms whichever ones survived. + /// + /// Returns how many entries were affected. + pub fn unconfirm_from(&mut self, height: u32) -> std::io::Result { + let affected: Vec = self + .entries + .values() + .filter(|e| e.block_height.is_some_and(|h| h >= height)) + .map(|e| e.txid.clone()) + .collect(); + if affected.is_empty() { + return Ok(0); + } + let snapshot = self.entries.clone(); + for txid in &affected { + if let Some(e) = self.entries.get_mut(txid) { + e.block_height = None; + } + } + if let Err(e) = self.flush() { + self.entries = snapshot; + return Err(e); + } + Ok(affected.len()) + } + + fn flush(&self) -> std::io::Result<()> { + let rows: Vec<&HistoryEntry> = self.entries.values().collect(); + let body = serde_json::to_vec_pretty(&rows).map_err(std::io::Error::other)?; + ghost_lock::atomic_file::write_atomic(&self.path, &body, Some(0o600)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(txid: &str, at: i64, sats: i64) -> HistoryEntry { + HistoryEntry { + txid: txid.into(), + at, + block_height: None, + amount_sats: Some(sats), + fee_sats: Some(500), + kind: "send".into(), + memo: None, + } + } + + #[test] + fn a_record_survives_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("history.json"); + { + let mut h = HistoryStore::open(&path).unwrap(); + h.record(entry("aa", 100, -1_000)).unwrap(); + } + let h = HistoryStore::open(&path).unwrap(); + assert_eq!(h.len(), 1); + assert_eq!(h.list()[0].amount_sats, Some(-1_000)); + } + + /// Newest first — a history that starts at the beginning buries the thing + /// somebody just did. + #[test] + fn entries_come_back_newest_first() { + let dir = tempfile::tempdir().unwrap(); + let mut h = HistoryStore::open(dir.path().join("h.json")).unwrap(); + h.record(entry("old", 100, -1)).unwrap(); + h.record(entry("new", 900, -2)).unwrap(); + let list = h.list(); + assert_eq!(list[0].txid, "new"); + } + + /// A rebroadcast is the same payment, not a second one. + #[test] + fn recording_one_txid_twice_updates_rather_than_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let mut h = HistoryStore::open(dir.path().join("h.json")).unwrap(); + h.record(entry("aa", 100, -1_000)).unwrap(); + h.record(entry("aa", 200, -1_000)).unwrap(); + assert_eq!(h.len(), 1, "one transaction is one history entry"); + assert_eq!(h.list()[0].at, 200); + } + + /// The scanner sees every payment the wallet made, a few minutes after it + /// made it. If its write replaced rather than merged, every memo and every + /// measured fee would quietly disappear on confirmation. + #[test] + fn confirming_a_broadcast_keeps_what_only_the_broadcast_knew() { + let dir = tempfile::tempdir().unwrap(); + let mut h = HistoryStore::open(dir.path().join("h.json")).unwrap(); + h.record(HistoryEntry { + txid: "aa".into(), + at: 100, + block_height: None, + amount_sats: Some(-50_500), + fee_sats: Some(500), + kind: "lock_fund".into(), + memo: Some("rent".into()), + }) + .unwrap(); + // What the block scanner knows, and only that. + h.record(HistoryEntry { + txid: "aa".into(), + at: 900, + block_height: Some(900_001), + amount_sats: Some(-50_500), + fee_sats: None, + kind: "send".into(), + memo: None, + }) + .unwrap(); + + let e = &h.list()[0]; + assert_eq!(e.block_height, Some(900_001), "the height is news"); + assert_eq!( + e.at, 900, + "the block time dates it better than the broadcast" + ); + assert_eq!(e.memo.as_deref(), Some("rent"), "the memo must survive"); + assert_eq!(e.fee_sats, Some(500), "the measured fee must survive"); + assert_eq!( + e.kind, "lock_fund", + "the specific label beats the generic one" + ); + } + + /// A mempool sighting after confirmation must not un-mine the entry. + #[test] + fn a_later_unconfirmed_sighting_does_not_clear_the_height() { + let dir = tempfile::tempdir().unwrap(); + let mut h = HistoryStore::open(dir.path().join("h.json")).unwrap(); + let mut mined = entry("aa", 100, -1_000); + mined.block_height = Some(900_000); + mined.at = 500; + h.record(mined).unwrap(); + h.record(entry("aa", 900, -1_000)).unwrap(); + + let e = &h.list()[0]; + assert_eq!(e.block_height, Some(900_000)); + assert_eq!(e.at, 500, "a confirmed entry keeps its block time"); + } + + /// A reorg unmakes a confirmation, never the entry. The payment still + /// happened; what changed is the chain it was mined into. + #[test] + fn a_reorg_unconfirms_without_deleting() { + let dir = tempfile::tempdir().unwrap(); + let mut h = HistoryStore::open(dir.path().join("h.json")).unwrap(); + for (txid, height) in [ + ("old", 899_998u32), + ("forked", 900_001), + ("deeper", 900_005), + ] { + let mut e = entry(txid, 1, -1_000); + e.block_height = Some(height); + h.record(e).unwrap(); + } + let n = h.unconfirm_from(900_000).unwrap(); + assert_eq!(n, 2, "both entries at or above the fork"); + + let by_txid: std::collections::HashMap<_, _> = + h.list().into_iter().map(|e| (e.txid.clone(), e)).collect(); + assert_eq!(by_txid.len(), 3, "nothing is deleted"); + assert_eq!( + by_txid["old"].block_height, + Some(899_998), + "a block below the fork is untouched" + ); + assert_eq!(by_txid["forked"].block_height, None); + assert_eq!(by_txid["deeper"].block_height, None); + } + + /// A corrupt file must not read as "you have never sent anything". + #[test] + fn a_corrupt_history_is_refused_not_emptied() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("h.json"); + fs::write(&path, "{ not json").unwrap(); + let err = HistoryStore::open(&path).expect_err("must refuse"); + assert!( + format!("{err}").contains("never sent anything"), + "the error must say what the empty reading would imply: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn the_history_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("h.json"); + let mut h = HistoryStore::open(&path).unwrap(); + h.record(entry("aa", 1, -1)).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "a spending history is private"); + } +} diff --git a/apps/wraith-wallet/core/src/lib.rs b/apps/wraith-wallet/core/src/lib.rs index ce4420519..f078de10f 100644 --- a/apps/wraith-wallet/core/src/lib.rs +++ b/apps/wraith-wallet/core/src/lib.rs @@ -4,16 +4,29 @@ //! Binaries (`wraithd`, `wraith`) and the GUI shell are thin wrappers over this crate. pub mod auth; +pub mod block_scan; +pub mod candidate_scan; pub mod chain; pub mod descriptor; +pub mod detection_store; +pub mod ghost_lock_account; +pub mod ghost_lock_store; pub mod ghostd; -pub mod gsp; +pub mod history_store; pub mod keystore; pub mod light; -pub mod lock_recovery; +pub mod lock_cosign_client; pub mod mainnet_guard; pub mod psbt; +pub mod scan_state; pub mod signer; -pub mod user_entropy; +pub mod silent_payment; +pub mod wallet_meta; +/// Re-exported: the implementation moved to `ghost-entropy` so the offline +/// signer can share it. Unchanged, tags included. +pub use ghost_entropy as user_entropy; +/// Re-exported: moved to `wraith-protocol` beside the trait it implements, so +/// the coordinator can use it without depending on the wallet. +pub use wraith_protocol::signing_ledger_file; pub mod wraith; pub mod wraith_signer; diff --git a/apps/wraith-wallet/core/src/light/mod.rs b/apps/wraith-wallet/core/src/light/mod.rs index 8f6aed810..bb8aed2f5 100644 --- a/apps/wraith-wallet/core/src/light/mod.rs +++ b/apps/wraith-wallet/core/src/light/mod.rs @@ -22,14 +22,43 @@ pub enum LightError { Bitcoin(String), } +/// The plain wallet's receive chain: BIP86 account `0'`. +pub fn receive_path(index: u32) -> String { + format!("m/86'/{}'/0'/0/{}", GHOST_COIN_TYPE, index) +} + +/// A Ghost Lock owner key: account `1'`, kept off the plain wallet's account +/// so a Lock coin and a loose coin are never the same coin. +/// +/// The Cash lane is a bare key-path output for this key, so an address derived +/// here is a Cash lane address — which is why the ordinary signer has to know +/// about this family. Cash spends with the owner's key alone, and if the +/// signer only walks the receive chain those coins cannot be spent at all. +pub fn lock_owner_path(index: u32) -> String { + format!("m/86'/{}'/1'/0/{}", GHOST_COIN_TYPE, index) +} + /// Derive a fresh BIP86 (taproot) receive address at index `index`. pub fn receive_address( keystore: &Keystore, index: u32, network: Network, ) -> Result { - let path = format!("m/86'/{}'/0'/0/{}", GHOST_COIN_TYPE, index); - let xprv = keystore.derive_xprv(&path)?; + address_at(keystore, &receive_path(index), network) +} + +/// The key-path address for a Lock owner key at `index` — i.e. its Cash lane. +pub fn lock_owner_address( + keystore: &Keystore, + index: u32, + network: Network, +) -> Result { + address_at(keystore, &lock_owner_path(index), network) +} + +/// The BIP86 key-path address for one derivation path. +fn address_at(keystore: &Keystore, path: &str, network: Network) -> Result { + let xprv = keystore.derive_xprv(path)?; // bip32 returns a 33-byte SEC1 compressed pubkey; for taproot we want the // 32-byte x-only form (drop the parity prefix byte). diff --git a/apps/wraith-wallet/core/src/lock_cosign_client.rs b/apps/wraith-wallet/core/src/lock_cosign_client.rs new file mode 100644 index 000000000..588389657 --- /dev/null +++ b/apps/wraith-wallet/core/src/lock_cosign_client.rs @@ -0,0 +1,217 @@ +//! Asking the Wraith quorum to co-sign a Ghost Lock Spending spend. +//! +//! # Why this is one call and the device flow is three +//! +//! Both are MuSig2, so both are two rounds. The difference is who carries the +//! bytes: an air-gapped device needs a person walking between two machines, so +//! the wallet has to stop and hand them something. The quorum is reachable +//! over HTTP, so the wallet does both rounds itself and the user sees one +//! action. +//! +//! # A refusal is not a failure +//! +//! The quorum is entitled to say no — that is the entire security value of it +//! being a second factor rather than a rubber stamp. So a 403 is reported as +//! what it is, with the reason the coordinator gave, rather than flattened +//! into "request failed". An owner told only that something went wrong will +//! retry; an owner told the spend exceeds the window will wait. + +use bitcoin::TapNodeHash; +use ghost_lock::airgap::SigningRequest; +use ghost_lock::signing::{combine, NonceLedger, SigningSession}; +use serde::{Deserialize, Serialize}; + +/// Why the wallet could not get a co-signature. +#[derive(Debug, thiserror::Error)] +pub enum CosignError { + /// The quorum declined, and said why. + #[error("the quorum refused: {detail}")] + Refused { + /// The coordinator's machine-readable reason. + code: String, + /// Its human-readable one. + detail: String, + }, + /// This coordinator does not co-sign Locks at all. + #[error( + "this coordinator does not co-sign Ghost Locks ({detail}); try another one rather \ + than changing the spend" + )] + NotOffered { + /// What it said. + detail: String, + }, + /// The request never got there, or the reply made no sense. + #[error("could not reach the quorum: {0}")] + Transport(String), + /// Local signing failed. + #[error("{0}")] + Local(String), +} + +#[derive(Serialize)] +struct NonceBody<'a> { + binding_id: &'a str, + request: &'a SigningRequest, +} + +#[derive(Deserialize)] +struct NonceReply { + session: String, + public_nonce: String, + input_sats: u64, + fee_sats: u64, +} + +#[derive(Serialize)] +struct PartialBody<'a> { + session: &'a str, + public_nonces: Vec, +} + +#[derive(Deserialize)] +struct PartialReply { + partial: String, +} + +#[derive(Deserialize)] +struct ErrorBody { + error: String, + detail: String, +} + +/// What the quorum understood the spend to be. +/// +/// Returned so the wallet can check the two sides agree before the signature +/// is used. They are derived from the same PSBT, so a mismatch means one of +/// them read a different transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuorumView { + /// What leaves the lane. + pub input_sats: u64, + /// What the miners take. + pub fee_sats: u64, +} + +/// Run both rounds against a coordinator and return the finished signature. +/// +/// The wallet's own nonce is burned in its ledger before its partial signature +/// exists, exactly as in the device flow — the counterparty being a service +/// rather than a person changes nothing about nonce safety. +#[allow(clippy::too_many_arguments)] +pub async fn cosign_with_quorum( + http: &reqwest::Client, + coordinator_url: &str, + binding_id: &str, + request: &SigningRequest, + owner_key: &bitcoin::secp256k1::SecretKey, + keys: &[bitcoin::XOnlyPublicKey], + merkle_root: Option, + message: &[u8; 32], + nonces_ledger: &mut L, +) -> Result<(bitcoin::secp256k1::schnorr::Signature, QuorumView), CosignError> { + let base = coordinator_url.trim_end_matches('/'); + + // Round 1, quorum side. + let resp = http + .post(format!("{base}/api/v1/lock/cosign/nonce")) + .json(&NonceBody { + binding_id, + request, + }) + .send() + .await + .map_err(|e| CosignError::Transport(e.to_string()))?; + let quorum_nonce = read_nonce(resp).await?; + + // Round 1, our side. After the quorum's, so a refusal costs us no nonce. + let (session, commitment) = SigningSession::begin(keys, owner_key, merkle_root, message) + .map_err(|e| CosignError::Local(format!("round 1: {e}")))?; + + let their_nonce: [u8; 66] = hex::decode(quorum_nonce.public_nonce.trim()) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| { + CosignError::Transport("the quorum's public nonce is not 66 bytes of hex".into()) + })?; + + // Sorted, so both sides aggregate the same set without agreeing an order. + let mut nonces = vec![commitment.public_nonce, their_nonce]; + nonces.sort_unstable(); + + // Round 2, quorum side. + let resp = http + .post(format!("{base}/api/v1/lock/cosign/partial")) + .json(&PartialBody { + session: &quorum_nonce.session, + public_nonces: nonces.iter().map(hex::encode).collect(), + }) + .send() + .await + .map_err(|e| CosignError::Transport(e.to_string()))?; + let their_partial = read_partial(resp).await?; + + // Round 2, our side. + let ours = session + .sign(nonces_ledger, &nonces) + .map_err(|e| CosignError::Local(format!("round 2: {e}")))?; + + let sig = combine(keys, merkle_root, &nonces, &[ours, their_partial], message) + .map_err(|e| CosignError::Local(format!("combine: {e}")))?; + + Ok(( + sig, + QuorumView { + input_sats: quorum_nonce.input_sats, + fee_sats: quorum_nonce.fee_sats, + }, + )) +} + +async fn read_nonce(resp: reqwest::Response) -> Result { + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| CosignError::Transport(e.to_string()))?; + if status.is_success() { + return serde_json::from_str(&body) + .map_err(|e| CosignError::Transport(format!("unreadable reply: {e}"))); + } + Err(classify(status, &body)) +} + +async fn read_partial(resp: reqwest::Response) -> Result<[u8; 32], CosignError> { + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| CosignError::Transport(e.to_string()))?; + if !status.is_success() { + return Err(classify(status, &body)); + } + let parsed: PartialReply = serde_json::from_str(&body) + .map_err(|e| CosignError::Transport(format!("unreadable reply: {e}")))?; + hex::decode(parsed.partial.trim()) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| { + CosignError::Transport("the quorum's partial signature is not 32 bytes of hex".into()) + }) +} + +/// Turn a failing reply into an error that says what kind of "no" it was. +/// +/// 501 is kept distinct from 403 because they send an owner to opposite +/// places: one to a different coordinator, the other to a different spend. +fn classify(status: reqwest::StatusCode, body: &str) -> CosignError { + let parsed: Option = serde_json::from_str(body).ok(); + let (code, detail) = match parsed { + Some(e) => (e.error, e.detail), + None => (status.as_u16().to_string(), body.trim().to_string()), + }; + if status == reqwest::StatusCode::NOT_IMPLEMENTED { + return CosignError::NotOffered { detail }; + } + CosignError::Refused { code, detail } +} diff --git a/apps/wraith-wallet/core/src/lock_recovery.rs b/apps/wraith-wallet/core/src/lock_recovery.rs deleted file mode 100644 index b74fd7ad2..000000000 --- a/apps/wraith-wallet/core/src/lock_recovery.rs +++ /dev/null @@ -1,403 +0,0 @@ -//! Unilateral exit for Ghost Locks. -//! -//! Builds, signs, and produces a hex-encoded transaction that spends -//! a locked UTXO via the script's recovery branch (OP_ELSE: CSV + -//! recovery_pubkey OP_CHECKSIG). The wallet uses ITS OWN -//! recovery_secret (derived from `Keystore::ghost_keys()` at the -//! `recovery_index` it sent to ghost-pay at lock-prepare time). No -//! ghost-pay, no ghost-gsp, no operator cooperation. Just bitcoin. -//! -//! ## What this module does NOT do -//! -//! - **Broadcast.** The caller (daemon) hands the resulting raw tx -//! hex to a `GhostdRpc::send_raw_transaction` call. Decoupling -//! build-and-sign from broadcast means tests can assert tx shape -//! without spinning up a node, and the daemon can dry-run an -//! exit before sending. -//! -//! - **Funding outpoint discovery.** The caller passes the funding -//! `(txid, vout, value_sats)` it has already resolved (e.g. via -//! bitcoind's `getrawtransaction`). This module trusts those. -//! -//! - **Maturity check.** The caller asserts `current_height >= -//! creation_height + recovery_blocks` before calling. Bitcoin -//! itself rejects the spend if the CSV isn't satisfied, but -//! surfacing a friendly error before broadcast is better UX. -//! -//! ## Witness shape -//! -//! For the recovery branch, the witness stack is exactly: -//! -//! 1. Schnorr-style ECDSA signature with sighash byte appended -//! (P2WSH = ECDSA, NOT Schnorr — Schnorr is taproot only). -//! 2. The bytecode `0x` (empty / zero — selects OP_ELSE). -//! 3. The witness script (so the verifier can re-hash and check -//! against the scriptPubKey's WSH). -//! -//! Per the script docstring in ghost-locks/src/script.rs: -//! `Recovery: <0> ` - -use bitcoin::absolute::LockTime; -use bitcoin::ecdsa::Signature as EcdsaSignature; -use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; -use bitcoin::sighash::{EcdsaSighashType, SighashCache}; -use bitcoin::transaction::Version; -use bitcoin::{ - Address, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, - Witness, -}; -use std::str::FromStr; - -use ghost_locks::build_wsh_witness_script; - -#[derive(Debug, thiserror::Error)] -pub enum LockRecoveryError { - #[error("hex decode: {0}")] - Hex(#[from] hex::FromHexError), - #[error("bitcoin: {0}")] - Bitcoin(String), - #[error("ghost-locks: {0}")] - GhostLocks(#[from] ghost_locks::GhostLockError), - #[error("destination address is not valid for network {network:?}: {detail}")] - BadDestinationAddress { network: Network, detail: String }, - #[error( - "insufficient input value: prev {prev_sats} sats, fee {fee_sats} sats — need fee < prev" - )] - InsufficientInput { prev_sats: u64, fee_sats: u64 }, - #[error("timelock not yet matured: current {current}, required {required}")] - TimelockNotMatured { current: u32, required: u32 }, - #[error("secp: {0}")] - Secp(String), -} - -/// Inputs to a recovery-spend transaction. All caller-supplied — -/// this module just pickles them into a signed tx. -#[derive(Debug, Clone)] -pub struct RecoverySpendInputs { - /// The `lock_pubkey` (cooperative-path key) — needed to - /// reconstruct the witness script. - pub lock_pubkey_hex: String, - /// The user's `recovery_pubkey`. Echoed back during prepare, - /// stashed on the daemon. Goes into the witness script. - pub recovery_pubkey_hex: String, - /// CSV blocks the recovery branch waits on. - pub recovery_blocks: u32, - /// Funding txid (the on-chain tx that paid the lock's address). - pub funding_txid: String, - /// vout of the lock-funding output in `funding_txid`. Caller - /// resolves via `getrawtransaction` + scriptPubKey match. - pub funding_vout: u32, - /// Value of the lock-funding output, in sats. - pub prev_value_sats: u64, - /// Hex-encoded scriptPubKey of the funding output (P2WSH). - /// Used for the BIP-143 sighash's prevout commitment. - pub funding_scriptpubkey_hex: String, - /// Where the recovered funds go. Wallet-controlled. - pub destination_address: String, - /// Mining fee to pay, in sats. Subtracted from - /// `prev_value_sats`. Caller picks based on bitcoind's fee - /// estimate or a flat amount. - pub fee_sats: u64, - /// Network the destination address is parsed against. - pub network: Network, - /// Current block height. Used to verify maturity before signing. - pub current_height: u32, - /// Block height the lock was created at. Combined with - /// `recovery_blocks` gives the maturity height. - pub creation_height: u32, -} - -/// Output of a successful recovery-spend build. -#[derive(Debug, Clone)] -pub struct BuiltRecoveryTx { - /// Consensus-encoded raw tx, hex. Caller broadcasts via - /// bitcoind's `sendrawtransaction`. - pub raw_hex: String, - /// The signed `bitcoin::Transaction`. Returned alongside the hex - /// for tests / diagnostics. - pub tx: Transaction, - /// txid the eventual on-chain spend will commit to. - pub txid: String, -} - -/// Build, sign, and serialise a Ghost-Lock recovery-path spend -/// using the user's `recovery_secret`. Pure function — no I/O, -/// no mutable state. -pub fn build_recovery_spend( - inputs: &RecoverySpendInputs, - recovery_secret: &SecretKey, -) -> Result { - // 0. Sanity: maturity. CSV is relative; the spend is valid - // only when current_height >= creation_height + recovery_blocks. - let required = inputs - .creation_height - .saturating_add(inputs.recovery_blocks); - if inputs.current_height < required { - return Err(LockRecoveryError::TimelockNotMatured { - current: inputs.current_height, - required, - }); - } - - // 1. Sanity: enough input value to pay the fee. - if inputs.fee_sats >= inputs.prev_value_sats { - return Err(LockRecoveryError::InsufficientInput { - prev_sats: inputs.prev_value_sats, - fee_sats: inputs.fee_sats, - }); - } - - // 2. Reconstruct the witness script (P2WSH spends require - // revealing it). Both pubkeys come from the wallet's - // persisted prepare-lock metadata. - let lock_pk_bytes = hex::decode(inputs.lock_pubkey_hex.trim())?; - let recovery_pk_bytes = hex::decode(inputs.recovery_pubkey_hex.trim())?; - let lock_pk = bitcoin::secp256k1::PublicKey::from_slice(&lock_pk_bytes) - .map_err(|e| LockRecoveryError::Bitcoin(format!("lock_pubkey: {e}")))?; - let recovery_pk = bitcoin::secp256k1::PublicKey::from_slice(&recovery_pk_bytes) - .map_err(|e| LockRecoveryError::Bitcoin(format!("recovery_pubkey: {e}")))?; - let witness_script = build_wsh_witness_script(&lock_pk, &recovery_pk, inputs.recovery_blocks)?; - - // 3. Parse destination + assemble outputs. Fee is implicit - // (in - out = fee). - let dest_unchecked = Address::from_str(inputs.destination_address.trim()).map_err(|e| { - LockRecoveryError::BadDestinationAddress { - network: inputs.network, - detail: format!("parse: {e}"), - } - })?; - let dest = dest_unchecked - .require_network(inputs.network) - .map_err(|e| LockRecoveryError::BadDestinationAddress { - network: inputs.network, - detail: format!("network: {e}"), - })?; - let out_value = inputs.prev_value_sats - inputs.fee_sats; - let txout = TxOut { - value: Amount::from_sat(out_value), - script_pubkey: dest.script_pubkey(), - }; - - // 4. Assemble the unsigned tx. CRITICAL: nSequence must encode - // the relative-locktime block count for BIP-68/112 to fire. - // Per BIP-112 the input's nSequence must: - // * have bit 31 (disable flag) CLEAR — otherwise relative - // locktime is disabled and CSV becomes a no-op-disabled - // check that REJECTS, - // * have bit 22 (time-flag) CLEAR — block-based encoding - // matching the script's ` OP_CSV`, - // * have a value ≥ the script's pushed `n`. - // Setting `nSequence = recovery_blocks` directly satisfies all - // three. The legacy `0xFFFFFFFE` constant looked correct (and - // is what BIP-125 RBF docs cite) but its bit 31 is SET, - // disabling relative locktime — so CSV always fails. See - // BIP-68/112 for the encoding rules. - let funding_txid = Txid::from_str(inputs.funding_txid.trim()) - .map_err(|e| LockRecoveryError::Bitcoin(format!("funding_txid: {e}")))?; - let txin = TxIn { - previous_output: OutPoint { - txid: funding_txid, - vout: inputs.funding_vout, - }, - script_sig: ScriptBuf::new(), - sequence: Sequence::from_consensus(inputs.recovery_blocks), - witness: Witness::new(), - }; - let mut tx = Transaction { - version: Version::TWO, // BIP-68 requires version 2 for CSV - lock_time: LockTime::ZERO, - input: vec![txin], - output: vec![txout], - }; - - // 5. Compute BIP-143 sighash for the P2WSH input. - let prev_spk_bytes = hex::decode(inputs.funding_scriptpubkey_hex.trim())?; - let _prev_spk = ScriptBuf::from_bytes(prev_spk_bytes); // not strictly needed for the sighash call - let mut cache = SighashCache::new(&tx); - let sighash = cache - .p2wsh_signature_hash( - 0, - &witness_script, - Amount::from_sat(inputs.prev_value_sats), - EcdsaSighashType::All, - ) - .map_err(|e| LockRecoveryError::Bitcoin(format!("p2wsh sighash: {e}")))?; - - // 6. Sign with the user's recovery_secret. - let secp = Secp256k1::new(); - use bitcoin::hashes::Hash as _; - let msg = Message::from_digest(*sighash.as_byte_array()); - let raw_sig = secp.sign_ecdsa(&msg, recovery_secret); - let ecdsa_sig = EcdsaSignature { - signature: raw_sig, - sighash_type: EcdsaSighashType::All, - }; - - // 7. Assemble the witness stack per the recovery branch: - // [, , ] - // The empty-byte `OP_ELSE` selector is the second item. - // "Empty" on the witness stack is an empty bytestring; the - // script interpreter treats that as Bitcoin's OP_FALSE / 0, - // triggering the OP_ELSE branch. - let mut witness = Witness::new(); - witness.push(ecdsa_sig.to_vec()); - witness.push([]); // empty stack item = OP_FALSE → OP_ELSE branch - witness.push(witness_script.as_bytes()); - tx.input[0].witness = witness; - - // 8. Serialise. - let raw_hex = bitcoin::consensus::encode::serialize_hex(&tx); - let txid = tx.compute_txid().to_string(); - Ok(BuiltRecoveryTx { raw_hex, tx, txid }) -} - -#[cfg(test)] -mod tests { - use super::*; - use bitcoin::secp256k1::{PublicKey, SecretKey}; - use ghost_locks::{Denomination, GhostLock, TimelockTier}; - - fn make_keys() -> (SecretKey, SecretKey, PublicKey, PublicKey) { - let secp = Secp256k1::new(); - let lock_sk = SecretKey::from_slice(&[1; 32]).unwrap(); - let rec_sk = SecretKey::from_slice(&[2; 32]).unwrap(); - let lock_pk = PublicKey::from_secret_key(&secp, &lock_sk); - let rec_pk = PublicKey::from_secret_key(&secp, &rec_sk); - (lock_sk, rec_sk, lock_pk, rec_pk) - } - - fn fixture_lock() -> (GhostLock, SecretKey, SecretKey) { - let secp = Secp256k1::new(); - let (lock_sk, rec_sk, _, _) = make_keys(); - let lock = GhostLock::new( - &secp, - &lock_sk, - &rec_sk, - Denomination::Tiny, - TimelockTier::Short, - 800_000, - ) - .unwrap(); - (lock, lock_sk, rec_sk) - } - - fn fixture_inputs(lock: &GhostLock, mature: bool, fee_sats: u64) -> RecoverySpendInputs { - let creation_height = lock.creation_height(); - let recovery_blocks = lock.timelock_tier().blocks(); - let current_height = if mature { - creation_height + recovery_blocks - } else { - creation_height + recovery_blocks - 1 - }; - RecoverySpendInputs { - lock_pubkey_hex: hex::encode(lock.lock_pubkey().serialize()), - recovery_pubkey_hex: hex::encode(lock.recovery_pubkey().serialize()), - recovery_blocks, - funding_txid: "11".repeat(32), - funding_vout: 0, - prev_value_sats: lock.denomination().sats(), - funding_scriptpubkey_hex: hex::encode(lock.script_pubkey().as_bytes()), - destination_address: "tb1q0xcqpzrky6eff2g52qdye53xkk9jxkvraulyla".into(), - fee_sats, - network: Network::Signet, - current_height, - creation_height, - } - } - - #[test] - fn refuses_spend_before_timelock_matures() { - let (lock, _lock_sk, rec_sk) = fixture_lock(); - let inputs = fixture_inputs(&lock, false, 1_000); - let err = build_recovery_spend(&inputs, &rec_sk).expect_err("should fail"); - match err { - LockRecoveryError::TimelockNotMatured { current, required } => { - assert!(current < required); - } - other => panic!("expected TimelockNotMatured; got {other:?}"), - } - } - - #[test] - fn refuses_spend_when_fee_exceeds_input() { - let (lock, _, rec_sk) = fixture_lock(); - // Tiny denomination is 100_000 sats. - let inputs = fixture_inputs(&lock, true, 100_000); - let err = build_recovery_spend(&inputs, &rec_sk).expect_err("should fail"); - match err { - LockRecoveryError::InsufficientInput { .. } => {} - other => panic!("expected InsufficientInput; got {other:?}"), - } - } - - #[test] - fn produces_consensus_encodable_tx_with_recovery_witness_shape() { - let (lock, _lock_sk, rec_sk) = fixture_lock(); - let inputs = fixture_inputs(&lock, true, 1_000); - let built = build_recovery_spend(&inputs, &rec_sk).expect("should succeed"); - // Round-trip via the consensus deserializer. - use bitcoin::consensus::encode::deserialize_hex; - let decoded: Transaction = deserialize_hex(&built.raw_hex).unwrap(); - assert_eq!(decoded.input.len(), 1); - assert_eq!(decoded.output.len(), 1); - // CSV requires version >= 2. - assert_eq!(decoded.version, Version::TWO); - // nSequence must equal recovery_blocks so BIP-68/112 fires - // (bit 31 cleared, value matches the script's pushed CSV). - assert_eq!( - decoded.input[0].sequence, - Sequence::from_consensus(inputs.recovery_blocks) - ); - // Witness stack: [sig, empty, witness_script] - let w = &decoded.input[0].witness; - assert_eq!(w.len(), 3, "witness has 3 items"); - let mut iter = w.iter(); - let sig_bytes = iter.next().unwrap(); - assert!( - (71..=73).contains(&sig_bytes.len()), - "ecdsa sig is 71-73 bytes including sighash byte" - ); - let else_byte = iter.next().unwrap(); - assert_eq!(else_byte.len(), 0, "OP_ELSE selector is empty"); - let script = iter.next().unwrap(); - // The witness script length should match what build_wsh_witness_script produces. - assert!(script.len() > 70 && script.len() < 200); - } - - #[test] - fn signature_verifies_against_recovery_pubkey() { - // Round-trip — pull the sig out of the witness, recompute - // the sighash, verify. This is the crux: it proves the - // user's recovery_secret actually signs a valid spend. - use bitcoin::ecdsa::Signature as EcdsaSig; - let (lock, _, rec_sk) = fixture_lock(); - let inputs = fixture_inputs(&lock, true, 1_000); - let built = build_recovery_spend(&inputs, &rec_sk).unwrap(); - - let sig_bytes = built.tx.input[0].witness.iter().next().unwrap().to_vec(); - let parsed = EcdsaSig::from_slice(&sig_bytes).unwrap(); - - // Recompute sighash. - let lock_pk_bytes = hex::decode(&inputs.lock_pubkey_hex).unwrap(); - let rec_pk_bytes = hex::decode(&inputs.recovery_pubkey_hex).unwrap(); - let lock_pk = PublicKey::from_slice(&lock_pk_bytes).unwrap(); - let rec_pk = PublicKey::from_slice(&rec_pk_bytes).unwrap(); - let witness_script = - build_wsh_witness_script(&lock_pk, &rec_pk, inputs.recovery_blocks).unwrap(); - let mut cache = SighashCache::new(&built.tx); - let sighash = cache - .p2wsh_signature_hash( - 0, - &witness_script, - Amount::from_sat(inputs.prev_value_sats), - EcdsaSighashType::All, - ) - .unwrap(); - use bitcoin::hashes::Hash as _; - let msg = Message::from_digest(*sighash.as_byte_array()); - - // Verify with secp. - let secp = Secp256k1::new(); - secp.verify_ecdsa(&msg, &parsed.signature, &rec_pk).unwrap(); - } -} diff --git a/apps/wraith-wallet/core/src/psbt.rs b/apps/wraith-wallet/core/src/psbt.rs index a0cf9784c..a3ef50038 100644 --- a/apps/wraith-wallet/core/src/psbt.rs +++ b/apps/wraith-wallet/core/src/psbt.rs @@ -245,6 +245,43 @@ pub fn find_bip86_index_for_script( Ok(None) } +/// The derivation path of the key that owns `target`, if any. +/// +/// Walks the plain receive chain first, then the Lock owner chain. +/// +/// Two families, not one, because a **Cash lane is a bare key-path output for +/// a Lock owner key** — and its whole design is that it "spends with your key +/// alone, as an ordinary single-sig input". Lock owner keys live on account +/// `1'` so a Lock coin is never the same coin as a loose one, which means a +/// signer that walks only the receive chain cannot sign Cash at all and those +/// coins are stranded. +/// +/// The other three lanes are unaffected: their outputs commit to a script tree +/// and an aggregate internal key, so they never match a bare key-path address +/// derived here. +pub fn find_owned_key_path( + keystore: &Keystore, + network: Network, + target: &ScriptBuf, + scan_max: u32, +) -> Result, PsbtError> { + for idx in 0..=scan_max { + let addr = light::receive_address(keystore, idx, network) + .map_err(|e| PsbtError::Light(e.to_string()))?; + if &addr.script_pubkey() == target { + return Ok(Some(light::receive_path(idx))); + } + } + for idx in 0..=scan_max { + let addr = light::lock_owner_address(keystore, idx, network) + .map_err(|e| PsbtError::Light(e.to_string()))?; + if &addr.script_pubkey() == target { + return Ok(Some(light::lock_owner_path(idx))); + } + } + Ok(None) +} + /// Sign every input of `psbt` that the wallet owns at a BIP86 /// receive index ≤ `scan_max`. Returns the indices we actually /// signed (callers want this for the "signed N of M" UX). @@ -419,14 +456,15 @@ pub fn sign_owned_inputs( continue; } - // Find the BIP86 index, if any, that derives to this spk. - let idx = match find_bip86_index_for_script(keystore, network, target_spk, scan_max)? { - Some(idx) => idx, + // Find the path, if any, that derives to this spk. Covers the plain + // receive chain and the Lock owner chain, the latter because a Cash + // lane output is a bare key-path output for a Lock owner key. + let path = match find_owned_key_path(keystore, network, target_spk, scan_max)? { + Some(p) => p, None => continue, }; // Derive the signing key. - let path = format!("m/86'/{}'/0'/0/{}", light::GHOST_COIN_TYPE, idx); let xprv = keystore.derive_xprv(&path)?; let priv_bytes = xprv.private_key().to_bytes(); let sk = SecretKey::from_slice(&priv_bytes) @@ -573,6 +611,43 @@ pub fn create_psbt( tx_network: format!("{network:?}"), addr_network: format!("{e}"), })?; + create_psbt_to_scripts( + available, + recipient.script_pubkey(), + amount_sats, + &[], + change_address, + fee_rate_sats_per_vb, + ) +} + +/// Build a PSBT paying a raw scriptPubKey, plus any zero-value outputs. +/// +/// [`create_psbt`] is this with an address parsed for you and no extras. The +/// extras exist for a silent payment, which is two outputs that only work as a +/// pair: the taproot output carrying the money, and an `OP_RETURN` carrying +/// the sender's ephemeral key. Without the announcement the recipient cannot +/// find the coin at all, so it is not an optional decoration — it is part of +/// the payment, and it has to be funded and fee-estimated as such. +pub fn create_psbt_to_scripts( + available: &[AvailableUtxo], + recipient_spk: ScriptBuf, + amount_sats: u64, + extra_outputs: &[ScriptBuf], + change_address: &Address, + fee_rate_sats_per_vb: u64, +) -> Result<(Psbt, CreateMeta), CreateError> { + if available.is_empty() { + return Err(CreateError::NoUtxos); + } + // Bytes the extra outputs add to the transaction: 8 for the value, 1 for + // the script length, then the script. Counted before selection, because a + // fee estimate that ignores them under-funds the transaction and the node + // rejects it — after the wallet has already told the user it sent. + let extra_vbytes: u64 = extra_outputs + .iter() + .map(|s| 9 + s.as_bytes().len() as u64) + .sum(); const DUST: u64 = 330; if amount_sats <= DUST { return Err(CreateError::Dust { @@ -587,7 +662,6 @@ pub fn create_psbt( let mut sorted: Vec<&AvailableUtxo> = available.iter().collect(); sorted.sort_by_key(|u| std::cmp::Reverse(u.value_sats)); - let recipient_spk = recipient.script_pubkey(); let change_spk = change_address.script_pubkey(); // Iteratively grow the input set; on each step recompute the @@ -610,7 +684,7 @@ pub fn create_psbt( total_in = total_in.saturating_add(u.value_sats); let n_inputs = selected.len() as u64; // Two outputs first; if no-change-needed we drop one below. - let est_vbytes = 11 + n_inputs * 58 + 2 * 31; + let est_vbytes = 11 + n_inputs * 58 + 2 * 31 + extra_vbytes; fee = est_vbytes.saturating_mul(fee_rate_sats_per_vb); if total_in >= amount_sats.saturating_add(fee) { // Cover possible — try to lift to no-change form if @@ -618,7 +692,7 @@ pub fn create_psbt( let residual = total_in - amount_sats - fee; if residual <= DUST { // Drop the change output: residual rolls into fee. - let est_no_change = 11 + n_inputs * 58 + 31; + let est_no_change = 11 + n_inputs * 58 + 31 + extra_vbytes; let fee_no_change = est_no_change.saturating_mul(fee_rate_sats_per_vb); if total_in >= amount_sats.saturating_add(fee_no_change) { fee = total_in - amount_sats; // entire residual = fee @@ -666,11 +740,17 @@ pub fn create_psbt( witness: Witness::new(), }); } - let mut tx_outputs: Vec = Vec::with_capacity(2); + let mut tx_outputs: Vec = Vec::with_capacity(2 + extra_outputs.len()); tx_outputs.push(TxOut { value: Amount::from_sat(amount_sats), script_pubkey: recipient_spk.clone(), }); + for extra in extra_outputs { + tx_outputs.push(TxOut { + value: Amount::ZERO, + script_pubkey: extra.clone(), + }); + } if needed_change_output { tx_outputs.push(TxOut { value: Amount::from_sat(change_value), diff --git a/apps/wraith-wallet/core/src/scan_state.rs b/apps/wraith-wallet/core/src/scan_state.rs new file mode 100644 index 000000000..3d13aafed --- /dev/null +++ b/apps/wraith-wallet/core/src/scan_state.rs @@ -0,0 +1,116 @@ +//! How far the block scanner has read. +//! +//! Two fields, and the second is the one that matters: the hash of the last +//! block scanned. A height alone cannot tell you whether the chain you read is +//! still the chain that exists. After a reorg the same height holds a +//! different block, and a scanner that trusted the number would carry on from +//! a fork it had already left, never noticing that some of what it recorded +//! never happened. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// The last block the scanner processed. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ScanPoint { + pub height: u32, + pub hash: String, +} + +/// A JSON file holding one [`ScanPoint`]. +#[derive(Debug)] +pub struct ScanState { + path: PathBuf, + point: Option, +} + +impl ScanState { + /// Open (or create) the scan state at `path`. + /// + /// A malformed file resets to "never scanned" rather than erroring. Unlike + /// the history, nothing here is a record of something that happened — it + /// is a bookmark, and the cost of losing it is re-reading blocks, not + /// losing a fact. Refusing to start over a corrupt bookmark would take the + /// wallet down for the cheapest possible reason. + pub fn open(path: impl AsRef) -> std::io::Result { + let path = path.as_ref().to_path_buf(); + let point = fs::read_to_string(&path).ok().and_then(|raw| { + match serde_json::from_str::(&raw) { + Ok(p) => Some(p), + Err(e) => { + tracing::warn!( + path = %path.display(), + error = %e, + "scan bookmark unreadable; starting again from the tip" + ); + None + } + } + }); + Ok(Self { path, point }) + } + + /// Where the scanner got to, or `None` if it has never run. + pub fn point(&self) -> Option<&ScanPoint> { + self.point.as_ref() + } + + /// Record progress, durably. + pub fn set(&mut self, height: u32, hash: impl Into) -> std::io::Result<()> { + let next = ScanPoint { + height, + hash: hash.into(), + }; + let previous = self.point.replace(next); + if let Err(e) = self.flush() { + self.point = previous; + return Err(e); + } + Ok(()) + } + + fn flush(&self) -> std::io::Result<()> { + let Some(p) = self.point.as_ref() else { + return Ok(()); + }; + let body = serde_json::to_vec_pretty(p).map_err(std::io::Error::other)?; + ghost_lock::atomic_file::write_atomic(&self.path, &body, Some(0o600)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bookmark_survives_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scan.json"); + { + let mut s = ScanState::open(&path).unwrap(); + s.set(900_123, "abcd").unwrap(); + } + let s = ScanState::open(&path).unwrap(); + assert_eq!(s.point().unwrap().height, 900_123); + assert_eq!(s.point().unwrap().hash, "abcd"); + } + + #[test] + fn a_fresh_wallet_has_never_scanned() { + let dir = tempfile::tempdir().unwrap(); + let s = ScanState::open(dir.path().join("scan.json")).unwrap(); + assert!(s.point().is_none()); + } + + /// A corrupt bookmark costs re-reading, not an outage. This is the + /// opposite call from the history, where a corrupt file is refused — + /// there, silence would be mistaken for a fact. + #[test] + fn a_corrupt_bookmark_starts_over_rather_than_failing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scan.json"); + fs::write(&path, "{ not json").unwrap(); + let s = ScanState::open(&path).expect("must still open"); + assert!(s.point().is_none()); + } +} diff --git a/apps/wraith-wallet/core/src/signer/mod.rs b/apps/wraith-wallet/core/src/signer/mod.rs index afa953b4b..c664024ce 100644 --- a/apps/wraith-wallet/core/src/signer/mod.rs +++ b/apps/wraith-wallet/core/src/signer/mod.rs @@ -18,7 +18,6 @@ //! //! [`auth::xonly_pubkey_signer`](crate::auth::xonly_pubkey_signer), //! [`auth::wallet_id_hex_signer`](crate::auth::wallet_id_hex_signer), -//! [`auth::make_proof_signer`](crate::auth::make_proof_signer), and //! [`auth::sign_data_signer`](crate::auth::sign_data_signer) are the //! Signer-aware GSP-auth helpers. The daemon currently calls the //! keypair-based variants in `auth::*` because the `Keystore` is what diff --git a/apps/wraith-wallet/core/src/silent_payment.rs b/apps/wraith-wallet/core/src/silent_payment.rs new file mode 100644 index 000000000..e9211af0a --- /dev/null +++ b/apps/wraith-wallet/core/src/silent_payment.rs @@ -0,0 +1,336 @@ +//! Building a Ghost silent payment — the sender's side. +//! +//! # What was missing +//! +//! The wallet could receive these and not send them. The encoder lived in the +//! operator's service and went with it, leaving the Receive screen advertising +//! a Ghost ID that no wallet in the tree could pay. +//! +//! # The shape on chain +//! +//! Two outputs, and the pair is what makes the payment findable: +//! +//! * an `OP_RETURN` carrying exactly one 33-byte compressed pubkey — the +//! sender's one-shot ephemeral key, which is what lets the receiver run the +//! ECDH that reveals the payment; +//! * a taproot output at a key derived from that ephemeral key and the +//! receiver's published Ghost ID. +//! +//! Neither half is any use alone. Without the announcement the receiver has +//! nothing to scan against and the coin is unfindable; without the taproot +//! output the announcement pays nobody. +//! +//! # What it costs the sender +//! +//! An `OP_RETURN` is a marker anyone can see. A transaction shaped like this +//! says "a silent payment happened here" to every observer — it hides *who was +//! paid*, not *that a payment occurred*. That is the trade this protocol +//! makes, and it is worth knowing before choosing it over an ordinary address. + +use bitcoin::{Network, ScriptBuf}; +use ghost_keys::{GhostId, GhostNetwork}; + +/// The two outputs a silent payment adds to a transaction. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SilentPayment { + /// The taproot output that carries the money. + pub output_script: ScriptBuf, + /// The `OP_RETURN` announcement. Carries no value. + pub announcement_script: ScriptBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum SilentPaymentError { + #[error("not a Ghost ID for this network: {0}")] + BadGhostId(String), + #[error("deriving the payment address: {0}")] + Derive(String), +} + +/// Map a Bitcoin network to the Ghost ID it accepts. +/// +/// Rejecting rather than defaulting: a mainnet Ghost ID pasted into a signet +/// wallet is a mistake, and quietly paying it would put real money on the +/// wrong chain — irrecoverably, since nobody holds the other chain's key. +pub fn ghost_network_for(network: Network) -> Option { + match network { + Network::Bitcoin => Some(GhostNetwork::Mainnet), + Network::Testnet => Some(GhostNetwork::Testnet), + Network::Signet => Some(GhostNetwork::Signet), + Network::Regtest => Some(GhostNetwork::Regtest), + _ => None, + } +} + +/// Whether `s` looks like a Ghost ID for `network`. +/// +/// A prefix check, used to decide which kind of payment the user asked for +/// before committing to either. The full decode still happens in [`build`] — +/// this only routes. +pub fn looks_like_ghost_id(s: &str, network: Network) -> bool { + let Some(gn) = ghost_network_for(network) else { + return false; + }; + s.trim().starts_with(&format!("{}1", gn.hrp())) +} + +/// Build the outputs that pay `ghost_id`. +/// +/// `k` is the sender's counter for multiple outputs to the same recipient in +/// one transaction. It is carried in the derivation rather than inferred from +/// output position, so shuffling the outputs for privacy does not break +/// detection. +/// +/// A fresh ephemeral key is generated per call, so paying the same Ghost ID +/// twice produces unrelated on-chain outputs. +pub fn build( + ghost_id: &str, + network: Network, + k: u32, +) -> Result { + let gn = ghost_network_for(network) + .ok_or_else(|| SilentPaymentError::BadGhostId(format!("unsupported network {network}")))?; + let id = GhostId::decode_for_network(ghost_id.trim(), gn) + .map_err(|e| SilentPaymentError::BadGhostId(e.to_string()))?; + + let (output_pubkey, ephemeral_pubkey, _tweak) = id + .derive_payment_address_v2_full(k) + .map_err(|e| SilentPaymentError::Derive(e.to_string()))?; + + // The taproot output keeps only the x-coordinate; the receiver tries both + // parities when scanning, because the chain does not record which. + let mut output = Vec::with_capacity(34); + output.push(0x51); // OP_1 + output.push(0x20); // PUSH32 + output.extend_from_slice(&output_pubkey.serialize()[1..]); + + let mut announcement = Vec::with_capacity(35); + announcement.push(0x6a); // OP_RETURN + announcement.push(0x21); // PUSH33 + announcement.extend_from_slice(&ephemeral_pubkey.serialize()); + + Ok(SilentPayment { + output_script: ScriptBuf::from_bytes(output), + announcement_script: ScriptBuf::from_bytes(announcement), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ghost_keys::GhostKeys; + + fn ghost_id_for(keys: &GhostKeys, network: GhostNetwork) -> String { + GhostId::new(*keys.scan_pubkey(), *keys.spend_pubkey()) + .encode_for_network(network) + .expect("encode") + } + + /// The whole point: what this builds, the scanner finds. + /// + /// The sender and the receiver are the real implementations and the block + /// encoding in between is the format under test. A pair of fixtures I + /// wrote both sides of would only prove I am consistent with myself. + #[test] + fn what_the_sender_builds_the_scanner_detects() { + let receiver = GhostKeys::generate(); + let id = ghost_id_for(&receiver, GhostNetwork::Regtest); + + let pay = build(&id, Network::Regtest, 0).expect("build"); + + // Lay it out as a block would: the announcement, a stranger's output, + // then the payment — so the scanner has to pick rather than accept + // whatever it is handed. + let tx: crate::ghostd::BlockTx = serde_json::from_value(serde_json::json!({ + "txid": "feed", + "vin": [], + "vout": [ + { "n": 0, "value": 0.0, + "scriptPubKey": { "hex": hex::encode(pay.announcement_script.as_bytes()) } }, + { "n": 1, "value": 0.25, + "scriptPubKey": { "hex": format!("5120{}", "cc".repeat(32)) } }, + { "n": 2, "value": 0.5, + "scriptPubKey": { "hex": hex::encode(pay.output_script.as_bytes()) } }, + ], + })) + .expect("tx fixture"); + + let (ephemeral, outputs) = + crate::block_scan::candidate_in(&tx).expect("the announcement is found"); + let found = + crate::candidate_scan::scan_candidate(&receiver, &ephemeral, &outputs, "feed", None) + .expect("scan runs"); + + assert_eq!(found.len(), 1, "exactly the output that was ours"); + assert_eq!(found[0].vout, 2); + assert_eq!(found[0].amount_sats, Some(50_000_000)); + assert_eq!(found[0].k, 0, "the counter the sender used"); + } + + /// A funded silent payment must still be detectable once the wallet has + /// built it into a real transaction. + /// + /// The two tests above prove the scripts are right. This proves the + /// *transaction* is: that the builder keeps both outputs, funds them, and + /// that what comes out the other end is what a scanner reading that block + /// would find. A payment whose announcement got dropped during selection + /// would pass every earlier test and be unspendable on chain. + #[test] + fn a_built_transaction_is_still_detectable() { + use crate::psbt::{create_psbt_to_scripts, AvailableUtxo}; + use bitcoin::hashes::Hash; + use bitcoin::{Address, ScriptBuf}; + + let receiver = GhostKeys::generate(); + let pay = build( + &ghost_id_for(&receiver, GhostNetwork::Regtest), + Network::Regtest, + 0, + ) + .unwrap(); + + // A wallet coin to spend, and somewhere for the change to go. + let our_spk = ScriptBuf::from_bytes({ + let mut v = vec![0x51, 0x20]; + v.extend_from_slice(&[0xaa; 32]); + v + }); + let available = [AvailableUtxo { + txid: bitcoin::Txid::from_byte_array([7u8; 32]), + vout: 0, + value_sats: 1_000_000, + script_pubkey: our_spk, + }]; + let change: Address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080" + .parse::>() + .unwrap() + .require_network(Network::Regtest) + .unwrap(); + + let (psbt, meta) = create_psbt_to_scripts( + &available, + pay.output_script.clone(), + 500_000, + std::slice::from_ref(&pay.announcement_script), + &change, + 5, + ) + .expect("build the transaction"); + + let tx = psbt.unsigned_tx; + assert!( + tx.output + .iter() + .any(|o| o.script_pubkey == pay.announcement_script && o.value.to_sat() == 0), + "the announcement must survive, and carry no value" + ); + assert!( + tx.output + .iter() + .any(|o| o.script_pubkey == pay.output_script && o.value.to_sat() == 500_000), + "the payment must carry the amount" + ); + // Inputs must cover everything the transaction spends, or the node + // rejects it after the wallet has told the user it sent. + let out_total: u64 = tx.output.iter().map(|o| o.value.to_sat()).sum(); + assert_eq!( + meta.total_input_sats, + out_total + meta.fee_sats, + "inputs must equal outputs plus fee" + ); + + // And now read it back the way the scanner would. + let vouts: Vec = tx + .output + .iter() + .enumerate() + .map(|(n, o)| { + serde_json::json!({ + "n": n as u32, + "value": o.value.to_sat() as f64 / 100_000_000.0, + "scriptPubKey": { "hex": hex::encode(o.script_pubkey.as_bytes()) } + }) + }) + .collect(); + let block_tx: crate::ghostd::BlockTx = serde_json::from_value( + serde_json::json!({ "txid": "built", "vin": [], "vout": vouts }), + ) + .unwrap(); + + let (eph, outs) = + crate::block_scan::candidate_in(&block_tx).expect("announcement survives the build"); + let found = + crate::candidate_scan::scan_candidate(&receiver, &eph, &outs, "built", None).unwrap(); + assert_eq!(found.len(), 1, "the recipient finds their coin"); + assert_eq!(found[0].amount_sats, Some(500_000)); + } + + /// Paying somebody else must not be detectable as ours, or the test above + /// would pass for a scanner that accepted anything. + #[test] + fn a_payment_to_another_ghost_id_is_not_ours() { + let us = GhostKeys::generate(); + let them = GhostKeys::generate(); + let pay = build( + &ghost_id_for(&them, GhostNetwork::Regtest), + Network::Regtest, + 0, + ) + .expect("build"); + + let tx: crate::ghostd::BlockTx = serde_json::from_value(serde_json::json!({ + "txid": "feed", + "vin": [], + "vout": [ + { "n": 0, "value": 0.0, + "scriptPubKey": { "hex": hex::encode(pay.announcement_script.as_bytes()) } }, + { "n": 1, "value": 0.5, + "scriptPubKey": { "hex": hex::encode(pay.output_script.as_bytes()) } }, + ], + })) + .unwrap(); + let (eph, outs) = crate::block_scan::candidate_in(&tx).unwrap(); + let found = crate::candidate_scan::scan_candidate(&us, &eph, &outs, "feed", None).unwrap(); + assert!(found.is_empty(), "not ours, got {found:?}"); + } + + /// Two payments to one Ghost ID must not share an output key, or the + /// recipient's transactions become linkable by anyone watching. + #[test] + fn paying_the_same_recipient_twice_produces_unrelated_outputs() { + let receiver = GhostKeys::generate(); + let id = ghost_id_for(&receiver, GhostNetwork::Regtest); + let a = build(&id, Network::Regtest, 0).unwrap(); + let b = build(&id, Network::Regtest, 0).unwrap(); + assert_ne!(a.output_script, b.output_script, "output keys must differ"); + assert_ne!( + a.announcement_script, b.announcement_script, + "and so must the ephemeral keys" + ); + } + + /// A mainnet Ghost ID in a signet wallet is a mistake, and paying it + /// anyway would put money on a chain whose key nobody holds. + #[test] + fn a_ghost_id_from_another_network_is_refused() { + let receiver = GhostKeys::generate(); + let mainnet_id = ghost_id_for(&receiver, GhostNetwork::Mainnet); + let err = build(&mainnet_id, Network::Regtest, 0).expect_err("must refuse"); + assert!(matches!(err, SilentPaymentError::BadGhostId(_)), "{err}"); + } + + /// The routing check must not claim an ordinary address. + #[test] + fn an_ordinary_address_does_not_look_like_a_ghost_id() { + assert!(!looks_like_ghost_id( + "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080", + Network::Regtest + )); + let id = ghost_id_for(&GhostKeys::generate(), GhostNetwork::Regtest); + assert!(looks_like_ghost_id(&id, Network::Regtest)); + assert!( + !looks_like_ghost_id(&id, Network::Bitcoin), + "a regtest id is not a mainnet one" + ); + } +} diff --git a/apps/wraith-wallet/core/src/wallet_meta.rs b/apps/wraith-wallet/core/src/wallet_meta.rs new file mode 100644 index 000000000..b1bb5906f --- /dev/null +++ b/apps/wraith-wallet/core/src/wallet_meta.rs @@ -0,0 +1,82 @@ +//! What the wallet knows about itself, beside its keys. +//! +//! One field so far, and it is the one a restore depends on: the height the +//! wallet came into existence. Without it a restored wallet has no way to say +//! how far back the scanner should read, and the only safe defaults are both +//! bad — start at the tip and its past is invisible, start at genesis and it +//! reads twenty years of blocks to find a wallet that is usually a week old. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Per-wallet facts that are not secret and not derivable from the seed. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct WalletMeta { + /// The chain height at or before which this wallet can have no history. + /// + /// Set to the tip when a wallet is created — a wallet cannot have been + /// paid before it existed. On a restore it is whatever the owner says, and + /// `None` means they did not say: the scanner then starts at the tip and + /// the history begins there, which is stated rather than silently assumed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub birth_height: Option, +} + +/// Read `path`, or a default if it is absent or unreadable. +/// +/// Losing this costs history depth on a rescan, not money, so a malformed file +/// falls back rather than failing. That is the opposite call from the +/// detections beside it, where silence would hide coins. +pub fn load(path: impl AsRef) -> WalletMeta { + let path = path.as_ref(); + match fs::read_to_string(path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!(path = %path.display(), error = %e, "wallet metadata unreadable"); + WalletMeta::default() + }), + Err(_) => WalletMeta::default(), + } +} + +/// Write `meta` to `path`, durably. +pub fn save(path: impl AsRef, meta: &WalletMeta) -> std::io::Result<()> { + let path: PathBuf = path.as_ref().to_path_buf(); + let body = serde_json::to_vec_pretty(meta).map_err(std::io::Error::other)?; + // 0o600: the birth height dates the wallet. + ghost_lock::atomic_file::write_atomic(&path, &body, Some(0o600)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_birth_height_survives_a_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meta.json"); + save( + &path, + &WalletMeta { + birth_height: Some(900_123), + }, + ) + .unwrap(); + assert_eq!(load(&path).birth_height, Some(900_123)); + } + + #[test] + fn an_absent_file_reads_as_no_birth_height() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(load(dir.path().join("nope.json")).birth_height, None); + } + + /// A corrupt metadata file costs scan depth, not money — falling back + /// beats refusing to open the wallet. + #[test] + fn a_corrupt_file_falls_back_rather_than_failing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meta.json"); + fs::write(&path, "{ not json").unwrap(); + assert_eq!(load(&path).birth_height, None); + } +} diff --git a/apps/wraith-wallet/core/src/wraith.rs b/apps/wraith-wallet/core/src/wraith.rs index b9bb46187..f6e038341 100644 --- a/apps/wraith-wallet/core/src/wraith.rs +++ b/apps/wraith-wallet/core/src/wraith.rs @@ -62,6 +62,60 @@ pub enum WraithClientError { Coordinator { status: u16, detail: String }, #[error("response body did not match expected shape: {0}")] Shape(String), + /// This coin is already committed to a different round. + /// + /// Signing it again would double-spend it: one of the two rounds dies at + /// broadcast, and every other participant in that round loses it through no + /// fault of their own — and has their coin put in cooldown for it. + #[error("this coin is already committed to round {existing}; signing it into another would double-spend it and kill a round for everyone else in it")] + CoinAlreadyCommitted { + /// The round it is already committed to. + existing: String, + }, + + /// A signature was produced under a sighash that does not commit to what + /// was inspected, so the inspection would have been void. + #[error("input {input_index} was signed with a {len}-byte signature; BIP-341 SIGHASH_DEFAULT is 64 bytes, and anything longer carries a sighash flag that lets the round be edited after inspection")] + UnsafeSighash { + /// Which input. + input_index: usize, + /// Signature length produced. + len: usize, + }, + + /// The round failed inspection, so nothing was signed. + /// + /// Carries every reason rather than the first: a wallet deciding between + /// retrying and walking away needs the whole picture, and a thin round is a + /// different decision from a coordinator that misreported. + #[error("refused to sign the round: {}", .reasons.iter().map(ToString::to_string).collect::>().join("; "))] + RefusedRound { + /// Everything wrong with it. + reasons: Vec, + /// What this wallet counted for itself. + report: wraith_protocol::anonymity_set::SetReport, + }, + /// The round has too few seats to reach this wallet's floor, seen while + /// it could still be left without cost. + /// + /// Deliberately not a [`Self::RefusedRound`]: that carries this wallet's + /// own recount of an assembled transaction, and this carries a seat count + /// taken before any transaction exists. + /// + /// Seats, not entities, because entities are counted from committed inputs + /// and are therefore zero until somebody commits. Seats bound entities + /// from above, so too few seats proves the floor is unreachable; enough + /// seats proves nothing, and the real check still runs at `inspect`. + /// + /// Checking here rather than at `inspect` is the whole point. Refusing + /// after `/inputs` leaves the wallet a non-signer, and the sweep bans its + /// outpoint for a cooldown — punishing the wallet for enforcing its own + /// privacy policy. + #[error( + "round has {seats} seats, too few to reach this wallet's floor of \ + {min_entities} entities; left before committing the coin" + )] + RoundTooSmallToJoin { seats: usize, min_entities: usize }, #[error("hex decode: {0}")] Hex(#[from] hex::FromHexError), #[error("bitcoin consensus encode: {0}")] @@ -100,6 +154,55 @@ pub struct MixRequest { /// output. Must NOT be linkable to the wallet's input UTXO — /// fresh address recommended. pub mix_output_address: String, + /// Smallest anonymity set, in **distinct entities**, worth signing into. + /// + /// Entities rather than seats: a round of fifty where one party supplied + /// forty-nine is a set of two, and a floor counted in seats passes exactly + /// the round it exists to catch. + /// + /// A starting parameter, not a measurement. `DEFAULT_MIN_ENTITIES` is twice + /// the protocol's round minimum, which is reachable at modest volume and + /// gives an observer a one-in-ten guess. It should be revised against + /// measured round volume rather than left as received wisdom. + pub min_entities: usize, +} + +/// Default anonymity floor, in distinct entities. +/// +/// `MIN_ROUND_PARTICIPANTS` is 5, but that is the smallest round the *protocol* +/// will assemble, not a sensible privacy default — one in five is barely +/// privacy. Unmeasured; revise against real volume. +pub const DEFAULT_MIN_ENTITIES: usize = 10; + +/// Reject a witness whose signature was made under a sighash that does not +/// commit to the whole round. +/// +/// A BIP-341 key-path signature is 64 bytes under `SIGHASH_DEFAULT`. Every other +/// type appends its flag byte, making 65. So the length answers the question +/// without trusting the signer to report honestly — which matters precisely for +/// the signers most likely to differ: hardware wallets and remote signing +/// services. +/// +/// An empty witness is refused too. A signature that is not there cannot have +/// committed to anything. +pub fn check_witness_sighash( + witness: &Witness, + input_index: usize, +) -> Result<(), WraithClientError> { + let sig = witness + .iter() + .next() + .ok_or(WraithClientError::UnsafeSighash { + input_index, + len: 0, + })?; + if sig.len() != 64 { + return Err(WraithClientError::UnsafeSighash { + input_index, + len: sig.len(), + }); + } + Ok(()) } /// The result of a successful mix. @@ -178,6 +281,146 @@ pub struct PreparedMix { /// Wallet identity — kept for the /witness POST; not used by /// the caller. pub ghost_id: String, + /// The anonymity floor this mix was requested with, carried so the + /// split sign-then-submit path can apply the same check as `execute_mix`. + pub min_entities: usize, + /// The value the wallet's own output must carry. + pub expected_output_sats: u64, + /// The scriptPubKey the wallet's own output must pay. + pub expected_output_script: bitcoin::ScriptBuf, +} + +/// Proof that a round was inspected and its coin committed, before signing. +/// +/// # Why this is a type rather than a call somewhere +/// +/// The checks kept existing and not running. `pre_sign` was written and nothing +/// called it; `Verified::authorise` likewise; `SigningLedger` likewise. Each +/// time the fix was to add a call at the one place that seemed to matter, and +/// each time a second path — the split `prepare_mix` / `submit_witness` API the +/// daemon actually uses — went round it. +/// +/// So this is not a call to remember. `submit_witness` will not accept anything +/// else, and only [`PreparedMix::inspect`] mints one. Forgetting is a +/// compile error. +/// Owned rather than borrowing, because the split API spans two IPC calls: the +/// daemon prepares a round in one request and submits the witness in another, +/// so the proof has to be storable between them. +#[derive(Debug, Clone)] +pub struct InspectedMix { + prepared: PreparedMix, + /// What this wallet counted for itself, kept so a caller can show it. + pub report: wraith_protocol::anonymity_set::SetReport, +} + +impl InspectedMix { + /// The round that was inspected. + pub fn prepared(&self) -> &PreparedMix { + &self.prepared + } +} + +impl PreparedMix { + /// Inspect the round before signing it. + /// + /// **This is what stops the wallet signing whatever it is handed.** Until it + /// existed, `execute_mix` went straight from `/round-tx` to `sign` — no + /// check that the wallet's own input was present, that its own output + /// existed for the right amount, that the fee was sane, or that the + /// anonymity set was worth anything. + /// + /// The recount is done here from the transaction and its prevouts rather + /// than taken from the coordinator, because a coordinator can lie about the + /// set but not about the chain. + pub fn inspect( + &self, + ledger: &mut wraith_protocol::signing_ledger::SigningLedger, + ) -> Result + where + L: wraith_protocol::signing_ledger::SignatureStore, + { + use wraith_protocol::client_session::Joined; + use wraith_protocol::pre_sign::Expectation; + + let scripts: Vec> = self + .prevouts + .iter() + .map(|p| hex::decode(p.scriptpubkey_hex.trim()).unwrap_or_default()) + .collect(); + + let total_input_sats = self + .prevouts + .iter() + .fold(0u64, |acc, p| acc.saturating_add(p.value_sats)); + + let mine = self.unsigned_tx.input[self.input_index].previous_output; + let want = Expectation { + my_input: (mine.txid, mine.vout), + my_output_script: self.expected_output_script.clone(), + my_output_sats: self.expected_output_sats, + total_input_sats, + // The coordinator's own arithmetic bounds this; a round paying more + // to miners than its inputs allow cannot assemble at all. + max_fee_sats: total_input_sats, + min_set: self.min_entities, + set_report: None, + claimed_set: None, + }; + + let report = wraith_protocol::anonymity_set::recount_from_inputs( + &self + .unsigned_tx + .input + .iter() + .zip(scripts.iter()) + .map(|(txin, script)| wraith_protocol::clustering::CoinFacts { + outpoint: wraith_protocol::signing_ledger::OutPointKey { + txid: { + use bitcoin::hashes::Hash; + txin.previous_output.txid.to_byte_array() + }, + vout: txin.previous_output.vout, + }, + script_pubkey: script.clone(), + }) + .collect::>(), + ); + + if let Err(reasons) = Joined::new(want).verify_response(&self.unsigned_tx, &scripts, None) { + return Err(WraithClientError::RefusedRound { reasons, report }); + } + + // Commit the coin to THIS round, before any signature exists. + // + // Recorded first, deliberately: a crash between signing and recording is + // the window the once-per-coin rule closes, and an unrecorded signature + // is the one replayed into a second round after a restart. + { + use bitcoin::hashes::Hash as _; + let mine = self.unsigned_tx.input[self.input_index].previous_output; + let coin = wraith_protocol::signing_ledger::OutPointKey::new( + mine.txid.to_byte_array(), + mine.vout, + ); + let round_txid = self.unsigned_tx.compute_txid(); + ledger + .authorise(coin, round_txid.to_byte_array()) + .map_err(|e| match e { + wraith_protocol::signing_ledger::LedgerError::Conflict { existing_txid } => { + let mut disp = existing_txid; + disp.reverse(); + WraithClientError::CoinAlreadyCommitted { + existing: hex::encode(disp), + } + } + })?; + } + + Ok(InspectedMix { + prepared: self.clone(), + report, + }) + } } /// One input prevout reference. Mirrors the coordinator's wire-format @@ -332,18 +575,24 @@ impl WraithSessionClient { /// remote signer service) or when the caller wants to inspect /// `prepared.unsigned_tx` before signing — e.g. the /// daemon-integrated CLI. - pub async fn execute_mix( + pub async fn execute_mix( &self, request: MixRequest, mut signer: S, prove_ownership: P, + ledger: &mut wraith_protocol::signing_ledger::SigningLedger, ) -> Result where S: WitnessSigner, P: FnMut(&str) -> PFut, PFut: std::future::Future>, + L: wraith_protocol::signing_ledger::SignatureStore, { let prepared = self.prepare_mix(request, prove_ownership).await?; + // Inspect before signing. A signature is the only irreversible step in + // this protocol, so it is the one that has to be earned. The token this + // returns is what `submit_witness` requires, so neither path can skip it. + let inspected = prepared.inspect(ledger)?; let witness = signer .sign( &prepared.unsigned_tx, @@ -357,7 +606,7 @@ impl WraithSessionClient { detail: other.to_string(), }, })?; - self.submit_witness(&prepared, witness).await + self.submit_witness(&inspected, witness).await } /// Drive the protocol from /find_or_create through /round-tx. @@ -400,7 +649,38 @@ impl WraithSessionClient { // until quorum forms (or the fill window expires). Bounded // poll loop with backoff; gives up after the round's fill // window plus a safety margin. - self.wait_for_locked(&session_id).await?; + let locked = self.wait_for_locked(&session_id).await?; + + // 2c. Leave now if the round cannot possibly meet the floor. + // + // This is the last moment leaving is free. `/inputs` below commits + // the outpoint to this round, and a wallet that then declines to + // sign is swept as a non-signer and has that outpoint banned for a + // cooldown. The protocol assembles at five and this wallet's floor + // defaults to ten, so landing in a legal round below the floor is + // an ordinary event rather than an attack — and must not cost the + // coin. + // + // Judged on SEATS, which is all that exists yet. The entity count + // is derived from committed inputs, so before anyone commits it is + // zero and says nothing. Seats bound entities from above — + // clustering only ever collapses seats together, never splits one + // — so `seats < floor` proves the floor is unreachable, while + // `seats >= floor` proves nothing and is left to `inspect`, which + // recounts from the chain once the transaction exists. + let seats = locked.session.slots_filled as usize; + if seats < request.min_entities { + debug!( + %session_id, + seats, + min_entities = request.min_entities, + "round cannot reach this wallet's anonymity floor; leaving before committing" + ); + return Err(WraithClientError::RoundTooSmallToJoin { + seats, + min_entities: request.min_entities, + }); + } // 3. Commit UTXO. The 5th /inputs auto-advances the round to // Signing on the coordinator side. Earlier submitters @@ -534,6 +814,14 @@ impl WraithSessionClient { }) .collect(); + // What the wallet's own output must be, read from the transaction the + // coordinator served but at the index the wallet located itself. The + // check that follows compares the round against this, so it has to come + // from `locate_mix_output_index` rather than from anything the + // coordinator asserted about which output is ours. + let expected_output_script = tx.output[mixed_output_tx_index].script_pubkey.clone(); + let expected_output_sats = tx.output[mixed_output_tx_index].value.to_sat(); + Ok(PreparedMix { session_id, unsigned_tx: tx, @@ -542,6 +830,9 @@ impl WraithSessionClient { prevouts, mixed_output_tx_index, ghost_id: request.ghost_id, + min_entities: request.min_entities, + expected_output_sats, + expected_output_script, }) } @@ -550,9 +841,17 @@ impl WraithSessionClient { /// Complete before returning. Returns the broadcast txid. pub async fn submit_witness( &self, - prepared: &PreparedMix, + inspected: &InspectedMix, witness: Witness, ) -> Result { + let prepared = inspected.prepared(); + + // Inspection is only worth something if the signature commits to what + // was inspected. Checked here rather than in `execute_mix` alone, + // because the split API goes straight to this method and previously + // skipped it entirely. + check_witness_sighash(&witness, prepared.input_index)?; + let witness_hex = bitcoin::consensus::encode::serialize_hex(&witness); let session_id = &prepared.session_id; @@ -701,14 +1000,20 @@ impl WraithSessionClient { /// caller forever. Polls every 250ms — frequent enough to ride /// the manual state-flip in tests, sparse enough to avoid /// hammering a real coordinator. - async fn wait_for_locked(&self, session_id: &str) -> Result<(), WraithClientError> { + /// Block until the round is joinable, and hand back the status that said + /// so — the caller needs its headcount, and re-fetching would be a second + /// round-trip for a figure already in hand. + async fn wait_for_locked( + &self, + session_id: &str, + ) -> Result { let deadline = std::time::Instant::now() + Duration::from_secs(360); loop { let status: SessionStatusResponse = self .get_json(&format!("/api/v1/session/{session_id}")) .await?; match status.session.state.as_str() { - "locked" | "signing" => return Ok(()), + "locked" | "signing" => return Ok(status), "failed" => { return Err(WraithClientError::Coordinator { status: 410, diff --git a/apps/wraith-wallet/core/tests/chain_scan_utxos.rs b/apps/wraith-wallet/core/tests/chain_scan_utxos.rs deleted file mode 100644 index ab4056647..000000000 --- a/apps/wraith-wallet/core/tests/chain_scan_utxos.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Integration test for `GhostPayClient::scan_utxos`. Spins up an -//! axum stub that mimics ghost-pay's `POST /api/v1/utxos/scan`, -//! drives the real wallet client against it, and asserts: -//! -//! 1. the request body shape (addresses + min_confirmations) -//! 2. the `X-Internal-Auth` header is set when the client was -//! built with `with_internal_secret(...)`, and absent otherwise -//! 3. the response parses into the wallet-side -//! `ScanUtxosResponse` shape with all fields preserved. -//! -//! This is the contract test for the wallet-half of the L1 UTXO -//! scanner. The ghost-pay server-half has its own `parse_addr_from_desc` -//! tests; the bitcoind-side wire is covered by -//! `scripts/regtest-l1-scan-demo.sh`. Together they form the test -//! pyramid for the new endpoint. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - routing::post, - Json, Router, -}; -use serde_json::{json, Value}; -use wraith_wallet_core::chain::GhostPayClient; - -#[derive(Clone)] -struct StubState { - expect_auth: Arc, - /// True iff the most recent request carried X-Internal-Auth. - last_had_auth: Arc, - /// True iff the most recent request body shape matched. - last_body_ok: Arc, -} - -async fn handle_scan( - State(state): State, - headers: HeaderMap, - Json(body): Json, -) -> Result, (StatusCode, String)> { - let had_auth = headers.get("x-internal-auth").is_some(); - state.last_had_auth.store(had_auth, Ordering::SeqCst); - if state.expect_auth.load(Ordering::SeqCst) && !had_auth { - return Err((StatusCode::UNAUTHORIZED, "missing X-Internal-Auth".into())); - } - let body_ok = body - .get("addresses") - .and_then(Value::as_array) - .map(|a| !a.is_empty()) - .unwrap_or(false) - && body - .get("min_confirmations") - .and_then(Value::as_u64) - .is_some(); - state.last_body_ok.store(body_ok, Ordering::SeqCst); - - Ok(Json(json!({ - "utxos": [ - { - "txid": "11".repeat(32), - "vout": 0, - "amount_sats": 500_000, - "scriptpubkey_hex": "5120abcd", - "address": "bcrt1qfundedaddr", - "confirmations": 6, - "height": 105, - } - ], - "total_sats": 500_000, - "chain_height": 110, - }))) -} - -async fn spawn_stub(expect_auth: bool) -> (std::net::SocketAddr, StubState) { - let state = StubState { - expect_auth: Arc::new(AtomicBool::new(expect_auth)), - last_had_auth: Arc::new(AtomicBool::new(false)), - last_body_ok: Arc::new(AtomicBool::new(false)), - }; - let app = Router::new() - .route("/api/v1/utxos/scan", post(handle_scan)) - .with_state(state.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - (addr, state) -} - -#[tokio::test] -async fn scan_utxos_round_trips_with_auth() { - let (addr, stub) = spawn_stub(true).await; - let base = format!("http://{addr}"); - let client = GhostPayClient::new(base).with_internal_secret("shh-it-is-a-secret"); - - let result = client - .scan_utxos(&["bcrt1qfundedaddr".to_string()], 1) - .await - .expect("scan_utxos must succeed"); - - assert_eq!(result.utxos.len(), 1); - let u = &result.utxos[0]; - assert_eq!(u.amount_sats, 500_000); - assert_eq!(u.scriptpubkey_hex, "5120abcd"); - assert_eq!(u.address.as_deref(), Some("bcrt1qfundedaddr")); - assert_eq!(u.confirmations, 6); - assert_eq!(u.height, 105); - assert_eq!(result.total_sats, 500_000); - assert_eq!(result.chain_height, 110); - - assert!( - stub.last_had_auth.load(Ordering::SeqCst), - "expected X-Internal-Auth on the request" - ); - assert!( - stub.last_body_ok.load(Ordering::SeqCst), - "request body shape mismatch" - ); -} - -#[tokio::test] -async fn scan_utxos_omits_auth_header_when_unset() { - let (addr, stub) = spawn_stub(false).await; - let base = format!("http://{addr}"); - // No with_internal_secret — the header must not appear. - let client = GhostPayClient::new(base); - - let _ = client - .scan_utxos(&["bcrt1qaddr".to_string()], 0) - .await - .expect("scan_utxos must succeed (stub doesn't enforce auth)"); - - assert!( - !stub.last_had_auth.load(Ordering::SeqCst), - "X-Internal-Auth must not be sent when no secret is configured" - ); -} - -#[tokio::test] -async fn scan_utxos_surfaces_4xx_as_backend_error() { - let (addr, _stub) = spawn_stub(true).await; - let base = format!("http://{addr}"); - // Build the client WITHOUT a secret — the stub is configured to - // require auth, so we expect a 401 surfaced as ChainError::Backend. - let client = GhostPayClient::new(base); - - let err = client - .scan_utxos(&["bcrt1qaddr".to_string()], 0) - .await - .expect_err("must error when stub returns 401"); - - let msg = format!("{err}"); - assert!( - msg.contains("401"), - "expected 401 to bubble through, got: {msg}" - ); -} diff --git a/apps/wraith-wallet/core/tests/gsp_mock.rs b/apps/wraith-wallet/core/tests/gsp_mock.rs deleted file mode 100644 index b55300239..000000000 --- a/apps/wraith-wallet/core/tests/gsp_mock.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Round-trip integration test for the GSP REST client against an in-process axum mock. -//! -//! Does not require a real GSP. Asserts that the JSON shapes the wallet emits match what -//! the server's request handlers consume, by hosting a fake server that uses the SAME -//! `RegisterRequest` / `SessionRequest` types from `ghost-gsp-proto`. - -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use axum::{ - extract::{Json, State}, - routing::post, - Router, -}; -use ghost_gsp_proto::{ - RegisterRequest, RegisterResponse, SessionRequest, SessionResponse, SessionToken, WalletId, -}; -use wraith_wallet_core::auth; -use wraith_wallet_core::gsp::GspClient; -use wraith_wallet_core::keystore::Keystore; - -const VECTOR_MNEMONIC: &str = - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; - -#[derive(Default, Clone)] -struct MockState { - last_register: Arc>>, - last_session: Arc>>, -} - -async fn mock_register( - State(state): State, - Json(req): Json, -) -> Json { - // Sanity-checks the wallet would fail anyway; recording lets the test inspect. - let wallet_id = req.proof.wallet_id().expect("proof yields wallet_id"); - *state.last_register.lock().unwrap() = Some(req); - Json(RegisterResponse { - success: true, - wallet_id: Some(wallet_id), - error: None, - }) -} - -async fn mock_session( - State(state): State, - Json(req): Json, -) -> Json { - let wallet_id = req.derive_wallet_id().expect("derive session wallet_id"); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - let token = SessionToken { - token: "mock.jwt.token".to_string(), - wallet_id, - created_at: now, - expires_at: now + 3600, - }; - *state.last_session.lock().unwrap() = Some(req); - Json(SessionResponse { - success: true, - token: Some(token.clone()), - expires_at: Some(token.expires_at), - error: None, - }) -} - -async fn spawn_mock(state: MockState) -> std::net::SocketAddr { - let app = Router::new() - .route("/api/v1/register", post(mock_register)) - .route("/api/v1/session", post(mock_session)) - .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - // Tiny pause so listener is reachable before the client tries. - tokio::time::sleep(Duration::from_millis(20)).await; - addr -} - -#[tokio::test] -async fn register_round_trip() { - let state = MockState::default(); - let addr = spawn_mock(state.clone()).await; - let client = GspClient::new(format!("ws://{addr}/ws/v1")); - - let ks = Keystore::from_mnemonic(VECTOR_MNEMONIC).unwrap(); - let kp = auth::auth_keypair(&ks).unwrap(); - let proof = auth::make_proof(&kp, "register").unwrap(); - - let returned_id = client - .register(proof, Some("test-wallet".into())) - .await - .expect("register succeeds against mock"); - - let expected_id = { - let pk = auth::xonly_pubkey_bytes(&kp); - WalletId::from_pubkey(&pk) - }; - assert_eq!(returned_id, expected_id); - - let recorded = state.last_register.lock().unwrap().clone().unwrap(); - assert_eq!(recorded.proof.action(), Some("register")); - assert_eq!(recorded.display_name.as_deref(), Some("test-wallet")); -} - -#[tokio::test] -async fn create_session_round_trip() { - let state = MockState::default(); - let addr = spawn_mock(state.clone()).await; - let client = GspClient::new(format!("ws://{addr}/ws/v1")); - - let ks = Keystore::from_mnemonic(VECTOR_MNEMONIC).unwrap(); - let kp = auth::auth_keypair(&ks).unwrap(); - let proof = auth::make_proof(&kp, "session").unwrap(); - - let session_nonce = hex::encode([0xCDu8; 32]); - let token = client - .create_session(proof, Some(session_nonce.clone())) - .await - .expect("session succeeds against mock"); - - assert_eq!(token.token, "mock.jwt.token"); - assert!(!token.is_expired()); - - let recorded = state.last_session.lock().unwrap().clone().unwrap(); - assert_eq!(recorded.proof.action(), Some("session")); - assert_eq!( - recorded.session_nonce.as_deref(), - Some(session_nonce.as_str()) - ); -} - -#[tokio::test] -async fn server_error_propagates() { - // 4xx-equivalent — mock returns success: false, error: Some(...) - async fn handler(Json(_req): Json) -> Json { - Json(RegisterResponse { - success: false, - wallet_id: None, - error: Some("WalletAlreadyRegistered".to_string()), - }) - } - let app = Router::new().route("/api/v1/register", post(handler)); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(20)).await; - - let client = GspClient::new(format!("ws://{addr}/ws/v1")); - let ks = Keystore::from_mnemonic(VECTOR_MNEMONIC).unwrap(); - let kp = auth::auth_keypair(&ks).unwrap(); - let proof = auth::make_proof(&kp, "register").unwrap(); - let err = client.register(proof, None).await.unwrap_err(); - let msg = format!("{err}"); - assert!(msg.contains("WalletAlreadyRegistered"), "got: {msg}"); -} diff --git a/apps/wraith-wallet/core/tests/l2_transfer_e2e.rs b/apps/wraith-wallet/core/tests/l2_transfer_e2e.rs deleted file mode 100644 index a65b5e158..000000000 --- a/apps/wraith-wallet/core/tests/l2_transfer_e2e.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! End-to-end wiring test for `wraith light send` (the L2 transfer -//! one-shot). -//! -//! Mirrors `watch_payments.rs` in approach: spins up an in-process -//! axum WebSocket server that speaks just enough of the GSP proto -//! to (a) authenticate the client and (b) reply to -//! `ClientMessage::SendL2Payment` with `ServerMessage::PaymentSent`. -//! Then drives the wallet's real `SessionHandle::send_l2_payment` and -//! asserts the response shape. -//! -//! This is the contract test for the L2 send wire — if it goes red, -//! `wraith light send` will silently break end-to-end against a real -//! ghost-gsp + ghost-pay stack. The coverage is otherwise only via -//! `scripts/regtest-l2-transfer-demo.sh`, which needs a real -//! `ghostd`/`bitcoind` to run. - -use std::time::Duration; - -use axum::{ - extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, - response::IntoResponse, - routing::get, - Router, -}; -use ghost_gsp_proto::{ClientMessage, ServerMessage, TransactionInfo, WalletProof}; -use wraith_wallet_core::gsp::spawn_session; - -/// Handle the GSP WebSocket frame loop. The mock supports the two -/// message exchanges this test needs: `Authenticate` and -/// `SendL2Payment`. Anything else is silently dropped — the real -/// GSP handles a much larger surface but we only assert against -/// the L2 send round-trip here. -async fn handle_ws(mut socket: WebSocket) { - while let Some(Ok(frame)) = socket.recv().await { - let text = match frame { - WsMessage::Text(t) => t, - WsMessage::Close(_) => return, - _ => continue, - }; - let msg: ClientMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - match msg { - ClientMessage::Authenticate { .. } => { - let auth = ServerMessage::AuthResult { - success: true, - wallet_id: Some("alice-wallet".into()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&auth).unwrap())) - .await; - } - ClientMessage::SendL2Payment { - recipient, - amount_sats, - .. - } => { - // Mirror what ghost-gsp does on the wire: synthesize a - // payment_id and reply with PaymentSent. - let reply = ServerMessage::PaymentSent { - success: true, - payment_id: Some(format!("pay_{}", &recipient[..8.min(recipient.len())])), - amount_sats, - recipient: recipient.clone(), - status: Some("pending".to_string()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&reply).unwrap())) - .await; - } - _ => {} - } - } -} - -async fn spawn_mock() -> std::net::SocketAddr { - let app = Router::new().route( - "/ws/v1", - get(|ws: WebSocketUpgrade| async move { ws.on_upgrade(handle_ws).into_response() }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(20)).await; - addr -} - -#[tokio::test] -async fn light_send_round_trips_send_l2_payment() { - let addr = spawn_mock().await; - let ws_url = format!("ws://{addr}/ws/v1"); - - let session = spawn_session(vec![ws_url], "mock-jwt-token".to_string(), None, None); - - // The mock accepts any structurally-valid WalletProof; the test's - // assertion is on the wire round-trip, not on the proof crypto - // (which has its own coverage in ghost-gsp-proto's auth tests). - let proof = WalletProof::new("send-l2", &[7u8; 32]).expect("build proof"); - - let result = tokio::time::timeout( - Duration::from_secs(3), - session.send_l2_payment( - "bob_recipient_ghost_id_xyz".to_string(), - 5_000, - proof, - Some("groceries".to_string()), - ), - ) - .await - .expect("send_l2_payment timeout") - .expect("send_l2_payment failed"); - - assert_eq!(result.amount_sats, 5_000); - assert_eq!(result.recipient, "bob_recipient_ghost_id_xyz"); - assert_eq!(result.status, "pending"); - // payment_id is operator-synthesised; just check it has the - // expected prefix shape (the mock builds `pay_`). - assert!( - result.payment_id.starts_with("pay_"), - "expected pay_ prefix, got {}", - result.payment_id - ); -} - -#[tokio::test] -async fn light_history_round_trips_get_transactions() { - // Distinct mock that handles GetTransactions by replying with a - // small canned ledger. Tests pin the `Transactions` reply shape - // and the wallet's `TransactionsResult` parsing — closes the - // other half of the L2 send/history wire. - async fn handle_ws(mut socket: WebSocket) { - while let Some(Ok(frame)) = socket.recv().await { - let text = match frame { - WsMessage::Text(t) => t, - _ => continue, - }; - let msg: ClientMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - match msg { - ClientMessage::Authenticate { .. } => { - let auth = ServerMessage::AuthResult { - success: true, - wallet_id: Some("alice".into()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&auth).unwrap())) - .await; - } - ClientMessage::GetTransactions { - limit, - offset: _, - wallet_bech32: _, - } => { - // Build a tiny fake ledger: one send (-5000), one - // receive (+5000), both with the new shape. - let txs = vec![ - TransactionInfo { - txid: "deadbeef".repeat(8), - block_height: Some(123), - timestamp: 1_700_000_000, - amount_sats: -5_000, - fee_sats: Some(0), - tx_type: "send".to_string(), - confirmations: 6, - memo: Some("groceries".to_string()), - }, - TransactionInfo { - txid: "cafef00d".repeat(8), - block_height: Some(124), - timestamp: 1_700_000_500, - amount_sats: 5_000, - fee_sats: Some(0), - tx_type: "receive".to_string(), - confirmations: 5, - memo: None, - }, - ]; - let total = txs.len() as u32; - let truncated: Vec<_> = txs.into_iter().take(limit as usize).collect(); - let reply = ServerMessage::Transactions { - transactions: truncated, - total_count: total, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&reply).unwrap())) - .await; - } - _ => {} - } - } - } - - let app = Router::new().route( - "/ws/v1", - get(|ws: WebSocketUpgrade| async move { ws.on_upgrade(handle_ws).into_response() }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(20)).await; - let ws_url = format!("ws://{addr}/ws/v1"); - - let session = spawn_session(vec![ws_url], "mock-jwt-token".to_string(), None, None); - - let result = tokio::time::timeout(Duration::from_secs(3), session.get_transactions(50, 0)) - .await - .expect("get_transactions timeout") - .expect("get_transactions failed"); - - assert_eq!(result.total_count, 2); - assert_eq!(result.transactions.len(), 2); - let send = &result.transactions[0]; - assert_eq!(send.tx_type, "send"); - assert_eq!(send.amount_sats, -5_000); - assert_eq!(send.memo.as_deref(), Some("groceries")); - let receive = &result.transactions[1]; - assert_eq!(receive.tx_type, "receive"); - assert_eq!(receive.amount_sats, 5_000); -} - -#[tokio::test] -async fn send_l2_payment_propagates_server_error() { - // Distinct mock for the failure path so it doesn't share state. - async fn handle_ws_failure(mut socket: WebSocket) { - while let Some(Ok(frame)) = socket.recv().await { - let text = match frame { - WsMessage::Text(t) => t, - _ => continue, - }; - let msg: ClientMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - match msg { - ClientMessage::Authenticate { .. } => { - let auth = ServerMessage::AuthResult { - success: true, - wallet_id: Some("alice".into()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&auth).unwrap())) - .await; - } - ClientMessage::SendL2Payment { - recipient, - amount_sats, - .. - } => { - let reply = ServerMessage::PaymentSent { - success: false, - payment_id: None, - amount_sats, - recipient, - status: None, - error: Some("Insufficient L2 balance".to_string()), - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&reply).unwrap())) - .await; - } - _ => {} - } - } - } - - let app = Router::new().route( - "/ws/v1", - get(|ws: WebSocketUpgrade| async move { ws.on_upgrade(handle_ws_failure).into_response() }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(20)).await; - let ws_url = format!("ws://{addr}/ws/v1"); - - let session = spawn_session(vec![ws_url], "mock-jwt-token".to_string(), None, None); - - let proof = WalletProof::new("send-l2", &[8u8; 32]).expect("build proof"); - - let outcome = tokio::time::timeout( - Duration::from_secs(3), - session.send_l2_payment("bob_ghost_id".to_string(), 999_999_999, proof, None), - ) - .await - .expect("timeout waiting for failure response"); - - assert!(outcome.is_err(), "expected Err, got {outcome:?}"); - let err = outcome.unwrap_err(); - assert!( - err.contains("Insufficient L2 balance"), - "expected error to surface server message, got: {err}" - ); -} diff --git a/apps/wraith-wallet/core/tests/lock_cosign_mock.rs b/apps/wraith-wallet/core/tests/lock_cosign_mock.rs new file mode 100644 index 000000000..fb0acaf83 --- /dev/null +++ b/apps/wraith-wallet/core/tests/lock_cosign_mock.rs @@ -0,0 +1,394 @@ +//! The quorum co-signing client, against an in-process coordinator. +//! +//! The mock is not a stub: it runs the real `wraith_protocol::lock_cosign` +//! logic with a real quorum key, so this exercises the JSON shapes the two +//! sides actually exchange as well as the signature they jointly produce. +//! A stub that returned canned bytes would agree with itself and prove +//! nothing. + +use std::sync::{Arc, Mutex}; + +use axum::{extract::State, routing::post, Json, Router}; +use bitcoin::secp256k1::{Keypair, Message, Secp256k1, SecretKey}; +use bitcoin::{ + absolute::LockTime, psbt::Psbt, transaction::Version, Amount, Network, OutPoint, ScriptBuf, + Sequence, Transaction, TxIn, TxOut, Txid, Witness, +}; +use ghost_lock::airgap::SigningRequest; +use ghost_lock::signing::VolatileNonceLedger; +use wraith_protocol::lock_cosign::{ + begin_cosign, CosignPolicy, CosignSession, Role, VelocityLimit, VolatileSpendLog, +}; +use wraith_protocol::signing_ledger::{SigningLedger, VolatileStore}; +use wraith_wallet_core::lock_cosign_client::{cosign_with_quorum, CosignError}; + +use std::str::FromStr; + +fn sk(b: u8) -> SecretKey { + SecretKey::from_slice(&[b; 32]).unwrap() +} +fn xo(s: &SecretKey) -> bitcoin::XOnlyPublicKey { + Keypair::from_secret_key(&Secp256k1::new(), s) + .x_only_public_key() + .0 +} + +struct Quorum { + key: SecretKey, + policy: CosignPolicy, + role: Role, + coins: SigningLedger, + spends: VolatileSpendLog, + pending: std::collections::HashMap, +} + +type Shared = Arc>; + +#[derive(serde::Deserialize)] +struct NonceReq { + /// The Lock's BINDING id, not its `lock_id`. Deserialised by name so this + /// mock fails loudly if the wallet ever goes back to sending the lock id — + /// which cannot work, because a lock id is a hash over the very quorum key + /// this request asks to be derived. + #[allow(dead_code)] + binding_id: String, + request: SigningRequest, +} + +async fn nonce(State(q): State, Json(body): Json) -> axum::response::Response { + use axum::response::IntoResponse; + let mut q = q.lock().unwrap(); + let keys = ghost_lock::airgap::keys(&body.request).unwrap(); + let root = ghost_lock::airgap::merkle_root(&body.request).unwrap(); + let policy = q.policy; + let role = q.role; + let key = q.key; + let Quorum { coins, spends, .. } = &mut *q; + let outcome = begin_cosign( + &body.request, + Network::Regtest, + policy, + role, + coins, + spends, + 0, + &key, + &keys, + root, + ); + match outcome { + Ok(session) => { + let summary = session.summary().clone(); + let id = hex::encode(session.public_nonce())[..32].to_string(); + let n = hex::encode(session.public_nonce()); + q.pending.insert(id.clone(), session); + Json(serde_json::json!({ + "session": id, + "public_nonce": n, + "input_sats": summary.input_sats, + "fee_sats": summary.fee_sats, + })) + .into_response() + } + Err(e) => ( + axum::http::StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "refused", "detail": e.to_string() })), + ) + .into_response(), + } +} + +#[derive(serde::Deserialize)] +struct PartialReq { + session: String, + public_nonces: Vec, +} + +async fn partial( + State(q): State, + Json(body): Json, +) -> axum::response::Response { + use axum::response::IntoResponse; + let session = q.lock().unwrap().pending.remove(&body.session); + let Some(session) = session else { + return ( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "unknown_session", "detail": "gone" })), + ) + .into_response(); + }; + let nonces: Vec<[u8; 66]> = body + .public_nonces + .iter() + .map(|n| hex::decode(n).unwrap().try_into().unwrap()) + .collect(); + let mut ledger = VolatileNonceLedger::default(); + match session.sign(&mut ledger, &nonces) { + Ok(p) => Json(serde_json::json!({ + "session": body.session, + "partial": hex::encode(p), + })) + .into_response(), + Err(e) => ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "sign", "detail": e })), + ) + .into_response(), + } +} + +async fn serve(q: Shared) -> String { + let app = Router::new() + .route("/api/v1/lock/cosign/nonce", post(nonce)) + .route("/api/v1/lock/cosign/partial", post(partial)) + .with_state(q); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") +} + +/// A Spending lane and a spend of it. +fn fixture(input_sats: u64) -> (ghost_lock::lane::Lane, SecretKey, SecretKey, SigningRequest) { + let owner = sk(101); + let quorum = sk(102); + let lane = ghost_lock::lane::SpendingPolicy { + aggregate: ghost_lock::key_agg::aggregate(&[xo(&owner), xo(&quorum)]).unwrap(), + owner: xo(&owner), + } + .build(&Secp256k1::new(), Network::Regtest) + .unwrap(); + + let prevout = TxOut { + value: Amount::from_sat(input_sats), + script_pubkey: lane.address.script_pubkey(), + }; + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_str( + "0000000000000000000000000000000000000000000000000000000000000009", + ) + .unwrap(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(input_sats - 1_000), + script_pubkey: lane.address.script_pubkey(), + }], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).unwrap(); + psbt.inputs[0].witness_utxo = Some(prevout); + + use base64::Engine as _; + let req = SigningRequest { + psbt: base64::engine::general_purpose::STANDARD.encode(psbt.serialize()), + input_index: 0, + keys: vec![ + hex::encode(xo(&owner).serialize()), + hex::encode(xo(&quorum).serialize()), + ], + merkle_root: lane.spend_info.merkle_root().map(|r| { + use bitcoin::hashes::Hash as _; + hex::encode(r.to_byte_array()) + }), + }; + (lane, owner, quorum, req) +} + +fn quorum_state(policy: CosignPolicy, role: Role, key: SecretKey) -> Shared { + Arc::new(Mutex::new(Quorum { + key, + policy, + role, + coins: SigningLedger::new(VolatileStore::default()), + spends: VolatileSpendLog::default(), + pending: Default::default(), + })) +} + +/// **The whole path: the wallet asks, the quorum agrees, the lane accepts.** +#[tokio::test] +async fn the_wallet_gets_a_signature_the_lane_accepts() { + let secp = Secp256k1::new(); + let (lane, owner, quorum, req) = fixture(100_000); + let url = serve(quorum_state(CosignPolicy::default(), Role::Active, quorum)).await; + + let keys = ghost_lock::airgap::keys(&req).unwrap(); + let root = ghost_lock::airgap::merkle_root(&req).unwrap(); + let (_, message) = ghost_lock::airgap::review(&req, Network::Regtest).unwrap(); + + let mut ledger = VolatileNonceLedger::default(); + let (sig, view) = cosign_with_quorum( + &reqwest::Client::new(), + &url, + "binding-abc", + &req, + &owner, + &keys, + root, + &message, + &mut ledger, + ) + .await + .expect("the quorum co-signs"); + + assert_eq!(view.input_sats, 100_000, "both sides read the same spend"); + assert_eq!(view.fee_sats, 1_000); + + let out = lane.spend_info.output_key().to_x_only_public_key(); + secp.verify_schnorr(&sig, &Message::from_digest(message), &out) + .expect("the lane must accept the pair's signature"); +} + +/// A refusal reaches the wallet as a refusal, with the reason attached. +/// +/// An owner told only "request failed" retries; one told the spend is over the +/// ceiling changes the spend. +#[tokio::test] +async fn a_refusal_carries_its_reason() { + let (_, owner, quorum, req) = fixture(500_000); + let url = serve(quorum_state( + CosignPolicy { + max_spend_sats: Some(100_000), + window: Some(VelocityLimit { + max_sats: 10_000_000, + window_secs: 86_400, + }), + }, + Role::Active, + quorum, + )) + .await; + + let keys = ghost_lock::airgap::keys(&req).unwrap(); + let root = ghost_lock::airgap::merkle_root(&req).unwrap(); + let (_, message) = ghost_lock::airgap::review(&req, Network::Regtest).unwrap(); + let mut ledger = VolatileNonceLedger::default(); + + let err = cosign_with_quorum( + &reqwest::Client::new(), + &url, + "binding-abc", + &req, + &owner, + &keys, + root, + &message, + &mut ledger, + ) + .await + .expect_err("above the ceiling"); + + match err { + CosignError::Refused { detail, .. } => { + assert!(detail.contains("500000"), "{detail}"); + assert!( + detail.contains("exit leaf"), + "the owner must be told they still have a way out: {detail}" + ); + } + other => panic!("expected a refusal, got {other:?}"), + } +} + +/// A quorum refusal must not burn the wallet's nonce. +/// +/// The wallet's round 1 happens after the quorum's, so a spend that was never +/// going to be co-signed costs it nothing — otherwise a hostile coordinator +/// could exhaust a wallet's willingness to sign by refusing everything. +#[tokio::test] +async fn a_refusal_costs_the_wallet_no_nonce() { + let (_, owner, quorum, req) = fixture(500_000); + let url = serve(quorum_state( + CosignPolicy { + max_spend_sats: Some(100_000), + window: None, + }, + Role::Active, + quorum, + )) + .await; + + let keys = ghost_lock::airgap::keys(&req).unwrap(); + let root = ghost_lock::airgap::merkle_root(&req).unwrap(); + let (_, message) = ghost_lock::airgap::review(&req, Network::Regtest).unwrap(); + let mut ledger = VolatileNonceLedger::default(); + + for _ in 0..3 { + assert!(cosign_with_quorum( + &reqwest::Client::new(), + &url, + "binding-abc", + &req, + &owner, + &keys, + root, + &message, + &mut ledger, + ) + .await + .is_err()); + } + // Nothing was burned, so a spend the quorum WOULD accept still works. + let (_, owner2, quorum2, small) = fixture(50_000); + let url2 = serve(quorum_state( + CosignPolicy { + max_spend_sats: Some(100_000), + window: None, + }, + Role::Active, + quorum2, + )) + .await; + let keys2 = ghost_lock::airgap::keys(&small).unwrap(); + let root2 = ghost_lock::airgap::merkle_root(&small).unwrap(); + let (_, message2) = ghost_lock::airgap::review(&small, Network::Regtest).unwrap(); + assert!(cosign_with_quorum( + &reqwest::Client::new(), + &url2, + "binding-abc", + &small, + &owner2, + &keys2, + root2, + &message2, + &mut ledger, + ) + .await + .is_ok()); +} + +/// A standby is a routing problem, and the wallet says so. +#[tokio::test] +async fn a_standby_quorum_is_reported_as_a_refusal_not_a_crash() { + let (_, owner, quorum, req) = fixture(100_000); + let url = serve(quorum_state(CosignPolicy::default(), Role::Standby, quorum)).await; + let keys = ghost_lock::airgap::keys(&req).unwrap(); + let root = ghost_lock::airgap::merkle_root(&req).unwrap(); + let (_, message) = ghost_lock::airgap::review(&req, Network::Regtest).unwrap(); + let mut ledger = VolatileNonceLedger::default(); + + let err = cosign_with_quorum( + &reqwest::Client::new(), + &url, + "binding-abc", + &req, + &owner, + &keys, + root, + &message, + &mut ledger, + ) + .await + .expect_err("a standby does not co-sign"); + assert!(format!("{err}").contains("standby"), "{err}"); +} diff --git a/apps/wraith-wallet/core/tests/lock_recovery_e2e.rs b/apps/wraith-wallet/core/tests/lock_recovery_e2e.rs deleted file mode 100644 index 20ad0c1c1..000000000 --- a/apps/wraith-wallet/core/tests/lock_recovery_e2e.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! End-to-end test of the unilateral-exit path: wallet keystore + -//! BIP86 derivation + ghost-locks script + recovery-tx builder + -//! bitcoind RPC client. -//! -//! No real bitcoind in the loop — a tiny one-shot HTTP server -//! impersonates the relevant RPC subset (getblockcount, -//! getrawtransaction, sendrawtransaction). The point of this test -//! isn't "bitcoind accepts this" (that's verified by the round-trip -//! through `secp256k1::verify_ecdsa` in lock_recovery's unit tests -//! — same call Bitcoin Core makes during witness-program execution). -//! The point is "every wire-format edge between the modules -//! lines up, and the daemon's LocksRecover dispatch produces a -//! correctly-formed `sendrawtransaction` call against bitcoind." -//! -//! For an actual live demo against `bitcoind -regtest`, see -//! `scripts/regtest-recovery-demo.sh` (sibling commit). - -use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpListener, TcpStream}; -use std::sync::{Arc, Mutex}; -use std::thread::JoinHandle; - -use bitcoin::secp256k1::{PublicKey, Secp256k1}; -use bitcoin::Network; -use ghost_locks::{Denomination, GhostLock, TimelockTier}; -use wraith_wallet_core::ghostd::GhostdRpc; -use wraith_wallet_core::keystore::Keystore; -use wraith_wallet_core::lock_recovery::{build_recovery_spend, RecoverySpendInputs}; - -/// Test deterministic mnemonic — stable so the recovery_pubkey is -/// reproducible across test runs. -const TEST_MNEMONIC: &str = - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; - -/// Bitcoin Core regtest funding txid placeholder. Realistic enough -/// to round-trip through the daemon's resolution path. -const FUNDING_TXID: &str = "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"; - -/// Mock bitcoind that records every JSON-RPC method it sees and -/// replies with caller-configured fixtures. -struct MockBitcoind { - /// Method → reply JSON. Single-shot per method by default; if - /// `Vec` is supplied the responses replay in order. - replies: Mutex>>, - /// Methods the test asserted MUST be called. - received: Mutex>, -} - -impl MockBitcoind { - fn new() -> Arc { - Arc::new(Self { - replies: Mutex::new(Default::default()), - received: Mutex::new(Vec::new()), - }) - } - fn set_reply(&self, method: &str, reply: serde_json::Value) { - self.replies - .lock() - .unwrap() - .entry(method.into()) - .or_default() - .push(reply); - } - fn calls(&self) -> Vec { - self.received.lock().unwrap().clone() - } -} - -fn spawn_mock(mock: Arc, expect_calls: usize) -> (String, JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let url = format!("http://127.0.0.1:{port}/"); - let handle = - std::thread::spawn(move || { - // Serve EXACTLY the expected number of requests then exit. - // accept() blocks indefinitely; we'd hang on join() if we - // looped past the test's bounded request count. - for _ in 0..expect_calls { - let (mut stream, _) = match listener.accept() { - Ok(s) => s, - Err(_) => return, - }; - let body = read_request(&stream); - let parsed: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(_) => continue, - }; - let method = parsed["method"].as_str().unwrap_or("").to_string(); - mock.received.lock().unwrap().push(method.clone()); - let mut replies = mock.replies.lock().unwrap(); - let reply_value = replies - .get_mut(&method) - .and_then(|q| if q.is_empty() { None } else { Some(q.remove(0)) }) - .unwrap_or_else(|| { - serde_json::json!({ - "result": null, - "error": { "code": -32601, "message": format!("no fixture for {method}") }, - "id": "wraithd", - }) - }); - let body_str = reply_value.to_string(); - let resp = format!( - "HTTP/1.1 200 OK\r\n\ - Content-Type: application/json\r\n\ - Content-Length: {}\r\n\ - \r\n\ - {}", - body_str.len(), - body_str - ); - let _ = stream.write_all(resp.as_bytes()); - } - }); - (url, handle) -} - -fn read_request(stream: &TcpStream) -> String { - let mut reader = BufReader::new(stream); - let mut content_length: usize = 0; - loop { - let mut line = String::new(); - if reader.read_line(&mut line).is_err() { - return String::new(); - } - if line == "\r\n" { - break; - } - let lower = line.to_ascii_lowercase(); - if let Some(rest) = lower.strip_prefix("content-length:") { - content_length = rest.trim().parse().unwrap_or(0); - } - } - let mut body = vec![0u8; content_length]; - let _ = std::io::Read::read_exact(&mut reader, &mut body); - String::from_utf8(body).unwrap_or_default() -} - -#[test] -fn unilateral_exit_e2e_recovers_locked_funds_with_no_operator_cooperation() { - use bitcoin::{Address, ScriptBuf}; - - // 1. Wallet derives its own recovery_pubkey at index 0 from a - // real keystore. This is the SAME call the daemon's - // LocksPrepare handler makes. - let keystore = Keystore::from_mnemonic(TEST_MNEMONIC).unwrap(); - let ghost_keys = keystore.ghost_keys().unwrap(); - let recovery_pubkey_bytes = ghost_keys.derive_recovery_pubkey(0).unwrap(); - let recovery_secret = ghost_keys.derive_recovery_secret(0).unwrap(); - let recovery_pubkey = PublicKey::from_slice(&recovery_pubkey_bytes).unwrap(); - - // 2. Operator-side: imagine ghost-pay has a master key. We - // simulate it with a deterministic secret. In production - // this is `keys.derive_lock_secret(lock_index)`. - let secp = Secp256k1::new(); - let lock_secret = bitcoin::secp256k1::SecretKey::from_slice(&[0x42u8; 32]).unwrap(); - let lock_pubkey = PublicKey::from_secret_key(&secp, &lock_secret); - - // 3. Build the actual P2WSH lock — exact same constructor - // ghost-pay uses post-commit-65. - let creation_height: u32 = 800_000; - let lock = GhostLock::from_pubkeys( - lock_pubkey, - recovery_pubkey, - Denomination::Tiny, - TimelockTier::Short, - creation_height, - ) - .expect("lock builds"); - - // The on-chain funding output is the lock's scriptPubKey. - let funding_address = Address::from_script(lock.script_pubkey(), Network::Signet) - .unwrap() - .to_string(); - let funding_value_sats = Denomination::Tiny.sats(); // 100_000 - - // 4. Set up the mock bitcoind. The wallet's LocksRecover path - // calls THREE RPC methods in order — getblockcount, - // getrawtransaction, sendrawtransaction. - let mock = MockBitcoind::new(); - let recovery_blocks = TimelockTier::Short.blocks(); - // Tip the chain past the timelock so maturity check passes. - mock.set_reply( - "getblockcount", - serde_json::json!({ - "result": (creation_height + recovery_blocks + 5) as u64, - "error": null, - "id": "wraithd", - }), - ); - // getrawtransaction returns a tx whose vout[0] pays our lock - // address with our funding amount + the lock's scriptPubKey hex. - mock.set_reply( - "getrawtransaction", - serde_json::json!({ - "result": { - "txid": FUNDING_TXID, - "confirmations": 6, - "vout": [ - { - "n": 0, - "value": (funding_value_sats as f64) / 100_000_000.0, - "scriptPubKey": { - "hex": hex::encode(lock.script_pubkey().as_bytes()), - "address": funding_address, - "type": "witness_v0_scripthash", - } - } - ] - }, - "error": null, - "id": "wraithd", - }), - ); - // sendrawtransaction returns the tx's own computed txid (matches - // honest-bitcoind behaviour). The mock doesn't validate the tx — - // that's intentional. Validity is asserted by the round-trip - // verify in lock_recovery's unit tests. - // We supply a placeholder txid; the wallet doesn't actually use - // it for assertions in this test (it only echoes it back). - mock.set_reply( - "sendrawtransaction", - serde_json::json!({ - "result": "0000000000000000000000000000000000000000000000000000000000000bee", - "error": null, - "id": "wraithd", - }), - ); - - let (rpc_url, server_handle) = spawn_mock(mock.clone(), 3); - let rpc = GhostdRpc::new(rpc_url, "user", "pass"); - - // 5. Resolve the funding outpoint via getrawtransaction (same - // code path the daemon's LocksRecover handler uses). - let raw = rpc.get_raw_transaction_verbose(FUNDING_TXID).unwrap(); - let target_addr = funding_address.clone(); - let vout = raw - .vout - .iter() - .find(|v| v.script_pubkey.first_address() == Some(&target_addr)) - .expect("funding vout present"); - assert_eq!(vout.value_sats(), funding_value_sats); - assert_eq!(vout.n, 0); - - // 6. Maturity check. - let current_height = rpc.get_block_count().unwrap() as u32; - assert!(current_height >= creation_height + recovery_blocks); - - // 7. Build the recovery spend with the user's keystore-derived - // recovery_secret. - let destination = "tb1q0xcqpzrky6eff2g52qdye53xkk9jxkvraulyla"; - let inputs = RecoverySpendInputs { - lock_pubkey_hex: hex::encode(lock_pubkey.serialize()), - recovery_pubkey_hex: hex::encode(recovery_pubkey.serialize()), - recovery_blocks, - funding_txid: FUNDING_TXID.into(), - funding_vout: vout.n, - prev_value_sats: vout.value_sats(), - funding_scriptpubkey_hex: vout.script_pubkey.hex.clone(), - destination_address: destination.into(), - fee_sats: 1_000, - network: Network::Signet, - current_height, - creation_height, - }; - let built = build_recovery_spend(&inputs, &recovery_secret).expect("build ok"); - - // 8. Broadcast. - let returned_txid = rpc.send_raw_transaction(&built.raw_hex).unwrap(); - assert_eq!( - returned_txid, - "0000000000000000000000000000000000000000000000000000000000000bee" - ); - - // 9. The mock saw the three methods, in order. That's the - // full LocksRecover wire path: maturity → outpoint → broadcast. - server_handle - .join() - .unwrap_or_else(|_| panic!("mock server panicked")); - let calls = mock.calls(); - assert_eq!( - calls, - vec![ - "getrawtransaction".to_string(), - "getblockcount".to_string(), - "sendrawtransaction".to_string(), - ], - "wallet hit bitcoind's RPC in the expected order with the expected methods" - ); - - // 10. The recovery tx's witness is the recovery branch — empty - // selector picks OP_ELSE — and the destination output goes - // to the wallet-controlled address. This is the headline: - // no operator key, no operator HTTP endpoint, just wallet + - // bitcoind, and the user gets their bitcoin back. - assert_eq!(built.tx.input.len(), 1); - assert_eq!(built.tx.output.len(), 1); - use std::str::FromStr; - let dest_spk = Address::from_str(destination) - .unwrap() - .require_network(Network::Signet) - .unwrap() - .script_pubkey(); - assert_eq!(built.tx.output[0].script_pubkey, dest_spk); - assert_eq!( - built.tx.output[0].value.to_sat(), - funding_value_sats - 1_000 - ); - let witness_items = built.tx.input[0].witness.iter().count(); - assert_eq!(witness_items, 3, "recovery witness has 3 items"); - let _ = ScriptBuf::new(); -} diff --git a/apps/wraith-wallet/core/tests/peer_rotation.rs b/apps/wraith-wallet/core/tests/peer_rotation.rs index 659feeba6..52a5f844f 100644 --- a/apps/wraith-wallet/core/tests/peer_rotation.rs +++ b/apps/wraith-wallet/core/tests/peer_rotation.rs @@ -69,6 +69,8 @@ fn signet_addr_for(i: u8) -> String { fn fixture_request() -> MixRequest { MixRequest { + // Rotation is under test here, not the anonymity floor. + min_entities: 1, tier_id: "100k_sats".into(), ghost_id: "rotation-test".into(), utxo: ParticipantUtxo { diff --git a/apps/wraith-wallet/core/tests/prepare_lock_e2e.rs b/apps/wraith-wallet/core/tests/prepare_lock_e2e.rs deleted file mode 100644 index d0f657482..000000000 --- a/apps/wraith-wallet/core/tests/prepare_lock_e2e.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! End-to-end wiring test for `wraith locks prepare`. -//! -//! Mirrors `l2_transfer_e2e.rs`: spins up an in-process axum -//! WebSocket server that speaks just enough of the GSP proto to -//! handle `Authenticate` + `PrepareGhostLock`, drives the wallet's -//! real `SessionHandle::prepare_ghost_lock`, and asserts the -//! `LockPreparedResult` shape — every field that wraithd later -//! stores in `prepared_locks.json` and that the recovery path needs -//! to rebuild the lock script. -//! -//! This is the contract test for the lock-prepare wire. If it goes -//! red, `wraith locks prepare` will silently break end-to-end -//! against a real ghost-gsp + ghost-pay stack. The coverage is -//! otherwise only via `scripts/regtest-recovery-demo.sh`, which -//! needs a real `ghostd`/`bitcoind` to run. - -use std::time::Duration; - -use axum::{ - extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, - response::IntoResponse, - routing::get, - Router, -}; -use ghost_gsp_proto::{ClientMessage, ServerMessage}; -use wraith_wallet_core::gsp::spawn_session; - -const FAKE_OWNER_PUBKEY: &str = - "02a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"; -const FAKE_RECOVERY_PUBKEY: &str = - "03f5b761c5d570a323c368af1ed38981a3ab668f3843f47bc9c467ec83fcdb07b0"; - -async fn handle_ws(mut socket: WebSocket) { - while let Some(Ok(frame)) = socket.recv().await { - let text = match frame { - WsMessage::Text(t) => t, - WsMessage::Close(_) => return, - _ => continue, - }; - let msg: ClientMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - match msg { - ClientMessage::Authenticate { .. } => { - let auth = ServerMessage::AuthResult { - success: true, - wallet_id: Some("test-wallet".into()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&auth).unwrap())) - .await; - } - ClientMessage::PrepareGhostLock { - owner_pubkey, - capacity_sats, - recovery_pubkey, - recovery_index, - } => { - // Mirror what ghost-gsp does: synthesise a lock_id + - // funding address based on the inputs, echo every - // wallet-supplied field back so the wallet can verify - // the operator didn't substitute the recovery key. - let lock_id = format!("lock_{}", &owner_pubkey[..16.min(owner_pubkey.len())]); - let reply = ServerMessage::LockPrepared { - success: true, - lock_id: Some(lock_id), - funding_address: Some( - "bcrt1qmagdfmhljgml3arzv3sgu2kkn89dhnjwqzn8wr5aqnv4np08x8us03gh3a" - .to_string(), - ), - required_sats: Some(capacity_sats), - lock_pubkey: Some(owner_pubkey), - recovery_pubkey: Some(recovery_pubkey), - recovery_index: Some(recovery_index), - recovery_blocks: Some(10), - creation_height: Some(101), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&reply).unwrap())) - .await; - } - _ => {} - } - } -} - -async fn spawn_mock() -> std::net::SocketAddr { - let app = Router::new().route( - "/ws/v1", - get(|ws: WebSocketUpgrade| async move { ws.on_upgrade(handle_ws).into_response() }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(20)).await; - addr -} - -#[tokio::test] -async fn prepare_ghost_lock_round_trips_lock_prepared() { - let addr = spawn_mock().await; - let ws_url = format!("ws://{addr}/ws/v1"); - - let session = spawn_session(vec![ws_url], "mock-jwt-token".to_string(), None, None); - - let result = tokio::time::timeout( - Duration::from_secs(3), - session.prepare_ghost_lock( - FAKE_OWNER_PUBKEY.to_string(), - 100_000, - FAKE_RECOVERY_PUBKEY.to_string(), - 7, - ), - ) - .await - .expect("prepare_ghost_lock timeout") - .expect("prepare_ghost_lock failed"); - - assert_eq!(result.required_sats, 100_000); - assert_eq!( - result.funding_address, - "bcrt1qmagdfmhljgml3arzv3sgu2kkn89dhnjwqzn8wr5aqnv4np08x8us03gh3a" - ); - assert!( - result.lock_id.starts_with("lock_"), - "expected lock_ prefix, got {}", - result.lock_id - ); - // Verify the operator echoed back exactly what the wallet sent — - // this is the substitution-attack guard the recovery path relies - // on. If these drift, the wallet's recovery would build a script - // that doesn't match the on-chain lock. - assert_eq!(result.lock_pubkey, FAKE_OWNER_PUBKEY); - assert_eq!(result.recovery_pubkey, FAKE_RECOVERY_PUBKEY); - assert_eq!(result.recovery_index, 7); - assert_eq!(result.recovery_blocks, 10); - assert_eq!(result.creation_height, 101); -} - -#[tokio::test] -async fn prepare_ghost_lock_propagates_server_error() { - async fn handle_ws_failure(mut socket: WebSocket) { - while let Some(Ok(frame)) = socket.recv().await { - let text = match frame { - WsMessage::Text(t) => t, - _ => continue, - }; - let msg: ClientMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - match msg { - ClientMessage::Authenticate { .. } => { - let auth = ServerMessage::AuthResult { - success: true, - wallet_id: Some("alice".into()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&auth).unwrap())) - .await; - } - ClientMessage::PrepareGhostLock { .. } => { - let reply = ServerMessage::LockPrepared { - success: false, - lock_id: None, - funding_address: None, - required_sats: None, - lock_pubkey: None, - recovery_pubkey: None, - recovery_index: None, - recovery_blocks: None, - creation_height: None, - error: Some("Capacity below dust limit".to_string()), - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&reply).unwrap())) - .await; - } - _ => {} - } - } - } - - let app = Router::new().route( - "/ws/v1", - get(|ws: WebSocketUpgrade| async move { ws.on_upgrade(handle_ws_failure).into_response() }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(20)).await; - let ws_url = format!("ws://{addr}/ws/v1"); - - let session = spawn_session(vec![ws_url], "mock-jwt-token".to_string(), None, None); - - let outcome = tokio::time::timeout( - Duration::from_secs(3), - session.prepare_ghost_lock( - FAKE_OWNER_PUBKEY.to_string(), - 42, // below dust on purpose - FAKE_RECOVERY_PUBKEY.to_string(), - 0, - ), - ) - .await - .expect("timeout waiting for failure response"); - - assert!(outcome.is_err(), "expected Err, got {outcome:?}"); - let err = outcome.unwrap_err(); - assert!( - err.contains("Capacity below dust limit"), - "expected error to surface server message, got: {err}" - ); -} diff --git a/apps/wraith-wallet/core/tests/watch_payments.rs b/apps/wraith-wallet/core/tests/watch_payments.rs deleted file mode 100644 index a046aa848..000000000 --- a/apps/wraith-wallet/core/tests/watch_payments.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! End-to-end wiring test for the persistent session's payment broadcast. -//! -//! Spins up an in-process axum mock that speaks just enough of the GSP -//! WebSocket protocol to (a) authenticate a client, (b) reply to GetBalance, -//! and (c) push a single synthetic BIP-352 CandidateTransaction crafted to -//! match a freshly generated GhostKeys. -//! -//! Then it spawns the real `session::run` task against that mock, subscribes -//! via `SessionHandle::subscribe_payments()`, and asserts the detection lands -//! on the broadcast channel within a small timeout. -//! -//! This is the contract test for `subscribe_payments` — if it goes red, the -//! daemon's `WatchPayments` push will silently stop reaching clients. - -use std::time::Duration; - -use axum::{ - extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, - response::IntoResponse, - routing::get, - Router, -}; -use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use ghost_gsp_proto::{CandidateOutput, ClientMessage, ServerMessage}; -use ghost_keys::{derive_payment_address_v2, derive_shared_secret, GhostKeys}; -use rand::RngCore; -use wraith_wallet_core::gsp::spawn_session; - -async fn handle_ws(mut socket: WebSocket, candidate: ServerMessage) { - while let Some(Ok(frame)) = socket.recv().await { - let text = match frame { - WsMessage::Text(t) => t, - WsMessage::Close(_) => return, - _ => continue, - }; - let msg: ClientMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - match msg { - ClientMessage::Authenticate { .. } => { - let auth = ServerMessage::AuthResult { - success: true, - wallet_id: Some("mock-wallet".into()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&auth).unwrap())) - .await; - } - ClientMessage::GetBalance { .. } => { - let bal = ServerMessage::BalanceUpdate { - confirmed: 0, - unconfirmed: 0, - locked: 0, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&bal).unwrap())) - .await; - // After the initial GetBalance round-trip the session enters - // its main loop. Push the synthetic candidate exactly once. - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&candidate).unwrap())) - .await; - } - // Anything else (Pings, SubscribeSilentPayments, etc.) we just - // accept silently — the test only cares about the candidate path. - _ => {} - } - } -} - -fn build_synthetic_candidate(keys: &GhostKeys) -> ServerMessage { - let secp = Secp256k1::new(); - let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); - let eph_secret = SecretKey::from_slice(&bytes).expect("nonzero scalar"); - let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); - - let shared = derive_shared_secret(&eph_secret, keys.scan_pubkey()); - let (output_pub, _tweak) = - derive_payment_address_v2(keys.spend_pubkey(), &shared, 0).expect("derive output pubkey"); - let serialized = output_pub.serialize(); - let xonly = &serialized[1..]; - - ServerMessage::CandidateTransaction { - ephemeral_pubkey: hex::encode(ephemeral_pub.serialize()), - outputs: vec![CandidateOutput { - output_pubkey: hex::encode(xonly), - amount_sats: Some(50_000), - vout: 7, - }], - txid: "0".repeat(64), - block_height: Some(123_456), - } -} - -async fn spawn_mock(candidate: ServerMessage) -> std::net::SocketAddr { - let app = Router::new().route( - "/ws/v1", - get(move |ws: WebSocketUpgrade| { - let cand = candidate.clone(); - async move { - ws.on_upgrade(move |socket| handle_ws(socket, cand)) - .into_response() - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(20)).await; - addr -} - -#[tokio::test] -async fn watch_payments_delivers_synthetic_match() { - let receiver = GhostKeys::generate(); - let candidate = build_synthetic_candidate(&receiver); - - let addr = spawn_mock(candidate).await; - let ws_url = format!("ws://{addr}/ws/v1"); - - let session = spawn_session( - vec![ws_url], - "mock-jwt-token".to_string(), - Some(receiver), - None, - ); - let mut rx = session.subscribe_payments(); - - let detected = tokio::time::timeout(Duration::from_secs(3), rx.recv()) - .await - .expect("watch did not deliver in time") - .expect("broadcast channel closed before delivery"); - - assert_eq!(detected.k, 0); - assert_eq!(detected.amount_sats, Some(50_000)); - assert_eq!(detected.vout, 7); - assert_eq!(detected.block_height, Some(123_456)); - assert_eq!(detected.txid, "0".repeat(64)); -} - -#[tokio::test] -async fn watch_payments_no_match_keeps_channel_quiet() { - // A receiver who didn't generate the payment must NOT see the event, - // even though they subscribed before the mock pushed it. - let real_receiver = GhostKeys::generate(); - let unrelated_receiver = GhostKeys::generate(); - let candidate = build_synthetic_candidate(&real_receiver); - - let addr = spawn_mock(candidate).await; - let ws_url = format!("ws://{addr}/ws/v1"); - - let session = spawn_session( - vec![ws_url], - "mock-jwt-token".to_string(), - Some(unrelated_receiver), - None, - ); - let mut rx = session.subscribe_payments(); - - // 500ms is plenty for the candidate to arrive AND be scanned-and-dropped. - let outcome = tokio::time::timeout(Duration::from_millis(500), rx.recv()).await; - assert!( - outcome.is_err(), - "broadcast must stay silent for non-matching candidates, got {outcome:?}" - ); -} diff --git a/apps/wraith-wallet/core/tests/wraith_e2e.rs b/apps/wraith-wallet/core/tests/wraith_e2e.rs index 2dcb3eeed..7e3437053 100644 --- a/apps/wraith-wallet/core/tests/wraith_e2e.rs +++ b/apps/wraith-wallet/core/tests/wraith_e2e.rs @@ -140,6 +140,10 @@ async fn five_wallets_complete_a_full_mix_round() { scriptpubkey_hex: participant_address(i as u8).script_pubkey().to_hex_string(), }; let req = MixRequest { + // The flow is what is under test here, not the anonymity floor; + // a dedicated test covers the refusal. Set to 1 so a small + // fixture round does not fail for the wrong reason. + min_entities: 1, tier_id: TIER_ID.into(), ghost_id: ghost.clone(), utxo, @@ -147,8 +151,15 @@ async fn five_wallets_complete_a_full_mix_round() { mix_output_address: participant_address(i as u8 + 10).to_string(), }; let signer = |_tx: &bitcoin::Transaction, _idx: usize, _amt: u64| { + // 64 bytes, the length a real BIP-341 SIGHASH_DEFAULT signature + // has. The bytes are not a valid signature — this test drives + // the protocol flow, and `five_wallets_sign_real_taproot_...` + // covers real signing — but the LENGTH has to be realistic, + // because `check_witness_sighash` reads it to detect a sighash + // type that would void the pre-sign inspection. A four-byte + // placeholder was never something that could reach a chain. let mut w = Witness::new(); - w.push([0xde, 0xad, 0xbe, 0xef]); + w.push([0xdeu8; 64]); Ok::(w) }; let prove = move |challenge: &str| { @@ -161,7 +172,13 @@ async fn five_wallets_complete_a_full_mix_round() { Ok::(ownership_proof(&sid, i as u8, &txid, i as u32)) } }; - client.execute_mix(req, signer, prove).await + // Each wallet keeps its own ledger, as each would in production. + // Volatile is right here: the test is one process and one round, and + // the durable store has its own tests. + let mut ledger = wraith_protocol::signing_ledger::SigningLedger::new( + wraith_protocol::signing_ledger::VolatileStore::default(), + ); + client.execute_mix(req, signer, prove, &mut ledger).await }); handles.push(handle); } @@ -255,6 +272,123 @@ async fn wait_for_quorum(state: &CoordinatorState) -> String { } } +/// A round below the wallet's floor is left BEFORE the coin is committed. +/// +/// The protocol assembles rounds at five participants; this wallet's default +/// floor is ten. Landing in a legal round that is too small is therefore +/// ordinary, and it must be free to walk away from. +/// +/// It was not. The floor was only checked by `inspect`, which runs on the +/// assembled transaction — long after `/inputs` has committed the outpoint to +/// the round. A wallet that then declined to sign was swept as a non-signer +/// and had its own outpoint banned for a cooldown: punished for enforcing its +/// own privacy policy. +/// +/// The assertion that matters is the second one. An error alone would still be +/// satisfied by refusing too late, so this checks the coordinator's input +/// store is empty — that the coin was never handed over. +#[tokio::test] +async fn a_round_below_the_floor_is_left_before_the_coin_is_committed() { + let stub_broadcaster = StubBroadcaster::new(); + let state = Arc::new( + CoordinatorState::with_components( + Network::Signet, + Arc::new(wraith_protocol::SystemClock), + Arc::new(wraith_protocol::RandomSessionIdGenerator), + Some(signet_addr(99)), + Some(Arc::new(stub_broadcaster.clone()) as Arc), + ) + .with_utxo_source(Arc::new(participant_utxos())), + ); + let app = build_router(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("axum serve"); + }); + let base_url = format!("http://127.0.0.1:{port}"); + + // Five wallets enrol — a legal round, and exactly the size the protocol + // is allowed to assemble. + let mut handles = Vec::with_capacity(N); + for i in 0..N { + let base_url = base_url.clone(); + handles.push(tokio::spawn(async move { + let client = WraithSessionClient::new(base_url, Network::Signet); + let req = MixRequest { + // Ten: the wallet's real default, and twice what this round + // can hold. + min_entities: 10, + tier_id: TIER_ID.into(), + ghost_id: format!("wallet-{i}"), + utxo: ParticipantUtxo { + txid: "11".repeat(32), + vout: i as u32, + value_sats: SEAT_PRICE, + scriptpubkey_hex: participant_address(i as u8).script_pubkey().to_hex_string(), + }, + mix_output_address: participant_address(i as u8 + 10).to_string(), + }; + let prove = move |challenge: &str| { + let challenge = challenge.to_string(); + async move { + let txid = "11".repeat(32); + let sid = challenge.lines().nth(1).unwrap_or_default().to_string(); + Ok::(ownership_proof(&sid, i as u8, &txid, i as u32)) + } + }; + client.prepare_mix(req, prove).await + })); + } + + // Drive the round to Locked, the point at which the headcount is knowable. + let session_id = wait_for_quorum(&state).await; + state + .sessions + .apply_event(SessionGossipEvent::StateChanged { + session_id: session_id.clone(), + new_state: LiteSessionState::Locked, + }) + .expect("apply Locked"); + + for (i, h) in handles.into_iter().enumerate() { + let outcome = h.await.expect("wallet task"); + match outcome { + Err(WraithClientError::RoundTooSmallToJoin { + seats, + min_entities, + }) => { + assert_eq!(min_entities, 10, "wallet {i} reported the wrong floor"); + assert_eq!( + seats, N, + "wallet {i} should have seen all {N} enrolled seats, saw {seats} — \ + a count that is zero before anyone commits would make this test \ + pass for the wrong reason" + ); + } + other => panic!("wallet {i} should have left the round early, got {other:?}"), + } + } + + // The point of leaving early: nothing was committed, so nothing can be + // swept as a non-signer. + let committed = state + .inputs_store + .lock() + .expect("inputs_store") + .get(&session_id) + .cloned() + .unwrap_or_default(); + assert!( + committed.is_empty(), + "{} coins were committed to a round every wallet refused; each is now \ + exposed to the non-signer sweep", + committed.len() + ); +} + // --------------------------------------------------------------------------- // SOCKS5 proxy wiring (B: Tor anonymity for /outputs) // --------------------------------------------------------------------------- @@ -316,6 +450,10 @@ async fn prepare_then_submit_works_via_split_api() { let client = WraithSessionClient::new(base_url, Network::Signet); let ghost = format!("wallet-{i}"); let req = MixRequest { + // The flow is what is under test here, not the anonymity floor; + // a dedicated test covers the refusal. Set to 1 so a small + // fixture round does not fail for the wrong reason. + min_entities: 1, tier_id: TIER_ID.into(), ghost_id: ghost.clone(), utxo: ParticipantUtxo { @@ -350,10 +488,16 @@ async fn prepare_then_submit_works_via_split_api() { // Schnorr / ECDSA sign). tokio::time::sleep(std::time::Duration::from_millis(5)).await; let mut w = Witness::new(); - w.push([0xab, 0xcd, 0xef]); + w.push([0xabu8; 64]); // Phase 2: submit. - client.submit_witness(&prepared, w).await + // The split API now requires proof of inspection, exactly as the + // daemon's two-phase flow does. + let mut ledger = wraith_protocol::signing_ledger::SigningLedger::new( + wraith_protocol::signing_ledger::VolatileStore::default(), + ); + let inspected = prepared.inspect(&mut ledger).expect("round inspects"); + client.submit_witness(&inspected, w).await })); } @@ -466,6 +610,10 @@ async fn five_wallets_sign_real_taproot_witnesses_end_to_end() { let client = WraithSessionClient::new(base_url, Network::Signet); let ghost = format!("wallet-{i}"); let req = MixRequest { + // The flow is what is under test here, not the anonymity floor; + // a dedicated test covers the refusal. Set to 1 so a small + // fixture round does not fail for the wrong reason. + min_entities: 1, tier_id: TIER_ID.into(), ghost_id: ghost.clone(), utxo: ParticipantUtxo { @@ -508,7 +656,11 @@ async fn five_wallets_sign_real_taproot_witnesses_end_to_end() { DEFAULT_SCAN_INDEX_MAX.min(16), ) .expect("real signer ok"); - client.submit_witness(&prepared, witness).await + let mut ledger = wraith_protocol::signing_ledger::SigningLedger::new( + wraith_protocol::signing_ledger::VolatileStore::default(), + ); + let inspected = prepared.inspect(&mut ledger).expect("round inspects"); + client.submit_witness(&inspected, witness).await }); handles.push(handle); } @@ -535,9 +687,15 @@ async fn five_wallets_sign_real_taproot_witnesses_end_to_end() { let final_tx = stub_broadcaster.last().expect("broadcast happened"); assert_eq!(final_tx.input.len(), N); - // Reconstruct prevouts in tx order. inputs_store still holds the - // per-participant records; we walk it the same way the coordinator - // did when shipping prevouts on /round-tx. + // Reconstruct prevouts BY OUTPOINT, in transaction order. + // + // This used to walk `inputs_store` in registration order and claim that was + // "the same way the coordinator did". It no longer is: round inputs are + // shuffled, because registration order is close to arrival order and leaked + // who joined when. `/round-tx` keys its prevouts by outpoint for exactly + // this reason, and so must anything recomputing a sighash — a prevout list + // in the wrong order produces a wrong sighash and a signature that looks + // forged. let inputs = state .inputs_store .lock() @@ -545,18 +703,45 @@ async fn five_wallets_sign_real_taproot_witnesses_end_to_end() { .get(&session_id) .cloned() .unwrap_or_default(); - let mut prev_txouts: Vec = Vec::with_capacity(inputs.len()); - for inp in &inputs { + + let by_outpoint: std::collections::HashMap<(String, u32), _> = inputs + .iter() + .flat_map(|a| { + a.inputs + .iter() + .map(move |r| ((r.txid.trim().to_ascii_lowercase(), r.vout), (a, r))) + }) + .collect(); + + let mut prev_txouts: Vec = Vec::with_capacity(final_tx.input.len()); + for txin in &final_tx.input { + let key = ( + txin.previous_output.txid.to_string().to_ascii_lowercase(), + txin.previous_output.vout, + ); + let (_, r) = by_outpoint.get(&key).expect("prevout for every tx input"); prev_txouts.push(TxOut { - value: bitcoin::Amount::from_sat(inp.input.value_sats), - script_pubkey: ScriptBuf::from_bytes(hex::decode(&inp.input.scriptpubkey_hex).unwrap()), + value: bitcoin::Amount::from_sat(r.value_sats), + script_pubkey: ScriptBuf::from_bytes(hex::decode(&r.scriptpubkey_hex).unwrap()), }); } let secp = Secp256k1::new(); use bitcoin::hashes::Hash as _; use bitcoin::key::TapTweak; - for (idx, inp) in inputs.iter().enumerate() { + // Iterate the TRANSACTION's inputs, not the registration list — the two + // are no longer in the same order, and using the registration index would + // verify each signature against somebody else's input. + for idx in 0..final_tx.input.len() { + let key = ( + final_tx.input[idx] + .previous_output + .txid + .to_string() + .to_ascii_lowercase(), + final_tx.input[idx].previous_output.vout, + ); + let (inp, _) = by_outpoint.get(&key).expect("record for every tx input"); // Recompute the sighash for this input. let mut cache = SighashCache::new(&final_tx); let sighash = cache @@ -602,3 +787,148 @@ async fn five_wallets_sign_real_taproot_witnesses_end_to_end() { }); } } + +/// The floor refuses a round that is too small, and refuses it BEFORE signing. +/// +/// The wallet used to go straight from `/round-tx` to `sign` with no inspection +/// at all — no check that its own input was there, that its own output existed +/// for the right amount, or that the anonymity set was worth anything. This +/// pins the check that closed that. +#[test] +fn a_prepared_round_below_the_floor_is_refused_before_signing() { + use bitcoin::{ + absolute::LockTime, transaction::Version, Amount, OutPoint, ScriptBuf, Transaction, TxIn, + TxOut, + }; + use wraith_wallet_core::wraith::{PreparedMix, PreparedPrevOut, WraithClientError}; + + // Three inputs, all siblings of one funding transaction — one entity. + let shared = bitcoin::Txid::from_raw_hash(bitcoin::hashes::Hash::all_zeros()); + let spk = ScriptBuf::from_bytes( + hex::decode("0014000102030405060708090a0b0c0d0e0f1011121314").unwrap(), + ); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: (0..3u32) + .map(|vout| TxIn { + previous_output: OutPoint { txid: shared, vout }, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: Amount::from_sat(100_000), + script_pubkey: spk.clone(), + }], + }; + + let prepared = PreparedMix { + session_id: "s".into(), + unsigned_tx: tx, + input_index: 0, + prev_amount_sats: 150_000, + prevouts: (0..3) + .map(|_| PreparedPrevOut { + scriptpubkey_hex: hex::encode(spk.as_bytes()), + value_sats: 150_000, + }) + .collect(), + mixed_output_tx_index: 0, + ghost_id: "g".into(), + min_entities: 5, + expected_output_sats: 100_000, + expected_output_script: spk, + }; + + let mut ledger = wraith_protocol::signing_ledger::SigningLedger::new( + wraith_protocol::signing_ledger::VolatileStore::default(), + ); + match prepared.inspect(&mut ledger) { + Err(WraithClientError::RefusedRound { reasons, report }) => { + assert_eq!( + report.entities, 1, + "three siblings of one funding tx are one entity, not three" + ); + assert_eq!(report.seats, 3, "seats and entities must both be reported"); + assert!( + reasons.iter().any(|r| matches!( + r, + wraith_protocol::pre_sign::RefuseToSign::SetTooSmall { .. } + )), + "{reasons:?}" + ); + } + other => panic!("expected a refusal, got {other:?}"), + } +} + +/// A signature made under the wrong sighash voids the inspection, and is caught +/// by its length rather than by trusting the signer. +/// +/// `client_session::Verified::authorise` performs this check and was never +/// called from anywhere — `inspect` returns the report and drops the `Verified`. +/// The wallet's own signer happens to use SIGHASH_DEFAULT, so it would have +/// passed; that is luck, not a check, and it would not hold for a hardware +/// wallet or a remote signer that chose differently. +#[test] +fn a_signature_under_the_wrong_sighash_is_refused_by_its_length() { + use bitcoin::Witness; + use wraith_wallet_core::wraith::{check_witness_sighash, WraithClientError}; + + // 64 bytes: SIGHASH_DEFAULT, commits to every input and output. + let good = Witness::from_slice(&[vec![0u8; 64]]); + assert!(check_witness_sighash(&good, 0).is_ok()); + + // 65 bytes: some other type, with its flag appended. The extra byte is the + // whole tell — it means the round can be edited after inspection. + let flagged = Witness::from_slice(&[vec![0u8; 65]]); + assert!(matches!( + check_witness_sighash(&flagged, 3), + Err(WraithClientError::UnsafeSighash { + input_index: 3, + len: 65 + }) + )); + + // Nothing at all cannot have committed to anything. + let empty = Witness::new(); + assert!(matches!( + check_witness_sighash(&empty, 0), + Err(WraithClientError::UnsafeSighash { len: 0, .. }) + )); +} + +/// The same coin cannot be signed into two different rounds. +/// +/// `SigningLedger` existed with no caller anywhere, so the rule was written down +/// and not enforced. That matters more than it sounds: a coin signed into two +/// rounds double-spends itself, one round dies at broadcast, and every other +/// participant in it loses their round — and their coin's freedom, once the +/// no-sign sweep puts it in cooldown — through no fault of their own. +#[test] +fn a_coin_committed_to_one_round_is_refused_for_another() { + use wraith_protocol::signing_ledger::{Decision, LedgerError, OutPointKey, SigningLedger}; + use wraith_wallet_core::signing_ledger_file::FileSignatureStore; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("signed.json"); + let coin = OutPointKey::new([0xAB; 32], 1); + + let mut ledger = SigningLedger::new(FileSignatureStore::open(&path).unwrap()); + assert_eq!(ledger.authorise(coin, [0x11; 32]), Ok(Decision::Sign)); + + // A second, different round wants the same coin. + assert_eq!( + ledger.authorise(coin, [0x22; 32]), + Err(LedgerError::Conflict { + existing_txid: [0x11; 32] + }) + ); + assert_eq!(ledger.refusals(), 1, "the refusal must be countable"); + + // And it still refuses after a restart, which is the case a volatile store + // would silently get wrong. + drop(ledger); + let mut reopened = SigningLedger::new(FileSignatureStore::open(&path).unwrap()); + assert!(reopened.authorise(coin, [0x22; 32]).is_err()); +} diff --git a/apps/wraith-wallet/daemon/Cargo.toml b/apps/wraith-wallet/daemon/Cargo.toml index c0fb78545..b6e2c5139 100644 --- a/apps/wraith-wallet/daemon/Cargo.toml +++ b/apps/wraith-wallet/daemon/Cargo.toml @@ -11,13 +11,12 @@ name = "wraithd" path = "src/main.rs" [dependencies] +ghost-lock = { workspace = true } wraith-wallet-core = { workspace = true } wraith-wallet-ipc = { workspace = true } # Coordinator-seat sharding (shard_for) for resolving an elected coordinator. wraith-protocol = { workspace = true } # Shard-key derivation (SHA256) for coordinator resolution. -sha2 = { workspace = true } -ghost-gsp-proto = { workspace = true } ghost-keys = { workspace = true } bitcoin = { workspace = true } tokio = { workspace = true } @@ -29,14 +28,15 @@ serde = { workspace = true } serde_json = { workspace = true } secrecy = { workspace = true } hex = { workspace = true } +base64 = { workspace = true } rand = { workspace = true } reqwest = { workspace = true } [dev-dependencies] wraith-wallet-ipc = { workspace = true } +ghost-lock = { workspace = true } wraith-wallet-core = { workspace = true } ghost-keys = { workspace = true } -ghost-gsp-proto = { workspace = true } tokio = { workspace = true } serde_json = { workspace = true } tempfile = "3" diff --git a/apps/wraith-wallet/daemon/src/coordinator_resolve.rs b/apps/wraith-wallet/daemon/src/coordinator_resolve.rs index 61e4928eb..29e3d19fb 100644 --- a/apps/wraith-wallet/daemon/src/coordinator_resolve.rs +++ b/apps/wraith-wallet/daemon/src/coordinator_resolve.rs @@ -1,13 +1,23 @@ -//! Resolve which seated Wraith coordinator owns a wallet's mix, from the node's +//! Resolve which seated Wraith coordinator owns a wallet's mix, from a //! published election view — so a wallet can mix without being handed a //! coordinator URL. //! -//! Inc 5 of `tasks/plan_coordinator_activation.md` — the daemon-side plumbing. -//! The wallet obtains the election JSON **through ghost-pay** (never the node's -//! pool API directly; wallet hard rule, `apps/wraith-wallet/CLAUDE.md`) via -//! `GhostPayClient::coordinator_election`, then resolves the owning seat here. -//! The `WraithResolveCoordinator` IPC request exposes it; the GUI "use the -//! network-elected coordinator" toggle that calls it is the deferred next task. +//! # Where the election comes from now +//! +//! The wallet used to obtain it *through ghost-pay*, so it never spoke to the +//! pool itself. With the operator gone it asks a pool node directly, over Tor +//! when one is configured, and caches the answer for the whole epoch so the +//! number of asks stops tracking the number of mixes. See +//! `verified_election` in the daemon for that side. +//! +//! What lives here is the half that makes asking safe enough to do: an +//! election is *recomputed* before it is used, so a relayed view cannot lie +//! about who was seated, and the beacon is pinned to a real block hash rather +//! than taken on the publisher's word (#697). +//! +//! ⚠ The roster remains a trusted input, and no amount of care here changes +//! that — see `verified_election` for why the mesh node-list checkpoint does +//! not close it and what would. use wraith_protocol::sortition::{ shard_for, verify_election, CoordinatorNodeId, ElectedCoordinator, diff --git a/apps/wraith-wallet/daemon/src/main.rs b/apps/wraith-wallet/daemon/src/main.rs index 0b65ae5e1..0d5486e69 100644 --- a/apps/wraith-wallet/daemon/src/main.rs +++ b/apps/wraith-wallet/daemon/src/main.rs @@ -33,69 +33,48 @@ mod server { #[cfg_attr(not(unix), allow(unused_imports))] use std::fs; use std::path::{Path, PathBuf}; - use std::sync::atomic::AtomicU32; use std::sync::Arc; use std::time::Instant; - use ghost_gsp_proto::{PaymentMode, SessionToken}; use interprocess::local_socket::traits::tokio::{Listener as _, Stream as _}; use interprocess::local_socket::ListenerOptions; use secrecy::SecretString; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::RwLock; - /// Full-duplex IPC stream (splits into [`IpcRecvHalf`] + [`IpcSendHalf`]). + /// Full-duplex IPC stream (splits into a read half and [`IpcSendHalf`]). type IpcStream = interprocess::local_socket::tokio::Stream; - /// Read half of a connection — feeds the newline-delimited request reader. - type IpcRecvHalf = interprocess::local_socket::tokio::RecvHalf; /// Write half of a connection — carries JSON responses / pushes. type IpcSendHalf = interprocess::local_socket::tokio::SendHalf; use wraith_wallet_core::auth; use wraith_wallet_core::chain::ChainClient; - use wraith_wallet_core::gsp::GspClient; - use wraith_wallet_core::gsp::{ - spawn_session_with_bech32, GspError, SessionHandle, SessionPhase, SessionStatus, - }; use wraith_wallet_core::keystore::{Keystore, KeystoreError}; use wraith_wallet_core::light; use wraith_wallet_core::signer::{Signer, SoftwareSigner}; use wraith_wallet_ipc::{ - ChainStatusResponse, CheckForUpdateResponse, ConnectionStatusResponse, DaemonEnvResponse, - DetectedPaymentEntry, DoctorCheck, DoctorResponse, Envelope, ErrorResponse, - GlyphClaimResult, GlyphInfo, GspAuthResponse, GspPingResponse, GspSessionStatusResponse, - HealthResponse, LightBalanceResponse, LightDetectedResponse, LightHistoryEntry, - LightHistoryResponse, LightL1UtxoEntry, LightL1UtxosResponse, LightReceiveResponse, - LightSentResponse, LightUtxoEntry, LightUtxosResponse, LockEntry, LocksConfirmedResponse, - LocksJumpedResponse, LocksListResponse, LocksPreparedResponse, LocksRecoveredResponse, - NodeEndpointsResponse, PsbtBroadcastResponse, PsbtBumpFeeResponse, PsbtInputSummary, - PsbtInspectResponse, PsbtOutputSummary, PsbtSignResponse, ReleaseManifest, Request, - Response, SignerInfoIpc, WalletAuthInfoResponse, WalletCreateResponse, - WalletDeriveResponse, WalletGhostIdResponse, WalletListEntry, WalletListResponse, - WalletShowMnemonicResponse, WalletStatusResponse, WalletXpubResponse, - WraithDiscoverResponse, WraithDiscoverTier, WraithMixCompletedResponse, - WraithMixPreparedResponse, + AnonymitySetReport, ChainStatusResponse, CheckForUpdateResponse, ConnectionStatusResponse, + DaemonEnvResponse, DetectedPaymentEntry, DoctorCheck, DoctorResponse, Envelope, + ErrorResponse, EscapeCoin, GhostLockEscapePlanResponse, GhostLockEscapeSignedResponse, + GhostLockForgottenResponse, GhostLockLane, GhostLockLanesResponse, GhostLockListResponse, + GhostLockQuorumBindingIdResponse, GhostLockQuorumSignedResponse, GhostLockRecord, + GhostLockRoundDestinationResponse, GhostLockSavedResponse, GhostLockSignBegunResponse, + GhostLockSignNoncedResponse, GhostLockSignedResponse, HealthResponse, LightBalanceResponse, + LightDetectedResponse, LightHistoryEntry, LightHistoryResponse, LightL1UtxoEntry, + LightL1UtxosResponse, LightReceiveResponse, LightUtxoEntry, LightUtxosResponse, + LockSpendOutput, LockSpendSummary, NodeResponse, PsbtBroadcastResponse, + PsbtBumpFeeResponse, PsbtInputSummary, PsbtInspectResponse, PsbtOutputSummary, + PsbtSignResponse, ReleaseManifest, Request, Response, SignerInfoIpc, + WalletAuthInfoResponse, WalletCreateResponse, WalletDeriveResponse, WalletGhostIdResponse, + WalletListEntry, WalletListResponse, WalletShowMnemonicResponse, WalletStatusResponse, + WalletXpubResponse, WraithDiscoverResponse, WraithDiscoverTier, WraithMixCompletedResponse, + WraithMixPreparedResponse, WraithMixRefusedResponse, }; - /// Bundled public preset — the Bitcoin Ghost fleet, reachable without - /// running your own node. `pool.bitcoinghost.org` round-robins the four - /// fleet IPs; ghost-pay serves TLS on :8800 and GSP on :8900. A brand-new - /// install defaults here so the wallet works out of the box. - const PUBLIC_GHOST_PAY: &str = "https://pool.bitcoinghost.org:8800"; - const PUBLIC_GSP: &str = "wss://pool.bitcoinghost.org:8900/ws/v1"; - /// Node-selection preset labels. Persisted in `node.json` and surfaced via - /// `DaemonEnv.node_preset` so the settings UI knows which radio is active. - const PRESET_PUBLIC: &str = "public"; - const PRESET_CUSTOM: &str = "custom"; - /// Optional override for the on-disk node-selection config path. Defaults - /// to `/../node.json` (i.e. `~/.wraith/node.json`). + /// Optional override for the on-disk node config path. Defaults to + /// `/../node.json` (i.e. `~/.wraith/node.json`). const NODE_CONFIG_ENV: &str = "WRAITHD_NODE_CONFIG"; - const GHOST_PAY_ENV: &str = "WRAITHD_GHOST_PAY"; - /// Optional shared secret for ghost-pay's `X-Internal-Auth` - /// bypass. When set, the wallet can call ghost-pay's - /// authenticated routes (e.g. `/api/v1/utxos/scan`) without - /// HMAC. Required for the L1 UTXO scanner; other routes work - /// without it. - const GHOST_PAY_INTERNAL_AUTH_ENV: &str = "WRAITHD_GHOST_PAY_INTERNAL_AUTH"; + /// Optional pool node consulted for the coordinator election. + const POOL_URL_ENV: &str = "WRAITHD_POOL_URL"; /// Optional default wraith-coordinator URL. When set, the /// `Doctor` check probes its `/api/v1/pool/discover` endpoint /// for liveness. Mixes still use the per-call URL the wallet @@ -109,7 +88,6 @@ mod server { /// restarts. Used for retail/POS deployments where untrusted /// staff at the till should only be able to take payments. const KIOSK_MODE_ENV: &str = "WRAITHD_KIOSK_MODE"; - const GSP_ENV: &str = "WRAITHD_GSP"; const WALLETS_DIR_ENV: &str = "WRAITHD_WALLETS_DIR"; const NETWORK_ENV: &str = "WRAITHD_NETWORK"; /// Optional SOCKS5 proxy (e.g. `socks5h://127.0.0.1:9050` for Tor). @@ -140,66 +118,366 @@ mod server { /// Unset → no auto-update channel is configured; per-call URLs still work. const UPDATE_MANIFEST_ENV: &str = "WRAITHD_UPDATE_MANIFEST_URL"; - /// A `SessionToken` paired with the wallet name that produced it AND a live - /// `SessionHandle` running the persistent authenticated WebSocket. Dropping - /// the `StoredSession` aborts the session task (via `SessionHandle::Drop`). - struct StoredSession { - wallet_name: String, - token: SessionToken, - handle: SessionHandle, + /// Turn a refusal into something the wallet can render. + /// + /// A refusal shown as a sentence gives the user nothing to decide with. The + /// figures are what they need: how many entities were actually there, what + /// was discounted, and whether the coordinator's claim was the problem. + fn refusal_response( + session_id: String, + min_entities: usize, + e: &wraith_wallet_core::wraith::WraithClientError, + ) -> Option { + use wraith_protocol::pre_sign::RefuseToSign; + use wraith_wallet_core::wraith::WraithClientError; + + let WraithClientError::RefusedRound { reasons, report } = e else { + return None; + }; + + // An over-claim is not a size problem. The coordinator stated a figure + // the chain does not support, and no floor makes that acceptable — so + // the wallet must not offer to lower one. + let over_claimed = reasons + .iter() + .any(|r| matches!(r, RefuseToSign::SetOverClaimed { .. })); + + Some(WraithMixRefusedResponse { + session_id, + report: AnonymitySetReport { + seats: report.seats, + entities: report.entities, + discounted: report.discounted(), + unverified: report.unverified, + payers: report.payers, + }, + reasons: reasons.iter().map(ToString::to_string).collect(), + min_entities, + lowering_the_floor_would_help: !over_claimed, + }) + } + + /// Open the store of Ghost Lock definitions. + /// + /// Beside `node.json`, and beside the signing ledger — which is a different + /// kind of file despite the neighbourhood. Losing *this* one costs + /// convenience; the lanes rebuild from the same three keys and the keystore. + /// Losing the ledger re-permits a double-sign. + fn ghost_lock_store_for( + state: &Arc, + ) -> std::io::Result { + wraith_wallet_core::ghost_lock_store::GhostLockStore::open(ghost_lock_store_path(state)) } - /// In-flight Wraith Lite mix between `WraithMixPrepare` and - /// `WraithMixSubmit`. Holds the prepared round + the client that - /// produced it (so /witness submission re-uses the same HTTP - /// client / proxy config without rebuilding it). Caller is - /// expected to submit promptly — the coordinator's no-sign - /// deadline is ticking. - struct StoredWraithMix { - prepared: wraith_wallet_core::wraith::PreparedMix, - client: Arc, + /// Where remembered Ghost Locks live. + fn ghost_lock_store_path(state: &Arc) -> PathBuf { + state + .node_config_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("ghost-locks.json") + } + + /// Where one wallet's own records live: `//`. + /// + /// ⚠ Per wallet, not per daemon, and the distinction is load-bearing. + /// These files answer "what happened to *this* wallet" — a history, a set + /// of detected coins, a scan bookmark. Shared across wallets they are + /// wrong in both directions at once: one wallet's payments appear in + /// another's history, and the shared bookmark tells the scanner those + /// blocks are already read, so a wallet switched to never gets a history + /// at all. The keystore and its descriptors already live here, and + /// `WalletDelete` removes the directory, so a deleted wallet takes its + /// records with it. + fn wallet_data_dir(state: &DaemonState, wallet: &str) -> PathBuf { + state.wallets_dir.join(wallet) + } + + /// The active wallet's name, or a message saying there isn't one. + async fn active_wallet_name(state: &Arc) -> Result { + state.active.read().await.clone().ok_or_else(|| { + "no active wallet; run `wraith wallet unlock ` or \ + `wraith wallet select ` first" + .to_string() + }) + } + + /// Open the active wallet's record of what it has sent and received. + /// + /// This exists because transaction history used to come from the + /// operator's GSP session: the wallet asked somebody else what it had + /// done. With that gone, nothing remembers unless this does. + async fn history_store_for( + state: &Arc, + ) -> Result { + wraith_wallet_core::history_store::HistoryStore::open(wallet_history_path(state).await?) + .map_err(|e| format!("history store: {e}")) + } + + /// Where the active wallet's history lives. One definition, so the lock + /// keys on exactly the path the store opens. + async fn wallet_history_path(state: &Arc) -> Result { + let name = active_wallet_name(state).await?; + Ok(wallet_data_dir(state, &name).join("history.json")) + } + + /// Where the active wallet's silent-payment detections live. + async fn wallet_detections_path(state: &Arc) -> Result { + let name = active_wallet_name(state).await?; + Ok(wallet_data_dir(state, &name).join("detections.json")) + } + + /// Where the active wallet's scan bookmark lives. + async fn wallet_scan_state_path(state: &Arc) -> Result { + let name = active_wallet_name(state).await?; + Ok(wallet_data_dir(state, &name).join("scan-state.json")) + } + + /// Open the active wallet's store of silent payments the scanner found. + async fn detection_store_for( + state: &Arc, + ) -> Result { + wraith_wallet_core::detection_store::DetectionStore::open( + wallet_detections_path(state).await?, + ) + .map_err(|e| format!("detections: {e}")) + } + + /// Record the height a wallet came into being. + /// + /// Best-effort by design: a node that is unreachable at creation time must + /// not stop a wallet being made. A missing birth height costs history + /// depth on a later rescan, which is recoverable by setting one; refusing + /// to create the wallet is not. + async fn record_birth_height(state: &Arc, wallet: &str, height: Option) { + let path = wallet_data_dir(state, wallet).join("wallet-meta.json"); + let meta = wraith_wallet_core::wallet_meta::WalletMeta { + birth_height: height, + }; + if let Err(e) = wraith_wallet_core::wallet_meta::save(&path, &meta) { + tracing::warn!(wallet, error = %e, "could not record the wallet's birth height"); + } + } + + /// The current chain tip, if a node is reachable. + async fn current_tip(state: &Arc) -> Option { + state + .chain() + .await + .status() + .await + .ok() + .and_then(|s| s.chain_height) + .map(|h| h as u32) + } + + /// Read the active wallet's metadata. + async fn wallet_meta_for( + state: &Arc, + ) -> Result { + let name = active_wallet_name(state).await?; + Ok(wraith_wallet_core::wallet_meta::load( + wallet_data_dir(state, &name).join("wallet-meta.json"), + )) + } + + /// Open the active wallet's block-scanner bookmark. + async fn scan_state_for( + state: &Arc, + ) -> Result { + wraith_wallet_core::scan_state::ScanState::open(wallet_scan_state_path(state).await?) + .map_err(|e| format!("scan state: {e}")) + } + + fn lock_record(l: &wraith_wallet_core::ghost_lock_store::StoredLock) -> GhostLockRecord { + GhostLockRecord { + lock_id: l.lock_id.clone(), + label: l.label.clone(), + backup_pubkey: l.backup_pubkey.clone(), + heir_pubkey: l.heir_pubkey.clone(), + quorum_pubkey: l.quorum_pubkey.clone(), + anchor_height: l.anchor_height, + inherit_height: l.inherit_height, + bip86_index: l.bip86_index, + } + } + + /// The wallet's own MuSig2 nonce ledger. + /// + /// Lives beside `node.json` in the wallet's data directory. Opened per + /// operation rather than held: the file is small, the write is the + /// expensive part either way, and a fresh read means a second process + /// touching the same wallet cannot be missed. + /// + /// Separate file from the round signing ledger: they answer different + /// questions (has this coin been signed for / has this nonce been used) + /// and sharing a file would make one's corruption the other's outage. + fn ghost_lock_nonce_ledger_for( + state: &Arc, + ) -> std::io::Result { + ghost_lock::nonce_ledger_file::FileNonceLedger::open(nonce_ledger_path(state)) } - /// Local metadata for a Ghost Lock the wallet has prepared. - /// Keyed by lock_id in `DaemonState::prepared_locks`. Required for - /// the `LocksRecover` (unilateral exit) path — the wallet must - /// know its recovery_index (to derive the secret), the full lock - /// script details (to reconstruct the witness program), and the - /// funding outpoint (to spend the right UTXO). + /// The write lock for one store file. /// - /// Persisted to `//locks.json` so a daemon - /// restart between LocksPrepare and LocksRecover doesn't lose - /// the recovery_index. Loaded on wallet unlock; written on - /// every prepare / confirm / recover. - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] - struct PreparedLockMeta { - wallet_name: String, - recovery_index: u32, - lock_pubkey_hex: String, - recovery_pubkey_hex: String, - recovery_blocks: u32, - creation_height: u32, - funding_address: String, - capacity_sats: u64, - /// Set once `LocksConfirm` lands. - funding_txid: Option, + /// Hold it across open-modify-write on that path, and release it before + /// any unrelated await. See `DaemonState::store_locks`. + fn store_lock(state: &Arc, path: &std::path::Path) -> Arc> { + let mut map = state + .store_locks + .lock() + // A poisoned registry is still a usable map of `Arc`s, and + // refusing to hand out locks because an unrelated request panicked + // would take the whole daemon down with it. + .unwrap_or_else(|e| e.into_inner()); + Arc::clone(map.entry(path.to_path_buf()).or_default()) } - /// The live node clients + their configured URLs, held together so a - /// runtime endpoint change (`SetNodeEndpoints`) swaps all of them - /// atomically under one write lock. Read paths clone the `Arc`s out and - /// release the lock immediately, so a slow ghost-pay/GSP call never blocks - /// a config change and vice-versa. + /// Write one entry into the active wallet's history, under its lock. + /// + /// Opens, records and flushes inside the critical section. The scanner and + /// a concurrent payment both write this file; `record` merges on txid, but + /// only against what was on disk when the store was opened, so the open has + /// to be inside the lock too. + async fn record_history( + state: &Arc, + entry: wraith_wallet_core::history_store::HistoryEntry, + ) -> Result<(), String> { + let path = wallet_history_path(state).await?; + let lock = store_lock(state, &path); + let _guard = lock.lock().await; + let mut store = history_store_for(state).await?; + store.record(entry).map_err(|e| format!("history: {e}")) + } + + /// Record silent-payment detections under the detections lock, returning + /// how many were new. + async fn record_detections( + state: &Arc, + found: Vec, + ) -> Result { + let path = wallet_detections_path(state).await?; + let lock = store_lock(state, &path); + let _guard = lock.lock().await; + let mut store = detection_store_for(state).await?; + store + .record_all(found) + .map_err(|e| format!("detections write: {e}")) + } + + /// Outcome of the pre-sign check against the once-per-coin ledger. + enum LedgerCheck { + Passed(Box), + /// The ledger file itself could not be opened. Distinct from a + /// refusal: nothing was judged, so nothing can be concluded. + Unavailable(std::io::Error), + /// The round was inspected and refused. + Refused(Box), + } + + /// Inspect a prepared round with the signing ledger held exclusively. + /// + /// The lock is what makes check-then-record atomic. `signing_ledger_for` + /// opens a fresh store that snapshots the file, and `record` rewrites the + /// whole table from that snapshot, so two mixes that both opened before + /// either wrote would each persist a table missing the other's coin. The + /// wallet would then permit a coin into a second round — the one thing the + /// ledger exists to refuse. A round's participants routinely share one + /// daemon, so this is the ordinary path, not a corner. + /// + /// The guard dies with this function, before any signing or network work. + /// Holding it across a round's round-trips would deadlock a round whose + /// participants all share this daemon. + async fn check_against_ledger( + state: &Arc, + prepared: &wraith_wallet_core::wraith::PreparedMix, + ) -> LedgerCheck { + let path = signing_ledger_path(state); + let lock = store_lock(state, &path); + let _guard = lock.lock().await; + let mut ledger = match signing_ledger_for(state) { + Ok(l) => l, + Err(e) => return LedgerCheck::Unavailable(e), + }; + match prepared.inspect(&mut ledger) { + Ok(i) => LedgerCheck::Passed(Box::new(i)), + Err(e) => LedgerCheck::Refused(Box::new(e)), + } + } + + fn signing_ledger_for( + state: &Arc, + ) -> std::io::Result< + wraith_protocol::signing_ledger::SigningLedger< + wraith_wallet_core::signing_ledger_file::FileSignatureStore, + >, + > { + Ok(wraith_protocol::signing_ledger::SigningLedger::new( + wraith_wallet_core::signing_ledger_file::FileSignatureStore::open( + signing_ledger_path(state), + )?, + )) + } + + /// Where the once-per-coin ledger lives. Daemon-wide rather than + /// per-wallet: the keys are outpoints, which no two wallets share. + fn signing_ledger_path(state: &Arc) -> PathBuf { + state + .node_config_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("wraith-signed-coins.json") + } + + /// Where the MuSig2 nonce-burn ledger lives. + fn nonce_ledger_path(state: &Arc) -> PathBuf { + state + .node_config_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("ghost-lock-nonces.json") + } + + /// One air-gapped Lock signing, between rounds. + /// + /// The `session` is `Some` only between round 1 and round 2. The owner's + /// partial signature is produced as soon as both nonces are known, so no + /// secret nonce is held while somebody carries the second payload to the + /// device. + struct PendingLockSign { + keys: Vec, + merkle_root: Option, + message: [u8; 32], + psbt: String, + input_index: u32, + our_nonce: [u8; 66], + /// Consumed at round 2. + session: Option, + /// Set at round 2, with every party's nonce in the order they were + /// aggregated. + nonces: Vec<[u8; 66]>, + our_partial: Option<[u8; 32]>, + } + + /// In-flight Wraith Lite mix between `WraithMixPrepare` and + /// `WraithMixSubmit`. Holds the prepared round + the client that produced + /// it, so witness submission re-uses the same HTTP client and proxy config + /// without rebuilding it. The caller is expected to submit promptly — the + /// coordinator's no-sign deadline is ticking. + struct StoredWraithMix { + /// The **inspected** round. Not a `PreparedMix`: `submit_witness` will + /// not accept anything else, so a round cannot reach the wire without + /// having been checked and its coin committed. + inspected: wraith_wallet_core::wraith::InspectedMix, + client: Arc, + } + + /// The live chain client, held behind a lock so a runtime endpoint change + /// swaps it without a restart. Read paths clone the `Arc` out and release + /// the lock immediately, so a slow node call never blocks a config change + /// and vice-versa. struct NodeClients { chain: Arc, - gsp: Arc, - /// Ghost-pay base URLs in failover order — surfaced via DaemonEnv. - ghost_pay_urls: Vec, - /// GSP WS URLs in failover order — passed to spawn_session at gsp_auth time. - gsp_urls: Vec, - /// Which node preset is active: `public` or `custom`. Drives the - /// settings UI's radio selection. - preset: String, } struct DaemonState { @@ -207,18 +485,9 @@ mod server { /// The active node clients + endpoint config. Swapped wholesale by /// `SetNodeEndpoints` without a daemon restart. clients: RwLock, - /// True when `WRAITHD_GHOST_PAY` / `WRAITHD_GSP` pinned the endpoints at - /// boot. While either is set the URLs are power-user-owned: the UI shows - /// them read-only and `SetNodeEndpoints` refuses to change them. - ghost_pay_env_override: bool, - gsp_env_override: bool, /// Absolute path to the persisted node-selection config (`node.json`). node_config_path: PathBuf, - /// Optional ghost-pay `X-Internal-Auth` secret, kept so a runtime - /// endpoint swap can rebuild the chain client with the same auth. - ghost_pay_internal_auth: Option, - /// Optional SOCKS5 proxy for both REST and WS (e.g. socks5h://127.0.0.1:9050). - /// Threaded into spawn_session so the persistent WS routes through Tor too. + /// Optional SOCKS5 proxy (e.g. socks5h://127.0.0.1:9050). tor_proxy: Option, /// Optional default wraith-coordinator URL — used by Doctor /// to probe coordinator liveness in the dev stack. None @@ -233,7 +502,6 @@ mod server { wallets_dir: PathBuf, wallets: RwLock>, active: RwLock>, - session: RwLock>, network: bitcoin::Network, /// Human-readable IPC endpoint (Unix socket path, or Windows /// `\\.\pipe\...` name). Surfaced via DaemonEnv for diagnostics. @@ -258,30 +526,53 @@ mod server { /// `WraithSessionClient` that produced it (so submit reuses /// the same HTTP client / proxy config). wraith_mixes: RwLock>, - /// Locks the wallet has prepared, keyed by lock_id. Populated - /// by `LocksPrepare`, consumed by `LocksRecover` (and consulted - /// by `LocksConfirm` to attach the funding txid). - prepared_locks: RwLock>, - /// Monotonic counter for the wallet's own recovery-key derivation - /// indices. Independent of any operator-side index. On wallet unlock it - /// is advanced past the highest `recovery_index` persisted in - /// `locks.json` (via `fetch_max`), so it never re-issues an index an - /// existing lock already uses across a daemon restart. - next_recovery_index: AtomicU32, - /// Optional bitcoind RPC URL. Required for the LocksRecover - /// (unilateral exit) path — wallet talks directly to bitcoind, - /// not through ghost-pay. None disables the path; the IPC - /// returns a clear "no bitcoind configured" error. - ghostd_url: Option, - /// Cookie file path (preferred) OR explicit user/pass for - /// bitcoind RPC auth. At most one of these branches is set. - ghostd_cookie_path: Option, - ghostd_user: Option, - ghostd_pass: Option, - /// HTTP client used for daemon-side fetches outside the GSP/ghost-pay - /// stack (currently just the manifest fetch). Reuses rustls so we - /// don't pull in a second TLS implementation. + /// Air-gapped Lock signings waiting on the backup device. + /// + /// In memory by design. A daemon restart loses the secret nonce, which + /// is the safe direction: nothing can be reused, and the spend is + /// retryable because the nonce ledger keys on the nonce rather than the + /// message. + lock_signings: RwLock>, + /// Where the node is and how to reach it. Behind a lock so the + /// settings screen can change it without a restart. Also pins the + /// election beacon to the chain; with no node that check is skipped. + ghostd: RwLock, + /// A Ghost pool node, consulted only for the coordinator election. + pool_url: RwLock>, + /// The last verified election, with the epoch it was drawn for. + /// + /// Cached for the whole epoch — 144 blocks, about a day — so the + /// number of times the wallet asks a pool anything stops tracking the + /// number of times it mixes. Without that, a pool watching request + /// timing learns when its askers are about to mix even though it + /// learns nothing from the request itself. + election_cache: RwLock>, + /// True when the environment pinned the node at boot. While it is set + /// the settings are power-user-owned and `SetNode` refuses. + ghostd_env_override: bool, + /// HTTP client used for daemon-side fetches (currently just the + /// manifest fetch). Reuses rustls so we don't pull in a second TLS + /// implementation. http: reqwest::Client, + /// Serialises read-modify-write on each store file, keyed by path. + /// + /// Every store in this daemon is opened per request, read wholly into + /// memory, and persisted by rewriting the whole file. Two requests that + /// open the same file before either writes each persist a copy missing + /// the other's change — a lost update, and for the signing and nonce + /// ledgers a lost safety record. Keyed by path so two wallets, or two + /// different stores, never wait on each other. + /// + /// Callers take the lock around open-modify-write and nothing more. A + /// store must not be held across unrelated `await`s: the block scanner + /// does RPC round-trips between writes, and holding history open across + /// a whole batch is what let a concurrent payment be erased by the + /// scanner's stale snapshot. + /// + /// The registry mutex is only ever held long enough to clone an `Arc`, + /// never across an await, and a poisoned registry is still a usable + /// map — so it recovers rather than cascading. + store_locks: std::sync::Mutex>>>, } fn default_wallets_dir() -> PathBuf { @@ -307,34 +598,51 @@ mod server { /// Construct a fresh concrete `GhostPayClient` for the glyph /// routes. `state.chain` is a `dyn ChainClient` trait object, so - /// it can't expose the inherent glyph methods — rebuild from the - /// daemon's configured ghost-pay URLs + proxy, attaching the - /// internal-auth secret (claim is an authenticated route). - async fn build_ghost_pay_client( - state: &DaemonState, - ) -> Result { - let mut c = wraith_wallet_core::chain::GhostPayClient::with_urls_and_proxy( - state.ghost_pay_urls().await, - state.tor_proxy.as_deref(), - ) - .map_err(|e| format!("ghost-pay client: {e}"))?; - if let Some(secret) = state.ghost_pay_internal_auth.as_ref() { - if !secret.is_empty() { - c = c.with_internal_secret(secret.clone()); + /// Where the wallet's node is, and how to authenticate to it. + /// + /// All four may be absent: a fresh install has no node, and the wallet + /// says so rather than borrowing somebody else's. + #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] + struct GhostdSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + url: Option, + /// Path to the node's `.cookie`. Preferred over user/pass: it rotates + /// with the node and is never typed anywhere. + #[serde(default, skip_serializing_if = "Option::is_none")] + cookie_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + user: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pass: Option, + } + + impl GhostdSettings { + /// How the wallet authenticates, as one word, for display. + /// + /// Never the credential itself — this is what goes back over the IPC + /// and into the settings screen. + fn auth_kind(&self) -> &'static str { + if self.cookie_path.is_some() { + "cookie" + } else if self.user.is_some() || self.pass.is_some() { + "userpass" + } else { + "none" } } - Ok(c) } - /// Node-selection config persisted to `node.json`. Loaded at boot and - /// rewritten whenever the user picks a node via `SetNodeEndpoints`. Absent - /// on a fresh install — the daemon then falls back to the public preset. - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] + /// Node config persisted to `node.json`. Loaded at boot and rewritten + /// whenever the user points the wallet at a node. Absent on a fresh + /// install, in which case the wallet has no chain backend until one is + /// configured. + #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] struct NodeConfig { - /// `public` or `custom`. - preset: String, - ghost_pay_urls: Vec, - gsp_urls: Vec, + #[serde(default)] + ghostd: GhostdSettings, + /// A Ghost pool node, consulted only for the coordinator election. + #[serde(default, skip_serializing_if = "Option::is_none")] + pool_url: Option, } /// Resolve where the node-selection config lives. `WRAITHD_NODE_CONFIG` @@ -351,8 +659,8 @@ mod server { } /// Read `node.json`. Absent or malformed → `None` (a corrupt file must not - /// wedge the daemon; it falls back to the public preset and the next save - /// overwrites it). + /// wedge the daemon; it starts with no node and the next save overwrites + /// it). fn load_node_config(path: &std::path::Path) -> Option { let raw = fs::read_to_string(path).ok()?; match serde_json::from_str::(&raw) { @@ -364,161 +672,128 @@ mod server { } } - /// Persist `node.json` atomically (temp-file + rename) with 0600 perms on - /// unix — the file only lists endpoint URLs, but it lives in the wallet - /// data dir so we keep it user-private like the keystores. + /// Persist `node.json` atomically, 0600 on unix. It can hold an RPC + /// password, so owner-only is not optional. + /// + /// This previously staged with a plain `fs::write` and no fsync of either + /// the file or its directory, so a power loss could lose a saved node + /// endpoint that `SetNode` had already reported as stored. fn save_node_config(path: &std::path::Path, cfg: &NodeConfig) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } let json = serde_json::to_string_pretty(cfg).map_err(std::io::Error::other)?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, json.as_bytes())?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?; - } - fs::rename(&tmp, path)?; - Ok(()) - } - - /// Validate + parse a custom node's ghost-pay and GSP URL strings (each may - /// be a comma-separated failover list). Rejects empty input and the wrong - /// scheme so a typo can't silently leave the wallet pointed at nothing. - fn validate_custom_endpoints( - pay_raw: &str, - gsp_raw: &str, - ) -> Result<(Vec, Vec), String> { - let pay = wraith_wallet_core::chain::GhostPayClient::parse_urls(pay_raw); - let gsp = wraith_wallet_core::gsp::GspClient::parse_urls(gsp_raw); - if pay.is_empty() { - return Err("a ghost-pay URL is required for a custom node".to_string()); - } - if gsp.is_empty() { - return Err("a GSP URL is required for a custom node".to_string()); - } - for u in &pay { - if !(u.starts_with("http://") || u.starts_with("https://")) { - return Err(format!( - "ghost-pay URL must start with http:// or https:// — got '{u}'" - )); - } - } - for u in &gsp { - if !(u.starts_with("ws://") || u.starts_with("wss://")) { - return Err(format!( - "GSP URL must start with ws:// or wss:// — got '{u}'" - )); - } - } - Ok((pay, gsp)) + ghost_lock::atomic_file::write_atomic(path, json.as_bytes(), Some(0o600)) } impl DaemonState { async fn chain(&self) -> Arc { self.clients.read().await.chain.clone() } - async fn gsp(&self) -> Arc { - self.clients.read().await.gsp.clone() - } - async fn ghost_pay_urls(&self) -> Vec { - self.clients.read().await.ghost_pay_urls.clone() - } - async fn gsp_urls(&self) -> Vec { - self.clients.read().await.gsp_urls.clone() + + /// The node settings currently in force. + async fn ghostd(&self) -> GhostdSettings { + self.ghostd.read().await.clone() } - /// Build a fresh ghost-pay chain client for `urls`, reusing the daemon's - /// tor proxy + internal-auth secret. - fn build_chain(&self, urls: Vec) -> Result, String> { - let mut c = wraith_wallet_core::chain::GhostPayClient::with_urls_and_proxy( - urls, - self.tor_proxy.as_deref(), - ) - .map_err(|e| format!("ghost-pay client: {e}"))?; - if let Some(secret) = self.ghost_pay_internal_auth.as_ref() { - if !secret.is_empty() { - c = c.with_internal_secret(secret.clone()); + + /// Build the chain backend from the node settings. + /// + /// No node means `NoChain`, whose every call refuses with a sentence + /// saying what to configure. Falling back to somebody else's server + /// would be the alternative, and a self-custody wallet quietly asking + /// a stranger what it owns is exactly what this is for. + async fn build_chain(&self) -> Arc { + match self.build_ghostd_rpc().await { + Some(rpc) => { + tracing::info!("chain backend: the wallet's own node"); + Arc::new(wraith_wallet_core::chain::GhostdChainClient::new( + rpc, + self.network.to_string(), + )) } + None => { + tracing::warn!( + "chain backend: none — no node is configured, so balances, \ + scans and broadcasts will all refuse until one is" + ); + Arc::new(wraith_wallet_core::chain::NoChain) + } + } + } + + /// An RPC connection to the owner's node, if one is configured. + /// + /// Shared with the election-beacon check rather than built twice: two + /// constructions of the same connection drift, and the one that drifts + /// is always the one nobody is looking at. + async fn build_ghostd_rpc(&self) -> Option { + use wraith_wallet_core::ghostd::GhostdRpc; + let cfg = self.ghostd().await; + let url = cfg.url.as_deref()?; + match ( + cfg.cookie_path.as_ref(), + cfg.user.as_deref(), + cfg.pass.as_deref(), + ) { + (Some(cookie), _, _) => match GhostdRpc::from_cookie(url, cookie.as_path()) { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!(error = %e, "ghostd cookie unreadable; falling back"); + None + } + }, + (None, Some(u), Some(p)) => Some(GhostdRpc::new(url, u, p)), + _ => None, } - Ok(Arc::new(c)) } - /// Apply a node selection at runtime: rebuild the ghost-pay + GSP - /// clients, persist the choice to `node.json`, and drop any live GSP - /// session so it re-authenticates against the new endpoint. Refuses - /// while an env-var override pins the endpoints (power-user precedence). - async fn set_node_endpoints( + /// Point the wallet at a node, at runtime. + /// + /// Persists first, then swaps: if the disk write fails the daemon + /// keeps running on the old settings rather than on a config a + /// restart would silently revert. Refuses while the environment pins + /// the node — a power user who set `WRAITHD_GHOSTD_URL` did not mean + /// for a settings screen to overrule it. + async fn set_node( &self, - preset: &str, - ghost_pay_url: Option, - gsp_url: Option, - ) -> Result { - if self.ghost_pay_env_override || self.gsp_env_override { - return Err("node endpoints are pinned by environment variables \ - (WRAITHD_GHOST_PAY / WRAITHD_GSP); unset them to manage the \ + next: GhostdSettings, + pool_url: Option, + ) -> Result { + if self.ghostd_env_override { + return Err("the node is pinned by environment variables \ + (WRAITHD_GHOSTD_URL and friends); unset them to manage the \ node from the wallet" .to_string()); } - let (ghost_pay_urls, gsp_urls, preset_label) = match preset { - PRESET_PUBLIC => ( - vec![PUBLIC_GHOST_PAY.to_string()], - vec![PUBLIC_GSP.to_string()], - PRESET_PUBLIC.to_string(), - ), - PRESET_CUSTOM => { - let (pay, gsp) = validate_custom_endpoints( - ghost_pay_url.as_deref().unwrap_or(""), - gsp_url.as_deref().unwrap_or(""), - )?; - (pay, gsp, PRESET_CUSTOM.to_string()) - } - other => { - return Err(format!( - "unknown node preset '{other}' (expected 'public' or 'custom')" - )) + for (what, url) in [("node", next.url.as_deref()), ("pool", pool_url.as_deref())] { + if let Some(url) = url { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err(format!( + "{what} URL must start with http:// or https:// (got '{url}')" + )); + } } - }; - // Build the replacements before touching anything — if either fails - // we leave the running config untouched. - let chain = self.build_chain(ghost_pay_urls.clone())?; - let gsp = Arc::new( - wraith_wallet_core::gsp::GspClient::with_urls_and_proxy( - gsp_urls.clone(), - self.tor_proxy.as_deref(), - ) - .map_err(|e| format!("gsp client: {e}"))?, - ); - // Persist first: if the disk write fails we refuse rather than run - // on a config a restart would silently revert. - let cfg = NodeConfig { - preset: preset_label.clone(), - ghost_pay_urls: ghost_pay_urls.clone(), - gsp_urls: gsp_urls.clone(), - }; - save_node_config(&self.node_config_path, &cfg) - .map_err(|e| format!("persist node.json: {e}"))?; - { - let mut w = self.clients.write().await; - w.chain = chain; - w.gsp = gsp; - w.ghost_pay_urls = ghost_pay_urls.clone(); - w.gsp_urls = gsp_urls.clone(); - w.preset = preset_label.clone(); - } - // Old session points at the old GSP URL; drop it so the header's - // auto-auth re-establishes one against the new endpoint. - *self.session.write().await = None; - tracing::info!( - preset = %preset_label, - ghost_pay = ?ghost_pay_urls, - gsp = ?gsp_urls, - "node endpoints updated at runtime", - ); - Ok(NodeEndpointsResponse { - preset: preset_label, - ghost_pay_urls, - gsp_urls, + } + save_node_config( + &self.node_config_path, + &NodeConfig { + ghostd: next.clone(), + pool_url: pool_url.clone(), + }, + ) + .map_err(|e| format!("persist node.json: {e}"))?; + *self.ghostd.write().await = next.clone(); + *self.pool_url.write().await = pool_url.clone(); + // A different pool, or none, invalidates what the last one said. + *self.election_cache.write().await = None; + let chain = self.build_chain().await; + self.clients.write().await.chain = chain; + tracing::info!(url = ?next.url, auth = next.auth_kind(), "node updated at runtime"); + let auth = next.auth_kind().to_string(); + Ok(NodeResponse { + ghostd_url: next.url, + pool_url, + // The credential itself never crosses the IPC. Which *kind* + // is in use is what a settings screen needs to show. + auth, + env_pinned: false, }) } } @@ -528,14 +803,6 @@ mod server { /// pixels)). Must stay byte-for-byte identical to /// `GhostGlyph::compute_bitmap_hash` or `check` queries the /// wrong key. - fn glyph_bitmap_hash_hex(pixels: &[u8]) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(b"GhostGlyphBitmap/v1"); - hasher.update(pixels); - hex::encode(hasher.finalize()) - } - fn parse_network(s: &str) -> Option { match s.trim().to_ascii_lowercase().as_str() { "mainnet" | "bitcoin" => Some(bitcoin::Network::Bitcoin), @@ -575,44 +842,6 @@ mod server { /// operator's election is not made dishonest by the wallet's own bitcoind /// being down, and treating it as such would hand anyone who can knock /// out a wallet's node the power to force it onto a manual coordinator. - fn beacon_pinned_to_chain(state: &DaemonState, election: &serde_json::Value) -> bool { - use wraith_wallet_core::ghostd::GhostdRpc; - - let Some((anchor_height, _)) = - crate::coordinator_resolve::beacon_anchor_expectation(election) - else { - // No beacon published at all — `election_is_honest` refuses this - // on its own, so there is nothing to add here. - return true; - }; - let Some(url) = state.ghostd_url.as_deref() else { - tracing::debug!("no bitcoind configured; election beacon not pinned to the chain"); - return true; - }; - let rpc = match ( - state.ghostd_cookie_path.as_ref(), - state.ghostd_user.as_deref(), - state.ghostd_pass.as_deref(), - ) { - (Some(cookie), None, None) => match GhostdRpc::from_cookie(url, cookie.as_path()) { - Ok(r) => r, - Err(e) => { - tracing::debug!(error = %e, "bitcoind auth unusable; beacon not pinned"); - return true; - } - }, - (None, Some(u), Some(p)) => GhostdRpc::new(url, u, p), - _ => return true, - }; - match rpc.get_block_hash(anchor_height) { - Ok(hash) => crate::coordinator_resolve::beacon_matches_chain(election, &hash), - Err(e) => { - tracing::debug!(error = %e, anchor_height, "anchor block unreachable; beacon not pinned"); - true - } - } - } - fn validate_wallet_name(name: &str) -> Result<(), String> { if name.is_empty() { return Err("wallet name must not be empty".into()); @@ -659,114 +888,6 @@ mod server { Ok(()) } - /// Per-wallet on-disk index of prepared Ghost Locks. Each entry - /// carries everything `LocksRecover` needs to spend the recovery - /// branch without operator cooperation: the recovery_index, the - /// full lock script details, and the funding outpoint. - /// - /// Stored as plain JSON at `//locks.json` - /// with file mode 0600. The data isn't a seed — losing the - /// file means the wallet can't recover via this path, but the - /// recovery_secret can still be re-derived from the keystore - /// if the user remembers / can scan back through indices. - /// Treating the file as plain (not encrypted) keeps the - /// recovery flow accessible even if the keystore is locked at - /// scan time. This is a deliberate trade-off; documented. - fn locks_path(wallets_dir: &Path, name: &str) -> PathBuf { - wallets_dir.join(name).join("locks.json") - } - - /// Persist the subset of prepared_locks that belongs to - /// `wallet_name`. Called from every dispatch arm that mutates - /// the in-memory map (LocksPrepare, LocksConfirm, LocksRecover). - /// Filtering by wallet_name keeps each wallet's locks file - /// isolated even when multiple wallets are unlocked at once. - async fn persist_prepared_locks(state: &Arc, wallet_name: &str) { - let snapshot: HashMap = state - .prepared_locks - .read() - .await - .iter() - .filter(|(_, m)| m.wallet_name == wallet_name) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - if let Err(e) = save_locks_for_wallet(&state.wallets_dir, wallet_name, &snapshot) { - tracing::warn!(wallet = %wallet_name, error = %e, "failed to persist locks"); - } - } - - /// Atomic write to `path`: serialise `locks` as pretty JSON, - /// write to a temp file, fsync, rename. Mode 0600. - fn save_locks_for_wallet( - wallets_dir: &Path, - wallet_name: &str, - locks: &HashMap, - ) -> std::io::Result<()> { - let path = locks_path(wallets_dir, wallet_name); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let bytes = serde_json::to_vec_pretty(locks).map_err(std::io::Error::other)?; - let tmp = path.with_extension("json.tmp"); - { - let mut f = std::fs::File::create(&tmp)?; - use std::io::Write; - f.write_all(&bytes)?; - f.sync_all()?; - } - // mode 0600 on Unix; Windows inherits the user-profile ACL. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perm = std::fs::metadata(&tmp)?.permissions(); - perm.set_mode(0o600); - std::fs::set_permissions(&tmp, perm)?; - } - std::fs::rename(&tmp, &path)?; - Ok(()) - } - - /// Load whatever's at `//locks.json`. Returns - /// an empty map when the file doesn't exist. Logs and returns - /// empty on parse error rather than refusing to unlock — a - /// corrupt locks file shouldn't make the wallet unusable. - fn load_locks_for_wallet( - wallets_dir: &Path, - wallet_name: &str, - ) -> HashMap { - let path = locks_path(wallets_dir, wallet_name); - let bytes = match std::fs::read(&path) { - Ok(b) => b, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), - Err(e) => { - tracing::warn!(?path, error = %e, "could not read locks file"); - return HashMap::new(); - } - }; - match serde_json::from_slice::>(&bytes) { - Ok(map) => map, - Err(e) => { - tracing::warn!(?path, error = %e, "locks file is corrupt — ignoring"); - HashMap::new() - } - } - } - - /// Advance `counter` past the highest `recovery_index` present in `locks`, - /// monotonically (`fetch_max` never lowers it). Called on every wallet - /// unlock so a daemon restart never re-issues a recovery-derivation index an - /// existing lock already uses — which would re-derive the same recovery key - /// and break the lock's unilateral-exit guarantee. No-op when `locks` is - /// empty. - fn advance_recovery_index_past_locks( - counter: &AtomicU32, - locks: &HashMap, - ) { - if let Some(max_idx) = locks.values().map(|m| m.recovery_index).max() { - counter.fetch_max(max_idx + 1, std::sync::atomic::Ordering::SeqCst); - } - } - /// Enumerate every directory under `wallets_dir` that contains a `keystore.bin`. fn list_on_disk(wallets_dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(wallets_dir) else { @@ -803,13 +924,19 @@ mod server { }; let endpoint_display = wraith_wallet_ipc::endpoint_display(); let tor_proxy = std::env::var(TOR_PROXY_ENV).ok(); - let ghostd_url = std::env::var(GHOSTD_URL_ENV).ok(); - let ghostd_cookie_path = std::env::var(GHOSTD_COOKIE_ENV).ok().map(PathBuf::from); - let ghostd_user = std::env::var(GHOSTD_USER_ENV).ok(); - let ghostd_pass = std::env::var(GHOSTD_PASS_ENV).ok(); - let ghost_pay_internal_auth = std::env::var(GHOST_PAY_INTERNAL_AUTH_ENV) - .ok() - .filter(|s| !s.is_empty()); + let ghostd_env = GhostdSettings { + url: std::env::var(GHOSTD_URL_ENV).ok().filter(|s| !s.is_empty()), + cookie_path: std::env::var(GHOSTD_COOKIE_ENV) + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from), + user: std::env::var(GHOSTD_USER_ENV) + .ok() + .filter(|s| !s.is_empty()), + pass: std::env::var(GHOSTD_PASS_ENV) + .ok() + .filter(|s| !s.is_empty()), + }; let wallets_dir = default_wallets_dir(); let node_config_path = node_config_path(&wallets_dir); let network = std::env::var(NETWORK_ENV) @@ -817,76 +944,41 @@ mod server { .and_then(|s| parse_network(&s)) .unwrap_or(bitcoin::Network::Bitcoin); - // Endpoint resolution precedence, per field: - // 1. WRAITHD_GHOST_PAY / WRAITHD_GSP env var (power-user override) + // Node resolution, in order: + // 1. the environment (power-user override, pins the settings screen) // 2. persisted node.json (the choice made in the wallet UI) - // 3. bundled public preset (so a fresh install works out of the box) - // Both env vars still accept a comma-separated failover list. + // 3. nothing — the wallet has no chain backend and says so + // + // There is deliberately no bundled default. A wallet that silently + // points at somebody else's node on a fresh install is a wallet whose + // owner never chose who gets to see their addresses. + let ghostd_env_override = ghostd_env.url.is_some(); let persisted = load_node_config(&node_config_path); - let ghost_pay_env = std::env::var(GHOST_PAY_ENV).ok().filter(|s| !s.is_empty()); - let gsp_env = std::env::var(GSP_ENV).ok().filter(|s| !s.is_empty()); - let ghost_pay_env_override = ghost_pay_env.is_some(); - let gsp_env_override = gsp_env.is_some(); - // A persisted `public` preset is symbolic — it always resolves to the - // *current* bundled fleet URLs, so a client that once picked "public" - // follows the fleet if these constants change in a later release. - let persisted_is_public = persisted.as_ref().map(|c| c.preset == PRESET_PUBLIC); - let ghost_pay_urls = if let Some(raw) = ghost_pay_env { - wraith_wallet_core::chain::GhostPayClient::parse_urls(&raw) - } else if persisted_is_public == Some(false) { - persisted.as_ref().unwrap().ghost_pay_urls.clone() - } else { - vec![PUBLIC_GHOST_PAY.to_string()] - }; - let gsp_urls = if let Some(raw) = gsp_env { - wraith_wallet_core::gsp::GspClient::parse_urls(&raw) - } else if persisted_is_public == Some(false) { - persisted.as_ref().unwrap().gsp_urls.clone() - } else { - vec![PUBLIC_GSP.to_string()] - }; - // Preset label for the settings UI: a persisted choice wins; otherwise - // an env override reads as `custom`, and a clean fresh install reads as - // `public` (the bundled default it just fell back to). - let node_preset = if let Some(cfg) = persisted.as_ref() { - cfg.preset.clone() - } else if ghost_pay_env_override || gsp_env_override { - PRESET_CUSTOM.to_string() + let ghostd = if ghostd_env_override { + ghostd_env } else { - PRESET_PUBLIC.to_string() + persisted.clone().map(|c| c.ghostd).unwrap_or_default() }; + let pool_url = std::env::var(POOL_URL_ENV) + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| persisted.and_then(|c| c.pool_url)); tracing::info!( - preset = %node_preset, - ghost_pay = ?ghost_pay_urls, - gsp = ?gsp_urls, + node = ?ghostd.url, + auth = ghostd.auth_kind(), wallets_dir = %wallets_dir.display(), network = ?network, tor_proxy = ?tor_proxy, - ghost_pay_env_override, - gsp_env_override, - "node endpoints + wallets dir + network configured", - ); - - let chain: Arc = { - let mut c = wraith_wallet_core::chain::GhostPayClient::with_urls_and_proxy( - ghost_pay_urls.clone(), - tor_proxy.as_deref(), - ) - .map_err(|e| std::io::Error::other(format!("ghost-pay client: {e}")))?; - if let Some(secret) = ghost_pay_internal_auth.as_ref() { - if !secret.is_empty() { - c = c.with_internal_secret(secret.clone()); - } - } - Arc::new(c) - }; - let gsp = Arc::new( - wraith_wallet_core::gsp::GspClient::with_urls_and_proxy( - gsp_urls.clone(), - tor_proxy.as_deref(), - ) - .map_err(|e| std::io::Error::other(format!("gsp client: {e}")))?, + ghostd_env_override, + "node + wallets dir + network configured", ); + if ghostd.url.is_none() { + tracing::warn!( + "no node configured — set one in Settings or via \ + WRAITHD_GHOSTD_URL; until then the wallet cannot read or \ + write the chain" + ); + } let idle_lock_secs = std::env::var(IDLE_LOCK_ENV) .ok() @@ -918,24 +1010,18 @@ mod server { } let state = Arc::new(DaemonState { started: Instant::now(), + // Placeholder: the real backend is built from `ghostd` just + // below, once the state exists to build it from. clients: RwLock::new(NodeClients { - chain, - gsp, - ghost_pay_urls, - gsp_urls, - preset: node_preset, + chain: Arc::new(wraith_wallet_core::chain::NoChain), }), - ghost_pay_env_override, - gsp_env_override, node_config_path, - ghost_pay_internal_auth, tor_proxy: tor_proxy.clone(), wraith_coordinator_url, kiosk_mode, wallets_dir, wallets: RwLock::new(HashMap::new()), active: RwLock::new(None), - session: RwLock::new(None), network, endpoint_display: endpoint_display.clone(), last_activity: std::sync::atomic::AtomicU64::new(now_unix_secs()), @@ -943,30 +1029,58 @@ mod server { shroud_max_ms, update_manifest_url, http, + store_locks: std::sync::Mutex::new(HashMap::new()), wraith_mixes: RwLock::new(HashMap::new()), - prepared_locks: RwLock::new(HashMap::new()), - next_recovery_index: AtomicU32::new(0), - ghostd_url, - ghostd_cookie_path, - ghostd_user, - ghostd_pass, + lock_signings: RwLock::new(HashMap::new()), + ghostd: RwLock::new(ghostd), + ghostd_env_override, + pool_url: RwLock::new(pool_url), + election_cache: RwLock::new(None), }); + state.clients.write().await.chain = state.build_chain().await; // Auto-lock task. Wakes every 30 s. If idle_lock_secs is 0 the task // exits immediately — no overhead when the feature is disabled. + // Watch the chain for money arriving. Cheap when there is nothing to + // do: it returns immediately without an unlocked wallet or a node. + tokio::spawn(block_scan_task(state.clone())); + if idle_lock_secs > 0 { tokio::spawn(idle_lock_task(state.clone())); } - // Unix-domain sockets leave a filesystem entry; clear any stale one - // and ensure the parent dir exists before binding. Windows named - // pipes have no such artefact, so this housekeeping is unix-only. + // Unix-domain sockets leave a filesystem entry; clear a stale one and + // ensure the parent dir exists before binding. Windows named pipes have + // no such artefact, so this housekeeping is unix-only. + // + // The entry is only stale if nothing answers on it. This used to remove + // it unconditionally, which let a second daemon take the endpoint away + // from a running one. Both then served the same wallets directory with + // their own in-process locks, which is precisely the arrangement the + // store locks cannot protect: every read-modify-write race they exist + // to stop comes back across the process boundary, and the first daemon + // is left holding a listener nothing will ever connect to. #[cfg(unix)] { if socket_path.exists() { + let live = match wraith_wallet_ipc::endpoint_name() { + Ok(name) => IpcStream::connect(name).await.is_ok(), + Err(_) => false, + }; + if live { + return Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + format!( + "another wraithd is already listening on {} and serving this \ + wallets directory. Two daemons on one directory corrupt each \ + other's stores. Stop the running one first.", + socket_path.display() + ), + )); + } tracing::warn!( path = %socket_path.display(), - "stale socket file present, removing" + "stale socket file present (nothing answered on it), removing" ); fs::remove_file(&socket_path)?; } @@ -1015,8 +1129,6 @@ mod server { } } - // Drop the active GSP session (SessionHandle::Drop aborts the task). - *state.session.write().await = None; // Wallets clear on drop (zeroized). state.wallets.write().await.clear(); // Remove the socket so the next startup doesn't see a stale file. @@ -1068,20 +1180,6 @@ mod server { let (reader, mut writer) = stream.split(); let mut lines = BufReader::new(reader).lines(); while let Ok(Some(line)) = lines.next_line().await { - // Streaming subscriptions short-circuit the request/response cycle: - // we ack on the original id, then keep writing pushes (id=0) until - // the client drops. After the stream ends the connection is done — - // we don't try to read more requests on the same connection. - if let Ok(env) = serde_json::from_str::>(&line) { - if matches!(env.payload, Request::WatchPayments) { - let ack: Envelope = Envelope::new(env.id, Response::Watching); - if !write_envelope(&mut writer, &ack).await { - return; - } - run_watch_payments(writer, lines, state.clone()).await; - return; - } - } let response = dispatch(&line, &state).await; if !write_envelope(&mut writer, &response).await { return; @@ -1109,335 +1207,14 @@ mod server { /// payment-detection broadcast and forwards each event as a push envelope /// (id=0). Exits when the client disconnects, the active session is /// rotated out, or the broadcast channel is closed. - async fn run_watch_payments( - mut writer: IpcSendHalf, - mut lines: tokio::io::Lines>, - state: Arc, - ) { - let mut rx = match state.session.read().await.as_ref() { - Some(s) => s.handle.subscribe_payments(), - None => { - let err: Envelope = Envelope::new( - 0, - Response::Error(ErrorResponse { - message: "no active session; call gsp_auth first".to_string(), - }), - ); - let _ = write_envelope(&mut writer, &err).await; - return; - } - }; - loop { - tokio::select! { - read = lines.next_line() => { - // The client closed (or sent another request — we don't accept - // anything else on a watch connection; just hang up). - match read { - Ok(Some(_)) => return, - _ => return, - } - } - event = rx.recv() => { - match event { - Ok(d) => { - let push: Envelope = Envelope::new( - 0, - Response::PaymentDetected(DetectedPaymentEntry { - txid: d.txid, - block_height: d.block_height, - vout: d.vout, - amount_sats: d.amount_sats, - k: d.k, - received_at: d.received_at, - }), - ); - if !write_envelope(&mut writer, &push).await { - return; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - tracing::warn!(missed = n, "watch_payments lagged; client should resync via light_detected"); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - // Session was rotated out — close the watch. - return; - } - } - } - } - } - } - /// `GspAuth` orchestration: register-if-needed + session. Stores the resulting /// `SessionToken` in `state.session` so subsequent commits can use it to open /// a persistent authenticated WebSocket. - async fn gsp_auth(state: &Arc) -> Result { - // 1. Get the auth keypair + active wallet name. - let (active_name, kp) = { - let active = state - .active - .read() - .await - .clone() - .ok_or_else(|| "no active wallet".to_string())?; - let wallets = state.wallets.read().await; - let ks = wallets - .get(&active) - .ok_or_else(|| format!("active wallet '{active}' is not unlocked"))?; - let kp = auth::auth_keypair(ks).map_err(|e| format!("auth keypair: {e}"))?; - (active, kp) - }; - let wallet_id = auth::wallet_id_hex(&kp); - - // 2. Register (idempotent — treat "already registered" server errors as success). - let gsp = state.gsp().await; - let register_proof = - auth::make_proof(&kp, "register").map_err(|e| format!("register proof: {e}"))?; - let already_registered = match gsp.register(register_proof, None).await { - Ok(_) => false, - Err(GspError::Server(msg)) if msg.to_ascii_lowercase().contains("already") => true, - Err(e) => return Err(format!("register: {e}")), - }; - - // 3. Generate session_nonce + sign session proof + create session. - use rand::RngCore; - let mut nonce_bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); - let session_nonce = hex::encode(nonce_bytes); - - let session_proof = - auth::make_proof(&kp, "session").map_err(|e| format!("session proof: {e}"))?; - let token = gsp - .create_session(session_proof, Some(session_nonce)) - .await - .map_err(|e| format!("session: {e}"))?; - - let token_prefix: String = token.token.chars().take(12).collect(); - let expires_at = token.expires_at; - let jwt_for_session = token.token.clone(); - - // Derive ghost keys for client-side BIP-352 detection. Best-effort: - // failure here just means the session won't auto-scan; auth still works. - let scan_keys = { - let wallets = state.wallets.read().await; - wallets - .get(&active_name) - .and_then(|ks| ks.ghost_keys().ok()) - }; - - // Compute the wallet's network-correct bech32 ghost-id once - // up front. The session forwards it with each - // GetTransactions so ghost-pay can match recipient-side - // rows. `GhostKeys::ghost_id().to_string()` would emit the - // mainnet HRP — wrong for regtest/signet/testnet. - let ghost_id_bech32 = scan_keys.as_ref().and_then(|gk| { - gk.ghost_id() - .encode_for_network(ghost_network_from_bitcoin(state.network)) - .ok() - }); - - // 4. Stash the token + spawn a persistent authenticated session task. - // Replacing an existing slot drops the old SessionHandle, which aborts - // its task before the new one starts. - let handle = spawn_session_with_bech32( - state.gsp_urls().await, - jwt_for_session, - scan_keys, - ghost_id_bech32, - state.tor_proxy.clone(), - ); - *state.session.write().await = Some(StoredSession { - wallet_name: active_name, - token, - handle, - }); - - Ok(GspAuthResponse { - wallet_id, - already_registered, - token_prefix, - expires_at, - }) - } - - /// Helpers shared by lock operations: pull the auth keypair from the session's wallet. - /// Used so each lock op binds to the wallet that produced the session token. - async fn auth_keypair_for_session( - state: &Arc, - ) -> Result { - let session = state.session.read().await; - let session = session - .as_ref() - .ok_or_else(|| "no GSP session — run `wraith gsp auth` first".to_string())?; - let wallets = state.wallets.read().await; - let ks = wallets.get(&session.wallet_name).ok_or_else(|| { - format!( - "wallet '{}' (the session's wallet) is not unlocked", - session.wallet_name - ) - })?; - wraith_wallet_core::auth::auth_keypair(ks).map_err(|e| format!("auth keypair: {e}")) - } - - fn parse_jump_priority(s: &str) -> Result { - match s.trim().to_ascii_lowercase().as_str() { - "" | "normal" => Ok("normal".to_string()), - "high" => Ok("high".to_string()), - "urgent" => Ok("urgent".to_string()), - other => Err(format!( - "unknown jump priority '{other}' (try normal, high, urgent)" - )), - } - } - - fn parse_payment_mode(s: &str) -> Result { - // Send only exposes the instant L2 ledger transfer (`ghostpay`). - // The `wraith` and `confidential` modes were retired here because - // they never had a real code path in Send — both silently took the - // plaintext L2 ledger route, so advertising them was a - // truth-in-advertising defect. Unlinkable L1 spends live in the Mix - // tab (Wraith CoinJoin); a shielded confidential L2 transfer needs - // client-side ZK proving the wallet-core cannot yet produce, so it - // is not offered rather than faked. Both are rejected below instead - // of silently accepted — a rejected send can never leak as a - // plaintext one. - match s.trim().to_ascii_lowercase().as_str() { - "" | "ghostpay" | "ghost-pay" | "ghost_pay" => Ok(PaymentMode::GhostPay), - "wraith" => Err( - "payment mode 'wraith' is not available from Send — unlinkable L1 spends go \ - through the Mix tab (Wraith CoinJoin)" - .to_string(), - ), - "confidential" => Err( - "payment mode 'confidential' is not available: shielded L2 transfers require \ - client-side ZK proving that is not yet supported" - .to_string(), - ), - other => Err(format!("unknown payment mode '{other}' (try ghostpay)")), - } - } - /// `LightSend` orchestration: PreparePayment → sign sighash with auth key → SubmitSignedPayment. /// Mirrors `ghost-light-wallet::payments::send::sign_and_submit` so wire format matches. - async fn light_send( - state: &Arc, - recipient: String, - amount_sats: u64, - mode_str: String, - memo: Option, - shroud_override_ms: Option, - ) -> Result { - // The `mode` field on the IPC is parsed and validated. Only - // `ghostpay` (the instant L2 ledger transfer) is accepted; the - // retired `wraith`/`confidential` modes are rejected here so a - // stale caller can never fall through to a plaintext send it - // did not intend (see `parse_payment_mode`). - let mode = parse_payment_mode(&mode_str)?; - let mode_label = format!("{mode}"); - - let session = state.session.read().await; - let session = session - .as_ref() - .ok_or_else(|| "no GSP session — run `wraith gsp auth` first".to_string())?; - - // Auth keypair from the active wallet (must match session's wallet). - let kp = { - let wallets = state.wallets.read().await; - let ks = wallets.get(&session.wallet_name).ok_or_else(|| { - format!( - "wallet '{}' (the session's wallet) is not unlocked", - session.wallet_name - ) - })?; - wraith_wallet_core::auth::auth_keypair(ks).map_err(|e| format!("auth keypair: {e}"))? - }; - - // Phase 9 Shroud: hold the request for a uniform random delay - // in [0, max] before sending. For L2 ledger ops there's no P2P - // broadcast to correlate against, but a network observer with - // both wallet→ghost-pay HTTP and ghost-pay→peer ledger update - // vantage points could still correlate "user typed send" with - // "ledger updated" — the shroud breaks that timing seam. - let max_ms = shroud_override_ms.unwrap_or(state.shroud_max_ms); - let shroud_delay_ms = shroud_pick_delay(max_ms); - if let Some(chosen) = shroud_delay_ms { - tracing::debug!( - shroud_max_ms = max_ms, - chosen_ms = chosen, - "shroud relay: holding L2 send before submit" - ); - tokio::time::sleep(std::time::Duration::from_millis(chosen)).await; - } - - // Fresh per-call auth proof and a single SendL2Payment. - // Replaces the prepare/sign/submit dance — L2 transfers are - // session-authenticated ledger ops, not Bitcoin txs requiring - // per-payment sighash signatures. - let proof = wraith_wallet_core::auth::make_proof(&kp, "send_l2_payment") - .map_err(|e| format!("send_l2_payment proof: {e}"))?; - - let result = session - .handle - .send_l2_payment(recipient.clone(), amount_sats, proof, memo.clone()) - .await - .map_err(|e| format!("SendL2Payment: {e}"))?; - - Ok(LightSentResponse { - payment_id: result.payment_id, - // L2 transfers are off-chain ledger ops — there's no - // bitcoin txid until the eventual settlement step - // (reconciliation or confidential-transfer ZK proof). - txid: None, - recipient, - amount_sats: result.amount_sats, - // ghost-pay's L2 send doesn't currently expose a fee - // breakdown in its response. v1 reports 0; the - // operator-side fee accounting can surface later via - // a separate query if/when needed. - fee_sats: 0, - mode: mode_label, - shroud_delay_ms, - }) - } - /// Send `RegisterScanKey` over the persistent session: derives the wallet's /// BIP-352 scan pubkey, signs a `register_scan_key` proof, and delegates to /// the session task. Returns (wallet_id, scan_pubkey_hex) on success. - async fn gsp_register_scan_key(state: &Arc) -> Result<(String, String), String> { - let session = state.session.read().await; - let session = session - .as_ref() - .ok_or_else(|| "no GSP session — run `wraith gsp auth` first".to_string())?; - - // Derive scan pubkey + auth keypair from the session's wallet. - let (scan_pubkey_hex, kp) = { - let wallets = state.wallets.read().await; - let ks = wallets.get(&session.wallet_name).ok_or_else(|| { - format!( - "wallet '{}' (the session's wallet) is not unlocked", - session.wallet_name - ) - })?; - let gk = ks.ghost_keys().map_err(|e| format!("ghost-keys: {e}"))?; - let scan_hex = hex::encode(gk.scan_pubkey().serialize()); - let kp = wraith_wallet_core::auth::auth_keypair(ks) - .map_err(|e| format!("auth keypair: {e}"))?; - (scan_hex, kp) - }; - - let proof = wraith_wallet_core::auth::make_proof(&kp, "register_scan_key") - .map_err(|e| format!("register_scan_key proof: {e}"))?; - let wallet_id = wraith_wallet_core::auth::wallet_id_hex(&kp); - - session - .handle - .register_scan_key(scan_pubkey_hex.clone(), proof) - .await - .map_err(|e| format!("RegisterScanKey: {e}"))?; - - Ok((wallet_id, scan_pubkey_hex)) - } - /// Run all connectivity / liveness checks and return a summary. async fn doctor_run(state: &Arc) -> DoctorResponse { let mut checks: Vec = Vec::new(); @@ -1454,54 +1231,44 @@ mod server { ), }); - // 2. ghost-pay /api/v1/status round-trip + latency. + // 2. The node: reachability, sync, and round-trip. let t0 = std::time::Instant::now(); + let configured = state.ghostd().await.url.is_some(); match state.chain().await.status().await { Ok(s) => { let rtt = t0.elapsed().as_millis(); + let height = match s.chain_height { + Some(h) => h.to_string(), + None => "unknown".into(), + }; checks.push(DoctorCheck { - name: "ghost-pay".into(), + name: "node".into(), status: "pass".into(), detail: format!( - "v{} ({}) — locks={}, sessions={} — round-trip {rtt}ms", - s.backend_version, s.network, s.lock_count, s.active_sessions + "{} ({}) — height {height} — round-trip {rtt}ms", + s.backend_version, s.network ), }); } + // No node configured is a setup step, not a failure: it does not + // fail the run, because there is nothing broken to fix — only + // something not yet chosen. + Err(e) if !configured => checks.push(DoctorCheck { + name: "node".into(), + status: "skip".into(), + detail: format!("{e}"), + }), Err(e) => { all_pass = false; let rtt = t0.elapsed().as_millis(); checks.push(DoctorCheck { - name: "ghost-pay".into(), + name: "node".into(), status: "fail".into(), detail: format!("{e} (after {rtt}ms)"), }); } } - // 3. GSP ping round-trip. - match state.gsp().await.ping().await { - Ok(p) => { - let detail = match p.round_trip_ms { - Some(rtt) => format!("server_time {} — round-trip {}ms", p.server_time, rtt), - None => format!("server_time {}", p.server_time), - }; - checks.push(DoctorCheck { - name: "ghost-gsp".into(), - status: "pass".into(), - detail, - }); - } - Err(e) => { - all_pass = false; - checks.push(DoctorCheck { - name: "ghost-gsp".into(), - status: "fail".into(), - detail: format!("{e}"), - }); - } - } - // 4. Active wallet status. match state.active.read().await.clone() { Some(active) => checks.push(DoctorCheck { @@ -1518,35 +1285,6 @@ mod server { } } - // 5. Session — present? - match state.session.read().await.as_ref() { - None => checks.push(DoctorCheck { - name: "gsp session".into(), - status: "skip".into(), - detail: "no session — `wraith gsp auth`".into(), - }), - Some(s) => { - let snap = s.handle.snapshot().await; - let phase = phase_label(snap.phase); - let status = if matches!(snap.phase, SessionPhase::Authenticated) { - "pass".to_string() - } else { - all_pass = false; - "fail".to_string() - }; - checks.push(DoctorCheck { - name: "gsp session".into(), - status, - detail: format!( - "{} (connects: {}, expires in {}s)", - phase, - snap.connect_count, - s.token.remaining_secs() - ), - }); - } - } - // 6. wraith-coordinator probe (only when WRAITHD_WRAITH_COORDINATOR // is set — mixes use a per-call URL from the wallet, so this is // purely a dev-stack diagnostic). @@ -1584,11 +1322,9 @@ mod server { // checks here aren't run on signet / testnet / regtest because the // privacy-and-integrity stakes don't apply to test networks. if state.network == bitcoin::Network::Bitcoin { - let ghost_pay_urls = state.ghost_pay_urls().await; - let gsp_urls = state.gsp_urls().await; + let node = state.ghostd().await; mainnet_readiness_checks( - &ghost_pay_urls, - &gsp_urls, + node.url.as_deref(), state.tor_proxy.as_deref(), &mut checks, &mut all_pass, @@ -1615,106 +1351,64 @@ mod server { matches!(host, "127.0.0.1" | "::1" | "localhost") } - /// Phase: mainnet-only doctor checks. Flags plaintext non-loopback - /// URLs (real privacy hole on real bitcoin) and the absence of a Tor - /// proxy (advisory — Tor is opt-in by design, but worth surfacing so - /// the user knows they're publishing their IP to ghost-pay/GSP). + /// Extra rows emitted only on mainnet, where the stakes are real. + /// + /// Test networks are excluded deliberately: a plaintext regtest node is + /// not a privacy problem, and failing on it would train people to ignore + /// the row that matters. fn mainnet_readiness_checks( - ghost_pay_urls: &[String], - gsp_urls: &[String], + node_url: Option<&str>, tor_proxy: Option<&str>, checks: &mut Vec, all_pass: &mut bool, ) { - let plaintext_pay: Vec<&String> = ghost_pay_urls - .iter() - .filter(|u| u.starts_with("http://") && !is_loopback_url(u)) - .collect(); - let plaintext_gsp: Vec<&String> = gsp_urls - .iter() - .filter(|u| u.starts_with("ws://") && !is_loopback_url(u)) - .collect(); - - // Plaintext ghost-pay row. Fail = wallet→ghost-pay traffic is - // visible to anyone on the path; an observer can correlate - // submissions with broadcasts. - if plaintext_pay.is_empty() { - checks.push(DoctorCheck { - name: "mainnet/ghost-pay tls".into(), - status: "pass".into(), - detail: "all ghost-pay endpoints use https or are loopback-bound".into(), - }); - } else { - *all_pass = false; - checks.push(DoctorCheck { - name: "mainnet/ghost-pay tls".into(), - status: "fail".into(), - detail: format!( - "{} non-TLS endpoint(s): {}. switch to https:// or run ghost-pay on \ - loopback.", - plaintext_pay.len(), - plaintext_pay - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ), - }); - } - - // Plaintext GSP row. Same threat: ws:// leaks the wallet's - // existence + auth identity to anyone on the path. - if plaintext_gsp.is_empty() { - checks.push(DoctorCheck { - name: "mainnet/gsp tls".into(), + // Plaintext RPC row. The node connection carries the wallet's + // addresses and its transactions before they are broadcast; in the + // clear, anyone on the path learns both. Loopback is exempt — the + // traffic never leaves the machine, and TLS there is CPU burned for + // no privacy gain. + match node_url { + None => checks.push(DoctorCheck { + name: "mainnet/node tls".into(), + status: "skip".into(), + detail: "no node configured".into(), + }), + Some(u) if u.starts_with("http://") && !is_loopback_url(u) => { + *all_pass = false; + checks.push(DoctorCheck { + name: "mainnet/node tls".into(), + status: "fail".into(), + detail: format!( + "{u} is plaintext and not loopback — your addresses and \ + unbroadcast transactions are visible to anyone on the path. \ + use https://, or reach the node over loopback or an SSH tunnel." + ), + }); + } + Some(_) => checks.push(DoctorCheck { + name: "mainnet/node tls".into(), status: "pass".into(), - detail: "all gsp endpoints use wss or are loopback-bound".into(), - }); - } else { - *all_pass = false; - checks.push(DoctorCheck { - name: "mainnet/gsp tls".into(), - status: "fail".into(), - detail: format!( - "{} non-TLS endpoint(s): {}. switch to wss:// or run GSP on loopback.", - plaintext_gsp.len(), - plaintext_gsp - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ), - }); + detail: "the node is reached over https or loopback".into(), + }), } - // Tor row. Advisory only — Tor is opt-in by design, and forcing - // it would break legitimate setups (e.g. an operator running - // their own ghost-pay on a private network). "skip" rather than - // "fail" so all_pass isn't lowered. - if tor_proxy.is_none() { - checks.push(DoctorCheck { + // Tor row. Advisory only — Tor is opt-in by design, and forcing it + // would break legitimate setups (a node on a private network, say). + // "skip" rather than "fail" so all_pass isn't lowered. + match tor_proxy { + None => checks.push(DoctorCheck { name: "mainnet/tor".into(), status: "skip".into(), - detail: "WRAITHD_TOR_PROXY unset — your IP is visible to ghost-pay and GSP. \ - set e.g. socks5h://127.0.0.1:9050 to route through Tor." + detail: "WRAITHD_TOR_PROXY unset — your IP is visible to anything the \ + wallet talks to. set e.g. socks5h://127.0.0.1:9050 to route \ + through Tor." .into(), - }); - } else { - checks.push(DoctorCheck { + }), + Some(p) => checks.push(DoctorCheck { name: "mainnet/tor".into(), status: "pass".into(), - detail: format!("routing through {}", tor_proxy.unwrap_or("?")), - }); - } - } - - fn phase_label(p: SessionPhase) -> &'static str { - match p { - SessionPhase::Disconnected => "disconnected", - SessionPhase::Connecting => "connecting", - SessionPhase::Authenticating => "authenticating", - SessionPhase::Authenticated => "authenticated", - SessionPhase::Backoff => "backoff", + detail: format!("routing through {p}"), + }), } } @@ -1939,15 +1633,38 @@ mod server { } // 4. Build the unsigned PSBT. - let (psbt, meta) = psbt_mod::create_psbt( - &available, + // + // A Ghost ID is paid differently from an address: the money goes to a + // taproot output derived per-payment, and an OP_RETURN alongside it + // carries the ephemeral key the recipient needs to find it. Routed on + // the recipient's form rather than on a flag, so the caller cannot ask + // for one and get the other. + let (psbt, meta) = if wraith_wallet_core::silent_payment::looks_like_ghost_id( recipient_address, - amount_sats, - &change_addr, network, - fee_rate_sats_per_vb, - ) - .map_err(|e| format!("create_psbt: {e}"))?; + ) { + let pay = wraith_wallet_core::silent_payment::build(recipient_address, network, 0) + .map_err(|e| format!("silent payment: {e}"))?; + psbt_mod::create_psbt_to_scripts( + &available, + pay.output_script, + amount_sats, + std::slice::from_ref(&pay.announcement_script), + &change_addr, + fee_rate_sats_per_vb, + ) + .map_err(|e| format!("create_psbt: {e}"))? + } else { + psbt_mod::create_psbt( + &available, + recipient_address, + amount_sats, + &change_addr, + network, + fee_rate_sats_per_vb, + ) + .map_err(|e| format!("create_psbt: {e}"))? + }; let encoded = psbt_mod::encode_psbt(&psbt, psbt_mod::PsbtEncoding::Base64); Ok(wraith_wallet_ipc::PsbtCreateResponse { @@ -1965,12 +1682,125 @@ mod server { }) } + /// What one on-chain payment needs to know. + /// + /// A struct rather than a long argument list: every field but the first + /// two is optional in spirit, and eight positional arguments of mostly + /// numbers is a place where two of them quietly swap. + struct L1SendParams { + recipient_address: String, + amount_sats: u64, + fee_rate_sats_per_vb: u64, + change_index: Option, + bip86_scan_max: u32, + selected_outpoints: Vec, + memo: Option, + shroud_override_ms: Option, + } + + /// Build, sign and broadcast an ordinary on-chain payment. + /// + /// Composed from the three verbs that already exist rather than + /// re-implementing any of them: `psbt_create_handler` selects coins and + /// sets the change, `sign_owned_inputs` signs what the wallet owns, and + /// `psbt_broadcast_handler` is the single place a transaction reaches the + /// network and the single place history is written. + /// + /// It stops with a clear error rather than broadcasting a partly signed + /// transaction. An incomplete PSBT here means a selected input was not + /// ours to sign — which is worth saying, because the alternative is a + /// rejection from the node whose message explains nothing. + async fn l1_send( + state: &Arc, + p: L1SendParams, + ) -> Result { + use wraith_wallet_core::psbt as psbt_mod; + + let L1SendParams { + recipient_address, + amount_sats, + fee_rate_sats_per_vb, + change_index, + bip86_scan_max, + selected_outpoints, + memo, + shroud_override_ms, + } = p; + + let built = psbt_create_handler( + state, + &recipient_address, + amount_sats, + fee_rate_sats_per_vb, + change_index, + bip86_scan_max, + &selected_outpoints, + ) + .await?; + + let network = state.network; + let scan_max = bip86_scan_max.max(1); + let (mut parsed, encoding) = + psbt_mod::decode_psbt(&built.psbt).map_err(|e| format!("decode: {e}"))?; + let signed_count = with_active_wallet(state, move |_, ks| { + psbt_mod::sign_owned_inputs(&mut parsed, ks, network, scan_max) + .map(|n| (n, parsed)) + .map_err(|e| format!("sign: {e}")) + }) + .await?; + let (signed, signed_psbt) = signed_count; + if !psbt_mod::is_complete(&signed_psbt) { + return Err(format!( + "signed {} of {} inputs — the rest are not this wallet's to sign, \ + so nothing was broadcast", + signed.len(), + signed_psbt.inputs.len() + )); + } + + // The same shroud as `LightSend`, and it means more here: this one + // does reach the P2P network, where the moment of broadcast is what an + // observer correlates against the user's keystrokes. + let max_ms = shroud_override_ms.unwrap_or(state.shroud_max_ms); + let shroud_delay_ms = shroud_pick_delay(max_ms); + if let Some(chosen) = shroud_delay_ms { + tracing::debug!( + shroud_max_ms = max_ms, + chosen_ms = chosen, + "shroud relay: holding L1 payment before broadcast" + ); + tokio::time::sleep(std::time::Duration::from_millis(chosen)).await; + } + + let encoded = psbt_mod::encode_psbt(&signed_psbt, encoding); + let txid = psbt_broadcast_handler(state, &encoded, "send", memo).await?; + + Ok(wraith_wallet_ipc::L1SendResponse { + txid, + recipient: recipient_address, + // From the built transaction, not from the request: coin selection + // decides what the fee ends up being. + amount_sats: built.recipient_sats, + fee_sats: built.fee_sats, + change_sats: built.change_sats, + input_count: built.input_count, + shroud_delay_ms, + }) + } + /// Extract a finalized tx from a PSBT (or accept raw tx hex - /// directly) and broadcast it via ghost-pay. Returns the - /// txid bitcoind accepted. + /// directly), broadcast it, and write it into the local history. + /// Returns the txid the node accepted. + /// + /// This is the one place a transaction reaches the network, which is why + /// it is also the one place history is written: a spend that never left + /// is not something the wallet did, and one that left must not be + /// forgotten. async fn psbt_broadcast_handler( - state: &DaemonState, + state: &Arc, psbt_or_tx_hex: &str, + kind: &str, + memo: Option, ) -> Result { use wraith_wallet_core::psbt as psbt_mod; let trimmed = psbt_or_tx_hex.trim(); @@ -1978,6 +1808,7 @@ mod server { // Anything else, treat as raw consensus-encoded tx hex. let is_psbt = trimmed.to_lowercase().starts_with("70736274ff") || trimmed.starts_with("cHNidP"); + let mut source_psbt = None; let tx_hex = if is_psbt { let (parsed, _) = psbt_mod::decode_psbt(trimmed).map_err(|e| format!("decode_psbt: {e}"))?; @@ -1987,21 +1818,162 @@ mod server { ); } let tx = parsed + .clone() .extract_tx() .map_err(|e| format!("extract_tx: {e}"))?; - bitcoin::consensus::encode::serialize_hex(&tx) + let hex = bitcoin::consensus::encode::serialize_hex(&tx); + // Kept for the history entry: a PSBT carries the input values, so + // it is the only form from which the fee and the true net change + // can be worked out. A bare transaction does not carry them. + source_psbt = Some(parsed); + hex } else { let bytes = hex::decode(trimmed).map_err(|e| format!("hex: {e}"))?; let _: bitcoin::Transaction = bitcoin::consensus::encode::deserialize(&bytes) .map_err(|e| format!("invalid raw tx: {e}"))?; trimmed.to_string() }; - state + let txid = state .chain() .await .broadcast_tx(&tx_hex) .await - .map_err(|e| format!("broadcast: {e}")) + .map_err(|e| format!("broadcast: {e}"))?; + // Recorded after the node accepted it, because a transaction the node + // rejected is not something the wallet did. A failure to record is + // logged and not propagated: the money has already moved, and + // reporting the broadcast as failed would be the more damaging lie. + if let Err(e) = record_broadcast(state, &txid, source_psbt.as_ref(), kind, memo).await { + tracing::warn!( + txid = %txid, + error = %e, + "broadcast succeeded but could not be written to local history" + ); + } + Ok(txid) + } + + /// Write one broadcast into the local history. + /// + /// Both figures come from the PSBT or from nowhere. A raw transaction does + /// not carry its input values, so neither the fee nor the net change can + /// be derived from one; the entry is then recorded with `None` for both + /// rather than with a plausible-looking wrong number. `None` reads as "—" + /// in the UI, where a `0` would read as "moved nothing". + async fn record_broadcast( + state: &Arc, + txid: &str, + source_psbt: Option<&bitcoin::psbt::Psbt>, + kind: &str, + memo: Option, + ) -> Result<(), String> { + let (amount_sats, fee_sats) = match source_psbt { + Some(p) => match own_script_pubkeys(state).await { + Some(ours) => psbt_ledger_effect(p, &ours), + // Locked wallet: the fee is still inputs minus outputs and + // needs no keys, but which coins were ours does. + None => (None, psbt_ledger_effect_fee(p)), + }, + None => (None, None), + }; + record_history( + state, + wraith_wallet_core::history_store::HistoryEntry { + txid: txid.to_string(), + at: now_unix_secs() as i64, + // Unconfirmed until the scanner sees it mined. + block_height: None, + amount_sats, + fee_sats, + kind: kind.to_string(), + memo, + }, + ) + .await + } + + /// The value backing one PSBT input, from whichever UTXO field carries it. + fn psbt_input_value(psbt: &bitcoin::psbt::Psbt, i: usize) -> Option<&bitcoin::TxOut> { + let input = psbt.inputs.get(i)?; + if let Some(txout) = input.witness_utxo.as_ref() { + return Some(txout); + } + // Legacy inputs carry the whole previous transaction instead. + let prev = input.non_witness_utxo.as_ref()?; + let outpoint = psbt.unsigned_tx.input.get(i)?.previous_output; + prev.output.get(outpoint.vout as usize) + } + + /// Miner fee: every input value minus every output value. + /// + /// `None` if any input's value is missing — a fee computed from a subset + /// of the inputs is not a smaller fee, it is a wrong one. + fn psbt_ledger_effect_fee(psbt: &bitcoin::psbt::Psbt) -> Option { + let mut inputs = 0u64; + for i in 0..psbt.unsigned_tx.input.len() { + inputs = inputs.saturating_add(psbt_input_value(psbt, i)?.value.to_sat()); + } + let outputs: u64 = psbt + .unsigned_tx + .output + .iter() + .map(|o| o.value.to_sat()) + .sum(); + Some(inputs.saturating_sub(outputs)) + } + + /// What this PSBT does to the wallet's balance, and what it pays in fee. + /// + /// The net is our outputs minus our inputs, so it accounts for change and + /// for the fee without either being special-cased: a 50,000 sat payment + /// costing 500 in fee nets −50,500, which is the number the balance will + /// actually move by. Addresses beyond the scan window read as somebody + /// else's and overstate what left — the safer direction to be wrong in for + /// a record the user checks against their memory of the payment. + fn psbt_ledger_effect( + psbt: &bitcoin::psbt::Psbt, + ours: &std::collections::HashSet>, + ) -> (Option, Option) { + let fee = psbt_ledger_effect_fee(psbt); + let mut spent: i64 = 0; + for i in 0..psbt.unsigned_tx.input.len() { + let Some(txout) = psbt_input_value(psbt, i) else { + // One unknown input value makes the net unknowable; the fee + // is already `None` for the same reason. + return (None, fee); + }; + if ours.contains(txout.script_pubkey.as_bytes()) { + spent = spent.saturating_add(txout.value.to_sat() as i64); + } + } + let mut received: i64 = 0; + for out in &psbt.unsigned_tx.output { + if ours.contains(out.script_pubkey.as_bytes()) { + received = received.saturating_add(out.value.to_sat() as i64); + } + } + (Some(received.saturating_sub(spent)), fee) + } + + /// The scripts this wallet can spend, over the scan window. + /// + /// `None` when the wallet is locked — deriving needs the keys. Change + /// addresses beyond the window read as somebody else's, which overstates + /// what left; that is the safer direction to be wrong in for a record the + /// user checks against their own memory of the payment. + async fn own_script_pubkeys(state: &DaemonState) -> Option>> { + let network = state.network; + with_active_wallet(state, move |_, ks| { + let mut set = std::collections::HashSet::new(); + for i in 0..wraith_wallet_core::psbt::DEFAULT_SCAN_INDEX_MAX { + let a = light::receive_address(ks, i, network) + .map_err(|e| format!("derive index {i}: {e}"))?; + set.insert(a.script_pubkey().as_bytes().to_vec()); + } + Ok(set) + }) + .await + .ok() } /// Inspect a multisig descriptor. Pure function: parse, derive @@ -2376,19 +2348,6 @@ mod server { } } - /// Error text for `locks_recover` when we hold no local metadata for the - /// requested lock. Prepared locks are persisted to `/locks.json` - /// and reloaded on `WalletUnlock`, so a miss means the entry belongs to a - /// different wallet/daemon or its `locks.json` row is gone — not that the - /// index was lost to a restart. Pulled out so the message is unit-testable. - fn missing_lock_metadata_error(lock_id: &str) -> String { - format!( - "no local metadata for lock '{lock_id}' — either it was prepared \ - by a different wallet/daemon, or its locks.json entry is missing. \ - Unlock the wallet that prepared it and retry." - ) - } - /// Error text for `WraithMixSubmit` when the `session_id` is unknown. The /// `wraith_mixes` map is in-memory only by design (the coordinator's /// no-sign deadline is ticking), so a miss means the round expired or the @@ -2408,15 +2367,11 @@ mod server { } /// Returns true iff this request counts as user-facing activity for the - /// idle-lock timer. Diagnostics (Health, Doctor, DaemonEnv) and the watch - /// stream itself don't reset the timer — they're either too quiet to - /// indicate a present user, or they're held open continuously and would - /// defeat the feature. + /// idle-lock timer. Diagnostics (Health, Doctor, DaemonEnv) don't reset + /// it — they are too quiet to indicate a present user, and a status bar + /// polling every few seconds would defeat the feature outright. fn is_activity(req: &Request) -> bool { - !matches!( - req, - Request::Health | Request::Doctor | Request::DaemonEnv | Request::WatchPayments - ) + !matches!(req, Request::Health | Request::Doctor | Request::DaemonEnv) } /// Background task that locks every unlocked wallet after @@ -2458,892 +2413,2420 @@ mod server { } drop(wallets); *state.active.write().await = None; - // Active GSP session belonged to one of those wallets; drop it. - *state.session.write().await = None; } } - async fn dispatch(line: &str, state: &Arc) -> Envelope { - let parsed: Result, _> = serde_json::from_str(line); - let (id, request) = match parsed { - Ok(env) => (env.id, env.payload), - Err(e) => { - return Envelope::new( - 0, - Response::Error(ErrorResponse { - message: format!("malformed request: {e}"), - }), - ); - } - }; + /// How many blocks one scan tick will read. + /// + /// A wallet that has been shut for a week has a lot to catch up on, and + /// reading it in one go would hold the runtime and the node for minutes. + /// Bounded work per tick means it catches up steadily and stays responsive + /// while it does. + const SCAN_BATCH_BLOCKS: u32 = 50; - // Bump the idle-lock timer for user-facing requests. Diagnostics - // (Health, Doctor, DaemonEnv) and WatchPayments don't count. - if is_activity(&request) { - state - .last_activity - .store(now_unix_secs(), std::sync::atomic::Ordering::Relaxed); - } + /// How far back a reorg is looked for before giving up. + /// + /// Deeper than any reorg this chain has seen. If the fork is further back + /// than this the scanner says so rather than guessing — a bookmark that + /// cannot be reconciled is a thing to report, not to paper over. + const REORG_SEARCH_DEPTH: u32 = 100; - let response = match request { - Request::Health => Response::Health(HealthResponse { - daemon_version: env!("CARGO_PKG_VERSION").to_string(), - uptime_secs: state.started.elapsed().as_secs(), - }), - Request::Doctor => Response::Doctor(doctor_run(state).await), - Request::ChainStatus => match state.chain().await.status().await { - Ok(s) => Response::ChainStatus(ChainStatusResponse { - backend_version: s.backend_version, - network: s.network, - has_keys: s.has_keys, - lock_count: s.lock_count, - active_sessions: s.active_sessions, - chain_height: s.chain_height, - chain_headers: s.chain_headers, - chain_verification_progress: s.chain_verification_progress, - chain_initial_block_download: s.chain_initial_block_download, - l2_height: s.l2_height, - l2_epoch: s.l2_epoch, - }), - Err(e) => Response::Error(ErrorResponse { - message: format!("chain: {e}"), - }), - }, - Request::GspPing => match state.gsp().await.ping().await { - Ok(p) => Response::GspPing(GspPingResponse { - server_time: p.server_time, - round_trip_ms: p.round_trip_ms, - }), - Err(e) => Response::Error(ErrorResponse { - message: format!("gsp: {e}"), - }), - }, - Request::GspAuth => match gsp_auth(state).await { - Ok(r) => Response::GspAuth(r), - Err(message) => Response::Error(ErrorResponse { message }), - }, - Request::GspRegisterScanKey => match gsp_register_scan_key(state).await { - Ok((wallet_id, scan_pubkey_hex)) => Response::GspScanKeyRegistered { - wallet_id, - scan_pubkey_hex, - }, - Err(message) => Response::Error(ErrorResponse { message }), - }, - Request::GspSessionStatus => { - let guard = state.session.read().await; - match guard.as_ref() { - Some(s) => { - let snap: SessionStatus = s.handle.snapshot().await; - Response::GspSessionStatus(GspSessionStatusResponse { - have_token: true, - wallet_name: Some(s.wallet_name.clone()), - wallet_id: Some(s.token.wallet_id.0.clone()), - expires_at: Some(s.token.expires_at), - remaining_secs: Some(s.token.remaining_secs()), - phase: Some(phase_label(snap.phase).to_string()), - connect_count: Some(snap.connect_count), - last_error: snap.last_error, - }) + /// Read new blocks and record what they did to the wallet. + /// + /// # What this replaces + /// + /// The operator's GSP watched the chain and pushed what it found, which + /// meant giving somebody a scan key and believing the answer. This asks + /// the wallet's own node instead. The cost is latency — a payment appears + /// within a tick rather than the instant it is relayed — and the gain is + /// that nobody else needs to know the wallet is watching. + async fn block_scan_task(state: Arc) { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(20)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + loop { + match scan_new_blocks(&state).await { + Ok(0) => break, + // A full batch means there is more waiting. Go straight + // round again rather than sleeping: a wallet restored from + // a year ago has fifty thousand blocks to read, and doing + // that at one batch per tick would take most of a day. + // Idle, this still costs nothing — the first pass returns + // zero and the loop ends. + Ok(n) if n >= SCAN_BATCH_BLOCKS => continue, + Ok(n) => { + tracing::debug!(blocks = n, "block scan caught up"); + break; + } + // A node that is down, syncing or mid-restart is the common + // case and not worth an error line every twenty seconds. + // The status header already says the node is unreachable. + Err(e) => { + tracing::debug!(error = %e, "block scan tick did not complete"); + break; } - None => Response::GspSessionStatus(GspSessionStatusResponse { - have_token: false, - wallet_name: None, - wallet_id: None, - expires_at: None, - remaining_secs: None, - phase: None, - connect_count: None, - last_error: None, - }), } } - Request::ConnectionStatus => { - // One probe answers "is ghost-pay reachable" AND supplies the - // chain fields. On error we report unreachable rather than - // surfacing a Response::Error — the whole point is a header - // that says "unreachable" instead of spinning forever. - let ( - ghost_pay_reachable, - ghost_pay_version, - ghost_pay_error, - chain_height, - chain_headers, - chain_ibd, - l2_height, - ) = match state.chain().await.status().await { - Ok(s) => ( - true, - Some(s.backend_version), - None, - s.chain_height, - s.chain_headers, - s.chain_initial_block_download, - s.l2_height, - ), - Err(e) => (false, None, Some(format!("{e}")), None, None, None, None), - }; - // Same rule the GUI's SyncIndicator uses: verified height has - // caught the header tip (or headers unknown) AND bitcoind is - // out of initial block download. - let chain_synced = ghost_pay_reachable - && chain_height.is_some() - && chain_headers.is_none_or(|h| chain_height.unwrap_or(0) >= h) - && chain_ibd == Some(false); - let (gsp_have_token, gsp_phase) = { - let guard = state.session.read().await; - match guard.as_ref() { - Some(s) => { - let snap = s.handle.snapshot().await; - (true, Some(phase_label(snap.phase).to_string())) - } - None => (false, None), - } - }; - let gsp_connected = gsp_phase.as_deref() == Some("authenticated"); - Response::ConnectionStatus(ConnectionStatusResponse { - network: network_label(state.network).to_string(), - ghost_pay_reachable, - ghost_pay_version, - ghost_pay_error, - gsp_have_token, - gsp_connected, - gsp_phase, - chain_height, - chain_headers, - chain_synced, - l2_height, - }) + } + } + + /// One pass of the scanner. Returns how many blocks it read. + /// + /// Does nothing at all without an unlocked wallet — deriving the scripts + /// to match against needs the keys, and there is no useful work to do + /// while the wallet is locked. + async fn scan_new_blocks(state: &Arc) -> Result { + let Some(ours) = own_script_pubkeys(state).await else { + return Ok(0); + }; + // The Ghost ID's scan and spend keys. A silent payment lands on a key + // derived from these rather than on any address the wallet published, + // so `ours` above cannot find one. + let ghost_keys = with_active_wallet(state, |_, ks| { + ks.ghost_keys().map_err(|e| format!("ghost keys: {e}")) + }) + .await + .ok(); + let Some(rpc) = state.build_ghostd_rpc().await else { + return Ok(0); + }; + let rpc = Arc::new(rpc); + + let tip = { + let rpc = rpc.clone(); + tokio::task::spawn_blocking(move || rpc.get_block_count()) + .await + .map_err(|e| format!("join: {e}"))? + .map_err(|e| format!("get_block_count: {e}"))? as u32 + }; + + let mut bookmark = scan_state_for(state).await?; + + // Where a wallet that has never scanned begins. + // + // Its birth height when it has one: a wallet created here recorded the + // tip, and a restored one recorded whatever its owner said. Reading + // forward from there rebuilds the history. + // + // Otherwise the tip — not genesis. Reading the whole chain to find a + // wallet that may have no history at all is hours of work for, usually, + // nothing, and the wallet cannot tell the difference between "restored + // from years ago" and "made this morning" unless it is told. Coins that + // arrived before this point are not lost from view — the balance and + // the UTXO list scan the entire UTXO set — they are absent from the + // *history*, which is a narrower claim and a stated one. + let Some(point) = bookmark.point().cloned() else { + let birth = wallet_meta_for(state) + .await + .ok() + .and_then(|m| m.birth_height); + let start = birth.unwrap_or(tip).min(tip); + // One before the start, because the loop below scans from + // `from + 1`: the birth block itself can hold the first payment. + let anchor = start.saturating_sub(1); + let hash = block_hash_at(&rpc, anchor).await?; + bookmark + .set(anchor, hash) + .map_err(|e| format!("scan state write: {e}"))?; + match birth { + Some(b) => tracing::info!( + birth_height = b, + tip, + behind = tip.saturating_sub(b), + "block scanner rebuilding history from the wallet's birth height" + ), + None => tracing::info!( + height = tip, + "block scanner started watching from the tip — this wallet has no \ + recorded birth height, so nothing before now will appear in its history" + ), } - Request::LightBalance => { - let guard = state.session.read().await; - match guard.as_ref() { - None => Response::Error(ErrorResponse { - message: "no GSP session — run `wraith gsp auth` first".to_string(), - }), - Some(s) => { - let snap = s.handle.snapshot().await; - match snap.last_balance { - None => Response::LightBalance(LightBalanceResponse { - confirmed_sats: None, - unconfirmed_sats: None, - locked_sats: None, - received_at: None, - }), - Some(b) => Response::LightBalance(LightBalanceResponse { - confirmed_sats: Some(b.confirmed_sats), - unconfirmed_sats: Some(b.unconfirmed_sats), - locked_sats: Some(b.locked_sats), - received_at: Some(b.received_at), - }), - } + return Ok(0); + }; + + // Is the chain we read still the chain that exists? + let mut from = point.height; + if block_hash_at(&rpc, point.height).await? != point.hash { + let mut fork = None; + let floor = point.height.saturating_sub(REORG_SEARCH_DEPTH); + for h in (floor..point.height).rev() { + // Walking back to a height both chains agree on. The first + // agreement is the fork point; everything above it was read + // from blocks that are no longer in the chain. + if let Some(known) = recorded_hash_at(state, h).await { + if block_hash_at(&rpc, h).await? == known { + fork = Some(h); + break; } } } - Request::LightUtxos { min_confirmations } => { - let guard = state.session.read().await; - match guard.as_ref() { - None => Response::Error(ErrorResponse { - message: "no GSP session — run `wraith gsp auth` first".to_string(), - }), - Some(s) => match s.handle.get_utxos(min_confirmations).await { - Ok(result) => { - let utxos = result - .utxos - .into_iter() - .map(|u| LightUtxoEntry { - txid: u.txid, - vout: u.vout, - amount_sats: u.amount_sats, - confirmations: u.confirmations, - script_type: u.script_type, - spendable: u.spendable, - }) - .collect(); - Response::LightUtxos(LightUtxosResponse { - utxos, - total_sats: result.total_sats, - }) - } - Err(e) => Response::Error(ErrorResponse { - message: format!("light utxos: {e}"), - }), - }, - } - } - Request::LightL1Utxos { - scan_max_index, - min_confirmations, - } => { - use std::collections::HashMap; - let scan_max = scan_max_index.min(1024); - let network = state.network; - // Derive 0..scan_max receive addresses from the active - // keystore. We need both the address (to send to - // ghost-pay) and the scriptPubKey (to attribute each - // returned UTXO back to its derivation index). - // - // Why scriptPubKey, not address: bitcoind's - // scantxoutset normalises `addr()` into - // `rawtr()` (or `wpkh()`, etc.) in - // its response — the address descriptor is not - // round-tripped. Matching on the canonical - // scriptPubKey instead avoids depending on which - // descriptor format bitcoind chooses to echo back. - #[derive(Clone)] - struct DerivedAddr { - address: String, - scriptpubkey_hex: String, - index: u32, - } - let derived: Result, String> = - with_active_wallet(state, |_, ks| { - let mut out = Vec::with_capacity(scan_max as usize); - for i in 0..scan_max { - let a = light::receive_address(ks, i, network) - .map_err(|e| format!("derive index {i}: {e}"))?; - let spk_hex = hex::encode(a.script_pubkey().as_bytes()); - out.push(DerivedAddr { - address: a.to_string(), - scriptpubkey_hex: spk_hex, - index: i, - }); - } - Ok(out) - }) - .await; - let pairs = match derived { - Ok(p) => p, - Err(e) => { - return Envelope::new(id, Response::Error(ErrorResponse { message: e })); - } - }; - // scriptpubkey_hex → (bip86_index, address). The - // canonical match key — see comment above. - let spk_to_idx: HashMap = pairs - .iter() - .map(|d| (d.scriptpubkey_hex.clone(), (d.index, d.address.clone()))) - .collect(); - let addresses: Vec = pairs.into_iter().map(|d| d.address).collect(); - let scan = match state - .chain() - .await - .scan_utxos(&addresses, min_confirmations) + let restart = fork.unwrap_or(floor); + let n = { + let hpath = wallet_history_path(state).await?; + let hlock = store_lock(state, &hpath); + let _hguard = hlock.lock().await; + let mut history = history_store_for(state).await?; + history + .unconfirm_from(restart + 1) + .map_err(|e| format!("history: {e}"))? + }; + tracing::warn!( + was = point.height, + restart_from = restart, + unconfirmed = n, + "chain reorganised under the scanner; rescanning" + ); + from = restart; + } + + if from >= tip { + return Ok(0); + } + let end = tip.min(from + SCAN_BATCH_BLOCKS); + // History is opened per write, not held across the batch. Each + // iteration below awaits several RPC round-trips, and a store held + // open across them keeps a snapshot that predates any payment made + // meanwhile — flushing it erases that payment. `record` merges on + // txid, so re-opening per write is also what makes the merge see + // what the other writer left. + for height in (from + 1)..=end { + let hash = block_hash_at(&rpc, height).await?; + let block = { + let rpc = rpc.clone(); + let h = hash.clone(); + tokio::task::spawn_blocking(move || rpc.get_block_with_prevouts(&h)) .await + .map_err(|e| format!("join: {e}"))? + .map_err(|e| format!("getblock {height}: {e}"))? + }; + // Silent payments first: a detection is also money arriving, and + // recording it as history below keeps one story rather than two. + if let Some(keys) = ghost_keys.as_ref() { + let mut found = Vec::new(); + for (txid, ephemeral, outputs) in + wraith_wallet_core::block_scan::candidates_in_block(&block) { - Ok(s) => s, - Err(e) => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!("ghost-pay scan: {e}"), - }), - ); + match wraith_wallet_core::candidate_scan::scan_candidate( + keys, + &ephemeral, + &outputs, + &txid, + Some(height), + ) { + Ok(hits) => found.extend(hits), + // A malformed announcement is somebody else's problem, + // not a reason to stop scanning the chain. + Err(e) => tracing::debug!(txid = %txid, error = %e, "candidate skipped"), } - }; - let utxos: Vec = scan - .utxos - .into_iter() - .filter_map(|u| { - // Match by scriptPubKey — independent of - // whether bitcoind echoed `addr(...)` or - // `rawtr(...)` in the response descriptor. - let (bip86_index, address) = - spk_to_idx.get(&u.scriptpubkey_hex).cloned()?; - Some(LightL1UtxoEntry { - txid: u.txid, - vout: u.vout, - amount_sats: u.amount_sats, - scriptpubkey_hex: u.scriptpubkey_hex, - bip86_index, - // Use the daemon-derived address — the - // ghost-pay-side parser may have lost it - // when the descriptor came back as - // rawtr(...). - address, - confirmations: u.confirmations, - height: u.height, - }) - }) - .collect(); - let total_sats = utxos.iter().map(|u| u.amount_sats).sum(); - Response::LightL1Utxos(LightL1UtxosResponse { - utxos, - total_sats, - chain_height: scan.chain_height, - scanned_max_index: scan_max, - }) - } - Request::LightDetected => { - let guard = state.session.read().await; - match guard.as_ref() { - None => Response::Error(ErrorResponse { - message: "no GSP session — run `wraith gsp auth` first".to_string(), - }), - Some(s) => { - let snap = s.handle.snapshot().await; - let detections = snap - .detections - .into_iter() - .map(|d| DetectedPaymentEntry { - txid: d.txid, - block_height: d.block_height, - vout: d.vout, - amount_sats: d.amount_sats, - k: d.k, - received_at: d.received_at, - }) - .collect(); - Response::LightDetected(LightDetectedResponse { detections }) + } + if !found.is_empty() { + let credited: i64 = found + .iter() + .filter_map(|d| d.amount_sats) + .fold(0i64, |a, v| a.saturating_add(v as i64)); + let txid = found[0].txid.clone(); + let n = record_detections(state, found).await?; + if n > 0 { + tracing::info!(height, coins = n, "silent payment detected"); + record_history( + state, + wraith_wallet_core::history_store::HistoryEntry { + txid, + at: block.time, + block_height: Some(height), + amount_sats: Some(credited), + // The sender paid the fee; the receiver of a + // silent payment has no way to know what it was + // and no reason to be charged for it on paper. + fee_sats: None, + kind: "receive".to_string(), + memo: None, + }, + ) + .await?; } } } - // Streaming subscription. handle_connection intercepts this before - // dispatch; reaching the dispatcher means the connection wasn't - // running our normal IPC loop. Fail loudly so misuse is obvious. - Request::WatchPayments => Response::Error(ErrorResponse { - message: "watch_payments must be sent on a fresh connection — \ - handled in handle_connection, not dispatch" - .to_string(), - }), - Request::DaemonEnv => { - let network = match state.network { - bitcoin::Network::Bitcoin => "mainnet", - bitcoin::Network::Signet => "signet", - bitcoin::Network::Testnet => "testnet", - bitcoin::Network::Regtest => "regtest", - _ => "unknown", - } - .to_string(); - let clients = state.clients.read().await; - Response::DaemonEnv(DaemonEnvResponse { - ghost_pay_urls: clients.ghost_pay_urls.clone(), - gsp_urls: clients.gsp_urls.clone(), - node_preset: clients.preset.clone(), - ghost_pay_env_override: state.ghost_pay_env_override, - gsp_env_override: state.gsp_env_override, - network, - wallets_dir: state.wallets_dir.display().to_string(), - tor_proxy: state.tor_proxy.clone(), - socket_path: state.endpoint_display.clone(), - idle_lock_secs: state.idle_lock_secs, - shroud_max_ms: state.shroud_max_ms, - update_manifest_url: state.update_manifest_url.clone(), - kiosk_mode: state.kiosk_mode, - }) - } - Request::SetNodeEndpoints { - preset, - ghost_pay_url, - gsp_url, - } => match state - .set_node_endpoints(&preset, ghost_pay_url, gsp_url) - .await - { - Ok(applied) => Response::NodeEndpointsSet(applied), - Err(message) => Response::Error(ErrorResponse { message }), - }, - Request::CheckForUpdate { manifest_url } => { - match check_for_update(state, manifest_url).await { - Ok(r) => Response::CheckForUpdate(r), - Err(message) => Response::Error(ErrorResponse { message }), - } - } - Request::LightHistory { limit, offset } => { - let guard = state.session.read().await; - match guard.as_ref() { - None => Response::Error(ErrorResponse { - message: "no GSP session — run `wraith gsp auth` first".to_string(), - }), - Some(s) => match s.handle.get_transactions(limit, offset).await { - Ok(result) => { - let transactions = result - .transactions - .into_iter() - .map(|t| LightHistoryEntry { - txid: t.txid, - block_height: t.block_height, - timestamp: t.timestamp, - amount_sats: t.amount_sats, - fee_sats: t.fee_sats, - tx_type: t.tx_type, - confirmations: t.confirmations, - memo: t.memo, - }) - .collect(); - Response::LightHistory(LightHistoryResponse { - transactions, - total_count: result.total_count, - }) - } - Err(e) => Response::Error(ErrorResponse { - message: format!("light history: {e}"), - }), + + for m in wraith_wallet_core::block_scan::scan_block(&block, &ours) { + record_history( + state, + wraith_wallet_core::history_store::HistoryEntry { + amount_sats: Some(m.net_sats()), + txid: m.txid, + at: m.time, + block_height: Some(m.height as u32), + fee_sats: m.fee_sats, + kind: if m.is_incoming { "receive" } else { "send" }.to_string(), + // The scanner cannot see a memo. `record` merges, so + // `None` here leaves any memo already recorded alone. + memo: None, }, - } + ) + .await?; } - Request::LightSend { - recipient, - amount_sats, - mode, - memo, - shroud_max_ms, - } => match light_send(state, recipient, amount_sats, mode, memo, shroud_max_ms).await { - Ok(r) => Response::LightSent(r), - Err(message) => Response::Error(ErrorResponse { message }), - }, - Request::LocksPrepare { capacity_sats } => { - let kp = match auth_keypair_for_session(state).await { - Ok(k) => k, - Err(message) => { - return Envelope::new(id, Response::Error(ErrorResponse { message })); - } - }; - let owner_pubkey = hex::encode(wraith_wallet_core::auth::xonly_pubkey_bytes(&kp)); - - // Derive the wallet's recovery_pubkey at the next free - // index. The matching recovery_secret stays in the - // wallet's keystore, never crossing the wire. This is - // what makes the timelock recovery branch a real - // unilateral exit: the operator holds the lock_pubkey - // (cooperative path), the user holds this - // recovery_pubkey's matching secret. - let recovery_index = state - .next_recovery_index - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - let active_name = match state.active.read().await.clone() { - Some(n) => n, - None => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: "no active wallet".into(), - }), - ); - } - }; - let recovery_pubkey_hex = match with_active_wallet(state, |_, ks| { - let ghost_keys = ks.ghost_keys().map_err(|e| format!("ghost_keys: {e}"))?; - let pk_bytes = ghost_keys - .derive_recovery_pubkey(recovery_index) - .map_err(|e| format!("derive_recovery_pubkey: {e}"))?; - Ok::(hex::encode(pk_bytes)) - }) - .await - { - Ok(s) => s, - Err(message) => { - return Envelope::new(id, Response::Error(ErrorResponse { message })); - } - }; + // Advanced per block, not per batch: an interrupted catch-up + // resumes where it stopped instead of re-reading from the start. + bookmark + .set(height, hash) + .map_err(|e| format!("scan state write: {e}"))?; + } + Ok(end - from) + } - let session = state.session.read().await; - let session = session.as_ref().expect("just checked above"); - match session - .handle - .prepare_ghost_lock( - owner_pubkey, - capacity_sats, - recovery_pubkey_hex.clone(), - recovery_index, - ) - .await - { - Ok(r) => { - // Belt-and-braces: server MUST echo the same - // recovery_pubkey we sent. If it doesn't, the - // operator has substituted its own key and the - // recovery path is no longer ours. Refuse. - if r.recovery_pubkey != recovery_pubkey_hex - || r.recovery_index != recovery_index - { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!( - "operator returned mismatched recovery key \ - (sent {} idx={}, got {} idx={}); refusing lock — \ - possible operator substitution attack", - recovery_pubkey_hex, - recovery_index, - r.recovery_pubkey, - r.recovery_index, - ), - }), - ); - } + /// The hash the node reports at `height`. + async fn block_hash_at( + rpc: &Arc, + height: u32, + ) -> Result { + let rpc = rpc.clone(); + tokio::task::spawn_blocking(move || rpc.get_block_hash(height as u64)) + .await + .map_err(|e| format!("join: {e}"))? + .map_err(|e| format!("get_block_hash {height}: {e}")) + } - // Stash everything LocksRecover will need. - state.prepared_locks.write().await.insert( - r.lock_id.clone(), - PreparedLockMeta { - wallet_name: active_name.clone(), - recovery_index, - lock_pubkey_hex: r.lock_pubkey.clone(), - recovery_pubkey_hex: r.recovery_pubkey.clone(), - recovery_blocks: r.recovery_blocks, - creation_height: r.creation_height, - funding_address: r.funding_address.clone(), - capacity_sats: r.required_sats, - funding_txid: None, - }, - ); - persist_prepared_locks(state, &active_name).await; + /// The hash the bookmark holds for `height`, if it is the bookmarked one. + /// + /// Only one height is remembered, so the reorg walk can confirm agreement + /// at exactly that point and otherwise falls back to rescanning the search + /// depth — which is correct, just more work. + async fn recorded_hash_at(state: &Arc, height: u32) -> Option { + let bookmark = scan_state_for(state).await.ok()?; + let p = bookmark.point()?; + (p.height == height).then(|| p.hash.clone()) + } - Response::LocksPrepared(LocksPreparedResponse { - lock_id: r.lock_id, - funding_address: r.funding_address, - required_sats: r.required_sats, - }) - } - Err(message) => Response::Error(ErrorResponse { - message: format!("locks prepare: {message}"), - }), - } + /// The coordinator election for the current epoch, verified. + /// + /// # Where it comes from, and what that costs + /// + /// A pool node publishes the draw at `/api/v1/pool/coordinator`. The + /// wallet used to reach that through Ghost Pay, precisely so it never + /// spoke to the pool itself; with the operator gone the choice is between + /// asking a pool directly and not rotating coordinators at all. A single + /// hard-coded coordinator URL defeats the point of the election, which + /// exists so coordination moves across the qualified set instead of + /// settling on whoever the wallet was shipped pointing at. + /// + /// So it asks, and pays for it in two ways that are worth naming: + /// + /// * The pool learns this IP asked. Route it through Tor if that matters — + /// the proxy is used when one is configured. + /// * The pool could learn *when* somebody is about to mix, if the ask + /// happened per mix. It does not: the result is cached for the whole + /// epoch (144 blocks, about a day), so the number of asks stops tracking + /// the number of mixes. + /// + /// # What is checked + /// + /// The draw is recomputed from the beacon and roster published beside it, + /// and the beacon is re-derived from the anchor block's hash **as the + /// wallet's own node reports it**. A pool that names itself every seat is + /// refused (#697). + /// + /// ⚠ The roster is still trusted. The published seat list must follow from + /// the roster, but nothing here proves the roster is the real qualified + /// set — a pool that omits honest candidates produces a self-consistent + /// election over a subset it prefers. Closing that needs the qualified set + /// to come from consensus, and it cannot come from the mesh node-list + /// checkpoint: that one carries *public-mining* nodes and their stratum + /// ports, while this draws from *coordinator*-opted-in nodes. ghost-pool + /// builds the coordinator roster from live mesh state and says so — + /// "the roster comes from live mesh state, which is the defect this value + /// exposes rather than repairs" — so two nodes can legitimately disagree, + /// and `roster_commitment` exists to make that visible. A trustless roster + /// needs its own BFT-finalised checkpoint on the pool side, with a height + /// gate and a fleet roll. Until then this is chain-anchored, not + /// trustless, and the difference is the roster. + async fn verified_election(state: &Arc) -> Option { + let pool_url = state.pool_url.read().await.clone()?; + + // Which epoch we are in, from our own node. Asking the pool would let + // it choose which epoch it answers for. + let tip = current_tip(state).await?; + let epoch = wraith_protocol::epoch_for_height(tip as u64); + + if let Some((cached_epoch, view)) = state.election_cache.read().await.as_ref() { + if *cached_epoch == epoch { + return Some(view.clone()); } - Request::LocksConfirm { - lock_id, - funding_txid, - } => { - let kp = match auth_keypair_for_session(state).await { - Ok(k) => k, - Err(message) => { - return Envelope::new(id, Response::Error(ErrorResponse { message })); - } - }; - let proof = match wraith_wallet_core::auth::make_proof(&kp, "confirm_lock") { - Ok(p) => p, - Err(e) => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!("confirm_lock proof: {e}"), - }), - ); - } - }; - let session = state.session.read().await; - let session = session.as_ref().expect("just checked above"); - match session - .handle - .confirm_ghost_lock_funding(lock_id, funding_txid, proof) - .await - { - Ok(r) => { - // Attach the funding txid to our local lock - // metadata so LocksRecover can spend the right - // outpoint without going back to the operator. - // Capture the wallet_name out of the meta so we - // can persist after dropping the write guard. - let wallet_to_persist = { - let mut guard = state.prepared_locks.write().await; - guard.get_mut(&r.lock_id).map(|m| { - m.funding_txid = Some(r.txid.clone()); - m.wallet_name.clone() - }) - }; - if let Some(wallet) = wallet_to_persist { - persist_prepared_locks(state, &wallet).await; - } - Response::LocksConfirmed(LocksConfirmedResponse { - lock_id: r.lock_id, - txid: r.txid, - block_height: r.block_height, - }) - } - Err(message) => Response::Error(ErrorResponse { - message: format!("locks confirm: {message}"), - }), + } + + let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(20)); + if let Some(proxy) = state.tor_proxy.as_deref() { + match reqwest::Proxy::all(proxy) { + Ok(p) => builder = builder.proxy(p), + // Refusing rather than falling back to a direct request: the + // user asked for Tor, and quietly revealing their IP instead + // is the one outcome they were trying to avoid. + Err(e) => { + tracing::warn!(error = %e, "tor proxy unusable; not asking the pool"); + return None; } } - Request::LocksJump { - lock_id, - target_address, - priority, - } => { - let priority = match parse_jump_priority(&priority) { - Ok(p) => p, - Err(message) => { - return Envelope::new(id, Response::Error(ErrorResponse { message })); - } - }; - let kp = match auth_keypair_for_session(state).await { - Ok(k) => k, - Err(message) => { - return Envelope::new(id, Response::Error(ErrorResponse { message })); - } - }; - let proof = match wraith_wallet_core::auth::make_proof(&kp, "request_jump") { - Ok(p) => p, - Err(e) => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!("request_jump proof: {e}"), - }), - ); - } - }; - let session = state.session.read().await; - let session = session.as_ref().expect("just checked above"); - match session - .handle - .request_jump(lock_id, priority, target_address, proof) - .await - { - Ok(r) => Response::LocksJumped(LocksJumpedResponse { - lock_id: r.lock_id, - jump_txid: r.jump_txid, - }), - Err(message) => Response::Error(ErrorResponse { - message: format!("locks jump: {message}"), - }), + } + let client = builder.build().ok()?; + let url = format!("{}/api/v1/pool/coordinator", pool_url.trim_end_matches('/')); + let election: serde_json::Value = match client.get(&url).send().await { + Ok(r) => match r.json().await { + Ok(v) => v, + Err(e) => { + tracing::debug!(error = %e, "election view was not JSON"); + return None; } + }, + Err(e) => { + tracing::debug!(error = %e, "could not reach the pool for the election"); + return None; } - Request::LocksRecover { - lock_id, - destination_address, - fee_sats, - } => { - use wraith_wallet_core::ghostd::GhostdRpc; - use wraith_wallet_core::lock_recovery::{ - build_recovery_spend, RecoverySpendInputs, - }; + }; - // 1. bitcoind must be configured. Without it the - // recovery path can't reach L1 — this is the only - // IPC method that talks straight to bitcoind. - let url = match state.ghostd_url.as_deref() { - Some(u) => u, - None => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: "no bitcoind RPC configured \ - (set WRAITHD_GHOSTD_URL + WRAITHD_GHOSTD_COOKIE \ - or WRAITHD_GHOSTD_USER+PASS)" - .into(), - }), - ); - } - }; - let rpc_result = match ( - state.ghostd_cookie_path.as_ref(), - state.ghostd_user.as_deref(), - state.ghostd_pass.as_deref(), - ) { - (Some(cookie), None, None) => GhostdRpc::from_cookie(url, cookie), - (None, Some(u), Some(p)) => Ok(GhostdRpc::new(url, u, p)), - _ => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: "bitcoind auth misconfigured: supply either \ - cookie path or user+pass, not both / neither" - .into(), - }), - ); - } - }; - let rpc = match rpc_result { - Ok(r) => r, - Err(e) => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!("bitcoind init: {e}"), - }), - ); - } - }; + if election.get("enabled").and_then(|v| v.as_bool()) != Some(true) { + tracing::debug!("the pool has coordinator elections turned off"); + return None; + } - // 2. Pull the prepared-lock metadata from our local - // stash. Without it we can't reconstruct the - // witness script or know which recovery_secret to - // sign with. - let meta = match state.prepared_locks.read().await.get(&lock_id).cloned() { - Some(m) => m, - None => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: missing_lock_metadata_error(&lock_id), - }), - ); - } - }; - let funding_txid = match meta.funding_txid.clone() { - Some(t) => t, - None => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!( - "lock '{lock_id}' has no recorded funding txid \ - (call locks confirm first)" - ), - }), - ); - } - }; + // Pin the beacon to the chain. Verifying the draw against the beacon + // published beside it only proves internal consistency; the anchor + // block's hash is a fact the pool does not get to state. + let (anchor_height, _) = crate::coordinator_resolve::beacon_anchor_expectation(&election)?; + let rpc = state.build_ghostd_rpc().await?; + let anchor_hash = tokio::task::spawn_blocking(move || rpc.get_block_hash(anchor_height)) + .await + .ok()? + .ok()?; + if !crate::coordinator_resolve::beacon_matches_chain(&election, &anchor_hash) { + tracing::warn!( + anchor_height, + "the published election beacon does not follow from the anchor block — \ + refusing the election" + ); + return None; + } - // 3. Resolve the funding outpoint via bitcoind. Walk - // the tx vouts for one whose address matches our - // funding_address. (P2WSH addresses are unique - // per script so a single match is all we need.) - let raw_tx = match rpc.get_raw_transaction_verbose(&funding_txid) { - Ok(t) => t, - Err(e) => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!("bitcoind getrawtransaction: {e}"), - }), - ); - } - }; - let matching_vout = raw_tx.vout.iter().find(|v| { - v.script_pubkey.first_address() == Some(meta.funding_address.as_str()) - }); - let vout = match matching_vout { - Some(v) => v, - None => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!( - "funding tx {funding_txid} has no output \ - paying lock address {}", - meta.funding_address - ), - }), - ); - } - }; + // A draw over one candidate is not a draw. Said plainly, because a + // wallet that mixes through a single-node "election" has the privacy + // of not mixing at all and no way to tell (#708). + if election.get("degraded").and_then(|v| v.as_bool()) == Some(true) { + tracing::warn!( + roster_size = election.get("roster_size").and_then(|v| v.as_u64()), + "the coordinator election is degraded — too few candidates for the draw \ + to mean anything" + ); + } - // 4. Maturity check. - let current_height = match rpc.get_block_count() { - Ok(h) => h as u32, - Err(e) => { - return Envelope::new( - id, - Response::Error(ErrorResponse { - message: format!("bitcoind getblockcount: {e}"), - }), - ); - } - }; + *state.election_cache.write().await = Some((epoch, election.clone())); + Some(election) + } - // 5. Build the recovery tx using the wallet's own - // recovery_secret. with_active_wallet locks the - // keystore briefly for the (sync) sighash + ECDSA - // sign step. - let prev_value_sats = vout.value_sats(); - let funding_scriptpubkey_hex = vout.script_pubkey.hex.clone(); - let funding_vout_n = vout.n; - let recovery_index = meta.recovery_index; - let inputs = RecoverySpendInputs { - lock_pubkey_hex: meta.lock_pubkey_hex.clone(), - recovery_pubkey_hex: meta.recovery_pubkey_hex.clone(), - recovery_blocks: meta.recovery_blocks, - funding_txid: funding_txid.clone(), - funding_vout: funding_vout_n, - prev_value_sats, - funding_scriptpubkey_hex, - destination_address: destination_address.clone(), - fee_sats, - network: state.network, - current_height, - creation_height: meta.creation_height, - }; + /// Derive a Lock's four lanes from the supplied keys plus the active + /// wallet's owner key. + /// + /// One definition, deliberately. `GhostLockLanes` and + /// `GhostLockRoundDestination` must not be able to derive different + /// addresses for the same Lock — a round paying into an address the + /// balance view does not watch would look exactly like a lost deposit. + async fn build_lock_account( + state: &Arc, + backup_pubkey: &str, + heir_pubkey: &str, + quorum_pubkey: &str, + inherit_height: u32, + anchor_height: u32, + bip86_index: Option, + ) -> Result { + use bitcoin::secp256k1::Secp256k1; + use bitcoin::XOnlyPublicKey; + use std::str::FromStr; + use wraith_wallet_core::ghost_lock_account::{GhostLockAccount, LockKeys}; + + fn xonly(label: &str, hexstr: &str) -> Result { + XOnlyPublicKey::from_str(hexstr.trim()) + .map_err(|e| format!("{label} is not an x-only public key: {e}")) + } + + // The owner key comes from the active keystore; the backup, heir and + // quorum keys are supplied. The two MuSig2 aggregates are DERIVED + // below, not supplied — BIP-327 key aggregation is a deterministic + // function of the public keys, so no ceremony and no other party + // online is needed to CREATE a Lock. Interaction is only required to + // SIGN a key-path spend. + let idx = bip86_index.unwrap_or(0); + let network = state.network; + let owner = with_active_wallet(state, move |_, ks| { + let path = lock_owner_path(idx); + let xprv = ks.derive_xprv(&path).map_err(|e| format!("derive: {e}"))?; + let secp = Secp256k1::new(); + let sk = bitcoin::secp256k1::SecretKey::from_slice(&xprv.private_key().to_bytes()) + .map_err(|e| format!("owner key: {e}"))?; + Ok::( + bitcoin::secp256k1::Keypair::from_secret_key(&secp, &sk) + .x_only_public_key() + .0, + ) + }) + .await?; + + let backup = xonly("backup_pubkey", backup_pubkey)?; + let quorum = xonly("quorum_pubkey", quorum_pubkey)?; + + // Derived here rather than accepted from the caller. BIP-327 + // aggregation is deterministic, so both sides reach the same answer + // independently — and a pasted aggregate that did not match its parts + // would build a Lock whose key path nobody can satisfy, with nothing + // noticing until a spend failed. + let owner_backup_aggregate = ghost_lock::aggregate(&[owner, backup]) + .map_err(|e| format!("owner+backup aggregate: {e}"))?; + let owner_quorum_aggregate = ghost_lock::aggregate(&[owner, quorum]) + .map_err(|e| format!("owner+quorum aggregate: {e}"))?; + + let keys = LockKeys { + owner, + backup, + heir: xonly("heir_pubkey", heir_pubkey)?, + owner_backup_aggregate, + owner_quorum_aggregate, + quorum, + }; + let secp = Secp256k1::verification_only(); + GhostLockAccount::build(&secp, &keys, network, anchor_height, inherit_height) + .map_err(|e| format!("lock: {e}")) + } + + fn unhex32(label: &str, s: &str) -> Result<[u8; 32], String> { + let raw = hex::decode(s.trim()).map_err(|e| format!("{label} is not hex: {e}"))?; + raw.try_into() + .map_err(|_| format!("{label} must be 32 bytes")) + } + + fn unhex66(label: &str, s: &str) -> Result<[u8; 66], String> { + let raw = hex::decode(s.trim()).map_err(|e| format!("{label} is not hex: {e}"))?; + raw.try_into() + .map_err(|_| format!("{label} must be 66 bytes")) + } - let built = match with_active_wallet(state, |_, ks| { - let ghost_keys = ks.ghost_keys().map_err(|e| format!("ghost_keys: {e}"))?; - let recovery_secret = ghost_keys - .derive_recovery_secret(recovery_index) - .map_err(|e| format!("derive_recovery_secret: {e}"))?; - build_recovery_spend(&inputs, &recovery_secret) - .map_err(|e| format!("build recovery: {e}")) + fn lock_spend_summary(s: &ghost_lock::airgap::SpendSummary) -> LockSpendSummary { + LockSpendSummary { + input_index: s.input_index, + input_sats: s.input_sats, + input_address: s.input_address.clone(), + outputs: s + .outputs + .iter() + .map(|o| LockSpendOutput { + address: o.address.clone(), + sats: o.sats, }) - .await - { - Ok(b) => b, - Err(message) => { - return Envelope::new(id, Response::Error(ErrorResponse { message })); - } - }; + .collect(), + fee_sats: s.fee_sats, + input_count: s.input_count, + } + } - // 6. Broadcast. - match rpc.send_raw_transaction(&built.raw_hex) { - Ok(network_txid) => { - tracing::info!( - %lock_id, - broadcast_txid = %network_txid, - recovered_sats = prev_value_sats - fee_sats, - "lock recovery broadcast — unilateral exit complete", - ); - // The lock is spent — drop it from the stash - // so subsequent recovery attempts on the same - // lock_id fail cleanly. Persist the change. - let wallet_to_persist = state - .prepared_locks - .write() - .await - .remove(&lock_id) - .map(|m| m.wallet_name); - if let Some(wallet) = wallet_to_persist { - persist_prepared_locks(state, &wallet).await; - } - Response::LocksRecovered(LocksRecoveredResponse { - lock_id, - broadcast_txid: network_txid, - destination_address, - recovered_sats: prev_value_sats - fee_sats, - fee_sats, - }) - } - Err(e) => Response::Error(ErrorResponse { - message: format!("bitcoind sendrawtransaction: {e}"), - }), - } + /// Load a remembered Lock, derive its lanes, and resolve the named lane. + /// + /// Shares `build_lock_account` with the balance view and the round + /// destination, so all three derive one Lock's addresses identically. + async fn lock_lane_for( + state: &Arc, + lock_id: &str, + lane: &str, + ) -> Result< + ( + wraith_wallet_core::ghost_lock_account::GhostLockAccount, + wraith_wallet_core::ghost_lock_account::LaneKind, + wraith_wallet_core::ghost_lock_store::StoredLock, + ), + String, + > { + let kind = parse_lane(lane)?; + let (account, record) = lock_account_for(state, lock_id).await?; + Ok((account, kind, record)) + } + + /// Rebuild one remembered Lock's four lanes. + async fn lock_account_for( + state: &Arc, + lock_id: &str, + ) -> Result< + ( + wraith_wallet_core::ghost_lock_account::GhostLockAccount, + wraith_wallet_core::ghost_lock_store::StoredLock, + ), + String, + > { + let record = { + let store = ghost_lock_store_for(state).map_err(|e| format!("lock store: {e}"))?; + store + .get(lock_id) + .cloned() + .ok_or_else(|| format!("no remembered Lock '{lock_id}'"))? + }; + let account = build_lock_account( + state, + &record.backup_pubkey, + &record.heir_pubkey, + &record.quorum_pubkey, + record.inherit_height, + record.anchor_height, + Some(record.bip86_index), + ) + .await?; + Ok((account, record)) + } + + /// Which compartment a coin belongs to, across every remembered Lock. + /// + /// `None` when the script matches no lane of any Lock — a loose wallet + /// coin, which the compartment rules do not speak about. + /// + /// A Lock whose lanes cannot be rebuilt right now (wallet locked, record + /// malformed) is skipped rather than reported as "not a lane". Treating an + /// unknown as a refusal would block ordinary mixing whenever a Lock is + /// unreadable, and treating it as Private would be a claim we cannot + /// support; skipping keeps this a best-effort classifier, with the + /// authoritative recount still ahead at `inspect`. + async fn compartment_of_script( + state: &Arc, + scriptpubkey_hex: &str, + ) -> Option { + lane_of_script(state, scriptpubkey_hex) + .await + .map(|(_, kind)| kind.compartment()) + } + + /// Which Lock and lane a coin sits in, if any. + /// + /// The lane matters and not just the compartment: two coins in different + /// lanes of one Lock are in the same compartment, and spending them + /// together still collapses the separation the lanes exist to create. + async fn lane_of_script( + state: &Arc, + scriptpubkey_hex: &str, + ) -> Option<(String, wraith_wallet_core::ghost_lock_account::LaneKind)> { + let want = scriptpubkey_hex.trim(); + let ids: Vec = { + let store = ghost_lock_store_for(state).ok()?; + store.list().iter().map(|l| l.lock_id.clone()).collect() + }; + for id in ids { + let Ok((account, _)) = lock_account_for(state, &id).await else { + continue; + }; + for built in &account.lanes { + let spk = hex::encode(built.lane.address.script_pubkey().as_bytes()); + if spk.eq_ignore_ascii_case(want) { + return Some((id, built.kind)); + } + } + } + None + } + + /// Refuse a Lock spend that reaches outside the lane being signed. + /// + /// `check_spend_together` states the Cash boundary. This states the + /// stricter rule the lanes actually need, and it rests on the same + /// sentence that rule is built on: spending two coins together proves they + /// share an owner. That is true of any two coins, not only of a Cash coin + /// beside a private one. + /// + /// A transaction spending a Savings coin beside a Spending coin collapses + /// two lanes into one, even though both are `Compartment::Private`. Beside + /// an ordinary account-1'-less wallet coin it ties the lane to the public + /// wallet. Neither is caught by the compartment rule, and both are exactly + /// the linkage the Lock exists to prevent. + /// + /// So every input must sit in the same lane of the same Lock as the one + /// being signed. An input whose previous output the PSBT does not carry is + /// refused too: it cannot be shown to be in the lane, and for a rule about + /// what a signature reveals, unproven is not good enough. + async fn refuse_if_spend_leaves_the_lane( + state: &Arc, + psbt: &bitcoin::psbt::Psbt, + lock_id: &str, + lane: wraith_wallet_core::ghost_lock_account::LaneKind, + ) -> Option { + for i in 0..psbt.inputs.len() { + let Some(txout) = psbt_input_value(psbt, i) else { + return Some(format!( + "input {i} carries no previous output, so it cannot be shown to be \ + in the {lane:?} lane; refusing rather than signing a transaction \ + whose other inputs are unknown" + )); + }; + let spk = hex::encode(txout.script_pubkey.as_bytes()); + match lane_of_script(state, &spk).await { + Some((id, kind)) if id == lock_id && kind == lane => {} + Some((id, kind)) => { + return Some(format!( + "input {i} is in the {kind:?} lane of Lock {id}, not the {lane:?} \ + lane of Lock {lock_id}; spending them together proves one owner \ + and collapses the separation the lanes exist to create" + )); + } + None => { + return Some(format!( + "input {i} is not in any remembered Lock lane; spending it \ + alongside a {lane:?} coin ties that lane to the rest of the \ + wallet" + )); + } + } + } + None + } + + /// Every check a Lock spend must pass before a signature exists. + /// + /// One place, because there are three handlers that sign an input of a + /// PSBT the caller supplied — quorum co-sign, escape spend, and the + /// air-gapped key-path spend — and a rule applied to two of them is a rule + /// with a way around it. + async fn refuse_unsafe_lock_spend( + state: &Arc, + psbt_str: &str, + lock_id: &str, + lane: &str, + ) -> Option { + let kind = match parse_lane(lane) { + Ok(k) => k, + Err(e) => return Some(e), + }; + let parsed = match wraith_wallet_core::psbt::decode_psbt(psbt_str) { + Ok((p, _)) => p, + Err(e) => return Some(format!("decode psbt: {e}")), + }; + if let Some(reason) = refuse_if_spend_links_compartments(state, &parsed).await { + return Some(reason); + } + refuse_if_spend_leaves_the_lane(state, &parsed, lock_id, kind).await + } + + /// Refuse a spend that would link two compartments. + /// + /// Rule 3 of `ghost_lock::compartment`, which had no callers at all + /// outside its own tests. It is reachable here because the Lock signing + /// handlers sign one input of a PSBT the **caller** built — so the caller + /// chooses the other inputs. A transaction spending a Cash coin alongside + /// a private-lane coin publishes the link between them, undoing the + /// separation the lanes exist to create, in a transaction this wallet + /// signed itself. + /// + /// Inputs that belong to no Lock lane are not counted. The rule is stated + /// over compartments, and a loose wallet coin is not in one. That leaves a + /// real gap — spending a Savings coin beside an ordinary account-0' coin + /// still links Savings to the public wallet — but closing it means + /// deciding that non-Lock coins are Cash-like, which is a policy choice + /// this function is not the place to make silently. + async fn refuse_if_spend_links_compartments( + state: &Arc, + psbt: &bitcoin::psbt::Psbt, + ) -> Option { + let mut compartments = Vec::new(); + for i in 0..psbt.inputs.len() { + let Some(txout) = psbt_input_value(psbt, i) else { + continue; + }; + let spk = hex::encode(txout.script_pubkey.as_bytes()); + if let Some(c) = compartment_of_script(state, &spk).await { + compartments.push(c); + } + } + ghost_lock::check_spend_together(&compartments) + .err() + .map(|e| e.to_string()) + } + + /// Refuse a coin the compartment rule keeps out of rounds. + /// + /// Rule 1 of `ghost_lock::compartment`, which until now was computed only + /// to colour a lane in the UI and never actually enforced. A Cash coin is + /// public by design, and mixing one re-links the strangers it is mixed + /// with — their problem, not the owner's, which is why the wallet must not + /// leave it to the user. + /// + /// Enforceable only since Lock keys moved off the plain wallet's account: + /// while the Cash lane was the wallet's own receive address, this would + /// have refused every ordinary coin. + async fn refuse_if_not_round_eligible( + state: &Arc, + scriptpubkey_hex: &str, + ) -> Option { + let compartment = compartment_of_script(state, scriptpubkey_hex).await?; + ghost_lock::check_round_eligible(compartment) + .err() + .map(|e| e.to_string()) + } + + /// Every receive address the wallet would use, up to `scan_max`. + /// + /// Shared by the UTXO list and the balance, so the two cannot be computed + /// over different address sets and disagree about how much money there is. + async fn derived_receive_addresses( + state: &Arc, + scan_max: u32, + ) -> Result, String> { + let network = state.network; + with_active_wallet(state, move |_, ks| { + let mut out = Vec::with_capacity(scan_max as usize); + for i in 0..scan_max { + let a = light::receive_address(ks, i, network) + .map_err(|e| format!("derive index {i}: {e}"))?; + let spk = hex::encode(a.script_pubkey().as_bytes()); + out.push((i, a.to_string(), spk)); + } + Ok(out) + }) + .await + } + + /// The wallet's on-chain balance, from the configured chain backend. + /// + /// Settled and unsettled are summed separately and never added together: + /// money that can still vanish must not read as money you have. That is + /// the same rule the Lock lanes follow, and for the same reason. + async fn l1_balance( + state: &Arc, + scan_max: u32, + ) -> Result<(u64, u64, u32), String> { + let pairs = derived_receive_addresses(state, scan_max).await?; + let addresses: Vec = pairs.into_iter().map(|(_, a, _)| a).collect(); + // Scanned at zero confirmations, then split here — one round trip + // gives both figures, where two scans could disagree with each other. + let scan = state + .chain() + .await + .scan_utxos(&addresses, 0) + .await + .map_err(|e| format!("scan: {e}"))?; + let mut confirmed = 0u64; + let mut unconfirmed = 0u64; + for u in &scan.utxos { + if u.confirmations == 0 { + unconfirmed = unconfirmed.saturating_add(u.amount_sats); + } else { + confirmed = confirmed.saturating_add(u.amount_sats); + } + } + Ok((confirmed, unconfirmed, scan.chain_height)) + } + + /// The wallet's spendable outputs, from the node. + /// + /// Shares its derivation and scan with [`l1_balance`], so the balance and + /// the coin list can never disagree about which coins exist — they are two + /// readings of one answer, not two questions asked separately. + async fn l1_utxo_entries( + state: &Arc, + scan_max: u32, + min_confirmations: u32, + ) -> Result<(Vec, u64), String> { + let pairs = derived_receive_addresses(state, scan_max).await?; + let addresses: Vec = pairs.into_iter().map(|(_, a, _)| a).collect(); + let scan = state + .chain() + .await + .scan_utxos(&addresses, min_confirmations) + .await + .map_err(|e| format!("scan: {e}"))?; + let mut total = 0u64; + let mut out = Vec::with_capacity(scan.utxos.len()); + for u in scan.utxos { + total = total.saturating_add(u.amount_sats); + out.push(LightUtxoEntry { + txid: u.txid, + vout: u.vout, + amount_sats: u.amount_sats, + confirmations: u.confirmations, + // Every address the wallet derives is BIP86 taproot; the scan + // only looked at those, so anything it returned is one. + script_type: "p2tr".to_string(), + // The scan filtered on confirmations already, and these are + // the wallet's own single-key outputs. + spendable: true, + }); + } + Ok((out, total)) + } + + /// Transaction history from the wallet's own record, confirmed against the + /// node. + /// + /// # What this can and cannot show + /// + /// Both directions, now that the block scanner runs: what the wallet sent, + /// recorded at broadcast, and what arrived, recorded when a block carrying + /// it was read. + /// + /// The one gap is what happened before the scanner started watching. It + /// begins at the tip on first run rather than reading the chain from + /// genesis, so a restored wallet's older payments are absent from the + /// history. They are not absent from the wallet: the balance and the UTXO + /// list scan the whole UTXO set and see every coin. It is a narrower claim + /// than it used to be, and a stated one. + async fn l1_history(state: &Arc, limit: u32, offset: u32) -> Response { + let store = match history_store_for(state).await { + Ok(s) => s, + Err(message) => return Response::Error(ErrorResponse { message }), + }; + let all = store.list(); + let total_count = all.len() as u32; + let page: Vec<_> = all + .into_iter() + .skip(offset as usize) + .take(limit as usize) + .collect(); + + let chain = state.chain().await; + // One tip read for the whole page. Confirmations are derived from the + // height the scanner recorded, so a settled history costs a single + // round trip rather than one per row. + let tip = chain.status().await.ok().and_then(|s| s.chain_height); + + let mut transactions = Vec::with_capacity(page.len()); + for e in page { + let confirmations = match (e.block_height, tip) { + // Inclusive of the block it landed in: an entry in the tip + // block has one confirmation, not zero. That off-by-one is the + // difference between "spendable" and "invisible". + (Some(h), Some(t)) if t >= h as u64 => Some((t - h as u64 + 1) as u32), + // Mined deeper than the tip we just read means the tip moved + // backwards under us — a reorg the scanner has not caught up + // with yet. Unknown is the honest answer for one tick. + (Some(_), Some(_)) => None, + (Some(_), None) => None, + // Never seen in a block. Ask the node whether it at least + // holds the transaction, which distinguishes "in the mempool" + // from "the node has never heard of this". + (None, _) => chain.tx_confirmations(&e.txid).await.unwrap_or(None), + }; + transactions.push(LightHistoryEntry { + txid: e.txid, + block_height: e.block_height, + timestamp: e.at, + amount_sats: e.amount_sats, + fee_sats: e.fee_sats, + tx_type: e.kind, + confirmations, + memo: e.memo, + }); + } + Response::LightHistory(LightHistoryResponse { + transactions, + total_count, + }) + } + + /// One lane-name parser, so every caller accepts the same words. + fn parse_lane(lane: &str) -> Result { + use wraith_wallet_core::ghost_lock_account::LaneKind; + match lane.trim().to_ascii_lowercase().as_str() { + "savings" => Ok(LaneKind::Savings), + "spending" => Ok(LaneKind::Spending), + "cash" => Ok(LaneKind::Cash), + "investments" => Ok(LaneKind::Investments), + other => Err(format!( + "unknown lane '{other}' (try savings, spending, cash, investments)" + )), + } + } + + /// Resolve a lane and the escape its owner can take. + /// + /// Refuses the lanes with no owner escape by name. Cash has no leaves at + /// all — it is the owner's key on the key path, so there is nothing to + /// escape from. + async fn lock_escape_for( + state: &Arc, + lock_id: &str, + lane: &str, + ) -> Result< + ( + wraith_wallet_core::ghost_lock_account::GhostLockAccount, + wraith_wallet_core::ghost_lock_account::LaneKind, + wraith_wallet_core::ghost_lock_store::StoredLock, + ghost_lock::escape::OwnerEscape, + ), + String, + > { + use ghost_lock::escape::OwnerEscape; + use wraith_wallet_core::ghost_lock_account::LaneKind; + + // Resolve the lane BEFORE loading the Lock. Both orders are correct; + // only this one is useful. Asking about Cash with an unknown lock_id + // should say Cash has no escape, not that the Lock is missing — the + // second answer sends someone looking for the wrong problem. + let kind = parse_lane(lane)?; + let escape = match kind { + LaneKind::Savings => OwnerEscape::SavingsRecovery, + LaneKind::Spending => OwnerEscape::SpendingExit, + LaneKind::Investments => OwnerEscape::InvestmentsRecall, + LaneKind::Cash => { + return Err( + "Cash has no escape leaf: it already spends with your key alone, so \ + there is nothing to wait for" + .into(), + ) + } + }; + let (account, kind, record) = lock_lane_for(state, lock_id, lane).await?; + Ok((account, kind, record, escape)) + } + + /// Decode a base64 PSBT and pull out every prevout. + /// + /// Every one, because a Taproot sighash commits to all of them. A missing + /// prevout is refused rather than defaulted: the signature would be over a + /// transaction different from the one presented. + fn decode_psbt_with_prevouts( + psbt_b64: &str, + ) -> Result<(bitcoin::psbt::Psbt, Vec), String> { + use base64::Engine as _; + let raw = base64::engine::general_purpose::STANDARD + .decode(psbt_b64.trim()) + .map_err(|e| format!("psbt is not base64: {e}"))?; + let psbt = bitcoin::psbt::Psbt::deserialize(&raw).map_err(|e| format!("psbt: {e}"))?; + let mut prevouts = Vec::with_capacity(psbt.inputs.len()); + for (i, input) in psbt.inputs.iter().enumerate() { + let utxo = input.witness_utxo.as_ref().ok_or_else(|| { + format!( + "input {i} has no witness_utxo, so its value and script are unknown — \ + the Taproot sighash commits to every input, so this cannot be signed \ + correctly" + ) + })?; + prevouts.push(utxo.clone()); + } + Ok((psbt, prevouts)) + } + + /// Which keys spend a lane by its key path. + /// + /// Not one answer for the whole Lock: each lane's Taproot internal key is a + /// different thing, and signing under the wrong pair produces a signature + /// that fails against the address with nothing to say why. + /// + /// * **Savings** — MuSig2 of owner + backup. The air-gapped case. + /// * **Spending** — MuSig2 of owner + quorum. Networked. + /// * **Cash** — the owner's key alone. Ordinary single-sig; a MuSig2 + /// ceremony here would be two rounds of theatre. + /// * **Investments** — the quorum's key alone. The owner cannot spend it + /// by the key path at all; the owner's route out is the recall leaf. + fn lane_cosigners( + kind: wraith_wallet_core::ghost_lock_account::LaneKind, + owner: bitcoin::XOnlyPublicKey, + record: &wraith_wallet_core::ghost_lock_store::StoredLock, + ) -> Result, String> { + use std::str::FromStr; + use wraith_wallet_core::ghost_lock_account::LaneKind; + let parse = |label: &str, hexstr: &str| { + bitcoin::XOnlyPublicKey::from_str(hexstr.trim()) + .map_err(|e| format!("{label} is not an x-only public key: {e}")) + }; + match kind { + LaneKind::Savings => Ok(vec![owner, parse("backup_pubkey", &record.backup_pubkey)?]), + LaneKind::Spending => Ok(vec![owner, parse("quorum_pubkey", &record.quorum_pubkey)?]), + LaneKind::Cash => Err( + "Cash spends with your key alone — sign it as an ordinary single-sig input, \ + not through a MuSig2 ceremony" + .into(), + ), + LaneKind::Investments => Err( + "Investments spends by the quorum's key alone, so there is no key path for \ + you to co-sign; your route out is the recall leaf after its delay" + .into(), + ), + } + } + + /// Derivation path for a Ghost Lock's owner key. + /// + /// Account `1'`, deliberately not the account the plain wallet receives and + /// spends on. + /// + /// These keys used to share account `0'` with `light receive`, and the Cash + /// lane is a bare key-path output for the owner key — so the Cash lane came + /// out byte-identical to the wallet's receive address at the same index. + /// One coin then appeared in both the wallet's balance and the Lock's + /// total, and the compartment rule that a Cash coin must never enter a + /// round could not be enforced without refusing every ordinary coin along + /// with it. + /// + /// A Lock coin and a loose coin are now different coins. + fn lock_owner_path(index: u32) -> String { + // One definition, in `light`, because the ordinary PSBT signer has to + // walk this same family to sign a Cash lane input. + wraith_wallet_core::light::lock_owner_path(index) + } + + /// The owner's signing key for a Lock, from the active keystore. + async fn lock_owner_seckey( + state: &Arc, + bip86_index: u32, + ) -> Result { + with_active_wallet(state, move |_, ks| { + let path = lock_owner_path(bip86_index); + let xprv = ks.derive_xprv(&path).map_err(|e| format!("derive: {e}"))?; + bitcoin::secp256k1::SecretKey::from_slice(&xprv.private_key().to_bytes()) + .map_err(|e| format!("owner key: {e}")) + }) + .await + } + + /// Attach a finished key-path signature to the PSBT input. + /// + /// A key-path spend's witness is the signature and nothing else, so this is + /// the whole of finalisation for that input. + fn attach_key_path_signature( + psbt_b64: &str, + input_index: u32, + sig: &bitcoin::secp256k1::schnorr::Signature, + ) -> Result { + use base64::Engine as _; + let raw = base64::engine::general_purpose::STANDARD + .decode(psbt_b64.trim()) + .map_err(|e| format!("psbt is not base64: {e}"))?; + let mut psbt = bitcoin::psbt::Psbt::deserialize(&raw).map_err(|e| format!("psbt: {e}"))?; + let idx = input_index as usize; + let input = psbt + .inputs + .get_mut(idx) + .ok_or_else(|| format!("input {idx} does not exist"))?; + input.tap_key_sig = Some(bitcoin::taproot::Signature { + signature: *sig, + sighash_type: bitcoin::TapSighashType::Default, + }); + let mut witness = bitcoin::Witness::new(); + witness.push(sig.serialize()); + input.final_script_witness = Some(witness); + Ok(base64::engine::general_purpose::STANDARD.encode(psbt.serialize())) + } + + async fn dispatch(line: &str, state: &Arc) -> Envelope { + let parsed: Result, _> = serde_json::from_str(line); + let (id, request) = match parsed { + Ok(env) => (env.id, env.payload), + Err(e) => { + return Envelope::new( + 0, + Response::Error(ErrorResponse { + message: format!("malformed request: {e}"), + }), + ); + } + }; + + // Bump the idle-lock timer for user-facing requests. Diagnostics + // (Health, Doctor, DaemonEnv) and WatchPayments don't count. + if is_activity(&request) { + state + .last_activity + .store(now_unix_secs(), std::sync::atomic::Ordering::Relaxed); + } + + let response = match request { + Request::Health => Response::Health(HealthResponse { + daemon_version: env!("CARGO_PKG_VERSION").to_string(), + uptime_secs: state.started.elapsed().as_secs(), + }), + Request::Doctor => Response::Doctor(doctor_run(state).await), + Request::ChainStatus => match state.chain().await.status().await { + Ok(s) => Response::ChainStatus(ChainStatusResponse { + backend_version: s.backend_version, + network: s.network, + chain_height: s.chain_height, + chain_headers: s.chain_headers, + chain_verification_progress: s.chain_verification_progress, + chain_initial_block_download: s.chain_initial_block_download, + }), + Err(e) => Response::Error(ErrorResponse { + message: format!("chain: {e}"), + }), + }, + Request::ConnectionStatus => { + // One probe answers "is the node reachable" AND supplies the + // chain fields. An unreachable node is reported as a field + // rather than as an error: the point of this call is a header + // that says "unreachable" instead of spinning forever. + let node_configured = state.ghostd().await.url.is_some(); + let (node_reachable, node_version, node_error, chain_height, chain_headers, ibd) = + match state.chain().await.status().await { + Ok(s) => ( + true, + Some(s.backend_version), + None, + s.chain_height, + s.chain_headers, + s.chain_initial_block_download, + ), + // With no node configured there is nothing to be + // unreachable, and `NoChain`'s refusal is a setup + // instruction rather than a probe failure — so it is + // not reported as one. + Err(e) => ( + false, + None, + node_configured.then(|| format!("{e}")), + None, + None, + None, + ), + }; + // Same rule the GUI's SyncIndicator uses: verified height has + // caught the header tip (or headers unknown) AND the node is + // out of initial block download. + let chain_synced = node_reachable + && chain_height.is_some() + && chain_headers.is_none_or(|h| chain_height.unwrap_or(0) >= h) + && ibd == Some(false); + Response::ConnectionStatus(ConnectionStatusResponse { + network: network_label(state.network).to_string(), + node_configured, + node_reachable, + node_version, + node_error, + chain_height, + chain_headers, + chain_synced, + }) + } + Request::LightBalance => match l1_balance(state, 1024).await { + Ok((confirmed, unconfirmed, _height)) => { + Response::LightBalance(LightBalanceResponse { + confirmed_sats: Some(confirmed), + unconfirmed_sats: Some(unconfirmed), + // An operator-side concept with no on-chain meaning. + // `None` says "not applicable" rather than claiming + // nothing is locked. + locked_sats: None, + received_at: Some(now_unix_secs() as i64), + }) + } + Err(message) => Response::Error(ErrorResponse { message }), + }, + Request::LightUtxos { min_confirmations } => { + match l1_utxo_entries(state, 1024, min_confirmations).await { + Ok((utxos, total_sats)) => { + Response::LightUtxos(LightUtxosResponse { utxos, total_sats }) + } + Err(message) => Response::Error(ErrorResponse { message }), + } + } + Request::GhostLockSave { + label, + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + } => { + use wraith_wallet_core::ghost_lock_store::StoredLock; + // Refuse a key that cannot build a lane, HERE, rather than at + // the first operation that needs one. + // + // Saving is where a person hands over a key they pasted from + // somewhere, and it was the one place that never checked. A + // malformed pubkey stored happily, reported `created: true`, + // and then every `lanes`, `destination`, `escape` and spend on + // that Lock failed — with the failure landing far from the + // typo that caused it, on a Lock the wallet says it has. + let mut bad: Option = None; + for (what, key) in [ + ("backup_pubkey", &backup_pubkey), + ("heir_pubkey", &heir_pubkey), + ("quorum_pubkey", &quorum_pubkey), + ] { + if let Err(e) = + ::from_str(key.trim()) + { + bad = Some(format!("{what} is not an x-only public key: {e}")); + break; + } + } + if let Some(message) = bad { + return Envelope::new(id, Response::Error(ErrorResponse { message })); + } + let lock = StoredLock::new( + label, + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index.unwrap_or(0), + ); + // Open-modify-write under the store's lock: `put` rewrites + // the whole file from the snapshot `open` read, so a + // concurrent save would otherwise drop one of the two locks. + let lock_store_lock = store_lock(state, &ghost_lock_store_path(state)); + let _lock_store_guard = lock_store_lock.lock().await; + match ghost_lock_store_for(state) { + Err(e) => Response::Error(ErrorResponse { + message: format!("lock store: {e}"), + }), + Ok(mut store) => { + let created = store.get(&lock.lock_id).is_none(); + match store.put(lock.clone()) { + Err(e) => Response::Error(ErrorResponse { + message: format!("save lock: {e}"), + }), + Ok(()) => Response::GhostLockSaved(GhostLockSavedResponse { + lock: lock_record(&lock), + created, + }), + } + } + } + } + Request::GhostLockQuorumSign { + lock_id, + lane, + psbt, + input_index, + coordinator_url, + } => { + use wraith_wallet_core::ghost_lock_account::LaneKind; + // The PSBT came from the caller, so its other inputs are the + // caller's choice. Judge them before a signature exists. + if let Some(reason) = refuse_unsafe_lock_spend(state, &psbt, &lock_id, &lane).await + { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("refused the spend: {reason}"), + }), + ); + } + let (account, kind, record) = match lock_lane_for(state, &lock_id, &lane).await { + Ok(v) => v, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + if kind != LaneKind::Spending { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "the quorum only co-signs Spending; {} is signed another way", + kind.label() + ), + }), + ); + } + let Some(built) = account.lanes.iter().find(|l| l.kind == kind) else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: "Lock has no Spending lane".into(), + }), + ); + }; + let root = built.lane.spend_info.merkle_root(); + + let owner_sk = match lock_owner_seckey(state, record.bip86_index).await { + Ok(k) => k, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + let owner_xonly = owner_sk + .x_only_public_key(&bitcoin::secp256k1::Secp256k1::new()) + .0; + let keys = match lane_cosigners(kind, owner_xonly, &record) { + Ok(k) => k, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + + let request = ghost_lock::airgap::SigningRequest { + psbt: psbt.clone(), + input_index, + keys: keys.iter().map(|k| hex::encode(k.serialize())).collect(), + merkle_root: root.map(|r| { + use bitcoin::hashes::Hash as _; + hex::encode(r.to_byte_array()) + }), + }; + + let (summary, message) = match ghost_lock::airgap::review(&request, state.network) { + Ok(v) => v, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("review: {e}"), + }), + ) + } + }; + + // The input must be the lane's, checked here rather than trusted + // from whoever supplied the PSBT. + let expected = built.lane.address.to_string(); + if summary.input_address.as_deref() != Some(expected.as_str()) { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "input {input_index} is not the Spending lane: it pays to {}, \ + the lane is {expected}", + summary + .input_address + .as_deref() + .unwrap_or("an unrenderable script") + ), + }), + ); + } + + // Under the nonce ledger's lock for as long as the ledger is + // alive. A MuSig2 secret nonce used twice publishes the + // signer's key, and the burn is only durable if the record + // that survives is written from a table that already contains + // every other burn — which means opening inside the lock. + let nonce_lock = store_lock(state, &nonce_ledger_path(state)); + let _nonce_guard = nonce_lock.lock().await; + let mut ledger = match ghost_lock_nonce_ledger_for(state) { + Ok(l) => l, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("nonce ledger unavailable: {e}"), + }), + ) + } + }; + + // The BINDING id, not the lock id. The quorum derives its key + // from what it is handed, and the lock id is a hash over that + // very key — so handing it the lock id asks for a key that + // could not have been in the Lock. See + // `StoredLock::quorum_binding_id`. + let binding_id = record.binding_id(); + let (sig, view) = match wraith_wallet_core::lock_cosign_client::cosign_with_quorum( + &state.http, + &coordinator_url, + &binding_id, + &request, + &owner_sk, + &keys, + root, + &message, + &mut ledger, + ) + .await + { + Ok(v) => v, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("{e}"), + }), + ) + } + }; + + let psbt_out = match attach_key_path_signature(&psbt, input_index, &sig) { + Ok(p) => p, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + // The finished transaction, when every input is signed. A + // multi-input spend may still be waiting on somebody else, so + // an empty string here means "signed, not yet complete" rather + // than a failure — the PSBT above is the thing to pass on. + let tx_hex = { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD + .decode(psbt_out.trim()) + .ok() + .and_then(|raw| bitcoin::psbt::Psbt::deserialize(&raw).ok()) + .and_then(|p| p.extract_tx().ok()) + .map(|tx| bitcoin::consensus::encode::serialize_hex(&tx)) + .unwrap_or_default() + }; + + Response::GhostLockQuorumSigned(GhostLockQuorumSignedResponse { + lock_id, + signature: hex::encode(sig.serialize()), + psbt: psbt_out, + tx_hex, + quorum_saw_input_sats: view.input_sats, + quorum_saw_fee_sats: view.fee_sats, + }) + } + + Request::GhostLockEscapePlan { lock_id, lane } => { + let (account, kind, _record, escape) = + match lock_escape_for(state, &lock_id, &lane).await { + Ok(v) => v, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + let Some(built) = account.lanes.iter().find(|l| l.kind == kind) else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("Lock has no {} lane", kind.label()), + }), + ); + }; + let address = built.lane.address.to_string(); + + let seq = match ghost_lock::escape::escape_sequence(escape.blocks()) { + Ok(s) => s, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("sequence: {e}"), + }), + ) + } + }; + + // Scanned at zero confirmations so an immature coin is listed + // with the wait still to go, rather than being invisible until + // it is already spendable. + let scan = match state + .chain() + .await + .scan_utxos(std::slice::from_ref(&address), 0) + .await + { + Ok(s) => s, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("scan: {e}"), + }), + ) + } + }; + let coins: Vec = scan + .utxos + .iter() + .map(|u| EscapeCoin { + txid: u.txid.clone(), + vout: u.vout, + sats: u.amount_sats, + confirmations: u.confirmations, + blocks_remaining: escape.blocks().saturating_sub(u.confirmations), + }) + .collect(); + + Response::GhostLockEscapePlan(GhostLockEscapePlanResponse { + lock_id, + lane: kind.label().to_ascii_lowercase(), + escape: escape.label().to_string(), + delay_blocks: escape.blocks(), + required_sequence: seq.0, + lane_address: address, + coins, + }) + } + + Request::GhostLockEscapeSign { + lock_id, + lane, + psbt, + input_index, + } => { + // Same guard as the other two signing paths: an escape spend + // is still a signature over a transaction the caller built. + if let Some(reason) = refuse_unsafe_lock_spend(state, &psbt, &lock_id, &lane).await + { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("refused the spend: {reason}"), + }), + ); + } + let (account, kind, record, escape) = + match lock_escape_for(state, &lock_id, &lane).await { + Ok(v) => v, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + let Some(built) = account.lanes.iter().find(|l| l.kind == kind) else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("Lock has no {} lane", kind.label()), + }), + ); + }; + + let owner_sk = match lock_owner_seckey(state, record.bip86_index).await { + Ok(k) => k, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + let owner_xonly = owner_sk + .x_only_public_key(&bitcoin::secp256k1::Secp256k1::new()) + .0; + let leaf = match escape.leaf(&owner_xonly) { + Ok(l) => l, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("leaf: {e}"), + }), + ) + } + }; + + let (mut parsed, prevouts) = match decode_psbt_with_prevouts(&psbt) { + Ok(v) => v, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + + // The input must be this lane's. Otherwise the daemon would + // sign whatever input it was pointed at, on the say-so of + // whoever supplied the PSBT. + let idx = input_index as usize; + let Some(prev) = prevouts.get(idx) else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("input {idx} does not exist"), + }), + ); + }; + if prev.script_pubkey != built.lane.address.script_pubkey() { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "input {idx} is not the {} lane — it pays to a different script", + kind.label() + ), + }), + ); + } + + let witness = match ghost_lock::escape::sign_escape( + &built.lane, + &leaf, + escape.blocks(), + &owner_sk, + &parsed.unsigned_tx, + idx, + &prevouts, + ) { + Ok(w) => w, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("{e}"), + }), + ) + } + }; + + parsed.inputs[idx].final_script_witness = Some(witness); + let tx_hex = match parsed.clone().extract_tx() { + Ok(tx) => bitcoin::consensus::encode::serialize_hex(&tx), + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "the spend is signed but not complete ({e}); every input \ + needs its own witness before this can be broadcast" + ), + }), + ) + } + }; + use base64::Engine as _; + Response::GhostLockEscapeSigned(GhostLockEscapeSignedResponse { + lock_id, + lane: kind.label().to_ascii_lowercase(), + escape: escape.label().to_string(), + psbt: base64::engine::general_purpose::STANDARD.encode(parsed.serialize()), + tx_hex, + }) + } + + Request::GhostLockSignBegin { + lock_id, + lane, + psbt, + input_index, + } => { + // The PSBT came from the caller, so its other inputs are the + // caller's choice. Judge them before a signature exists. + if let Some(reason) = refuse_unsafe_lock_spend(state, &psbt, &lock_id, &lane).await + { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("refused the spend: {reason}"), + }), + ); + } + let (account, kind, record) = match lock_lane_for(state, &lock_id, &lane).await { + Ok(v) => v, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + let Some(built) = account.lanes.iter().find(|l| l.kind == kind) else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("Lock has no {} lane", kind.label()), + }), + ); + }; + + let root = built.lane.spend_info.merkle_root(); + + let owner_sk = match lock_owner_seckey(state, record.bip86_index).await { + Ok(k) => k, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + let owner_xonly = owner_sk + .x_only_public_key(&bitcoin::secp256k1::Secp256k1::new()) + .0; + let keys = match lane_cosigners(kind, owner_xonly, &record) { + Ok(k) => k, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + + let request = ghost_lock::airgap::SigningRequest { + psbt: psbt.clone(), + input_index, + keys: keys.iter().map(|k| hex::encode(k.serialize())).collect(), + merkle_root: root.map(|r| { + use bitcoin::hashes::Hash as _; + hex::encode(r.to_byte_array()) + }), + }; + + let (summary, message) = match ghost_lock::airgap::review(&request, state.network) { + Ok(v) => v, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("review: {e}"), + }), + ) + } + }; + + // The input must be the lane's. Without this the daemon would + // happily sign an input belonging to somebody else's script, + // on the say-so of whoever supplied the PSBT. + let expected = built.lane.address.to_string(); + if summary.input_address.as_deref() != Some(expected.as_str()) { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "input {input_index} is not the {} lane: it pays to {}, the lane is {expected}", + kind.label(), + summary.input_address.as_deref().unwrap_or("an unrenderable script") + ), + }), + ); + } + + let (session, commitment) = match ghost_lock::signing::SigningSession::begin( + &keys, &owner_sk, root, &message, + ) { + Ok(v) => v, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("round 1: {e}"), + }), + ) + } + }; + + let session_hex = hex::encode(commitment.session.as_bytes()); + let our_nonce = commitment.public_nonce; + state.lock_signings.write().await.insert( + session_hex.clone(), + PendingLockSign { + keys, + merkle_root: root, + message, + psbt, + input_index, + our_nonce, + session: Some(session), + nonces: Vec::new(), + our_partial: None, + }, + ); + + Response::GhostLockSignBegun(GhostLockSignBegunResponse { + session: session_hex, + summary: lock_spend_summary(&summary), + device_request: match serde_json::to_string_pretty(&request) { + Ok(j) => j, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("device request: {e}"), + }), + ) + } + }, + our_nonce: hex::encode(our_nonce), + }) + } + + Request::GhostLockSignNonce { + session, + device_nonce, + } => { + let device = match unhex66("device_nonce", &device_nonce) { + Ok(v) => v, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + + let mut guard = state.lock_signings.write().await; + let Some(pending) = guard.get_mut(&session) else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "no signing session '{session}' — a daemon restart drops \ + these, which is safe: start again with `lock sign begin`" + ), + }), + ); + }; + let Some(sess) = pending.session.take() else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: "this session already has its nonce round; the next step \ + is `lock sign complete`" + .into(), + }), + ); + }; + + // Nonce order must match what every party aggregates. Sorted, + // so both sides reach the same aggregate without agreeing who + // goes first — the same reason the keys are sorted. + let mut nonces = vec![pending.our_nonce, device]; + nonces.sort_unstable(); + + // Sign now, while both nonces are known. After this the daemon + // holds no secret nonce, so none is sitting in memory while the + // second payload is carried to the device. + // Under the nonce ledger's lock for as long as the ledger is + // alive. A MuSig2 secret nonce used twice publishes the + // signer's key, and the burn is only durable if the record + // that survives is written from a table that already contains + // every other burn — which means opening inside the lock. + let nonce_lock = store_lock(state, &nonce_ledger_path(state)); + let _nonce_guard = nonce_lock.lock().await; + let mut ledger = match ghost_lock_nonce_ledger_for(state) { + Ok(l) => l, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("nonce ledger unavailable: {e}"), + }), + ) + } + }; + let partial = match sess.sign(&mut ledger, &nonces) { + Ok(p) => p, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("round 2: {e}"), + }), + ) + } + }; + pending.nonces = nonces.clone(); + pending.our_partial = Some(partial); + + let req = ghost_lock::airgap::PartialRequest { + session: session.clone(), + public_nonces: nonces.iter().map(hex::encode).collect(), + }; + Response::GhostLockSignNonced(GhostLockSignNoncedResponse { + session, + device_request: match serde_json::to_string_pretty(&req) { + Ok(j) => j, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("device request: {e}"), + }), + ) + } + }, + }) + } + + Request::GhostLockSignComplete { + session, + device_partial, + } => { + let device = match unhex32("device_partial", &device_partial) { + Ok(v) => v, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + + let mut guard = state.lock_signings.write().await; + let Some(pending) = guard.get(&session) else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("no signing session '{session}'"), + }), + ); + }; + let Some(ours) = pending.our_partial else { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: "this session has not completed its nonce round yet".into(), + }), + ); + }; + + let sig = match ghost_lock::signing::combine( + &pending.keys, + pending.merkle_root, + &pending.nonces, + &[ours, device], + &pending.message, + ) { + Ok(s) => s, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("combine: {e}"), + }), + ) + } + }; + + let psbt_out = + match attach_key_path_signature(&pending.psbt, pending.input_index, &sig) { + Ok(p) => p, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + let out = Response::GhostLockSigned(GhostLockSignedResponse { + session: session.clone(), + signature: hex::encode(sig.serialize()), + psbt: psbt_out, + }); + guard.remove(&session); + out } - Request::LocksList => { - let guard = state.session.read().await; - match guard.as_ref() { + + Request::GhostLockRoundDestination { lock_id, lane } => { + use wraith_wallet_core::ghost_lock_account::LaneKind; + + // Name the lane, never accept an address. The whole point of + // private entry is that the round's output IS the lane, so if + // a caller could hand in an arbitrary address then "fund my + // Savings privately" and "pay this stranger" would be the same + // request with the same audit trail. + let kind = match lane.trim().to_ascii_lowercase().as_str() { + "savings" => LaneKind::Savings, + "spending" => LaneKind::Spending, + "cash" => LaneKind::Cash, + "investments" => LaneKind::Investments, + other => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "unknown lane '{other}' (try savings, spending, cash, investments)" + ), + }), + ) + } + }; + + // Refused here rather than in the CLI. A rule enforced only in + // the client is enforced only for clients that ask nicely. + if let Err(e) = ghost_lock::check_round_destination(kind.compartment()) { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("{} lane: {e}", kind.label()), + }), + ); + } + + let record = match ghost_lock_store_for(state) { + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("lock store: {e}"), + }), + ) + } + Ok(store) => match store.get(&lock_id) { + Some(l) => l.clone(), + None => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!( + "no remembered Lock '{lock_id}' — `wraith lock list` shows the ones this wallet knows" + ), + }), + ) + } + }, + }; + + let account = match build_lock_account( + state, + &record.backup_pubkey, + &record.heir_pubkey, + &record.quorum_pubkey, + record.inherit_height, + record.anchor_height, + Some(record.bip86_index), + ) + .await + { + Ok(a) => a, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + + match account.lanes.iter().find(|l| l.kind == kind) { + Some(built) => { + Response::GhostLockRoundDestination(GhostLockRoundDestinationResponse { + lock_id: record.lock_id.clone(), + lane: lane.trim().to_ascii_lowercase(), + label: kind.label().to_string(), + address: built.lane.address.to_string(), + }) + } None => Response::Error(ErrorResponse { - message: "no GSP session — run `wraith gsp auth` first".to_string(), + message: format!("Lock has no {} lane", kind.label()), }), - Some(s) => match s.handle.get_ghost_locks().await { - Ok(result) => { - let locks = result - .locks - .into_iter() - .map(|l| LockEntry { - lock_id: l.lock_id, - // Canonical lowercase via Display ("pending"/"active"/"in_use"); - // Debug formatting would render multi-word variants wrong (e.g. "inuse"). - status: l.status.to_string(), - capacity_sats: l.capacity_sats, - balance_sats: l.balance_sats, - denomination: l.denomination, - timelock_tier: l.timelock_tier, - funding_address: l.funding_address, - funding_txid: l.funding_txid, - funding_vout: l.funding_vout, - creation_height: l.creation_height, - recovery_height: l.recovery_height, - }) - .collect(); - Response::LocksList(LocksListResponse { - locks, - total_locked_sats: result.total_locked_sats, - }) - } + } + } + Request::GhostLockList => match ghost_lock_store_for(state) { + Err(e) => Response::Error(ErrorResponse { + message: format!("lock store: {e}"), + }), + Ok(store) => Response::GhostLockList(GhostLockListResponse { + locks: store.list().iter().map(lock_record).collect(), + }), + }, + Request::GhostLockQuorumBindingId { + backup_pubkey, + heir_pubkey, + anchor_height, + inherit_height, + bip86_index, + } => { + // Validate the keys here rather than hashing whatever arrives. + // A binding id built from a typo is a co-signing key nobody can + // produce, and the failure would surface much later as a Lock + // whose quorum simply never matches. + let mut bad = None; + for (name, k) in [ + ("backup_pubkey", &backup_pubkey), + ("heir_pubkey", &heir_pubkey), + ] { + if ::from_str(k.trim()).is_err() { + bad = Some(format!("{name} is not an x-only public key")); + break; + } + } + if let Some(message) = bad { + return Envelope::new(id, Response::Error(ErrorResponse { message })); + } + let idx = bip86_index.unwrap_or(0); + let binding_id = + wraith_wallet_core::ghost_lock_store::StoredLock::quorum_binding_id( + &backup_pubkey, + &heir_pubkey, + anchor_height, + inherit_height, + idx, + ); + Response::GhostLockQuorumBindingId(GhostLockQuorumBindingIdResponse { + derive_with: format!( + "ghost-lock-signer quorum-pubkey --seed --lock-id {binding_id}" + ), + binding_id, + }) + } + Request::GhostLockForget { lock_id } => { + let lock_store_lock = store_lock(state, &ghost_lock_store_path(state)); + let _lock_store_guard = lock_store_lock.lock().await; + match ghost_lock_store_for(state) { + Err(e) => Response::Error(ErrorResponse { + message: format!("lock store: {e}"), + }), + Ok(mut store) => match store.remove(&lock_id) { Err(e) => Response::Error(ErrorResponse { - message: format!("locks list: {e}"), + message: format!("forget lock: {e}"), + }), + Ok(existed) => Response::GhostLockForgotten(GhostLockForgottenResponse { + lock_id, + existed, }), }, } } + Request::GhostLockLanes { + backup_pubkey, + heir_pubkey, + quorum_pubkey, + inherit_height, + anchor_height, + bip86_index, + } => { + use wraith_wallet_core::ghost_lock_account::balances; + + let account = match build_lock_account( + state, + &backup_pubkey, + &heir_pubkey, + &quorum_pubkey, + inherit_height, + anchor_height, + bip86_index, + ) + .await + { + Ok(a) => a, + Err(message) => { + return Envelope::new(id, Response::Error(ErrorResponse { message })) + } + }; + + let addresses: Vec = account + .lanes + .iter() + .map(|l| l.lane.address.to_string()) + .collect(); + + // Scanned at ZERO confirmations, then split. One round trip + // gives both figures, and the split happens here rather than + // being two scans that could disagree with each other. + let scan = match state.chain().await.scan_utxos(&addresses, 0).await { + Ok(s) => s, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("scan: {e}"), + }), + ) + } + }; + + // Attribute each UTXO to its lane by scriptPubKey. + // + // ⚠ NOT by address, which is what this did and why every lane + // read as empty. `scantxoutset` returns scripts, not addresses + // — it normalises `addr()` into `rawtr()` on + // the way out — so `GhostdChainClient` leaves `address` as + // `None` rather than inventing a form bitcoind did not send. + // Matching on it therefore skipped every coin, and a Lock + // holding real money reported zero in all four compartments. + // + // The script is the canonical thing both sides agree on, and + // it is what the L1 UTXO listing already matches on. + let lane_spk: Vec<(usize, String)> = account + .lanes + .iter() + .enumerate() + .map(|(i, l)| (i, hex::encode(l.lane.address.script_pubkey().as_bytes()))) + .collect(); + let mut per_lane: Vec = + Vec::new(); + for u in &scan.utxos { + if let Some(b) = lane_spk + .iter() + .find(|(_, spk)| spk.eq_ignore_ascii_case(&u.scriptpubkey_hex)) + .and_then(|(i, _)| account.lanes.get(*i)) + { + per_lane.push(wraith_wallet_core::ghost_lock_account::LaneCoin { + kind: b.kind, + sats: u.amount_sats, + confirmations: u.confirmations, + }); + } + } + + let b = balances(&account, &per_lane); + Response::GhostLockLanes(GhostLockLanesResponse { + lanes: b + .lanes + .iter() + .map(|l| GhostLockLane { + kind: format!("{:?}", l.kind).to_lowercase(), + label: l.label.clone(), + address: l.address.clone(), + balance_sats: l.balance_sats, + pending_sats: l.pending_sats, + quorum_can_spend_alone: l.quorum_can_spend_alone, + round_eligible: l.round_eligible, + }) + .collect(), + total_sats: b.total_sats, + total_pending_sats: b.total_pending_sats, + custodial_sats: b.custodial_sats, + chain_height: scan.chain_height, + }) + } + Request::LightL1Utxos { + scan_max_index, + min_confirmations, + } => { + use std::collections::HashMap; + let scan_max = scan_max_index.min(1024); + let network = state.network; + // Derive 0..scan_max receive addresses from the active + // keystore. We need both the address (to send to + // ghost-pay) and the scriptPubKey (to attribute each + // returned UTXO back to its derivation index). + // + // Why scriptPubKey, not address: bitcoind's + // scantxoutset normalises `addr()` into + // `rawtr()` (or `wpkh()`, etc.) in + // its response — the address descriptor is not + // round-tripped. Matching on the canonical + // scriptPubKey instead avoids depending on which + // descriptor format bitcoind chooses to echo back. + #[derive(Clone)] + struct DerivedAddr { + address: String, + scriptpubkey_hex: String, + index: u32, + } + let derived: Result, String> = + with_active_wallet(state, |_, ks| { + let mut out = Vec::with_capacity(scan_max as usize); + for i in 0..scan_max { + let a = light::receive_address(ks, i, network) + .map_err(|e| format!("derive index {i}: {e}"))?; + let spk_hex = hex::encode(a.script_pubkey().as_bytes()); + out.push(DerivedAddr { + address: a.to_string(), + scriptpubkey_hex: spk_hex, + index: i, + }); + } + Ok(out) + }) + .await; + let pairs = match derived { + Ok(p) => p, + Err(e) => { + return Envelope::new(id, Response::Error(ErrorResponse { message: e })); + } + }; + // scriptpubkey_hex → (bip86_index, address). The + // canonical match key — see comment above. + let spk_to_idx: HashMap = pairs + .iter() + .map(|d| (d.scriptpubkey_hex.clone(), (d.index, d.address.clone()))) + .collect(); + let addresses: Vec = pairs.into_iter().map(|d| d.address).collect(); + let scan = match state + .chain() + .await + .scan_utxos(&addresses, min_confirmations) + .await + { + Ok(s) => s, + Err(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("ghost-pay scan: {e}"), + }), + ); + } + }; + let utxos: Vec = scan + .utxos + .into_iter() + .filter_map(|u| { + // Match by scriptPubKey — independent of + // whether bitcoind echoed `addr(...)` or + // `rawtr(...)` in the response descriptor. + let (bip86_index, address) = + spk_to_idx.get(&u.scriptpubkey_hex).cloned()?; + Some(LightL1UtxoEntry { + txid: u.txid, + vout: u.vout, + amount_sats: u.amount_sats, + scriptpubkey_hex: u.scriptpubkey_hex, + bip86_index, + // Use the daemon-derived address — the + // ghost-pay-side parser may have lost it + // when the descriptor came back as + // rawtr(...). + address, + confirmations: u.confirmations, + height: u.height, + }) + }) + .collect(); + let total_sats = utxos.iter().map(|u| u.amount_sats).sum(); + Response::LightL1Utxos(LightL1UtxosResponse { + utxos, + total_sats, + chain_height: scan.chain_height, + scanned_max_index: scan_max, + }) + } + Request::DaemonEnv => { + let network = match state.network { + bitcoin::Network::Bitcoin => "mainnet", + bitcoin::Network::Signet => "signet", + bitcoin::Network::Testnet => "testnet", + bitcoin::Network::Regtest => "regtest", + _ => "unknown", + } + .to_string(); + let ghostd = state.ghostd().await; + Response::DaemonEnv(DaemonEnvResponse { + ghostd_url: ghostd.url.clone(), + ghostd_auth: ghostd.auth_kind().to_string(), + ghostd_env_override: state.ghostd_env_override, + pool_url: state.pool_url.read().await.clone(), + network, + wallets_dir: state.wallets_dir.display().to_string(), + tor_proxy: state.tor_proxy.clone(), + socket_path: state.endpoint_display.clone(), + idle_lock_secs: state.idle_lock_secs, + shroud_max_ms: state.shroud_max_ms, + update_manifest_url: state.update_manifest_url.clone(), + kiosk_mode: state.kiosk_mode, + }) + } + Request::SetNode { + ghostd_url, + cookie_path, + user, + pass, + pool_url, + } => match state + .set_node( + GhostdSettings { + url: ghostd_url, + cookie_path: cookie_path.map(PathBuf::from), + user, + pass, + }, + pool_url, + ) + .await + { + Ok(applied) => Response::NodeSet(applied), + Err(message) => Response::Error(ErrorResponse { message }), + }, + Request::CheckForUpdate { manifest_url } => { + match check_for_update(state, manifest_url).await { + Ok(r) => Response::CheckForUpdate(r), + Err(message) => Response::Error(ErrorResponse { message }), + } + } + Request::LightDetected => match detection_store_for(state).await { + Err(e) => Response::Error(ErrorResponse { + message: format!("detections: {e}"), + }), + Ok(store) => Response::LightDetected(LightDetectedResponse { + detections: store + .list() + .into_iter() + .map(|d| DetectedPaymentEntry { + txid: d.txid, + vout: d.vout, + amount_sats: d.amount_sats, + block_height: d.block_height, + k: d.k, + received_at: d.received_at, + }) + .collect(), + }), + }, + Request::LightHistory { limit, offset } => { + return Envelope::new(id, l1_history(state, limit, offset).await) + } + Request::L1Send { + recipient_address, + amount_sats, + fee_rate_sats_per_vb, + change_index, + bip86_scan_max, + selected_outpoints, + memo, + shroud_max_ms, + } => match l1_send( + state, + L1SendParams { + recipient_address, + amount_sats, + fee_rate_sats_per_vb, + change_index, + bip86_scan_max, + selected_outpoints, + memo, + shroud_override_ms: shroud_max_ms, + }, + ) + .await + { + Ok(r) => Response::L1Sent(r), + Err(message) => Response::Error(ErrorResponse { message }), + }, Request::WalletCreate { name, passphrase, @@ -3386,6 +4869,12 @@ mod server { Ok(()) => { state.wallets.write().await.insert(name.clone(), ks); *state.active.write().await = Some(name.clone()); + // A wallet cannot have been paid before it + // existed, so the tip is its birth height + // and the scanner need never look further + // back than this. + let tip = current_tip(state).await; + record_birth_height(state, &name, tip).await; Response::WalletCreate(WalletCreateResponse { name, mnemonic, @@ -3407,6 +4896,7 @@ mod server { name, mnemonic, passphrase, + birth_height, } => { if let Some(refused) = refuse_in_kiosk_mode(state, "wallet import") { return Envelope::new(id, refused); @@ -3441,6 +4931,11 @@ mod server { Ok(()) => { state.wallets.write().await.insert(name.clone(), ks); *state.active.write().await = Some(name.clone()); + // Whatever the owner said, and nothing if + // they said nothing. Defaulting to the tip + // here would look like a birth height and + // silently mean "no history before now". + record_birth_height(state, &name, birth_height).await; Response::WalletImported { name, path: path.display().to_string(), @@ -3471,26 +4966,7 @@ mod server { match Keystore::load(&path, &pass) { Ok(ks) => { state.wallets.write().await.insert(name.clone(), ks); - *state.active.write().await = Some(name.clone()); - // Restore the wallet's previously-prepared locks - // from disk. Merges into the in-memory map so - // multi-wallet setups don't clobber each other. - let restored = load_locks_for_wallet(&state.wallets_dir, &name); - if !restored.is_empty() { - let mut guard = state.prepared_locks.write().await; - for (k, v) in restored { - guard.insert(k, v); - } - // Advance the recovery-index counter past every - // index already committed to disk so a restart - // never re-issues (and thus re-derives) a recovery - // key an existing lock already uses. - advance_recovery_index_past_locks( - &state.next_recovery_index, - &guard, - ); - tracing::info!(wallet = %name, "restored prepared locks from disk"); - } + *state.active.write().await = Some(name.clone()); Response::WalletUnlocked } Err(KeystoreError::Decrypt) => Response::Error(ErrorResponse { @@ -3531,11 +5007,6 @@ mod server { if active.as_deref() == Some(target.as_str()) { *active = None; } - // Drop any GSP session bound to the wallet we just locked. - let mut session = state.session.write().await; - if session.as_ref().is_some_and(|s| s.wallet_name == target) { - *session = None; - } Response::WalletLocked { name: target } } } @@ -3568,10 +5039,6 @@ mod server { if active.as_deref() == Some(name.as_str()) { *active = None; } - let mut session = state.session.write().await; - if session.as_ref().is_some_and(|s| s.wallet_name == name) { - *session = None; - } Response::WalletDeleted { name } } } @@ -3627,11 +5094,6 @@ mod server { }) } else { *state.active.write().await = Some(name.clone()); - // Drop any GSP session that belongs to a different wallet. - let mut session = state.session.write().await; - if session.as_ref().is_some_and(|s| s.wallet_name != name) { - *session = None; - } Response::WalletSelected { name } } } @@ -3746,54 +5208,6 @@ mod server { Err(message) => Response::Error(ErrorResponse { message }), } } - Request::WalletGlyph { ghost_id } => match build_ghost_pay_client(state).await { - Ok(client) => match client.get_glyph(&ghost_id).await { - Ok(v) => match serde_json::from_value::(v) { - Ok(info) => Response::WalletGlyph(info), - Err(e) => Response::Error(ErrorResponse { - message: format!("glyph parse: {e}"), - }), - }, - Err(e) => Response::Error(ErrorResponse { - message: format!("glyph: {e}"), - }), - }, - Err(message) => Response::Error(ErrorResponse { message }), - }, - Request::WalletGlyphCheck { pixels } => match build_ghost_pay_client(state).await { - Ok(client) => { - let bitmap_hash_hex = glyph_bitmap_hash_hex(&pixels); - match client.check_glyph(&bitmap_hash_hex).await { - Ok(v) => { - let available = v - .get("available") - .and_then(|b| b.as_bool()) - .unwrap_or(false); - Response::WalletGlyphChecked { available } - } - Err(e) => Response::Error(ErrorResponse { - message: format!("glyph check: {e}"), - }), - } - } - Err(message) => Response::Error(ErrorResponse { message }), - }, - Request::WalletGlyphClaim { ghost_id, pixels } => { - match build_ghost_pay_client(state).await { - Ok(client) => match client.claim_glyph(&ghost_id, &pixels).await { - Ok(v) => match serde_json::from_value::(v) { - Ok(r) => Response::WalletGlyphClaimed(r), - Err(e) => Response::Error(ErrorResponse { - message: format!("glyph claim parse: {e}"), - }), - }, - Err(e) => Response::Error(ErrorResponse { - message: format!("glyph claim: {e}"), - }), - }, - Err(message) => Response::Error(ErrorResponse { message }), - } - } Request::WalletAuthInfo => { match with_active_wallet(state, |_, ks| { let kp = auth::auth_keypair(ks).map_err(|e| format!("auth-info: {e}"))?; @@ -3981,6 +5395,7 @@ mod server { utxo_value_sats, utxo_scriptpubkey_hex, mix_output_address, + min_entities, } => { use wraith_wallet_core::wraith::{ MixRequest, ParticipantUtxo, WraithClientError, WraithSessionClient, @@ -4012,6 +5427,21 @@ mod server { ); } }; + // Compartment rule 1, before the coin is offered to anyone. + // A Cash coin is public by design; mixing one re-links the + // strangers it is mixed with, which is their problem rather + // than the owner's — so the wallet refuses it rather than + // leaving the choice to whoever typed the command. + if let Some(reason) = + refuse_if_not_round_eligible(state, &utxo_scriptpubkey_hex).await + { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("refused the round: {reason}"), + }), + ); + } // Same reason: `req` takes the scriptPubKey, and the // ownership proof needs it to find the key that owns it. let utxo_scriptpubkey_hex_for_proof = utxo_scriptpubkey_hex.clone(); @@ -4027,6 +5457,8 @@ mod server { scriptpubkey_hex: utxo_scriptpubkey_hex, }, mix_output_address, + min_entities: min_entities + .unwrap_or(wraith_wallet_core::wraith::DEFAULT_MIN_ENTITIES), }; // Prove control of the input UTXO. The coordinator checks // this against the scriptPubKey the chain reports for the @@ -4054,20 +5486,47 @@ mod server { }; match client.prepare_mix(req, prove_ownership).await { Ok(prepared) => { - let resp = WraithMixPreparedResponse { - session_id: prepared.session_id.clone(), - unsigned_tx_hex: bitcoin::consensus::encode::serialize_hex( - &prepared.unsigned_tx, - ), - input_index: prepared.input_index as u32, - prev_amount_sats: prepared.prev_amount_sats, - mixed_output_tx_index: prepared.mixed_output_tx_index as u32, - }; - state.wraith_mixes.write().await.insert( - prepared.session_id.clone(), - StoredWraithMix { prepared, client }, - ); - Response::WraithMixPrepared(resp) + // Inspect here, at prepare time — before the caller is + // handed a transaction to sign. Checking later would + // mean the wallet had already produced a signature over + // a round it never verified. + match check_against_ledger(state, &prepared).await { + LedgerCheck::Unavailable(e) => Response::Error(ErrorResponse { + message: format!("signing ledger unavailable: {e}"), + }), + LedgerCheck::Refused(e) => match refusal_response( + prepared.session_id.clone(), + min_entities + .unwrap_or(wraith_wallet_core::wraith::DEFAULT_MIN_ENTITIES), + &e, + ) { + Some(r) => Response::WraithMixRefused(r), + None => Response::Error(ErrorResponse { + message: format!("refused the round: {e}"), + }), + }, + LedgerCheck::Passed(inspected) => { + let p = inspected.prepared(); + let resp = WraithMixPreparedResponse { + session_id: p.session_id.clone(), + unsigned_tx_hex: bitcoin::consensus::encode::serialize_hex( + &p.unsigned_tx, + ), + input_index: p.input_index as u32, + prev_amount_sats: p.prev_amount_sats, + mixed_output_tx_index: p.mixed_output_tx_index as u32, + }; + let sid = p.session_id.clone(); + state.wraith_mixes.write().await.insert( + sid, + StoredWraithMix { + inspected: *inspected, + client, + }, + ); + Response::WraithMixPrepared(resp) + } + } } Err(e) => Response::Error(ErrorResponse { message: format!("wraith prepare: {e}"), @@ -4126,7 +5585,7 @@ mod server { }; match stored .client - .submit_witness(&stored.prepared, witness) + .submit_witness(&stored.inspected, witness) .await { Ok(outcome) => Response::WraithMixCompleted(WraithMixCompletedResponse { @@ -4178,44 +5637,17 @@ mod server { } } Request::WraithResolveCoordinator { tier_id } => { - // Fetch the node's election view THROUGH ghost-pay (wallet hard - // rule: never the pool API directly), then resolve the seat that - // owns this tier. Any failure → (None, None) so the caller falls - // back to a manually-configured coordinator URL. - let (endpoint, epoch) = - match wraith_wallet_core::chain::GhostPayClient::with_urls_and_proxy( - state.ghost_pay_urls().await, - None, - ) { - Ok(client) => match client.coordinator_election().await { - Ok(election) => { - // Pin the beacon to the chain if this wallet has - // its own node. Verifying the draw against the - // beacon published beside it only proves internal - // consistency; the block hash is a fact the - // operator does not get to state (#697). - if !beacon_pinned_to_chain(state, &election) { - tracing::warn!( - "resolve coordinator: published beacon does not match \ - the anchor block; refusing the election" - ); - (None, election.get("epoch").and_then(|e| e.as_u64())) - } else { - crate::coordinator_resolve::resolve_from_election( - &election, &tier_id, - ) - } - } - Err(e) => { - tracing::debug!(error = %e, "resolve coordinator: election fetch failed"); - (None, None) - } - }, - Err(e) => { - tracing::debug!(error = %e, "resolve coordinator: ghost-pay client build failed"); - (None, None) - } - }; + // A verified election, or no answer. "No answer" is not a + // failure here: the caller falls back to a coordinator URL the + // user supplied, which is a worse answer than a verified + // election and a better one than obeying an unverifiable claim + // about who is in charge. + let (endpoint, epoch) = match verified_election(state).await { + Some(election) => { + crate::coordinator_resolve::resolve_from_election(&election, &tier_id) + } + None => (None, None), + }; Response::WraithCoordinatorResolved { endpoint, epoch } } Request::WraithMixOneShot { @@ -4231,6 +5663,7 @@ mod server { mix_output_address, bip86_index, bip86_scan_max, + min_entities, } => { use wraith_wallet_core::wraith::{ MixRequest, ParticipantUtxo, WraithClientError, WraithSessionClient, @@ -4265,6 +5698,21 @@ mod server { ); } }; + // Compartment rule 1, before the coin is offered to anyone. + // A Cash coin is public by design; mixing one re-links the + // strangers it is mixed with, which is their problem rather + // than the owner's — so the wallet refuses it rather than + // leaving the choice to whoever typed the command. + if let Some(reason) = + refuse_if_not_round_eligible(state, &utxo_scriptpubkey_hex).await + { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("refused the round: {reason}"), + }), + ); + } // Same reason: `req` takes the scriptPubKey, and the // ownership proof needs it to find the key that owns it. let utxo_scriptpubkey_hex_for_proof = utxo_scriptpubkey_hex.clone(); @@ -4280,6 +5728,8 @@ mod server { scriptpubkey_hex: utxo_scriptpubkey_hex, }, mix_output_address, + min_entities: min_entities + .unwrap_or(wraith_wallet_core::wraith::DEFAULT_MIN_ENTITIES), }; // Prove control of the input UTXO. The coordinator checks // this against the scriptPubKey the chain reports for the @@ -4316,6 +5766,37 @@ mod server { ); } }; + + // Inspect BEFORE signing. This path is the one-shot mix, and it + // previously went from `/round-tx` straight to the keystore — + // no check that the wallet's own input and output were in the + // round, no anonymity floor, and no commitment of the coin. + let inspected = match check_against_ledger(state, &prepared).await { + LedgerCheck::Passed(i) => *i, + LedgerCheck::Unavailable(e) => { + return Envelope::new( + id, + Response::Error(ErrorResponse { + message: format!("signing ledger unavailable: {e}"), + }), + ); + } + LedgerCheck::Refused(e) => { + let resp = match refusal_response( + prepared.session_id.clone(), + min_entities + .unwrap_or(wraith_wallet_core::wraith::DEFAULT_MIN_ENTITIES), + &e, + ) { + Some(r) => Response::WraithMixRefused(r), + None => Response::Error(ErrorResponse { + message: format!("refused the round: {e}"), + }), + }; + return Envelope::new(id, resp); + } + }; + // Sign with the active wallet's keystore. `with_active_wallet` // is async and re-locks the keystore RwLock on each call; // we hold the lock just for the (sync) sighash + Schnorr step. @@ -4350,7 +5831,7 @@ mod server { return Envelope::new(id, Response::Error(ErrorResponse { message })); } }; - match client.submit_witness(&prepared, witness).await { + match client.submit_witness(&inspected, witness).await { Ok(outcome) => Response::WraithMixCompleted(WraithMixCompletedResponse { session_id: outcome.session_id, broadcast_txid: outcome.broadcast_txid.to_string(), @@ -4361,501 +5842,1196 @@ mod server { }), } } - Request::PsbtInspect { psbt } => { + Request::PsbtInspect { psbt } => { + use wraith_wallet_core::psbt as psbt_mod; + match psbt_mod::decode_psbt(&psbt) { + Err(e) => Response::Error(ErrorResponse { + message: format!("psbt decode: {e}"), + }), + Ok((parsed, _encoding)) => { + let inspect = psbt_mod::inspect(&parsed); + let network = state.network; + // Resolve the active wallet (if any) to + // answer the per-input "is this signable + // by me?" question. Inspector still works + // without an active wallet — those flags + // just come back false. + let active = state.active.read().await.clone(); + let scan_max = psbt_mod::DEFAULT_SCAN_INDEX_MAX; + let (input_signable, output_owned) = if let Some(name) = active { + let wallets = state.wallets.read().await; + if let Some(ks) = wallets.get(&name) { + let inputs_flags: Vec = inspect + .inputs + .iter() + .map(|iv| match &iv.script_pubkey { + Some(spk) if spk.is_p2tr() => { + psbt_mod::find_bip86_index_for_script( + ks, network, spk, scan_max, + ) + .unwrap_or(None) + .is_some() + } + _ => false, + }) + .collect(); + let outputs_flags: Vec = inspect + .outputs + .iter() + .map(|ov| { + if !ov.script_pubkey.is_p2tr() { + return false; + } + psbt_mod::find_bip86_index_for_script( + ks, + network, + &ov.script_pubkey, + scan_max, + ) + .unwrap_or(None) + .is_some() + }) + .collect(); + (inputs_flags, outputs_flags) + } else { + ( + vec![false; inspect.inputs.len()], + vec![false; inspect.outputs.len()], + ) + } + } else { + ( + vec![false; inspect.inputs.len()], + vec![false; inspect.outputs.len()], + ) + }; + + let inputs: Vec = inspect + .inputs + .iter() + .enumerate() + .map(|(i, iv)| PsbtInputSummary { + previous_txid: iv.previous_txid.to_string(), + previous_vout: iv.previous_vout, + value_sats: iv.value_sats, + script_pubkey_hex: iv + .script_pubkey + .as_ref() + .map(|s| hex::encode(s.as_bytes())), + address: iv + .script_pubkey + .as_ref() + .and_then(|s| psbt_mod::script_to_address(s, network)), + is_finalized: iv.is_finalized, + partial_signatures: iv.partial_signatures, + is_signable_by_active_wallet: input_signable[i] && !iv.is_finalized, + }) + .collect(); + let outputs: Vec = inspect + .outputs + .iter() + .enumerate() + .map(|(i, ov)| PsbtOutputSummary { + value_sats: ov.value_sats, + script_pubkey_hex: hex::encode(ov.script_pubkey.as_bytes()), + address: psbt_mod::script_to_address(&ov.script_pubkey, network), + is_owned_by_active_wallet: output_owned[i], + }) + .collect(); + + let total_in_sats: Option = + if inputs.iter().all(|i| i.value_sats.is_some()) { + Some(inputs.iter().map(|i| i.value_sats.unwrap_or(0)).sum()) + } else { + None + }; + let total_out_sats: u64 = outputs.iter().map(|o| o.value_sats).sum(); + let fee_sats = total_in_sats.and_then(|t| t.checked_sub(total_out_sats)); + let is_complete = psbt_mod::is_complete(&parsed); + let has_signable_inputs = + inputs.iter().any(|i| i.is_signable_by_active_wallet); + + let network_label = match state.network { + bitcoin::Network::Bitcoin => "mainnet", + bitcoin::Network::Signet => "signet", + bitcoin::Network::Testnet => "testnet", + bitcoin::Network::Regtest => "regtest", + _ => "unknown", + }; + + Response::PsbtInspected(PsbtInspectResponse { + network: network_label.to_string(), + unsigned_tx_hex: bitcoin::consensus::encode::serialize_hex( + &parsed.unsigned_tx, + ), + txid: inspect.txid.to_string(), + inputs, + outputs, + total_in_sats, + total_out_sats, + fee_sats, + is_complete, + has_signable_inputs, + }) + } + } + } + Request::PsbtCreate { + recipient_address, + amount_sats, + fee_rate_sats_per_vb, + change_index, + bip86_scan_max, + selected_outpoints, + } => match psbt_create_handler( + state, + &recipient_address, + amount_sats, + fee_rate_sats_per_vb, + change_index, + bip86_scan_max, + &selected_outpoints, + ) + .await + { + Ok(r) => Response::PsbtCreated(r), + Err(e) => Response::Error(ErrorResponse { message: e }), + }, + Request::WraithPrepareCoin { + tier_id, + coordinator_url, + coordinator_peers, + receive_index, + fee_rate_sats_per_vb, + bip86_scan_max, + } => match wraith_prepare_coin_handler( + state, + &tier_id, + coordinator_url, + coordinator_peers, + receive_index, + fee_rate_sats_per_vb, + bip86_scan_max, + ) + .await + { + Ok(r) => Response::WraithCoinPrepared(r), + Err(e) => Response::Error(ErrorResponse { message: e }), + }, + Request::PsbtBroadcast { psbt_or_tx_hex } => { + match psbt_broadcast_handler(state, &psbt_or_tx_hex, "send", None).await { + Ok(txid) => Response::PsbtBroadcast(PsbtBroadcastResponse { txid }), + Err(e) => Response::Error(ErrorResponse { message: e }), + } + } + Request::PsbtBumpFee { + psbt, + new_fee_rate_sats_per_vb, + bip86_scan_max, + } => { + match psbt_bump_fee_handler(state, &psbt, new_fee_rate_sats_per_vb, bip86_scan_max) + .await + { + Ok(r) => Response::PsbtBumped(r), + Err(e) => Response::Error(ErrorResponse { message: e }), + } + } + Request::PsbtSign { + psbt, + bip86_scan_max, + } => { use wraith_wallet_core::psbt as psbt_mod; + let scan_max = bip86_scan_max.unwrap_or(psbt_mod::DEFAULT_SCAN_INDEX_MAX); match psbt_mod::decode_psbt(&psbt) { Err(e) => Response::Error(ErrorResponse { message: format!("psbt decode: {e}"), }), - Ok((parsed, _encoding)) => { - let inspect = psbt_mod::inspect(&parsed); + Ok((mut parsed, encoding)) => { let network = state.network; - // Resolve the active wallet (if any) to - // answer the per-input "is this signable - // by me?" question. Inspector still works - // without an active wallet — those flags - // just come back false. - let active = state.active.read().await.clone(); - let scan_max = psbt_mod::DEFAULT_SCAN_INDEX_MAX; - let (input_signable, output_owned) = if let Some(name) = active { - let wallets = state.wallets.read().await; - if let Some(ks) = wallets.get(&name) { - let inputs_flags: Vec = inspect - .inputs - .iter() - .map(|iv| match &iv.script_pubkey { - Some(spk) if spk.is_p2tr() => { - psbt_mod::find_bip86_index_for_script( - ks, network, spk, scan_max, - ) - .unwrap_or(None) - .is_some() - } - _ => false, - }) - .collect(); - let outputs_flags: Vec = inspect - .outputs - .iter() - .map(|ov| { - if !ov.script_pubkey.is_p2tr() { - return false; - } - psbt_mod::find_bip86_index_for_script( - ks, - network, - &ov.script_pubkey, - scan_max, - ) - .unwrap_or(None) - .is_some() - }) - .collect(); - (inputs_flags, outputs_flags) - } else { - ( - vec![false; inspect.inputs.len()], - vec![false; inspect.outputs.len()], - ) + let result = with_active_wallet(state, move |_, ks| { + psbt_mod::sign_owned_inputs(&mut parsed, ks, network, scan_max) + .map(|signed| (signed, parsed)) + .map_err(|e| format!("psbt sign: {e}")) + }) + .await; + match result { + Err(e) => Response::Error(ErrorResponse { message: e }), + Ok((signed, signed_psbt)) => { + let input_count = signed_psbt.unsigned_tx.input.len() as u32; + let is_complete = psbt_mod::is_complete(&signed_psbt); + let encoded = psbt_mod::encode_psbt(&signed_psbt, encoding); + Response::PsbtSigned(PsbtSignResponse { + psbt: encoded, + signed_inputs: signed, + input_count, + is_complete, + }) } - } else { - ( - vec![false; inspect.inputs.len()], - vec![false; inspect.outputs.len()], - ) - }; + } + } + } + } + }; - let inputs: Vec = inspect - .inputs - .iter() - .enumerate() - .map(|(i, iv)| PsbtInputSummary { - previous_txid: iv.previous_txid.to_string(), - previous_vout: iv.previous_vout, - value_sats: iv.value_sats, - script_pubkey_hex: iv - .script_pubkey - .as_ref() - .map(|s| hex::encode(s.as_bytes())), - address: iv - .script_pubkey - .as_ref() - .and_then(|s| psbt_mod::script_to_address(s, network)), - is_finalized: iv.is_finalized, - partial_signatures: iv.partial_signatures, - is_signable_by_active_wallet: input_signable[i] && !iv.is_finalized, - }) - .collect(); - let outputs: Vec = inspect - .outputs - .iter() - .enumerate() - .map(|(i, ov)| PsbtOutputSummary { - value_sats: ov.value_sats, - script_pubkey_hex: hex::encode(ov.script_pubkey.as_bytes()), - address: psbt_mod::script_to_address(&ov.script_pubkey, network), - is_owned_by_active_wallet: output_owned[i], - }) - .collect(); + Envelope::new(id, response) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn unknown_mix_session_error_is_clear() { + let msg = super::unknown_mix_session_error("sess-123"); + assert!(msg.contains("sess-123"), "must name the session: {msg}"); + assert!(msg.contains("not found"), "must say not found: {msg}"); + // Honest about why it's gone: expired or daemon restart mid-round. + assert!( + msg.contains("expired") && msg.contains("restarted"), + "must explain expiry/restart cause: {msg}" + ); + assert!( + msg.contains("start the mix again"), + "must tell the user how to recover: {msg}" + ); + } + + use super::shroud_pick_delay; + + #[test] + fn shroud_disabled_when_max_is_zero() { + for _ in 0..100 { + assert_eq!(shroud_pick_delay(0), None); + } + } + + #[test] + fn shroud_delay_is_within_bounds() { + // Sample across a few distributions to make sure the gen_range + // semantics are inclusive on both ends and never overshoot. + for max in [1u64, 10, 100, 5000, 60_000] { + for _ in 0..256 { + let d = shroud_pick_delay(max).expect("non-zero max yields Some"); + assert!(d <= max, "delay {d} must not exceed max {max}"); + } + } + } + + #[test] + fn shroud_max_one_emits_both_zero_and_one() { + // With max_ms=1 we sample {0, 1}; over 1000 picks both should + // appear. Probability of all-zeros or all-ones is 2 * 2^-1000. + let mut saw_zero = false; + let mut saw_one = false; + for _ in 0..1000 { + match shroud_pick_delay(1) { + Some(0) => saw_zero = true, + Some(1) => saw_one = true, + other => panic!("unexpected delay: {other:?}"), + } + if saw_zero && saw_one { + return; + } + } + panic!("did not see both 0 and 1 across 1000 samples"); + } + + /// Minimal `ChainClient` stub. The handlers exercised here refuse + /// before any I/O, so a status-only error stub is all the + /// `DaemonState` field needs. + struct RejectChain; + + #[async_trait::async_trait] + impl ChainClient for RejectChain { + async fn status( + &self, + ) -> Result + { + Err(wraith_wallet_core::chain::ChainError::Backend( + "test stub".into(), + )) + } + } + + /// A distinct taproot-shaped script, so "ours" and "theirs" can be + /// told apart without needing real keys. + fn spk(tag: u8) -> bitcoin::ScriptBuf { + let mut v = vec![0x51, 0x20]; + v.extend_from_slice(&[tag; 32]); + bitcoin::ScriptBuf::from_bytes(v) + } + + /// A PSBT spending `inputs` into `outputs`, each entry a value and the + /// script tag paying it. + fn test_psbt(inputs: &[(u64, u8)], outputs: &[(u64, u8)]) -> bitcoin::psbt::Psbt { + use bitcoin::hashes::Hash; + use bitcoin::{absolute::LockTime, transaction::Version, Amount, Transaction, TxOut}; + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: (0..inputs.len()) + .map(|i| bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: bitcoin::Txid::from_byte_array([i as u8; 32]), + vout: 0, + }, + ..Default::default() + }) + .collect(), + output: outputs + .iter() + .map(|(v, tag)| TxOut { + value: Amount::from_sat(*v), + script_pubkey: spk(*tag), + }) + .collect(), + }; + let mut psbt = bitcoin::psbt::Psbt::from_unsigned_tx(tx).unwrap(); + for (i, (v, tag)) in inputs.iter().enumerate() { + psbt.inputs[i].witness_utxo = Some(TxOut { + value: Amount::from_sat(*v), + script_pubkey: spk(*tag), + }); + } + psbt + } + + /// The net is what the balance will actually move by — the payment + /// *and* the fee, with change netted back out. A history that showed + /// only the payment would never reconcile against the balance. + #[test] + fn the_net_change_counts_the_fee_and_nets_out_change() { + let ours: std::collections::HashSet> = + [spk(0xaa).to_bytes()].into_iter().collect(); + // 100,000 of ours in; 50,000 to a stranger, 49,500 back as change. + let psbt = test_psbt(&[(100_000, 0xaa)], &[(50_000, 0xbb), (49_500, 0xaa)]); + let (net, fee) = psbt_ledger_effect(&psbt, &ours); + assert_eq!(fee, Some(500)); + assert_eq!( + net, + Some(-50_500), + "the balance drops by the payment plus the fee, not the payment alone" + ); + } + + /// A consolidation pays only the miner. It is not a zero-value event. + #[test] + fn a_self_send_nets_the_fee_only() { + let ours: std::collections::HashSet> = + [spk(0xaa).to_bytes()].into_iter().collect(); + let psbt = test_psbt(&[(10_000, 0xaa), (10_000, 0xaa)], &[(19_800, 0xaa)]); + let (net, fee) = psbt_ledger_effect(&psbt, &ours); + assert_eq!(fee, Some(200)); + assert_eq!(net, Some(-200)); + } + + /// One input of unknown value makes both figures unknowable. A fee + /// computed from only the inputs that happened to be present is not a + /// smaller fee — it is a wrong one, and it would read as authoritative. + #[test] + fn a_missing_input_value_yields_no_figures_rather_than_partial_ones() { + let ours: std::collections::HashSet> = + [spk(0xaa).to_bytes()].into_iter().collect(); + let mut psbt = test_psbt(&[(100_000, 0xaa), (100_000, 0xaa)], &[(199_000, 0xbb)]); + psbt.inputs[1].witness_utxo = None; + let (net, fee) = psbt_ledger_effect(&psbt, &ours); + assert_eq!(fee, None, "a partial fee is a wrong fee"); + assert_eq!(net, None); + } + + /// A chain stub returning one UTXO at a given script, with `address` + /// left `None` — exactly what `scantxoutset` gives back. + struct SpkChain { + spk_hex: String, + sats: u64, + } + + #[async_trait::async_trait] + impl ChainClient for SpkChain { + async fn status( + &self, + ) -> Result + { + Err(wraith_wallet_core::chain::ChainError::Backend( + "stub".into(), + )) + } + async fn scan_utxos( + &self, + _addresses: &[String], + _min_confirmations: u32, + ) -> Result< + wraith_wallet_core::chain::ScanUtxosResponse, + wraith_wallet_core::chain::ChainError, + > { + Ok(wraith_wallet_core::chain::ScanUtxosResponse { + utxos: vec![wraith_wallet_core::chain::ScannedL1Utxo { + txid: "aa".repeat(32), + vout: 0, + amount_sats: self.sats, + scriptpubkey_hex: self.spk_hex.clone(), + // The whole point: the node does not send an address. + address: None, + confirmations: 3, + height: 900_000, + }], + total_sats: self.sats, + chain_height: 900_002, + }) + } + } + + /// A Lock that cannot derive lanes must not save. + /// + /// Saving is where somebody pastes a key, and it was the one place + /// that never checked one. A malformed pubkey stored happily and + /// reported `created: true`; every later operation on that Lock then + /// failed, far from the typo, on a Lock the wallet claimed to have. + #[tokio::test] + async fn a_lock_with_a_malformed_key_is_refused_at_save() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + + // 32 bytes that are not a curve point — what `openssl rand -hex 32` + // gives you about half the time. + let req = serde_json::to_string(&Envelope::new( + 1, + Request::GhostLockSave { + label: Some("broken".into()), + backup_pubkey: "11".repeat(32), + heir_pubkey: "22".repeat(32), + quorum_pubkey: "33".repeat(32), + anchor_height: 900_000, + inherit_height: 950_000, + bip86_index: None, + }, + )) + .unwrap(); + match super::dispatch(&req, &state).await.payload { + Response::Error(e) => assert!( + e.message.contains("x-only public key"), + "the error must name what is wrong: {}", + e.message + ), + other => panic!("a malformed key must not save, got {other:?}"), + } + assert!( + ghost_lock_store_for(&state).unwrap().list().is_empty(), + "and nothing may be persisted" + ); + } - let total_in_sats: Option = - if inputs.iter().all(|i| i.value_sats.is_some()) { - Some(inputs.iter().map(|i| i.value_sats.unwrap_or(0)).sum()) - } else { - None - }; - let total_out_sats: u64 = outputs.iter().map(|o| o.value_sats).sum(); - let fee_sats = total_in_sats.and_then(|t| t.checked_sub(total_out_sats)); - let is_complete = psbt_mod::is_complete(&parsed); - let has_signable_inputs = - inputs.iter().any(|i| i.is_signable_by_active_wallet); + /// A funded lane must not read as empty. + /// + /// Lane coins were attributed by matching the scan's `address` field + /// against the lane's address. `scantxoutset` does not return + /// addresses — it normalises `addr()` into `rawtr()` + /// — so the chain client leaves that field `None` rather than + /// inventing one, every coin was skipped, and a Lock holding real + /// money reported zero in all four compartments. + /// + /// Worth driving the real handler because the failure is silent and + /// reads as a fact: "0 sats" looks like an empty lane, not like a + /// lookup that matched nothing. A test that only checked the fixture's + /// shape would have passed against the broken code. + #[tokio::test] + async fn a_funded_lane_is_attributed_by_script_not_address() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; - let network_label = match state.network { - bitcoin::Network::Bitcoin => "mainnet", - bitcoin::Network::Signet => "signet", - bitcoin::Network::Testnet => "testnet", - bitcoin::Network::Regtest => "regtest", - _ => "unknown", - }; + // A real keystore, because the lanes are derived from the owner's + // key and a stub cannot stand in for it. + let ks = Keystore::from_mnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about", + ) + .expect("keystore from mnemonic"); + state + .wallets + .write() + .await + .insert("harness".to_string(), ks); + + // Real x-only keys — 32 arbitrary bytes are not a curve point, and + // the handler rightly refuses them. + let xonly = |seed: u8| { + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + let sk = SecretKey::from_slice(&[seed; 32]).expect("nonzero scalar"); + let (xk, _) = sk.x_only_public_key(&Secp256k1::new()); + hex::encode(xk.serialize()) + }; + let lanes_req = |id: u64| { + serde_json::to_string(&Envelope::new( + id, + Request::GhostLockLanes { + backup_pubkey: xonly(0x11), + heir_pubkey: xonly(0x22), + quorum_pubkey: xonly(0x33), + anchor_height: 900_000, + inherit_height: 950_000, + bip86_index: None, + }, + )) + .unwrap() + }; - Response::PsbtInspected(PsbtInspectResponse { - network: network_label.to_string(), - unsigned_tx_hex: bitcoin::consensus::encode::serialize_hex( - &parsed.unsigned_tx, - ), - txid: inspect.txid.to_string(), - inputs, - outputs, - total_in_sats, - total_out_sats, - fee_sats, - is_complete, - has_signable_inputs, - }) - } + // First pass: nothing on chain, so learn the lane addresses. + state.clients.write().await.chain = Arc::new(SpkChain { + spk_hex: "51".to_string() + "20" + &"ff".repeat(32), + sats: 0, + }); + let cash_addr = match super::dispatch(&lanes_req(1), &state).await.payload { + Response::GhostLockLanes(r) => r + .lanes + .iter() + .find(|l| l.kind == "cash") + .map(|l| l.address.clone()) + .expect("a cash lane"), + other => panic!("expected lanes, got {other:?}"), + }; + + // Now put a coin at exactly that lane's script — reporting it the + // way the node does, with no address field. + let spk = cash_addr + .parse::>() + .unwrap() + .assume_checked() + .script_pubkey(); + state.clients.write().await.chain = Arc::new(SpkChain { + spk_hex: hex::encode(spk.as_bytes()), + sats: 1_000_000, + }); + + match super::dispatch(&lanes_req(2), &state).await.payload { + Response::GhostLockLanes(r) => { + let cash = r.lanes.iter().find(|l| l.kind == "cash").unwrap(); + assert_eq!( + cash.balance_sats, 1_000_000, + "the funded lane must show its coin, not zero" + ); + let others: u64 = r + .lanes + .iter() + .filter(|l| l.kind != "cash") + .map(|l| l.balance_sats) + .sum(); + assert_eq!(others, 0, "and the coin must land in ONE compartment"); } + other => panic!("expected lanes, got {other:?}"), } - Request::PsbtCreate { - recipient_address, - amount_sats, - fee_rate_sats_per_vb, - change_index, - bip86_scan_max, - selected_outpoints, - } => match psbt_create_handler( - state, - &recipient_address, - amount_sats, - fee_rate_sats_per_vb, - change_index, - bip86_scan_max, - &selected_outpoints, + } + + /// Concurrent history writers must not erase each other. + /// + /// The block scanner and an outgoing payment both write this file. + /// `record` merges — but only against the snapshot taken when the + /// store was opened, so without a lock around open-modify-write + /// whoever flushes last persists a table missing everything the other + /// recorded meanwhile. The scanner used to hold one store open across + /// a whole batch of block fetches, which made that window seconds + /// wide and a payment made during a catch-up scan simply vanished. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_history_writers_do_not_erase_each_other() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + + const WRITERS: usize = 24; + let mut tasks = Vec::with_capacity(WRITERS); + for i in 0..WRITERS { + let state = Arc::clone(&state); + tasks.push(tokio::spawn(async move { + record_history( + &state, + wraith_wallet_core::history_store::HistoryEntry { + txid: format!("tx-{i:04}"), + at: 1_700_000_000 + i as i64, + block_height: None, + amount_sats: Some(1_000 + i as i64), + fee_sats: None, + kind: "send".into(), + memo: None, + }, + ) + .await + })); + } + for (i, t) in tasks.into_iter().enumerate() { + t.await + .unwrap_or_else(|e| panic!("writer {i} panicked: {e}")) + .unwrap_or_else(|e| panic!("writer {i} failed: {e}")); + } + + let store = history_store_for(&state).await.unwrap(); + assert_eq!( + store.len(), + WRITERS, + "history lost entries: {} of {WRITERS} survived", + store.len() + ); + } + + /// A Cash coin is refused from a round; a private-lane coin is not. + /// + /// Compartment rule 1 — "a Cash coin must never enter a round" — was + /// written, tested inside `ghost-lock`, and never called. The only + /// caller of `check_round_eligible` computed a flag for the UI to + /// colour a lane with. Nothing stopped the coin. + /// + /// It could not be enforced before, either: Lock owner keys shared + /// account 0' with the plain wallet, so the Cash lane WAS the wallet's + /// receive address and refusing Cash would have refused every ordinary + /// coin. Keys moved to account 1', which is what makes this checkable. + /// + /// Driven through `dispatch` with an unreachable coordinator on + /// purpose: the refusal must come from the compartment rule, not from + /// a failed connection, which also proves the check runs before the + /// coin is offered to anybody. + #[tokio::test] + async fn a_cash_coin_is_refused_from_a_round_and_a_private_one_is_not() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + let ks = Keystore::from_mnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about", ) - .await + .expect("keystore from mnemonic"); + state + .wallets + .write() + .await + .insert("harness".to_string(), ks); + + let xonly = |seed: u8| { + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + let sk = SecretKey::from_slice(&[seed; 32]).expect("nonzero scalar"); + let (xk, _) = sk.x_only_public_key(&Secp256k1::new()); + hex::encode(xk.serialize()) + }; + + // Remember a Lock, so its lanes are classifiable. + let save = serde_json::to_string(&Envelope::new( + 1, + Request::GhostLockSave { + label: Some("compartment-test".into()), + backup_pubkey: xonly(0x11), + heir_pubkey: xonly(0x22), + quorum_pubkey: xonly(0x33), + anchor_height: 900_000, + inherit_height: 950_000, + bip86_index: None, + }, + )) + .unwrap(); + match super::dispatch(&save, &state).await.payload { + Response::GhostLockSaved(_) => {} + other => panic!("could not remember a Lock: {other:?}"), + } + + // Lane derivation reports balances, so it needs a chain that can + // scan. Nothing is funded; only the addresses matter here. + state.clients.write().await.chain = Arc::new(SpkChain { + spk_hex: "51".to_string() + "20" + &"ff".repeat(32), + sats: 0, + }); + + let lanes = serde_json::to_string(&Envelope::new( + 2, + Request::GhostLockLanes { + backup_pubkey: xonly(0x11), + heir_pubkey: xonly(0x22), + quorum_pubkey: xonly(0x33), + anchor_height: 900_000, + inherit_height: 950_000, + bip86_index: None, + }, + )) + .unwrap(); + let addr_of = match super::dispatch(&lanes, &state).await.payload { + Response::GhostLockLanes(r) => r + .lanes + .iter() + .map(|l| (l.kind.clone(), l.address.clone())) + .collect::>(), + other => panic!("expected lanes, got {other:?}"), + }; + let spk_of = |kind: &str| { + let a = addr_of + .get(kind) + .unwrap_or_else(|| panic!("no {kind} lane")); + let addr = a + .parse::>() + .expect("lane address") + .assume_checked(); + hex::encode(addr.script_pubkey().as_bytes()) + }; + + let mix_req = |id: u64, spk: String| { + serde_json::to_string(&Envelope::new( + id, + Request::WraithMixPrepare { + // Deliberately unreachable: a compartment refusal must + // not depend on a coordinator being there. + coordinator_url: "http://127.0.0.1:1".into(), + socks5_proxy: None, + coordinator_peers: vec![], + tier_id: "100k_sats".into(), + ghost_id: "compartment-test".into(), + utxo_txid: "11".repeat(32), + utxo_vout: 0, + utxo_value_sats: 100_000, + utxo_scriptpubkey_hex: spk, + mix_output_address: addr_of.get("savings").expect("savings lane").clone(), + min_entities: Some(1), + }, + )) + .unwrap() + }; + + // Cash: refused, and by the compartment rule. + match super::dispatch(&mix_req(3, spk_of("cash")), &state) + .await + .payload { - Ok(r) => Response::PsbtCreated(r), - Err(e) => Response::Error(ErrorResponse { message: e }), - }, - Request::WraithPrepareCoin { - tier_id, - coordinator_url, - coordinator_peers, - receive_index, - fee_rate_sats_per_vb, - bip86_scan_max, - } => match wraith_prepare_coin_handler( - state, - &tier_id, - coordinator_url, - coordinator_peers, - receive_index, - fee_rate_sats_per_vb, - bip86_scan_max, - ) - .await + Response::Error(e) => assert!( + e.message.contains("Cash") || e.message.contains("cash"), + "a Cash coin must be refused by the compartment rule, got: {}", + e.message + ), + other => panic!("a Cash coin entered a round: {other:?}"), + } + + // Savings: gets past the compartment gate. It still fails, because + // the coordinator does not exist — which is the point: the failure + // must be the connection, not the rule. + match super::dispatch(&mix_req(4, spk_of("savings")), &state) + .await + .payload { - Ok(r) => Response::WraithCoinPrepared(r), - Err(e) => Response::Error(ErrorResponse { message: e }), - }, - Request::PsbtBroadcast { psbt_or_tx_hex } => { - match psbt_broadcast_handler(state, &psbt_or_tx_hex).await { - Ok(txid) => Response::PsbtBroadcast(PsbtBroadcastResponse { txid }), - Err(e) => Response::Error(ErrorResponse { message: e }), - } + Response::Error(e) => assert!( + !e.message.contains("Cash") && !e.message.contains("cash"), + "a private-lane coin was refused by the Cash rule: {}", + e.message + ), + other => panic!("unexpected success against a dead coordinator: {other:?}"), } - Request::PsbtBumpFee { - psbt, - new_fee_rate_sats_per_vb, - bip86_scan_max, - } => { - match psbt_bump_fee_handler(state, &psbt, new_fee_rate_sats_per_vb, bip86_scan_max) + } + + /// A Lock spend may not reach outside the lane being signed. + /// + /// Three signing handlers — quorum co-sign, escape, and the air-gapped + /// key-path spend — sign one input of a PSBT the CALLER built, so the + /// caller chooses the other inputs. Nothing checked them. + /// + /// `check_spend_together` (compartment rule 3) had no callers at all + /// outside its own tests, and even called it only covers the Cash + /// boundary. It says nothing about a Savings coin spent beside a + /// Spending coin — both are `Compartment::Private` — or beside an + /// ordinary wallet coin. Both prove one owner and collapse exactly the + /// separation the lanes exist to create. + /// + /// The last case is the one that keeps this honest: two coins in the + /// SAME lane must still be spendable together, or the rule has just + /// broken ordinary use. + #[tokio::test] + async fn a_lock_spend_may_not_reach_outside_its_lane() { + use bitcoin::{absolute::LockTime, transaction::Version, OutPoint, TxIn, TxOut}; + + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + let ks = Keystore::from_mnemonic( + "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about", + ) + .expect("keystore from mnemonic"); + state + .wallets + .write() + .await + .insert("harness".to_string(), ks); + state.clients.write().await.chain = Arc::new(SpkChain { + spk_hex: "51".to_string() + "20" + &"ff".repeat(32), + sats: 0, + }); + + let xonly = |seed: u8| { + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + let sk = SecretKey::from_slice(&[seed; 32]).expect("nonzero scalar"); + let (xk, _) = sk.x_only_public_key(&Secp256k1::new()); + hex::encode(xk.serialize()) + }; + let save = serde_json::to_string(&Envelope::new( + 1, + Request::GhostLockSave { + label: Some("lane-rule".into()), + backup_pubkey: xonly(0x11), + heir_pubkey: xonly(0x22), + quorum_pubkey: xonly(0x33), + anchor_height: 900_000, + inherit_height: 950_000, + bip86_index: None, + }, + )) + .unwrap(); + let lock_id = match super::dispatch(&save, &state).await.payload { + Response::GhostLockSaved(r) => r.lock.lock_id, + other => panic!("could not remember a Lock: {other:?}"), + }; + let lanes = serde_json::to_string(&Envelope::new( + 2, + Request::GhostLockLanes { + backup_pubkey: xonly(0x11), + heir_pubkey: xonly(0x22), + quorum_pubkey: xonly(0x33), + anchor_height: 900_000, + inherit_height: 950_000, + bip86_index: None, + }, + )) + .unwrap(); + let addr_of = match super::dispatch(&lanes, &state).await.payload { + Response::GhostLockLanes(r) => r + .lanes + .iter() + .map(|l| (l.kind.clone(), l.address.clone())) + .collect::>(), + other => panic!("expected lanes, got {other:?}"), + }; + let to_spk = |a: &str| { + a.parse::>() + .expect("address") + .assume_checked() + .script_pubkey() + }; + let spk_of = |kind: &str| { + to_spk( + addr_of + .get(kind) + .unwrap_or_else(|| panic!("no {kind} lane")), + ) + }; + + // The wallet's own ordinary receive address — in no lane at all. + let loose = + serde_json::to_string(&Envelope::new(3, Request::LightReceive { index: 0 })) + .unwrap(); + let loose_spk = match super::dispatch(&loose, &state).await.payload { + Response::LightReceive(r) => to_spk(&r.address), + other => panic!("expected a receive address, got {other:?}"), + }; + + let build = |second: bitcoin::ScriptBuf| { + let input = |vout: u32| TxIn { + previous_output: OutPoint { + txid: "11".repeat(32).parse().unwrap(), + vout, + }, + ..Default::default() + }; + let tx = bitcoin::Transaction { + version: Version(2), + lock_time: LockTime::ZERO, + input: vec![input(0), input(1)], + output: vec![TxOut { + value: bitcoin::Amount::from_sat(150_000), + script_pubkey: spk_of("investments"), + }], + }; + let mut psbt = bitcoin::psbt::Psbt::from_unsigned_tx(tx).expect("psbt"); + psbt.inputs[0].witness_utxo = Some(TxOut { + value: bitcoin::Amount::from_sat(100_000), + script_pubkey: spk_of("savings"), + }); + psbt.inputs[1].witness_utxo = Some(TxOut { + value: bitcoin::Amount::from_sat(60_000), + script_pubkey: second, + }); + // base64: the review step downstream accepts only that form, + // so the case that must be ALLOWED has to reach it. + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(psbt.serialize()) + }; + + let sign_req = |id: u64, psbt: String| { + serde_json::to_string(&Envelope::new( + id, + Request::GhostLockSignBegin { + lock_id: lock_id.clone(), + lane: "savings".into(), + psbt, + input_index: 0, + }, + )) + .unwrap() + }; + + // Every one of these, spent beside a Savings coin, is linkage. + for (label, second) in [ + ("a Cash coin", spk_of("cash")), + ("another lane of the same Lock", spk_of("spending")), + ("the wallet's own receive address", loose_spk), + ] { + match super::dispatch(&sign_req(10, build(second)), &state) .await + .payload { - Ok(r) => Response::PsbtBumped(r), - Err(e) => Response::Error(ErrorResponse { message: e }), + Response::Error(e) => assert!( + e.message.contains("refused the spend"), + "spending a Savings coin beside {label} must be refused, got: {}", + e.message + ), + other => panic!("the wallet agreed to link Savings to {label}: {other:?}"), } } - Request::PsbtSign { - psbt, - bip86_scan_max, - } => { - use wraith_wallet_core::psbt as psbt_mod; - let scan_max = bip86_scan_max.unwrap_or(psbt_mod::DEFAULT_SCAN_INDEX_MAX); - match psbt_mod::decode_psbt(&psbt) { - Err(e) => Response::Error(ErrorResponse { - message: format!("psbt decode: {e}"), - }), - Ok((mut parsed, encoding)) => { - let network = state.network; - let result = with_active_wallet(state, move |_, ks| { - psbt_mod::sign_owned_inputs(&mut parsed, ks, network, scan_max) - .map(|signed| (signed, parsed)) - .map_err(|e| format!("psbt sign: {e}")) - }) - .await; - match result { - Err(e) => Response::Error(ErrorResponse { message: e }), - Ok((signed, signed_psbt)) => { - let input_count = signed_psbt.unsigned_tx.input.len() as u32; - let is_complete = psbt_mod::is_complete(&signed_psbt); - let encoded = psbt_mod::encode_psbt(&signed_psbt, encoding); - Response::PsbtSigned(PsbtSignResponse { - psbt: encoded, - signed_inputs: signed, - input_count, - is_complete, - }) - } - } - } - } + + // And the rule must not have broken ordinary use: two coins in the + // same lane still belong in one transaction. + // Reaching the signing machinery at all is a pass: it got past the + // rule. Only a refusal BY the rule is a failure. + if let Response::Error(e) = + super::dispatch(&sign_req(20, build(spk_of("savings"))), &state) + .await + .payload + { + assert!( + !e.message.contains("refused the spend"), + "two coins in the SAME lane must still be spendable together, got: {}", + e.message + ); } - }; + } - Envelope::new(id, response) - } + /// A chain stub that answers with a fixed tip, so confirmation + /// arithmetic can be tested without a node. + struct TipChain(u64); - #[cfg(test)] - mod tests { - use super::*; + #[async_trait::async_trait] + impl ChainClient for TipChain { + async fn status( + &self, + ) -> Result + { + Ok(wraith_wallet_core::chain::ChainStatus { + backend_version: "stub".into(), + network: "regtest".into(), + chain_height: Some(self.0), + chain_headers: Some(self.0), + chain_verification_progress: None, + chain_initial_block_download: Some(false), + }) + } + } + + /// Confirmations count the block the transaction landed in. + /// + /// An entry mined in the tip block has one confirmation, not zero. + /// That off-by-one is the difference between a coin reading as + /// spendable and reading as not there yet. + #[tokio::test] + async fn confirmations_are_inclusive_of_the_mining_block() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + state.clients.write().await.chain = Arc::new(TipChain(900_010)); + + let mut store = history_store_for(&state).await.unwrap(); + for (txid, height) in [("tip", 900_010u32), ("ten_deep", 900_001)] { + store + .record(wraith_wallet_core::history_store::HistoryEntry { + txid: txid.into(), + at: 1, + block_height: Some(height), + amount_sats: Some(1_000), + fee_sats: None, + kind: "receive".into(), + memo: None, + }) + .unwrap(); + } - fn fixture_meta(wallet: &str, lock_id: &str) -> PreparedLockMeta { - PreparedLockMeta { - wallet_name: wallet.into(), - recovery_index: 7, - lock_pubkey_hex: "02".to_string() + &"00".repeat(32), - recovery_pubkey_hex: "03".to_string() + &"11".repeat(32), - recovery_blocks: 1008, - creation_height: 800_000, - funding_address: format!("tb1q{lock_id}"), - capacity_sats: 100_000, - funding_txid: Some("aa".repeat(32)), + match l1_history(&state, 10, 0).await { + Response::LightHistory(h) => { + let by: std::collections::HashMap<_, _> = h + .transactions + .into_iter() + .map(|t| (t.txid.clone(), t)) + .collect(); + assert_eq!(by["tip"].confirmations, Some(1), "the tip block counts"); + assert_eq!(by["ten_deep"].confirmations, Some(10)); + assert_eq!(by["tip"].block_height, Some(900_010)); + } + other => panic!("expected history, got {other:?}"), } } - #[test] - fn round_trip_locks_to_disk_preserves_every_field() { + /// An entry the scanner has not seen mined must not borrow the tip and + /// claim a depth. It is unconfirmed, and the honest count is unknown + /// until the node is asked about it directly. + #[tokio::test] + async fn an_unmined_entry_does_not_infer_confirmations_from_the_tip() { let dir = tempfile::tempdir().unwrap(); - let mut map = HashMap::new(); - let meta = fixture_meta("alice", "lock-A"); - map.insert("lock-A".to_string(), meta.clone()); - super::save_locks_for_wallet(dir.path(), "alice", &map).unwrap(); - - let restored = super::load_locks_for_wallet(dir.path(), "alice"); - assert_eq!(restored.len(), 1); - let r = restored.get("lock-A").unwrap(); - assert_eq!(r.wallet_name, meta.wallet_name); - assert_eq!(r.recovery_index, meta.recovery_index); - assert_eq!(r.lock_pubkey_hex, meta.lock_pubkey_hex); - assert_eq!(r.recovery_pubkey_hex, meta.recovery_pubkey_hex); - assert_eq!(r.recovery_blocks, meta.recovery_blocks); - assert_eq!(r.creation_height, meta.creation_height); - assert_eq!(r.funding_address, meta.funding_address); - assert_eq!(r.capacity_sats, meta.capacity_sats); - assert_eq!(r.funding_txid, meta.funding_txid); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + state.clients.write().await.chain = Arc::new(TipChain(900_010)); + + let mut store = history_store_for(&state).await.unwrap(); + store + .record(wraith_wallet_core::history_store::HistoryEntry { + txid: "pending".into(), + at: 1, + block_height: None, + amount_sats: Some(-1_000), + fee_sats: None, + kind: "send".into(), + memo: None, + }) + .unwrap(); + + match l1_history(&state, 10, 0).await { + Response::LightHistory(h) => { + assert_eq!(h.transactions[0].confirmations, None); + assert_eq!(h.transactions[0].block_height, None); + } + other => panic!("expected history, got {other:?}"), + } } - #[test] - fn load_returns_empty_when_file_missing() { + /// One wallet's history must not appear in another's. + /// + /// The stores used to sit beside `node.json`, shared by every wallet. + /// That is wrong twice over: payments show up under the wrong wallet, + /// and the shared scan bookmark tells the scanner those blocks are + /// already read — so a wallet switched to would never build a history + /// at all. + #[tokio::test] + async fn two_wallets_do_not_share_a_history() { let dir = tempfile::tempdir().unwrap(); - let restored = super::load_locks_for_wallet(dir.path(), "missing"); - assert!(restored.is_empty()); - } + let state = test_state_in(dir.path().to_path_buf()); - #[test] - fn recovery_index_advances_past_persisted_locks() { - use std::sync::atomic::Ordering::SeqCst; - - // Regression: the counter reset to 0 each boot, so after a restart a - // freshly-prepared lock re-issued an index an existing lock already - // used — re-deriving the same recovery key. - let counter = AtomicU32::new(0); - let mut map = HashMap::new(); - let mut a = fixture_meta("alice", "lock-A"); - a.recovery_index = 4; - let mut b = fixture_meta("alice", "lock-B"); - b.recovery_index = 9; // highest - let mut c = fixture_meta("alice", "lock-C"); - c.recovery_index = 2; - map.insert("lock-A".to_string(), a); - map.insert("lock-B".to_string(), b); - map.insert("lock-C".to_string(), c); - - super::advance_recovery_index_past_locks(&counter, &map); - assert_eq!( - counter.load(SeqCst), - 10, - "counter must sit one past the highest persisted recovery_index" + *state.active.write().await = Some("alice".to_string()); + history_store_for(&state) + .await + .unwrap() + .record(wraith_wallet_core::history_store::HistoryEntry { + txid: "alice-tx".into(), + at: 1, + block_height: Some(900_000), + amount_sats: Some(1_000), + fee_sats: None, + kind: "receive".into(), + memo: None, + }) + .unwrap(); + + *state.active.write().await = Some("bob".to_string()); + let bob = history_store_for(&state).await.unwrap(); + assert!( + bob.is_empty(), + "bob must not see alice's payment, got {:?}", + bob.list() ); - // Monotonic: a second wallet with lower indices must not lower it. - let mut map2 = HashMap::new(); - let mut d = fixture_meta("bob", "lock-D"); - d.recovery_index = 3; - map2.insert("lock-D".to_string(), d); - super::advance_recovery_index_past_locks(&counter, &map2); + *state.active.write().await = Some("alice".to_string()); assert_eq!( - counter.load(SeqCst), - 10, - "fetch_max must never lower the counter" + history_store_for(&state).await.unwrap().len(), + 1, + "and alice must still have her own" ); - - // Empty set is a no-op. - super::advance_recovery_index_past_locks(&counter, &HashMap::new()); - assert_eq!(counter.load(SeqCst), 10); } - #[test] - fn load_returns_empty_when_file_corrupt() { + /// A store keyed on the active wallet has nothing to open when there + /// is no active wallet, and says so rather than falling back to a + /// shared file. + #[tokio::test] + async fn a_store_without_an_active_wallet_refuses() { let dir = tempfile::tempdir().unwrap(); - let path = super::locks_path(dir.path(), "borked"); - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(&path, b"this is not json").unwrap(); - let restored = super::load_locks_for_wallet(dir.path(), "borked"); - assert!( - restored.is_empty(), - "corrupt file is logged + ignored, never bubbles" - ); + let state = test_state_in(dir.path().to_path_buf()); + let err = history_store_for(&state).await.expect_err("must refuse"); + assert!(err.contains("no active wallet"), "got: {err}"); } - #[cfg(unix)] - #[test] - fn save_writes_with_mode_0600() { - use std::os::unix::fs::PermissionsExt; + /// The birth height is what a restore reads forward from. Recording + /// one and reading it back is the whole contract the scanner relies + /// on, so it is pinned end to end rather than trusted. + #[tokio::test] + async fn a_recorded_birth_height_is_read_back() { let dir = tempfile::tempdir().unwrap(); - let mut map = HashMap::new(); - map.insert("k".to_string(), fixture_meta("w", "k")); - super::save_locks_for_wallet(dir.path(), "w", &map).unwrap(); - let path = super::locks_path(dir.path(), "w"); - let perm = std::fs::metadata(&path).unwrap().permissions(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + assert_eq!( - perm.mode() & 0o777, - 0o600, - "locks file must be wallet-owner-only readable", + wallet_meta_for(&state).await.unwrap().birth_height, + None, + "a wallet with no recorded height must not invent one" ); - } - #[test] - fn missing_lock_metadata_error_is_accurate() { - let msg = super::missing_lock_metadata_error("lock-XYZ"); - // Names the offending lock so the operator can act on it. - assert!(msg.contains("lock-XYZ"), "must name the lock: {msg}"); - // Points at the real recovery path now that locks persist to - // locks.json and reload on WalletUnlock. - assert!(msg.contains("locks.json"), "must mention locks.json: {msg}"); - assert!( - msg.contains("different wallet/daemon"), - "must explain the cross-wallet/daemon case: {msg}" - ); - // Regression guard: the old message falsely claimed the index was - // in-memory only and lost on restart. That is no longer true. - assert!( - !msg.contains("restarts lose the index"), - "stale, now-false claim must be gone: {msg}" - ); - assert!( - !msg.contains("in-memory"), - "stale in-memory claim must be gone: {msg}" + record_birth_height(&state, "harness", Some(880_000)).await; + assert_eq!( + wallet_meta_for(&state).await.unwrap().birth_height, + Some(880_000) ); } - #[test] - fn unknown_mix_session_error_is_clear() { - let msg = super::unknown_mix_session_error("sess-123"); - assert!(msg.contains("sess-123"), "must name the session: {msg}"); - assert!(msg.contains("not found"), "must say not found: {msg}"); - // Honest about why it's gone: expired or daemon restart mid-round. - assert!( - msg.contains("expired") && msg.contains("restarted"), - "must explain expiry/restart cause: {msg}" + /// A locked wallet cannot tell its own outputs from a stranger's, so + /// it records no amount. Recording a `0` would tell the user the + /// transaction moved nothing, which is the one reading that is + /// certainly wrong. + #[tokio::test] + async fn a_broadcast_without_keys_records_no_amount_rather_than_zero() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + let psbt = test_psbt(&[(10_000, 0xaa)], &[(9_500, 0xbb)]); + record_broadcast(&state, "deadbeef", Some(&psbt), "send", None) + .await + .expect("recording must succeed even with no wallet unlocked"); + let store = history_store_for(&state).await.unwrap(); + let rows = store.list(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].amount_sats, None, + "no keys means no amount, not a zero amount" ); - assert!( - msg.contains("start the mix again"), - "must tell the user how to recover: {msg}" + assert_eq!( + rows[0].fee_sats, + Some(500), + "the fee needs no keys — it is inputs minus outputs" ); } - use super::shroud_pick_delay; - - #[test] - fn shroud_disabled_when_max_is_zero() { - for _ in 0..100 { - assert_eq!(shroud_pick_delay(0), None); - } - } - - #[test] - fn shroud_delay_is_within_bounds() { - // Sample across a few distributions to make sure the gen_range - // semantics are inclusive on both ends and never overshoot. - for max in [1u64, 10, 100, 5000, 60_000] { - for _ in 0..256 { - let d = shroud_pick_delay(max).expect("non-zero max yields Some"); - assert!(d <= max, "delay {d} must not exceed max {max}"); - } - } - } - - #[test] - fn shroud_max_one_emits_both_zero_and_one() { - // With max_ms=1 we sample {0, 1}; over 1000 picks both should - // appear. Probability of all-zeros or all-ones is 2 * 2^-1000. - let mut saw_zero = false; - let mut saw_one = false; - for _ in 0..1000 { - match shroud_pick_delay(1) { - Some(0) => saw_zero = true, - Some(1) => saw_one = true, - other => panic!("unexpected delay: {other:?}"), - } - if saw_zero && saw_one { - return; + /// A backend that cannot answer must not have its silence rendered as + /// "unconfirmed" — that would show every settled payment as pending. + #[tokio::test] + async fn history_reports_unknown_confirmations_as_unknown() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + record_broadcast(&state, "aa11", None, "send", None) + .await + .unwrap(); + match l1_history(&state, 10, 0).await { + Response::LightHistory(h) => { + assert_eq!(h.total_count, 1); + assert_eq!( + h.transactions[0].confirmations, None, + "the stub chain cannot say, so the history must not claim zero" + ); } - } - panic!("did not see both 0 and 1 across 1000 samples"); - } - - // ---- payment-mode gating ------------------------------------- - // - // Send exposes exactly one real mode (`ghostpay`). The former - // `wraith`/`confidential` modes were cosmetic — they parsed into - // a label but took the same plaintext L2 ledger path — so they - // are now refused. These tests lock that in: a retired mode must - // never resolve into an accepted send. - - #[test] - fn parse_payment_mode_accepts_ghostpay_aliases_and_default() { - for s in [ - "", - "ghostpay", - "GhostPay", - "ghost-pay", - "ghost_pay", - " ghostpay ", - ] { - assert!( - matches!(super::parse_payment_mode(s), Ok(PaymentMode::GhostPay)), - "{s:?} should resolve to GhostPay" - ); + other => panic!("expected history, got {other:?}"), } } - #[test] - fn parse_payment_mode_rejects_retired_modes() { - for s in ["wraith", "Wraith", "confidential", "CONFIDENTIAL"] { - let err = super::parse_payment_mode(s) - .expect_err(&format!("retired mode {s:?} must be rejected")); - assert!( - err.contains("not available"), - "{s:?} rejection should explain it is unavailable; got: {err}" - ); + /// `total_count` is the whole history, not the size of the page — a + /// pager that reports the page length can never advance past page one. + #[tokio::test] + async fn paging_reports_the_full_total_not_the_page_size() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_with_wallet(dir.path().to_path_buf()).await; + for i in 0..5u32 { + record_broadcast(&state, &format!("tx{i}"), None, "send", None) + .await + .unwrap(); } - } - - #[test] - fn parse_payment_mode_rejects_unknown() { - let err = - super::parse_payment_mode("banana").expect_err("an unknown mode must be rejected"); - assert!( - err.contains("unknown payment mode"), - "unexpected error text: {err}" - ); - } - - /// Minimal `ChainClient` stub — `light_send` never touches the - /// chain (its gating happens before any I/O), so a status-only - /// error stub is all we need to satisfy the `DaemonState` field. - struct RejectChain; - - #[async_trait::async_trait] - impl ChainClient for RejectChain { - async fn status( - &self, - ) -> Result - { - Err(wraith_wallet_core::chain::ChainError::Backend( - "test stub".into(), - )) + match l1_history(&state, 2, 0).await { + Response::LightHistory(h) => { + assert_eq!(h.transactions.len(), 2, "the page is two"); + assert_eq!(h.total_count, 5, "the total is five"); + } + other => panic!("expected history, got {other:?}"), } } @@ -4867,28 +7043,30 @@ mod server { test_state_in(std::env::temp_dir()) } + /// A state whose per-wallet stores resolve, without unlocking a real + /// keystore. The stores key on the ACTIVE wallet's name; a harness + /// without one exercises the "no active wallet" path instead of the + /// behaviour under test. + async fn test_state_with_wallet(wallets_dir: std::path::PathBuf) -> Arc { + let state = test_state_in(wallets_dir); + *state.active.write().await = Some("harness".to_string()); + state + } + fn test_state_in(wallets_dir: std::path::PathBuf) -> Arc { let node_config_path = wallets_dir.join("node.json"); Arc::new(DaemonState { started: Instant::now(), clients: RwLock::new(NodeClients { chain: Arc::new(RejectChain), - gsp: Arc::new(GspClient::new("ws://127.0.0.1:0")), - ghost_pay_urls: vec!["http://127.0.0.1:0".to_string()], - gsp_urls: vec!["ws://127.0.0.1:0".to_string()], - preset: PRESET_CUSTOM.to_string(), }), - ghost_pay_env_override: false, - gsp_env_override: false, node_config_path, - ghost_pay_internal_auth: None, tor_proxy: None, wraith_coordinator_url: None, kiosk_mode: false, wallets_dir, wallets: RwLock::new(HashMap::new()), active: RwLock::new(None), - session: RwLock::new(None), network: bitcoin::Network::Regtest, endpoint_display: std::env::temp_dir() .join("wraithd-modegate-test.sock") @@ -4898,71 +7076,192 @@ mod server { idle_lock_secs: 0, shroud_max_ms: 0, update_manifest_url: None, + store_locks: std::sync::Mutex::new(HashMap::new()), http: reqwest::Client::new(), wraith_mixes: RwLock::new(HashMap::new()), - prepared_locks: RwLock::new(HashMap::new()), - next_recovery_index: AtomicU32::new(0), - ghostd_url: None, - ghostd_cookie_path: None, - ghostd_user: None, - ghostd_pass: None, + lock_signings: RwLock::new(HashMap::new()), + ghostd: RwLock::new(GhostdSettings::default()), + ghostd_env_override: false, + pool_url: RwLock::new(None), + election_cache: RwLock::new(None), }) } + /// Cash is refused an escape, and told why rather than just "no". + /// + /// Every other lane has a leaf that lets the owner leave alone. Cash + /// does not need one — it already spends with the owner's key on the + /// key path — and a caller who asks should learn that rather than + /// conclude the lane is stuck. #[tokio::test] - async fn light_send_refuses_retired_modes_before_any_send() { + async fn cash_has_no_escape_and_says_so() { let state = test_state(); - for mode in ["wraith", "confidential"] { - let err = super::light_send( - &state, - "tghost1qexample".into(), - 1000, - mode.into(), - None, - Some(0), - ) - .await - .expect_err("a retired mode must be refused, never silently sent"); - // Must fail at the mode gate — NOT by reaching the session - // step. If it reached the session it would return the - // "no GSP session" error, which would mean the mode was - // (wrongly) accepted as sendable. - assert!( - !err.contains("no GSP session"), - "mode `{mode}` must be rejected at the gate before the send path; got: {err}" - ); - assert!( - err.contains("not available"), - "mode `{mode}` rejection should explain it is unavailable; got: {err}" - ); - } + let line = serde_json::to_string(&Envelope::new( + 1, + Request::GhostLockEscapePlan { + lock_id: "no-such-lock".into(), + lane: "cash".into(), + }, + )) + .unwrap(); + let resp = super::dispatch(&line, &state).await; + let Response::Error(e) = resp.payload else { + panic!("Cash must not resolve to an escape plan"); + }; + assert!( + e.message.contains("nothing to wait for"), + "the refusal must explain, not just decline: {}", + e.message + ); + // The lock_id is nonsense on purpose: if the error were about the + // Lock, the lane check would be running too late to be useful. + assert!( + !e.message.contains("no remembered Lock"), + "Cash must be refused before the Lock lookup: {}", + e.message + ); + } + + /// Each lane's key path has different co-signers, and two lanes have + /// no owner-signable key path at all. + /// + /// Getting this wrong does not fail loudly: signing under the wrong + /// pair produces a well-formed signature that simply does not verify + /// against the address, discovered at broadcast. + #[test] + fn each_lane_names_its_own_cosigners() { + use std::str::FromStr; + use wraith_wallet_core::ghost_lock_account::LaneKind; + + let k = |b: u8| { + let sk = bitcoin::secp256k1::SecretKey::from_slice(&[b; 32]).unwrap(); + sk.x_only_public_key(&bitcoin::secp256k1::Secp256k1::new()) + .0 + }; + let owner = k(1); + let backup = k(2); + let quorum = k(3); + let record = wraith_wallet_core::ghost_lock_store::StoredLock { + lock_id: "l".into(), + label: None, + backup_pubkey: hex::encode(backup.serialize()), + heir_pubkey: hex::encode(k(4).serialize()), + quorum_pubkey: hex::encode(quorum.serialize()), + anchor_height: 1, + inherit_height: 2, + bip86_index: 0, + }; + + // Savings co-signs with the backup device. + let savings = super::lane_cosigners(LaneKind::Savings, owner, &record).unwrap(); + assert_eq!(savings, vec![owner, backup]); + + // Spending co-signs with the quorum — a DIFFERENT pair. + let spending = super::lane_cosigners(LaneKind::Spending, owner, &record).unwrap(); + assert_eq!(spending, vec![owner, quorum]); + assert_ne!( + savings, spending, + "the two co-signed lanes must not share a key set" + ); + + // Cash is single-sig; a ceremony here would be theatre. + let err = super::lane_cosigners(LaneKind::Cash, owner, &record) + .expect_err("Cash has no MuSig2 key path"); + assert!(err.contains("your key alone"), "{err}"); + + // Investments is the quorum's alone — the owner cannot co-sign it. + let err = super::lane_cosigners(LaneKind::Investments, owner, &record) + .expect_err("Investments has no owner key path"); + assert!( + err.contains("recall leaf"), + "the refusal must name the way out: {err}" + ); + + let _ = bitcoin::XOnlyPublicKey::from_str(&record.backup_pubkey).unwrap(); } + /// Private entry must refuse the Cash lane, and refuse it at the gate + /// — before the Lock is even looked up. + /// + /// Tested through `dispatch` rather than a helper because the point of + /// putting the rule in the daemon is that it holds for anything that + /// speaks the wire, not just for the CLI that asks nicely. #[tokio::test] - async fn light_send_accepts_ghostpay_past_the_mode_gate() { - // ghostpay (and the empty default) must pass the mode gate. - // With no session configured the send can't complete, but it - // must advance to the session step — proven by the - // "no GSP session" error rather than a mode-rejection error. + async fn a_round_may_not_be_pointed_at_the_cash_lane() { let state = test_state(); - for mode in ["ghostpay", ""] { - let err = super::light_send( - &state, - "tghost1qexample".into(), - 1000, - mode.into(), - None, - Some(0), - ) - .await - .expect_err("no session is configured in this unit test"); + let line = serde_json::to_string(&Envelope::new( + 1, + Request::GhostLockRoundDestination { + lock_id: "no-such-lock".into(), + lane: "cash".into(), + }, + )) + .unwrap(); + let resp = super::dispatch(&line, &state).await; + let Response::Error(e) = resp.payload else { + panic!("Cash must be refused, never resolved to an address"); + }; + assert!( + e.message.contains("Cash"), + "the refusal must name the lane: {}", + e.message + ); + // The lock_id is deliberately nonsense. If the error is about the + // Lock not being found, the compartment rule ran too late — a real + // lock_id would then have sailed past it. + assert!( + !e.message.contains("no remembered Lock"), + "Cash must be refused BEFORE the Lock lookup; got: {}", + e.message + ); + } + + /// The three private lanes get past the compartment gate. They stop at + /// the Lock lookup instead, which is what proves the gate let them + /// through rather than the request failing for some earlier reason. + #[tokio::test] + async fn the_private_lanes_get_past_the_compartment_gate() { + let state = test_state(); + for lane in ["savings", "spending", "investments"] { + let line = serde_json::to_string(&Envelope::new( + 1, + Request::GhostLockRoundDestination { + lock_id: "no-such-lock".into(), + lane: lane.into(), + }, + )) + .unwrap(); + let resp = super::dispatch(&line, &state).await; + let Response::Error(e) = resp.payload else { + panic!("{lane}: a nonexistent Lock cannot resolve to an address"); + }; assert!( - err.contains("no GSP session"), - "ghostpay must clear the mode gate and reach the session step; got: {err}" + !e.message.contains("cannot pay out into Cash"), + "{lane} is a private lane and must not hit the Cash rule: {}", + e.message ); } } + /// An unknown lane name is refused, not silently coerced to a default. + #[tokio::test] + async fn an_unknown_lane_is_refused() { + let state = test_state(); + let line = serde_json::to_string(&Envelope::new( + 1, + Request::GhostLockRoundDestination { + lock_id: "no-such-lock".into(), + lane: "chequing".into(), + }, + )) + .unwrap(); + let resp = super::dispatch(&line, &state).await; + let Response::Error(e) = resp.payload else { + panic!("an unknown lane must not resolve to an address"); + }; + assert!(e.message.contains("unknown lane"), "got: {}", e.message); + } + #[tokio::test] async fn wallet_delete_removes_keystore_and_forgets_active() { let dir = tempfile::tempdir().unwrap(); @@ -5033,11 +7332,11 @@ mod server { #[tokio::test] async fn connection_status_reports_unreachable_without_erroring() { - // With the RejectChain stub (ghost-pay unreachable) and no GSP - // session, ConnectionStatus must still return a structured - // snapshot — NOT a Response::Error. This is what lets the header - // render a clear "unreachable" state instead of a perpetual - // "connecting…" spinner on a laptop with no local endpoints. + // With the RejectChain stub standing in for an unreachable node, + // ConnectionStatus must still return a structured snapshot — NOT + // a Response::Error. That is what lets the header render a clear + // "unreachable" state instead of a perpetual "connecting…" + // spinner on a laptop with nothing running locally. let state = test_state(); let req = serde_json::to_string(&Envelope::new(1, Request::ConnectionStatus)).unwrap(); let resp = super::dispatch(&req, &state).await; @@ -5047,113 +7346,221 @@ mod server { s.network, "regtest", "network is read from config, not the backend" ); + assert!(!s.node_reachable, "the stub must read as unreachable"); assert!( - !s.ghost_pay_reachable, - "RejectChain stub must read as unreachable" + !s.node_configured, + "this harness has no node set, and that is a different \ + state from one that is set and not answering" ); assert!( - s.ghost_pay_error.is_some(), - "an unreachable backend should carry an error hint" - ); - assert!(s.ghost_pay_version.is_none()); - assert!(!s.gsp_have_token, "no session configured in this test"); - assert!(!s.gsp_connected); - assert!(s.gsp_phase.is_none()); - assert!( - !s.chain_synced, - "cannot be synced while ghost-pay is unreachable" + s.node_error.is_none(), + "with no node configured there is nothing to have failed — \ + reporting a probe error would send the user hunting for a \ + fault instead of a setting" ); + assert!(s.node_version.is_none()); + assert!(!s.chain_synced, "cannot be synced with no node"); assert!(s.chain_height.is_none()); } other => panic!("expected ConnectionStatus, got {other:?}"), } } - /// SetNodeEndpoints must: apply the new URLs at runtime, persist them to - /// node.json, and have DaemonEnv reflect the change — all without a - /// restart. + /// `SetNode` must apply at runtime, persist to node.json, and be + /// reflected by `DaemonEnv` — all without a restart. #[tokio::test] - async fn set_node_endpoints_applies_persists_and_surfaces() { + async fn set_node_applies_persists_and_surfaces() { let dir = tempfile::tempdir().unwrap(); let state = test_state_in(dir.path().to_path_buf()); - // Switch to a custom node. let req = serde_json::to_string(&Envelope::new( 1, - Request::SetNodeEndpoints { - preset: "custom".into(), - ghost_pay_url: Some("https://pay.example.com:8800".into()), - gsp_url: Some("wss://gsp.example.com:8900/ws/v1".into()), + Request::SetNode { + ghostd_url: Some("https://node.example.com:8332".into()), + cookie_path: Some("/home/test/.ghost/.cookie".into()), + user: None, + pass: None, + pool_url: None, }, )) .unwrap(); match super::dispatch(&req, &state).await.payload { - Response::NodeEndpointsSet(r) => { - assert_eq!(r.preset, "custom"); - assert_eq!(r.ghost_pay_urls, vec!["https://pay.example.com:8800"]); - assert_eq!(r.gsp_urls, vec!["wss://gsp.example.com:8900/ws/v1"]); + Response::NodeSet(r) => { + assert_eq!( + r.ghostd_url.as_deref(), + Some("https://node.example.com:8332") + ); + assert_eq!(r.auth, "cookie"); + assert!(!r.env_pinned); } - other => panic!("expected NodeEndpointsSet, got {other:?}"), + other => panic!("expected NodeSet, got {other:?}"), } - // Persisted to node.json, and reloadable. let persisted = super::load_node_config(&state.node_config_path).expect("node.json written"); - assert_eq!(persisted.preset, "custom"); assert_eq!( - persisted.ghost_pay_urls, - vec!["https://pay.example.com:8800"] + persisted.ghostd.url.as_deref(), + Some("https://node.example.com:8332") ); - // Live state reflects it via the accessors + DaemonEnv. - assert_eq!( - state.ghost_pay_urls().await, - vec!["https://pay.example.com:8800".to_string()] - ); let env = serde_json::to_string(&Envelope::new(2, Request::DaemonEnv)).unwrap(); match super::dispatch(&env, &state).await.payload { Response::DaemonEnv(e) => { - assert_eq!(e.node_preset, "custom"); - assert_eq!(e.gsp_urls, vec!["wss://gsp.example.com:8900/ws/v1"]); - assert!(!e.ghost_pay_env_override); + assert_eq!( + e.ghostd_url.as_deref(), + Some("https://node.example.com:8332") + ); + assert_eq!(e.ghostd_auth, "cookie"); + assert!(!e.ghostd_env_override); } other => panic!("expected DaemonEnv, got {other:?}"), } + } + + /// The pool is optional, and its absence is silence rather than an + /// error: mixing still works with a coordinator URL supplied per + /// round, it just never rotates. + #[tokio::test] + async fn no_pool_configured_means_no_election_and_no_network_call() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_in(dir.path().to_path_buf()); + assert!(state.pool_url.read().await.is_none()); + // Returns without touching the chain stub, which would error. + assert!(verified_election(&state).await.is_none()); + } - // Switching to the public preset ignores the URL fields and applies - // the bundled fleet endpoints. - let pub_req = serde_json::to_string(&Envelope::new( - 3, - Request::SetNodeEndpoints { - preset: "public".into(), - ghost_pay_url: None, - gsp_url: None, + /// The pool URL is persisted and reported back, so a settings screen + /// can show what is in force after a restart. + #[tokio::test] + async fn a_pool_url_is_persisted_and_surfaced() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_in(dir.path().to_path_buf()); + let req = serde_json::to_string(&Envelope::new( + 1, + Request::SetNode { + ghostd_url: Some("http://127.0.0.1:8332".into()), + cookie_path: None, + user: None, + pass: None, + pool_url: Some("https://pool.example:8443".into()), }, )) .unwrap(); - match super::dispatch(&pub_req, &state).await.payload { - Response::NodeEndpointsSet(r) => { - assert_eq!(r.preset, "public"); - assert_eq!(r.ghost_pay_urls, vec![super::PUBLIC_GHOST_PAY.to_string()]); - assert_eq!(r.gsp_urls, vec![super::PUBLIC_GSP.to_string()]); + match super::dispatch(&req, &state).await.payload { + Response::NodeSet(r) => { + assert_eq!(r.pool_url.as_deref(), Some("https://pool.example:8443")) } - other => panic!("expected NodeEndpointsSet, got {other:?}"), + other => panic!("expected NodeSet, got {other:?}"), } + let persisted = super::load_node_config(&state.node_config_path).unwrap(); + assert_eq!( + persisted.pool_url.as_deref(), + Some("https://pool.example:8443") + ); + + let env = serde_json::to_string(&Envelope::new(2, Request::DaemonEnv)).unwrap(); + match super::dispatch(&env, &state).await.payload { + Response::DaemonEnv(e) => { + assert_eq!(e.pool_url.as_deref(), Some("https://pool.example:8443")) + } + other => panic!("expected DaemonEnv, got {other:?}"), + } + } + + /// Changing the pool must drop what the last one said. + /// + /// The election is cached for a whole epoch — about a day — so a stale + /// entry would keep sending rounds to the previous pool's seat long + /// after the user pointed the wallet somewhere else. + #[tokio::test] + async fn changing_the_pool_invalidates_the_cached_election() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_in(dir.path().to_path_buf()); + *state.election_cache.write().await = Some((7, serde_json::json!({ "enabled": true }))); + + state + .set_node( + GhostdSettings { + url: Some("http://127.0.0.1:8332".into()), + ..Default::default() + }, + Some("https://other.example:8443".into()), + ) + .await + .expect("set node"); + + assert!( + state.election_cache.read().await.is_none(), + "a cached election must not outlive the pool that served it" + ); + } + + /// A malformed pool URL is refused, and nothing is persisted — the + /// same rule the node URL follows. + #[tokio::test] + async fn a_pool_url_with_the_wrong_scheme_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_in(dir.path().to_path_buf()); + let err = state + .set_node(GhostdSettings::default(), Some("ws://pool.example".into())) + .await + .expect_err("must refuse"); + assert!(err.contains("pool URL"), "got: {err}"); + assert!( + !state.node_config_path.exists(), + "a rejected change must not write node.json" + ); + } + + /// The RPC password must never come back out over the IPC. + /// + /// A settings screen needs to know *how* the wallet authenticates, and + /// nothing more. Echoing the secret back would put it in every log, + /// screenshot and bug report that captured an IPC trace. + #[tokio::test] + async fn the_node_password_never_crosses_the_ipc() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state_in(dir.path().to_path_buf()); + let req = serde_json::to_string(&Envelope::new( + 1, + Request::SetNode { + ghostd_url: Some("http://127.0.0.1:8332".into()), + cookie_path: None, + user: Some("ghost".into()), + pass: Some("hunter2-the-secret".into()), + pool_url: None, + }, + )) + .unwrap(); + let reply = super::dispatch(&req, &state).await; + let wire = serde_json::to_string(&reply).unwrap(); + assert!( + !wire.contains("hunter2-the-secret"), + "the password must not appear in the reply: {wire}" + ); + let env = serde_json::to_string(&Envelope::new(2, Request::DaemonEnv)).unwrap(); + let wire = serde_json::to_string(&super::dispatch(&env, &state).await).unwrap(); + assert!( + !wire.contains("hunter2-the-secret"), + "the password must not appear in DaemonEnv either: {wire}" + ); } - /// A custom node with a wrong-scheme URL is rejected, and nothing is - /// persisted — a typo must never silently point the wallet at nothing. + /// A wrong-scheme URL is rejected, and nothing is persisted — a typo + /// must never silently point the wallet at nothing. #[tokio::test] - async fn set_node_endpoints_rejects_bad_scheme() { + async fn set_node_rejects_bad_scheme() { let dir = tempfile::tempdir().unwrap(); let state = test_state_in(dir.path().to_path_buf()); let req = serde_json::to_string(&Envelope::new( 1, - Request::SetNodeEndpoints { - preset: "custom".into(), - // ws:// where http(s):// is required for ghost-pay. - ghost_pay_url: Some("ws://pay.example.com:8800".into()), - gsp_url: Some("wss://gsp.example.com:8900/ws/v1".into()), + Request::SetNode { + // ws:// where http(s):// is required for an RPC endpoint. + ghostd_url: Some("ws://node.example.com:8332".into()), + cookie_path: None, + user: None, + pass: None, + pool_url: None, }, )) .unwrap(); @@ -5171,16 +7578,15 @@ mod server { ); } - /// While an env-var override pins the endpoints, SetNodeEndpoints is - /// refused — env vars keep power-user precedence. + /// While the environment pins the node, `SetNode` is refused — env + /// vars keep power-user precedence. #[tokio::test] - async fn set_node_endpoints_refused_under_env_override() { + async fn set_node_refused_under_env_override() { let dir = tempfile::tempdir().unwrap(); let mut state = test_state_in(dir.path().to_path_buf()); - // Simulate a boot with WRAITHD_GHOST_PAY set. - Arc::get_mut(&mut state).unwrap().ghost_pay_env_override = true; + Arc::get_mut(&mut state).unwrap().ghostd_env_override = true; let err = state - .set_node_endpoints("public", None, None) + .set_node(GhostdSettings::default(), None) .await .expect_err("must refuse while env override is active"); assert!( diff --git a/apps/wraith-wallet/daemon/tests/mainnet_guards.rs b/apps/wraith-wallet/daemon/tests/mainnet_guards.rs index 954c32a00..e7f779e6e 100644 --- a/apps/wraith-wallet/daemon/tests/mainnet_guards.rs +++ b/apps/wraith-wallet/daemon/tests/mainnet_guards.rs @@ -92,6 +92,7 @@ async fn mainnet_refuses_canonical_test_vector() { &socket, 1, Request::WalletImport { + birth_height: None, name: "blocked".into(), mnemonic: CANONICAL_TEST_VECTOR.into(), passphrase: "doesnt-matter-aaaaaaaaaaa".into(), @@ -122,6 +123,7 @@ async fn signet_allows_canonical_test_vector() { &socket, 1, Request::WalletImport { + birth_height: None, name: "ok".into(), mnemonic: CANONICAL_TEST_VECTOR.into(), passphrase: "signet-test-passphrase-aaa".into(), @@ -135,115 +137,73 @@ async fn signet_allows_canonical_test_vector() { child.kill().await.ok(); } +/// A node reached in the clear over a network fails the mainnet TLS row. +/// +/// The RPC connection carries the wallet's addresses and its transactions +/// before they are broadcast. In the clear, anyone on the path learns both. #[tokio::test] -async fn doctor_mainnet_flags_plaintext_remote_endpoints() { - // Point ghost-pay at a non-loopback http:// host. The doctor's - // mainnet/ghost-pay-tls row must be `fail`. +async fn doctor_mainnet_flags_a_plaintext_remote_node() { let (mut child, socket, _tmp) = spawn_daemon_with_env( "mainnet", - &[ - ("WRAITHD_GHOST_PAY", "http://203.0.113.5:8800"), - ("WRAITHD_GSP", "ws://203.0.113.5:8900/ws/v1"), - ], + &[("WRAITHD_GHOSTD_URL", "http://203.0.113.5:8332")], ) .await; match rpc(&socket, 1, Request::Doctor).await { Response::Doctor(d) => { - let pay = d + let row = d .checks .iter() - .find(|c| c.name == "mainnet/ghost-pay tls") - .expect("ghost-pay tls row present on mainnet"); + .find(|c| c.name == "mainnet/node tls") + .expect("node tls row present on mainnet"); assert_eq!( - pay.status, "fail", - "non-loopback http:// must fail: {pay:?}" + row.status, "fail", + "non-loopback http:// must fail: {row:?}" ); - let gsp = d - .checks - .iter() - .find(|c| c.name == "mainnet/gsp tls") - .expect("gsp tls row present on mainnet"); - assert_eq!(gsp.status, "fail", "non-loopback ws:// must fail: {gsp:?}"); } other => panic!("expected Doctor, got {other:?}"), } child.kill().await.ok(); } +/// Loopback plaintext is fine: the traffic never leaves the machine, and TLS +/// there is CPU burned for no privacy gain. #[tokio::test] -async fn doctor_mainnet_passes_loopback_plaintext() { - // A plain mainnet daemon (no endpoint env vars, no persisted node.json) - // defaults to the bundled public preset, which is https:// + wss:// — so - // the TLS rows pass. Loopback http:// / ws:// is also exempt (a wallet - // talking to ghost-pay on the same box doesn't need TLS); either way a - // default mainnet daemon should pass the TLS rows. - let (mut child, socket, _tmp) = spawn_daemon("mainnet").await; +async fn doctor_mainnet_passes_a_loopback_node() { + let (mut child, socket, _tmp) = spawn_daemon_with_env( + "mainnet", + &[("WRAITHD_GHOSTD_URL", "http://127.0.0.1:8332")], + ) + .await; match rpc(&socket, 1, Request::Doctor).await { Response::Doctor(d) => { - let pay = d - .checks - .iter() - .find(|c| c.name == "mainnet/ghost-pay tls") - .expect("ghost-pay tls row present"); - assert_eq!(pay.status, "pass", "loopback http:// must pass: {pay:?}"); - let gsp = d + let row = d .checks .iter() - .find(|c| c.name == "mainnet/gsp tls") - .expect("gsp tls row present"); - assert_eq!(gsp.status, "pass", "loopback ws:// must pass: {gsp:?}"); + .find(|c| c.name == "mainnet/node tls") + .expect("node tls row present"); + assert_eq!(row.status, "pass", "loopback http:// must pass: {row:?}"); } other => panic!("expected Doctor, got {other:?}"), } child.kill().await.ok(); } +/// With no node set there is nothing to have got wrong, so the row skips +/// rather than fails — a red row here would send someone hunting for a +/// misconfiguration when what is missing is a configuration. #[tokio::test] -async fn doctor_signet_omits_mainnet_rows() { - // The mainnet rows are mainnet-only — signet doesn't get them at all. - // A signet operator pointing at an http:// ghost-pay is doing nothing - // wrong and we shouldn't pretend they are. - let (mut child, socket, _tmp) = spawn_daemon_with_env( - "signet", - &[("WRAITHD_GHOST_PAY", "http://203.0.113.5:8800")], - ) - .await; +async fn doctor_mainnet_skips_the_tls_row_with_no_node() { + let (mut child, socket, _tmp) = spawn_daemon("mainnet").await; match rpc(&socket, 1, Request::Doctor).await { Response::Doctor(d) => { - assert!( - d.checks.iter().all(|c| !c.name.starts_with("mainnet/")), - "signet doctor must not emit mainnet/ rows; got {:?}", - d.checks.iter().map(|c| &c.name).collect::>() - ); + let row = d + .checks + .iter() + .find(|c| c.name == "mainnet/node tls") + .expect("node tls row present"); + assert_eq!(row.status, "skip", "no node is not a TLS failure: {row:?}"); } other => panic!("expected Doctor, got {other:?}"), } child.kill().await.ok(); } - -#[tokio::test] -async fn mainnet_allows_a_strong_mnemonic() { - // Sanity: the guard rejects only the curated weak list, not arbitrary - // valid mnemonics. Use a BIP-39 reference vector that's NOT on the - // weak list — proves the guard is precise, not a blanket "no - // imports on mainnet". (This vector is published in the BIP-39 spec - // so don't ever actually use it on mainnet — but it's distinct from - // the all-abandon vector that the guard blocks.) - let strong = "legal winner thank year wave sausage worth useful legal winner thank yellow"; - let (mut child, socket, _tmp) = spawn_daemon("mainnet").await; - match rpc( - &socket, - 1, - Request::WalletImport { - name: "strong".into(), - mnemonic: strong.into(), - passphrase: "mainnet-test-passphrase-aa".into(), - }, - ) - .await - { - Response::WalletImported { name, .. } => assert_eq!(name, "strong"), - other => panic!("strong mnemonic must import on mainnet, got {other:?}"), - } - child.kill().await.ok(); -} diff --git a/apps/wraith-wallet/daemon/tests/wallet_lifecycle.rs b/apps/wraith-wallet/daemon/tests/wallet_lifecycle.rs index 0e243ff5c..ca3cb48dd 100644 --- a/apps/wraith-wallet/daemon/tests/wallet_lifecycle.rs +++ b/apps/wraith-wallet/daemon/tests/wallet_lifecycle.rs @@ -213,6 +213,7 @@ async fn wallet_lifecycle_round_trip() { &socket, 8, Request::WalletImport { + birth_height: None, name: "beta".into(), mnemonic: known.clone(), passphrase: pass.clone(), @@ -229,6 +230,7 @@ async fn wallet_lifecycle_round_trip() { &socket, 9, Request::WalletImport { + birth_height: None, name: "beta".into(), mnemonic: known, passphrase: pass.clone(), @@ -530,53 +532,3 @@ async fn idle_lock_locks_wallets_after_threshold() { child.kill().await.ok(); } - -/// WatchPayments before any gsp_auth must return a clean Error envelope on -/// the same connection and not panic the daemon. Pinned because the streaming -/// code path is structurally different from the request/response dispatch and -/// regressions there are easy to miss. -#[tokio::test] -async fn watch_payments_without_session_errors_cleanly() { - let (mut child, socket, _tmp) = spawn_daemon().await; - - // Open a connection, send WatchPayments. The daemon should send the - // Watching ack on the original id, then immediately send an Error envelope - // (id=0) saying "no active session", and close. - let stream = UnixStream::connect(&socket).await.expect("connect"); - let (reader, mut writer) = stream.into_split(); - let mut line = - serde_json::to_string(&Envelope::new(42, Request::WatchPayments)).expect("serialise"); - line.push('\n'); - writer.write_all(line.as_bytes()).await.expect("write"); - - let mut reader = BufReader::new(reader); - - // First reply: the ack. - let mut ack_line = String::new(); - reader.read_line(&mut ack_line).await.expect("read ack"); - let ack: Envelope = serde_json::from_str(&ack_line).expect("decode ack"); - assert_eq!(ack.id, 42); - assert!( - matches!(ack.payload, Response::Watching), - "expected Watching ack, got {:?}", - ack.payload - ); - - // Second reply: the no-session error pushed with id=0. - let mut err_line = String::new(); - reader.read_line(&mut err_line).await.expect("read err"); - let err: Envelope = serde_json::from_str(&err_line).expect("decode err"); - assert_eq!(err.id, 0, "push must use id=0"); - match err.payload { - Response::Error(e) => { - assert!( - e.message.to_lowercase().contains("session"), - "expected session-related error, got: {}", - e.message - ); - } - other => panic!("expected Error push, got {other:?}"), - } - - child.kill().await.ok(); -} diff --git a/apps/wraith-wallet/daemon/tests/watch_payments_daemon.rs b/apps/wraith-wallet/daemon/tests/watch_payments_daemon.rs deleted file mode 100644 index 5b3f77869..000000000 --- a/apps/wraith-wallet/daemon/tests/watch_payments_daemon.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Daemon-level happy-path test for the WatchPayments stream. -//! -//! This is the contract test for the **whole** push-channel: wraithd opens a -//! WS to a mock GSP, gets an Authenticate ack, the mock pushes a synthetic -//! BIP-352 candidate, the daemon's session task scans + emits on the -//! broadcast channel, and `run_watch_payments` marshals it back as a -//! `Response::PaymentDetected` envelope (id=0) on the IPC socket. -//! -//! The unit-level core/tests/watch_payments.rs covers the broadcast wiring; -//! daemon/tests/wallet_lifecycle.rs covers the IPC marshalling for one-shot -//! requests + the no-session error-push shape. This file is the only piece -//! that proves they work end-to-end together. - -use std::path::PathBuf; -use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; - -use axum::{ - extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, - extract::{Json, State}, - response::IntoResponse, - routing::{get, post}, - Router, -}; -use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use ghost_gsp_proto::{ - CandidateOutput, ClientMessage, RegisterRequest, RegisterResponse, ServerMessage, - SessionRequest, SessionResponse, SessionToken, -}; -use ghost_keys::{derive_payment_address_v2, derive_shared_secret, GhostKeys}; -use rand::RngCore; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::UnixStream; -use tokio::process::{Child, Command}; -use wraith_wallet_core::keystore::Keystore; -use wraith_wallet_ipc::{Envelope, Request, Response}; - -/// Standard BIP-39 test vector — gives us deterministic GhostKeys to target. -const TEST_MNEMONIC: &str = - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; - -#[derive(Clone)] -struct MockState { - candidate: Arc, -} - -async fn mock_register(Json(req): Json) -> Json { - let wallet_id = req.proof.wallet_id().expect("proof yields wallet_id"); - Json(RegisterResponse { - success: true, - wallet_id: Some(wallet_id), - error: None, - }) -} - -async fn mock_session(Json(req): Json) -> Json { - let wallet_id = req.derive_wallet_id().expect("derive session wallet_id"); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - let token = SessionToken { - token: "mock.jwt.token".into(), - wallet_id, - created_at: now, - expires_at: now + 3600, - }; - Json(SessionResponse { - success: true, - token: Some(token.clone()), - expires_at: Some(token.expires_at), - error: None, - }) -} - -async fn mock_ws(state: State, ws: WebSocketUpgrade) -> impl IntoResponse { - ws.on_upgrade(move |socket| handle_ws(socket, state.candidate.clone())) -} - -async fn handle_ws(mut socket: WebSocket, candidate: Arc) { - // Drain incoming. After Authenticate, we authenticate; after GetBalance we - // emit a balance, and from then on we push the candidate every 200 ms so - // there's no race vs. the watch IPC connection setup. Stops on Close. - let mut authenticated = false; - loop { - tokio::select! { - biased; - frame = socket.recv() => { - let Some(Ok(frame)) = frame else { return }; - let text = match frame { - WsMessage::Text(t) => t, - WsMessage::Close(_) => return, - _ => continue, - }; - let msg: ClientMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - match msg { - ClientMessage::Authenticate { .. } => { - let auth = ServerMessage::AuthResult { - success: true, - wallet_id: Some("mock-wallet".into()), - error: None, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&auth).unwrap())) - .await; - authenticated = true; - } - ClientMessage::GetBalance { .. } => { - let bal = ServerMessage::BalanceUpdate { - confirmed: 0, - unconfirmed: 0, - locked: 0, - }; - let _ = socket - .send(WsMessage::Text(serde_json::to_string(&bal).unwrap())) - .await; - } - _ => {} - } - } - _ = tokio::time::sleep(Duration::from_millis(200)) => { - if !authenticated { continue; } - if socket - .send(WsMessage::Text(serde_json::to_string(&*candidate).unwrap())) - .await - .is_err() - { - return; - } - } - } - } -} - -fn build_synthetic_candidate(keys: &GhostKeys) -> ServerMessage { - let secp = Secp256k1::new(); - let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); - let eph_secret = SecretKey::from_slice(&bytes).expect("nonzero scalar"); - let ephemeral_pub = PublicKey::from_secret_key(&secp, &eph_secret); - let shared = derive_shared_secret(&eph_secret, keys.scan_pubkey()); - let (output_pub, _) = - derive_payment_address_v2(keys.spend_pubkey(), &shared, 0).expect("derive output pubkey"); - let serialized = output_pub.serialize(); - let xonly = &serialized[1..]; - - ServerMessage::CandidateTransaction { - ephemeral_pubkey: hex::encode(ephemeral_pub.serialize()), - outputs: vec![CandidateOutput { - output_pubkey: hex::encode(xonly), - amount_sats: Some(73_000), - vout: 3, - }], - txid: "1".repeat(64), - block_height: Some(900_001), - } -} - -async fn spawn_mock(candidate: ServerMessage) -> std::net::SocketAddr { - let app = Router::new() - .route("/api/v1/register", post(mock_register)) - .route("/api/v1/session", post(mock_session)) - .route("/ws/v1", get(mock_ws)) - .with_state(MockState { - candidate: Arc::new(candidate), - }); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - tokio::time::sleep(Duration::from_millis(30)).await; - addr -} - -fn wraithd_binary() -> PathBuf { - if let Some(p) = option_env!("CARGO_BIN_EXE_wraithd") { - return PathBuf::from(p); - } - let exe = std::env::current_exe().expect("current_exe"); - let mut dir = exe.parent().expect("exe parent").to_path_buf(); - while dir.pop() { - let candidate = dir.join("wraithd"); - if candidate.exists() { - return candidate; - } - } - panic!("wraithd binary not found") -} - -async fn spawn_daemon(gsp_url: &str) -> (Child, PathBuf, tempfile::TempDir) { - let tmp = tempfile::tempdir().expect("tempdir"); - let socket = tmp.path().join("wraithd.sock"); - let wallets = tmp.path().join("wallets"); - std::fs::create_dir_all(&wallets).expect("mkdir wallets"); - - let child = Command::new(wraithd_binary()) - .env("WRAITHD_SOCKET", &socket) - .env("WRAITHD_WALLETS_DIR", &wallets) - // This test imports a publicly-known test mnemonic, which the daemon now - // (correctly) refuses on the mainnet default. Pin the test to signet. - .env("WRAITHD_NETWORK", "signet") - .env("WRAITHD_GSP", gsp_url) - // ghost-pay is unused on this test path. Point it at a dead address - // so the daemon doesn't accidentally hit a real local instance. - .env("WRAITHD_GHOST_PAY", "http://127.0.0.1:1") - .env("RUST_LOG", "warn") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .expect("spawn wraithd"); - - let deadline = std::time::Instant::now() + Duration::from_secs(3); - while std::time::Instant::now() < deadline { - if socket.exists() { - tokio::time::sleep(Duration::from_millis(40)).await; - return (child, socket, tmp); - } - tokio::time::sleep(Duration::from_millis(40)).await; - } - panic!("wraithd socket never appeared"); -} - -async fn rpc(socket: &PathBuf, id: u64, request: Request) -> Response { - let stream = UnixStream::connect(socket).await.expect("connect"); - let (reader, mut writer) = stream.into_split(); - let mut line = serde_json::to_string(&Envelope::new(id, request)).expect("serialise"); - line.push('\n'); - writer.write_all(line.as_bytes()).await.expect("write"); - writer.shutdown().await.expect("shutdown"); - let mut buf = String::new(); - BufReader::new(reader) - .read_line(&mut buf) - .await - .expect("read"); - let env: Envelope = serde_json::from_str(&buf).expect("decode"); - assert_eq!(env.id, id); - env.payload -} - -#[tokio::test] -async fn watch_payments_happy_path_through_daemon() { - // Deterministic keys for the synthetic match. We use the same mnemonic on - // both sides — the test computes the candidate against these GhostKeys, - // and the daemon-imported wallet derives the same keys via Keystore. - let keystore = Keystore::from_mnemonic(TEST_MNEMONIC).expect("from_mnemonic"); - let keys = keystore.ghost_keys().expect("ghost_keys"); - let candidate = build_synthetic_candidate(&keys); - - let mock_addr = spawn_mock(candidate).await; - let gsp_url = format!("ws://{mock_addr}/ws/v1"); - - let (mut child, socket, _tmp) = spawn_daemon(&gsp_url).await; - - // Import the wallet keyed to the same mnemonic; gsp_auth needs an - // unlocked active wallet to derive the auth keypair. - match rpc( - &socket, - 1, - Request::WalletImport { - name: "mock".into(), - mnemonic: TEST_MNEMONIC.into(), - passphrase: "watch-test-passphrase-aaa".into(), - }, - ) - .await - { - Response::WalletImported { name, .. } => assert_eq!(name, "mock"), - other => panic!("expected WalletImported, got {other:?}"), - } - - match rpc(&socket, 2, Request::GspAuth).await { - Response::GspAuth(_) => {} - other => panic!("gsp_auth failed: {other:?}"), - } - - // Open the WatchPayments stream. The first reply is the Watching ack; - // subsequent envelopes are pushes (id=0). The mock pushes the candidate - // every 200ms, so we should see at least one detection within ~1s. - let stream = UnixStream::connect(&socket).await.expect("watch connect"); - let (reader, mut writer) = stream.into_split(); - let mut line = - serde_json::to_string(&Envelope::new(99, Request::WatchPayments)).expect("serialise"); - line.push('\n'); - writer.write_all(line.as_bytes()).await.expect("write"); - - let mut reader = BufReader::new(reader); - let mut ack_line = String::new(); - reader.read_line(&mut ack_line).await.expect("read ack"); - let ack: Envelope = serde_json::from_str(&ack_line).expect("decode ack"); - assert_eq!(ack.id, 99); - assert!(matches!(ack.payload, Response::Watching), "ack"); - - // Read the first detection push within 5s. - let mut detected_line = String::new(); - let read = tokio::time::timeout(Duration::from_secs(5), reader.read_line(&mut detected_line)) - .await - .expect("watch never delivered"); - read.expect("read detection"); - let env: Envelope = serde_json::from_str(&detected_line).expect("decode push"); - assert_eq!(env.id, 0, "push must use id=0"); - match env.payload { - Response::PaymentDetected(d) => { - assert_eq!(d.amount_sats, Some(73_000)); - assert_eq!(d.vout, 3); - assert_eq!(d.k, 0); - assert_eq!(d.block_height, Some(900_001)); - assert_eq!(d.txid, "1".repeat(64)); - } - other => panic!("expected PaymentDetected, got {other:?}"), - } - - child.kill().await.ok(); -} diff --git a/apps/wraith-wallet/gui/src-tauri/src/lib.rs b/apps/wraith-wallet/gui/src-tauri/src/lib.rs index a28e67fea..0608f0fac 100644 --- a/apps/wraith-wallet/gui/src-tauri/src/lib.rs +++ b/apps/wraith-wallet/gui/src-tauri/src/lib.rs @@ -8,12 +8,10 @@ //! is fleshed out. use interprocess::local_socket::traits::tokio::Stream as _; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, WindowEvent, + Manager, WindowEvent, }; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use wraith_wallet_ipc::{Envelope, Request, Response}; @@ -121,24 +119,6 @@ async fn ensure_daemon() { } } -/// Coordinates the long-lived watch task so we don't accidentally spawn a -/// second one if the frontend calls `start_watch()` twice. Frontends that need -/// per-window subscriptions should manage that themselves; this is a -/// daemon-wide singleton from the Rust side's perspective. -struct WatchState { - running: AtomicBool, -} - -impl WatchState { - fn new() -> Self { - Self { - running: AtomicBool::new(false), - } - } -} - -/// Tauri command: ask the daemon for its health and return a JSON-serializable -/// summary. Used by the frontend to render a "daemon up" badge. #[tauri::command] async fn daemon_health() -> Result { let resp = call_daemon(Request::Health).await?; @@ -213,11 +193,13 @@ async fn wallet_import( name: String, mnemonic: String, passphrase: String, + birth_height: Option, ) -> Result { let resp = call_daemon(Request::WalletImport { name, mnemonic, passphrase, + birth_height, }) .await?; to_value(&resp) @@ -298,20 +280,22 @@ async fn connection_status() -> Result { to_value(&resp) } -/// Choose which node the wallet talks to. `preset` is `"public"` (the -/// bundled Ghost fleet) or `"custom"` (uses `ghost_pay_url` + `gsp_url`). -/// The daemon rebuilds its clients in place, persists the choice, and drops -/// any live GSP session so it re-authenticates against the new endpoint. +/// Point the wallet at a node. Clearing every field clears the node, after +/// which chain operations refuse until one is set again. #[tauri::command] -async fn set_node_endpoints( - preset: String, - ghost_pay_url: Option, - gsp_url: Option, +async fn set_node( + ghostd_url: Option, + cookie_path: Option, + user: Option, + pass: Option, + pool_url: Option, ) -> Result { - let resp = call_daemon(Request::SetNodeEndpoints { - preset, - ghost_pay_url, - gsp_url, + let resp = call_daemon(Request::SetNode { + ghostd_url, + cookie_path, + user, + pass, + pool_url, }) .await?; to_value(&resp) @@ -323,119 +307,32 @@ async fn wallet_ghost_id() -> Result { to_value(&resp) } -#[tauri::command] -async fn wallet_glyph(ghost_id: String) -> Result { - let resp = call_daemon(Request::WalletGlyph { ghost_id }).await?; - to_value(&resp) -} - -#[tauri::command] -async fn wallet_glyph_claim( - ghost_id: String, - pixels: Vec, -) -> Result { - let resp = call_daemon(Request::WalletGlyphClaim { ghost_id, pixels }).await?; - to_value(&resp) -} - -#[tauri::command] -async fn wallet_glyph_check(pixels: Vec) -> Result { - let resp = call_daemon(Request::WalletGlyphCheck { pixels }).await?; - to_value(&resp) -} - #[tauri::command] async fn wallet_auth_info() -> Result { let resp = call_daemon(Request::WalletAuthInfo).await?; to_value(&resp) } +/// Build, sign and broadcast an ordinary on-chain payment in one call. #[tauri::command] -async fn gsp_register_scan_key() -> Result { - let resp = call_daemon(Request::GspRegisterScanKey).await?; - to_value(&resp) -} - -#[tauri::command] -async fn gsp_session_status() -> Result { - let resp = call_daemon(Request::GspSessionStatus).await?; - to_value(&resp) -} - -#[tauri::command] -async fn gsp_auth() -> Result { - let resp = call_daemon(Request::GspAuth).await?; - to_value(&resp) -} - -#[tauri::command] -async fn locks_list() -> Result { - let resp = call_daemon(Request::LocksList).await?; - to_value(&resp) -} - -#[tauri::command] -async fn locks_prepare(capacity_sats: u64) -> Result { - let resp = call_daemon(Request::LocksPrepare { capacity_sats }).await?; - to_value(&resp) -} - -#[tauri::command] -async fn locks_confirm(lock_id: String, funding_txid: String) -> Result { - let resp = call_daemon(Request::LocksConfirm { - lock_id, - funding_txid, - }) - .await?; - to_value(&resp) -} - -#[tauri::command] -async fn locks_jump( - lock_id: String, - target_address: String, - priority: String, -) -> Result { - let resp = call_daemon(Request::LocksJump { - lock_id, - target_address, - priority, - }) - .await?; - to_value(&resp) -} - -/// Unilateral exit. Builds + signs + broadcasts a recovery spend -/// using the wallet's own recovery secret, after confirming the -/// timelock has matured. Talks straight to the configured ghostd — -/// no GSP, no operator cooperation. -#[tauri::command] -async fn locks_recover( - lock_id: String, - destination_address: String, - fee_sats: u64, -) -> Result { - let resp = call_daemon(Request::LocksRecover { - lock_id, - destination_address, - fee_sats, - }) - .await?; - to_value(&resp) -} - -#[tauri::command] -async fn light_send( - recipient: String, +#[allow(clippy::too_many_arguments)] +async fn l1_send( + recipient_address: String, amount_sats: u64, - mode: String, + fee_rate_sats_per_vb: Option, + change_index: Option, + bip86_scan_max: Option, + selected_outpoints: Option>, memo: Option, shroud_max_ms: Option, ) -> Result { - let resp = call_daemon(Request::LightSend { - recipient, + let resp = call_daemon(Request::L1Send { + recipient_address, amount_sats, - mode, + fee_rate_sats_per_vb: fee_rate_sats_per_vb.unwrap_or(5), + change_index, + bip86_scan_max: bip86_scan_max.unwrap_or(32), + selected_outpoints: selected_outpoints.unwrap_or_default(), memo, shroud_max_ms, }) @@ -443,6 +340,13 @@ async fn light_send( to_value(&resp) } +/// Silent payments the block scanner has found. +#[tauri::command] +async fn light_detected() -> Result { + let resp = call_daemon(Request::LightDetected).await?; + to_value(&resp) +} + #[tauri::command] async fn light_utxos(min_confirmations: Option) -> Result { let resp = call_daemon(Request::LightUtxos { @@ -513,6 +417,7 @@ async fn wraith_mix_run( mix_output_address: String, bip86_index: Option, bip86_scan_max: Option, + min_entities: Option, ) -> Result { let resp = call_daemon(Request::WraithMixOneShot { coordinator_url, @@ -527,6 +432,160 @@ async fn wraith_mix_run( mix_output_address, bip86_index, bip86_scan_max, + min_entities, + }) + .await?; + to_value(&resp) +} + +/// Derive a Ghost Lock's four lanes and report their balances. +/// +/// Remember a Ghost Lock's definition. Only public keys and two heights. +#[tauri::command] +async fn ghost_lock_save( + label: Option, + backup_pubkey: String, + heir_pubkey: String, + quorum_pubkey: String, + anchor_height: u32, + inherit_height: u32, + bip86_index: Option, +) -> Result { + let resp = call_daemon(Request::GhostLockSave { + label, + backup_pubkey, + heir_pubkey, + quorum_pubkey, + anchor_height, + inherit_height, + bip86_index, + }) + .await?; + to_value(&resp) +} + +/// Every remembered Lock. +#[tauri::command] +async fn ghost_lock_list() -> Result { + let resp = call_daemon(Request::GhostLockList).await?; + to_value(&resp) +} + +/// Forget a Lock's definition. Does not touch the funds. +#[tauri::command] +async fn ghost_lock_forget(lock_id: String) -> Result { + let resp = call_daemon(Request::GhostLockForget { lock_id }).await?; + to_value(&resp) +} + +/// What leaving alone needs, and whether the coins are old enough. +#[tauri::command] +async fn ghost_lock_escape_plan( + lock_id: String, + lane: String, +) -> Result { + let resp = call_daemon(Request::GhostLockEscapePlan { lock_id, lane }).await?; + to_value(&resp) +} + +/// Sign a lane's escape leaf with the owner's key. +#[tauri::command] +async fn ghost_lock_escape_sign( + lock_id: String, + lane: String, + psbt: String, + input_index: u32, +) -> Result { + let resp = call_daemon(Request::GhostLockEscapeSign { + lock_id, + lane, + psbt, + input_index, + }) + .await?; + to_value(&resp) +} + +/// Round 1 of an air-gapped key-path spend. +#[tauri::command] +async fn ghost_lock_sign_begin( + lock_id: String, + lane: String, + psbt: String, + input_index: u32, +) -> Result { + let resp = call_daemon(Request::GhostLockSignBegin { + lock_id, + lane, + psbt, + input_index, + }) + .await?; + to_value(&resp) +} + +/// Round 1 reply from the device. The daemon signs its own share here. +#[tauri::command] +async fn ghost_lock_sign_nonce( + session: String, + device_nonce: String, +) -> Result { + let resp = call_daemon(Request::GhostLockSignNonce { + session, + device_nonce, + }) + .await?; + to_value(&resp) +} + +/// Round 2 reply from the device. Completes the spend. +#[tauri::command] +async fn ghost_lock_sign_complete( + session: String, + device_partial: String, +) -> Result { + let resp = call_daemon(Request::GhostLockSignComplete { + session, + device_partial, + }) + .await?; + to_value(&resp) +} + +/// Where a round should pay to fund one lane privately. +/// +/// Asks the daemon rather than reusing the address the lanes view already +/// holds. The daemon applies the compartment rule — Cash is refused, because +/// a round would buy unlinkability a public-by-design lane discards on arrival +/// — and a rule enforced only in the client is enforced only for clients that +/// ask nicely. +#[tauri::command] +async fn ghost_lock_round_destination( + lock_id: String, + lane: String, +) -> Result { + let resp = call_daemon(Request::GhostLockRoundDestination { lock_id, lane }).await?; + to_value(&resp) +} + +/// The MuSig2 aggregates are derived by the daemon from the individual keys — +/// BIP-327 aggregation is deterministic, so no ceremony is involved. +#[tauri::command] +async fn ghost_lock_lanes( + backup_pubkey: String, + heir_pubkey: String, + quorum_pubkey: String, + inherit_height: u32, + anchor_height: u32, + bip86_index: Option, +) -> Result { + let resp = call_daemon(Request::GhostLockLanes { + backup_pubkey, + heir_pubkey, + quorum_pubkey, + inherit_height, + anchor_height, + bip86_index, }) .await?; to_value(&resp) @@ -675,80 +734,6 @@ async fn multisig_descriptor_delete(name: String) -> Result>, -) -> Result<(), String> { - if state - .running - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return Ok(()); // already running - } - let app = app.clone(); - let state = state.inner().clone(); - tokio::spawn(async move { - if let Err(e) = run_watch_loop(&app).await { - // Surface the failure to the frontend so it can show a banner. - let _ = app.emit("wraith://watch-error", serde_json::json!({ "message": e })); - } - state.running.store(false, Ordering::SeqCst); - }); - Ok(()) -} - -async fn run_watch_loop(app: &AppHandle) -> Result<(), String> { - let stream = connect_daemon() - .await - .map_err(|e| format!("connect: {e}"))?; - let (reader, mut writer) = stream.split(); - let mut line = serde_json::to_string(&Envelope::new(1, Request::WatchPayments)) - .map_err(|e| format!("serialise: {e}"))?; - line.push('\n'); - writer - .write_all(line.as_bytes()) - .await - .map_err(|e| format!("write: {e}"))?; - let mut reader = BufReader::new(reader); - loop { - let mut buf = String::new(); - match reader.read_line(&mut buf).await { - Ok(0) => return Ok(()), // daemon closed - Ok(_) => { - let env: Envelope = match serde_json::from_str(&buf) { - Ok(e) => e, - Err(_) => continue, // skip bad lines, keep stream alive - }; - match env.payload { - Response::Watching => {} - Response::PaymentDetected(d) => { - let _ = app.emit( - "wraith://payment-detected", - serde_json::json!({ - "txid": d.txid, - "block_height": d.block_height, - "vout": d.vout, - "amount_sats": d.amount_sats, - "k": d.k, - "received_at": d.received_at, - }), - ); - } - Response::Error(e) => return Err(e.message), - _ => {} - } - } - Err(e) => return Err(format!("read: {e}")), - } - } -} - /// Send a request to the running wraithd daemon over its local IPC endpoint. /// Returns the parsed [`Response`] payload (without the JSON-RPC envelope). async fn call_daemon(request: Request) -> Result { @@ -789,7 +774,6 @@ pub fn run() { ) .init(); tauri::Builder::default() - .manage(Arc::new(WatchState::new())) .setup(|app| { // Make sure a daemon is up. On a packaged install `wraithd` ships // as a Tauri sidecar next to this binary; spawn it if nothing is @@ -871,7 +855,6 @@ pub fn run() { daemon_env, chain_status, connection_status, - set_node_endpoints, wallet_list, wallet_status, wallet_unlock, @@ -887,25 +870,26 @@ pub fn run() { light_balance, light_receive, light_history, - light_send, + l1_send, + light_detected, + set_node, light_utxos, light_l1_utxos, wraith_coordinator_discover, wraith_resolve_coordinator, wraith_mix_run, + ghost_lock_lanes, + ghost_lock_save, + ghost_lock_list, + ghost_lock_forget, + ghost_lock_round_destination, + ghost_lock_escape_plan, + ghost_lock_escape_sign, + ghost_lock_sign_begin, + ghost_lock_sign_nonce, + ghost_lock_sign_complete, wallet_ghost_id, - wallet_glyph, - wallet_glyph_claim, - wallet_glyph_check, wallet_auth_info, - gsp_register_scan_key, - gsp_session_status, - gsp_auth, - locks_list, - locks_prepare, - locks_confirm, - locks_jump, - locks_recover, psbt_inspect, psbt_sign, psbt_create, @@ -917,7 +901,6 @@ pub fn run() { multisig_descriptor_list, multisig_descriptor_addresses, multisig_descriptor_delete, - start_watch, ]) .run(tauri::generate_context!()) .expect("error while running wraith-wallet-gui"); diff --git a/apps/wraith-wallet/gui/src/App.tsx b/apps/wraith-wallet/gui/src/App.tsx index bb5a641b1..5103955ec 100644 --- a/apps/wraith-wallet/gui/src/App.tsx +++ b/apps/wraith-wallet/gui/src/App.tsx @@ -1,11 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { connectionStatus, daemonEnv, - gspAuth, - onPaymentDetected, - onWatchError, - startWatch, + watchForPayments, walletStatus, type ConnectionStatusResponse, type DetectedPayment, @@ -16,10 +13,10 @@ import { Send } from "./screens/Send"; import { Sign } from "./screens/Sign"; import { Cosigner } from "./screens/Cosigner"; import { Mix } from "./screens/Mix"; -import { Glyph } from "./screens/Glyph"; import { Merchant } from "./screens/Merchant"; import { Reports } from "./screens/Reports"; import { Locks } from "./screens/Locks"; +import { Device } from "./screens/Device"; import { History } from "./screens/History"; import { Network } from "./screens/Network"; import { Settings } from "./screens/Settings"; @@ -32,13 +29,13 @@ import { FirstRunTour } from "./components/FirstRunTour"; import { CATEGORY_HELP } from "./lib/help"; type Screen = + | "device" | "wallet" | "receive" | "send" | "sign" | "cosigner" | "mix" - | "glyph" | "merchant" | "reports" | "locks" @@ -60,6 +57,7 @@ const NAV_GROUPS: Array<{ { id: "wallet", label: "Wallet" }, { id: "history", label: "History" }, { id: "locks", label: "Locks" }, + { id: "device", label: "Offline device" }, ], }, { @@ -70,7 +68,6 @@ const NAV_GROUPS: Array<{ { id: "sign", label: "Sign" }, { id: "cosigner", label: "Cosigner" }, { id: "mix", label: "Mix" }, - { id: "glyph", label: "Glyph" }, ], }, { @@ -98,8 +95,6 @@ export default function App() { }>({ active: null, unlocked: false }); const [paymentTick, setPaymentTick] = useState(0); const [lastDetect, setLastDetect] = useState(null); - const [watchErr, setWatchErr] = useState(null); - const [hasSession, setHasSession] = useState(false); const [daemonOffline, setDaemonOffline] = useState(false); const [conn, setConn] = useState(null); // Two layers of kiosk mode: @@ -131,11 +126,9 @@ export default function App() { }; const replayTour = () => setShowTour(true); - const autoAuthInFlight = useRef(null); - - // Header status tick — daemon + wallet status, kiosk-mode detection, - // auto-gsp-auth on first unlock. Best-effort: every error path here - // either falls through silently or surfaces via the "daemon offline" + // Header status tick — daemon + wallet status, kiosk-mode detection. + // Best-effort: every error path here either falls through silently or + // surfaces via the "daemon offline" // pill — never crashes the shell. useEffect(() => { let alive = true; @@ -166,25 +159,6 @@ export default function App() { setDaemonKiosk(isDaemonKiosk); if (isDaemonKiosk) setScreen("merchant"); } - if (w.active && w.unlocked) { - // Only act on a fresh snapshot; a transient null leaves the - // session state untouched until the next tick. - if (conn) { - setHasSession(conn.gsp_have_token); - if ( - !conn.gsp_have_token && - autoAuthInFlight.current !== w.active - ) { - autoAuthInFlight.current = w.active; - gspAuth().catch(() => { - if (alive) autoAuthInFlight.current = null; - }); - } - } - } else { - setHasSession(false); - if (!w.active) autoAuthInFlight.current = null; - } } catch { if (alive) setDaemonOffline(true); } @@ -201,52 +175,17 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Live BIP-352 receive notifications — register the event listeners - // once at mount. startWatch() itself is deferred to the effect below, - // which waits for a GSP session (the watch needs one to run). + // Notice money arriving, by asking the node on an interval. Runs whenever a + // wallet is unlocked; the watcher's first poll is a baseline, so opening the + // app never announces coins that were already there. useEffect(() => { - let alive = true; - let unlistenDetect: (() => void) | undefined; - let unlistenError: (() => void) | undefined; - (async () => { - unlistenDetect = await onPaymentDetected((p) => { - if (!alive) return; - setLastDetect(p); - setPaymentTick((n) => n + 1); - }); - unlistenError = await onWatchError((e) => { - if (!alive) return; - setWatchErr(e.message); - }); - })(); - return () => { - alive = false; - if (unlistenDetect) unlistenDetect(); - if (unlistenError) unlistenError(); - }; - }, []); - - // Start (or restart) the push-watch once a GSP session is live. The - // watch needs a session — calling startWatch() before one exists just - // fails with "no active sessions". startWatch() is idempotent, so - // re-calling it whenever a session appears is safe. - useEffect(() => { - if (!hasSession) { - setWatchErr(null); // no session yet — not an error state - return; - } - let alive = true; - startWatch() - .then(() => { - if (alive) setWatchErr(null); - }) - .catch((e) => { - if (alive) setWatchErr((e as Error).message ?? String(e)); - }); - return () => { - alive = false; - }; - }, [hasSession]); + if (!walletState.active || !walletState.unlocked) return; + const stop = watchForPayments((p) => { + setLastDetect(p); + setPaymentTick((n) => n + 1); + }); + return stop; + }, [walletState.active, walletState.unlocked]); // Active-screen renderer wrapped in the boundary so a screen's // crash doesn't blank the whole app. @@ -264,8 +203,6 @@ export default function App() { return ; case "mix": return ; - case "glyph": - return ; case "merchant": return ( ; case "locks": return ; + case "device": + return ; case "history": return ; case "network": @@ -346,12 +285,6 @@ export default function App() { )} - {hasSession && watchErr && ( - - watch offline - - )} - {daemonOffline ? ( = 10 ? "pass" : "warn"; +} + +export function AnonymitySet({ report, claimed, verified, problem }: Props) { + const t = tone(report, problem); + + return ( +
+
+ Anonymity set + {report.entities} + + across {report.seats} {report.seats === 1 ? "seat" : "seats"} + +
+ +
+
+
Distinct entities
+
{report.entities}
+
+ {report.discounted > 0 && ( +
+
+ Discounted + — coins traced to another participant +
+
{report.discounted}
+
+ )} + {report.unverified > 0 && ( +
+
+ Unverified + — no link found, not proof of independence +
+
{report.unverified}
+
+ )} +
+
+ Real payments + — cover that behaves like you +
+
{report.payers}
+
+
+ + {problem?.kind === "over_claimed" && ( +

+ The coordinator claimed {problem.claimed}. The round’s own + coins support at most {report.entities}. There is no honest reading of + that difference — reporting fewer than you count is normal caution, + reporting more is not derivable from the chain at all. +

+ )} + + {problem?.kind === "thin" && ( +

+ This round is smaller than your minimum of {problem.floor}. Nothing is + wrong with it — there simply are not many people paying right now. + Waiting for a fuller round costs you time and nothing else. +

+ )} + +

+ {verified + ? "✓ Counted by this wallet from the chain" + : "⚠ Not independently checked"} + {claimed !== undefined && verified && claimed !== report.entities && ( + · coordinator said {claimed} + )} +

+
+ ); +} diff --git a/apps/wraith-wallet/gui/src/components/ConnectionStatus.tsx b/apps/wraith-wallet/gui/src/components/ConnectionStatus.tsx index 2f38d9d11..791e53080 100644 --- a/apps/wraith-wallet/gui/src/components/ConnectionStatus.tsx +++ b/apps/wraith-wallet/gui/src/components/ConnectionStatus.tsx @@ -7,12 +7,17 @@ interface ConnectionStatusProps { onOpenDiagnostics?: () => void; } -/// Persistent header connectivity bar: three compact pills — Ghost Pay, -/// GSP, and chain sync. The whole point is that a user whose laptop has -/// no local ghost-pay / GSP sees a clear "unreachable" state (with a -/// hint) rather than a spinner that never resolves. The backing -/// `connectionStatus()` call never throws for an unreachable endpoint, -/// so a red pill here is a real, actionable answer. +/// Persistent header connectivity bar: two compact pills — the node, and +/// chain sync. +/// +/// The point is that a user gets a clear, actionable answer instead of a +/// spinner that never resolves, and that the three states stay distinct: +/// no node set, node set but silent, node answering. They have different +/// fixes, and merging them into one "unreachable" sends people hunting for +/// a fault when what is missing is a setting. +/// +/// The backing `connectionStatus()` call never throws for an unreachable +/// node, so a red pill here is a real answer rather than a failed request. export function ConnectionStatus({ conn, onOpenDiagnostics }: ConnectionStatusProps) { if (!conn) { // Daemon replied to nothing yet — brief, resolves on the next tick. @@ -23,66 +28,53 @@ export function ConnectionStatus({ conn, onOpenDiagnostics }: ConnectionStatusPr ? { onClick: onOpenDiagnostics, style: { cursor: "pointer", border: 0 } as const } : {}; - // ----- Ghost Pay ----- - const ghostPay = conn.ghost_pay_reachable ? ( - - Ghost Pay - - ) : ( - - Ghost Pay · unreachable - - ); - - // ----- GSP websocket ----- - let gsp; - if (conn.gsp_connected) { - gsp = ( - - GSP + // ----- The node ----- + let node; + if (!conn.node_configured) { + node = ( + + node · not set ); - } else if (conn.gsp_have_token) { - gsp = ( + } else if (conn.node_reachable) { + node = ( - GSP · {conn.gsp_phase ?? "connecting"} + node ); } else { - gsp = ( + node = ( - GSP · off + node · unreachable ); } // ----- Chain sync ----- let sync; - if (!conn.ghost_pay_reachable) { + if (!conn.node_reachable) { sync = ( sync · — @@ -90,13 +82,7 @@ export function ConnectionStatus({ conn, onOpenDiagnostics }: ConnectionStatusPr ); } else if (conn.chain_synced) { sync = ( - + synced · #{conn.chain_height?.toLocaleString() ?? "—"} ); @@ -121,8 +107,7 @@ export function ConnectionStatus({ conn, onOpenDiagnostics }: ConnectionStatusPr return ( - {ghostPay} - {gsp} + {node} {sync} ); diff --git a/apps/wraith-wallet/gui/src/components/EscapePanel.tsx b/apps/wraith-wallet/gui/src/components/EscapePanel.tsx new file mode 100644 index 000000000..6a497a030 --- /dev/null +++ b/apps/wraith-wallet/gui/src/components/EscapePanel.tsx @@ -0,0 +1,248 @@ +/** + * Leaving a lane alone, after the delay. + * + * Every lane but Cash has a leaf that spends with one key and a wait: Savings + * after ~14 months, Spending after ~7 days, Investments recalled after ~14. + * No quorum, no backup device, no ceremony. This is the path that stops a + * silent quorum from being the end of the money. + * + * # Why it asks before it signs + * + * A relative timelock is enforced against the input's `nSequence`, so the + * number is dictated by the leaf. Get it wrong and the network rejects the + * transaction as non-final — which looks like nothing happening rather than + * like an error. So the plan comes first, and it states the number to use. + * + * # Why the coins are listed even when they are not ready + * + * Somebody opening this screen is usually asking "can I get out yet". An + * immature coin hidden until it matures answers that question with silence. + * Each one shows how much longer it has, in days, because a block count is not + * a duration anybody feels. + */ + +import { useState } from "react"; +import { + ghostLockEscapePlan, + ghostLockEscapeSign, + type GhostLockEscapePlan, + type GhostLockRecord, +} from "../lib/tauri"; + +function sats(n: number): string { + return n.toLocaleString("en-GB"); +} + +function days(blocks: number): string { + const d = blocks / 144; + if (d >= 30) return `~${(d / 30.4).toFixed(1)} months`; + if (d >= 1) return `~${d.toFixed(1)} days`; + return `~${(d * 24).toFixed(0)} hours`; +} + +export function EscapePanel({ locks }: { locks: GhostLockRecord[] }) { + const [lockId, setLockId] = useState(""); + const [lane, setLane] = useState("spending"); + const [plan, setPlan] = useState(null); + const [psbt, setPsbt] = useState(""); + const [inputIndex, setInputIndex] = useState(0); + const [signed, setSigned] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const ready = plan?.coins.filter((c) => c.blocks_remaining === 0) ?? []; + const waiting = plan?.coins.filter((c) => c.blocks_remaining > 0) ?? []; + + async function run(fn: () => Promise, then: (v: T) => void) { + setBusy(true); + setError(null); + try { + then(await fn()); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+

Leaving alone

+

+ Every lane but Cash has a way out that needs nobody else — not the + quorum, not your backup device. It costs a wait, and that is the whole + trade: the quorum can stop answering and it cannot keep your money. +

+ + {error &&
{error}
} + +
+ + +
+ +
+ + +

+ Cash has no escape: it already spends with your key alone, so there is + nothing to wait for. +

+
+ + + + {plan && ( +
+

{plan.escape}

+
+
+ Wait + + {plan.delay_blocks} blocks ({days(plan.delay_blocks)}) + +
+
+ nSequence each input must carry + {plan.required_sequence} +
+
+ Lane + {plan.lane_address} +
+
+ + {plan.coins.length === 0 && ( +

No coins in this lane.

+ )} + + {ready.length > 0 && ( + <> +

Ready now

+
    + {ready.map((c) => ( +
  • + {sats(c.sats)} sats{" "} + + {c.txid.slice(0, 12)}…:{c.vout} + +
  • + ))} +
+ + )} + + {waiting.length > 0 && ( + <> +

Still waiting

+
    + {waiting.map((c) => ( +
  • + {sats(c.sats)} sats{" "} + + {c.blocks_remaining} more blocks ( + {days(c.blocks_remaining)}) + +
  • + ))} +
+ + )} + +
+ +