diff --git a/Cargo.lock b/Cargo.lock index 79abce7..11a415e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.17.0" +version = "0.18.0" dependencies = [ "axum", "base64", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.6.0" +version = "0.7.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index ac2e4fa..4890a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.17.0" +version = "0.18.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index 98a8aaf..07cdce0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -894,6 +894,36 @@ and REVOCABLE. All token comparisons are constant-time. created_ms }] }`, owner-only, atomic writes. The auth gate accepts the master token OR any token in this store (except for the pairing-administration methods). +### 7.12. Paired-token authorization for wallet methods (#370) + +The pairing framework (§7.11) authorizes `control.*` mutations. The thin-client model (epic #365) +extends the SAME paired-token gate to the **wallet method surface**: over the authorized loopback +surface, every wallet MUTATION and every custody-lifecycle method (§18.20) requires the master control +token OR a valid paired token; an unauthorized caller (no token, a wrong token, or a revoked token) is +rejected with `-32030 UNAUTHORIZED` before the method runs. + +**Gated wallet methods (MUST present a token).** The custody-lifecycle group (§18.20 — +`wallet.create` / `wallet.import` / `wallet.restore` / `wallet.unlock` / `wallet.lock` / +`wallet.status` / `wallet.delete`) and the mutation group (the send/spend group §18.9, the offer suite ++ DID/NFT mint & transfer §18.9a, and the state-changing record-update actions §18.16) are all gated. +These methods are NEVER relayed upstream — a signing/custody request must never leave the loopback node; +an authorized call is served locally by the node-custodied wallet (or, until the wallet surface is served +on a given transport, returns a catalogued error — it is never proxied to the public gateway). + +**Open wallet methods (no token).** Wallet READ methods (`get_*`) follow the read plane (§7.2): open to +local consumers. The recommendation of epic #365 is that the whole wallet WS session be paired-gated once +the bidirectional WS transport (#369) carries it; the security-critical MUST is that no mutation or +custody op ever runs unauthorized. + +**Node-local backup is not a wallet method.** Seed/mnemonic reveal (§18.20 backup) is reachable ONLY on +the node-local self-origin surface (§16.3) / a `dig-node wallet backup` CLI — NEVER over the paired +boundary, so key material never crosses to a paired caller even with a valid token. + +**Classification is pure + tested.** `wallet_authz::classify` maps a method to its class +(read | mutation | custody | pairing-admin | non-wallet) and `wallet_authz::authorize` decides allow/deny, +unit-tested exhaustively: an unpaired caller is denied on every mutation/custody method; a paired token +authorizes a mutation but NOT pairing administration; a revoked token is denied on the next request. + --- ## 8. CLI contract @@ -1525,7 +1555,8 @@ read it back, `cancel_offer` marks it cancelled. Sage's per-endpoint `auto_submi DIG-Browser callers (a `WalletSigner` over the wallet's synthetic p2 keys). Secret-touching endpoints (`get_secret_key`/`generate_mnemonic`/`import_key`/exportMnemonic/revealSeed) stay 501'd + loopback+token gated, NEVER reachable from a dapp/non-loopback origin. The MV3 extension self-custodies and does NOT use -the node's sign/spend path. +the node's sign/spend path. (SUPERSEDED for the PAIRED-extension thin-client path by §18.20/§18.21: there +the node custodies the key and signs + broadcasts on behalf of a paired caller, gated per §7.12.) 18.11. **NFT/DID/CAT reconstruction.** A raw `CoinState` does not reveal a coin's asset kind — that lives in the coin's puzzle, revealed only when its parent is spent. Reconstruction uncurries the parent spend @@ -1627,3 +1658,52 @@ way. `crates/dig-wallet/tests/conformance.rs` asserts every served method has a cross-checks representative request/response schemas field-name-identical against it — this caught the three real drifts documented in §18.15/§18.16/§18.17. The hand-authored `sage-endpoints-v0.12.11.json` (method-name-only) vector from #215 remains as a lighter first check. + +18.20. **Node-custodied wallet provisioning + custody lifecycle (#370).** For the thin-client model +(epic #365) the node HOLDS the wallet key: it generates or imports the seed, encrypts it at rest via +`dig-keystore` (§18.18 `seed_store`), and loads an in-memory `WalletSigner` on unlock. This is a distinct +custody locus from the read-only path of #217/#407 (where the node holds only the client's PUBLIC puzzle +hashes and NEVER a key) and supersedes, for the PAIRED-extension path, §18.10's "the extension +self-custodies and never uses the node's sign path". `crate::sage::custody::WalletCustody` owns the +lifecycle, each op authorized per §7.12: + +- **create(password)** — generate a fresh 24-word BIP-39 mnemonic, encrypt it under `password` + (`seed_store::encrypt_seed`), persist it to the custodied seed file, and load the signer. Returns the + wallet's receive address ONLY — NEVER the mnemonic (backup is node-local, below). +- **import(mnemonic, password)** / **restore(mnemonic, password)** — validate the mnemonic, encrypt + + persist it under `password`, and load the signer. This is the one-time migration path that accepts the + extension's existing seed IN (epic §migration); it is the only inbound key path, loopback-only + gated. +- **unlock(password)** — decrypt the on-disk seed and load the in-memory signer (derived over the wallet's + synthetic p2 keys for HD indices `0..N`); enables signing (§18.21). Wrong password fails closed. This is + the runtime signer load that replaces the bring-up-only `with_signer`. +- **lock()** — drop the in-memory signer (the encrypted seed stays on disk); signing is disabled until the + next unlock. +- **status()** — `none` (no seed on this device), `locked` (encrypted seed present, no signer loaded), or + `unlocked` (a signer is loaded), plus the address when unlocked. +- **delete(password)** — verify the password against the on-disk seed, then remove the seed file + lock. + +**Key at rest + never exported.** The seed is Argon2id + AES-256-GCM encrypted at rest (§18.18) and is +never logged, never returned by any lifecycle op, and never crosses the paired boundary. The ONLY seed +egress is the node-local, password-gated backup (`WalletCustody::reveal_mnemonic`, surfaced on the +self-origin `/api/export` UI §16.3 or a `dig-node wallet backup` CLI) — never a wallet/`control.*` method +(§7.12). + +18.21. **Sign + broadcast on behalf of the paired caller + per-op consent (#371).** With a wallet unlocked +(§18.20), the node is the SIGNER + BROADCASTER for a paired caller (§7.12): a spend request (or a +wasm-built unsigned bundle) is built with the canonical `chia-wallet-sdk` driver constructors (§18.9), +signed with the node-custodied `WalletSigner` (native BLS), validated by `dig-clvm` +(`validate_spend_bundle`, `DONT_VALIDATE_SIGNATURE` — §18.9) BEFORE broadcast (fail-closed on a tampered or +over-spending bundle), and broadcast to mainnet via `crate::sage::spend::ChiaQueryBroadcaster` (the real +`Broadcaster`, wrapping `chia_query::push_tx` = decentralized peers + coinset fallback, mirroring Sage's +peer `send_transaction`). + +**Per-op consent gate.** A broadcast reaches mainnet ONLY when it is BOTH authorized (a paired/master +token, §7.12) AND explicitly consented for that specific operation. Consent is enforced at the +`Broadcaster` seam by `crate::sage::spend::ConsentBroadcaster`: it forwards to the real broadcaster only +when a one-shot consent has been ARMED for the pending op (the extension surfaces the confirm; the served +layer arms consent on the confirmed op) and DISARMS after one broadcast; an unarmed (unconsented) broadcast +fails closed and the inner broadcaster is never called. This is the §16.2 broadcast-gate model adapted for +the authorized-extension path (distinct from the `DIG_WALLET_ALLOW_BROADCAST` dapp dry-run env of §16.2): +an unconsented op builds + signs + validates but does NOT broadcast (nothing is spent). CI NEVER broadcasts +to mainnet — tests drive the `chia-sdk-test` simulator (real consensus incl. BLS) or the recording +`MockBroadcaster`; a real mainnet broadcast is a separate, explicitly-gated live pass. diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 7b04441..35c1448 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -43,6 +43,7 @@ pub mod pairing; pub mod rpc; pub mod server; pub mod service; +pub mod wallet_authz; /// Windows Service Control Protocol entrypoint — only meaningful on Windows, where /// the SCM-launched binary must speak the service protocol (see the module docs). diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 10c16cb..b3a087b 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -38,6 +38,7 @@ use crate::meta; use crate::meta::ErrorCode; use crate::pairing; use crate::rpc::{normalize_request, request_id, rpc_error}; +use crate::wallet_authz; /// The dig-node binary version, surfaced by `/health`. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -530,6 +531,46 @@ async fn rpc( return (StatusCode::OK, Json(resp)); } + // WALLET plane (#370, §7.12): custody-lifecycle (`wallet.*`) + wallet MUTATION methods + // (sign/spend/offer/mint/transfer + state-changing actions) are paired-token gated over this + // authorized surface AND are NEVER relayed upstream — a signing/custody request must not leave + // the loopback node. Authorization is the master control token OR a valid paired token (#280); + // an unauthorized caller (no/wrong/revoked token) is -32030. An authorized call is served + // locally by the node-custodied wallet once that surface is wired on this transport + // (#368/#369); until then it returns a catalogued method-not-served error rather than leaking a + // spend/custody op to the public gateway. + if wallet_authz::requires_authorization(&method) { + let header_tok = headers + .get(control::CONTROL_TOKEN_HEADER) + .and_then(|v| v.to_str().ok()); + let presented = control::presented_token(header_tok, &req); + let paired_path = pairing::paired_tokens_path(&state.config_path); + let authorized = + wallet_authz::authorize(&method, presented.as_deref(), &state.control_token, |tok| { + pairing::is_paired_token(&paired_path, tok) + }); + if !authorized { + return ( + StatusCode::OK, + Json(rpc_error( + id, + ErrorCode::Unauthorized, + "this wallet method requires the local control token (X-Dig-Control-Token \ + header or params._control_token) or a paired controller token (see \ + `dig-node pair`); it is never relayed upstream", + )), + ); + } + return ( + StatusCode::OK, + Json(rpc_error( + id, + ErrorCode::MethodNotFound, + "the node-custodied wallet surface is not served on this transport yet", + )), + ); + } + // Keep the original request for a possible passthrough relay (the upstream must // see exactly what the client sent, not the dig-node-normalised form). let original = req.clone(); diff --git a/crates/dig-node-service/src/wallet_authz.rs b/crates/dig-node-service/src/wallet_authz.rs new file mode 100644 index 0000000..3a0393b --- /dev/null +++ b/crates/dig-node-service/src/wallet_authz.rs @@ -0,0 +1,229 @@ +//! Paired-token authorization for the WALLET method surface (#370, SPEC §7.12). +//! +//! The pairing framework ([`crate::pairing`]) authorizes `control.*` mutations. The thin-client +//! model (epic #365) extends the SAME gate to the wallet methods: over the authorized loopback +//! surface, every wallet MUTATION and every custody-lifecycle method requires the master control +//! token OR a valid paired token; an unauthorized caller (no token / a wrong token / a revoked +//! token) is rejected with `-32030 UNAUTHORIZED` before the method runs. Wallet READ methods stay +//! open to local consumers (the read plane, §7.2). +//! +//! Gated wallet methods are ALSO never relayed upstream — a signing/custody request must never +//! leave the loopback node (the server enforces that, [`crate::server`]). +//! +//! This module is PURE (string classification + an allow/deny predicate) so the policy is +//! unit-tested exhaustively without a running server. The gated-mutation set mirrors the dig-wallet +//! Sage mutation surface (SPEC §18.9/§18.9a/§18.16/§18.17); it is kept in sync by SPEC §7.12 — a +//! mutation added to the wallet surface is added here. + +use crate::control::ct_eq; + +/// The custody-lifecycle namespace prefix (§18.20): `wallet.create`, `wallet.import`, +/// `wallet.restore`, `wallet.unlock`, `wallet.lock`, `wallet.status`, `wallet.delete`. EVERY +/// `wallet.*` method is gated (even `wallet.status`, which reveals wallet presence + the address). +pub const CUSTODY_PREFIX: &str = "wallet."; + +/// Wallet MUTATION methods that MUST be authorized (§7.12): they sign, spend, broadcast, or change +/// persisted wallet state. Sourced from the dig-wallet Sage surface (§18.9/§18.9a/§18.16/§18.17). +const GATED_WALLET_MUTATIONS: &[&str] = &[ + // send/spend group (§18.9) — key-touching (sign_coin_spends signs; submit broadcasts). + "send_xch", + "bulk_send_xch", + "send_cat", + "bulk_send_cat", + "combine", + "split", + "multi_send", + "sign_coin_spends", + "submit_transaction", + // offer suite + DID/NFT mint & transfer (§18.9a). + "make_offer", + "take_offer", + "combine_offers", + "cancel_offer", + "create_did", + "bulk_mint_nfts", + "transfer_nfts", + "transfer_dids", + // option contracts (§18.15). + "mint_option", + "transfer_options", + "exercise_options", + // state-changing record-update actions (§18.16). + "resync_cat", + "update_cat", + "update_did", + "update_option", + "update_nft", + "update_nft_collection", + "redownload_nft", + "increase_derivation_index", + // network / peer / settings mutations (§18.17). + "add_peer", + "remove_peer", + "set_discover_peers", + "set_target_peers", + "set_network", + "set_network_override", + "set_delta_sync", + "set_delta_sync_override", + "set_change_address", + "save_user_theme", + "delete_user_theme", +]; + +/// The authorization class of a JSON-RPC method w.r.t. the wallet surface (§7.12). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalletMethodClass { + /// A custody-lifecycle method (`wallet.*`, §18.20) — GATED. + Custody, + /// A wallet MUTATION (sign/spend/offer/mint/transfer + state-changing actions) — GATED. + Mutation, + /// Not a gated wallet method — a wallet READ, a `control.*`/`pairing.*`/`dig.*`/`cache.*` + /// method, or anything else. This gate leaves it alone (the read plane and the control gate + /// apply their own policy). + Other, +} + +/// Classify a method against the wallet-authorization policy. PURE. +pub fn classify(method: &str) -> WalletMethodClass { + if method.starts_with(CUSTODY_PREFIX) { + WalletMethodClass::Custody + } else if GATED_WALLET_MUTATIONS.contains(&method) { + WalletMethodClass::Mutation + } else { + WalletMethodClass::Other + } +} + +/// Whether `method` requires the master or a paired token over the authorized surface (§7.12) — +/// true for every custody-lifecycle and wallet-mutation method. +pub fn requires_authorization(method: &str) -> bool { + matches!( + classify(method), + WalletMethodClass::Custody | WalletMethodClass::Mutation + ) +} + +/// Decide whether a wallet-surface call is AUTHORIZED. PURE. +/// +/// - A method that does NOT require authorization (a read / non-wallet method) is always +/// authorized here — other gates (the read plane, the `control.*` gate) apply their own policy. +/// - A GATED (custody/mutation) method is authorized ONLY when the presented token is the master +/// control token (constant-time) OR a valid paired token (`is_paired`). No token → denied. +/// +/// `is_paired` is injected so this stays pure + unit-testable without the on-disk paired-token +/// store; the server passes [`crate::pairing::is_paired_token`]. +pub fn authorize( + method: &str, + presented: Option<&str>, + master: &str, + is_paired: impl Fn(&str) -> bool, +) -> bool { + if !requires_authorization(method) { + return true; + } + match presented { + Some(tok) => ct_eq(tok, master) || is_paired(tok), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MASTER: &str = "master-token-value"; + const PAIRED: &str = "paired-token-value"; + + /// A stand-in paired-token store: `PAIRED` is valid, everything else (incl. a revoked token) is not. + fn is_paired(tok: &str) -> bool { + tok == PAIRED + } + + #[test] + fn custody_methods_are_gated() { + for m in [ + "wallet.create", + "wallet.import", + "wallet.restore", + "wallet.unlock", + "wallet.lock", + "wallet.status", + "wallet.delete", + ] { + assert_eq!(classify(m), WalletMethodClass::Custody, "{m}"); + assert!(requires_authorization(m), "{m} must be gated"); + } + } + + #[test] + fn spend_sign_and_offer_methods_are_gated_mutations() { + for m in [ + "send_xch", + "send_cat", + "sign_coin_spends", + "submit_transaction", + "make_offer", + "take_offer", + "create_did", + "bulk_mint_nfts", + "transfer_nfts", + ] { + assert_eq!(classify(m), WalletMethodClass::Mutation, "{m}"); + assert!(requires_authorization(m), "{m} must be gated"); + } + } + + #[test] + fn reads_and_non_wallet_methods_are_not_gated() { + for m in [ + "get_coins", + "get_sync_status", + "view_coin_spends", + "view_offer", + "check_address", + "login", + "dig.getContent", + "cache.getConfig", + "control.status", + "pairing.request", + "rpc.discover", + ] { + assert_eq!(classify(m), WalletMethodClass::Other, "{m}"); + assert!(!requires_authorization(m), "{m} must not be gated here"); + } + } + + #[test] + fn unpaired_caller_is_denied_on_every_gated_method() { + // No token, a wrong token, and a revoked token are all denied for every mutation + custody. + for m in GATED_WALLET_MUTATIONS.iter().copied().chain([ + "wallet.create", + "wallet.unlock", + "wallet.delete", + ]) { + assert!(!authorize(m, None, MASTER, is_paired), "{m}: no token"); + assert!( + !authorize(m, Some("wrong-token"), MASTER, is_paired), + "{m}: wrong token" + ); + assert!( + !authorize(m, Some("revoked-token-not-in-store"), MASTER, is_paired), + "{m}: revoked token" + ); + } + } + + #[test] + fn master_or_paired_token_authorizes_a_gated_mutation() { + assert!(authorize("send_xch", Some(MASTER), MASTER, is_paired)); + assert!(authorize("send_xch", Some(PAIRED), MASTER, is_paired)); + assert!(authorize("wallet.unlock", Some(PAIRED), MASTER, is_paired)); + } + + #[test] + fn a_read_is_authorized_without_a_token() { + assert!(authorize("get_coins", None, MASTER, is_paired)); + assert!(authorize("dig.getContent", None, MASTER, is_paired)); + } +} diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 1df9b6c..e320bf0 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.6.0" +version = "0.7.0" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." diff --git a/crates/dig-wallet/src/sage/custody.rs b/crates/dig-wallet/src/sage/custody.rs new file mode 100644 index 0000000..0993341 --- /dev/null +++ b/crates/dig-wallet/src/sage/custody.rs @@ -0,0 +1,477 @@ +//! Node-custodied wallet seed lifecycle (#370, SPEC §18.20). +//! +//! For the thin-client model (epic #365) the node HOLDS the wallet key: it generates or imports +//! the BIP-39 seed, encrypts it at rest via [`crate::seed_store`] (`dig-keystore` Argon2id + +//! AES-256-GCM, §18.18), and loads an in-memory [`WalletSigner`] on unlock so the node can sign + +//! broadcast on the caller's behalf (§18.21). +//! +//! # Trust boundary (custody of mainnet-spending keys) +//! +//! This is the sanctioned custody locus for the paired-extension path, DISTINCT from the read-only +//! path of #217/#407 (where the node holds only the client's PUBLIC puzzle hashes and NEVER a key). +//! The seed/key NEVER leaves the node: +//! +//! - no lifecycle op returns the mnemonic or a secret key — [`WalletCustody::create`] returns only +//! the receive address; +//! - the seed is encrypted at rest and is never logged; +//! - the ONLY seed egress is the node-local, password-gated [`WalletCustody::reveal_mnemonic`] +//! (surfaced on the self-origin backup UI / a `dig-node wallet backup` CLI), NEVER over the paired +//! authorized boundary (§7.12). +//! +//! Every op that mutates custody is authorized by the paired-token gate at the transport layer +//! (SPEC §7.12); this module owns the custody state machine + crypto, not the transport authz. + +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; + +use chia_protocol::Bytes32; +use chia_wallet_sdk::types::{MAINNET_CONSTANTS, TESTNET11_CONSTANTS}; +use digstore_chain::keys::{derive_indexed_keys, derive_wallet_keys, owner_address}; +use digstore_chain::seed::{generate_mnemonic, validate_mnemonic}; +use zeroize::Zeroizing; + +use super::spend::WalletSigner; +use super::{Error, Result}; +use crate::seed_store; + +/// The minimum length of the password that encrypts the seed at rest. Mirrors the self-origin +/// wallet UI's floor (`crate::lib`), so both custody surfaces reject a trivially-weak password. +const MIN_PASSWORD_LEN: usize = 8; + +/// How many unhardened HD indices the custodied signer covers by default (indices `0..N`). The +/// signer can spend a coin at any of these addresses' standard puzzle hashes; a coin outside the +/// range is not spendable by the loaded signer (the wallet stays within its first `N` addresses, +/// matching typical usage — the count is a construction parameter for callers that need more). +pub const DEFAULT_DERIVATION_COUNT: u32 = 50; + +/// The Chia network the custodied signer signs for. Selects the aggregate-signature domain the +/// broadcast target validates against (mainnet in production; testnet11 for the simulator tests). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Network { + /// Chia mainnet — the production target. + Mainnet, + /// testnet11 — used by the `chia-sdk-test` simulator in tests. + Testnet11, +} + +impl Network { + /// The `AGG_SIG_ME` additional data the network's consensus validates spend signatures against. + fn agg_sig_data(self) -> Bytes32 { + match self { + Network::Mainnet => MAINNET_CONSTANTS.agg_sig_me_additional_data, + Network::Testnet11 => TESTNET11_CONSTANTS.agg_sig_me_additional_data, + } + } +} + +/// The custody state reported by [`WalletCustody::status`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CustodyState { + /// No seed on this device — the wallet has not been created/imported yet. + None, + /// An encrypted seed is on disk but no signer is loaded (needs `unlock`). + Locked, + /// A signer is loaded in memory (spend/sign is enabled). + Unlocked, +} + +/// The custody status: the state plus the receive address when unlocked. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CustodyStatus { + /// The lifecycle state. + pub state: CustodyState, + /// The wallet's receive address (`xch1…`), present only when unlocked. + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, +} + +/// An unlocked custody session: the in-memory signer + the wallet's receive address. +struct Unlocked { + signer: Arc, + address: String, +} + +/// The node-custodied wallet key lifecycle (§18.20). Owns the encrypted-seed file path and the +/// in-memory unlocked signer; every method is transport-agnostic (authorization is the caller's +/// concern, §7.12). Cheap to `clone` — the unlocked state is shared behind an `Arc`. +#[derive(Clone)] +pub struct WalletCustody { + /// The encrypted-seed file path (per node config dir, owner-only). + seed_path: PathBuf, + /// The network the loaded signer signs for. + network: Network, + /// How many unhardened HD indices the signer covers (`0..derivation_count`). + derivation_count: u32, + /// The in-memory unlocked session; `None` when locked. Shared across clones so an `unlock` on + /// one handle is visible on the others. + unlocked: Arc>>, +} + +impl WalletCustody { + /// Build a custody manager over `seed_path`, signing for `network`, covering HD indices + /// `0..derivation_count`. + pub fn new(seed_path: PathBuf, network: Network, derivation_count: u32) -> Self { + Self { + seed_path, + network, + derivation_count: derivation_count.max(1), + unlocked: Arc::new(RwLock::new(None)), + } + } + + /// Build a mainnet custody manager with the default derivation coverage. + pub fn mainnet(seed_path: PathBuf) -> Self { + Self::new(seed_path, Network::Mainnet, DEFAULT_DERIVATION_COUNT) + } + + /// Whether an encrypted seed exists on disk. + pub fn seed_exists(&self) -> bool { + self.seed_path.exists() + } + + /// The current lifecycle state (+ address when unlocked). + pub fn status(&self) -> CustodyStatus { + if let Some(u) = self.unlocked.read().unwrap().as_ref() { + return CustodyStatus { + state: CustodyState::Unlocked, + address: Some(u.address.clone()), + }; + } + CustodyStatus { + state: if self.seed_exists() { + CustodyState::Locked + } else { + CustodyState::None + }, + address: None, + } + } + + /// Generate a fresh 24-word wallet, encrypt it under `password`, persist it, and load the + /// signer. Returns ONLY the receive address — the mnemonic is NEVER returned (§18.20: back it + /// up via the node-local [`Self::reveal_mnemonic`]). Refuses if a wallet already exists. + pub fn create(&self, password: &str) -> Result { + self.check_password(password)?; + self.refuse_if_exists()?; + let mnemonic = generate_mnemonic(24) + .map_err(|e| Error::internal(format!("failed to generate a recovery phrase: {e}")))?; + self.persist_and_unlock(&mnemonic, password) + } + + /// Import an existing mnemonic (the one-time migration path, §18.20): validate it, encrypt + + /// persist it under `password`, and load the signer. Refuses if a wallet already exists (delete + /// first to replace). Returns the receive address. + pub fn import(&self, mnemonic: &str, password: &str) -> Result { + self.check_password(password)?; + self.refuse_if_exists()?; + let m = validate_mnemonic(mnemonic) + .map_err(|e| Error::api(format!("invalid recovery phrase: {e}")))?; + self.persist_and_unlock(&m, password) + } + + /// Restore a wallet from a mnemonic. Behaviourally identical to [`Self::import`]; a distinct + /// name so the lifecycle surface reads naturally (§18.20). + pub fn restore(&self, mnemonic: &str, password: &str) -> Result { + self.import(mnemonic, password) + } + + /// Decrypt the on-disk seed with `password` and load the in-memory signer (the runtime signer + /// load that replaces the bring-up-only `with_signer`). Wrong password fails closed. Returns + /// the receive address. + pub fn unlock(&self, password: &str) -> Result { + let mnemonic = self.read_seed(password)?; + let (signer, address) = self.build_signer(&mnemonic)?; + *self.unlocked.write().unwrap() = Some(Unlocked { + signer: Arc::new(signer), + address: address.clone(), + }); + Ok(address) + } + + /// Drop the in-memory signer (the encrypted seed stays on disk). Signing is disabled until the + /// next [`Self::unlock`]. Idempotent. + pub fn lock(&self) { + *self.unlocked.write().unwrap() = None; + } + + /// Delete the wallet: verify `password` against the on-disk seed (proof of ownership), then + /// remove the seed file and lock. A wrong password fails closed and the seed is preserved. + pub fn delete(&self, password: &str) -> Result<()> { + // Verify ownership before destroying anything (fails closed on a wrong password). + let _ = self.read_seed(password)?; + std::fs::remove_file(&self.seed_path) + .map_err(|e| Error::internal(format!("failed to delete the seed: {e}")))?; + self.lock(); + Ok(()) + } + + /// NODE-LOCAL backup ONLY: decrypt + return the mnemonic. This is the sole seed-egress path and + /// MUST NOT be exposed over the paired authorized boundary (§7.12/§18.20) — it exists for the + /// self-origin backup UI / a `dig-node wallet backup` CLI. Wrong password fails closed. + pub fn reveal_mnemonic(&self, password: &str) -> Result> { + self.read_seed(password) + } + + /// The in-memory signer for the sign/broadcast path (§18.21), or `None` when locked. + pub fn signer(&self) -> Option> { + self.unlocked + .read() + .unwrap() + .as_ref() + .map(|u| u.signer.clone()) + } + + // ---- internals -------------------------------------------------------- + + /// Reject a password below the minimum length. + fn check_password(&self, password: &str) -> Result<()> { + if password.len() < MIN_PASSWORD_LEN { + return Err(Error::api(format!( + "password must be at least {MIN_PASSWORD_LEN} characters" + ))); + } + Ok(()) + } + + /// Refuse a create/import when a wallet already exists (never silently clobber a seed). + fn refuse_if_exists(&self) -> Result<()> { + if self.seed_exists() { + return Err(Error::api( + "a wallet already exists on this device; delete it first to create or import another", + )); + } + Ok(()) + } + + /// Read + decrypt the on-disk seed under `password` (maps missing → 404, wrong password → 401). + fn read_seed(&self, password: &str) -> Result> { + let bytes = std::fs::read(&self.seed_path) + .map_err(|_| Error::not_found("no wallet on this device"))?; + seed_store::decrypt_seed(&bytes, password) + .map_err(|_| Error::unauthorized("wrong password")) + } + + /// Encrypt + persist `mnemonic` under `password` (owner-only), then load the signer. + fn persist_and_unlock(&self, mnemonic: &str, password: &str) -> Result { + let enc = seed_store::encrypt_seed(mnemonic, password).map_err(Error::internal)?; + if let Some(dir) = self.seed_path.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| Error::internal(format!("failed to create the wallet dir: {e}")))?; + } + std::fs::write(&self.seed_path, &enc) + .map_err(|e| Error::internal(format!("failed to persist the seed: {e}")))?; + restrict_permissions(&self.seed_path); + let (signer, address) = self.build_signer(mnemonic)?; + *self.unlocked.write().unwrap() = Some(Unlocked { + signer: Arc::new(signer), + address: address.clone(), + }); + Ok(address) + } + + /// Derive the signer (over HD indices `0..derivation_count`) + the receive address from a + /// mnemonic. The signer's per-key puzzle hashes are the standard p2 puzzle hashes the wallet's + /// coins sit at, so it can sign any spend of a coin the wallet owns within its address range. + fn build_signer(&self, mnemonic: &str) -> Result<(WalletSigner, String)> { + let indexed = derive_indexed_keys(mnemonic, 0..self.derivation_count) + .map_err(|e| Error::internal(format!("failed to derive wallet keys: {e}")))?; + let secret_keys = indexed + .into_iter() + .map(|k| k.synthetic_sk) + .collect::>(); + let signer = WalletSigner::new(secret_keys, self.network.agg_sig_data()); + let keys0 = derive_wallet_keys(mnemonic) + .map_err(|e| Error::internal(format!("failed to derive the receive address: {e}")))?; + Ok((signer, owner_address(&keys0))) + } +} + +/// Restrict the seed file to owner read/write on Unix (`0600`); best-effort defense-in-depth +/// (loopback-only + at-rest encryption are the primary controls). No-op on non-Unix. +#[cfg(unix)] +fn restrict_permissions(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); +} +#[cfg(not(unix))] +fn restrict_permissions(_path: &std::path::Path) {} + +#[cfg(test)] +mod tests { + use super::*; + + /// The canonical BIP-39 test vector ("abandon…art") — a KNOWN mnemonic so an import→unlock + /// round-trip is deterministic (the golden migration seed). + const ABANDON: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon art"; + + /// A fresh custody manager over a unique temp seed path (no file yet). A small derivation count + /// keeps the key-build fast in tests. + fn fresh() -> (WalletCustody, PathBuf) { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("dig-node-custody-{}-{}", std::process::id(), n)); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("seed.bin"); + (WalletCustody::new(path.clone(), Network::Mainnet, 3), path) + } + + #[test] + fn status_is_none_when_no_seed_exists() { + let (c, _p) = fresh(); + assert_eq!(c.status().state, CustodyState::None); + assert!(c.status().address.is_none()); + } + + #[test] + fn create_persists_an_encrypted_seed_and_never_returns_the_mnemonic() { + let (c, path) = fresh(); + let address = c.create("hunter2pw").unwrap(); + + // The return is the receive address (an xch1 address has no spaces), NOT a 24-word phrase. + assert!(address.starts_with("xch1"), "got {address}"); + assert!( + !address.contains(' '), + "create must not return the space-separated mnemonic" + ); + + // The seed file exists and is ENCRYPTED at rest — its bytes are a dig-keystore container, + // and decrypting yields a 24-word phrase that is NOT present in plaintext on disk. + let on_disk = std::fs::read(&path).unwrap(); + let recovered = seed_store::decrypt_seed(&on_disk, "hunter2pw").unwrap(); + assert_eq!(recovered.split_whitespace().count(), 24); + assert!( + !String::from_utf8_lossy(&on_disk).contains(&*recovered), + "the mnemonic must not appear in plaintext in the seed file" + ); + + // Create leaves the wallet unlocked. + assert_eq!(c.status().state, CustodyState::Unlocked); + assert_eq!(c.status().address.as_deref(), Some(address.as_str())); + } + + #[test] + fn import_round_trips_the_golden_seed_and_unlock_recovers_the_same_address() { + let (c, _p) = fresh(); + let addr_import = c.import(ABANDON, "correcthorse").unwrap(); + assert!(addr_import.starts_with("xch1")); + + // Lock, then unlock: the same on-disk seed re-derives the identical address. + c.lock(); + assert_eq!(c.status().state, CustodyState::Locked); + let addr_unlock = c.unlock("correcthorse").unwrap(); + assert_eq!( + addr_unlock, addr_import, + "unlock must recover the same wallet" + ); + } + + #[test] + fn unlock_loads_the_signer_and_lock_clears_it() { + let (c, _p) = fresh(); + c.import(ABANDON, "correcthorse").unwrap(); + c.lock(); + + // Locked: no signer available. + assert!(c.signer().is_none(), "locked wallet exposes no signer"); + + // Unlock loads a signer covering the configured HD indices (0..3). + c.unlock("correcthorse").unwrap(); + let signer = c.signer().expect("unlock loads the signer"); + assert_eq!( + signer.puzzle_hashes().len(), + 3, + "the signer covers the 0..derivation_count addresses" + ); + + // The signer recognizes the wallet's index-0 coin (the address it reports). + let keys0 = derive_wallet_keys(ABANDON).unwrap(); + assert!( + signer.synthetic_for(keys0.owner_puzzle_hash).is_some(), + "the loaded signer owns the wallet's first address" + ); + + // Lock clears the signer. + c.lock(); + assert!(c.signer().is_none(), "lock drops the in-memory signer"); + } + + #[test] + fn wrong_password_fails_closed_on_unlock() { + let (c, _p) = fresh(); + c.create("rightpassword").unwrap(); + c.lock(); + let err = c.unlock("wrongpassword").unwrap_err(); + assert_eq!(err.kind, ErrorKind::Unauthorized); + assert!(c.signer().is_none(), "a failed unlock loads no signer"); + } + + #[test] + fn create_refuses_when_a_wallet_already_exists() { + let (c, _p) = fresh(); + c.create("firstpassword").unwrap(); + let err = c.create("secondpassword").unwrap_err(); + assert_eq!(err.kind, ErrorKind::Api); + assert!(err.message.contains("already exists")); + } + + #[test] + fn import_refuses_when_a_wallet_already_exists() { + let (c, _p) = fresh(); + c.create("firstpassword").unwrap(); + assert!(c.import(ABANDON, "anotherpassword").is_err()); + } + + #[test] + fn delete_requires_the_correct_password_then_removes_the_seed() { + let (c, path) = fresh(); + c.create("rightpassword").unwrap(); + assert!(path.exists()); + + // Wrong password: fails closed, seed preserved. + assert!(c.delete("wrongpassword").is_err()); + assert!( + path.exists(), + "a wrong-password delete must preserve the seed" + ); + + // Correct password: seed removed, back to `none`. + c.delete("rightpassword").unwrap(); + assert!(!path.exists()); + assert_eq!(c.status().state, CustodyState::None); + assert!(c.signer().is_none()); + } + + #[test] + fn reveal_mnemonic_returns_the_phrase_only_with_the_right_password() { + let (c, _p) = fresh(); + c.import(ABANDON, "correcthorse").unwrap(); + let revealed = c.reveal_mnemonic("correcthorse").unwrap(); + assert_eq!(&*revealed, ABANDON, "node-local backup recovers the phrase"); + assert!( + c.reveal_mnemonic("wrongpassword").is_err(), + "reveal fails closed on a wrong password" + ); + } + + #[test] + fn weak_password_is_rejected() { + let (c, path) = fresh(); + assert!(c.create("short").is_err()); + assert!(!path.exists(), "a rejected create writes no seed"); + } + + #[test] + fn invalid_mnemonic_is_rejected_on_import() { + let (c, _p) = fresh(); + let err = c + .import("not a valid bip39 phrase at all", "correcthorse") + .unwrap_err(); + assert_eq!(err.kind, ErrorKind::Api); + } + + use super::super::ErrorKind; +} diff --git a/crates/dig-wallet/src/sage/mod.rs b/crates/dig-wallet/src/sage/mod.rs index 5889eb0..f93fa43 100644 --- a/crates/dig-wallet/src/sage/mod.rs +++ b/crates/dig-wallet/src/sage/mod.rs @@ -59,6 +59,7 @@ //! image/color-extraction pipeline (SPEC §18.16). pub mod actions; +pub mod custody; pub mod db; pub mod events; pub mod fallback; diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 1f23872..9b2c1bc 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -2456,7 +2456,9 @@ mod tests { // ---- send/spend dispatch (#216) -------------------------------------- use super::super::db::NftDbRow; - use super::super::spend::{MockBroadcaster, WalletSigner}; + use super::super::spend::{ + BroadcastConsent, ConsentBroadcaster, MockBroadcaster, WalletSigner, + }; use chia_sdk_test::BlsPair; /// A backend with a signer over a single test key, a coin funded at that key's puzzle @@ -2493,6 +2495,57 @@ mod tests { (be, bc, ph) } + /// Like [`spend_backend`] but the broadcaster is a [`ConsentBroadcaster`] wrapping the mock — + /// so a broadcast reaches the (mock) network ONLY after per-op consent is armed (#371). + async fn consent_spend_backend( + fund: u64, + ) -> ( + WalletBackend, + std::sync::Arc, + BroadcastConsent, + ) { + let (base, mock, ph) = spend_backend(fund).await; + let consent = BroadcastConsent::new(); + let gated = Arc::new(ConsentBroadcaster::new(mock.clone(), consent.clone())); + // Rebuild the backend attaching the consent-gated broadcaster in place of the plain mock. + let be = base.with_broadcaster(gated); + let _ = ph; + (be, mock, consent) + } + + /// #371 (§18.21): `send_xch` builds + signs + validates, but the node broadcasts on the + /// paired caller's behalf ONLY with explicit per-op consent. Unconsented → fails closed, + /// nothing spent; consented → broadcasts exactly once. + #[tokio::test] + async fn send_xch_broadcasts_only_with_per_op_consent() { + let (be, mock, consent) = consent_spend_backend(1_000).await; + let dest = encode_address(&"22".repeat(32), "txch").unwrap(); + let body = format!(r#"{{"address":"{dest}","amount":600,"fee":10,"auto_submit":true}}"#); + + // Unconsented: the spend builds + signs + validates, but the broadcast is refused — + // nothing reaches the (mock) network and dispatch returns a non-200 fail-closed status. + let (status, resp) = be.dispatch("send_xch", &body).await; + assert_ne!( + status, 200, + "unconsented broadcast must fail closed: {resp}" + ); + assert_eq!( + mock.sent.lock().unwrap().len(), + 0, + "nothing is broadcast without per-op consent" + ); + + // Consent armed: the same op now signs + broadcasts exactly once. + consent.arm(); + let (status, resp) = be.dispatch("send_xch", &body).await; + assert_eq!(status, 200, "{resp}"); + assert_eq!( + mock.sent.lock().unwrap().len(), + 1, + "a consented op broadcasts exactly once" + ); + } + #[tokio::test] async fn send_xch_dispatch_builds_validates_and_broadcasts() { let (be, bc, _ph) = spend_backend(1_000).await; diff --git a/crates/dig-wallet/src/sage/spend.rs b/crates/dig-wallet/src/sage/spend.rs index 51acbbb..e505286 100644 --- a/crates/dig-wallet/src/sage/spend.rs +++ b/crates/dig-wallet/src/sage/spend.rs @@ -158,6 +158,132 @@ impl Broadcaster for MockBroadcaster { } } +/// The PRODUCTION broadcaster (§18.21): pushes a signed bundle to mainnet via +/// `chia_query::push_tx` — decentralized Chia peers with a coinset.org fallback (IPv6-first per +/// §5.2), mirroring Sage's peer `send_transaction`. This is the REAL [`Broadcaster`] the node +/// attaches on the authorized sign+broadcast path; tests use [`MockBroadcaster`] or the simulator, +/// NEVER this (there is no auto-broadcast in CI). +pub struct ChiaQueryBroadcaster { + query: std::sync::Arc, +} + +impl ChiaQueryBroadcaster { + /// Wrap a shared [`chia_query::ChiaQuery`] — the SAME substrate the fallback tier uses + /// ([`super::fallback::CoinsetFallback`]), so the serving layer shares one client. + pub fn new(query: std::sync::Arc) -> Self { + Self { query } + } +} + +/// Convert a `chia_protocol::SpendBundle` into the `chia_query` wire bundle `push_tx` accepts +/// (hex-encoded coin spends + aggregate signature; `chia_query` parses these 0x-tolerantly). PURE +/// — unit-tested against a known bundle so the field encoding can't silently drift. +fn to_query_bundle(bundle: &SpendBundle) -> Result { + let coin_spends = bundle + .coin_spends + .iter() + .map(|cs| { + Ok(chia_query::CoinSpend { + coin: chia_query::Coin { + parent_coin_info: hex::encode(cs.coin.parent_coin_info), + puzzle_hash: hex::encode(cs.coin.puzzle_hash), + amount: cs.coin.amount, + }, + puzzle_reveal: hex::encode( + cs.puzzle_reveal + .to_bytes() + .map_err(|e| Error::internal(format!("serialize puzzle: {e}")))?, + ), + solution: hex::encode( + cs.solution + .to_bytes() + .map_err(|e| Error::internal(format!("serialize solution: {e}")))?, + ), + }) + }) + .collect::>>()?; + Ok(chia_query::SpendBundle { + coin_spends, + aggregated_signature: hex::encode(bundle.aggregated_signature.to_bytes()), + }) +} + +#[async_trait] +impl Broadcaster for ChiaQueryBroadcaster { + async fn broadcast(&self, bundle: &SpendBundle) -> Result<()> { + let wire = to_query_bundle(bundle)?; + let status = self + .query + .push_tx(&wire) + .await + .map_err(|e| Error::internal(format!("broadcast (push_tx) failed: {e}")))?; + // Fail closed: a non-success mempool status is an error, not a silent no-op. + if !status.success { + return Err(Error::api(format!( + "the network rejected the transaction: {}", + status.status + ))); + } + Ok(()) + } +} + +/// A one-shot broadcast-consent handle (§18.21). The served layer ARMS it when the paired caller +/// confirms a SPECIFIC spend; [`ConsentBroadcaster`] then forwards exactly ONE broadcast and +/// disarms. Cheap to `clone` (shared flag) so the arming site and the broadcaster observe the same +/// state. The default is DISARMED — nothing broadcasts until explicit per-op consent. +#[derive(Clone, Default)] +pub struct BroadcastConsent { + armed: std::sync::Arc, +} + +impl BroadcastConsent { + /// A fresh, DISARMED consent handle. + pub fn new() -> Self { + Self::default() + } + /// Arm consent for the NEXT single broadcast (the caller confirmed the op). + pub fn arm(&self) { + self.armed.store(true, std::sync::atomic::Ordering::SeqCst); + } + /// Whether consent is currently armed (does not consume it). + pub fn is_armed(&self) -> bool { + self.armed.load(std::sync::atomic::Ordering::SeqCst) + } + /// Consume the armed consent (one-shot): returns whether it WAS armed, disarming it. + fn take(&self) -> bool { + self.armed.swap(false, std::sync::atomic::Ordering::SeqCst) + } +} + +/// A [`Broadcaster`] decorator enforcing per-op consent (§18.21). It forwards to `inner` ONLY when +/// a one-shot [`BroadcastConsent`] is armed, then disarms; an unarmed (unconsented) broadcast FAILS +/// CLOSED and the inner broadcaster is never called — nothing is spent. This adapts the §16.2 +/// broadcast gate to the authorized-extension path (each spend needs a fresh, explicit confirm). +pub struct ConsentBroadcaster { + inner: std::sync::Arc, + consent: BroadcastConsent, +} + +impl ConsentBroadcaster { + /// Wrap `inner`, gating each broadcast on `consent`. + pub fn new(inner: std::sync::Arc, consent: BroadcastConsent) -> Self { + Self { inner, consent } + } +} + +#[async_trait] +impl Broadcaster for ConsentBroadcaster { + async fn broadcast(&self, bundle: &SpendBundle) -> Result<()> { + if !self.consent.take() { + return Err(Error::unauthorized( + "broadcast requires explicit per-op consent; nothing was spent", + )); + } + self.inner.broadcast(bundle).await + } +} + // ---- coin selection ------------------------------------------------------- /// Greedily select coins (largest first) covering `target`. Errors if the coins cannot cover @@ -778,6 +904,76 @@ mod tests { assert!(sim.new_transaction(bundle).is_ok()); } + /// Per-op consent gate (#371, §18.21): an unconsented broadcast fails closed and the inner + /// broadcaster is never called; an armed consent forwards EXACTLY one broadcast, then disarms. + #[tokio::test] + async fn consent_broadcaster_refuses_without_consent_and_forwards_once_when_armed() { + let inner = std::sync::Arc::new(MockBroadcaster::default()); + let consent = BroadcastConsent::new(); + let bc = ConsentBroadcaster::new(inner.clone(), consent.clone()); + let bundle = SpendBundle::new(vec![], Signature::default()); + + // Unconsented: fails closed, inner never called (nothing spent). + assert!(bc.broadcast(&bundle).await.is_err()); + assert_eq!(inner.sent.lock().unwrap().len(), 0); + assert!(!consent.is_armed()); + + // Armed: forwards exactly once, then disarms. + consent.arm(); + assert!(consent.is_armed()); + assert!(bc.broadcast(&bundle).await.is_ok()); + assert_eq!(inner.sent.lock().unwrap().len(), 1); + + // One-shot: a second broadcast without re-arming fails closed again. + assert!(bc.broadcast(&bundle).await.is_err()); + assert_eq!(inner.sent.lock().unwrap().len(), 1); + } + + /// `to_query_bundle` hex-encodes each field into the `chia_query` wire shape (the production + /// broadcaster's conversion — proven deterministically so it can't silently drift). + #[test] + fn to_query_bundle_hex_encodes_every_field() { + let cs = CoinSpend { + coin: Coin::new(Bytes32::new([1; 32]), Bytes32::new([2; 32]), 42), + puzzle_reveal: Program::from(vec![0x80]), + solution: Program::from(vec![0x81]), + }; + let bundle = SpendBundle::new(vec![cs], Signature::default()); + let wire = to_query_bundle(&bundle).unwrap(); + assert_eq!(wire.coin_spends.len(), 1); + assert_eq!(wire.coin_spends[0].coin.parent_coin_info, "01".repeat(32)); + assert_eq!(wire.coin_spends[0].coin.puzzle_hash, "02".repeat(32)); + assert_eq!(wire.coin_spends[0].coin.amount, 42); + assert_eq!(wire.coin_spends[0].puzzle_reveal, "80"); + assert_eq!(wire.coin_spends[0].solution, "81"); + assert_eq!( + wire.aggregated_signature, + hex::encode(Signature::default().to_bytes()) + ); + } + + /// Fail-closed pre-broadcast validation (#371): a tampered bundle (an over-spend — inputs less + /// than outputs) is rejected by `dig-clvm` before any broadcast. + #[test] + fn tampered_bundle_fails_dig_clvm_validation() { + let mut sim = Simulator::new(); + let alice = sim.bls(1_000); + let signer = signer_for(alice.sk.clone()); + let dest = Bytes32::new([9; 32]); + let mut coin_spends = + build_xch_send(&signer, &[alice.coin], dest, 600, 10, alice.puzzle_hash).unwrap(); + + // The honest bundle validates. + assert!(run_and_validate(&coin_spends).is_ok()); + + // Tamper: claim the spent coin holds far less than its outputs create (over-spend). + coin_spends[0].coin.amount = 1; + assert!( + run_and_validate(&coin_spends).is_err(), + "a tampered/over-spending bundle must fail dig-clvm validation (fail-closed)" + ); + } + #[test] fn cat_send_builds_validates_and_broadcasts_on_simulator() { use chia_wallet_sdk::driver::Cat as SdkCat;