diff --git a/Cargo.lock b/Cargo.lock index 39b309e6..5d756940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.209.0" +version = "0.213.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index b65e9f89..ebf1006c 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.209.0" +version = "0.213.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/crates/dig-wallet/src/sage/db.rs b/crates/dig-wallet/src/sage/db.rs index bcb75554..add9c467 100644 --- a/crates/dig-wallet/src/sage/db.rs +++ b/crates/dig-wallet/src/sage/db.rs @@ -1395,7 +1395,31 @@ impl WalletDb { /// only the terminal answer of a full address-history catch-up produces. A caller that has not /// replayed history has nothing to build one out of, which is what makes the unsafe arming hard /// to write rather than merely discouraged. - pub async fn set_initial_sync_complete(&self, complete: bool) -> sqlx::Result<()> { + /// + /// # The `true` direction is UNREPRESENTABLE here, by type (dig-node#462) + /// + /// This takes no argument, so it can only DISARM. That is the whole fix: dig-node#454 made + /// every real ARMING writer take a `reset_epoch` guard, because an unguarded arm is that PR's + /// money-lie defect — a catch-up completing after a cache reset set `initial_sync_complete = 1` + /// over an EMPTIED table, giving `balance 0, synced true` on a funded wallet. A `bool` setter + /// beside those guards is a third path to that state which bypasses them, and the next author + /// who needs to set the flag will find a function whose name says it does exactly that. + /// + /// Removing the parameter discharges it STRUCTURALLY rather than by convention: there is no + /// `true` to pass. Arming remains reachable only through [`Self::complete_catch_up`] and + /// [`Self::record_coverage`], which is what the compiler now enforces. + /// + /// **Disarming stays public and unguarded, deliberately.** `sync.rs`'s reorg handler calls it + /// when the replica moves backwards, so wallet reads fall back until a fresh catch-up. That is + /// the conservative direction — it can only make the node claim LESS than it knows — and it is + /// the direction a guard would be protecting nothing by blocking. + pub async fn clear_initial_sync_complete(&self) -> sqlx::Result<()> { + self.write_initial_sync_complete(false).await + } + + /// The raw write, private so that the only ways to reach the `true` direction are the two + /// guarded arming paths and the test-only forcing hatch below. + async fn write_initial_sync_complete(&self, complete: bool) -> sqlx::Result<()> { sqlx::query("UPDATE sync_state SET initial_sync_complete = ? WHERE id = 0") .bind(i64::from(complete)) .execute(&self.pool) @@ -1403,6 +1427,15 @@ impl WalletDb { Ok(()) } + /// Force the flag from a test, in either direction. + /// + /// A test-only escape hatch is fine; an AMBIGUOUS one is what dig-node#462 is about, so the + /// name carries its own danger and `#[cfg(test)]` keeps it out of every production build. + #[cfg(test)] + pub async fn force_initial_sync_complete_for_test(&self, complete: bool) -> sqlx::Result<()> { + self.write_initial_sync_complete(complete).await + } + /// Record the puzzle-hash set a NON-catch-up sync path covered — the oracle-tier refresh /// ([`crate::sage::rpc::WalletBackend::refresh_tracked_coins`]), which fetches coins for a set /// of addresses by point read and latches `initial_sync_complete` without replaying history. @@ -4580,6 +4613,45 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { mod tests { use super::*; + /// **Proves (dig-node#462):** the only public write of `initial_sync_complete` that production + /// can reach DISARMS, and it really does clear a flag that was armed. + /// + /// # What enforces the other half, and why it is not asserted here + /// + /// The ticket asks that production be unable to ARM except through a `reset_epoch`-guarded + /// path, "enforced by the compiler rather than by convention", and explicitly rules out a test + /// that greps for callers. That half is discharged STRUCTURALLY and cannot be written as a + /// runtime assertion: [`WalletDb::clear_initial_sync_complete`] takes no argument, so there is + /// no `true` to pass, and the raw `write_initial_sync_complete` is private. The type cannot + /// express the unguarded arm — the same shape as a scalar that cannot represent a set of two. + /// + /// So this test pins the direction that IS representable, and the compiler pins the one that is + /// not. A future change that re-widens this to `fn set_initial_sync_complete(&self, bool)` is + /// caught by review against this doc-comment, not by a green suite — which is stated here + /// rather than implied, because a reader who finds only this test could otherwise conclude the + /// arming direction was never considered. + /// + /// **Catches:** a `clear_` that no longer clears — e.g. one wired to the wrong column, or one + /// that writes `true` because the parameter was removed by editing the call rather than the + /// body. + #[tokio::test] + async fn clear_initial_sync_complete_disarms_an_armed_flag() { + let db = WalletDb::open_in_memory().await.unwrap(); + + db.force_initial_sync_complete_for_test(true).await.unwrap(); + assert!( + db.sync_state().await.unwrap().initial_sync_complete, + "the fixture must start ARMED, or the clear below would prove nothing" + ); + + db.clear_initial_sync_complete().await.unwrap(); + assert!( + !db.sync_state().await.unwrap().initial_sync_complete, + "the reorg path's disarm must actually reach the flag — sync.rs relies on it to make \ + wallet reads fall back after the replica moves backwards" + ); + } + fn coin(id: &str, amount: u64, created: Option, spent: Option) -> CoinRow { CoinRow { coin_id: id.into(), @@ -4882,7 +4954,7 @@ mod tests { let db = WalletDb::open_in_memory().await.unwrap(); // What `refresh_tracked_coins` does on a fresh install with nothing in the DB yet. - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); assert_eq!( db.arrival_baseline().await.unwrap(), None, @@ -5292,7 +5364,7 @@ mod tests { db.upsert_coin(&coin("c1", 12_345, Some(10), None)) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); assert!(db.is_synced().await.unwrap(), "pre-state: authoritative"); let report = db.reset_chain_cache(0).await.unwrap().expect("not refused"); @@ -5321,7 +5393,7 @@ mod tests { db.upsert_coin(&coin("c1", 12_345, Some(10), None)) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); db.reserve_client_coins(&["c1".to_string()], Some(60_000), 0) .await .expect("reserve"); diff --git a/crates/dig-wallet/src/sage/offers.rs b/crates/dig-wallet/src/sage/offers.rs index 5b5bdb61..deaa5912 100644 --- a/crates/dig-wallet/src/sage/offers.rs +++ b/crates/dig-wallet/src/sage/offers.rs @@ -409,24 +409,30 @@ fn cat_offer_asset(asset_id: Bytes32, amount: u64) -> OfferAsset { } /// Greedily select XCH coins (largest first) covering `need`. +/// +/// The ordering, the tiebreak and the refusal are +/// [`super::selection::select_largest_first`]'s — the same ones the wallet's row selector, the CAT +/// leg selector and the mirror lifecycle's operator-scoped selector already use (dig-node#428). +/// +/// It was the LAST of the four to still carry its own loop, and the divergence had become +/// asymmetric rather than merely duplicated: this one accumulated with a bare `+=`, where the other +/// three saturate. On a coin set summing past `u64::MAX` that panics in a debug build and wraps in a +/// release one, and a wrapped total is read back as a shortfall — so the shipped failure was a +/// spurious "insufficient XCH" on a set that could in fact pay, rather than the false success the +/// ticket anticipated. Safe direction, wrong answer, and a debug-build panic on a money path. +/// +/// The tiebreak was CONFIRMED identical before deleting, not assumed: this sorted +/// `b.amount.cmp(&a.amount).then(a.coin_id().cmp(&b.coin_id()))`, which is exactly the canonical +/// key `(amount, coin_id)` — descending amount, ascending coin id. The refusal message is kept +/// verbatim, so an operator's log line does not change spelling under a refactor. fn select_xch(coins: &[Coin], need: u64) -> Result> { - let mut sorted: Vec = coins.to_vec(); - sorted.sort_by(|a, b| b.amount.cmp(&a.amount).then(a.coin_id().cmp(&b.coin_id()))); - let mut sum = 0u64; - let mut out = Vec::new(); - for c in sorted { - if sum >= need { - break; - } - sum += c.amount; - out.push(c); - } - if sum < need { - return Err(Error::api(format!( - "insufficient XCH to offer: need {need} have {sum}" - ))); - } - Ok(out) + super::selection::select_largest_first(coins.to_vec(), need, |c| (c.amount, c.coin_id())) + .map_err(|s| { + Error::api(format!( + "insufficient XCH to offer: need {} have {}", + s.need, s.have + )) + }) } /// Greedily select CAT coins of `asset_id` (largest first) covering `need`. @@ -453,6 +459,72 @@ fn select_cats(cats: &[Cat], asset_id: Bytes32, need: u64) -> Result> { #[cfg(test)] mod tests { use super::*; + + /// **Proves (dig-node#428):** `select_xch` accumulates its running total the way every other + /// selector in this crate does, so a coin set whose amounts sum past `u64::MAX` is handled + /// rather than overflowed. + /// + /// # The fixture, and the assertion it took two attempts to get right + /// + /// Two coins of `2^63` with a target just above one of them is the ONLY shape that reaches the + /// overflow at all: + /// + /// - the walk stops the moment `total >= target` and the set is sorted DESCENDING, so the + /// largest coin is added to a zero total and cannot overflow on its own; + /// - to overflow on the second coin, `a1 + a2` must exceed `u64::MAX` while `a1 < target`, + /// which forces BOTH `a1 > u64::MAX / 2` and `target > u64::MAX / 2`. + /// + /// So the intuitive "one huge coin plus small change" fixture proves nothing — the huge coin is + /// taken first, covers the target, and the loop breaks before a second addition happens. + /// + /// **The expected outcome is SUCCESS, not refusal.** Two coins of `2^63` genuinely do cover + /// `2^63 + 5`; a refusal would be the wrong answer. This test first asserted a shortfall and + /// failed against the correct implementation, which is the useful half of writing it: any set + /// that overflows sums to more than `u64::MAX`, hence more than any `u64` target, so + /// **saturation cannot manufacture a false success here** — the saturated total is only ever + /// reached when the real total is larger still. + /// + /// **Catches:** the bare `sum += c.amount` this replaces. In a debug build — which is how tests + /// run — that PANICS with `attempt to add with overflow` at the accumulation, so this test + /// fails loudly rather than by assertion. In a release build it wraps to a small total and the + /// caller reads back a spurious "insufficient XCH" on a set that could in fact pay. + #[test] + fn select_xch_covers_a_target_from_coins_that_sum_past_u64_max() { + let half = u64::MAX / 2 + 1; // 2^63 + let coins = vec![ + Coin::new(Bytes32::default(), Bytes32::default(), half), + Coin::new(Bytes32::new([1u8; 32]), Bytes32::default(), half), + ]; + // Above the first coin, so the walk must add the second and cross u64::MAX. + let target = half + 5; + + let selected = select_xch(&coins, target) + .expect("two coins of 2^63 cover 2^63 + 5; only the arithmetic could fail here"); + assert_eq!( + selected.len(), + 2, + "both coins are needed — one 2^63 alone does not reach 2^63 + 5" + ); + } + + /// **The control.** The saturating total must not turn a genuinely-covered target into a + /// refusal — a selector that always refused would pass the test above. + #[test] + fn select_xch_still_covers_an_ordinary_target_largest_first() { + let coins = vec![ + Coin::new(Bytes32::new([1u8; 32]), Bytes32::default(), 30), + Coin::new(Bytes32::new([2u8; 32]), Bytes32::default(), 100), + Coin::new(Bytes32::new([3u8; 32]), Bytes32::default(), 70), + ]; + let selected = select_xch(&coins, 90).expect("100 alone covers 90"); + assert_eq!( + selected.len(), + 1, + "largest-first must take the single 100 rather than 70 + 30" + ); + assert_eq!(selected[0].amount, 100); + } + use chia_sdk_test::Simulator; use chia_wallet_sdk::types::TESTNET11_CONSTANTS; diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index b75ba726..579e0f0b 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -5251,7 +5251,9 @@ mod tests { async fn backend_with(coins: Vec, synced: bool) -> WalletBackend { let db = WalletDb::open_in_memory().await.unwrap(); db.upsert_coins(&coins).await.unwrap(); - db.set_initial_sync_complete(synced).await.unwrap(); + db.force_initial_sync_complete_for_test(synced) + .await + .unwrap(); let fb = Arc::new(MockFallback::default()); // Scope reads (#407) to the test coins' puzzle hash so identity-scoped reads see // them — mirrors a client `login` declaring its public puzzle hash. @@ -5341,7 +5343,9 @@ mod tests { .await .unwrap(); } - db.set_initial_sync_complete(synced).await.unwrap(); + db.force_initial_sync_complete_for_test(synced) + .await + .unwrap(); if let Some(h) = peak { db.set_peak(h, &"cc".repeat(32)).await.unwrap(); } @@ -5892,7 +5896,7 @@ mod tests { /// 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 + /// the DB here is `force_initial_sync_complete_for_test(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 @@ -5904,7 +5908,7 @@ mod tests { 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.force_initial_sync_complete_for_test(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", @@ -6343,7 +6347,7 @@ mod tests { let (registry, _dir) = registry_with_key(&enrolled_key()); let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); db.set_peak(500, &"cc".repeat(32)).await.unwrap(); let be = WalletBackend::new( @@ -6412,7 +6416,7 @@ mod tests { // No chain source: arbitrary address, DB synced, EmptyFallback (not live). let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new(db, Arc::new(EmptyFallback), WalletConfig::default()); let arbitrary = encode_address(&"33".repeat(32), "xch").unwrap(); assert_eq!( @@ -6431,7 +6435,7 @@ mod tests { // Read failed: arbitrary address routes to a LIVE fallback that errors. let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new(db, Arc::new(ErringFallback), WalletConfig::default()); assert!(matches!( be.balance_for_address(&arbitrary, BalanceAsset::Xch).await, @@ -7243,7 +7247,7 @@ mod tests { #[tokio::test] async fn an_arbitrary_address_reads_its_coins_from_the_chain_tier() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let fb = Arc::new(MockFallback::with_coins(vec![ fallback_coin("live", &"33".repeat(32), 1_750, Some(9), None), fallback_coin("already-spent", &"33".repeat(32), 5, Some(9), Some(11)), @@ -7279,7 +7283,7 @@ mod tests { async fn a_chain_it_could_not_reach_is_an_error_never_an_empty_coin_list() { // No chain source: an arbitrary address, synced replica, a tier that is not live. let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new(db, Arc::new(EmptyFallback), WalletConfig::default()); let arbitrary = encode_address(&"33".repeat(32), "xch").unwrap(); assert_eq!( @@ -7299,7 +7303,7 @@ mod tests { // A live tier that errors: the answer is unknown, not empty. let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new(db, Arc::new(ErringFallback), WalletConfig::default()); assert!(matches!( be.coins_for_address(&arbitrary, BalanceAsset::Xch, None, TEST_PAGE) @@ -7556,7 +7560,7 @@ mod tests { /// and only the money reads, still paired `synced: true` with `peak_height: null`. /// /// The state is production-reachable rather than hypothetical: `refresh_tracked_coins` latches - /// the replica authoritative (`record_coverage` + `set_initial_sync_complete(true)`) WITHOUT + /// the replica authoritative (`record_coverage` + `force_initial_sync_complete_for_test(true)`) WITHOUT /// ever writing a peak, which is exactly what `db_with_owned_derivation(true, None)` builds. /// /// FIXTURE DESIGN. The peer tier is deliberately HONEST and OBSERVABLE — level with the @@ -7937,7 +7941,7 @@ mod tests { // A single token in the bucket, no refill: the first outbound read spends it. let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default()), @@ -7959,7 +7963,7 @@ mod tests { // The peak read reaches the tier only when the replica has no height of its own. let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default()), @@ -8152,7 +8156,7 @@ mod tests { TESTNET11_CONSTANTS.agg_sig_me_additional_data, )); let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let cfg = WalletConfig { network_id: "testnet11".into(), address_prefix: "txch".into(), @@ -8364,7 +8368,9 @@ mod tests { // loaded signers is empty. let restarted = WalletCustody::open(dir.clone()); let db2 = WalletDb::open_in_memory().await.unwrap(); - db2.set_initial_sync_complete(true).await.unwrap(); + db2.force_initial_sync_complete_for_test(true) + .await + .unwrap(); let after = WalletBackend::new(db2, Arc::new(MockFallback::default()), cfg) .with_custody(restarted) .with_pusher(pusher.clone()); @@ -8529,7 +8535,7 @@ mod tests { 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(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default()), @@ -8557,7 +8563,7 @@ mod tests { 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(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let fb = Arc::new(MockFallback::with_coins(vec![fallback_coin( "c1", &arb_ph, @@ -8900,7 +8906,7 @@ mod tests { db.record_coverage(&CoveredSet::from_hex([test_ph()])) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); // Reads scope to the wallet's identity (#407); the test coins sit at `test_ph()`. let cfg = WalletConfig { puzzle_hashes: vec![test_ph()], @@ -8935,7 +8941,9 @@ mod tests { spent_timestamp: None, }])); let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(false).await.unwrap(); // still syncing + db.force_initial_sync_complete_for_test(false) + .await + .unwrap(); // still syncing let cfg = WalletConfig { puzzle_hashes: vec![ph], ..Default::default() @@ -8969,7 +8977,7 @@ mod tests { db.upsert_coins(&[xch_coin("inwallet", 1, Some(1), None)]) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new(db, fb.clone(), WalletConfig::default()); let (status, body) = be @@ -9057,7 +9065,7 @@ mod tests { }) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let bc = Arc::new(MockBroadcaster::default()); let cfg = WalletConfig { puzzle_hashes: vec![hex::encode(ph)], @@ -9148,7 +9156,10 @@ mod tests { ); // The control: the ONLY thing that changes is the authority flag. - be.db.set_initial_sync_complete(true).await.unwrap(); + be.db + .force_initial_sync_complete_for_test(true) + .await + .unwrap(); let coins = be .spendable_coins(None) .await @@ -9240,7 +9251,10 @@ mod tests { // The control: the ONLY thing that changes is the authority flag. Both calls now get // PAST the tier gate and fail on the missing lineage source underneath, which is a // different failure — so the refusals above were the gate, not the empty backend. - be.db.set_initial_sync_complete(true).await.unwrap(); + be.db + .force_initial_sync_complete_for_test(true) + .await + .unwrap(); for e in [ be.select_cats(&asset, 1_000, 0).await.unwrap_err(), be.singleton_parent_child("c0").await.unwrap_err(), @@ -9649,7 +9663,7 @@ mod tests { #[tokio::test] async fn get_nfts_and_get_dids_return_reconstructed_rows() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let nft = NftRecord { launcher_id: "aa".repeat(32), collection_id: None, @@ -9740,7 +9754,7 @@ mod tests { .await .unwrap(); } - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let cfg = WalletConfig { puzzle_hashes: vec![hex::encode(ph)], address_prefix: "txch".into(), @@ -9883,7 +9897,7 @@ mod tests { let shared = std::sync::Arc::new(super::super::events::EventBus::with_capacity(4)); let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be2 = WalletBackend::new( db, Arc::new(MockFallback::default()), @@ -9942,7 +9956,9 @@ mod tests { // Even with the DB marked caught up, a wallet the node is NOT tracking (no login, // no config) still reads NOT synced — never a silent synced-0. let db2 = WalletDb::open_in_memory().await.unwrap(); - db2.set_initial_sync_complete(true).await.unwrap(); + db2.force_initial_sync_complete_for_test(true) + .await + .unwrap(); let be2 = WalletBackend::new( db2, Arc::new(MockFallback::default()), @@ -9980,7 +9996,7 @@ mod tests { ])) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); // Identity comes ONLY from the client's login — the node has no own wallet config. let be = WalletBackend::new( db, @@ -10033,7 +10049,7 @@ mod tests { let addr = encode_address(&ph, "xch").unwrap(); let db = WalletDb::open_in_memory().await.unwrap(); db.upsert_coins(&[coin_at("c1", &ph, 4_200)]).await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default()), @@ -10123,7 +10139,7 @@ mod tests { ); row.parent_coin_info = hex::encode(child_cat.coin.parent_coin_info); db.upsert_coin(&row).await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); // Attribute CATs by uncurrying the parent spend (the sync attribution step). let parent = ParentSpend { @@ -10163,7 +10179,7 @@ mod tests { db.record_coverage(&CoveredSet::from_hex([owner_ph.as_str()])) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default()), @@ -10423,7 +10439,9 @@ mod tests { #[tokio::test] async fn sync_status_reports_tristate_from_db() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(false).await.unwrap(); + db.force_initial_sync_complete_for_test(false) + .await + .unwrap(); let be = WalletBackend::new( db.clone(), Arc::new(MockFallback::default()), @@ -10433,7 +10451,7 @@ mod tests { assert_eq!(s.state, SyncLifecycle::Syncing); db.set_peak(123, "aa").await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let s = be.sync_status().await.unwrap(); assert_eq!(s.state, SyncLifecycle::Synced); assert_eq!(s.peak_height, Some(123)); @@ -10469,7 +10487,7 @@ mod tests { db.record_coverage(&CoveredSet::from_hex([covered_ph.as_str()])) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); db.set_peak(REPLICA_PEAK, &"cc".repeat(32)).await.unwrap(); assert!( db.is_synced().await.unwrap(), @@ -10551,7 +10569,7 @@ mod tests { db.record_coverage(&CoveredSet::from_hex([client_ph.as_str()])) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default()), @@ -10663,7 +10681,7 @@ mod tests { db.record_coverage(&CoveredSet::from_hex(["bb".repeat(32).as_str()])) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default().offline()), @@ -10704,7 +10722,7 @@ mod tests { db.record_coverage(&CoveredSet::from_hex([client_ph.as_str()])) .await .unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let be = WalletBackend::new( db, Arc::new(MockFallback::default()), diff --git a/crates/dig-wallet/src/sage/sync.rs b/crates/dig-wallet/src/sage/sync.rs index aed3639c..6dd16f13 100644 --- a/crates/dig-wallet/src/sage/sync.rs +++ b/crates/dig-wallet/src/sage/sync.rs @@ -949,7 +949,7 @@ pub async fn handle_coin_state_update( "wallet sync: replica moved backwards; clearing initial-sync-complete so wallet \ reads fall back until a fresh catch-up" ); - db.set_initial_sync_complete(false).await?; + db.clear_initial_sync_complete().await?; } apply_coin_states(db, &update.items, session.subscribed, session.derived).await?; db.record_peak(admitted, &hex::encode(update.peak_hash)) @@ -1978,7 +1978,7 @@ mod tests { .await .unwrap(); db.set_peak(6_000_000, "aa").await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); db } @@ -2287,7 +2287,7 @@ mod tests { /// the DB is left un-synced. /// /// The peer double here would happily report `is_finished` on the first response, so - /// without the guard the function reaches `set_initial_sync_complete(true)` over a DB + /// without the guard the function reaches `force_initial_sync_complete_for_test(true)` over a DB /// that was never queried for a single coin — and `routing::route(true, true)` then /// answers every wallet-scoped read from it. This is the floor of that invariant. #[tokio::test] @@ -3501,7 +3501,7 @@ mod tests { let db = WalletDb::open_in_memory().await.unwrap(); let anchor = 1000u32; db.set_peak(anchor, "aa").await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let events = EventBus::default(); let subscribed = subscribed_owned(); let mut session = corroborated(&subscribed, anchor); @@ -3693,7 +3693,7 @@ mod tests { .await .unwrap(); db.set_peak(anchor, "aa").await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let subscribed = subscribed_owned(); push( diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index 364892e8..efb6f510 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -1115,7 +1115,7 @@ async fn a_wallet_created_after_boot_is_subscribed_without_waiting_for_a_disconn #[tokio::test] async fn phase_is_syncing_when_caught_up_but_no_peer() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); db.set_peak(6_000_000, "aa").await.unwrap(); let (handle, _rx) = SyncHandle::new(); @@ -1172,7 +1172,7 @@ async fn phase_ladder_not_started_syncing_synced() { SyncPhase::Syncing ); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); assert_eq!( handle .status(&db, ChainPeerTier::UNOBSERVABLE) @@ -1388,7 +1388,7 @@ async fn a_previously_synced_wallet_restarted_locked_is_not_reported_as_synced() let db = WalletDb::open_in_memory().await.unwrap(); db.set_peak(9_131_403, "aa").await.unwrap(); // The catch-up genuinely completed in an earlier run, and the flag persists across restarts. - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let (handle, _rx) = SyncHandle::new(); handle.set_connected(1); @@ -1419,7 +1419,7 @@ async fn a_previously_synced_wallet_restarted_locked_is_not_reported_as_synced() #[tokio::test] async fn a_completed_catch_up_still_reports_synced_while_watching_addresses() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let (handle, _rx) = SyncHandle::new(); handle.set_connected(1); @@ -3189,7 +3189,7 @@ fn tier_at(peak: u32) -> ChainPeerTier { #[tokio::test] async fn a_replica_behind_its_peers_is_not_reported_as_synced() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); db.set_peak(FROZEN_REPLICA_PEAK, "aa").await.unwrap(); let (handle, _rx) = SyncHandle::new(); @@ -3216,7 +3216,7 @@ async fn a_replica_behind_its_peers_is_not_reported_as_synced() { #[tokio::test] async fn the_following_tolerance_holds_at_the_bound_and_fails_one_beyond_it() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); db.set_peak(FROZEN_REPLICA_PEAK, "aa").await.unwrap(); let (handle, _rx) = SyncHandle::new(); @@ -3250,7 +3250,7 @@ async fn the_following_tolerance_holds_at_the_bound_and_fails_one_beyond_it() { #[tokio::test] async fn an_unmeasured_height_leaves_the_phase_unchanged() { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let (handle, _rx) = SyncHandle::new(); handle.set_connected(1); @@ -4295,7 +4295,7 @@ async fn one_host_is_one_voice_however_many_ports_it_answers_on() { async fn a_refused_writer_is_not_reported_as_synced() { let db = WalletDb::open_in_memory().await.unwrap(); // The catch-up genuinely completed in an earlier run; the flag is persistent. - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); let (handle, _rx) = SyncHandle::new(); handle.set_connected(1); diff --git a/crates/dig-wallet/src/sage/transport.rs b/crates/dig-wallet/src/sage/transport.rs index c0653df9..878115de 100644 --- a/crates/dig-wallet/src/sage/transport.rs +++ b/crates/dig-wallet/src/sage/transport.rs @@ -479,7 +479,7 @@ mod tests { async fn test_backend() -> Arc { let db = WalletDb::open_in_memory().await.unwrap(); - db.set_initial_sync_complete(true).await.unwrap(); + db.force_initial_sync_complete_for_test(true).await.unwrap(); Arc::new(WalletBackend::new( db, Arc::new(MockFallback::default()),