Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
|---|---|---|---|
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions crates/dig-node-service/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
}
}

Expand Down
15 changes: 14 additions & 1 deletion crates/dig-node-service/src/meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -586,6 +592,7 @@ impl ErrorCode {
ErrorCode::WalletNoChainSource => -32040,
ErrorCode::WalletNotSynced => -32041,
ErrorCode::WalletReadFailed => -32042,
ErrorCode::WalletRateLimited => -32043,
}
}

Expand All @@ -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",
}
}

Expand All @@ -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",
Expand Down Expand Up @@ -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."
}
}
}

Expand All @@ -685,6 +697,7 @@ impl ErrorCode {
ErrorCode::WalletNoChainSource,
ErrorCode::WalletNotSynced,
ErrorCode::WalletReadFailed,
ErrorCode::WalletRateLimited,
]
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/dig-wallet/src/sage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
122 changes: 122 additions & 0 deletions crates/dig-wallet/src/sage/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -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<BucketState>,
}

#[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");
}
}
Loading
Loading