diff --git a/Cargo.lock b/Cargo.lock index a8343d1..8841227 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2281,7 +2281,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.75.1" +version = "0.75.2" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2e90fea..4cf079c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[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.75.1" +version = "0.75.2" # 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 a542f03..40200fa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1203,7 +1203,7 @@ lowercase 64-hex; a capsule reference is `storeId:rootHash`. Malformed refs yiel | `control.hostedStores.status` | `store` = `storeId[:rootHash]` | `store_id`, `pinned`, `capsule_count`, `total_bytes`, `capsules[]` | | `control.sync.status` | — | `available` (always `true` — the chunked capsule download needs no identity), `method: "chunked-capsule-download-with-section-21-clone-fallback"`, `identity_loaded`, `pinned_total`, `pinned_synced`, `whole_store_trigger_supported` (`true` — a store id alone is enough) | | `control.sync.trigger` | `store` = `storeId[:rootHash]`, or `store_id` [+ `root`] — the root is OPTIONAL; without one the node resolves the store's CHAIN-ANCHORED tip and syncs that generation | `status: "synced"`, `root`, `size_bytes`, `served_root` | -| `control.wallet.balance` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"`, default `"xch"`) | `balance` (confirmed, spendable — JSON NUMBER, u64 base units), `pending` (unspent + unconfirmed — JSON NUMBER, u64 base units), `synced` (bool — whether a fully-synced view answered), `peak_height` (the node's chain-view peak, or `null`). Matches `dig-node-control-interface` 0.3.0's `WalletBalanceResult { balance: u64, pending: u64, .. }` and dig-app's `BalanceResponse { balance: u64 }` — a Rust-to-Rust numeric contract, never a decimal string. The wallet backend tracks the base-unit total as `u128` (headroom for summed intermediate math); the wire boundary saturating-casts to `u64` (a single address's balance can never exceed `u64::MAX` mojos, ~18.4M XCH). READ-ONLY chain read of a PUBLIC address (no seed/signing key). Reuses the B.6 sync-state routing: the local DB when the address is the wallet's own and the DB is synced, else the coinset fallback. `$DIG` scopes by the canonical CAT asset id `digstore_chain::dig::DIG_ASSET_ID`. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error; the three read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. | +| `control.wallet.balance` | `address` (bech32m string), `asset` (`"xch"` \| `"dig"`, default `"xch"`) | `balance` (confirmed, spendable — JSON NUMBER, u64 base units), `pending` (unspent + unconfirmed — JSON NUMBER, u64 base units), `synced` (bool — whether a fully-synced view answered), `peak_height` (the node's chain-view peak, or `null`). Matches `dig-node-control-interface` 0.3.0's `WalletBalanceResult { balance: u64, pending: u64, .. }` and dig-app's `BalanceResponse { balance: u64 }` — a Rust-to-Rust numeric contract, never a decimal string. The wallet backend tracks the base-unit total as `u128` (headroom for summed intermediate math); the wire boundary saturating-casts to `u64` (a single address's balance can never exceed `u64::MAX` mojos, ~18.4M XCH). READ-ONLY chain read of a PUBLIC address (no seed/signing key). Reuses the B.6 sync-state routing: the local DB when the address is the wallet's own and the DB is synced, else the coinset fallback. This is an OPEN read (`is_open_control_read`, no token); the cheap local-DB fast path is unbounded, but the EXPENSIVE coinset-fallback leg is subject to a GLOBAL token-bucket rate bound (defense-in-depth against an open-read amplification/oracle sweep — #1957): a burst of arbitrary-address fallback reads beyond the bound is refused with `WALLET_RATE_LIMITED` (§10), while any single honest read (DB fast path or one fallback) always succeeds. `$DIG` scopes by the canonical CAT asset id `digstore_chain::dig::DIG_ASSET_ID`. A synced empty address is a SUCCESS `{balance:0, synced:true}`, never an error; the read-failure shapes are DISTINCT errors `WALLET_NO_CHAIN_SOURCE`/`WALLET_NOT_SYNCED`/`WALLET_READ_FAILED`/`WALLET_RATE_LIMITED` (§10), never a fabricated `0`. `INVALID_PARAMS` on a missing/malformed `address` or a bad `asset`. | ### 7.5. Ownership boundary @@ -2051,7 +2051,8 @@ method-not-found cue). docs.dig.net — and MUST NOT be used for control. (`dig-rpc-protocol` is the source of this resolution; any client that branched on the old control numbers keys on the symbolic `data.code`, not the number.) The wallet-read errors occupy `-3204x` (`WALLET_NO_CHAIN_SOURCE` / `WALLET_NOT_SYNCED` / -`WALLET_READ_FAILED`); `-3205x` is owned by the chat plane (§ chat) and MUST NOT collide. +`WALLET_READ_FAILED` / `WALLET_RATE_LIMITED`); `-3205x` is owned by the chat plane (§ chat) and MUST +NOT collide. | Code | Name | Origin | Meaning | |---|---|---|---| @@ -2073,6 +2074,7 @@ number.) The wallet-read errors occupy `-3204x` (`WALLET_NO_CHAIN_SOURCE` / `WAL | -32040 | `WALLET_NO_CHAIN_SOURCE` | node | `control.wallet.balance` (§ control catalog) had NO live chain source able to answer an arbitrary (non-wallet) address. Distinct from a truthful `0`. | | -32041 | `WALLET_NOT_SYNCED` | node | `control.wallet.balance` of the wallet's OWN address while the local DB is still syncing and no live fallback is attached (nothing can answer yet). | | -32042 | `WALLET_READ_FAILED` | node | `control.wallet.balance` failed at the underlying DB / chain-source layer. Distinct from `WALLET_NO_CHAIN_SOURCE` and `WALLET_NOT_SYNCED`. | +| -32043 | `WALLET_RATE_LIMITED` | node | `control.wallet.balance` refused: the GLOBAL coinset-fallback rate bound is exhausted (too many arbitrary-address reads hit the expensive fallback in a short window). Defense-in-depth against an open-read amplification/oracle sweep; back off and retry. The cheap local-DB fast path is never gated. | Read-path and upstream errors outside this table are relayed verbatim; this catalogue governs what the **shell** mints plus the cross-boundary codes a client must be able to branch on. diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 822a7e0..f73b6ae 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -1251,6 +1251,11 @@ async fn wallet_balance(ctx: &ControlCtx, id: Value, params: &Value) -> Value { ErrorCode::WalletReadFailed, format!("balance read failed: {e}"), ), + Err(BalanceError::RateLimited) => control_error( + id, + ErrorCode::WalletRateLimited, + "balance read refused: the open coinset-fallback rate limit is exhausted; back off and retry", + ), } } diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 7922490..1549098 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -567,6 +567,12 @@ pub enum ErrorCode { /// Distinct from `WALLET_NO_CHAIN_SOURCE` (nothing to ask) and `WALLET_NOT_SYNCED` /// (not ready). Node error. WalletReadFailed, + /// `-32043` — a wallet balance read was refused because the GLOBAL coinset-fallback rate + /// bound (#1957) is exhausted: too many arbitrary-address reads have hit the expensive + /// fallback in a short window. Defense-in-depth against an open-read amplification/oracle + /// sweep; the caller should back off and retry. The cheap local-DB fast path is never gated. + /// Node error. (Wallet range `-3204x`.) + WalletRateLimited, } impl ErrorCode { @@ -586,6 +592,7 @@ impl ErrorCode { ErrorCode::WalletNoChainSource => -32040, ErrorCode::WalletNotSynced => -32041, ErrorCode::WalletReadFailed => -32042, + ErrorCode::WalletRateLimited => -32043, } } @@ -606,6 +613,7 @@ impl ErrorCode { ErrorCode::WalletNoChainSource => "WALLET_NO_CHAIN_SOURCE", ErrorCode::WalletNotSynced => "WALLET_NOT_SYNCED", ErrorCode::WalletReadFailed => "WALLET_READ_FAILED", + ErrorCode::WalletRateLimited => "WALLET_RATE_LIMITED", } } @@ -626,7 +634,8 @@ impl ErrorCode { // The wallet balance read (#1851) is served by the node-custodied wallet backend. ErrorCode::WalletNoChainSource | ErrorCode::WalletNotSynced - | ErrorCode::WalletReadFailed => "node", + | ErrorCode::WalletReadFailed + | ErrorCode::WalletRateLimited => "node", // INVALID_PARAMS is returned by the embedded read path's locally-served // read methods (bad store_id / retrieval_key) before any I/O. ErrorCode::InvalidParams => "node", @@ -666,6 +675,9 @@ impl ErrorCode { "A wallet balance read of the wallet's own address is still syncing with no fallback." } ErrorCode::WalletReadFailed => "A wallet balance read failed at the DB / chain layer.", + ErrorCode::WalletRateLimited => { + "A wallet balance read was refused: the open coinset-fallback rate bound is exhausted." + } } } @@ -685,6 +697,7 @@ impl ErrorCode { ErrorCode::WalletNoChainSource, ErrorCode::WalletNotSynced, ErrorCode::WalletReadFailed, + ErrorCode::WalletRateLimited, ] } } diff --git a/crates/dig-wallet/src/sage/mod.rs b/crates/dig-wallet/src/sage/mod.rs index d9a0af0..0269a88 100644 --- a/crates/dig-wallet/src/sage/mod.rs +++ b/crates/dig-wallet/src/sage/mod.rs @@ -68,6 +68,7 @@ pub mod mint; pub mod network; pub mod offers; pub mod options; +pub mod rate_limit; pub mod routing; pub mod rpc; pub mod service; diff --git a/crates/dig-wallet/src/sage/rate_limit.rs b/crates/dig-wallet/src/sage/rate_limit.rs new file mode 100644 index 0000000..965ab98 --- /dev/null +++ b/crates/dig-wallet/src/sage/rate_limit.rs @@ -0,0 +1,122 @@ +//! A minimal, self-contained token-bucket rate limiter (#1957). +//! +//! It bounds the RATE at which an OPEN, externally-dependent read may reach an +//! expensive downstream — specifically the coinset fallback behind +//! [`super::rpc::WalletBackend::balance_for_address`]. `control.wallet.balance` +//! is an unauthenticated loopback read of any public address; the on-chain data +//! is public (no confidentiality risk), but an unbounded open read is a cheap +//! amplification/oracle surface — a caller can sweep many arbitrary addresses and +//! hammer the coinset fallback. This gate caps that abuse WITHOUT touching the +//! cheap, legitimate local-DB fast path. +//! +//! The limiter is GLOBAL (one bucket per backend), not per-source: on the +//! loopback control plane every caller is `127.0.0.1`, so a per-source bound is +//! meaningless — the thing worth bounding is the aggregate fallback call rate. + +use std::sync::Mutex; +use std::time::Instant; + +/// A classic token bucket: it holds up to `capacity` tokens, refills at +/// `refill_per_sec`, and each admitted call spends one token. A burst up to +/// `capacity` passes immediately; sustained traffic is capped at the refill +/// rate; when empty, [`TokenBucket::try_acquire`] refuses (returns `false`) so +/// the caller can back off rather than block. +/// +/// All state lives behind an internal [`Mutex`]; the critical section is a few +/// arithmetic ops with NO `.await`, so it can never deadlock or be held across a +/// yield point. +#[derive(Debug)] +pub struct TokenBucket { + capacity: f64, + refill_per_sec: f64, + state: Mutex, +} + +#[derive(Debug)] +struct BucketState { + tokens: f64, + last_refill: Instant, +} + +impl TokenBucket { + /// A bucket that starts FULL (`capacity` tokens) and refills at + /// `refill_per_sec`. A `refill_per_sec` of `0.0` makes the bucket a fixed + /// pool that never replenishes (used by the fast, deterministic unit tests). + /// + /// `capacity` is clamped to be non-negative; a capacity of `0.0` refuses + /// every call (useful to prove a code path bypasses the gate entirely). + pub fn new(capacity: f64, refill_per_sec: f64) -> Self { + let capacity = capacity.max(0.0); + Self { + capacity, + refill_per_sec: refill_per_sec.max(0.0), + state: Mutex::new(BucketState { + tokens: capacity, + last_refill: Instant::now(), + }), + } + } + + /// Try to spend one token. Returns `true` when the call is admitted (a token + /// was available and consumed), `false` when the bucket is empty and the + /// caller should back off. + pub fn try_acquire(&self) -> bool { + // A poisoned lock (a prior panic while held) must not wedge the read + // path; recover the guard and carry on — the arithmetic below fully + // reinitialises the accounting from `Instant::now()`. + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let now = Instant::now(); + let elapsed = now.duration_since(state.last_refill).as_secs_f64(); + state.tokens = (state.tokens + elapsed * self.refill_per_sec).min(self.capacity); + state.last_refill = now; + + if state.tokens >= 1.0 { + state.tokens -= 1.0; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_full_bucket_admits_up_to_capacity_then_refuses() { + let bucket = TokenBucket::new(3.0, 0.0); // no refill: a fixed pool of 3 + assert!(bucket.try_acquire(), "1st admitted"); + assert!(bucket.try_acquire(), "2nd admitted"); + assert!(bucket.try_acquire(), "3rd admitted"); + assert!(!bucket.try_acquire(), "4th refused — pool exhausted"); + assert!(!bucket.try_acquire(), "stays refused"); + } + + #[test] + fn a_zero_capacity_bucket_refuses_everything() { + let bucket = TokenBucket::new(0.0, 0.0); + assert!(!bucket.try_acquire()); + } + + #[test] + fn refill_replenishes_tokens_over_time() { + // 0 capacity start would never admit; use a tiny capacity fully drained, + // then a high refill rate so a real sleep restores at least one token. + let bucket = TokenBucket::new(1.0, 1_000.0); + assert!(bucket.try_acquire(), "the single starting token"); + assert!(!bucket.try_acquire(), "drained"); + std::thread::sleep(std::time::Duration::from_millis(5)); + assert!(bucket.try_acquire(), "refilled after the elapsed time"); + } + + #[test] + fn negative_inputs_are_clamped_to_zero() { + let bucket = TokenBucket::new(-5.0, -1.0); + assert!(!bucket.try_acquire(), "negative capacity behaves as empty"); + } +} diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 9595700..54f4e76 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -96,6 +96,11 @@ pub enum BalanceError { NotSynced, /// An underlying read (DB query or fallback) errored. ReadFailed(String), + /// The GLOBAL coinset-fallback rate bound (#1957) is exhausted: too many arbitrary-address + /// reads have hit the expensive fallback in a short window. Defense-in-depth against an + /// open-read amplification/oracle sweep — the caller should back off and retry. The cheap + /// local-DB fast path is NEVER subject to this bound. + RateLimited, } /// Static wallet identity + config the read surface needs (derived once at bring-up). @@ -126,6 +131,16 @@ impl Default for WalletConfig { } } +/// Default burst allowance for the open-balance coinset fallback (#1957): a single caller may +/// hit the expensive fallback this many times in a burst before the rate bound engages. Sized +/// generously so no realistic legitimate use (a handful of address lookups) is ever refused — +/// only a rapid sweep of many arbitrary addresses trips it. +const DEFAULT_FALLBACK_BURST: f64 = 64.0; + +/// Default sustained refill rate (tokens per second) for the open-balance coinset fallback +/// (#1957): once the burst is spent, fallback reads are admitted at this steady rate. +const DEFAULT_FALLBACK_REFILL_PER_SEC: f64 = 8.0; + /// The Sage-parity wallet backend. #[derive(Clone)] pub struct WalletBackend { @@ -177,6 +192,12 @@ pub struct WalletBackend { /// publishes to. DISTINCT from [`Self::events`] so tip events never leak into the Sage-parity /// `SyncEvent` stream. tip_events: Arc, + /// The GLOBAL rate bound on the EXPENSIVE coinset-fallback leg of `balance_for_address` + /// (#1957). `control.wallet.balance` is an open, unauthenticated loopback read; the local-DB + /// fast path is cheap + legitimate and stays unbounded, but the externally-dependent coinset + /// fallback is a cheap amplification/oracle surface, so its aggregate call rate is capped + /// here. Shared across `Clone`s so one bucket governs the whole backend, not per-connection. + fallback_rate: Arc, } /// The connected client's PUBLIC identity for a session (#407). Scoping data only — no key. @@ -208,9 +229,25 @@ impl WalletBackend { sign_lock: Arc::new(tokio::sync::Mutex::new(())), tipping: None, tip_events: Arc::new(super::tipping::TipEventBus::default()), + fallback_rate: Arc::new(super::rate_limit::TokenBucket::new( + DEFAULT_FALLBACK_BURST, + DEFAULT_FALLBACK_REFILL_PER_SEC, + )), } } + /// Override the coinset-fallback rate bound (#1957) — primarily for tests that want a small, + /// deterministic pool. `capacity` is the immediate burst allowance; `refill_per_sec` the + /// sustained rate (`0.0` = a fixed, non-replenishing pool). + #[must_use] + pub fn with_fallback_rate_limit(mut self, capacity: f64, refill_per_sec: f64) -> Self { + self.fallback_rate = Arc::new(super::rate_limit::TokenBucket::new( + capacity, + refill_per_sec, + )); + self + } + /// Attach the tipping subsystem (#378) — enables the `tip.*` methods. The engine should be /// constructed with the SAME [`super::tipping::TipEventBus`] passed to [`Self::with_tip_events`] /// so its tip pushes reach `/ws` subscribers. @@ -509,6 +546,13 @@ impl WalletBackend { BalanceError::NoChainSource }); } + // Defense-in-depth (#1957): the coinset fallback is the ONLY expensive, + // externally-dependent leg of this open read. Bound its aggregate call rate so an + // unauthenticated loopback caller cannot sweep arbitrary addresses to amplify load + // on / oracle the fallback. The cheap DB fast path above is never gated. + if !self.fallback_rate.try_acquire() { + return Err(BalanceError::RateLimited); + } let phs = [puzzle_hash]; // XCH coins sit AT the puzzle hash; CAT coins are HINTED to it. let coins = match asset { @@ -3362,6 +3406,94 @@ mod tests { } } + // ---- #1957: the coinset-fallback path is abuse-bounded ----------------------------------- + + /// A fresh arbitrary address for a sweep — each derived from a distinct seed byte so no two + /// reads collapse onto one puzzle hash. + fn arbitrary_address(seed: u8) -> String { + encode_address(&format!("{seed:02x}").repeat(32), "xch").unwrap() + } + + /// A rapid SWEEP of arbitrary addresses that force the coinset fallback trips the global + /// rate bound: with a fixed pool of `N` fallback tokens (no refill), the first `N` reads + /// succeed and the `(N+1)`th is refused with the distinct [`BalanceError::RateLimited`]. + /// RED without a limiter: every read would succeed. + #[tokio::test] + async fn wallet_balance_fallback_is_rate_limited_after_a_burst() { + const POOL: usize = 4; + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + let be = WalletBackend::new( + db, + Arc::new(MockFallback::default()), + WalletConfig::default(), + ) + .with_fallback_rate_limit(POOL as f64, 0.0); // no refill ⇒ deterministic + + for i in 0..POOL { + let r = be + .balance_for_address(&arbitrary_address(i as u8), BalanceAsset::Xch) + .await; + assert!(r.is_ok(), "read {i} within the burst is admitted: {r:?}"); + } + assert_eq!( + be.balance_for_address(&arbitrary_address(0xEE), BalanceAsset::Xch) + .await, + Err(BalanceError::RateLimited), + "the read past the burst is refused with the rate-limit error" + ); + } + + /// A SINGLE legitimate fallback read is never throttled — the limiter must not trip on one. + /// RED if the bound were set below 1 (too aggressive). + #[tokio::test] + async fn a_single_legitimate_balance_read_is_unaffected() { + let arb_ph = "44".repeat(32); + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + let fb = Arc::new(MockFallback::with_coins(vec![fallback_coin( + "c1", + &arb_ph, + 77, + Some(10), + None, + )])); + // Even a minimal pool of exactly one token admits the single honest read. + let be = + WalletBackend::new(db, fb, WalletConfig::default()).with_fallback_rate_limit(1.0, 0.0); + let r = be + .balance_for_address(&encode_address(&arb_ph, "xch").unwrap(), BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(r.balance, 77, "the honest read returns its real figure"); + } + + /// The cheap, legitimate local-DB fast path is NEVER rate-limited: even with a fully + /// exhausted (zero-capacity) fallback bucket, a burst of DB-hit reads all succeed, proving + /// the gate sits ONLY in front of the fallback, not ahead of the DB fast path. RED if the + /// limiter were placed before the routing decision. + #[tokio::test] + async fn the_local_db_fast_path_is_not_rate_limited() { + let db = db_with_owned_derivation(true, Some(10)).await; + db.upsert_coins(&[coin_at_ph("confirmed", &owned_ph(), 100, Some(10), None)]) + .await + .unwrap(); + // A zero-token bucket would refuse ANY fallback call — but the DB path must bypass it. + let be = WalletBackend::new( + db, + Arc::new(MockFallback::default()), + WalletConfig::default(), + ) + .with_fallback_rate_limit(0.0, 0.0); + for _ in 0..10 { + let r = be + .balance_for_address(&owned_address(), BalanceAsset::Xch) + .await + .expect("the DB fast path is never throttled"); + assert_eq!(r.balance, 100); + } + } + #[tokio::test] async fn get_version_reports_crate_version() { let be = backend_with(vec![], true).await;