diff --git a/Cargo.lock b/Cargo.lock index 337efc4..69b2d79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.100.2" +version = "0.101.0" dependencies = [ "async-trait", "axum", @@ -2470,7 +2470,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.12.2" +version = "0.13.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 9564be8..66c54dd 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.100.2" +version = "0.101.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 8fcfaee..0feabd7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1466,7 +1466,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. 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`. | +| `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), `source` (`"db"` \| `"fallback"` — which tier produced the figure, §18.7b), `synced` (bool), `peak_height` (`u32` 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. Per §18.7b, `source`/`synced`/`peak_height` describe the TIER that answered: a `"db"` answer reports `synced: true` and the node's own peak; a `"fallback"` answer reports `synced: false` and `peak_height: null`. 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`. | | `control.peers.ping` | `peer` (a 64-hex `peer_id`, or a dialable `host:port` with IPv6 bracketed), `peer_id` (OPTIONAL 64-hex — pins the identity the presented certificate MUST derive) | The connection-ladder report — see §7.4a. `INVALID_PARAMS` on a missing/blank `peer`; `CONTROL_ERROR` when no peer network is running; `PEER_PING_REFUSED` (§10) when the anti-amplification gate refuses before dialing. | ### 7.4a. `control.peers.ping` — the connection-ladder diagnostic @@ -3707,6 +3707,39 @@ hashes/addresses). read as NOT synced and never as a synced-zero. `selectable_balance` is the identity-scoped unspent XCH balance (0 when not tracking). +18.7b. **Tier disclosure — the reported state describes the ANSWER, not the node (#2233).** Every +wallet read that chooses a source per the §18.7 routing table MUST disclose the tier that actually +answered, and MUST derive every freshness field from that tier. + +- **`source` is additive and REQUIRED on the result.** `control.wallet.balance` returns + `source: "db" | "fallback"` alongside its figures. `"db"` means the node's own chain replica + produced the figure; `"fallback"` means a third-party coinset HTTP oracle did, which additionally + means the queried address WAS DISCLOSED off-node — a fact a caller on a metered or private + connection has a legitimate interest in. The field is additive per §5.1: a consumer that does not + read it parses unchanged. +- **`synced` and `peak_height` are properties of the tier.** A `"db"` answer reports `synced: true` + and the replica's own peak. A `"fallback"` answer reports `synced: false` and `peak_height: null`, + **regardless of the local DB's state** — the DB neither produced that figure nor bounds its + freshness, so its flag and peak say nothing about it. Implementations MUST NOT read those two + fields outside the tier decision. +- **Rationale — this is the falsifiability instrument for §18.6.** A success criterion phrased as a + flag value rather than as the path taken is satisfiable with the goal unmet: once the §18.6 sync + loop sets `initial_sync_complete`, a read still served by the oracle would report itself as a + synced local read. Acceptance for any sync work MUST name the `source` tier, never the `synced` + flag alone. +- **Operator visibility.** The routing branch emits a `tracing` event carrying `tier=db|fallback`, + so `dig-node.jsonl` records the same tier the wire reports. Diagnostics go through `tracing`, + never stderr (a Windows service discards it). + + **The FALLBACK tier MUST be logged at a level a stock node actually emits.** `dig-logging`'s + baked-in default is `info` and a default install sets none of the overrides, so a `debug!` here is + invisible in the field — which would make the sentence above false on every stock node, and would + let an acceptance run reading `dig-node.jsonl` mistake silence for "no fallback occurred". Fallback + is therefore `info`; it is the exceptional path and it means the read was disclosed to a + third-party oracle. The DB tier stays `debug`: once the §18.6 sync loop lands it is the ordinary + path, and logging every local read at `info` would turn an OPEN unauthenticated loopback endpoint + into a log-volume lever. + 18.8. **Method surface — reads (served).** `login`, `logout`, `get_version`, `get_sync_status`, `check_address`, `get_derivations`, `get_are_coins_spendable`, `get_spendable_coin_count`, `get_coins`, `get_coins_by_ids`, `get_cats`, `get_all_cats`, `get_token`, diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index a828fde..803a580 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -1197,6 +1197,7 @@ fn balance_wire(r: &dig_wallet::sage::rpc::WalletBalanceResult) -> Value { json!({ "balance": u64::try_from(r.balance).unwrap_or(u64::MAX), "pending": u64::try_from(r.pending).unwrap_or(u64::MAX), + "source": r.source, "synced": r.synced, "peak_height": r.peak_height, }) @@ -1208,7 +1209,10 @@ fn balance_wire(r: &dig_wallet::sage::rpc::WalletBalanceResult) -> Value { /// sync-state routing ([`dig_wallet::sage::rpc::WalletBackend::balance_for_address`]). /// /// Params: `{ address (bech32m string), asset ("xch" | "dig") }`. Result: -/// `{ balance, pending, synced, peak_height }`. A synced empty address is a SUCCESS with a zero +/// `{ balance, pending, source, synced, peak_height }`, where `source` is `"db"` (the node's own +/// chain replica) or `"fallback"` (a third-party coinset oracle — the address was disclosed +/// off-node), and `synced`/`peak_height` describe THAT tier (#2233). +/// A synced empty address is a SUCCESS with a zero /// figure; the three read-failure shapes map to DISTINCT catalogued errors (never a fabricated /// `0`): `WALLET_NO_CHAIN_SOURCE`, `WALLET_NOT_SYNCED`, `WALLET_READ_FAILED`. async fn wallet_balance(ctx: &ControlCtx, id: Value, params: &Value) -> Value { @@ -1816,6 +1820,7 @@ mod tests { /// against the printed JSON. #[test] fn balance_wire_emits_numeric_amounts_matching_app_contract() { + use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::WalletBalanceResult; #[derive(serde::Deserialize)] @@ -1826,6 +1831,7 @@ mod tests { let r = WalletBalanceResult { balance: 12_345, pending: 6, + source: Source::Db, synced: true, peak_height: Some(42), }; @@ -1834,7 +1840,10 @@ mod tests { // Golden shape: numeric, not string. assert_eq!( emitted, - json!({"balance": 12345u64, "pending": 6u64, "synced": true, "peak_height": 42}), + json!({ + "balance": 12345u64, "pending": 6u64, + "source": "db", "synced": true, "peak_height": 42 + }), ); assert!( emitted["balance"].is_number(), @@ -1858,15 +1867,51 @@ mod tests { /// overflow. #[test] fn balance_wire_saturates_u128_overflow_to_u64_max() { + use dig_wallet::sage::routing::Source; use dig_wallet::sage::rpc::WalletBalanceResult; let r = WalletBalanceResult { balance: u128::from(u64::MAX) + 1, pending: 0, + source: Source::Fallback, synced: false, peak_height: None, }; let emitted = balance_wire(&r); assert_eq!(emitted["balance"], json!(u64::MAX)); } + + /// (#2233) The tier reaches the WIRE as the lowercase token a consumer keys on, for BOTH + /// tiers — so a mapper that dropped the field, or emitted the Rust variant name (`"Db"`), + /// fails here rather than at a consumer. + /// + /// The `source` field is ADDITIVE (§5.1): the same test asserts a consumer struct that + /// does not know about it still deserializes, so ignoring it cannot break a caller. + #[test] + fn balance_wire_discloses_the_answering_tier_additively() { + use dig_wallet::sage::routing::Source; + use dig_wallet::sage::rpc::WalletBalanceResult; + + #[derive(serde::Deserialize)] + struct OldConsumer { + balance: u64, + synced: bool, + } + + for (source, wire) in [(Source::Db, "db"), (Source::Fallback, "fallback")] { + let emitted = balance_wire(&WalletBalanceResult { + balance: 1, + pending: 0, + source, + synced: source == Source::Db, + peak_height: None, + }); + assert_eq!(emitted["source"], json!(wire)); + + let old: OldConsumer = serde_json::from_value(emitted) + .expect("a consumer unaware of `source` must still parse"); + assert_eq!(old.balance, 1); + assert_eq!(old.synced, source == Source::Db); + } + } } diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index eaeabff..e602237 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.12.2" +version = "0.13.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/routing.rs b/crates/dig-wallet/src/sage/routing.rs index aae25ff..6de2b66 100644 --- a/crates/dig-wallet/src/sage/routing.rs +++ b/crates/dig-wallet/src/sage/routing.rs @@ -14,7 +14,12 @@ //! has a single, auditable definition; the RPC layer calls it once per wallet-data read. /// Where a wallet-data read is served from. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// This is also the WIRE spelling of the tier (`"db"` / `"fallback"`), reported on every +/// read result that makes a tier choice (#2233). One definition serves both the routing +/// decision and its disclosure, so the reported tier cannot drift from the tier taken. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] pub enum Source { /// The local SQLite wallet DB (peer-maintained, design B.3/B.6). Db, @@ -22,6 +27,19 @@ pub enum Source { Fallback, } +impl Source { + /// The wire/log spelling of this tier — the SAME string [`serde::Serialize`] emits. + /// + /// Used for the `tier` field on the routing `tracing` event, so a log line and the JSON + /// result a caller reads always name the tier identically. + pub fn as_wire(self) -> &'static str { + match self { + Self::Db => "db", + Self::Fallback => "fallback", + } + } +} + /// Select the source for a wallet-data read given the two B.6 axes. /// /// - `db_synced`: has the initial subscription catch-up completed @@ -59,4 +77,14 @@ mod tests { assert_eq!(route(true, false), Source::Fallback); assert_eq!(route(false, false), Source::Fallback); } + + /// The wire spelling and the log spelling are the SAME string, pinned literally so a + /// rename of the Rust variant cannot silently change what a consumer parses (#2233). + #[test] + fn tier_serializes_and_logs_as_the_same_lowercase_wire_string() { + for (src, wire) in [(Source::Db, "db"), (Source::Fallback, "fallback")] { + assert_eq!(serde_json::to_value(src).unwrap(), serde_json::json!(wire)); + assert_eq!(src.as_wire(), wire); + } + } } diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 54f4e76..90c07a6 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -75,10 +75,22 @@ pub struct WalletBalanceResult { pub balance: u128, /// Pending balance: unspent coins not yet confirmed on-chain (in-flight value). pub pending: u128, - /// Whether the answer reflects a fully-synced view (DB caught up), vs a fallback read - /// taken while the local replica is still converging. + /// Which tier actually produced this figure (#2233): [`Source::Db`] — the node's own + /// chain replica — or [`Source::Fallback`] — a third-party coinset HTTP oracle, which + /// means the queried address was disclosed off-node. + /// + /// Additive per §5.1: a consumer that ignores it parses unchanged. + pub source: Source, + /// Whether THIS answer reflects a fully-synced local view. + /// + /// Derived from the tier, never from the DB flag independently: only a + /// [`Source::Db`] answer can be synced, because only that tier read the local + /// replica. A fallback answer reports `false` however caught-up the DB happens to + /// be — the DB's state does not describe an answer the DB did not give (#2233). pub synced: bool, - /// The node's best-known chain peak height, when known. + /// The chain peak height THIS answer reflects, when known — `None` for a + /// [`Source::Fallback`] answer, whose figure came from the oracle's chain view, not + /// the node's (#2233). pub peak_height: Option, } @@ -483,15 +495,19 @@ impl WalletBackend { /// /// - **Wallet-owned address, DB synced** → the local DB is authoritative: /// [`db::WalletDb::balance_scoped`] (confirmed) + [`db::WalletDb::pending_scoped`] - /// (unconfirmed), `synced = true`. - /// - **Otherwise** → the fallback (coinset) tier answers, `synced` reflecting the real DB - /// sync state. If no LIVE fallback is attached, the read cannot honestly answer, so it - /// returns a DISTINCT error rather than a fabricated `0`: [`BalanceError::NotSynced`] - /// for the wallet's own address (the DB would answer once synced), - /// [`BalanceError::NoChainSource`] for an arbitrary address (only a chain source could). + /// (unconfirmed); `source = "db"`, `synced = true`, `peak_height` = the node's own peak. + /// - **Otherwise** → the fallback (coinset) tier answers; `source = "fallback"`, + /// `synced = false`, `peak_height = null`. If no LIVE fallback is attached, the read + /// cannot honestly answer, so it returns a DISTINCT error rather than a fabricated `0`: + /// [`BalanceError::NotSynced`] for the wallet's own address (the DB would answer once + /// synced), [`BalanceError::NoChainSource`] for an arbitrary address (only a chain + /// source could). /// - /// `peak_height` is sourced from the node's real chain view (the DB sync-state peak), - /// never fabricated. + /// **Every reported state field describes the tier that answered** (#2233). Reading the + /// DB's `synced` / `peak_height` on a coinset-served answer would describe the local + /// replica rather than the figure returned — so once a sync loop flips that flag, a + /// third-party oracle read would report itself as a synced local read. Those two fields + /// are therefore produced INSIDE the tier arms, never before the decision. pub async fn balance_for_address( &self, address: &str, @@ -503,19 +519,36 @@ impl WalletBackend { let read_err = |e: Error| BalanceError::ReadFailed(e.to_string()); let db_synced = self.db.is_synced().await.map_err(|e| read_err(e.into()))?; - let peak_height = self - .db - .sync_state() - .await - .map_err(|e| read_err(e.into()))? - .peak_height; let scoped = self .db .derivation_exists(&puzzle_hash) .await .map_err(|e| read_err(e.into()))?; - match routing::route(db_synced, scoped) { + let source = routing::route(db_synced, scoped); + // Disclose the tier in the node log as well as on the wire, so an operator reading + // `dig-node.jsonl` can tell a local-replica answer from a third-party oracle call. + // + // Split by level deliberately. `dig-logging`'s baked-in default is `info` + // (dig-logging `filter.rs:21`), and a stock install sets none of the overrides -- so a + // `debug!` here is INVISIBLE on every default node, which would make SPEC §18.7b's + // "dig-node.jsonl records the same tier the wire reports" false in the field and would + // let an acceptance run reading the log mistake silence for "no fallback occurred" + // (dig-node#189 review). + // + // FALLBACK is `info`: it is the exceptional path, it means this read was disclosed to a + // third-party oracle, and it is the evidence #2232's acceptance depends on. DB is `debug`: + // once sync works it is the ordinary path, and logging every local read at `info` would + // amplify an OPEN unauthenticated loopback endpoint into a log-volume lever. + match source { + Source::Db => tracing::debug!(tier = source.as_wire(), "wallet balance read routed"), + Source::Fallback => tracing::info!( + tier = source.as_wire(), + "wallet balance read routed to the third-party chain oracle" + ), + } + + match source { Source::Db => { let scope = [puzzle_hash]; let balance = self @@ -528,9 +561,18 @@ impl WalletBackend { .pending_scoped(asset_id.as_deref(), &scope) .await .map_err(|e| read_err(e.into()))?; + // The peak is read HERE, inside the arm, because it describes the replica + // this answer came from — it is not context for an answer taken elsewhere. + let peak_height = self + .db + .sync_state() + .await + .map_err(|e| read_err(e.into()))? + .peak_height; Ok(WalletBalanceResult { balance, pending, + source, synced: true, peak_height, }) @@ -574,8 +616,12 @@ impl WalletBackend { Ok(WalletBalanceResult { balance, pending, - synced: db_synced, - peak_height, + source, + // The DB neither produced this figure nor bounds its freshness, so its + // flag and its peak say nothing about it. `false` / `null` is the truth + // about a coinset-served answer, whatever the local replica's state. + synced: false, + peak_height: None, }) } } @@ -3295,31 +3341,100 @@ mod tests { assert_eq!(xch_bal.balance, 100, "XCH at the address, not the CAT"); } - /// An arbitrary (non-wallet) address routes to the LIVE fallback, and `synced` reflects the - /// DB's real `is_synced()` — asserted BOTH ways (false while syncing, true once caught up) - /// so the field cannot be a constant. + /// An arbitrary (non-wallet) address routes to the LIVE fallback and returns its figures. #[tokio::test] - async fn arbitrary_address_uses_fallback_and_synced_tracks_db_state() { - let arbitrary = encode_address(&"22".repeat(32), "xch").unwrap(); + async fn arbitrary_address_uses_fallback_tier() { let arb_ph = "22".repeat(32); - let coins = vec![ + let arbitrary = encode_address(&arb_ph, "xch").unwrap(); + let fb = Arc::new(MockFallback::with_coins(vec![ fallback_coin("c1", &arb_ph, 42, Some(10), None), fallback_coin("pend", &arb_ph, 5, None, None), - ]; + ])); + let be = WalletBackend::new( + WalletDb::open_in_memory().await.unwrap(), + fb, + WalletConfig::default(), + ); - for synced in [false, true] { - let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(synced).await.unwrap(); - let fb = Arc::new(MockFallback::with_coins(coins.clone())); - let be = WalletBackend::new(db, fb, WalletConfig::default()); - let r = be - .balance_for_address(&arbitrary, BalanceAsset::Xch) - .await - .unwrap(); - assert_eq!(r.balance, 42, "confirmed fallback coin"); - assert_eq!(r.pending, 5, "unconfirmed fallback coin"); - assert_eq!(r.synced, synced, "synced mirrors is_synced()"); - } + let r = be + .balance_for_address(&arbitrary, BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(r.balance, 42, "confirmed fallback coin"); + assert_eq!(r.pending, 5, "unconfirmed fallback coin"); + assert_eq!(r.source, Source::Fallback); + } + + /// **The instrument (#2233).** A coinset-served answer reports the FALLBACK tier and + /// reports NOTHING about the local replica — even when that replica is fully caught up. + /// + /// The fixture is chosen to distinguish the fix from the nearest wrong implementation: + /// the DB here is `set_initial_sync_complete(true)` with peak `9_000_000`, while the + /// queried address is unscoped, so routing still picks `Fallback`. The pre-fix code read + /// `synced` / `peak_height` OUTSIDE the tier decision and would answer + /// `synced: true, peak_height: Some(9_000_000)` on this input — a third-party oracle read + /// presented as a synced local one. An unsynced-DB fixture (the shipped state today, and + /// the one every prior test used) cannot see that difference at all: it is honest by + /// coincidence, which is exactly how this defect survived. + #[tokio::test] + async fn a_fallback_served_read_never_reports_the_dbs_sync_state() { + let arb_ph = "22".repeat(32); + let arbitrary = encode_address(&arb_ph, "xch").unwrap(); + let db = WalletDb::open_in_memory().await.unwrap(); + db.set_initial_sync_complete(true).await.unwrap(); + db.set_peak(9_000_000, &"cc".repeat(32)).await.unwrap(); + let fb = Arc::new(MockFallback::with_coins(vec![fallback_coin( + "c1", + &arb_ph, + 42, + Some(10), + None, + )])); + let be = WalletBackend::new(db, fb, WalletConfig::default()); + + let r = be + .balance_for_address(&arbitrary, BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(r.balance, 42, "the coinset figure, so the tier really ran"); + assert_eq!(r.source, Source::Fallback, "the tier that answered"); + assert!( + !r.synced, + "a coinset answer is never a synced local read, however synced the DB is" + ); + assert_eq!( + r.peak_height, None, + "the DB's peak does not bound a coinset answer's freshness" + ); + } + + /// The DB tier reports itself as the DB tier, with the replica's real peak. + /// + /// **Reachability caveat (#2234):** this asserts the arm, not an end-to-end path. The + /// `scoped_to_wallet` axis is `db.derivation_exists`, and `upsert_derivation` has no + /// production caller — so on a shipped node the `derivations` table is empty, `scoped` + /// is always `false`, and `balance_for_address` NEVER reaches this arm. The fixture + /// writes the derivation directly. This is fixture-only coverage until #2234 replaces + /// the routing axis with a production-written subscription watermark. + #[tokio::test] + async fn a_db_served_read_reports_the_db_tier_and_the_replicas_peak() { + let db = db_with_owned_derivation(true, Some(500)).await; + db.upsert_coins(&[coin_at_ph("confirmed", &owned_ph(), 100, Some(10), None)]) + .await + .unwrap(); + let be = WalletBackend::new( + db, + Arc::new(MockFallback::default()), + WalletConfig::default(), + ); + + let r = be + .balance_for_address(&owned_address(), BalanceAsset::Xch) + .await + .unwrap(); + assert_eq!(r.source, Source::Db, "the tier that answered"); + assert!(r.synced); + assert_eq!(r.peak_height, Some(500), "the replica's own peak"); } /// A synced, wallet-owned, EMPTY address is a SUCCESS with a zero figure — never an error. diff --git a/crates/dig-wallet/src/sage/service.rs b/crates/dig-wallet/src/sage/service.rs index eb74613..d89085a 100644 --- a/crates/dig-wallet/src/sage/service.rs +++ b/crates/dig-wallet/src/sage/service.rs @@ -11,8 +11,12 @@ //! SQLite wallet DB under the node config dir, and defaults the fallback tier to the graceful //! [`EmptyFallback`] so bring-up never waits on network/TLS peer discovery. The live direct-peer //! sync loop (which would swap in the [`CoinsetFallback`](super::fallback::CoinsetFallback) and -//! feed the DB) remains the documented remaining integration (SPEC §18.12); the [`EventBus`] is -//! wired here so that loop — and the WS sync-status push (#369) — publish to one shared bus. +//! feed the DB) remains the documented remaining integration: it is **SPEC §18.6**, explicitly +//! deferred by **§18.12a**. It is NOT §18.12 — §18.12 is the live spend *broadcaster*, which has +//! shipped. (This comment cited §18.12 and sent three separate readers to the wrong clause, +//! making the sync loop look like a wiring job against already-written machinery; #2232.) +//! The [`EventBus`] is wired here so that loop — and the WS sync-status push (#369) — publish to +//! one shared bus. use std::path::{Path, PathBuf}; use std::sync::Arc;