From 4adfd5021b31b61976e039759c5ab706c824c6f7 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 22:35:10 -0700 Subject: [PATCH 01/21] =?UTF-8?q?chore(release):=20v0.208.0=20=E2=80=94=20?= =?UTF-8?q?temp-dir=20leak=20lane=20(#397,=20#370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0fa369c..e54dfab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.206.0" +version = "0.208.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 5dce89d2..9879ff8d 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.206.0" +version = "0.208.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From 0958d5eb03cc7fda57dd3b1e0011a32f1ef49cf2 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 22:47:02 -0700 Subject: [PATCH 02/21] fix(test): own dig-wallet and profile-sync scratch dirs with tempfile::TempDir --- .../src/capsule_warm_locator_tests.rs | 18 +-- .../src/seams/dig_peer/module_reshare.rs | 18 +-- .../src/seams/dig_peer/module_serve.rs | 32 ++--- .../src/seams/dig_peer/profile_sync.rs | 126 +++++++----------- crates/dig-wallet/src/sage/rpc.rs | 26 ++-- crates/dig-wallet/src/sage/service.rs | 13 +- crates/dig-wallet/src/sage/sources.rs | 12 +- .../src/sage/sync_supervisor/tests.rs | 13 +- crates/dig-wallet/src/sage/tipping.rs | 14 +- crates/dig-wallet/src/sage/watchlist.rs | 12 +- crates/dig-wallet/src/seed_export.rs | 12 +- 11 files changed, 127 insertions(+), 169 deletions(-) diff --git a/crates/dig-node-core/src/capsule_warm_locator_tests.rs b/crates/dig-node-core/src/capsule_warm_locator_tests.rs index 34ba77b2..f59ca5b7 100644 --- a/crates/dig-node-core/src/capsule_warm_locator_tests.rs +++ b/crates/dig-node-core/src/capsule_warm_locator_tests.rs @@ -37,17 +37,13 @@ fn hex32(bytes: [u8; 32]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } -fn temp_dir(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "dig-node-warmloc-{tag}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - std::fs::create_dir_all(&dir).expect("tempdir"); - dir +/// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, +/// including on an unwind, so a failing assertion cannot leak it (dig-node#370). +fn temp_dir(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("dig-node-warmloc-{tag}-")) + .tempdir() + .expect("tempdir") } /// Confirms the fixture generation, so the warm passes its chain gate and reaches the locate step diff --git a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs index 2bfdd3a8..d054f1b6 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs @@ -1035,17 +1035,13 @@ mod tests { ]) } - fn temp_dir(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!( - "dig-node-warm-{tag}-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&d).unwrap(); - d + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn temp_dir(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("dig-node-warm-{tag}-")) + .tempdir() + .expect("a temp dir") } /// Counts announces, so "no announce happened" is an assertable property. diff --git a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs index 634faf82..74bc15b0 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs @@ -381,16 +381,14 @@ mod tests { } /// Write `bytes` as the cached module for `(store, root)` under a fresh temp cache dir. - fn cache_with(bytes: &[u8], store: &str, root: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "dig-node-modserve-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let path = module_path(&dir, store, root); + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn cache_with(bytes: &[u8], store: &str, root: &str) -> tempfile::TempDir { + let dir = tempfile::Builder::new() + .prefix("dig-node-modserve-") + .tempdir() + .expect("a cache dir"); + let path = module_path(dir.path(), store, root); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, bytes).unwrap(); dir @@ -570,15 +568,11 @@ mod tests { /// it), however many extra entries other tests happen to interleave in. #[test] fn the_descriptor_memo_is_capped_and_evicts_stale_entries() { - let dir = std::env::temp_dir().join(format!( - "dig-node-modserve-memocap-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); + // Owned by the guard, so the tree goes away on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-modserve-memocap-") + .tempdir() + .expect("a cache dir"); // DESCRIPTOR_MEMO_CAP + 1 distinct (store, root) keys, none shared with any other test in // this file (those use small repeated-byte ids; these are sequential integers padded to diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index 54080cdd..c8275fb5 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -1227,38 +1227,29 @@ mod tests { } } - /// A fresh directory for one test. + /// A fresh directory for one test, OWNED by the returned guard. /// - /// # Why the clock is in the name + /// # Why a guard rather than a hand-built path /// - /// `(process id, unique_suffix())` is unique WITHIN a run and repeats ACROSS runs: the counter - /// restarts at zero every process, and the OS recycles process ids. These tests never remove - /// what they create, so a later run that draws a recycled pid inherits an earlier run's - /// directory — already populated — and a test asserting on the FULL contents of its own - /// temp tree fails on somebody else's leftovers. + /// Two properties are needed here, and a `PathBuf` gave neither. /// - /// That is not hypothetical: `held_pairs_skips_names_this_module_did_not_write` failed once in - /// a full-suite run against 223 leaked `dig-profile-sync-test--` directories, passed - /// alone, and passed on a re-run — the signature of a name collision rather than a defect in - /// the code under test. A monotonic component makes the name unrepeatable across runs, which - /// is what the tests actually need from it. + /// **Removal that a failing assertion cannot skip** (dig-node#370). Every test in this module + /// used to end with a manual `remove_dir_all`, which is exactly the line an unwinding test + /// never reaches — so the runs a developer repeats were the runs that leaked. `TempDir`'s + /// `Drop` runs on the unwind. /// - /// The LEAK itself is untouched here and is still real (dig-node#365 lane finding): the fix - /// for it is a drop guard per test, which is a larger change than this file's share of the - /// work. - fn tempdir() -> PathBuf { - let since_epoch = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("a clock after 1970") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "dig-profile-sync-test-{}-{}-{}", - std::process::id(), - since_epoch, - unique_suffix() - )); - std::fs::create_dir_all(&dir).expect("temp dir"); - dir + /// **A name that does not repeat across runs** (dig-node#369). `(process id, counter)` is + /// unique within a run and repeats between them: the counter restarts every process and the OS + /// recycles pids, so a later run could inherit an earlier run's populated tree. That was not + /// hypothetical — `held_pairs_skips_names_this_module_did_not_write`, which asserts on the full + /// contents of its own tree, failed once in a full-suite run against 223 leftover directories, + /// passed alone, and passed on re-run. `tempfile`'s random component supplies that property + /// directly, and removal makes the collision unreachable rather than merely unlikely. + fn tempdir() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("dig-profile-sync-test-") + .tempdir() + .expect("temp dir") } // -- Spies ------------------------------------------------------------------------------------- @@ -1410,20 +1401,18 @@ mod tests { fn stored_bytes_are_returned_byte_identical() { // The whole portability claim: what one machine writes, another reads unchanged. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); store.put(&store_id(1), &root, &bytes).unwrap(); assert_eq!(store.get(&store_id(1), &root).unwrap(), Some(bytes)); - let _ = std::fs::remove_dir_all(dir); } #[test] fn a_missing_body_is_none_not_an_error() { // "Consulted, holds nothing" and "the read failed" need opposite remedies from a caller. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); assert_eq!(store.get(&store_id(1), &[9u8; 32]).unwrap(), None); - let _ = std::fs::remove_dir_all(dir); } #[test] @@ -1431,7 +1420,7 @@ mod tests { // Pins the bound from BOTH sides: three writes leave TWO artifacts — not one (which a // delete-every-other implementation would leave) and not three (which no pruning would). let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let sid = store_id(7); let mut roots = Vec::new(); for name in ["gen1", "gen2", "gen3"] { @@ -1447,7 +1436,6 @@ mod tests { assert!(store.has(&sid, &roots[2]), "the newest must survive"); assert!(store.has(&sid, &roots[1]), "so must its predecessor"); assert!(!store.has(&sid, &roots[0]), "the oldest must be pruned"); - let _ = std::fs::remove_dir_all(dir); } #[test] @@ -1457,12 +1445,11 @@ mod tests { let cache = tempdir(); let store = ProfileBodyStore::under_cache_dir(&cache); let path = store.path(&store_id(1), &[2u8; 32]); - assert!(path.starts_with(cache.join(PROFILES_DIR))); + assert!(path.starts_with(cache.path().join(PROFILES_DIR))); assert!( - !path.starts_with(cache.join("modules")), + !path.starts_with(cache.path().join("modules")), "a profile under modules/ would become a phantom DHT provider record" ); - let _ = std::fs::remove_dir_all(cache); } #[test] @@ -1482,7 +1469,7 @@ mod tests { #[tokio::test] async fn a_solicited_body_matching_the_requested_root_is_accepted_and_re_announced() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); let (subs, sol, tx, pen) = gate_fixtures(sid); @@ -1517,7 +1504,6 @@ mod tests { excluding the peer that supplied it" ); drop(announces); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1529,7 +1515,7 @@ mod tests { // unsolicited is not evidence of lying. An implementation that penalized everything it // refuses fails here. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); let (subs, sol, tx, pen) = gate_fixtures(sid); @@ -1553,7 +1539,6 @@ mod tests { "a late or forged answer must never cost a peer" ); assert!(!store.has(&sid, &root)); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1562,7 +1547,7 @@ mod tests { // profile — so the failure is precisely "does not hash to the root you were asked for" and // not "unparseable bytes", which a weaker fixture would conflate. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (_, requested_root) = dpb("Ada"); let (wrong, _) = dpb("Mallory"); let sid = store_id(1); @@ -1584,7 +1569,6 @@ mod tests { assert_eq!(pen.count(), 1); assert!(!store.has(&sid, &requested_root)); assert!(tx.announces.lock().unwrap().is_empty()); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1594,7 +1578,7 @@ mod tests { // the first answer would read peer 8 as unsolicited and lose the honest body — and a // single-peer fixture could not tell the two apart. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let (wrong, _) = dpb("Mallory"); let sid = store_id(1); @@ -1626,13 +1610,12 @@ mod tests { assert_eq!(first, AcceptOutcome::RootMismatch); assert!(matches!(second, AcceptOutcome::Accepted { .. })); assert_eq!(pen.count(), 1, "only the peer that lied is demoted"); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn a_body_for_an_unsubscribed_store_is_refused() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); let (_, sol, tx, pen) = gate_fixtures(sid); @@ -1652,7 +1635,6 @@ mod tests { assert_eq!(outcome, AcceptOutcome::NotSubscribed); assert_eq!(pen.count(), 0); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1660,7 +1642,7 @@ mod tests { // The bound comes from the protocol own frame ceiling, not from feel: one byte past // `MAX_PROFILE_BODY_BYTES` is exactly the largest body a 225 frame could carry. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let sid = store_id(1); let root = [4u8; 32]; let (subs, sol, tx, pen) = gate_fixtures(sid); @@ -1680,13 +1662,12 @@ mod tests { assert_eq!(outcome, AcceptOutcome::TooLarge); assert_eq!(pen.count(), 0); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn re_receiving_a_held_body_is_idempotent_and_does_not_re_announce() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); let (subs, sol, tx, pen) = gate_fixtures(sid); @@ -1706,7 +1687,6 @@ mod tests { assert_eq!(outcome, AcceptOutcome::AlreadyHeld); assert!(tx.announces.lock().unwrap().is_empty()); - let _ = std::fs::remove_dir_all(dir); } // -- The local (control-plane) entry point ------------------------------------------------------ @@ -1714,14 +1694,13 @@ mod tests { #[tokio::test] async fn a_local_body_matching_the_confirmed_chain_root_is_stored() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let path = accept_local_body(&store, &chain_at(root), store_id(1), root, &bytes) .await .expect("chain confirms this exact root"); assert!(path.is_file()); assert_eq!(store.get(&store_id(1), &root).unwrap(), Some(bytes)); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1731,7 +1710,7 @@ mod tests { // comparing against the INDEPENDENTLY resolved chain root refuses it. The chain is pinned // to a different generation, which is what a stale or forged publish looks like. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, declared) = dpb("Ada"); let (_, on_chain) = dpb("the real current generation"); assert_ne!(declared, on_chain); @@ -1743,7 +1722,6 @@ mod tests { assert!(matches!(err, LocalAcceptError::RootNotConfirmed(_))); assert!(!store.has(&store_id(1), &declared)); assert!(!store.has(&store_id(1), &on_chain)); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1751,14 +1729,13 @@ mod tests { // Fail closed. Again the body is genuinely valid for its declared root, so the ONLY thing // standing between it and disk is the missing chain answer. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let err = accept_local_body(&store, &chain_unreachable(), store_id(1), root, &bytes) .await .expect_err("no root means nothing to compare against"); assert!(matches!(err, LocalAcceptError::RootNotConfirmed(_))); assert!(!store.has(&store_id(1), &root)); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1766,14 +1743,13 @@ mod tests { // `Ok(None)` is a DIFFERENT chain answer from `Err`, and an implementation that only // guarded the error path would accept here. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let err = accept_local_body(&store, &Chain(Ok(None)), store_id(1), root, &bytes) .await .expect_err("an unminted store confirms nothing"); assert!(matches!(err, LocalAcceptError::RootNotConfirmed(_))); assert!(!store.has(&store_id(1), &root)); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1782,13 +1758,12 @@ mod tests { // the control-plane error uninterpretable. The chain here confirms the declared root, // isolating the failure to the bytes. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (_, root) = dpb("Ada"); let err = accept_local_body(&store, &chain_at(root), store_id(1), root, b"not a DPB") .await .expect_err("garbage is not a body"); assert!(matches!(err, LocalAcceptError::Malformed(_))); - let _ = std::fs::remove_dir_all(dir); } // -- The 224 responder -------------------------------------------------------------------------- @@ -1796,7 +1771,7 @@ mod tests { #[tokio::test] async fn a_held_body_is_served_and_an_unheld_one_is_not() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); store.put(&sid, &root, &bytes).unwrap(); @@ -1810,7 +1785,6 @@ mod tests { assert_eq!(served, ServeOutcome::Served(bytes.len())); assert_eq!(missing, ServeOutcome::NotHeld); assert_eq!(tx.sent_bodies.lock().unwrap().len(), 1); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1819,7 +1793,7 @@ mod tests { // bound tested only from above would pass for an off-by-one that throttles too early), and // the third must not. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); store.put(&sid, &root, &bytes).unwrap(); @@ -1838,7 +1812,6 @@ mod tests { "at capacity must pass" ); assert_eq!(c, ServeOutcome::Throttled, "one over must fail"); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1847,7 +1820,7 @@ mod tests { // the artifact is known to exist. A capacity of ONE makes the difference observable — under // the wrong ordering the single token is spent on a miss and the real request throttles. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (bytes, root) = dpb("Ada"); let sid = store_id(1); store.put(&sid, &root, &bytes).unwrap(); @@ -1862,7 +1835,6 @@ mod tests { let real = serve_body_request(&store, &tx, &budget, peer(9), &root_ref(sid, root)).await; assert_eq!(real, ServeOutcome::Served(bytes.len())); - let _ = std::fs::remove_dir_all(dir); } // -- The 223-driven fetch ----------------------------------------------------------------------- @@ -1870,7 +1842,7 @@ mod tests { #[tokio::test] async fn an_announce_the_chain_confirms_solicits_the_body_under_the_chain_root() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (_, root) = dpb("Ada"); let sid = store_id(1); let tx = Transport::with_peers(vec![peer(9)]); @@ -1892,7 +1864,6 @@ mod tests { "the solicitation must be recorded under the CHAIN-resolved root" ); assert_eq!(tx.sent_requests.lock().unwrap().len(), 1); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] @@ -1901,7 +1872,7 @@ mod tests { // nothing else — in particular it must not produce a solicitation, because a solicitation // is exactly what would later make an attacker body acceptable. let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (_, forged) = dpb("Mallory"); let (_, on_chain) = dpb("Ada"); let sid = store_id(1); @@ -1921,13 +1892,12 @@ mod tests { assert_eq!(asked, None); assert!(!sol.is_solicited(&sid, &forged, &peer(9))); assert!(tx.sent_requests.lock().unwrap().is_empty()); - let _ = std::fs::remove_dir_all(dir); } #[tokio::test] async fn an_announce_is_not_chased_while_the_chain_is_unreachable() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (_, root) = dpb("Ada"); let sid = store_id(1); let tx = Transport::with_peers(vec![peer(9)]); @@ -1945,7 +1915,6 @@ mod tests { assert_eq!(asked, None); assert!(tx.sent_requests.lock().unwrap().is_empty()); - let _ = std::fs::remove_dir_all(dir); } /// **A repeated announce buys the attacker nothing, and costs this node nothing.** @@ -1962,7 +1931,7 @@ mod tests { #[tokio::test] async fn a_repeated_announce_neither_re_reads_the_chain_nor_re_asks_a_peer() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (_, root) = dpb("Ada"); let sid = store_id(1); let tx = Transport::with_peers(vec![peer(9)]); @@ -1991,7 +1960,6 @@ mod tests { 1, "eight announces produced more than one chain lineage walk" ); - let _ = std::fs::remove_dir_all(dir); } /// A DIFFERENT root is still chased — the dedupe must not swallow a genuine new generation. @@ -2001,7 +1969,7 @@ mod tests { #[tokio::test] async fn a_second_root_for_the_same_store_is_still_chased() { let dir = tempdir(); - let store = ProfileBodyStore::new(dir.clone()); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (_, first) = dpb("Ada"); let (_, second) = dpb("Grace"); assert_ne!(first, second, "the fixture roots must differ"); @@ -2033,7 +2001,6 @@ mod tests { 2, "a new generation was suppressed by the duplicate-announce guard" ); - let _ = std::fs::remove_dir_all(dir); } // -- The kill switch ---------------------------------------------------------------------------- @@ -2065,7 +2032,8 @@ mod tests { /// right. Retention keeps current-plus-one, so two roots per store is the real maximum. #[test] fn held_pairs_enumerates_every_store_and_every_root() { - let store = ProfileBodyStore::new(tempdir()); + let dir = tempdir(); + let store = ProfileBodyStore::new(dir.path().to_path_buf()); let (alice, alice_root) = dpb("alice"); let (bob, bob_root) = dpb("bob"); store.put(&store_id(1), &alice_root, &alice).expect("put"); @@ -2088,7 +2056,7 @@ mod tests { #[test] fn held_pairs_skips_names_this_module_did_not_write() { let root_dir = tempdir(); - let store = ProfileBodyStore::new(root_dir.clone()); + let store = ProfileBodyStore::new(root_dir.path().to_path_buf()); let (bytes, root) = dpb("alice"); store.put(&store_id(1), &root, &bytes).expect("put"); diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index b75ba726..6656c8d2 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -5109,14 +5109,13 @@ mod tests { /// A scratch config dir unique to this process AND thread, so parallel tests never share a /// custody manifest. - fn refusal_scratch_dir(tag: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!( - "dig-wallet-tip-refusal-{tag}-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&dir); - dir + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn refusal_scratch_dir(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("dig-wallet-tip-refusal-{tag}-")) + .tempdir() + .expect("a scratch dir") } /// Ask `be` for a tip and return the `NotExecutable` reason, asserting on the way that the @@ -8328,12 +8327,11 @@ mod tests { /// address fallback too, so it could not tell the two implementations apart. #[tokio::test] async fn a_restarted_locked_node_still_refuses_a_bundle_over_its_non_primary_key() { - let dir = std::env::temp_dir().join(format!( - "dig-wallet-restart-guard-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&dir); + // Owned by the guard, so the tree goes away on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-wallet-restart-guard-") + .tempdir() + .expect("a scratch dir"); // The wallet as a pre-#1701 install left it: two HD keys persisted in the manifest, the // seed unreadable. `primary` stands for the receive address the old fallback covered; diff --git a/crates/dig-wallet/src/sage/service.rs b/crates/dig-wallet/src/sage/service.rs index e0855eaa..5203b09e 100644 --- a/crates/dig-wallet/src/sage/service.rs +++ b/crates/dig-wallet/src/sage/service.rs @@ -448,12 +448,13 @@ mod tests { } /// A unique temp config dir per test. - fn scratch() -> PathBuf { - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("dig-wallet-svc-{}-{}", std::process::id(), n)); - let _ = std::fs::remove_dir_all(&dir); - dir + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn scratch() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("dig-wallet-svc-") + .tempdir() + .expect("a scratch dir") } /// **Proves (#368):** the production assembler builds a served backend that answers diff --git a/crates/dig-wallet/src/sage/sources.rs b/crates/dig-wallet/src/sage/sources.rs index df5dfac8..5a05655b 100644 --- a/crates/dig-wallet/src/sage/sources.rs +++ b/crates/dig-wallet/src/sage/sources.rs @@ -1052,12 +1052,12 @@ mod sole_owner_tests { /// why the demonstration is needed rather than the real assertion alone. #[test] fn a_second_fabric_in_the_new_root_is_seen_and_judged_a_stray() { - let root = std::env::temp_dir().join(format!( - "dig-wallet-sole-owner-{}-{}", - std::process::id(), - line!() - )); - let nested = root.join("seams"); + // Owned by the guard, so the tree goes away on drop and on an unwind (dig-node#370). + let root = tempfile::Builder::new() + .prefix("dig-wallet-sole-owner-") + .tempdir() + .expect("a temporary root"); + let nested = root.path().join("seams"); std::fs::create_dir_all(&nested).expect("a temporary root"); // Built from `CONSTRUCTOR` rather than written out, so this file never contains the // needle it sweeps for; and named `sources.rs` because that is the name the unqualified diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index 364892e8..ed8c5155 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -721,12 +721,13 @@ impl Harness { } /// A unique temp config dir per test. -fn scratch() -> PathBuf { - static SEQ: AtomicUsize = AtomicUsize::new(0); - let n = SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("dig-wallet-sup-{}-{}", std::process::id(), n)); - let _ = std::fs::remove_dir_all(&dir); - dir +/// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, +/// including on an unwind, so a failing assertion cannot leak it (dig-node#370). +fn scratch() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("dig-wallet-sup-") + .tempdir() + .expect("a scratch dir") } /// A custody with ONE wallet enrolled under `dir`, standing in for a pre-#1701 install. diff --git a/crates/dig-wallet/src/sage/tipping.rs b/crates/dig-wallet/src/sage/tipping.rs index afd110f7..9e8e4f77 100644 --- a/crates/dig-wallet/src/sage/tipping.rs +++ b/crates/dig-wallet/src/sage/tipping.rs @@ -1269,13 +1269,13 @@ mod tests { const DAY0: u64 = 1_700_000_000; // 2023-11-14 (a stable day) const DAY1: u64 = DAY0 + 86_400; // the next day - fn scratch() -> PathBuf { - static SEQ: AtomicU64 = AtomicU64::new(0); - let n = SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("dig-tip-{}-{}", std::process::id(), n)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn scratch() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("dig-tip-") + .tempdir() + .expect("a scratch dir") } /// Build an engine over `dir` and seed its config (persisted). diff --git a/crates/dig-wallet/src/sage/watchlist.rs b/crates/dig-wallet/src/sage/watchlist.rs index ef5baa2b..be87872b 100644 --- a/crates/dig-wallet/src/sage/watchlist.rs +++ b/crates/dig-wallet/src/sage/watchlist.rs @@ -263,11 +263,13 @@ mod tests { SecretKey::from_seed(&seed).public_key() } - fn dir(tag: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!("dig-watchlist-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&p); - std::fs::create_dir_all(&p).unwrap(); - p + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn dir(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("dig-watchlist-{tag}-")) + .tempdir() + .expect("a scratch dir") } #[test] diff --git a/crates/dig-wallet/src/seed_export.rs b/crates/dig-wallet/src/seed_export.rs index 5298c3e8..adc4c169 100644 --- a/crates/dig-wallet/src/seed_export.rs +++ b/crates/dig-wallet/src/seed_export.rs @@ -125,11 +125,13 @@ mod tests { } /// A directory unique to one test, so tests never share a fixture path. - fn scratch(tag: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("dig-seed-export-{tag}-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("scratch dir"); - dir + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn scratch(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("dig-seed-export-{tag}-")) + .tempdir() + .expect("scratch dir") } /// Write a seed file in the LEGACY on-disk layout — the one every real custodied file From a3f09dfdfc72c6edae3ef624f50f452047a535cf Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 22:58:29 -0700 Subject: [PATCH 03/21] fix(test): own peer-pool and server harness scratch trees with tempfile::TempDir --- crates/dig-node-core/src/peer.rs | 54 +++++--- .../dig-node-core/src/seams/dig_peer/pex.rs | 4 +- crates/dig-node-core/tests/pool_connect.rs | 27 ++-- .../profile_reannounce_reaches_the_wire.rs | 24 ++-- crates/dig-node-service/tests/server.rs | 123 ++++++++++-------- crates/dig-wallet/src/sage/rpc.rs | 10 +- crates/dig-wallet/src/sage/service.rs | 8 +- crates/dig-wallet/src/sage/sources.rs | 2 +- .../src/sage/sync_supervisor/tests.rs | 44 +++---- crates/dig-wallet/src/sage/tipping.rs | 61 ++++----- crates/dig-wallet/src/sage/watchlist.rs | 20 +-- crates/dig-wallet/src/seed_export.rs | 12 +- 12 files changed, 217 insertions(+), 172 deletions(-) diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 9ed0189c..cefb7229 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -3618,8 +3618,11 @@ pub(crate) mod tests { let peer_port = peer_rpc.local_addr().unwrap().port(); // The gossip pool on its OWN OS-assigned ephemeral port (a different socket). - let dir = std::env::temp_dir().join(format!("dig-node-wuc-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); + // Owned by the guard: the tree goes away on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-wuc-") + .tempdir() + .expect("a pool cert dir"); let cfg = dig_gossip::GossipConfig { network_id: chia_protocol::Bytes32::new([1u8; 32]), cert_path: dir.join("node.cert").display().to_string(), @@ -3660,8 +3663,11 @@ pub(crate) mod tests { /// authority parses; the ONLY thing wrong with this anchor is that nothing answers there. #[tokio::test] async fn a_node_survives_every_bootstrap_anchor_being_unreachable() { - let dir = std::env::temp_dir().join(format!("dig-node-bootstrap-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); + // Owned by the guard: the tree goes away on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-bootstrap-") + .tempdir() + .expect("a pool cert dir"); let cfg = dig_gossip::GossipConfig { network_id: chia_protocol::Bytes32::new([3u8; 32]), cert_path: dir.join("node.cert").display().to_string(), @@ -3716,8 +3722,11 @@ pub(crate) mod tests { // same reservation the node drives. #[tokio::test] async fn wire_relay_reservation_shares_one_status_with_the_pool() { - let dir = std::env::temp_dir().join(format!("dig-node-wuc-share-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); + // Owned by the guard: the tree goes away on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-wuc-share-") + .tempdir() + .expect("a pool cert dir"); let cfg = dig_gossip::GossipConfig { network_id: chia_protocol::Bytes32::new([2u8; 32]), cert_path: dir.join("node.cert").display().to_string(), @@ -3801,7 +3810,7 @@ pub(crate) mod tests { /// than a log line, which prints on the broken path too. #[tokio::test] async fn a_stale_relayed_slot_does_not_refuse_the_direct_adoption() { - let handle = fresh_pool_handle("readopt-supersede", [11u8; 32]).await; + let (handle, _handle_dir) = fresh_pool_handle("readopt-supersede", [11u8; 32]).await; let peer = [0xAB; 32]; // A relayed adoption lands first, then its session DIES (the server half is dropped) — the @@ -3924,7 +3933,7 @@ pub(crate) mod tests { /// the CLI renderer, or a helper written and never called, leaves this red. #[tokio::test] async fn a_relay_peer_with_a_wildcard_pool_address_reports_no_address() { - let handle = fresh_pool_handle("wildcard-address", [23u8; 32]).await; + let (handle, _handle_dir) = fresh_pool_handle("wildcard-address", [23u8; 32]).await; // The peer seen in the wild: relayed, with dig-nat's unspecified remote. let (wildcard, _wildcard_server) = loopback_nat_conn( @@ -4014,7 +4023,7 @@ pub(crate) mod tests { /// state before any peer connects). Uses a real `GossipHandle` — the same type the node retains. #[tokio::test] async fn connected_peers_json_is_empty_for_a_fresh_pool() { - let handle = fresh_pool_handle("cpjson-empty", [3u8; 32]).await; + let (handle, _handle_dir) = fresh_pool_handle("cpjson-empty", [3u8; 32]).await; assert!(connected_peers_json(&handle).is_empty()); } @@ -4023,7 +4032,7 @@ pub(crate) mod tests { /// path the RPC arm returns as a control error. #[tokio::test] async fn connect_peer_rejects_a_non_address_non_peer_id_argument() { - let handle = fresh_pool_handle("connect-bad-arg", [4u8; 32]).await; + let (handle, _handle_dir) = fresh_pool_handle("connect-bad-arg", [4u8; 32]).await; let err = connect_peer(&handle, "not-an-address").await.unwrap_err(); assert!(err.contains("dialable address"), "got: {err}"); } @@ -4108,8 +4117,8 @@ pub(crate) mod tests { // Same network_id on both — a mismatch would be rejected at handshake. B binds a concrete // IPv6 loopback (§5.2 IPv6-first) so the inbound accept registers on every platform. let loopback_v6 = "[::1]:0".parse().expect("parse [::1]:0"); - let node_a = fresh_pool_handle("loopback-a", [0x5au8; 32]).await; - let node_b = fresh_pool_handle_on("loopback-b", [0x5au8; 32], loopback_v6).await; + let (node_a, _node_a_dir) = fresh_pool_handle("loopback-a", [0x5au8; 32]).await; + let (node_b, _node_b_dir) = fresh_pool_handle_on("loopback-b", [0x5au8; 32], loopback_v6).await; let a_peer_id = hex::encode(node_a.local_peer_id().expect("node A local_peer_id")); let b_port = node_b @@ -4172,7 +4181,7 @@ pub(crate) mod tests { /// mirroring the connect arg-validation path — no network touch, no hang. #[tokio::test] async fn disconnect_peer_rejects_a_malformed_peer_id() { - let handle = fresh_pool_handle("disconnect-bad-arg", [7u8; 32]).await; + let (handle, _handle_dir) = fresh_pool_handle("disconnect-bad-arg", [7u8; 32]).await; let err = disconnect_peer(&handle, "not-hex").await.unwrap_err(); assert!(err.contains("64-hex peer_id"), "got: {err}"); } @@ -4185,7 +4194,7 @@ pub(crate) mod tests { /// variant, a cross-family contract release, so this PR extends the existing peerStatus surface). #[tokio::test] async fn pool_stats_json_reports_the_pool_posture() { - let handle = fresh_pool_handle("pool-stats", [9u8; 32]).await; + let (handle, _handle_dir) = fresh_pool_handle("pool-stats", [9u8; 32]).await; let stats = pool_stats_json(&handle); assert_eq!(stats["connected"], 0, "a fresh pool has no connected peers"); assert_eq!(stats["in_flight"], 0, "no dials are in flight yet"); @@ -4221,13 +4230,19 @@ pub(crate) mod tests { /// inbound loopback connections into the pool (the native-tls dual-stack accept quirk — the same /// family of `[::]`-v6only issue tracked for the extension-offline path), whereas a concrete /// loopback bind does, on every platform. Production still binds dual-stack `[::]` (`run_peer_network`). + /// Returns the guard alongside the handle: the started pool reads its cert, key and + /// peers files for its whole lifetime, so the directory must outlive the handle rather + /// than this function. The caller holds both, and `TempDir`'s `Drop` removes the tree + /// when the test ends — including on an unwind (dig-node#370). pub(crate) async fn fresh_pool_handle_on( tag: &str, network: [u8; 32], listen_addr: std::net::SocketAddr, - ) -> dig_gossip::GossipHandle { - let dir = std::env::temp_dir().join(format!("dig-node-{tag}-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); + ) -> (dig_gossip::GossipHandle, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix(&format!("dig-node-{tag}-")) + .tempdir() + .expect("a pool cert dir"); let cfg = dig_gossip::GossipConfig { network_id: chia_protocol::Bytes32::new(network), cert_path: dir.join("node.cert").display().to_string(), @@ -4237,11 +4252,12 @@ pub(crate) mod tests { listen_addr, ..Default::default() }; - dig_gossip::GossipService::new(cfg) + let handle = dig_gossip::GossipService::new(cfg) .expect("gossip config") .start() .await - .expect("gossip start") + .expect("gossip start"); + (handle, dir) } #[test] diff --git a/crates/dig-node-core/src/seams/dig_peer/pex.rs b/crates/dig-node-core/src/seams/dig_peer/pex.rs index 642bf6d9..e47533f6 100644 --- a/crates/dig-node-core/src/seams/dig_peer/pex.rs +++ b/crates/dig-node-core/src/seams/dig_peer/pex.rs @@ -733,8 +733,8 @@ mod tests { let _ = rustls::crypto::ring::default_provider().install_default(); let network = [0x7bu8; 32]; - let node_a = crate::peer::tests::fresh_pool_handle("pex-adopt-a", network).await; - let node_b = crate::peer::tests::fresh_pool_handle_on( + let (node_a, _node_a_dir) = crate::peer::tests::fresh_pool_handle("pex-adopt-a", network).await; + let (node_b, _node_b_dir) = crate::peer::tests::fresh_pool_handle_on( "pex-adopt-b", network, "[::1]:0".parse().expect("parse [::1]:0"), diff --git a/crates/dig-node-core/tests/pool_connect.rs b/crates/dig-node-core/tests/pool_connect.rs index fc9f5f62..5b5f1b07 100644 --- a/crates/dig-node-core/tests/pool_connect.rs +++ b/crates/dig-node-core/tests/pool_connect.rs @@ -27,9 +27,19 @@ const POOL_REGISTERS_INBOUND_LOOPBACK: bool = cfg!(target_os = "linux"); /// Start a fresh gossip pool bound on `listen_addr` with a fixed non-zero `network_id`. Certs are /// minted into a throwaway temp dir; the pool is otherwise a stock, discovery-free node. -async fn start_pool(tag: &str, network: [u8; 32], listen_addr: SocketAddr) -> GossipHandle { - let dir = std::env::temp_dir().join(format!("dig-pool-connect-{tag}-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); +/// +/// The guard comes back with the handle because the started pool reads its cert, key and peers +/// files for its whole lifetime — so the tree must outlive this function, and `TempDir`'s `Drop` +/// removes it when the test ends, including on an unwind (dig-node#370). +async fn start_pool( + tag: &str, + network: [u8; 32], + listen_addr: SocketAddr, +) -> (GossipHandle, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix(&format!("dig-pool-connect-{tag}-")) + .tempdir() + .expect("a pool cert dir"); let cfg = GossipConfig { network_id: chia_protocol::Bytes32::new(network), cert_path: dir.join("node.cert").display().to_string(), @@ -39,11 +49,12 @@ async fn start_pool(tag: &str, network: [u8; 32], listen_addr: SocketAddr) -> Go listen_addr, ..Default::default() }; - GossipService::new(cfg) + let handle = GossipService::new(cfg) .expect("gossip config is valid (non-zero network_id)") .start() .await - .expect("gossip pool starts") + .expect("gossip pool starts"); + (handle, dir) } /// The `Via` this handle records for `peer_id`, if it lists it as a connected pool member. @@ -76,8 +87,8 @@ async fn two_pools_connect_over_loopback_and_count_each_other() { // binds so the inbound accept registers on every platform that supports it (§5.2 IPv6-first). let network = [0x5au8; 32]; let loopback: SocketAddr = "[::1]:0".parse().expect("parse [::1]:0"); - let node_a = start_pool("a", network, loopback).await; - let node_b = start_pool("b", network, loopback).await; + let (node_a, _node_a_dir) = start_pool("a", network, loopback).await; + let (node_b, _node_b_dir) = start_pool("b", network, loopback).await; let a_peer_id = node_a.local_peer_id().expect("node A local_peer_id"); let b_peer_id = node_b.local_peer_id().expect("node B local_peer_id"); @@ -138,7 +149,7 @@ async fn dialing_a_dead_port_never_counts_a_peer() { // here, B dials a port with no listener — the dial must FAIL and B must count ZERO peers. This is // the assertion that fails when connect is broken, proving the positive test above is real. dig_node_core::peer::install_crypto_provider(); - let node_b = start_pool( + let (node_b, _node_b_dir) = start_pool( "dead", [0x5au8; 32], "[::1]:0".parse().expect("parse [::1]:0"), diff --git a/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs b/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs index df49f630..4205a328 100644 --- a/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs +++ b/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs @@ -35,20 +35,18 @@ use dig_node_core::seams::dig_peer::profile_sync::{ /// /// Mirrors the bring-up `peer.rs`'s own tests use: an OS-assigned loopback port, self-generated /// certs under a per-process temp dir, no introducer and no relay. +/// The guard is returned with the transport: the running service reads its cert, key and peers +/// files for its whole lifetime, so the tree must outlive this function (dig-node#370). async fn transport_with_one_peer() -> ( dig_gossip::GossipService, dig_gossip::GossipHandle, GossipProfileTransport, + tempfile::TempDir, ) { - let dir = std::env::temp_dir().join(format!( - "dig-node-3061-{}-{:?}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&dir).expect("temp dir"); + let dir = tempfile::Builder::new() + .prefix("dig-node-3061-") + .tempdir() + .expect("temp dir"); let cfg = dig_gossip::GossipConfig { network_id: chia_protocol::Bytes32::new([7u8; 32]), @@ -75,7 +73,7 @@ async fn transport_with_one_peer() -> ( .expect("stub peer registers"); let transport = GossipProfileTransport::new(handle.clone()); - (service, handle, transport) + (service, handle, transport, dir) } /// **Proves #3061 end to end at the node's own transport:** the periodic re-announce of an unchanged @@ -86,7 +84,7 @@ async fn transport_with_one_peer() -> ( /// `announce_held_root` to `announce_root` and this test fails on the second iteration. #[tokio::test] async fn a_repeat_announce_of_an_unchanged_root_still_reaches_the_peer() { - let (_service, _handle, transport) = transport_with_one_peer().await; + let (_service, _handle, transport, _dir) = transport_with_one_peer().await; let store_id = [3u8; 32]; let root = [9u8; 32]; @@ -110,7 +108,7 @@ async fn a_repeat_announce_of_an_unchanged_root_still_reaches_the_peer() { /// same frame offered twice to `announce_root` must reach the peer once and then be suppressed. #[tokio::test] async fn the_forwarding_path_still_suppresses_its_own_repeat() { - let (_service, _handle, transport) = transport_with_one_peer().await; + let (_service, _handle, transport, _dir) = transport_with_one_peer().await; let root_ref = dig_gossip::service::profile_sync::ProfileRootRef { store_id: chia_protocol::Bytes32::new([4u8; 32]), root: chia_protocol::Bytes32::new([5u8; 32]), @@ -135,7 +133,7 @@ async fn the_forwarding_path_still_suppresses_its_own_repeat() { /// node's own echo returning from a neighbour. #[tokio::test] async fn a_local_announce_still_arms_the_loop_guard_against_its_own_echo() { - let (_service, _handle, transport) = transport_with_one_peer().await; + let (_service, _handle, transport, _dir) = transport_with_one_peer().await; let store_id = [1u8; 32]; let root = [2u8; 32]; diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 105253dc..6f1afb8d 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -90,11 +90,20 @@ async fn start_mock_upstream() -> (String, Arc>>) { /// global env stays pinned to ITS dir for the whole test, then releases it on drop. /// (Per-test unique dirs alone are not enough precisely because the reads are live /// and global — the lock is what makes them consistent.) +/// +/// It also carries the harness's scratch tree, when there is one (dig-node#370). The tree +/// must outlive the spawned server rather than the builder that made it, and this guard is +/// already the value every caller keeps alive for exactly that span — so hanging the +/// `TempDir` here removes the directory at the same instant the test ends, on an unwind as +/// well as on success. `Drop` cannot reclaim what a still-open handle pins, so on Windows a +/// tree whose `wallet.sqlite` the detached serve task still holds may survive; the residue +/// is bounded by that and is not pretended away. #[must_use] struct EnvHold( // Held purely for its Drop (RAII release of the serialization lock). The field is // never read — the value's lifetime IS its purpose — so silence dead_code. #[allow(dead_code)] tokio::sync::OwnedMutexGuard<()>, + #[allow(dead_code)] Option, ); /// Start the companion app on a random loopback port pointed at the given upstream @@ -129,7 +138,7 @@ async fn start_companion_full_inner( // node, but the server then reads DIG_NODE_CACHE/config_path LIVE per request, so // the lock must outlive construction to keep those reads consistent (see EnvHold). let hold = env_guard().lock_owned().await; - let (state, token) = { + let (state, token, base) = { // Isolate dig-node's on-disk state PER CALL so the test never touches the // real cache AND no two concurrent tests share state. // @@ -143,10 +152,11 @@ async fn start_companion_full_inner( // token-gated control.* call (the flaky failure this guards). Give each call // its own PARENT dir (`/dig-node-test--/cache`) so the // token + config.json are unique per server. (Set before from_env reads it.) - let unique = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let base = - std::env::temp_dir().join(format!("dig-node-test-{}-{}", std::process::id(), unique)); - let cache = base.join("cache"); + let base = tempfile::Builder::new() + .prefix("dig-node-test-") + .tempdir() + .expect("create the test base dir"); + let cache = base.path().join("cache"); std::fs::create_dir_all(&cache).expect("create test cache dir"); std::env::set_var("DIG_NODE_CACHE", &cache); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); @@ -155,7 +165,7 @@ async fn start_companion_full_inner( // DigNode`, …) the server + this test would resolve THAT shared dir instead of the temp // one, losing isolation and clobbering across concurrent tests. DIG_NODE_STATE_DIR (the // designed test/deploy override) pins it to this test's base dir, identity-independently. - std::env::set_var("DIG_NODE_STATE_DIR", &base); + std::env::set_var("DIG_NODE_STATE_DIR", base.path()); let state = dig_node_service::server::build_state(&config).await; let state = match chia_peers { Some(n) => state.with_chia_peer_count_for_tests(n), @@ -164,7 +174,7 @@ async fn start_companion_full_inner( // The token the server wrote (read from disk, exactly as a real controller // would). config_path() resolves under the temp DIG_NODE_CACHE we just set. let token = dig_node_service::control::load_or_create_token().unwrap(); - (state, token) + (state, token, base) }; // `into_make_service_with_connect_info` — not the plain `app` — is what makes // `ConnectInfo` extractable in the real `rpc()` handler (#1619 follow-up): a bare @@ -177,7 +187,7 @@ async fn start_companion_full_inner( tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (addr, token, EnvHold(hold)) + (addr, token, EnvHold(hold, Some(base))) } /// Like [`start_companion_full`], but with the wallet's Chia peer count pinned to `peers`. No @@ -200,18 +210,19 @@ async fn start_companion_probe(upstream: &str) -> (SocketAddr, Value, EnvHold) { ..dig_node_service::Config::default() }; let hold = env_guard().lock_owned().await; - let (state, probe) = { - let unique = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let base = - std::env::temp_dir().join(format!("dig-node-test-{}-{}", std::process::id(), unique)); - let cache = base.join("cache"); + let (state, probe, base) = { + let base = tempfile::Builder::new() + .prefix("dig-node-test-") + .tempdir() + .expect("create the test base dir"); + let cache = base.path().join("cache"); std::fs::create_dir_all(&cache).expect("create test cache dir"); std::env::set_var("DIG_NODE_CACHE", &cache); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); - std::env::set_var("DIG_NODE_STATE_DIR", &base); + std::env::set_var("DIG_NODE_STATE_DIR", base.path()); let state = dig_node_service::server::build_state(&config).await; let probe = state.loop_probe_request(); - (state, probe) + (state, probe, base) }; let app = dig_node_service::server::router(state).into_make_service_with_connect_info::(); @@ -220,7 +231,7 @@ async fn start_companion_probe(upstream: &str) -> (SocketAddr, Value, EnvHold) { tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (addr, probe, EnvHold(hold)) + (addr, probe, EnvHold(hold, Some(base))) } /// Like [`start_companion_probe`] but ALSO returns the built [`AppState`], so a test can observe @@ -240,18 +251,19 @@ async fn start_companion_probe_state( ..dig_node_service::Config::default() }; let hold = env_guard().lock_owned().await; - let (state, probe) = { - let unique = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let base = - std::env::temp_dir().join(format!("dig-node-test-{}-{}", std::process::id(), unique)); - let cache = base.join("cache"); + let (state, probe, base) = { + let base = tempfile::Builder::new() + .prefix("dig-node-test-") + .tempdir() + .expect("create the test base dir"); + let cache = base.path().join("cache"); std::fs::create_dir_all(&cache).expect("create test cache dir"); std::env::set_var("DIG_NODE_CACHE", &cache); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); - std::env::set_var("DIG_NODE_STATE_DIR", &base); + std::env::set_var("DIG_NODE_STATE_DIR", base.path()); let state = dig_node_service::server::build_state(&config).await; let probe = state.loop_probe_request(); - (state, probe) + (state, probe, base) }; let app = dig_node_service::server::router(state.clone()) .into_make_service_with_connect_info::(); @@ -260,7 +272,7 @@ async fn start_companion_probe_state( tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (addr, probe, state, EnvHold(hold)) + (addr, probe, state, EnvHold(hold, Some(base))) } /// Like [`start_companion_full`] but ALSO returns the served wallet backend (#368/#369) so a WS @@ -276,11 +288,12 @@ async fn start_companion_wallet( ..dig_node_service::Config::default() }; let hold = env_guard().lock_owned().await; - let (state, token, backend) = { - let unique = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let base = - std::env::temp_dir().join(format!("dig-node-test-{}-{}", std::process::id(), unique)); - let cache = base.join("cache"); + let (state, token, backend, base) = { + let base = tempfile::Builder::new() + .prefix("dig-node-test-") + .tempdir() + .expect("create the test base dir"); + let cache = base.path().join("cache"); std::fs::create_dir_all(&cache).expect("create test cache dir"); std::env::set_var("DIG_NODE_CACHE", &cache); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); @@ -289,11 +302,11 @@ async fn start_companion_wallet( // DigNode`, …) the server + this test would resolve THAT shared dir instead of the temp // one, losing isolation and clobbering across concurrent tests. DIG_NODE_STATE_DIR (the // designed test/deploy override) pins it to this test's base dir, identity-independently. - std::env::set_var("DIG_NODE_STATE_DIR", &base); + std::env::set_var("DIG_NODE_STATE_DIR", base.path()); let state = dig_node_service::server::build_state(&config).await; let token = dig_node_service::control::load_or_create_token().unwrap(); let backend = state.wallet_backend(); - (state, token, backend) + (state, token, backend, base) }; let app = dig_node_service::server::router(state).into_make_service_with_connect_info::(); @@ -302,7 +315,7 @@ async fn start_companion_wallet( tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - (addr, token, backend, EnvHold(hold)) + (addr, token, backend, EnvHold(hold, Some(base))) } fn client() -> reqwest::Client { @@ -711,11 +724,15 @@ async fn dual_listener_serves_localhost_when_dig_local_bind_fails() { // Drive serve under the env lock, held for the whole test (the server reads // DIG_NODE_CACHE/config live per request — see EnvHold). Bound to `_hold` so it // outlives the spawned server below. - let _hold = EnvHold(env_guard().lock_owned().await); + let _hold = EnvHold(env_guard().lock_owned().await, None); let stop = std::sync::Arc::new(tokio::sync::Notify::new()); let stop_for_server = stop.clone(); let server = { - let tmp = std::env::temp_dir().join(format!("dig-node-dual-{}", std::process::id())); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let tmp = tempfile::Builder::new() + .prefix("dig-node-dual-") + .tempdir() + .expect("a scratch dir"); std::env::set_var("DIG_NODE_CACHE", &tmp); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); // Isolate the #501 control-token/paired-token state dir per test (see the note above). @@ -777,11 +794,15 @@ async fn dual_stack_loopback_serves_both_ipv4_and_ipv6_on_the_same_port() { ..dig_node_service::Config::default() // host: None → dual-stack default }; - let _hold = EnvHold(env_guard().lock_owned().await); + let _hold = EnvHold(env_guard().lock_owned().await, None); let stop = std::sync::Arc::new(tokio::sync::Notify::new()); let stop_for_server = stop.clone(); let server = { - let tmp = std::env::temp_dir().join(format!("dig-node-dualstack-{}", std::process::id())); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let tmp = tempfile::Builder::new() + .prefix("dig-node-dualstack-") + .tempdir() + .expect("a scratch dir"); std::env::set_var("DIG_NODE_CACHE", &tmp); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); // Isolate the #501 control-token/paired-token state dir per test (see the note above). @@ -2948,13 +2969,13 @@ async fn control_updater_status_and_mutation_wired_over_http() { let (addr, token, _hold) = start_companion_full(&upstream).await; // -- status: absent (no beacon installed on this runner) is a normal, non-error result. - let status_dir = std::env::temp_dir().join(format!( - "dig-node-updater-e2e-status-{}-{}", - std::process::id(), - line!() - )); - let _ = std::fs::remove_dir_all(&status_dir); - std::env::set_var("DIG_UPDATER_STATUS_DIR", &status_dir); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). The node reads + // this path live, so the binding must outlive every assertion below. + let status_dir = tempfile::Builder::new() + .prefix("dig-node-updater-e2e-status-") + .tempdir() + .expect("a status dir"); + std::env::set_var("DIG_UPDATER_STATUS_DIR", status_dir.path()); let absent = post_rpc( &addr, @@ -3219,14 +3240,12 @@ async fn start_serving_node( ..dig_node_service::Config::default() }; - let hold = EnvHold(env_guard().lock_owned().await); - let unique = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let base = std::env::temp_dir().join(format!( - "dig-node-peernet-{}-{}", - std::process::id(), - unique - )); - let cache = base.join("cache"); + let hold = env_guard().lock_owned().await; + let base = tempfile::Builder::new() + .prefix("dig-node-peernet-") + .tempdir() + .expect("create the peer-network base dir"); + let cache = base.path().join("cache"); std::fs::create_dir_all(&cache).expect("create test cache dir"); std::env::set_var("DIG_NODE_CACHE", &cache); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); @@ -3260,7 +3279,7 @@ async fn start_serving_node( } assert!(served, "the node must serve /health after bring-up"); - (port, stop, server, token, hold) + (port, stop, server, token, EnvHold(hold, Some(base))) } /// Call `control.peerStatus` with the control token and return the JSON-RPC response. diff --git a/crates/dig-wallet/src/sage/rpc.rs b/crates/dig-wallet/src/sage/rpc.rs index 6656c8d2..55022826 100644 --- a/crates/dig-wallet/src/sage/rpc.rs +++ b/crates/dig-wallet/src/sage/rpc.rs @@ -5182,8 +5182,8 @@ mod tests { #[tokio::test] async fn an_enrolled_wallet_refuses_the_tip_as_an_unopenable_seed() { let dir = refusal_scratch_dir("enrolled"); - WalletCustody::enroll_for_tests(&dir, "tip-refusal-fixture", &[BlsPair::new(410).pk]); - let custody = WalletCustody::open(dir.clone()); + WalletCustody::enroll_for_tests(dir.path(), "tip-refusal-fixture", &[BlsPair::new(410).pk]); + let custody = WalletCustody::open(dir.path().to_path_buf()); assert!( custody.any_wallet(), "the fixture must really enrol a wallet, or it is the empty-custody case in disguise" @@ -5205,7 +5205,7 @@ mod tests { async fn custody_holding_no_wallet_refuses_the_tip_as_nothing_enrolled() { let dir = refusal_scratch_dir("empty"); std::fs::create_dir_all(&dir).expect("create the scratch dir"); - let custody = WalletCustody::open(dir.clone()); + let custody = WalletCustody::open(dir.path().to_path_buf()); assert!( !custody.any_wallet(), "the fixture must really be empty, or it is the enrolled case in disguise" @@ -8343,7 +8343,7 @@ mod tests { p2_hash(secondary.pk), "the fixture needs two DISTINCT keys, or it cannot tell the two guards apart" ); - WalletCustody::enroll_for_tests(&dir, "restart-fixture", &[primary.pk, secondary.pk]); + WalletCustody::enroll_for_tests(dir.path(), "restart-fixture", &[primary.pk, secondary.pk]); let pusher = FakePusher::accepting(); let cfg = WalletConfig { @@ -8360,7 +8360,7 @@ mod tests { // Restart: a fresh custody over the SAME directory and a fresh backend whose memo of // loaded signers is empty. - let restarted = WalletCustody::open(dir.clone()); + let restarted = WalletCustody::open(dir.path().to_path_buf()); let db2 = WalletDb::open_in_memory().await.unwrap(); db2.set_initial_sync_complete(true).await.unwrap(); let after = WalletBackend::new(db2, Arc::new(MockFallback::default()), cfg) diff --git a/crates/dig-wallet/src/sage/service.rs b/crates/dig-wallet/src/sage/service.rs index 5203b09e..f0ead95c 100644 --- a/crates/dig-wallet/src/sage/service.rs +++ b/crates/dig-wallet/src/sage/service.rs @@ -469,7 +469,7 @@ mod tests { #[tokio::test] async fn build_assembles_a_served_backend() { let dir = scratch(); - let svc = build_offline(&dir).await; + let svc = build_offline(dir.path()).await; let (status, body) = svc.backend.dispatch("get_version", "{}").await; assert_eq!(status, 200, "{body}"); @@ -503,7 +503,7 @@ mod tests { #[tokio::test] async fn build_serves_the_tipping_subsystem() { let dir = scratch(); - let svc = build_offline(&dir).await; + let svc = build_offline(dir.path()).await; let (status, body) = svc.backend.dispatch("tip.get_config", "{}").await; assert_eq!(status, 200, "{body}"); @@ -547,7 +547,7 @@ mod tests { chia_bls::SecretKey::from_seed(&seed).public_key() }; { - let svc = build_offline(&dir).await; + let svc = build_offline(dir.path()).await; assert!( svc.watchlist.is_empty(), "a fresh dir must start with nothing registered, or persistence proves nothing" @@ -558,7 +558,7 @@ mod tests { "the registration must go through the single enrolment door" ); } - let svc2 = build_offline(&dir).await; + let svc2 = build_offline(dir.path()).await; assert_eq!( svc2.watchlist.registered(), vec![key], diff --git a/crates/dig-wallet/src/sage/sources.rs b/crates/dig-wallet/src/sage/sources.rs index 5a05655b..2e45d3ba 100644 --- a/crates/dig-wallet/src/sage/sources.rs +++ b/crates/dig-wallet/src/sage/sources.rs @@ -1068,7 +1068,7 @@ mod sole_owner_tests { ) .expect("write the fixture"); - let roots = vec![("dig-node-core".to_string(), root.clone())]; + let roots = vec![("dig-node-core".to_string(), root.path().to_path_buf())]; let (sites, unread) = production_call_sites_in(&roots); std::fs::remove_dir_all(&root).expect("clean up the temporary root"); diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index ed8c5155..156f1e73 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -10,7 +10,6 @@ //! Only the socket is fake. use std::collections::VecDeque; -use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; @@ -772,7 +771,8 @@ fn peak_message(height: u32) -> Message { #[tokio::test] async fn supervisor_with_no_derivations_never_marks_initial_sync_complete() { let db = WalletDb::open_in_memory().await.unwrap(); - let custody = WalletCustody::open(scratch()); + let dir = scratch(); + let custody = WalletCustody::open(dir.path().to_path_buf()); assert!( custody.puzzle_hashes().is_empty(), "a fresh custody dir must yield no puzzle hashes" @@ -903,7 +903,7 @@ async fn a_default_install_with_no_wallet_settles_on_nothing_to_watch() { async fn supervisor_runs_catch_up_once_custody_has_keys() { let db = WalletDb::open_in_memory().await.unwrap(); let dir = scratch(); - let custody = enrolled_custody(&dir); + let custody = enrolled_custody(dir.path()); let mut expected: Vec = custody .custodied_public_keys() @@ -1339,11 +1339,11 @@ async fn an_enrolled_wallet_with_no_derivable_addresses_is_not_an_all_clear() { #[test] fn production_any_wallet_reads_the_manifest_not_the_derivable_keys() { let dir = scratch(); - enrolled_custody(&dir); + enrolled_custody(dir.path()); // Drop the manifest so the next construction must rebuild it from the seed files alone. No // seed is read, written, or inspected here — only the index beside them is removed. - let manifest = dir.join("wallets").join("index.json"); + let manifest = dir.path().join("wallets").join("index.json"); assert!( manifest.exists(), "the fixture must have written a manifest" @@ -1351,7 +1351,7 @@ fn production_any_wallet_reads_the_manifest_not_the_derivable_keys() { std::fs::remove_file(&manifest).expect("remove the manifest"); // A fresh custody over the same directory: seeds present, manifest rebuilt without keys. - let healed = WalletCustody::open(dir); + let healed = WalletCustody::open(dir.path().to_path_buf()); assert!( PuzzleHashSource::puzzle_hashes(&healed).is_empty(), @@ -3686,8 +3686,8 @@ fn registered_key(tag: u8) -> chia_bls::PublicKey { fn a_node_with_no_custody_follows_registered_keys() { let dir = scratch(); std::fs::create_dir_all(&dir).unwrap(); - let custody = WalletCustody::open(dir.clone()); - let registry = crate::sage::watchlist::WatchRegistry::new(&dir); + let custody = WalletCustody::open(dir.path().to_path_buf()); + let registry = crate::sage::watchlist::WatchRegistry::new(dir.path()); assert!( PuzzleHashSource::puzzle_hashes(&custody).is_empty(), "the premise: the node custodies nothing" @@ -3716,11 +3716,11 @@ fn a_node_with_no_custody_follows_registered_keys() { #[test] fn the_union_follows_custody_and_registered_keys_together() { let dir = scratch(); - let custody = enrolled_custody(&dir); + let custody = enrolled_custody(dir.path()); let custodied: Vec = PuzzleHashSource::puzzle_hashes(&custody); assert!(!custodied.is_empty(), "an enrolled wallet has public keys"); - let registry = crate::sage::watchlist::WatchRegistry::new(&dir); + let registry = crate::sage::watchlist::WatchRegistry::new(dir.path()); registry.watch(&[registered_key(2)]); let registered = puzzle_hash_for(®istered_key(2)); assert!( @@ -3753,9 +3753,9 @@ fn the_union_follows_custody_and_registered_keys_together() { fn unwatch_removes_the_address_from_the_subscription_set() { let dir = scratch(); std::fs::create_dir_all(&dir).unwrap(); - let registry = crate::sage::watchlist::WatchRegistry::new(&dir); + let registry = crate::sage::watchlist::WatchRegistry::new(dir.path()); registry.watch(&[registered_key(3), registered_key(4)]); - let union = UnionPuzzleHashSource::new(WalletCustody::open(dir.clone()), registry.clone()); + let union = UnionPuzzleHashSource::new(WalletCustody::open(dir.path().to_path_buf()), registry.clone()); assert_eq!(union.puzzle_hashes().len(), 2); registry.unwatch(&[registered_key(3)]); @@ -3766,8 +3766,8 @@ fn unwatch_removes_the_address_from_the_subscription_set() { "the deregistered address must leave the set the supervisor re-reads, and only it" ); let after_restart = UnionPuzzleHashSource::new( - WalletCustody::open(dir.clone()), - crate::sage::watchlist::WatchRegistry::new(&dir), + WalletCustody::open(dir.path().to_path_buf()), + crate::sage::watchlist::WatchRegistry::new(dir.path()), ); assert_eq!( after_restart.puzzle_hashes(), @@ -3783,14 +3783,14 @@ fn unwatch_removes_the_address_from_the_subscription_set() { #[test] fn a_key_held_by_both_sides_is_watched_once() { let dir = scratch(); - let custody = enrolled_custody(&dir); + let custody = enrolled_custody(dir.path()); let shared = *custody .custodied_public_keys() .iter() .next() .expect("an enrolled wallet has public keys"); - let registry = crate::sage::watchlist::WatchRegistry::new(&dir); + let registry = crate::sage::watchlist::WatchRegistry::new(dir.path()); registry.watch(&[shared]); let watched = UnionPuzzleHashSource::new(custody.clone(), registry).puzzle_hashes(); @@ -3811,8 +3811,8 @@ fn an_empty_union_still_reports_the_honest_no_wallet_state() { let dir = scratch(); std::fs::create_dir_all(&dir).unwrap(); let union = UnionPuzzleHashSource::new( - WalletCustody::open(dir.clone()), - crate::sage::watchlist::WatchRegistry::new(&dir), + WalletCustody::open(dir.path().to_path_buf()), + crate::sage::watchlist::WatchRegistry::new(dir.path()), ); assert!(union.puzzle_hashes().is_empty()); @@ -3831,14 +3831,14 @@ fn an_empty_union_still_reports_the_honest_no_wallet_state() { #[test] fn an_enrolled_but_unreachable_custody_is_not_an_all_clear_through_the_union() { let dir = scratch(); - enrolled_custody(&dir); + enrolled_custody(dir.path()); // Drop the manifest so it is rebuilt from the seed file alone, without public keys — one of the // four reachable states where an enrolled wallet derives no address. - std::fs::remove_file(dir.join("wallets").join("index.json")).expect("remove the manifest"); - let healed = WalletCustody::open(dir.clone()); + std::fs::remove_file(dir.path().join("wallets").join("index.json")).expect("remove the manifest"); + let healed = WalletCustody::open(dir.path().to_path_buf()); let union = - UnionPuzzleHashSource::new(healed, crate::sage::watchlist::WatchRegistry::new(&dir)); + UnionPuzzleHashSource::new(healed, crate::sage::watchlist::WatchRegistry::new(dir.path())); assert!( union.puzzle_hashes().is_empty(), diff --git a/crates/dig-wallet/src/sage/tipping.rs b/crates/dig-wallet/src/sage/tipping.rs index 9e8e4f77..b5f170f4 100644 --- a/crates/dig-wallet/src/sage/tipping.rs +++ b/crates/dig-wallet/src/sage/tipping.rs @@ -1348,7 +1348,7 @@ mod tests { let dir = scratch(); let owner = MockOwner::some(&owner_hex()); let sp = MockSpender::new(); - let eng = make(&dir, owner.clone(), sp, FixedClock::at(DAY0), test_config()).await; + let eng = make(dir.path(), owner.clone(), sp, FixedClock::at(DAY0), test_config()).await; // Two auto-tips for the same store on the same day: the first tips, the second is an // idempotent skip — but the owner is resolved only ONCE (cached). @@ -1362,7 +1362,7 @@ mod tests { let dir = scratch(); let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::none(), sp.clone(), FixedClock::at(DAY0), @@ -1383,7 +1383,7 @@ mod tests { cfg.creator.enabled = false; let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1402,7 +1402,7 @@ mod tests { let dir = scratch(); let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1447,7 +1447,7 @@ mod tests { let sp = MockSpender::new(); let clock = FixedClock::at(DAY0); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), clock.clone(), @@ -1481,7 +1481,7 @@ mod tests { { let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1498,7 +1498,7 @@ mod tests { // already reserved → SKIP, never a second spend. let sp2 = MockSpender::new(); let eng2 = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp2.clone(), FixedClock::at(DAY0), @@ -1525,7 +1525,7 @@ mod tests { cfg.daily_total_cap = 10_000; // not the binding constraint here let sp = MockSpender::new(); let clock = FixedClock::at(DAY0); - let eng = make(&dir, MockOwner::some(&owner_hex()), sp.clone(), clock, cfg).await; + let eng = make(dir.path(), MockOwner::some(&owner_hex()), sp.clone(), clock, cfg).await; // First store → owner tipped. A DIFFERENT store with the SAME owner the same day would // exceed the per-site cap (idempotency already blocks the same store; force a manual-style @@ -1536,8 +1536,9 @@ mod tests { cfg2.creator.dig_amount = 200; cfg2.creator.per_site_cap = 100; // 200 > 100 → blocked let sp2 = MockSpender::new(); + let dir2 = scratch(); let eng2 = make( - &scratch(), + dir2.path(), MockOwner::some(&owner_hex()), sp2.clone(), FixedClock::at(DAY0), @@ -1581,7 +1582,7 @@ mod tests { ("s2".into(), b.clone()), ("s3".into(), c.clone()), ]); - let eng = make(&dir, resolver, sp.clone(), clock, cfg).await; + let eng = make(dir.path(), resolver, sp.clone(), clock, cfg).await; assert!(matches!( eng.auto_tip_for_store("s1").await.unwrap(), @@ -1613,7 +1614,7 @@ mod tests { let sp = MockSpender::new(); let clock = FixedClock::at(DAY0); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), clock.clone(), @@ -1670,7 +1671,7 @@ mod tests { cfg.daily_total_cap = 150; // one 100 tip fits; the second (creator OR dev) is blocked let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1700,7 +1701,7 @@ mod tests { let sp = MockSpender::new(); sp.set(SpendBehaviour::NotExecutable); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1728,7 +1729,7 @@ mod tests { let sp = MockSpender::new(); sp.set(SpendBehaviour::Ambiguous); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1757,7 +1758,7 @@ mod tests { let sp = MockSpender::new(); sp.set(SpendBehaviour::BroadcastUnconfirmed); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1793,7 +1794,7 @@ mod tests { cfg.daily_total_cap = 0; // and it ignores the auto daily cap let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1829,7 +1830,7 @@ mod tests { cfg.creator.dig_amount = 777; { let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1840,7 +1841,7 @@ mod tests { } // A fresh engine over the same dir reads the persisted config. let eng2 = TippingEngine::load( - &dir, + dir.path(), Box::new(MockOwner::some(&owner_hex())), Box::new(sp), Box::new(FixedClock::at(DAY0)), @@ -1874,10 +1875,10 @@ mod tests { #[tokio::test] async fn present_but_corrupt_ledger_fails_closed_no_retip() { let dir = scratch(); - std::fs::write(dir.join("tip-ledger.json"), b"{ this is not valid json ]").unwrap(); + std::fs::write(dir.path().join("tip-ledger.json"), b"{ this is not valid json ]").unwrap(); let sp = MockSpender::new(); let eng = load_only( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1906,10 +1907,10 @@ mod tests { #[tokio::test] async fn truncated_zero_length_ledger_fails_closed() { let dir = scratch(); - std::fs::write(dir.join("tip-ledger.json"), b"").unwrap(); // zero-length + std::fs::write(dir.path().join("tip-ledger.json"), b"").unwrap(); // zero-length let sp = MockSpender::new(); let eng = load_only( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1930,7 +1931,7 @@ mod tests { { let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -1944,10 +1945,10 @@ mod tests { assert_eq!(sp.call_count(), 1); } // Corrupt the persisted ledger (e.g. AV/indexer lock recovery, partial write). - std::fs::write(dir.join("tip-ledger.json"), b"\x00\x00corrupt").unwrap(); + std::fs::write(dir.path().join("tip-ledger.json"), b"\x00\x00corrupt").unwrap(); let sp2 = MockSpender::new(); let eng2 = load_only( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp2.clone(), FixedClock::at(DAY0), @@ -1970,10 +1971,10 @@ mod tests { #[tokio::test] async fn present_but_corrupt_config_does_not_reenable_autotip() { let dir = scratch(); - std::fs::write(dir.join("tipping-config.json"), b"{ not: valid").unwrap(); + std::fs::write(dir.path().join("tipping-config.json"), b"{ not: valid").unwrap(); let sp = MockSpender::new(); let eng = load_only( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -2002,7 +2003,7 @@ mod tests { let dir = scratch(); // fresh, empty dir — no config or ledger files let sp = MockSpender::new(); let eng = load_only( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -2024,7 +2025,7 @@ mod tests { { let sp = MockSpender::new(); let eng = make( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp.clone(), FixedClock::at(DAY0), @@ -2039,7 +2040,7 @@ mod tests { // Reload: the persisted ledger parses (not poisoned) and the day is already tipped. let sp2 = MockSpender::new(); let eng2 = load_only( - &dir, + dir.path(), MockOwner::some(&owner_hex()), sp2.clone(), FixedClock::at(DAY0), diff --git a/crates/dig-wallet/src/sage/watchlist.rs b/crates/dig-wallet/src/sage/watchlist.rs index be87872b..66d78aec 100644 --- a/crates/dig-wallet/src/sage/watchlist.rs +++ b/crates/dig-wallet/src/sage/watchlist.rs @@ -275,7 +275,7 @@ mod tests { #[test] fn registers_and_reports_what_was_registered() { let d = dir("register"); - let r = WatchRegistry::new(&d); + let r = WatchRegistry::new(d.path()); assert!(r.is_empty(), "a fresh node registers nothing"); assert_eq!(r.watch(&[key(1), key(2)]), 2); @@ -287,7 +287,7 @@ mod tests { #[test] fn watch_is_idempotent() { let d = dir("idempotent"); - let r = WatchRegistry::new(&d); + let r = WatchRegistry::new(d.path()); r.watch(&[key(1)]); assert_eq!(r.watch(&[key(1)]), 0, "a known key adds nothing"); @@ -298,9 +298,9 @@ mod tests { #[test] fn survives_a_restart() { let d = dir("persist"); - WatchRegistry::new(&d).watch(&[key(1), key(2)]); + WatchRegistry::new(d.path()).watch(&[key(1), key(2)]); - let reopened = WatchRegistry::new(&d); + let reopened = WatchRegistry::new(d.path()); assert_eq!(reopened.registered(), vec_sorted(&[key(1), key(2)])); } @@ -310,7 +310,7 @@ mod tests { #[test] fn unwatch_stops_following_live_and_after_restart() { let d = dir("unwatch"); - let r = WatchRegistry::new(&d); + let r = WatchRegistry::new(d.path()); r.watch(&[key(1), key(2)]); assert_eq!(r.unwatch(&[key(1)]), 1); @@ -320,7 +320,7 @@ mod tests { "the live set drops only key 1" ); assert_eq!( - WatchRegistry::new(&d).registered(), + WatchRegistry::new(d.path()).registered(), vec![key(2)], "and a restart does not resurrect it" ); @@ -330,7 +330,7 @@ mod tests { #[test] fn unwatch_of_an_unregistered_key_removes_nothing() { let d = dir("unwatch-unknown"); - let r = WatchRegistry::new(&d); + let r = WatchRegistry::new(d.path()); r.watch(&[key(1)]); assert_eq!(r.unwatch(&[key(9)]), 0); @@ -342,7 +342,7 @@ mod tests { #[test] fn a_clone_sees_a_registration_made_through_another_handle() { let d = dir("clone"); - let handler = WatchRegistry::new(&d); + let handler = WatchRegistry::new(d.path()); let supervisor = handler.clone(); assert!(supervisor.is_empty()); @@ -366,9 +366,9 @@ mod tests { #[test] fn a_corrupt_file_yields_an_empty_registry() { let d = dir("corrupt"); - std::fs::write(d.join(WATCHLIST_FILE), b"{not json").unwrap(); + std::fs::write(d.path().join(WATCHLIST_FILE), b"{not json").unwrap(); - assert!(WatchRegistry::new(&d).is_empty()); + assert!(WatchRegistry::new(d.path()).is_empty()); } /// The given keys in the registry's own stable (G1 byte) order. diff --git a/crates/dig-wallet/src/seed_export.rs b/crates/dig-wallet/src/seed_export.rs index adc4c169..e8b6853b 100644 --- a/crates/dig-wallet/src/seed_export.rs +++ b/crates/dig-wallet/src/seed_export.rs @@ -156,7 +156,7 @@ mod tests { #[test] fn legacy_seed_file_exports() { let dir = scratch("legacy"); - let path = write_legacy_fixture(&dir, &password("legacy")); + let path = write_legacy_fixture(dir.path(), &password("legacy")); let recovered = export_mnemonic(&path, &password("legacy")).expect("legacy blob must export"); @@ -170,7 +170,7 @@ mod tests { #[test] fn current_format_seed_file_also_exports() { let dir = scratch("current"); - let path = dir.join("seed.bin"); + let path = dir.path().join("seed.bin"); let bytes = crate::seed_store::encrypt_seed(PHRASE, &password("current")).expect("current encrypt"); assert_ne!( @@ -193,7 +193,7 @@ mod tests { #[test] fn explicit_path_reaches_a_non_default_location() { let dir = scratch("override"); - let path = write_legacy_fixture(&dir, &password("fixture")); + let path = write_legacy_fixture(dir.path(), &password("fixture")); assert_ne!( path, default_seed_path(), @@ -213,7 +213,7 @@ mod tests { #[test] fn wrong_password_fails_without_leaking() { let dir = scratch("wrongpw"); - let path = write_legacy_fixture(&dir, &password("right")); + let path = write_legacy_fixture(dir.path(), &password("right")); let err = export_mnemonic(&path, &password("wrong")).expect_err("a wrong password must fail"); @@ -233,7 +233,7 @@ mod tests { #[test] fn missing_file_is_reported_as_missing() { let dir = scratch("missing"); - let path = dir.join("nothing-here.bin"); + let path = dir.path().join("nothing-here.bin"); let err = export_mnemonic(&path, &password("fixture")).expect_err("an absent file must fail"); @@ -247,7 +247,7 @@ mod tests { #[test] fn export_leaves_the_file_byte_identical() { let dir = scratch("readonly"); - let path = write_legacy_fixture(&dir, &password("fixture")); + let path = write_legacy_fixture(dir.path(), &password("fixture")); let before = std::fs::read(&path).expect("read before"); export_mnemonic(&path, &password("fixture")).expect("export"); From 49624d90ff39316d57d25597d029481e898c84c9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 23:02:35 -0700 Subject: [PATCH 04/21] fix(test): own control, state, pairing and census scratch dirs with tempfile::TempDir --- .../dig-node-service/src/collateral_census.rs | 19 ++- crates/dig-node-service/src/control.rs | 144 ++++++++---------- crates/dig-node-service/src/pairing.rs | 23 +-- .../dig-node-service/src/spend_audit_cli.rs | 23 +-- crates/dig-node-service/src/state.rs | 54 +++---- .../collateral_census_degraded_source.rs | 19 ++- 6 files changed, 129 insertions(+), 153 deletions(-) diff --git a/crates/dig-node-service/src/collateral_census.rs b/crates/dig-node-service/src/collateral_census.rs index f57cfad2..4e8144b7 100644 --- a/crates/dig-node-service/src/collateral_census.rs +++ b/crates/dig-node-service/src/collateral_census.rs @@ -651,16 +651,15 @@ mod tests { } /// A store in a fresh temp dir, holding only the genesis record the bring-up writes. - fn seeded_store(name: &str) -> (EpochRecordStore, std::path::PathBuf) { - let dir = std::env::temp_dir().join(format!( - "dig-node-census-{name}-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::create_dir_all(&dir).expect("create the scratch dir"); - let store = EpochRecordStore::at(dir.join("epochs.jsonl")); + /// The guard is returned with the store, which writes into the tree for as long as + /// the caller holds it; `TempDir`'s `Drop` then removes it, including on an unwind + /// (dig-node#370). + fn seeded_store(name: &str) -> (EpochRecordStore, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix(&format!("dig-node-census-{name}-")) + .tempdir() + .expect("create the scratch dir"); + let store = EpochRecordStore::at(dir.path().join("epochs.jsonl")); store .put(&StoredRecord::bootstrap()) .expect("seed the genesis record"); diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 20d04d79..30f5cd54 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -5948,18 +5948,19 @@ mod tests { fn token_mint_fails_closed_when_it_cannot_be_persisted() { // A path whose parent is a FILE, so `ensure_dir_restricted` / write cannot // create the token → `generate_token()?` / `write` returns Err, never a token. - let file = std::env::temp_dir().join(format!( - "dig-node-failclosed-{}-{}", - std::process::id(), - line!() - )); + // The scratch tree is owned by the guard (dig-node#370); the fixture the test needs + // is a regular FILE inside it, standing in for the non-directory parent. + let scratch = tempfile::Builder::new() + .prefix("dig-node-failclosed-") + .tempdir() + .expect("a scratch dir"); + let file = scratch.path().join("not-a-dir"); std::fs::write(&file, b"not a dir").unwrap(); let bogus = file.join("sub").join(CONTROL_TOKEN_FILE); assert!( load_or_create_token_at(&bogus).is_err(), "must fail closed (propagate), never return a usable token when it cannot mint+persist" ); - let _ = std::fs::remove_file(&file); } /// **Proves (dig-node#255):** a PAIRED token cannot reach `control.config.setUpstream`, while @@ -6036,18 +6037,16 @@ mod tests { #[test] fn load_or_create_token_persists_and_is_stable() { - let dir = std::env::temp_dir().join(format!( - "dig-node-token-test-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-token-test-") + .tempdir() + .expect("a scratch dir"); let path = dir.join(CONTROL_TOKEN_FILE); - let _ = std::fs::remove_dir_all(&dir); let first = load_or_create_token_at(&path).unwrap(); let second = load_or_create_token_at(&path).unwrap(); assert_eq!(first, second, "token must be stable across reads"); assert_eq!(first.len(), 64); - let _ = std::fs::remove_dir_all(&dir); } /// SECURITY (#501 residual, dig-node#355): a pre-existing control-token file that is NOT @@ -6072,13 +6071,12 @@ mod tests { fn foreign_owned_token_file_is_regenerated_not_trusted() { use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!( - "dig-node-token-untrusted-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-token-untrusted-") + .tempdir() + .expect("a scratch dir"); let path = dir.join(CONTROL_TOKEN_FILE); - let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let planted = "planted0".repeat(8); // a KNOWN 64-char attacker value (non-empty) std::fs::write(&path, &planted).unwrap(); @@ -6141,7 +6139,6 @@ mod tests { "the regenerated token must be owner-only 0600 (got {mode:o})" ); } - let _ = std::fs::remove_dir_all(&dir); } /// The trust RULE itself, asserted independently of whichever uid the suite happens to run @@ -6190,13 +6187,12 @@ mod tests { #[test] fn trusted_owner_only_token_file_is_kept() { use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!( - "dig-node-token-trusted-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-token-trusted-") + .tempdir() + .expect("a scratch dir"); let path = dir.join(CONTROL_TOKEN_FILE); - let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let existing = "a".repeat(64); std::fs::write(&path, &existing).unwrap(); @@ -6206,7 +6202,6 @@ mod tests { got, existing, "a trusted owner-only token must be loaded as-is, not regenerated" ); - let _ = std::fs::remove_dir_all(&dir); } /// SECURITY (#501): the control token grants full local control, so the created @@ -6218,13 +6213,12 @@ mod tests { #[test] fn created_token_file_is_not_world_or_group_readable() { use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!( - "dig-node-token-perms-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-token-perms-") + .tempdir() + .expect("a scratch dir"); let path = dir.join(CONTROL_TOKEN_FILE); - let _ = std::fs::remove_dir_all(&dir); load_or_create_token_at(&path).unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!( @@ -6232,7 +6226,6 @@ mod tests { 0, "token must have NO group/other permission bits (got {mode:o})" ); - let _ = std::fs::remove_dir_all(&dir); } /// The remedy hint names the concrete token path and, when the token is absent from @@ -6311,20 +6304,18 @@ mod tests { /// the token); a fresh mint must always be readable at its own path. #[test] fn service_mint_then_cli_read_round_trip() { - let dir = std::env::temp_dir().join(format!( - "dig-node-token-roundtrip-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-token-roundtrip-") + .tempdir() + .expect("a scratch dir"); let path = dir.join(CONTROL_TOKEN_FILE); - let _ = std::fs::remove_dir_all(&dir); let minted = load_or_create_token_at(&path).unwrap(); let read_back = read_token_readonly_at(&path).unwrap(); assert_eq!( minted, read_back, "the CLI read must return the exact token the service minted" ); - let _ = std::fs::remove_dir_all(&dir); } /// #856 mint-on-startup guarantee: minting on a PRE-EXISTING (already-created, tokenless) @@ -6333,12 +6324,11 @@ mod tests { /// half-hardened/recreated dir in place and always converging to a minted token. #[test] fn mint_on_a_pre_existing_dir_is_idempotent() { - let dir = std::env::temp_dir().join(format!( - "dig-node-mint-idempotent-{}-{}", - std::process::id(), - line!() - )); - let _ = std::fs::remove_dir_all(&dir); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-mint-idempotent-") + .tempdir() + .expect("a scratch dir"); // The dir pre-exists but holds NO token (a freshly (re)created state dir). std::fs::create_dir_all(&dir).unwrap(); let path = dir.join(CONTROL_TOKEN_FILE); @@ -6353,20 +6343,18 @@ mod tests { first, second, "mint-on-startup must be idempotent — a re-secure of an existing dir keeps the token" ); - let _ = std::fs::remove_dir_all(&dir); } /// A genuinely-absent token reads as `NotFound` with the "no control token found" remedy that /// now ALSO names the stale-service reinstall recovery (#772). #[test] fn read_readonly_reports_absent_token_as_not_found() { - let dir = std::env::temp_dir().join(format!( - "dig-node-token-absent-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-token-absent-") + .tempdir() + .expect("a scratch dir"); let path = dir.join(CONTROL_TOKEN_FILE); - let _ = std::fs::remove_dir_all(&dir); let err = read_token_readonly_at(&path).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::NotFound); let msg = err.to_string(); @@ -6385,13 +6373,12 @@ mod tests { #[test] fn unreadable_token_maps_to_permission_denied_not_not_found() { use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!( - "dig-node-token-denied-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-token-denied-") + .tempdir() + .expect("a scratch dir"); let path = dir.join(CONTROL_TOKEN_FILE); - let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(&path, "a".repeat(64)).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); @@ -6417,7 +6404,6 @@ mod tests { ); } let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -6435,13 +6421,12 @@ mod tests { #[test] fn pin_registry_roundtrips_and_is_idempotent() { - let dir = std::env::temp_dir().join(format!( - "dig-node-pins-test-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-pins-test-") + .tempdir() + .expect("a scratch dir"); let config_path = dir.join("config.json"); - let _ = std::fs::remove_dir_all(&dir); let store = "c".repeat(64); let root = "d".repeat(64); @@ -6458,20 +6443,18 @@ mod tests { assert!(read_pins_from(&config_path).is_empty()); // Removing an absent pin is a no-op false. assert!(!remove_pin(&config_path, &store).unwrap()); - let _ = std::fs::remove_dir_all(&dir); } #[test] fn update_config_preserves_dig_node_keys() { // This service's pin/upstream writes must NOT clobber dig-node's own keys // in the shared config.json (cache_cap_bytes, wc_project_id). - let dir = std::env::temp_dir().join(format!( - "dig-node-config-merge-test-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-config-merge-test-") + .tempdir() + .expect("a scratch dir"); let config_path = dir.join("config.json"); - let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( &config_path, @@ -6490,18 +6473,16 @@ mod tests { assert_eq!(v["wc_project_id"], json!("abc"), "dig-node key preserved"); assert_eq!(v["pinned_stores"][0]["store_id"], json!(store)); assert_eq!(v["upstream_override"], json!("https://example.test")); - let _ = std::fs::remove_dir_all(&dir); } #[test] fn upstream_override_roundtrips_and_clears() { - let dir = std::env::temp_dir().join(format!( - "dig-node-upstream-test-{}-{}", - std::process::id(), - line!() - )); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-node-upstream-test-") + .tempdir() + .expect("a scratch dir"); let config_path = dir.join("config.json"); - let _ = std::fs::remove_dir_all(&dir); assert_eq!(read_upstream_override_from(&config_path), None); set_upstream_override(&config_path, "https://up.test").unwrap(); assert_eq!( @@ -6511,7 +6492,6 @@ mod tests { // Blank clears it. set_upstream_override(&config_path, " ").unwrap(); assert_eq!(read_upstream_override_from(&config_path), None); - let _ = std::fs::remove_dir_all(&dir); } /// (#1851 leg-2) `control.wallet.balance` MUST emit `balance`/`pending` as JSON **numbers**, diff --git a/crates/dig-node-service/src/pairing.rs b/crates/dig-node-service/src/pairing.rs index f98f2c66..9f1c3a10 100644 --- a/crates/dig-node-service/src/pairing.rs +++ b/crates/dig-node-service/src/pairing.rs @@ -518,21 +518,14 @@ mod tests { /// A unique temp STATE dir (#501: the paired-token store now lives in the state /// dir, not beside a `config.json`). Returns `(state_dir, state_dir)` so both /// tuple bindings point at the dir a test seeds + cleans. - fn tmp_config() -> (PathBuf, PathBuf) { - // A process-wide counter makes the dir unique even when two tests build it in - // the same millisecond (parallel test threads) — otherwise one test's - // remove_dir_all could nuke another's dir mid-run. - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "dig-node-pairing-{}-{}-{}", - std::process::id(), - now_ms(), - n - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - (dir.clone(), dir) + /// The tree is OWNED by the returned guard: `TempDir`'s `Drop` removes it, including on + /// an unwind, so a failing assertion cannot leak it (dig-node#370). `tempfile`'s random + /// component also subsumes the hand-rolled pid + counter name, which repeated across runs. + fn tmp_config() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("dig-node-pairing-") + .tempdir() + .expect("a scratch dir") } fn pending() -> Mutex { diff --git a/crates/dig-node-service/src/spend_audit_cli.rs b/crates/dig-node-service/src/spend_audit_cli.rs index 5e9139f4..b0dc9fba 100644 --- a/crates/dig-node-service/src/spend_audit_cli.rs +++ b/crates/dig-node-service/src/spend_audit_cli.rs @@ -297,13 +297,16 @@ mod tests { NOW } - fn tmp_log() -> SpendLog { - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let dir = - std::env::temp_dir().join(format!("dig-node-spends-cli-{}-{}", std::process::id(), n)); - std::fs::create_dir_all(&dir).expect("temp dir"); - SpendLog::at(dir.join("spend-audit.jsonl")) + /// The guard comes back with the log: the `SpendLog` writes into the directory for as + /// long as the caller holds it, so the tree must outlive this function. `TempDir`'s `Drop` + /// then removes it when the test ends, including on an unwind (dig-node#370). + fn tmp_log() -> (SpendLog, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix("dig-node-spends-cli-") + .tempdir() + .expect("temp dir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + (log, dir) } fn intent(kind: &str, store: Option<&str>) -> SpendIntent { @@ -324,7 +327,7 @@ mod tests { /// A log holding one confirmed mirror-coin spend and one failed one. fn seeded_log() -> SpendLog { - let log = tmp_log(); + let (log, _dir) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let ok = journal.begin(intent(kinds::MIRROR_COIN, Some("store-a"))); @@ -403,7 +406,7 @@ mod tests { /// marker fails instead of matching by luck. #[test] fn an_expected_coin_is_marked_differently_from_a_confirmed_one() { - let log = tmp_log(); + let (log, _dir) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let pending = journal.begin(intent(kinds::MIRROR_COIN, Some("s1"))); @@ -614,7 +617,7 @@ mod tests { /// the spends a person opened the command to see. #[test] fn a_limit_keeps_the_newest_rows() { - let log = tmp_log(); + let (log, _dir) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); // Distinct initiated_ms so "newest" is well defined rather than a tiebreak. for (i, store) in ["old", "new"].iter().enumerate() { diff --git a/crates/dig-node-service/src/state.rs b/crates/dig-node-service/src/state.rs index a8c59448..0c026d96 100644 --- a/crates/dig-node-service/src/state.rs +++ b/crates/dig-node-service/src/state.rs @@ -1375,13 +1375,17 @@ mod tests { #[test] fn ensure_dir_restricted_is_0700_and_not_group_or_world_accessible() { use std::os::unix::fs::PermissionsExt; - let dir = - std::env::temp_dir().join(format!("dig-state-dir-{}-{}", std::process::id(), line!())); - let _ = std::fs::remove_dir_all(&dir); + // The property under test is what `ensure_dir_restricted` does when it CREATES the directory, + // so the path handed to it must not exist yet. The guard owns the PARENT, which keeps + // that intact while still removing the tree on drop and on an unwind (dig-node#370). + let parent = tempfile::Builder::new() + .prefix("dig-state-dir-") + .tempdir() + .expect("a scratch parent"); + let dir = parent.path().join("state"); ensure_dir_restricted(&dir).unwrap(); let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "state dir must be owner-only (got {mode:o})"); - let _ = std::fs::remove_dir_all(&dir); } // -- control-token FILE owner verification (#501 residual) ------------------ @@ -1433,14 +1437,12 @@ mod tests { fn token_file_is_trusted_rejects_a_service_run_on_a_user_owned_file() { // A file THIS (interactive-user) process creates is owned by the current user. A // NON-service run trusts it; a SERVICE run does NOT (it requires SYSTEM/Administrators). - let dir = std::env::temp_dir().join(format!( - "dig-token-owner-{}-{}", - std::process::id(), - line!() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("control-token"); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-token-owner-") + .tempdir() + .expect("a scratch dir"); + let path = dir.path().join("control-token"); std::fs::write(&path, b"deadbeef").unwrap(); assert!( token_file_is_trusted(&path, false), @@ -1450,7 +1452,6 @@ mod tests { !token_file_is_trusted(&path, true), "a service run must NOT trust a user-owned token" ); - let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] @@ -1458,14 +1459,12 @@ mod tests { fn token_file_is_trusted_accepts_0600_rejects_group_readable() { use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!( - "dig-token-owner-{}-{}", - std::process::id(), - line!() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("control-token"); + // Owned by the guard: removed on drop and on an unwind (dig-node#370). + let dir = tempfile::Builder::new() + .prefix("dig-token-owner-") + .tempdir() + .expect("a scratch dir"); + let path = dir.path().join("control-token"); std::fs::write(&path, b"deadbeef").unwrap(); // A 0600 file owned by the test user (== current euid) is trusted. std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); @@ -1508,19 +1507,22 @@ mod tests { "a group/other-readable token is not trusted" ); } - let _ = std::fs::remove_dir_all(&dir); } #[cfg(unix)] #[test] fn harden_state_dir_sets_0700_on_unix() { use std::os::unix::fs::PermissionsExt; - let dir = - std::env::temp_dir().join(format!("dig-harden-dir-{}-{}", std::process::id(), line!())); - let _ = std::fs::remove_dir_all(&dir); + // The property under test is what `harden_state_dir` does when it CREATES the directory, + // so the path handed to it must not exist yet. The guard owns the PARENT, which keeps + // that intact while still removing the tree on drop and on an unwind (dig-node#370). + let parent = tempfile::Builder::new() + .prefix("dig-harden-dir-") + .tempdir() + .expect("a scratch parent"); + let dir = parent.path().join("state"); harden_state_dir(&dir, None).unwrap(); let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "harden must leave the dir 0700 (got {mode:o})"); - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/dig-node-service/tests/collateral_census_degraded_source.rs b/crates/dig-node-service/tests/collateral_census_degraded_source.rs index 19305fd9..9f2979c7 100644 --- a/crates/dig-node-service/tests/collateral_census_degraded_source.rs +++ b/crates/dig-node-service/tests/collateral_census_degraded_source.rs @@ -178,16 +178,15 @@ impl ChainSource for Chain { } /// A store in a fresh temp dir holding only the epoch-1 record the bring-up writes. -fn seeded_store(name: &str) -> (EpochRecordStore, std::path::PathBuf) { - let dir = std::env::temp_dir().join(format!( - "dig-node-405-{name}-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - std::fs::create_dir_all(&dir).expect("temp dir"); - let store = EpochRecordStore::at(dir.join("collateral-epochs.jsonl")); +/// The guard is returned with the store, which writes into the tree for as long as +/// the caller holds it; `TempDir`'s `Drop` then removes it, including on an unwind +/// (dig-node#370). +fn seeded_store(name: &str) -> (EpochRecordStore, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix(&format!("dig-node-405-{name}-")) + .tempdir() + .expect("temp dir"); + let store = EpochRecordStore::at(dir.path().join("collateral-epochs.jsonl")); store .put(&StoredRecord::bootstrap()) .expect("seed the genesis record"); From 9b99114d7d27ab265821ca528d72016fa428f80e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 23:06:07 -0700 Subject: [PATCH 05/21] fix(test): own updater, beacon, drift-guard and logging scratch trees --- crates/dig-node-service/src/updater.rs | 30 ++++++++++--------- .../tests/beacon_cli_process.rs | 26 +++++++++------- .../tests/logging_degraded.rs | 16 +++++++--- .../mirror_funding_reservation_expiry.rs | 18 +++++------ .../tests/openrpc_drift_guard.rs | 27 ++++++++--------- .../dig-node-service/tests/spend_audit_e2e.rs | 13 ++++---- crates/dig-runtime/src/lib.rs | 20 +++++++++++++ 7 files changed, 92 insertions(+), 58 deletions(-) diff --git a/crates/dig-node-service/src/updater.rs b/crates/dig-node-service/src/updater.rs index 8ff93183..f11a221d 100644 --- a/crates/dig-node-service/src/updater.rs +++ b/crates/dig-node-service/src/updater.rs @@ -344,12 +344,18 @@ mod tests { /// A unique-per-call scratch path, so concurrent test RUNS (across `cargo test` /// invocations) never collide even though tests within this file are serialized. - fn unique_path(label: &str) -> PathBuf { - let n = SEQ.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "dig-node-updater-test-{label}-{}-{n}", - std::process::id() - )) + /// + /// The returned guard owns the path's PARENT rather than the path itself, because one + /// caller's whole point is that the status dir is ABSENT — a guard over the directory + /// would create it and quietly retire that test. The parent still removes the tree on + /// drop and on an unwind (dig-node#370). + fn unique_path(label: &str) -> (tempfile::TempDir, PathBuf) { + let parent = tempfile::Builder::new() + .prefix("dig-node-updater-test-") + .tempdir() + .expect("a scratch parent"); + let path = parent.path().join(label); + (parent, path) } // -- status: read directly off disk -------------------------------------------------- @@ -357,8 +363,7 @@ mod tests { #[test] fn status_reports_not_installed_when_status_json_is_absent() { let _guard = env_guard().blocking_lock(); - let dir = unique_path("absent"); - let _ = std::fs::remove_dir_all(&dir); + let (_scratch, dir) = unique_path("absent"); std::env::set_var(STATUS_DIR_ENV, &dir); let resp = status(json!(1)); @@ -371,7 +376,7 @@ mod tests { #[test] fn status_returns_the_file_verbatim_when_present() { let _guard = env_guard().blocking_lock(); - let dir = unique_path("present"); + let (_scratch, dir) = unique_path("present"); std::fs::create_dir_all(&dir).unwrap(); std::env::set_var(STATUS_DIR_ENV, &dir); let body = json!({ "schema": 1, "version": "0.6.0", "channel": "alpha" }); @@ -381,14 +386,13 @@ mod tests { assert_eq!(resp["result"]["installed"], json!(true)); assert_eq!(resp["result"]["status"], body); - let _ = std::fs::remove_dir_all(&dir); std::env::remove_var(STATUS_DIR_ENV); } #[test] fn status_reports_a_control_error_on_corrupt_json_not_not_installed() { let _guard = env_guard().blocking_lock(); - let dir = unique_path("corrupt"); + let (_scratch, dir) = unique_path("corrupt"); std::fs::create_dir_all(&dir).unwrap(); std::env::set_var(STATUS_DIR_ENV, &dir); std::fs::write(dir.join("status.json"), b"{ not json").unwrap(); @@ -396,7 +400,6 @@ mod tests { let resp = status(json!(1)); assert_eq!(resp["error"]["data"]["code"], json!("CONTROL_ERROR")); - let _ = std::fs::remove_dir_all(&dir); std::env::remove_var(STATUS_DIR_ENV); } @@ -449,7 +452,7 @@ mod tests { // Force an empty resolution dir so the test is hermetic: no real dig-updater will ever // be found, even if one is installed on the machine. This ensures the test reliably // passes by failing binary resolution, not by assuming it doesn't exist locally. - let empty_dir = unique_path("empty-resolution"); + let (_scratch, empty_dir) = unique_path("empty-resolution"); let _ = std::fs::create_dir_all(&empty_dir); std::env::set_var(CLI_BIN_ENV, empty_dir.join("dig-updater")); // Override to a path that // won't exist @@ -475,7 +478,6 @@ mod tests { ); } - let _ = std::fs::remove_dir_all(&empty_dir); std::env::remove_var(CLI_BIN_ENV); std::env::remove_var(CLI_BIN_RESOLUTION_DIRS_ENV); } diff --git a/crates/dig-node-service/tests/beacon_cli_process.rs b/crates/dig-node-service/tests/beacon_cli_process.rs index 72c9cf96..01e598bc 100644 --- a/crates/dig-node-service/tests/beacon_cli_process.rs +++ b/crates/dig-node-service/tests/beacon_cli_process.rs @@ -34,12 +34,16 @@ fn env_guard() -> &'static Mutex<()> { static SEQ: AtomicU64 = AtomicU64::new(0); /// A unique-per-call scratch path for the fixture's `FAKE_UPDATER_ARGS_FILE` capture. -fn unique_path(label: &str) -> PathBuf { - let n = SEQ.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "dig-node-updater-cli-test-{label}-{}-{n}", - std::process::id() - )) +/// +/// The guard owns the directory the file lands in, so the capture is removed on drop and on +/// an unwind (dig-node#370) — this harness used to leak one file per call, forever. +fn unique_path(label: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::Builder::new() + .prefix("dig-node-updater-cli-test-") + .tempdir() + .expect("a scratch dir"); + let path = dir.path().join(label); + (dir, path) } fn fake_cli_path() -> PathBuf { @@ -66,7 +70,7 @@ async fn set_channel_runs_the_cli_and_returns_its_json_verbatim() { "FAKE_UPDATER_STDOUT", r#"{"command":"channel","channel":"alpha"}"#, ); - let args_file = unique_path("args-setchannel"); + let (_scratch, args_file) = unique_path("args-setchannel"); std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); let resp = set_channel(json!(1), &json!({ "channel": "alpha" })).await; @@ -97,7 +101,7 @@ async fn set_channel_forwards_every_channel_token_verbatim() { "FAKE_UPDATER_STDOUT", format!(r#"{{"command":"channel","channel":"{channel}"}}"#), ); - let args_file = unique_path(&format!("args-setchannel-{channel}")); + let (_scratch, args_file) = unique_path(&format!("args-setchannel-{channel}")); std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); let resp = set_channel(json!(1), &json!({ "channel": channel })).await; @@ -123,7 +127,7 @@ async fn pause_with_until_passes_the_flag_through() { "FAKE_UPDATER_STDOUT", r#"{"command":"pause","paused":true,"paused_until":500}"#, ); - let args_file = unique_path("args-pause"); + let (_scratch, args_file) = unique_path("args-pause"); std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); let resp = pause(json!(1), &json!({ "until": 500 })).await; @@ -148,7 +152,7 @@ async fn resume_runs_with_no_extra_flags() { "FAKE_UPDATER_STDOUT", r#"{"command":"pause","paused":false,"paused_until":null}"#, ); - let args_file = unique_path("args-resume"); + let (_scratch, args_file) = unique_path("args-resume"); std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); let resp = resume(json!(1)).await; @@ -172,7 +176,7 @@ async fn check_now_forwards_the_pass_report_on_success() { "FAKE_UPDATER_STDOUT", r#"{"applied":false,"reason":"paused","detail":null,"components":[],"state_advanced":false}"#, ); - let args_file = unique_path("args-checknow"); + let (_scratch, args_file) = unique_path("args-checknow"); std::env::set_var("FAKE_UPDATER_ARGS_FILE", &args_file); let resp = check_now(json!(1)).await; diff --git a/crates/dig-node-service/tests/logging_degraded.rs b/crates/dig-node-service/tests/logging_degraded.rs index ef1bd200..bb9ba98a 100644 --- a/crates/dig-node-service/tests/logging_degraded.rs +++ b/crates/dig-node-service/tests/logging_degraded.rs @@ -26,16 +26,24 @@ use dig_node_service::logging; use tracing::level_filters::LevelFilter; /// A log-dir root that cannot be created: a path nested inside a regular file. -fn unopenable_log_root() -> std::path::PathBuf { - let base = std::env::temp_dir().join(format!("dig-node-logtest-{}", std::process::id())); +/// +/// The guard owns the scratch tree the blocking file sits in, so it is removed on drop and +/// on an unwind (dig-node#370); the caller holds it for the test's duration. +fn unopenable_log_root() -> (tempfile::TempDir, std::path::PathBuf) { + let scratch = tempfile::Builder::new() + .prefix("dig-node-logtest-") + .tempdir() + .expect("a scratch dir"); + let base = scratch.path().join("blocking-file"); let mut file = std::fs::File::create(&base).expect("create the blocking regular file"); file.write_all(b"not a directory").unwrap(); - base.join("root") + let root = base.join("root"); + (scratch, root) } #[test] fn unwritable_log_dir_leaves_console_logging_live_and_the_file_sink_reported_off() { - let root = unopenable_log_root(); + let (_scratch, root) = unopenable_log_root(); // SAFETY: single-threaded test body, set before the process's only `init`. unsafe { std::env::set_var("DIG_LOG_DIR", &root) }; diff --git a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs index d624e62a..30459b77 100644 --- a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs +++ b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs @@ -150,15 +150,15 @@ impl ChainSource for Chain { /// probes deriving the same path append to one file -- and the failure that produces is a ledger /// with a foreign record in it, which reads as the code under test having written something it /// never wrote. Measured here on the first run. -fn tmp_log(name: &str) -> SpendLog { - static SEQ: AtomicU64 = AtomicU64::new(0); - let n = SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "dig-node-471-{}-{NOW}-{name}-{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("a temp dir"); - SpendLog::at(dir.join("spend-audit.jsonl")) +/// The guard comes back with the log, which writes into the tree for as long as the caller +/// holds it; `TempDir`'s `Drop` then removes it, including on an unwind (dig-node#370). +fn tmp_log(name: &str) -> (SpendLog, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix(&format!("dig-node-471-{name}-")) + .tempdir() + .expect("a temp dir"); + let log = SpendLog::at(dir.path().join("spend-audit.jsonl")); + (log, dir) } fn intent() -> SpendIntent { diff --git a/crates/dig-node-service/tests/openrpc_drift_guard.rs b/crates/dig-node-service/tests/openrpc_drift_guard.rs index 77a11399..b7891c45 100644 --- a/crates/dig-node-service/tests/openrpc_drift_guard.rs +++ b/crates/dig-node-service/tests/openrpc_drift_guard.rs @@ -37,18 +37,17 @@ use std::sync::Arc; /// Build a read-path Node whose cache + §21 identity live in a throwaway tempdir, so /// the test never reads or writes the real user cache / identity key. Env is read by /// `Node::from_env` at construction; set it immediately before building. -fn ephemeral_node() -> Arc { - let base = std::env::temp_dir().join(format!( - "dig-node-drift-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::env::set_var("DIG_NODE_CACHE", base.join("cache")); - std::env::set_var("DIG_IDENTITY_DIR", base.join("identity")); - Node::from_env() +/// The guard comes back with the node: the node reads its cache and identity paths for as +/// long as it lives, so the tree must outlive this function. `TempDir`'s `Drop` then removes +/// it when the caller drops both, including on an unwind (dig-node#370). +fn ephemeral_node() -> (Arc, tempfile::TempDir) { + let base = tempfile::Builder::new() + .prefix("dig-node-drift-") + .tempdir() + .expect("a scratch dir"); + std::env::set_var("DIG_NODE_CACHE", base.path().join("cache")); + std::env::set_var("DIG_IDENTITY_DIR", base.path().join("identity")); + (Node::from_env(), base) } /// Dispatch one method with empty params through the real read path and return the @@ -83,7 +82,7 @@ fn is_shell_only(name: &str) -> bool { #[tokio::test(flavor = "multi_thread")] async fn local_methods_are_resolved_by_dig_node() { const METHOD_NOT_FOUND: i64 = -32601; - let node = ephemeral_node(); + let (node, _scratch) = ephemeral_node(); for m in meta::methods() { if is_shell_only(m.name) || m.served != "local" { @@ -112,7 +111,7 @@ async fn local_methods_are_resolved_by_dig_node() { #[tokio::test(flavor = "multi_thread")] async fn passthrough_methods_are_not_resolved_by_dig_node() { const METHOD_NOT_FOUND: i64 = -32601; - let node = ephemeral_node(); + let (node, _scratch) = ephemeral_node(); for m in meta::methods() { if is_shell_only(m.name) || m.served != "passthrough" { diff --git a/crates/dig-node-service/tests/spend_audit_e2e.rs b/crates/dig-node-service/tests/spend_audit_e2e.rs index 04d5ae2b..beb9337e 100644 --- a/crates/dig-node-service/tests/spend_audit_e2e.rs +++ b/crates/dig-node-service/tests/spend_audit_e2e.rs @@ -24,12 +24,13 @@ fn dign() -> PathBuf { } /// A private state dir, standing in for the machine-wide one the daemon and the CLI share. -fn state_dir(tag: &str) -> PathBuf { - let dir = - std::env::temp_dir().join(format!("dig-node-spend-e2e-{}-{}", std::process::id(), tag)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("state dir"); - dir +/// The tree is OWNED by the returned guard: `TempDir`'s `Drop` removes it, including on an +/// unwind, so a failing assertion cannot leak it (dig-node#370). +fn state_dir(tag: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("dig-node-spend-e2e-{tag}-")) + .tempdir() + .expect("state dir") } fn intent(store: &str) -> SpendIntent { diff --git a/crates/dig-runtime/src/lib.rs b/crates/dig-runtime/src/lib.rs index 47d8d742..d1fa9859 100644 --- a/crates/dig-runtime/src/lib.rs +++ b/crates/dig-runtime/src/lib.rs @@ -627,6 +627,11 @@ mod tests { // The full runtime still constructs the node engine (other consumers keep it). #[test] fn build_runtime_full_has_node_engine() { + // Deliberately a FIXED name rather than a `TempDir` (dig-node#370): these tests + // start the process-global runtime, which keeps reading this cache for the rest + // of the process, so a guard dropped at the end of the test would pull the tree + // out from under it. The residue is bounded at one directory per name — four in + // total, reused every run — rather than one per run. let tmp = std::env::temp_dir().join("dig-runtime-buildfull"); std::env::set_var("DIG_IDENTITY_DIR", tmp.join("id")); std::env::set_var("DIG_NODE_CACHE", tmp.join("cache")); @@ -917,6 +922,11 @@ mod tests { #[test] fn ffi_roundtrip_unknown_method() { // Isolate the identity + cache the node creates so the test is hermetic. + // Deliberately a FIXED name rather than a `TempDir` (dig-node#370): these tests + // start the process-global runtime, which keeps reading this cache for the rest + // of the process, so a guard dropped at the end of the test would pull the tree + // out from under it. The residue is bounded at one directory per name — four in + // total, reused every run — rather than one per run. let tmp = std::env::temp_dir().join("dig-runtime-test"); std::env::set_var("DIG_IDENTITY_DIR", tmp.join("id")); std::env::set_var("DIG_NODE_CACHE", tmp.join("cache")); @@ -941,6 +951,11 @@ mod tests { #[test] fn wallet_ffi_roundtrip_chain_id_envelope() { // Isolate the identity + cache (the runtime brings up the node + wallet). + // Deliberately a FIXED name rather than a `TempDir` (dig-node#370): these tests + // start the process-global runtime, which keeps reading this cache for the rest + // of the process, so a guard dropped at the end of the test would pull the tree + // out from under it. The residue is bounded at one directory per name — four in + // total, reused every run — rather than one per run. let tmp = std::env::temp_dir().join("dig-runtime-wallet-test"); std::env::set_var("DIG_IDENTITY_DIR", tmp.join("id")); std::env::set_var("DIG_NODE_CACHE", tmp.join("cache")); @@ -967,6 +982,11 @@ mod tests { // request is an empty body → the dispatch's malformed-JSON 400 error envelope.) #[test] fn wallet_ffi_null_pointers_yield_error_envelope_not_ub() { + // Deliberately a FIXED name rather than a `TempDir` (dig-node#370): these tests + // start the process-global runtime, which keeps reading this cache for the rest + // of the process, so a guard dropped at the end of the test would pull the tree + // out from under it. The residue is bounded at one directory per name — four in + // total, reused every run — rather than one per run. let tmp = std::env::temp_dir().join("dig-runtime-wallet-null-test"); std::env::set_var("DIG_IDENTITY_DIR", tmp.join("id")); std::env::set_var("DIG_NODE_CACHE", tmp.join("cache")); From ebe66a79af0bf8fe23483d862f10e248cc0364e9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 23:09:00 -0700 Subject: [PATCH 06/21] test(profile-sync): assert the scratch tree is removed on drop and on an unwind --- .../src/capsule_warm_locator_tests.rs | 6 +-- crates/dig-node-core/src/peer.rs | 24 ++++----- .../src/seams/dig_peer/module_reshare.rs | 2 + .../src/seams/dig_peer/profile_sync.rs | 50 +++++++++++++++++++ 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/crates/dig-node-core/src/capsule_warm_locator_tests.rs b/crates/dig-node-core/src/capsule_warm_locator_tests.rs index f59ca5b7..62d5b905 100644 --- a/crates/dig-node-core/src/capsule_warm_locator_tests.rs +++ b/crates/dig-node-core/src/capsule_warm_locator_tests.rs @@ -131,7 +131,7 @@ fn node_with_pool_peer(pool_peer: &str, self_peer_id: Option) -> Arc); From 3f1778f08c0e47c1a0798ee0427261186a1851bf Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 23:10:18 -0700 Subject: [PATCH 07/21] fix(test): thread the pool scratch guard through fresh_pool_handle --- crates/dig-node-core/src/peer.rs | 5 +- .../src/seams/dig_peer/module_reshare.rs | 100 +++++++++--------- .../src/seams/dig_peer/module_serve.rs | 28 ++--- .../src/seams/dig_peer/profile_sync.rs | 8 +- 4 files changed, 72 insertions(+), 69 deletions(-) diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 05bb5c28..60240887 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -4216,10 +4216,13 @@ pub(crate) mod tests { /// Build a real, freshly-started `GossipHandle` on the production-shaped dual-stack unspecified /// bind (`[::]:0`, §5.2) for the pool-handle tests. + /// The scratch guard rides along with the handle, for the reason + /// [`fresh_pool_handle_on`] documents: the started pool reads its cert files for its + /// whole lifetime, so the caller holds both. pub(crate) async fn fresh_pool_handle( tag: &str, network: [u8; 32], - ) -> dig_gossip::GossipHandle { + ) -> (dig_gossip::GossipHandle, tempfile::TempDir) { fresh_pool_handle_on(tag, network, fresh_pool_listen_addr().await).await } diff --git a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs index 8ab44bc1..5194eceb 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs @@ -1084,7 +1084,7 @@ mod tests { fn promotes_the_artifact_the_gate_admitted() { let dir = temp_dir("promote-ok"); let module = module_committing(STORE, chain_root()); - let staged = dir.join("staged.dig"); + let staged = dir.path().join("staged.dig"); std::fs::write(&staged, &module).unwrap(); let verifier = @@ -1099,7 +1099,7 @@ mod tests { dig_download::ModuleAnchor::Anchored ); - let cached = dir.join("cached.module"); + let cached = dir.path().join("cached.module"); assert_eq!( promote_into_cache(&staged, &cached, &verifier, HolderClaim::Announce), Ok(module.len() as u64) @@ -1115,7 +1115,7 @@ mod tests { fn promoting_an_admitted_artifact_bumps_the_refetch_counter() { let dir = temp_dir("promote-refetch-counter"); let module = module_committing(STORE, chain_root()); - let staged = dir.join("staged.dig"); + let staged = dir.path().join("staged.dig"); std::fs::write(&staged, &module).unwrap(); let verifier = ChainAnchoredModuleVerifier::for_generation(Bytes32(STORE), Bytes32(chain_root())); @@ -1132,7 +1132,7 @@ mod tests { // never smaller, so `>= before + 1` is the strongest claim that stays deterministic under // full-suite parallelism while still proving THIS promotion contributed at least one bump. let before = crate::CACHE_REFETCH_COUNT.load(std::sync::atomic::Ordering::Relaxed); - let cached = dir.join("cached.module"); + let cached = dir.path().join("cached.module"); assert!(promote_into_cache(&staged, &cached, &verifier, HolderClaim::Announce).is_ok()); let after = crate::CACHE_REFETCH_COUNT.load(std::sync::atomic::Ordering::Relaxed); assert!( @@ -1165,12 +1165,12 @@ mod tests { // The gate admitted `module`; what is on disk is something else (one flipped byte, and a // trailing tail — both invisible to a caller that only checks the Ok). - let staged = dir.join("staged.dig"); + let staged = dir.path().join("staged.dig"); let mut tampered = module.clone(); tampered.extend_from_slice(b"trailing garbage"); std::fs::write(&staged, &tampered).unwrap(); - let cached = dir.join("cached.module"); + let cached = dir.path().join("cached.module"); assert_eq!( promote_into_cache(&staged, &cached, &verifier, HolderClaim::Announce), Err(WarmFailure::PromotedArtifactMismatch) @@ -1187,12 +1187,12 @@ mod tests { #[test] fn refuses_to_promote_an_artifact_no_gate_admitted() { let dir = temp_dir("promote-ungated"); - let staged = dir.join("staged.dig"); + let staged = dir.path().join("staged.dig"); std::fs::write(&staged, module_committing(STORE, chain_root())).unwrap(); let verifier = ChainAnchoredModuleVerifier::for_generation(Bytes32(STORE), Bytes32(chain_root())); - let cached = dir.join("cached.module"); + let cached = dir.path().join("cached.module"); assert_eq!( promote_into_cache(&staged, &cached, &verifier, HolderClaim::Announce), Err(WarmFailure::PromotedArtifactMismatch) @@ -1297,11 +1297,11 @@ mod tests { Arc::new(NoHolders), Arc::new(UnusedTransport), Arc::new(crate::seams::dig_peer::NoPullState), - Arc::new(dig_download::FileStateStore::new(dir.join("state"))), + Arc::new(dig_download::FileStateStore::new(dir.path().join("state"))), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: dir.join("staging"), - cache_dir: dir.join("cache"), + staging_dir: dir.path().join("staging"), + cache_dir: dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::clone(&shared), @@ -1391,11 +1391,11 @@ mod tests { Arc::new(OneHolder), Arc::new(RefusingHolder), Arc::new(crate::seams::dig_peer::NoPullState), - Arc::new(dig_download::FileStateStore::new(dir.join("state"))), + Arc::new(dig_download::FileStateStore::new(dir.path().join("state"))), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: dir.join("staging"), - cache_dir: dir.join("cache"), + staging_dir: dir.path().join("staging"), + cache_dir: dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::new(WarmRegistry::new()), @@ -1424,7 +1424,7 @@ mod tests { async fn a_failed_pull_announces_nothing() { let dir = temp_dir("failed-pull"); let spy = Arc::new(AnnounceSpy::default()); - let warmer = warmer_with(Arc::new(ConfirmingResolver), Arc::clone(&spy), &dir); + let warmer = warmer_with(Arc::new(ConfirmingResolver), Arc::clone(&spy), dir.path()); let outcome = warmer.warm(&hex32(STORE), &hex32(chain_root())).await; @@ -1438,7 +1438,7 @@ mod tests { "a failed pull must never announce this node as a holder" ); assert!( - !dir.join("cache").join("modules").exists(), + !dir.path().join("cache").join("modules").exists(), "no module may appear at the cache path" ); let _ = std::fs::remove_dir_all(&dir); @@ -1486,8 +1486,8 @@ mod tests { Arc::new(dig_download::InMemoryStateStore::new()), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: dir.join("staging"), - cache_dir: dir.join("cache"), + staging_dir: dir.path().join("staging"), + cache_dir: dir.path().join("cache"), }, Arc::clone(&spy) as Arc, Arc::new(WarmRegistry::new()), @@ -1703,7 +1703,7 @@ mod tests { )); let state: Arc = Arc::new(dig_download::InMemoryStateStore::new()); - let warmer = severing_warmer(&dir, &transport, &state); + let warmer = severing_warmer(dir.path(), &transport, &state); // ATTEMPT 1 — the link dies half way through the capsule. let severed = warmer.warm(&store_hex, &root_hex).await; @@ -1721,7 +1721,7 @@ mod tests { "the control: attempt 1 must stage a genuine PARTIAL ({staged_in_attempt_1} of \ {capsule_bytes} bytes)" ); - let partial = dig_download::staging_path_for(&staged_module_path(&dir)); + let partial = dig_download::staging_path_for(&staged_module_path(dir.path())); assert_eq!( std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0), staged_in_attempt_1, @@ -1756,7 +1756,7 @@ mod tests { "attempt 2 must fetch the missing bytes and ONLY the missing bytes" ); assert_eq!( - std::fs::read(cached_module_path(&dir)).expect("the capsule is cached"), + std::fs::read(cached_module_path(dir.path())).expect("the capsule is cached"), module, "the capsule assembled from a partial plus a resume is byte-identical to the real one" ); @@ -1803,8 +1803,8 @@ mod tests { Arc::clone(&state), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: dir.join("staging"), - cache_dir: dir.join("cache"), + staging_dir: dir.path().join("staging"), + cache_dir: dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::new(WarmRegistry::new()), @@ -1821,13 +1821,13 @@ mod tests { ), "a blob that fails the whole-module hash gate must be refused: {outcome:?}" ); - let staged = staged_module_path(&dir); + let staged = staged_module_path(dir.path()); assert!( !staged.exists() && !dig_download::staging_path_for(&staged).exists(), "bytes attributable only to a proven-false descriptor must not survive the failure" ); assert!( - !cached_module_path(&dir).exists(), + !cached_module_path(dir.path()).exists(), "nothing may reach the cache path" ); // dig-node#332: the checkpoint is the OTHER half of the same partial. Left behind, it claims @@ -1962,8 +1962,8 @@ mod tests { Arc::new(dig_download::InMemoryStateStore::new()), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: failed_dir.join("staging"), - cache_dir: failed_dir.join("cache"), + staging_dir: failed_dir.path().join("staging"), + cache_dir: failed_dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::new(WarmRegistry::new()), @@ -2003,8 +2003,8 @@ mod tests { Arc::new(dig_download::InMemoryStateStore::new()), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: ok_dir.join("staging"), - cache_dir: ok_dir.join("cache"), + staging_dir: ok_dir.path().join("staging"), + cache_dir: ok_dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::new(WarmRegistry::new()), @@ -2087,8 +2087,8 @@ mod tests { Arc::new(dig_download::InMemoryStateStore::new()), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: dir.join("staging"), - cache_dir: dir.join("cache"), + staging_dir: dir.path().join("staging"), + cache_dir: dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::new(WarmRegistry::new()), @@ -2142,14 +2142,14 @@ mod tests { // RELAYED — pulled for a stranger. let relay_dir = temp_dir("relayed-warm"); let relay_spy = Arc::new(AnnounceSpy::default()); - let relayed = serving_warmer(&relay_dir, &relay_spy, module.clone()) + let relayed = serving_warmer(relay_dir.path(), &relay_spy, module.clone()) .warm_relayed(&store_hex, &root_hex) .await; // LOCAL — the identical pull, for this node's own sake. The control. let local_dir = temp_dir("local-warm"); let local_spy = Arc::new(AnnounceSpy::default()); - let local = serving_warmer(&local_dir, &local_spy, module.clone()) + let local = serving_warmer(local_dir.path(), &local_spy, module.clone()) .warm(&store_hex, &root_hex) .await; @@ -2180,7 +2180,7 @@ mod tests { // artifact a holder serves from, byte-identically, so the requestor needs no second code path. for dir in [&relay_dir, &local_dir] { assert_eq!( - std::fs::read(cached_module_path(dir)).expect("module is at the cache path"), + std::fs::read(cached_module_path(dir.path())).expect("module is at the cache path"), module, "the verified capsule is cached whether or not it was announced" ); @@ -2268,11 +2268,11 @@ mod tests { // Both capsules land in the SAME cache, which is what a node that both relays and reshares // actually looks like on disk. let spy = Arc::new(AnnounceSpy::default()); - let relayed = serving_warmer_for(&dir, &spy, STORE, module_committing(STORE, chain_root())) + let relayed = serving_warmer_for(dir.path(), &spy, STORE, module_committing(STORE, chain_root())) .warm_relayed(&relay_store_hex, &root_hex) .await; let local = serving_warmer_for( - &dir, + dir.path(), &spy, LOCAL_STORE, module_committing(LOCAL_STORE, chain_root()), @@ -2285,7 +2285,7 @@ mod tests { "both pulls must genuinely succeed, or the announce set proves nothing" ); - let (inventory, announced) = announce_set_from_disk(&dir); + let (inventory, announced) = announce_set_from_disk(dir.path()); // The relayed capsule is still CACHED and servable. Suppression withholds the advertisement, // never the bytes — serving them is the whole point of relaying. @@ -2296,7 +2296,7 @@ mod tests { ); assert!( std::fs::read( - dir.join("cache") + dir.path().join("cache") .join("modules") .join(&relay_store_hex) .join(format!("{root_hex}.dig")) @@ -2339,10 +2339,10 @@ mod tests { let module = module_committing(STORE, chain_root()); let spy = Arc::new(AnnounceSpy::default()); - serving_warmer_for(&dir, &spy, STORE, module.clone()) + serving_warmer_for(dir.path(), &spy, STORE, module.clone()) .warm_relayed(&store_hex, &root_hex) .await; - let (_, suppressed) = announce_set_from_disk(&dir); + let (_, suppressed) = announce_set_from_disk(dir.path()); assert!( suppressed.is_empty(), "the relayed capsule is not announceable before this node claims it" @@ -2350,12 +2350,12 @@ mod tests { // The node now reads that generation for its own sake. The capsule is already on disk, so this // is the `AlreadyHeld` path — the one that has to promote provenance rather than shrug. - let outcome = serving_warmer_for(&dir, &spy, STORE, module) + let outcome = serving_warmer_for(dir.path(), &spy, STORE, module) .warm(&store_hex, &root_hex) .await; assert_eq!(outcome, WarmOutcome::AlreadyHeld); - let (_, announced) = announce_set_from_disk(&dir); + let (_, announced) = announce_set_from_disk(dir.path()); assert!( announced.contains(&dig_dht::ContentId::capsule(STORE, chain_root())), "a capsule this node now holds for itself must become announceable" @@ -2416,8 +2416,8 @@ mod tests { Arc::new(dig_download::InMemoryStateStore::new()), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: dir.join("staging"), - cache_dir: dir.join("cache"), + staging_dir: dir.path().join("staging"), + cache_dir: dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::new(WarmRegistry::new()), @@ -2449,11 +2449,11 @@ mod tests { Arc::new(NoHolders), Arc::new(UnusedTransport), Arc::new(crate::seams::dig_peer::NoPullState), - Arc::new(dig_download::FileStateStore::new(dir.join("state"))), + Arc::new(dig_download::FileStateStore::new(dir.path().join("state"))), Arc::new(ConfirmingResolver), WarmPaths { - staging_dir: dir.join("staging"), - cache_dir: dir.join("cache"), + staging_dir: dir.path().join("staging"), + cache_dir: dir.path().join("cache"), }, Arc::new(AnnounceSpy::default()), Arc::new(WarmRegistry::new()), @@ -2483,7 +2483,7 @@ mod tests { async fn without_a_chain_anchor_the_pull_never_starts() { let dir = temp_dir("no-anchor"); let spy = Arc::new(AnnounceSpy::default()); - let warmer = warmer_with(Arc::new(UnreachableChain), Arc::clone(&spy), &dir); + let warmer = warmer_with(Arc::new(UnreachableChain), Arc::clone(&spy), dir.path()); let outcome = warmer.warm(&hex32(STORE), &hex32(chain_root())).await; @@ -2498,7 +2498,7 @@ mod tests { async fn a_generation_the_chain_does_not_confirm_is_refused() { let dir = temp_dir("wrong-gen"); let spy = Arc::new(AnnounceSpy::default()); - let warmer = warmer_with(Arc::new(ConfirmingResolver), Arc::clone(&spy), &dir); + let warmer = warmer_with(Arc::new(ConfirmingResolver), Arc::clone(&spy), dir.path()); // The chain says CHAIN_ROOT; this asks to warm a different generation. let outcome = warmer.warm(&hex32(STORE), &hex32([0xc3; 32])).await; @@ -2513,7 +2513,7 @@ mod tests { async fn a_non_canonical_id_is_refused() { let dir = temp_dir("bad-id"); let spy = Arc::new(AnnounceSpy::default()); - let warmer = warmer_with(Arc::new(ConfirmingResolver), Arc::clone(&spy), &dir); + let warmer = warmer_with(Arc::new(ConfirmingResolver), Arc::clone(&spy), dir.path()); assert_eq!( warmer.warm("not-an-id", &hex32(chain_root())).await, WarmOutcome::Refused(WarmFailure::NoChainAnchor) diff --git a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs index 74bc15b0..035b79dc 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_serve.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_serve.rs @@ -404,7 +404,7 @@ mod tests { let bytes: Vec = (0..5000u32).map(|i| (i % 251) as u8).collect(); let dir = cache_with(&bytes, &store, &root); - let info = describe_module(&dir, &store, &root).expect("held"); + let info = describe_module(dir.path(), &store, &root).expect("held"); assert_eq!(info.total_size, bytes.len() as u64); assert_eq!(info.module_hash, hex32(&sha256(&bytes))); assert_eq!(info.chunk_lens.iter().sum::(), info.total_size); @@ -417,7 +417,7 @@ mod tests { #[test] fn an_unheld_module_is_not_described() { let dir = cache_with(b"x", &hex_id(1), &hex_id(2)); - assert!(describe_module(&dir, &hex_id(9), &hex_id(9)).is_none()); + assert!(describe_module(dir.path(), &hex_id(9), &hex_id(9)).is_none()); let _ = std::fs::remove_dir_all(&dir); } @@ -429,7 +429,7 @@ mod tests { fn an_empty_module_file_is_not_described() { let (store, root) = (hex_id(3), hex_id(4)); let dir = cache_with(b"", &store, &root); - assert!(describe_module(&dir, &store, &root).is_none()); + assert!(describe_module(dir.path(), &store, &root).is_none()); let _ = std::fs::remove_dir_all(&dir); } @@ -438,8 +438,8 @@ mod tests { #[test] fn a_non_canonical_id_never_reaches_the_filesystem() { let dir = cache_with(b"x", &hex_id(1), &hex_id(2)); - assert!(describe_module(&dir, "../../etc", &hex_id(2)).is_none()); - assert!(read_module_window(&dir, "../../etc", &hex_id(2), 0, 16).is_none()); + assert!(describe_module(dir.path(), "../../etc", &hex_id(2)).is_none()); + assert!(read_module_window(dir.path(), "../../etc", &hex_id(2), 0, 16).is_none()); let _ = std::fs::remove_dir_all(&dir); } @@ -471,7 +471,7 @@ mod tests { let bytes: Vec = (0..300u32).map(|i| i as u8).collect(); let dir = cache_with(&bytes, &store, &root); assert_eq!( - read_module_window(&dir, &store, &root, 100, 50).expect("held"), + read_module_window(dir.path(), &store, &root, 100, 50).expect("held"), bytes[100..150] ); let _ = std::fs::remove_dir_all(&dir); @@ -486,12 +486,12 @@ mod tests { let dir = cache_with(&bytes, &store, &root); // Past the end: an empty window, not an error and not a wrapped read. - assert!(read_module_window(&dir, &store, &root, 1_000, 10) + assert!(read_module_window(dir.path(), &store, &root, 1_000, 10) .expect("held") .is_empty()); // Absurd length: clamped to what exists. assert_eq!( - read_module_window(&dir, &store, &root, 0, u64::MAX) + read_module_window(dir.path(), &store, &root, 0, u64::MAX) .expect("held") .len(), 100 @@ -513,7 +513,7 @@ mod tests { let offset = MAX_MODULE_WINDOW * 2 + 17; let want = 4096u64; - let window = read_module_window(&dir, &store, &root, offset, want).expect("held"); + let window = read_module_window(dir.path(), &store, &root, offset, want).expect("held"); assert_eq!(window.len(), want as usize); assert_eq!(window, bytes[offset as usize..(offset + want) as usize]); @@ -532,8 +532,8 @@ mod tests { let bytes: Vec = (0..2000u32).map(|i| (i % 200) as u8).collect(); let dir = cache_with(&bytes, &store, &root); - let real = describe_module(&dir, &store, &root).expect("held"); - let metadata = std::fs::metadata(module_path(&dir, &store, &root)).unwrap(); + let real = describe_module(dir.path(), &store, &root).expect("held"); + let metadata = std::fs::metadata(module_path(dir.path(), &store, &root)).unwrap(); let sentinel = ModuleInfo { module_hash: "0".repeat(64), ..real.clone() @@ -547,7 +547,7 @@ mod tests { }, ); - let second = describe_module(&dir, &store, &root).expect("held"); + let second = describe_module(dir.path(), &store, &root).expect("held"); assert_eq!( second.module_hash, sentinel.module_hash, "the unchanged file's second describe_module call is answered from the memo" @@ -581,10 +581,10 @@ mod tests { .map(|i| (format!("{i:064x}"), format!("{:064x}", i + 10_000_000))) .collect(); for (store, root) in &keys { - let path = module_path(&dir, store, root); + let path = module_path(dir.path(), store, root); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, b"x").unwrap(); - describe_module(&dir, store, root).expect("held"); + describe_module(dir.path(), store, root).expect("held"); } let first = keys[0].clone(); diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index 91f8b900..3c0ed2be 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -1493,7 +1493,7 @@ mod tests { // A PLACEMENT property, asserted on the path relationship rather than on an outcome a // differently-placed store would satisfy identically. let cache = tempdir(); - let store = ProfileBodyStore::under_cache_dir(&cache); + let store = ProfileBodyStore::under_cache_dir(cache.path()); let path = store.path(&store_id(1), &[2u8; 32]); assert!(path.starts_with(cache.path().join(PROFILES_DIR))); assert!( @@ -2112,10 +2112,10 @@ mod tests { // A short name, an uppercase-hex name of the right length, and a loose file at the top of // the tree — each is 64-hex-adjacent and none of them is a store id. - std::fs::create_dir_all(root_dir.join("not-a-store-id")).expect("dir"); - std::fs::create_dir_all(root_dir.join(hex::encode(store_id(9)).to_uppercase())) + std::fs::create_dir_all(root_dir.path().join("not-a-store-id")).expect("dir"); + std::fs::create_dir_all(root_dir.path().join(hex::encode(store_id(9)).to_uppercase())) .expect("dir"); - std::fs::write(root_dir.join("README.txt"), b"not a store").expect("file"); + std::fs::write(root_dir.path().join("README.txt"), b"not a store").expect("file"); assert_eq!(store.held_pairs(), vec![(store_id(1), root)]); } From 1712baef98ef135ff3fd6783fe1324cff355269a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 23:21:26 -0700 Subject: [PATCH 08/21] fix(test): finish the spend-audit e2e and warm-locator scratch guards --- .../src/capsule_warm_locator_tests.rs | 1 - .../dig-node-service/tests/spend_audit_e2e.rs | 24 +++++++++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/dig-node-core/src/capsule_warm_locator_tests.rs b/crates/dig-node-core/src/capsule_warm_locator_tests.rs index 62d5b905..6400e840 100644 --- a/crates/dig-node-core/src/capsule_warm_locator_tests.rs +++ b/crates/dig-node-core/src/capsule_warm_locator_tests.rs @@ -16,7 +16,6 @@ //! [`crate::download::NodeContent::warm_provider_locator`], which is the handle production uses. use std::net::SocketAddr; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; use async_trait::async_trait; diff --git a/crates/dig-node-service/tests/spend_audit_e2e.rs b/crates/dig-node-service/tests/spend_audit_e2e.rs index beb9337e..383ce8a1 100644 --- a/crates/dig-node-service/tests/spend_audit_e2e.rs +++ b/crates/dig-node-service/tests/spend_audit_e2e.rs @@ -59,7 +59,7 @@ fn intent(store: &str) -> SpendIntent { #[test] fn an_automated_spend_is_written_by_the_node_and_read_back_by_dign() { let dir = state_dir("roundtrip"); - let log = SpendLog::at(dir.join(SPEND_AUDIT_FILE)); + let log = SpendLog::at(dir.path().join(SPEND_AUDIT_FILE)); let journal = SpendJournal::new(log); let ok = journal.begin(intent("store-alpha")); @@ -81,7 +81,7 @@ fn an_automated_spend_is_written_by_the_node_and_read_back_by_dign() { // The operator's command. A separate process, resolving the state dir on its own. let out = Command::new(dign()) .args(["spends", "list", "--json"]) - .env("DIG_NODE_STATE_DIR", &dir) + .env("DIG_NODE_STATE_DIR", dir.path()) .output() .expect("run dign"); assert!( @@ -128,14 +128,14 @@ fn an_automated_spend_is_written_by_the_node_and_read_back_by_dign() { #[test] fn the_human_output_answers_what_and_on_whose_authority() { let dir = state_dir("human"); - let journal = SpendJournal::new(SpendLog::at(dir.join(SPEND_AUDIT_FILE))); + let journal = SpendJournal::new(SpendLog::at(dir.path().join(SPEND_AUDIT_FILE))); let s = journal.begin(intent("store-gamma")); journal.failed(&s, FailureStage::Signing, "insufficient funds"); drop(s); let out = Command::new(dign()) .args(["spends", "list"]) - .env("DIG_NODE_STATE_DIR", &dir) + .env("DIG_NODE_STATE_DIR", dir.path()) .output() .expect("run dign"); assert!( @@ -160,7 +160,7 @@ fn a_node_that_never_spent_unattended_reports_an_empty_record() { let dir = state_dir("empty"); let out = Command::new(dign()) .args(["spends", "list", "--json"]) - .env("DIG_NODE_STATE_DIR", &dir) + .env("DIG_NODE_STATE_DIR", dir.path()) .output() .expect("run dign"); assert!(out.status.success()); @@ -175,7 +175,7 @@ fn a_node_that_never_spent_unattended_reports_an_empty_record() { #[test] fn a_status_filter_passed_on_the_command_line_actually_narrows() { let dir = state_dir("filter"); - let journal = SpendJournal::new(SpendLog::at(dir.join(SPEND_AUDIT_FILE))); + let journal = SpendJournal::new(SpendLog::at(dir.path().join(SPEND_AUDIT_FILE))); let ok = journal.begin(intent("store-one")); journal.confirmed(&ok, TargetCoinId("b".repeat(64)), 10); @@ -186,7 +186,7 @@ fn a_status_filter_passed_on_the_command_line_actually_narrows() { let out = Command::new(dign()) .args(["spends", "list", "--status", "failed", "--json"]) - .env("DIG_NODE_STATE_DIR", &dir) + .env("DIG_NODE_STATE_DIR", dir.path()) .output() .expect("run dign"); let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("json"); @@ -198,7 +198,7 @@ fn a_status_filter_passed_on_the_command_line_actually_narrows() { #[test] fn show_reaches_one_entry_by_the_id_list_printed() { let dir = state_dir("show"); - let journal = SpendJournal::new(SpendLog::at(dir.join(SPEND_AUDIT_FILE))); + let journal = SpendJournal::new(SpendLog::at(dir.path().join(SPEND_AUDIT_FILE))); let s = journal.begin(intent("store-delta")); journal.confirmed(&s, TargetCoinId("c".repeat(64)), 77); let id = s.id().to_string(); @@ -206,7 +206,7 @@ fn show_reaches_one_entry_by_the_id_list_printed() { let out = Command::new(dign()) .args(["spends", "show", &id, "--json"]) - .env("DIG_NODE_STATE_DIR", &dir) + .env("DIG_NODE_STATE_DIR", dir.path()) .output() .expect("run dign"); assert!(out.status.success()); @@ -222,7 +222,7 @@ fn an_unknown_id_exits_non_zero() { let dir = state_dir("unknown"); let out = Command::new(dign()) .args(["spends", "show", "sp_does_not_exist", "--json"]) - .env("DIG_NODE_STATE_DIR", &dir) + .env("DIG_NODE_STATE_DIR", dir.path()) .output() .expect("run dign"); assert!(!out.status.success()); @@ -238,14 +238,14 @@ fn an_unknown_id_exits_non_zero() { #[test] fn reconcile_without_a_chain_source_refuses_rather_than_claiming_agreement() { let dir = state_dir("reconcile"); - let journal = SpendJournal::new(SpendLog::at(dir.join(SPEND_AUDIT_FILE))); + let journal = SpendJournal::new(SpendLog::at(dir.path().join(SPEND_AUDIT_FILE))); let s = journal.begin(intent("store-eps")); journal.confirmed(&s, TargetCoinId("d".repeat(64)), 5); drop(s); let out = Command::new(dign()) .args(["spends", "reconcile", &"e".repeat(64), "--json"]) - .env("DIG_NODE_STATE_DIR", &dir) + .env("DIG_NODE_STATE_DIR", dir.path()) .output() .expect("run dign"); assert!(!out.status.success(), "an unperformed check is not a pass"); From 9766c13019c543bfc9a6ed806b2365604a481e40 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Mon, 31 Aug 2026 23:30:05 -0700 Subject: [PATCH 09/21] fix(test): thread the mirror-expiry scratch guard and drop the now-dead counters --- crates/dig-node-service/tests/beacon_cli_process.rs | 3 --- .../tests/mirror_funding_reservation_expiry.rs | 11 +++++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/dig-node-service/tests/beacon_cli_process.rs b/crates/dig-node-service/tests/beacon_cli_process.rs index 01e598bc..8d9bd242 100644 --- a/crates/dig-node-service/tests/beacon_cli_process.rs +++ b/crates/dig-node-service/tests/beacon_cli_process.rs @@ -15,7 +15,6 @@ //! (The fixture is deliberately NOT named after "dig-updater" — see its own doc comment.) use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; use dig_node_service::updater::{check_now, pause, resume, set_channel, CLI_BIN_ENV}; @@ -31,8 +30,6 @@ fn env_guard() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } -static SEQ: AtomicU64 = AtomicU64::new(0); - /// A unique-per-call scratch path for the fixture's `FAKE_UPDATER_ARGS_FILE` capture. /// /// The guard owns the directory the file lands in, so the capture is removed on drop and on diff --git a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs index 30459b77..61fd1723 100644 --- a/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs +++ b/crates/dig-node-service/tests/mirror_funding_reservation_expiry.rs @@ -28,7 +28,6 @@ mod support; use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; use chia_protocol::{Bytes32, CoinSpend}; use chia_sha2::Sha256; @@ -183,12 +182,12 @@ fn intent() -> SpendIntent { /// The whole holding, deliberately: a wallet with an uncommitted coin to spare could satisfy the /// selector from that coin and would report success while the committed coin stayed stranded /// forever. Committing everything is what makes `Insufficient` the observable. -fn wedged_wallet() -> (Chain, Wallet, SpendLog) { +fn wedged_wallet() -> (Chain, Wallet, SpendLog, tempfile::TempDir) { let operator = wallet(0x21); let mut chain = Chain::default(); let coins = chain.fund(&operator, &[REQUIRED], salt(1)); - let log = tmp_log("wedged"); + let (log, scratch) = tmp_log("wedged"); let journal = SpendJournal::with_clock(log.clone(), clock); let spend = journal.begin(intent()); journal.submitted( @@ -208,7 +207,7 @@ fn wedged_wallet() -> (Chain, Wallet, SpendLog) { // test is the `Submitted` one written at `NOW`. std::mem::forget(spend); - (chain, operator, log) + (chain, operator, log, scratch) } /// **A coin committed to a spend still INSIDE the confirmation window is NOT released.** @@ -222,7 +221,7 @@ fn wedged_wallet() -> (Chain, Wallet, SpendLog) { /// from well inside it can only confirm itself. #[test] fn a_coin_committed_to_a_spend_still_in_flight_is_not_selectable() { - let (chain, operator, log) = wedged_wallet(); + let (chain, operator, log, _scratch) = wedged_wallet(); let committed = committed_funding_coin_ids(&log, NOW + FUNDING_RESERVATION_WINDOW_MS - 1) .expect("the audit record is readable"); @@ -248,7 +247,7 @@ fn a_coin_committed_to_a_spend_still_in_flight_is_not_selectable() { /// Only the instant differs, so nothing but the elapsed time can explain the difference. #[test] fn a_coin_committed_to_a_spend_that_never_lands_is_selectable_two_passes_later() { - let (chain, operator, log) = wedged_wallet(); + let (chain, operator, log, _scratch) = wedged_wallet(); let n_passes = FUNDING_RESERVATION_WINDOW_MS / dig_constants::MIRROR_ROUND_LENGTH_MS as u64; assert_eq!( From 9bc07f764ef72fe15a26a77c08825a3f786e8870 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 02:34:49 -0700 Subject: [PATCH 10/21] chore(release): 0.212.0 Main released 0.208.0 while this lane worked, so its bump became a no-op against the new main -- the version gate reads the file, not the log. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e54dfab0..003dfd3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.208.0" +version = "0.212.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index 9879ff8d..41fd4b0e 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.208.0" +version = "0.212.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over From bc9b391137af1c75d494c15fd93b32421484c9d4 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 02:44:27 -0700 Subject: [PATCH 11/21] fix(test): finish the control, census, pairing and spends-cli scratch guards --- crates/dig-node-service/src/collateral_census.rs | 2 +- crates/dig-node-service/src/control.rs | 14 +++++++------- crates/dig-node-service/src/pairing.rs | 12 ++++++++---- crates/dig-node-service/src/spend_audit_cli.rs | 3 ++- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/dig-node-service/src/collateral_census.rs b/crates/dig-node-service/src/collateral_census.rs index 4e8144b7..a8d1b0dd 100644 --- a/crates/dig-node-service/src/collateral_census.rs +++ b/crates/dig-node-service/src/collateral_census.rs @@ -998,7 +998,7 @@ mod tests { use std::io::Write as _; let mut f = std::fs::OpenOptions::new() .append(true) - .open(dir.join("epochs.jsonl")) + .open(dir.path().join("epochs.jsonl")) .expect("open the store for the rotted append"); writeln!(f, "{{\"epoch\":2}}").expect("append the rotted line"); } diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 30f5cd54..46e33423 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -6042,7 +6042,7 @@ mod tests { .prefix("dig-node-token-test-") .tempdir() .expect("a scratch dir"); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); let first = load_or_create_token_at(&path).unwrap(); let second = load_or_create_token_at(&path).unwrap(); assert_eq!(first, second, "token must be stable across reads"); @@ -6309,7 +6309,7 @@ mod tests { .prefix("dig-node-token-roundtrip-") .tempdir() .expect("a scratch dir"); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); let minted = load_or_create_token_at(&path).unwrap(); let read_back = read_token_readonly_at(&path).unwrap(); assert_eq!( @@ -6331,7 +6331,7 @@ mod tests { .expect("a scratch dir"); // The dir pre-exists but holds NO token (a freshly (re)created state dir). std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); assert!(!path.exists(), "precondition: the dir starts tokenless"); let first = load_or_create_token_at(&path).unwrap(); @@ -6354,7 +6354,7 @@ mod tests { .prefix("dig-node-token-absent-") .tempdir() .expect("a scratch dir"); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); let err = read_token_readonly_at(&path).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::NotFound); let msg = err.to_string(); @@ -6426,7 +6426,7 @@ mod tests { .prefix("dig-node-pins-test-") .tempdir() .expect("a scratch dir"); - let config_path = dir.join("config.json"); + let config_path = dir.path().join("config.json"); let store = "c".repeat(64); let root = "d".repeat(64); @@ -6454,7 +6454,7 @@ mod tests { .prefix("dig-node-config-merge-test-") .tempdir() .expect("a scratch dir"); - let config_path = dir.join("config.json"); + let config_path = dir.path().join("config.json"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write( &config_path, @@ -6482,7 +6482,7 @@ mod tests { .prefix("dig-node-upstream-test-") .tempdir() .expect("a scratch dir"); - let config_path = dir.join("config.json"); + let config_path = dir.path().join("config.json"); assert_eq!(read_upstream_override_from(&config_path), None); set_upstream_override(&config_path, "https://up.test").unwrap(); assert_eq!( diff --git a/crates/dig-node-service/src/pairing.rs b/crates/dig-node-service/src/pairing.rs index 9f1c3a10..35a436df 100644 --- a/crates/dig-node-service/src/pairing.rs +++ b/crates/dig-node-service/src/pairing.rs @@ -548,7 +548,8 @@ mod tests { #[test] fn poll_unknown_then_pending_then_approved_delivers_token_once() { - let (config, _d) = tmp_config(); + let scratch = tmp_config(); + let config = scratch.path(); let p = pending(); // Unknown id → status unknown. @@ -591,7 +592,8 @@ mod tests { #[test] fn approve_unknown_pairing_is_invalid_params() { - let (config, _d) = tmp_config(); + let scratch = tmp_config(); + let config = scratch.path(); let p = pending(); let resp = approve(&p, &config, json!(1), &json!({ "pairing_id": "nope" })); assert_eq!( @@ -602,7 +604,8 @@ mod tests { #[test] fn list_shows_pending_and_issued_tokens() { - let (config, _d) = tmp_config(); + let scratch = tmp_config(); + let config = scratch.path(); let p = pending(); let req = request(&p, json!(1), &json!({ "client_name": "ext-A" })); let pid = req["result"]["pairing_id"].as_str().unwrap().to_string(); @@ -655,7 +658,8 @@ mod tests { #[test] fn load_paired_tokens_tolerates_missing_and_malformed() { - let (config, _d) = tmp_config(); + let scratch = tmp_config(); + let config = scratch.path(); let path = paired_tokens_path(&config); assert!(load_paired_tokens(&path).is_empty(), "missing file → empty"); std::fs::write(&path, b"not json").unwrap(); diff --git a/crates/dig-node-service/src/spend_audit_cli.rs b/crates/dig-node-service/src/spend_audit_cli.rs index b0dc9fba..b03af232 100644 --- a/crates/dig-node-service/src/spend_audit_cli.rs +++ b/crates/dig-node-service/src/spend_audit_cli.rs @@ -464,8 +464,9 @@ mod tests { /// An empty record says so plainly rather than printing nothing at all. #[test] fn an_empty_record_says_no_money_moved_unattended() { + let (log, _scratch) = tmp_log(); let out = - run_against(&tmp_log(), SpendsAction::List(SpendQuery::default()), None).expect("list"); + run_against(&log, SpendsAction::List(SpendQuery::default()), None).expect("list"); assert_eq!(out.result["count"], 0); assert!( out.summary.contains("no money unattended"), From cd734687f1c700755080345267006f8a14b584b9 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 02:53:37 -0700 Subject: [PATCH 12/21] fix(test): pass scratch paths to set_var rather than the guard itself --- crates/dig-node-service/tests/server.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 6f1afb8d..26b0f2e9 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -733,10 +733,10 @@ async fn dual_listener_serves_localhost_when_dig_local_bind_fails() { .prefix("dig-node-dual-") .tempdir() .expect("a scratch dir"); - std::env::set_var("DIG_NODE_CACHE", &tmp); + std::env::set_var("DIG_NODE_CACHE", tmp.path()); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); // Isolate the #501 control-token/paired-token state dir per test (see the note above). - std::env::set_var("DIG_NODE_STATE_DIR", &tmp); + std::env::set_var("DIG_NODE_STATE_DIR", tmp.path()); // This test exercises the LISTENER bind fallback, not the peer network. Opt out of the // §14 peer-network bring-up (#213) so `serve_with_shutdown` stays hermetic here (no gossip // pool / DHT / relay reach). A dedicated test covers the peer-network wiring. @@ -803,10 +803,10 @@ async fn dual_stack_loopback_serves_both_ipv4_and_ipv6_on_the_same_port() { .prefix("dig-node-dualstack-") .tempdir() .expect("a scratch dir"); - std::env::set_var("DIG_NODE_CACHE", &tmp); + std::env::set_var("DIG_NODE_CACHE", tmp.path()); std::env::set_var("DIG_NODE_CACHE_CAP", "67108864"); // Isolate the #501 control-token/paired-token state dir per test (see the note above). - std::env::set_var("DIG_NODE_STATE_DIR", &tmp); + std::env::set_var("DIG_NODE_STATE_DIR", tmp.path()); std::env::set_var("DIG_PEER_NETWORK", "off"); tokio::spawn(async move { dig_node_service::server::serve_with_shutdown(config, async move { @@ -2989,7 +2989,7 @@ async fn control_updater_status_and_mutation_wired_over_http() { std::fs::create_dir_all(&status_dir).unwrap(); let body = json!({ "schema": 1, "version": "0.6.0", "channel": "alpha", "paused": false }); std::fs::write( - status_dir.join("status.json"), + status_dir.path().join("status.json"), serde_json::to_vec(&body).unwrap(), ) .unwrap(); From 6b8efc959c7f92d906edcdd1434f4c9f76a14455 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 03:01:51 -0700 Subject: [PATCH 13/21] fix(test): use the pool scratch guard's path in the gossip config --- crates/dig-node-core/tests/pool_connect.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-core/tests/pool_connect.rs b/crates/dig-node-core/tests/pool_connect.rs index 5b5f1b07..07689bbf 100644 --- a/crates/dig-node-core/tests/pool_connect.rs +++ b/crates/dig-node-core/tests/pool_connect.rs @@ -42,9 +42,9 @@ async fn start_pool( .expect("a pool cert dir"); let cfg = GossipConfig { network_id: chia_protocol::Bytes32::new(network), - cert_path: dir.join("node.cert").display().to_string(), - key_path: dir.join("node.key").display().to_string(), - peers_file_path: dir.join("peers.json"), + cert_path: dir.path().join("node.cert").display().to_string(), + key_path: dir.path().join("node.key").display().to_string(), + peers_file_path: dir.path().join("peers.json"), peer_pool: Some(PeerPoolConfig::default()), listen_addr, ..Default::default() From 0a609c294291cf24154ecfbcaf49701064635a5c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 03:10:13 -0700 Subject: [PATCH 14/21] fix(test): use the reannounce scratch guard's path in the gossip config --- .../tests/profile_reannounce_reaches_the_wire.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs b/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs index 4205a328..5df6b6a6 100644 --- a/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs +++ b/crates/dig-node-core/tests/profile_reannounce_reaches_the_wire.rs @@ -50,9 +50,9 @@ async fn transport_with_one_peer() -> ( let cfg = dig_gossip::GossipConfig { network_id: chia_protocol::Bytes32::new([7u8; 32]), - cert_path: dir.join("node.cert").display().to_string(), - key_path: dir.join("node.key").display().to_string(), - peers_file_path: dir.join("peers.json"), + cert_path: dir.path().join("node.cert").display().to_string(), + key_path: dir.path().join("node.key").display().to_string(), + peers_file_path: dir.path().join("peers.json"), peer_pool: Some(dig_gossip::PeerPoolConfig::default()), listen_addr: std::net::SocketAddr::new( std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), From dad1120fa07647350886ae39ed7f526b73756481 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 03:15:26 -0700 Subject: [PATCH 15/21] chore(test): drop the uniqueness counters tempfile's suffix replaced --- crates/dig-node-service/src/updater.rs | 3 --- crates/dig-node-service/tests/server.rs | 9 +-------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/crates/dig-node-service/src/updater.rs b/crates/dig-node-service/src/updater.rs index f11a221d..d487532d 100644 --- a/crates/dig-node-service/src/updater.rs +++ b/crates/dig-node-service/src/updater.rs @@ -327,7 +327,6 @@ pub async fn check_now(id: Value) -> Value { #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; use tokio::sync::Mutex; @@ -340,8 +339,6 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } - static SEQ: AtomicU64 = AtomicU64::new(0); - /// A unique-per-call scratch path, so concurrent test RUNS (across `cargo test` /// invocations) never collide even though tests within this file are serialized. /// diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 26b0f2e9..ddc24953 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -37,13 +37,6 @@ fn env_guard() -> Arc> { .clone() } -/// Monotonic sequence giving each `start_companion_full` call a UNIQUE cache/config -/// dir, so the per-server control-token file + pin registry (and pinned-store list a -/// test asserts on) are never shared between tests. This + the [`EnvHold`] lock -/// together remove the flaky UNAUTHORIZED: the lock makes the global-env reads -/// consistent, the unique dir keeps each server's on-disk state isolated. -static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - /// Start a mock upstream DIG RPC on a random loopback port. It records every /// request and answers `dig.getAnchoredRoot` / `dig.listCapsules` / echoes the /// rest — enough to assert delegation + passthrough. Returns (base_url, calls). @@ -150,7 +143,7 @@ async fn start_companion_full_inner( // and on Windows a concurrent reader could hit that file mid-write, error, // and fall back to a random in-memory token → intermittent UNAUTHORIZED on a // token-gated control.* call (the flaky failure this guards). Give each call - // its own PARENT dir (`/dig-node-test--/cache`) so the + // its own PARENT dir (`/dig-node-test-/cache`, owned by a TempDir) so // token + config.json are unique per server. (Set before from_env reads it.) let base = tempfile::Builder::new() .prefix("dig-node-test-") From f0224b7e44cd22f77f2cf35b499f6ba5849e84ef Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 03:36:43 -0700 Subject: [PATCH 16/21] fix(test): return the spends-cli scratch guard with the log it points into --- .../dig-node-service/src/spend_audit_cli.rs | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/crates/dig-node-service/src/spend_audit_cli.rs b/crates/dig-node-service/src/spend_audit_cli.rs index b03af232..1ac38b32 100644 --- a/crates/dig-node-service/src/spend_audit_cli.rs +++ b/crates/dig-node-service/src/spend_audit_cli.rs @@ -326,8 +326,11 @@ mod tests { } /// A log holding one confirmed mirror-coin spend and one failed one. - fn seeded_log() -> SpendLog { - let (log, _dir) = tmp_log(); + /// The guard travels with the log. Binding it locally here would drop the tree at the + /// end of THIS function, leaving every caller holding a `SpendLog` that points at a + /// directory which no longer exists. + fn seeded_log() -> (SpendLog, tempfile::TempDir) { + let (log, scratch) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let ok = journal.begin(intent(kinds::MIRROR_COIN, Some("store-a"))); @@ -345,7 +348,7 @@ mod tests { journal.failed(&bad, FailureStage::Signing, "insufficient funds"); drop(bad); - log + (log, scratch) } /// **A blocked node does not read as an idle one.** The failed spend appears in the default @@ -353,7 +356,7 @@ mod tests { /// failure cannot tell "failures are listed" apart from "everything is listed". #[test] fn the_default_listing_shows_failures_beside_successes() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let out = run_against(&log, SpendsAction::List(SpendQuery::default()), None).expect("list"); assert_eq!(out.result["count"], 2); let tokens: Vec<&str> = out.result["spends"] @@ -370,7 +373,7 @@ mod tests { /// The status filter narrows to one row, and the row it keeps is the right one. #[test] fn the_status_filter_narrows_the_listing() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let out = run_against( &log, SpendsAction::List(SpendQuery { @@ -387,7 +390,7 @@ mod tests { /// The store filter narrows to the named store, and does NOT match the other one. #[test] fn the_store_filter_narrows_the_listing() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let out = run_against( &log, SpendsAction::List(SpendQuery { @@ -449,7 +452,7 @@ mod tests { #[test] fn an_incomplete_record_is_reported_as_incomplete() { use std::io::Write as _; - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let mut f = std::fs::OpenOptions::new() .append(true) .open(log.path()) @@ -478,7 +481,7 @@ mod tests { /// `show` renders one entry, and an unknown id is a usage error rather than an empty success. #[test] fn show_finds_an_entry_and_refuses_an_unknown_id() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let ledger = log.ledger().expect("ledger"); let id = ledger.records[0].id.clone(); @@ -509,7 +512,7 @@ mod tests { /// confirmed coin, making that wrong version produce a visibly different answer. #[test] fn reconcile_without_a_chain_source_refuses_rather_than_reporting_clean() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let err = run_against( &log, SpendsAction::Reconcile { @@ -525,7 +528,7 @@ mod tests { /// With a chain source, a coin the chain shows and the record does not is reported as the alarm. #[test] fn reconcile_reports_a_coin_the_record_does_not_account_for() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let chain = FakeChain(vec!["coin-ok".to_string(), "coin-orphan".to_string()]); let out = run_against( &log, @@ -544,7 +547,7 @@ mod tests { /// The clean case reads as clean — the honest control for the test above. #[test] fn reconcile_reports_agreement_when_the_chain_matches() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let chain = FakeChain(vec!["coin-ok".to_string()]); let out = run_against( &log, @@ -565,7 +568,7 @@ mod tests { /// The `--json` envelope keys are a contract the app and scripts read. Pinned. #[test] fn the_json_listing_keys_are_stable() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let out = run_against(&log, SpendsAction::List(SpendQuery::default()), None).expect("list"); for key in ["path", "count", "unreadable_lines", "spends"] { assert!(out.result.get(key).is_some(), "missing {key}"); @@ -587,7 +590,7 @@ mod tests { /// the person reading the terminal. #[test] fn the_human_output_states_on_whose_authority_the_node_spent() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let out = run_against(&log, SpendsAction::List(SpendQuery::default()), None).expect("list"); assert!(out.summary.contains("by node"), "{}", out.summary); assert!( @@ -601,7 +604,7 @@ mod tests { /// silently fall back to showing everything. #[test] fn a_filter_matching_nothing_returns_nothing_rather_than_everything() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let out = run_against( &log, SpendsAction::List(SpendQuery { @@ -650,7 +653,7 @@ mod tests { /// as expected. #[test] fn show_marks_a_confirmed_coin_as_observed() { - let log = seeded_log(); + let (log, _scratch) = seeded_log(); let ledger = log.ledger().expect("ledger"); let confirmed = ledger .records From 4c5450c9a347e05a133fefe0ab79503bc6f59892 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 04:31:39 -0700 Subject: [PATCH 17/21] fix(test): own the spend-audit and warm-locator scratch trees with tempfile guards --- .../src/capsule_warm_locator_tests.rs | 16 +++-- crates/dig-node-service/src/spend_audit.rs | 59 +++++++++++-------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/crates/dig-node-core/src/capsule_warm_locator_tests.rs b/crates/dig-node-core/src/capsule_warm_locator_tests.rs index 6400e840..4bc1c79a 100644 --- a/crates/dig-node-core/src/capsule_warm_locator_tests.rs +++ b/crates/dig-node-core/src/capsule_warm_locator_tests.rs @@ -120,7 +120,13 @@ impl AnnounceHolder for SilentAnnounce { /// The locator is built through [`NodeContent::provider_locator_chain`] — the union, the /// self-exclusion and the capsule fallback `for_dht` installs — so these fixtures drive the layers /// production drives. -fn node_with_pool_peer(pool_peer: &str, self_peer_id: Option) -> Arc { +/// +/// The scratch guard is returned with the node rather than bound here: dropping it at the end of +/// THIS function would delete the directory the returned `NodeContent` is rooted at. +fn node_with_pool_peer( + pool_peer: &str, + self_peer_id: Option, +) -> (Arc, tempfile::TempDir) { let dir = temp_dir("engine"); let content = NodeContent::new( NodeContent::provider_locator_chain( @@ -136,7 +142,7 @@ fn node_with_pool_peer(pool_peer: &str, self_peer_id: Option) -> Arc().expect("test address")], ); - content + (content, dir) } /// A warmer built over `content`'s PRODUCTION warm locator and a recording transport. @@ -177,7 +183,7 @@ fn warmer_over( async fn a_warm_reaches_a_holder_that_only_the_connected_pool_can_name() { let dir = temp_dir("reachability"); let holder = mock_peer_hex(9); - let content = node_with_pool_peer(&holder, Some(mock_peer_hex(1))); + let (content, _scratch) = node_with_pool_peer(&holder, Some(mock_peer_hex(1))); let transport = Arc::new(RecordingModuleTransport::default()); let warmer = warmer_over(&content, Arc::clone(&transport), dir.path()); @@ -218,7 +224,7 @@ async fn a_warm_reaches_a_holder_that_only_the_connected_pool_can_name() { async fn a_pool_entry_naming_this_node_is_never_a_dial_candidate() { let dir = temp_dir("self-exclusion"); let me = mock_peer_hex(9); - let content = node_with_pool_peer(&me, Some(me.clone())); + let (content, _scratch) = node_with_pool_peer(&me, Some(me.clone())); let transport = Arc::new(RecordingModuleTransport::default()); let warmer = warmer_over(&content, Arc::clone(&transport), dir.path()); @@ -247,7 +253,7 @@ async fn a_pool_entry_naming_this_node_is_never_a_dial_candidate() { #[tokio::test] async fn discovery_still_excludes_the_pool_that_the_warm_locator_includes() { let peer = mock_peer_hex(9); - let content = node_with_pool_peer(&peer, Some(mock_peer_hex(1))); + let (content, _scratch) = node_with_pool_peer(&peer, Some(mock_peer_hex(1))); let capsule = dig_dht::ContentId::capsule(STORE, ROOT); let discovered = content diff --git a/crates/dig-node-service/src/spend_audit.rs b/crates/dig-node-service/src/spend_audit.rs index 0633b70f..ac7de4a7 100644 --- a/crates/dig-node-service/src/spend_audit.rs +++ b/crates/dig-node-service/src/spend_audit.rs @@ -1276,17 +1276,18 @@ mod tests { NOW } - fn tmp_log() -> SpendLog { - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "dig-node-spend-audit-{}-{}-{}", - std::process::id(), - now_ms(), - n - )); - std::fs::create_dir_all(&dir).expect("temp dir"); - SpendLog::at(dir.join(SPEND_AUDIT_FILE)) + /// A scratch log plus the guard that owns its directory. + /// + /// The guard travels with the log. Binding it locally inside this helper would drop the + /// tree at the end of THIS function, leaving the caller holding a `SpendLog` that points + /// at a directory which no longer exists — so every caller must bind it for the whole test. + fn tmp_log() -> (SpendLog, tempfile::TempDir) { + let dir = tempfile::Builder::new() + .prefix("dig-node-spend-audit-") + .tempdir() + .expect("temp dir"); + let log = SpendLog::at(dir.path().join(SPEND_AUDIT_FILE)); + (log, dir) } fn intent() -> SpendIntent { @@ -1313,7 +1314,8 @@ mod tests { /// as it signs" apart from "recorded once it finished". #[test] fn the_entry_is_on_disk_before_the_producer_signs() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); let recorded = journal.begin(intent()); // This is the producer's signing step. @@ -1330,7 +1332,8 @@ mod tests { /// A node blocked on funds must not read as an idle node. #[test] fn a_failed_spend_is_recorded_with_its_stage_and_reason() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); let recorded = journal.begin(intent()); journal.failed(&recorded, FailureStage::Signing, "insufficient funds"); drop(recorded); @@ -1354,7 +1357,8 @@ mod tests { /// where nothing lands cannot tell a correct confirmation apart from a broken one. #[test] fn a_competing_spend_of_the_funding_coin_never_confirms_this_spend() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); let recorded = journal.begin(intent()); journal.submitted( &recorded, @@ -1389,7 +1393,8 @@ mod tests { /// and its chain reference is marked observed. #[test] fn observing_the_created_coin_confirms_the_spend() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); let recorded = journal.begin(intent()); journal.submitted( &recorded, @@ -1426,7 +1431,8 @@ mod tests { /// entry to `unresolved` — not `pending` (which reads as still in flight) and never `confirmed`. #[test] fn a_dropped_spend_settles_itself_as_unresolved() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); { let recorded = journal.begin(intent()); journal.submitted( @@ -1456,7 +1462,8 @@ mod tests { /// silence wearing an entry's clothes. #[test] fn a_producer_that_panics_still_leaves_an_unresolved_entry_with_its_coin() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let recorded = journal.begin(intent()); @@ -1499,7 +1506,8 @@ mod tests { /// A settled entry is not overwritten by the drop guard. #[test] fn dropping_a_settled_spend_does_not_overwrite_its_outcome() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); { let recorded = journal.begin(intent()); journal.confirmed(&recorded, TargetCoinId("c".to_string()), 5); @@ -1512,7 +1520,7 @@ mod tests { /// The file is append-only: every revision survives, and the fold reports the newest. #[test] fn the_file_keeps_every_revision_and_the_fold_reports_the_newest() { - let log = tmp_log(); + let (log, _scratch) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let recorded = journal.begin(intent()); journal.submitted( @@ -1536,7 +1544,7 @@ mod tests { /// read as a tidy shorter one. #[test] fn an_unparseable_line_is_counted_rather_than_hidden() { - let log = tmp_log(); + let (log, _scratch) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let recorded = journal.begin(intent()); journal.failed(&recorded, FailureStage::Broadcast, "rejected"); @@ -1557,7 +1565,8 @@ mod tests { /// would fold into one and the record would quietly lose money movement. #[test] fn two_spends_in_the_same_millisecond_are_two_records() { - let journal = SpendJournal::with_clock(tmp_log(), clock); + let (log, _scratch) = tmp_log(); + let journal = SpendJournal::with_clock(log, clock); let a = journal.begin(intent()); let b = journal.begin(intent()); journal.failed(&a, FailureStage::Signing, "a"); @@ -2148,7 +2157,7 @@ mod tests { /// long ago they were observed, cannot be satisfied that way. #[test] fn a_stuck_spend_releases_its_funding_coins_once_the_hold_lapses() { - let log = tmp_log(); + let (log, _scratch) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let stuck = journal.begin(intent()); @@ -2217,7 +2226,7 @@ mod tests { /// long-lapsed and release a coin that was committed moments ago. The closed direction. #[test] fn a_record_dated_in_the_future_keeps_its_hold() { - let log = tmp_log(); + let (log, _scratch) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let spend = journal.begin(intent()); @@ -2244,7 +2253,7 @@ mod tests { /// `Failed { stage: Signing }` spend never moved money. #[test] fn the_window_never_extends_a_hold_a_terminal_status_already_released() { - let log = tmp_log(); + let (log, _scratch) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let confirmed = journal.begin(intent()); @@ -2296,7 +2305,7 @@ mod tests { /// indistinguishable from one that refused. #[test] fn a_lost_line_refuses_the_committed_set() { - let log = tmp_log(); + let (log, _scratch) = tmp_log(); let journal = SpendJournal::with_clock(log.clone(), clock); let spend = journal.begin(intent()); From ede3479dde6e601762bca538eee848881a77927c Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 04:36:11 -0700 Subject: [PATCH 18/21] test(profile-sync): record the separate mutation that proves the unwind half --- .../src/seams/dig_peer/profile_sync.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index 3ec8975a..fdaf2dce 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -1266,6 +1266,20 @@ mod tests { /// nearest wrong one: a helper that called `TempDir::keep` (or handed back an unowned /// `PathBuf`) would satisfy nothing here, while a merely-tidier manual cleanup would pass /// the success case and fail this one. + /// + /// # Each half is independently load-bearing, and each was proved by a SEPARATE mutation + /// + /// One mutation cannot demonstrate both, because `TempDir`'s cleanup is a single `Drop` on + /// both paths: breaking it fails the success assertion first, and the unwind assertion never + /// gets to speak. So the halves were mutated separately. + /// + /// - **Success half:** `tempfile::Builder::disable_cleanup(true)` in [`tempdir`] — the first + /// assertion fails and the unwind half is never reached. + /// - **Unwind half:** both blocks rewritten to the pre-fix idiom this ticket removes — a raw + /// `create_dir_all` with a manual `remove_dir_all` at the end of the block. The success half + /// REACHES that line and passes; the panicking closure skips it, and the run fails at the + /// unwind assertion alone. That is the nearest wrong implementation — a merely tidier manual + /// cleanup — and this assertion is the only thing in the suite that rejects it. #[test] fn the_scratch_tree_is_removed_on_drop_and_on_an_unwind() { let on_success = { From 2225313d8c65a28feeb060daa3d08de5bfc12890 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 04:36:48 -0700 Subject: [PATCH 19/21] style: rustfmt the profile-sync scratch-guard test additions --- .../src/seams/dig_peer/profile_sync.rs | 13 ++++++-- crates/dig-wallet/tests/scratch_addr.rs | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 crates/dig-wallet/tests/scratch_addr.rs diff --git a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs index fdaf2dce..d99e8722 100644 --- a/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs +++ b/crates/dig-node-core/src/seams/dig_peer/profile_sync.rs @@ -1285,7 +1285,10 @@ mod tests { let on_success = { let dir = tempdir(); let path = dir.path().to_path_buf(); - assert!(path.is_dir(), "the helper must create the tree it hands back"); + assert!( + path.is_dir(), + "the helper must create the tree it hands back" + ); path }; assert!( @@ -2127,8 +2130,12 @@ mod tests { // A short name, an uppercase-hex name of the right length, and a loose file at the top of // the tree — each is 64-hex-adjacent and none of them is a store id. std::fs::create_dir_all(root_dir.path().join("not-a-store-id")).expect("dir"); - std::fs::create_dir_all(root_dir.path().join(hex::encode(store_id(9)).to_uppercase())) - .expect("dir"); + std::fs::create_dir_all( + root_dir + .path() + .join(hex::encode(store_id(9)).to_uppercase()), + ) + .expect("dir"); std::fs::write(root_dir.path().join("README.txt"), b"not a store").expect("file"); assert_eq!(store.held_pairs(), vec![(store_id(1), root)]); diff --git a/crates/dig-wallet/tests/scratch_addr.rs b/crates/dig-wallet/tests/scratch_addr.rs new file mode 100644 index 00000000..79b605e7 --- /dev/null +++ b/crates/dig-wallet/tests/scratch_addr.rs @@ -0,0 +1,30 @@ +//! Scratch tool (not committed): print each watched public key's p2 puzzle hash, its bech32m +//! address, and the $DIG CAT outer puzzle hash it owns coins at. + +use chia_bls::PublicKey; +use chia_puzzle_types::standard::StandardArgs; + +const KEYS: [&str; 3] = [ + "82a042f2a57c2863862a061700d9cf2650adede6f90aff662a73ed31c47f60511ac9dc4b8f276477c606ba9272a43de9", + "91e72e437529e82ebea7e4f973939791b5c7bca07a28862cfe2b96dbe4c8785650fbf4d37cfc7ee35632c028bf90a899", + "a652cdf7278788fc26be31b2a7935ebca074ae352d2dbf098e78a0ad3568fc48002cc6f02af37591f721e7d67a76ba64", +]; + +#[test] +fn print_addresses() { + for k in KEYS { + let bytes: [u8; 48] = hex::decode(k).unwrap().try_into().unwrap(); + let pk = PublicKey::from_bytes(&bytes).unwrap(); + let ph = StandardArgs::curry_tree_hash(pk); + let ph32 = chia_protocol::Bytes32::from(ph.to_bytes()); + let cat = digstore_chain::cat::cat_puzzle_hash(ph32, digstore_chain::dig::DIG_ASSET_ID); + println!( + "key={k}\n p2={}\n addr={}\n cat={}", + hex::encode(ph32), + chia_wallet_sdk::utils::Address::new(ph32, "xch".to_string()) + .encode() + .unwrap(), + hex::encode(cat) + ); + } +} From 0388e9dfc88e47953997daf97c03304547db22f6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 04:37:41 -0700 Subject: [PATCH 20/21] style: rustfmt this lane's test edits; drop a stray scratch file from the branch --- crates/dig-node-core/src/peer.rs | 3 +- .../src/seams/dig_peer/module_reshare.rs | 14 +- .../dig-node-core/src/seams/dig_peer/pex.rs | 3 +- .../dig-node-service/src/spend_audit_cli.rs | 3 +- .../src/sage/sync_supervisor/tests.rs | 14 +- crates/dig-wallet/src/sage/tipping.rs | 4166 +++++++++-------- crates/dig-wallet/tests/scratch_addr.rs | 30 - 7 files changed, 2117 insertions(+), 2116 deletions(-) delete mode 100644 crates/dig-wallet/tests/scratch_addr.rs diff --git a/crates/dig-node-core/src/peer.rs b/crates/dig-node-core/src/peer.rs index 60240887..b80d0c88 100644 --- a/crates/dig-node-core/src/peer.rs +++ b/crates/dig-node-core/src/peer.rs @@ -4118,7 +4118,8 @@ pub(crate) mod tests { // IPv6 loopback (§5.2 IPv6-first) so the inbound accept registers on every platform. let loopback_v6 = "[::1]:0".parse().expect("parse [::1]:0"); let (node_a, _node_a_dir) = fresh_pool_handle("loopback-a", [0x5au8; 32]).await; - let (node_b, _node_b_dir) = fresh_pool_handle_on("loopback-b", [0x5au8; 32], loopback_v6).await; + let (node_b, _node_b_dir) = + fresh_pool_handle_on("loopback-b", [0x5au8; 32], loopback_v6).await; let a_peer_id = hex::encode(node_a.local_peer_id().expect("node A local_peer_id")); let b_port = node_b diff --git a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs index 5194eceb..bf78a9f0 100644 --- a/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs +++ b/crates/dig-node-core/src/seams/dig_peer/module_reshare.rs @@ -2268,9 +2268,14 @@ mod tests { // Both capsules land in the SAME cache, which is what a node that both relays and reshares // actually looks like on disk. let spy = Arc::new(AnnounceSpy::default()); - let relayed = serving_warmer_for(dir.path(), &spy, STORE, module_committing(STORE, chain_root())) - .warm_relayed(&relay_store_hex, &root_hex) - .await; + let relayed = serving_warmer_for( + dir.path(), + &spy, + STORE, + module_committing(STORE, chain_root()), + ) + .warm_relayed(&relay_store_hex, &root_hex) + .await; let local = serving_warmer_for( dir.path(), &spy, @@ -2296,7 +2301,8 @@ mod tests { ); assert!( std::fs::read( - dir.path().join("cache") + dir.path() + .join("cache") .join("modules") .join(&relay_store_hex) .join(format!("{root_hex}.dig")) diff --git a/crates/dig-node-core/src/seams/dig_peer/pex.rs b/crates/dig-node-core/src/seams/dig_peer/pex.rs index e47533f6..2803e9a9 100644 --- a/crates/dig-node-core/src/seams/dig_peer/pex.rs +++ b/crates/dig-node-core/src/seams/dig_peer/pex.rs @@ -733,7 +733,8 @@ mod tests { let _ = rustls::crypto::ring::default_provider().install_default(); let network = [0x7bu8; 32]; - let (node_a, _node_a_dir) = crate::peer::tests::fresh_pool_handle("pex-adopt-a", network).await; + let (node_a, _node_a_dir) = + crate::peer::tests::fresh_pool_handle("pex-adopt-a", network).await; let (node_b, _node_b_dir) = crate::peer::tests::fresh_pool_handle_on( "pex-adopt-b", network, diff --git a/crates/dig-node-service/src/spend_audit_cli.rs b/crates/dig-node-service/src/spend_audit_cli.rs index 1ac38b32..71142010 100644 --- a/crates/dig-node-service/src/spend_audit_cli.rs +++ b/crates/dig-node-service/src/spend_audit_cli.rs @@ -468,8 +468,7 @@ mod tests { #[test] fn an_empty_record_says_no_money_moved_unattended() { let (log, _scratch) = tmp_log(); - let out = - run_against(&log, SpendsAction::List(SpendQuery::default()), None).expect("list"); + let out = run_against(&log, SpendsAction::List(SpendQuery::default()), None).expect("list"); assert_eq!(out.result["count"], 0); assert!( out.summary.contains("no money unattended"), diff --git a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs index 19d13516..d79581e5 100644 --- a/crates/dig-wallet/src/sage/sync_supervisor/tests.rs +++ b/crates/dig-wallet/src/sage/sync_supervisor/tests.rs @@ -3755,7 +3755,10 @@ fn unwatch_removes_the_address_from_the_subscription_set() { std::fs::create_dir_all(&dir).unwrap(); let registry = crate::sage::watchlist::WatchRegistry::new(dir.path()); registry.watch(&[registered_key(3), registered_key(4)]); - let union = UnionPuzzleHashSource::new(WalletCustody::open(dir.path().to_path_buf()), registry.clone()); + let union = UnionPuzzleHashSource::new( + WalletCustody::open(dir.path().to_path_buf()), + registry.clone(), + ); assert_eq!(union.puzzle_hashes().len(), 2); registry.unwatch(&[registered_key(3)]); @@ -3834,11 +3837,14 @@ fn an_enrolled_but_unreachable_custody_is_not_an_all_clear_through_the_union() { enrolled_custody(dir.path()); // Drop the manifest so it is rebuilt from the seed file alone, without public keys — one of the // four reachable states where an enrolled wallet derives no address. - std::fs::remove_file(dir.path().join("wallets").join("index.json")).expect("remove the manifest"); + std::fs::remove_file(dir.path().join("wallets").join("index.json")) + .expect("remove the manifest"); let healed = WalletCustody::open(dir.path().to_path_buf()); - let union = - UnionPuzzleHashSource::new(healed, crate::sage::watchlist::WatchRegistry::new(dir.path())); + let union = UnionPuzzleHashSource::new( + healed, + crate::sage::watchlist::WatchRegistry::new(dir.path()), + ); assert!( union.puzzle_hashes().is_empty(), diff --git a/crates/dig-wallet/src/sage/tipping.rs b/crates/dig-wallet/src/sage/tipping.rs index b5f170f4..4b7e8ebf 100644 --- a/crates/dig-wallet/src/sage/tipping.rs +++ b/crates/dig-wallet/src/sage/tipping.rs @@ -1,2074 +1,2092 @@ -//! The **tipping subsystem** (#378, child of the auto-tip epic #377). -//! -//! The dig-node OWNS tipping: it holds the wallet/keys and builds+signs+broadcasts the $DIG -//! spend. The extension (#379/#380) only CONFIGURES + DISPLAYS it over the WS wallet/control -//! transport (SPEC §4.8). This module is the node-side engine: -//! -//! - **Owner-PH lookup** — resolve a store's on-chain OWNER puzzle hash from its singleton -//! (the launcher id), cached per store ([`OwnerResolver`]). -//! - **Auto-tip policy engine** — a persisted [`TippingConfig`] (creator + dev-account policies) -//! with HARD budget caps (per-site/day AND a daily total) enforced FAIL-CLOSED, and -//! idempotency per `(site, day)` so a crash+retry never double-tips ([`TippingEngine`]). -//! - **Creator auto-tip is DEFAULT-ON** (a real on-chain-resolved recipient always exists). -//! - **DIG dev-account daily tip** — the SAME engine, a SEPARATE toggle. Recipient = the canonical -//! DIG treasury inner puzzle hash (the existing per-capsule-payment shared contract, sourced from -//! `digstore_chain::dig::treasury_inner_puzzle_hash()` via `dig_treasury_ph_hex`, never -//! re-hardcoded). A REAL recipient, so it is DEFAULT-ON with a small daily amount + the same caps. -//! - **Unattended execution** — when enabled + within budget the engine builds+signs+broadcasts -//! with NO user interaction, and skips cleanly when disabled/over-budget/already-tipped. -//! - **On-demand manual tip** — one-tap tip to a store's owner (explicit user consent; not -//! bounded by the auto caps, not subject to the once-per-day idempotency). -//! - **Tip ledger** — every reservation/tip is recorded (`recipient / amount / ts / txid / -//! auto|manual / creator|dev / status`), persisted, exposed via `get_ledger`, and PUSHED over -//! the WS wallet/control surface via a dedicated [`TipEventBus`] (kept OUT of the Sage-parity -//! `SyncEvent` union, [`super::events`]). -//! -//! ## Money-safety design (real mainnet $DIG) -//! -//! The engine is the single authorization+consent gate for a tip. Two properties are enforced -//! FAIL-CLOSED — a bug skips the tip, never over-spends: -//! -//! 1. **Hard caps** — a per-site/day cap AND a daily total cap (spanning creator + dev). Reserved -//! (pending), confirmed, AND ambiguous-failed amounts all count toward the caps, so an -//! in-flight or unknown-outcome tip can never be double-counted into an over-spend. -//! 2. **Crash-safe idempotency** — the ledger reservation (a `Pending` entry) is persisted to disk -//! IMMEDIATELY BEFORE the broadcast; the broadcast is the only money-moving step. So a crash at -//! ANY point leaves at most one reserved entry for a `(site, day)`, and on restart the engine -//! (re-loaded from the ledger file) treats that `(site, day)` as already tipped and SKIPS — it -//! errs toward under-tipping, never a double-spend. A definitively PRE-broadcast failure -//! (locked wallet / not-yet-synced / insufficient $DIG — [`TipSpendOutcome::NotExecutable`]) -//! rolls the reservation back so it can retry later; an AMBIGUOUS broadcast error keeps the -//! reservation (as `Failed`) so it is never retried that day. -//! -//! Broadcasting goes through the [`TipSpender`] seam. Tests inject a recording mock (or drive the -//! `chia-sdk-test` simulator via the underlying [`super::spend`] builders) — a real mainnet -//! broadcast is NEVER reached from a test. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use tokio::sync::{broadcast, Mutex}; - -use super::{Error, Result}; - -// ───────────────────────────────────────────────────────────────────────────── -// Dev-account recipient — the existing canonical DIG treasury shared contract -// ───────────────────────────────────────────────────────────────────────────── - -/// The DIG treasury / dev-fee recipient the dev-account daily tip pays: the SAME canonical -/// shared-contract puzzle hash that receives every per-capsule $DIG payment -/// (`digstore_chain::dig::treasury_inner_puzzle_hash()`, decoded from `TREASURY_ADDRESS` -/// `xch1a37rq3cgcl2ecpudttsf35x75qzdan68lgw2l6ajvmqs44jxdn5qv6pk3y` = -/// `ec7c304708c7d59c078d5ae098d0dea004decf47fa1cafebb266c10ad6466ce8`; mirrored byte-identical in -/// chip35 + dighub-core). It is a REAL recipient, so the dev-account daily tip is DEFAULT-ON (#377) -/// with the same hard caps as creator tips. Sourced from the shared contract — NEVER re-hardcoded -/// here — so a payment-critical value can't drift into a 4th copy. The tip's CAT spend targets this -/// inner PH exactly as the per-capsule payment does (`Cat::spend_all` CAT-wraps it). -fn dig_treasury_ph_hex() -> String { - hex::encode(digstore_chain::dig::treasury_inner_puzzle_hash()) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Sensible small default amounts ($DIG has 3 decimals — 1 DIG = 1000 base units) -// ───────────────────────────────────────────────────────────────────────────── - -/// Base units per whole $DIG (`digstore_chain::dig::DIG_DECIMALS == 3`). -pub const DIG_BASE_UNITS: u64 = 1_000; - -/// Default creator tip per site/day = 0.1 $DIG. -pub const DEFAULT_CREATOR_TIP: u64 = DIG_BASE_UNITS / 10; -/// Default per-site/day cap for creator tips = 0.1 $DIG. -pub const DEFAULT_PER_SITE_CAP: u64 = DIG_BASE_UNITS / 10; -/// Default daily TOTAL cap across ALL auto tips (creator + dev) = 1 $DIG. -pub const DEFAULT_DAILY_TOTAL_CAP: u64 = DIG_BASE_UNITS; -/// Default dev-account daily tip = 0.1 $DIG. -pub const DEFAULT_DEV_TIP: u64 = DIG_BASE_UNITS / 10; -/// Default XCH fee per tip spend (0 — a low-priority tip needs no fee at normal congestion; the -/// user can raise it in config). -pub const DEFAULT_TIP_FEE: u64 = 0; - -// ───────────────────────────────────────────────────────────────────────────── -// Config -// ───────────────────────────────────────────────────────────────────────────── - -/// How an auto-tip policy meters spending across a day. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum TipMode { - /// Tip each consumed site at most once per day, `dig_amount` each, bounded by the per-site - /// cap AND the daily total cap. - PerSitePerDay, - /// A single daily budget pool: tip each consumed site once per day drawing from the pool - /// until the daily total cap is exhausted (the per-site cap is not separately enforced). - DailyBudget, -} - -/// One auto-tip policy (creator OR dev). The two share the top-level [`TippingConfig::daily_total_cap`]. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AutoTipPolicy { - /// Whether this policy tips automatically (unattended). - pub enabled: bool, - /// The tip amount per site/day, in $DIG base units. - pub dig_amount: u64, - /// How the policy meters spend across a day. - pub mode: TipMode, - /// The hard per-site/day ceiling in base units (enforced in [`TipMode::PerSitePerDay`]). - pub per_site_cap: u64, - /// Per-site amount overrides (site key = owner puzzle-hash hex → base units). - #[serde(default)] - pub per_site_overrides: HashMap, -} - -/// The persisted tipping configuration. Both creator AND dev-account auto-tip are DEFAULT-ON (#377): -/// each has a real recipient — the creator's is the on-chain-resolved store owner PH, the -/// dev-account's is the existing DIG treasury inner PH shared contract -/// (`digstore_chain::dig::treasury_inner_puzzle_hash()`), never a placeholder. Safe out of the box -/// paired with the honest-default disclosure + one-click-off (§6.0, #207). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TippingConfig { - /// Creator auto-tip (pays the on-chain-resolved store owner). - pub creator: AutoTipPolicy, - /// DIG dev-account daily tip (pays the DIG treasury shared contract, `dig_treasury_ph_hex`). - pub dev: AutoTipPolicy, - /// The HARD daily total cap in base units, spanning creator + dev auto tips. - pub daily_total_cap: u64, - /// The XCH fee applied to each tip spend. - pub fee: u64, -} - -impl Default for TippingConfig { - fn default() -> Self { - Self { - creator: AutoTipPolicy { - enabled: true, // DEFAULT-ON (#377): a real on-chain recipient always exists. - dig_amount: DEFAULT_CREATOR_TIP, - mode: TipMode::PerSitePerDay, - per_site_cap: DEFAULT_PER_SITE_CAP, - per_site_overrides: HashMap::new(), - }, - dev: AutoTipPolicy { - // DEFAULT-ON (#377): the recipient is the REAL DIG treasury shared contract, so a - // small daily "support DIG itself" tip is safe out of the box (hard caps + ledger). - enabled: true, - dig_amount: DEFAULT_DEV_TIP, - mode: TipMode::PerSitePerDay, - per_site_cap: DEFAULT_DEV_TIP, - per_site_overrides: HashMap::new(), - }, - daily_total_cap: DEFAULT_DAILY_TOTAL_CAP, - fee: DEFAULT_TIP_FEE, - } - } -} - -impl TippingConfig { - /// The FAIL-CLOSED config: both policies DISABLED. Used when the persisted config is present - /// but unreadable — a corrupt/locked config file must NEVER silently fall back to the - /// DEFAULT-ON config (which would move real $DIG against a user who had disabled auto-tip). - fn disabled() -> Self { - let mut c = Self::default(); - c.creator.enabled = false; - c.dev.enabled = false; - c - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Ledger -// ───────────────────────────────────────────────────────────────────────────── - -/// Which policy a ledger entry belongs to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TipKind { - /// A tip to a content creator (store owner). - Creator, - /// A tip to the DIG dev account. - Dev, -} - -/// Whether a tip was fired by the auto policy or by an explicit user tap. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TipTrigger { - /// Unattended auto-tip (governed by caps + idempotency). - Auto, - /// Explicit one-tap manual tip (user consent; not bounded by the auto caps/idempotency). - Manual, -} - -/// The lifecycle status of a ledger entry. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TipStatus { - /// Reserved before broadcast (money may or may not have moved). Counts toward caps + blocks a - /// same-day retry — the crash-safety reservation. - Pending, - /// The broadcast was accepted by the network. `txid` is set. - Confirmed, - /// An AMBIGUOUS broadcast failure (the tx may have entered a mempool). Kept, counts toward - /// caps, and is NOT retried that day (fail-closed — never double-spend). - Failed, -} - -/// One tip ledger entry (`recipient / amount / ts / txid / auto|manual / creator|dev / status`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TipLedgerEntry { - /// A stable, monotonically-increasing id (the extension can key rows by it). - pub id: u64, - /// The recipient puzzle hash (lowercase hex, no `0x`). - pub recipient_ph: String, - /// The store the tip was for (launcher-id hex); `None` for a dev-account tip. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub store_id: Option, - /// The tip amount in $DIG base units. - pub dig_amount: u64, - /// Unix seconds when the entry was reserved. - pub ts: u64, - /// The UTC day bucket (`YYYY-MM-DD`) — the idempotency key alongside `recipient_ph`/`kind`. - pub day: String, - /// The broadcast transaction id (spend-bundle name hex); `None` until confirmed. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub txid: Option, - /// Auto vs manual. - pub trigger: TipTrigger, - /// Creator vs dev. - pub kind: TipKind, - /// The lifecycle status. - pub status: TipStatus, -} - -/// The result of a tip decision — either a tip happened or it was skipped (with a machine-stable -/// reason so the extension can render "already tipped today", "over budget", etc.). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "result", rename_all = "snake_case")] -pub enum TipOutcome { - /// A tip was built, signed, and broadcast. Money moved. - Tipped { - /// The broadcast transaction id. - txid: String, - /// The amount tipped, in base units. - dig_amount: u64, - /// The recipient puzzle hash (hex). - recipient_ph: String, - }, - /// No tip happened. `reason` is a stable machine token. - Skipped { - /// Why the tip was skipped. - reason: String, - }, -} - -impl TipOutcome { - fn skipped(reason: impl Into) -> Self { - TipOutcome::Skipped { - reason: reason.into(), - } - } -} - -/// The outcome of the wallet's attempt to build+broadcast a tip spend (the [`TipSpender`] result). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TipSpendOutcome { - /// The spend was built, signed, validated, and BROADCAST. Money moved. `txid` = bundle name. - Broadcast { - /// The broadcast transaction id (spend-bundle name hex). - txid: String, - /// Whether the spend was CONFIRMED on-chain within the confirmer's window (§18.12). `true` - /// ⇒ a block included it (ledger status `Confirmed`); `false` ⇒ accepted into the mempool - /// but not yet confirmed (ledger status `Pending`, txid set — money moved, confirmation is - /// asynchronous, and the reservation blocks a same-day retry either way). - confirmed: bool, - }, - /// The wallet cannot currently build/broadcast the tip (no signing key / not-yet-synced / no - /// lineage / insufficient $DIG). Definitively PRE-broadcast — no money moved; the caller may - /// retry later. - NotExecutable { - /// A human-readable reason. When the refusal is signer-absence, it is exactly one of - /// [`NO_SIGNER_CONFIGURED`], [`WALLET_ENROLLED_BUT_UNOPENABLE`] or [`NO_WALLET_ENROLLED`]. - reason: String, - }, -} - -/// Why a tip refusal happened when no signing key could be resolved (#410). -/// -/// These are the exact `TipSpendOutcome::NotExecutable::reason` strings for the three signer-absence -/// states a [`super::rpc::WalletBackend`] can actually OBSERVE, published as constants so a caller -/// (and a test) can match a refusal by equality rather than by reading prose. -/// -/// They exist because the single reason they replace — `"wallet is locked"` — was false in the state -/// a shipped node is always in. Nothing attaches a signer to the served backend (`with_signer` has no -/// non-test caller), so a user with a perfectly unlocked wallet was told to unlock it, would try, and -/// would get nowhere. Each string below therefore describes a state the user can check, and none of -/// them asks for an unlock that would not help. -/// -/// A fourth state, `Orphaned` (a sealed seed whose device key is gone, -/// [`crate::autoseed::BootstrapState::Orphaned`]), is deliberately NOT represented: it is decided at -/// bootstrap from paths the backend does not hold, and [`super::custody::CustodyState`] has no -/// variant for it. Minting a reason the backend cannot distinguish would reintroduce exactly the -/// defect this fixes. -pub mod refusal { - /// No signing key and no custody view at all — this backend was built without either, so it - /// could never spend. There is no wallet state for the user to change. - pub const NO_SIGNER_CONFIGURED: &str = - "no signing key is configured on this node, so it cannot sign a tip"; - - /// A wallet IS enrolled on this device, and the node cannot open its sealed seed. Node-managed - /// unlock was removed (SPEC §18.24), so this is not a lock the user can open from here. - pub const WALLET_ENROLLED_BUT_UNOPENABLE: &str = - "a wallet is enrolled on this device but this node cannot open its sealed seed, so it cannot sign a tip"; - - /// Custody is attached and holds no wallet — nothing is enrolled to sign with. - pub const NO_WALLET_ENROLLED: &str = - "no wallet is enrolled on this device, so it cannot sign a tip"; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Seams (traits) — injected so the money-safety logic is testable without a chain -// ───────────────────────────────────────────────────────────────────────────── - -/// Resolves a store's on-chain OWNER puzzle hash from its singleton (launcher id). -#[async_trait] -pub trait OwnerResolver: Send + Sync { - /// Return the owner puzzle hash (lowercase hex, no `0x`) of the store `store_id_hex` - /// (launcher-id hex), or `None` when the store singleton cannot be found on chain. - async fn resolve_owner(&self, store_id_hex: &str) -> Result>; -} - -/// Builds+signs+validates+broadcasts a $DIG tip. The ONLY component that moves money. -/// -/// Every caller has already enforced enabled + caps + idempotency (and the fail-closed -/// unreadable-state guard); the ledger reservation is persisted BEFORE this is invoked -/// (crash-safety). The contract: -/// `Ok(Broadcast)` = accepted by the network; `Ok(NotExecutable)` = definitively pre-broadcast -/// (safe to retry); `Err` = an AMBIGUOUS broadcast failure (the engine keeps the reservation and -/// does not retry that day — fail-closed). -#[async_trait] -pub trait TipSpender: Send + Sync { - /// Send `amount` base units of $DIG to `recipient_ph_hex` with an XCH `fee`. - async fn send_dig_tip( - &self, - recipient_ph_hex: &str, - amount: u64, - fee: u64, - ) -> Result; -} - -/// A wall clock (injected so tests can pin "today"). -pub trait Clock: Send + Sync { - /// Current unix time in seconds. - fn now_unix(&self) -> u64; - /// Today's UTC date as `YYYY-MM-DD` (the idempotency day bucket). - fn today_utc(&self) -> String { - unix_to_utc_date(self.now_unix()) - } -} - -/// The production system clock. -pub struct SystemClock; - -impl Clock for SystemClock { - fn now_unix(&self) -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - } -} - -/// Convert unix seconds to a UTC `YYYY-MM-DD` string (Howard Hinnant's civil-from-days algorithm — -/// dependency-free, so the day boundary needs no `chrono`). -fn unix_to_utc_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; // days since 1970-01-01 (UTC) - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; // [0, 146096] - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] - let mut y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] - if m <= 2 { - y += 1; - } - format!("{y:04}-{m:02}-{d:02}") -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tip event bus (WS push) — SEPARATE from the Sage-parity SyncEvent union -// ───────────────────────────────────────────────────────────────────────────── - -/// A tip event pushed to connected WS clients when a tip is recorded (SPEC §4.8 `{type:"tip"}`). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TipEvent { - /// The recorded ledger entry. - pub entry: TipLedgerEntry, -} - -/// An in-process publish/subscribe bus for [`TipEvent`]s. Deliberately DISTINCT from the -/// Sage-parity [`super::events::EventBus`] so DIG-specific tip events never leak into the -/// byte-parity Sage `SyncEvent` stream (`GET /events`). Cheap to clone; a publish with no -/// subscribers is a harmless no-op. -#[derive(Clone)] -pub struct TipEventBus { - tx: broadcast::Sender, -} - -impl TipEventBus { - /// A bus with the given per-subscriber buffer capacity. - pub fn with_capacity(capacity: usize) -> Self { - let (tx, _rx) = broadcast::channel(capacity.max(1)); - Self { tx } - } - /// Publish to every current subscriber (no-op with no listeners). - pub fn publish(&self, event: TipEvent) { - let _ = self.tx.send(event); - } - /// Subscribe to future tip events. - pub fn subscribe(&self) -> broadcast::Receiver { - self.tx.subscribe() - } - /// The current subscriber count (test/diagnostic). - pub fn subscriber_count(&self) -> usize { - self.tx.receiver_count() - } -} - -impl Default for TipEventBus { - fn default() -> Self { - Self::with_capacity(64) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Engine -// ───────────────────────────────────────────────────────────────────────────── - -/// The in-memory config + ledger, guarded as one unit so idempotency + cap checks and the -/// reservation write are atomic. -#[derive(Debug, Default)] -struct TippingState { - config: TippingConfig, - ledger: Vec, - next_id: u64, -} - -impl TippingState { - /// Sum of the amounts of all reserved/attempted AUTO entries on `day` (any kind) — the daily - /// total cap basis. Pending/Confirmed/Failed all count (fail-closed). - fn auto_spent_today(&self, day: &str) -> u64 { - self.ledger - .iter() - .filter(|e| e.trigger == TipTrigger::Auto && e.day == day) - .map(|e| e.dig_amount) - .fold(0, u64::saturating_add) - } - - /// Sum of reserved/attempted AUTO amounts for one `(kind, recipient, day)` — the per-site cap - /// basis. - fn auto_site_spent_today(&self, kind: TipKind, recipient: &str, day: &str) -> u64 { - self.ledger - .iter() - .filter(|e| { - e.trigger == TipTrigger::Auto - && e.kind == kind - && e.recipient_ph == recipient - && e.day == day - }) - .map(|e| e.dig_amount) - .fold(0, u64::saturating_add) - } - - /// Whether an AUTO tip for `(kind, recipient, day)` has already been reserved/attempted (the - /// once-per-site-per-day idempotency invariant). - fn auto_already_reserved(&self, kind: TipKind, recipient: &str, day: &str) -> bool { - self.ledger.iter().any(|e| { - e.trigger == TipTrigger::Auto - && e.kind == kind - && e.recipient_ph == recipient - && e.day == day - }) - } - - /// Push a reservation and return its id. - fn reserve(&mut self, mut entry: TipLedgerEntry) -> u64 { - let id = self.next_id; - self.next_id += 1; - entry.id = id; - self.ledger.push(entry); - id - } - - fn entry_mut(&mut self, id: u64) -> Option<&mut TipLedgerEntry> { - self.ledger.iter_mut().find(|e| e.id == id) - } - - fn remove(&mut self, id: u64) { - self.ledger.retain(|e| e.id != id); - } -} - -/// The persisted-on-disk ledger shape (a versioned wrapper so future fields are additive). -#[derive(Debug, Default, Serialize, Deserialize)] -struct LedgerFile { - #[serde(default)] - next_id: u64, - #[serde(default)] - entries: Vec, -} - -/// The node-side tipping engine (SPEC §18.23). Owns the persisted config + ledger, the owner -/// resolver (cached per store), the tip spender, and the tip-event bus. -pub struct TippingEngine { - config_path: PathBuf, - ledger_path: PathBuf, - state: Mutex, - owner: Box, - spender: Box, - clock: Box, - events: std::sync::Arc, - owner_cache: Mutex>, - /// FAIL-CLOSED poison: `Some(reason)` when the persisted config OR ledger was present on disk - /// but could not be read/parsed at load. A money ledger that can't be read MUST NEVER degrade - /// to "empty → tip freely" (that would reset the cap + idempotency accounting and re-tip the - /// full daily budget on every restart, double-spending sites already tipped). While poisoned, - /// EVERY tip (auto + manual) and config mutation is REFUSED until the operator resolves the - /// file and restarts. Set only at [`Self::load`]; immutable thereafter (no lock needed). - poison: Option, -} - -impl TippingEngine { - /// Load the engine from `` (reading `tipping-config.json` + `tip-ledger.json`), - /// wiring the owner resolver, tip spender, clock, and tip-event bus. - /// - /// FAIL-CLOSED distinction: a file that is ABSENT is a genuine first run (config → DEFAULT-ON, - /// ledger → empty). A file that is PRESENT but unreadable/unparseable POISONS the engine — the - /// config falls back to DISABLED (never re-enables auto-tip) and every tip/mutation is refused - /// until the operator resolves it. A transiently-locked or corrupt/truncated ledger can thus - /// never silently reset the caps + idempotency accounting. - pub fn load( - config_dir: &Path, - owner: Box, - spender: Box, - clock: Box, - events: std::sync::Arc, - ) -> Self { - let config_path = config_dir.join("tipping-config.json"); - let ledger_path = config_dir.join("tip-ledger.json"); - let mut poison: Option = None; - - // Config: absent → DEFAULT-ON (genuine first run). Present-but-unreadable → FAIL CLOSED - // (DISABLED) + poison, so a corrupt config never silently re-enables auto-tip. - let config = match read_json_strict::(&config_path) { - Ok(Some(c)) => c, - Ok(None) => TippingConfig::default(), - Err(e) => { - eprintln!( - "dig-node: WARN tipping config present but unreadable — auto-tip DISABLED \ - (fail-closed) until resolved: {e}" - ); - add_poison(&mut poison, format!("config unreadable: {e}")); - TippingConfig::disabled() - } - }; - - // Ledger: absent → empty (first run). Present-but-unreadable → FAIL CLOSED + poison, so the - // caps + idempotency accounting can NEVER reset to "empty → tip freely". - let (ledger, next_id) = match read_json_strict::(&ledger_path) { - Ok(Some(f)) => { - let next_id = f - .entries - .iter() - .map(|e| e.id + 1) - .chain(std::iter::once(f.next_id)) - .max() - .unwrap_or(0); - (f.entries, next_id) - } - Ok(None) => (Vec::new(), 0), - Err(e) => { - eprintln!( - "dig-node: WARN tip ledger present but unreadable — ALL tips REFUSED \ - (fail-closed) until resolved: {e}" - ); - add_poison(&mut poison, format!("ledger unreadable: {e}")); - (Vec::new(), 0) - } - }; - - let state = TippingState { - config, - ledger, - next_id, - }; - Self { - config_path, - ledger_path, - state: Mutex::new(state), - owner, - spender, - clock, - events, - owner_cache: Mutex::new(HashMap::new()), - poison, - } - } - - /// If the engine is poisoned (unreadable persisted state at load), the machine-stable skip - /// reason; else `None`. Every spend/mutation path consults this FIRST and fails closed. - fn poisoned(&self) -> Option { - self.poison - .as_ref() - .map(|r| TipOutcome::skipped(format!("state-unreadable: {r}"))) - } - - /// The tip-event bus WS sessions subscribe to (SPEC §4.8 `{type:"tip"}` push). - pub fn events(&self) -> &std::sync::Arc { - &self.events - } - - /// The current tipping configuration. - pub async fn get_config(&self) -> TippingConfig { - self.state.lock().await.config.clone() - } - - /// Replace + persist the tipping configuration. REFUSED while poisoned — writing a fresh config - /// over an unreadable persisted state would mask the problem (and could clobber a ledger whose - /// contents we could not read); the operator must resolve the file and restart. - pub async fn set_config(&self, config: TippingConfig) -> Result<()> { - if let Some(reason) = &self.poison { - return Err(Error::internal(format!( - "tipping state is unreadable ({reason}); resolve the file and restart before \ - changing config" - ))); - } - let mut st = self.state.lock().await; - st.config = config; - write_json(&self.config_path, &st.config) - } - - /// The tip ledger, newest first. `since_ts` (unix seconds) optionally filters older entries. - pub async fn get_ledger(&self, since_ts: Option) -> Vec { - let st = self.state.lock().await; - let mut out: Vec = st - .ledger - .iter() - .filter(|e| match since_ts { - Some(t) => e.ts >= t, - None => true, - }) - .cloned() - .collect(); - out.sort_by(|a, b| b.ts.cmp(&a.ts).then(b.id.cmp(&a.id))); - out - } - - /// Resolve `store_id_hex`'s owner puzzle hash (hex), caching the result per store. - async fn resolve_owner_cached(&self, store_id_hex: &str) -> Result> { - let key = normalize_hex(store_id_hex); - if let Some(ph) = self.owner_cache.lock().await.get(&key).cloned() { - return Ok(Some(ph)); - } - match self.owner.resolve_owner(&key).await? { - Some(ph) => { - let ph = normalize_hex(&ph); - self.owner_cache.lock().await.insert(key, ph.clone()); - Ok(Some(ph)) - } - None => Ok(None), - } - } - - /// Run the CREATOR auto-tip for a consumed store. Resolves the owner, then tips per the creator - /// policy — idempotent per `(owner, day)`, fail-closed on the per-site + daily caps. A no-op - /// (clean `Skipped`) when disabled / over-budget / already-tipped / owner-unresolvable. - pub async fn auto_tip_for_store(&self, store_id_hex: &str) -> Result { - // FAIL CLOSED: unreadable persisted state → refuse (never re-tip a possibly-already-tipped - // site with a reset ledger). - if let Some(skip) = self.poisoned() { - return Ok(skip); - } - let (enabled, amount, per_site_cap, mode, daily_total_cap) = { - let st = self.state.lock().await; - let c = &st.config.creator; - ( - c.enabled, - c.dig_amount, - c.per_site_cap, - c.mode, - st.config.daily_total_cap, - ) - }; - if !enabled { - return Ok(TipOutcome::skipped("disabled")); - } - let Some(owner) = self - .resolve_owner_cached(store_id_hex) - .await - .unwrap_or(None) - else { - return Ok(TipOutcome::skipped("owner-unresolved")); - }; - let amount = { - let st = self.state.lock().await; - *st.config - .creator - .per_site_overrides - .get(&owner) - .unwrap_or(&amount) - }; - self.reserve_and_spend(ReserveArgs { - kind: TipKind::Creator, - trigger: TipTrigger::Auto, - recipient: owner, - store_id: Some(normalize_hex(store_id_hex)), - amount, - per_site_cap, - mode, - daily_total_cap, - }) - .await - } - - /// Run the DIG dev-account daily tip — the "support DIG itself" contribution. Recipient = the - /// canonical DIG treasury shared contract (`dig_treasury_ph_hex`); idempotent per day, bounded - /// by the daily total cap. A no-op when disabled / over-budget / already-tipped-today, and - /// fail-closed while the persisted state is unreadable. - pub async fn dev_daily_tip(&self) -> Result { - if let Some(skip) = self.poisoned() { - return Ok(skip); - } - let (enabled, amount, mode, daily_total_cap) = { - let st = self.state.lock().await; - let d = &st.config.dev; - (d.enabled, d.dig_amount, d.mode, st.config.daily_total_cap) - }; - if !enabled { - return Ok(TipOutcome::skipped("disabled")); - } - self.reserve_and_spend(ReserveArgs { - kind: TipKind::Dev, - trigger: TipTrigger::Auto, - recipient: dig_treasury_ph_hex(), - store_id: None, - amount, - per_site_cap: u64::MAX, // the dev tip is bounded only by the daily total cap. - mode, - daily_total_cap, - }) - .await - } - - /// A one-tap MANUAL tip to a store's owner. Explicit user consent: NOT bounded by the auto - /// caps and NOT subject to the once-per-day idempotency (a user may tip repeatedly). Still - /// recorded + crash-safe (reservation before broadcast). - pub async fn manual_tip(&self, store_id_hex: &str) -> Result { - // FAIL CLOSED even for a manual tip: an unreadable ledger means we can't safely append - // (we'd clobber entries we couldn't read); refuse until resolved. - if let Some(skip) = self.poisoned() { - return Ok(skip); - } - let Some(owner) = self - .resolve_owner_cached(store_id_hex) - .await - .unwrap_or(None) - else { - return Ok(TipOutcome::skipped("owner-unresolved")); - }; - let (amount, fee) = { - let st = self.state.lock().await; - (st.config.creator.dig_amount, st.config.fee) - }; - let amount = { - let st = self.state.lock().await; - *st.config - .creator - .per_site_overrides - .get(&owner) - .unwrap_or(&amount) - }; - // Reserve (always — no idempotency/caps for a manual tip), then spend + reconcile. - let day = self.clock.today_utc(); - let ts = self.clock.now_unix(); - let id = { - let mut st = self.state.lock().await; - let id = st.reserve(TipLedgerEntry { - id: 0, - recipient_ph: owner.clone(), - store_id: Some(normalize_hex(store_id_hex)), - dig_amount: amount, - ts, - day, - txid: None, - trigger: TipTrigger::Manual, - kind: TipKind::Creator, - status: TipStatus::Pending, - }); - self.persist_ledger(&st)?; - id - }; - self.spend_and_reconcile(id, owner, amount, fee).await - } - - /// The shared auto-tip reserve→spend→reconcile path: authoritative idempotency + cap checks - /// under the lock, a persisted PENDING reservation BEFORE the broadcast, then reconcile. - async fn reserve_and_spend(&self, args: ReserveArgs) -> Result { - let day = self.clock.today_utc(); - let ts = self.clock.now_unix(); - let fee = { self.state.lock().await.config.fee }; - let id = { - let mut st = self.state.lock().await; - // Idempotency (never double-tip a site in a day) — authoritative under the lock. - if st.auto_already_reserved(args.kind, &args.recipient, &day) { - return Ok(TipOutcome::skipped("already-tipped-today")); - } - // Per-site cap (PerSitePerDay mode only) — fail-closed. - if args.mode == TipMode::PerSitePerDay { - let site = st.auto_site_spent_today(args.kind, &args.recipient, &day); - if site.saturating_add(args.amount) > args.per_site_cap { - return Ok(TipOutcome::skipped("over-per-site-cap")); - } - } - // Daily total cap (creator + dev) — fail-closed. - let total = st.auto_spent_today(&day); - if total.saturating_add(args.amount) > args.daily_total_cap { - return Ok(TipOutcome::skipped("over-daily-cap")); - } - let id = st.reserve(TipLedgerEntry { - id: 0, - recipient_ph: args.recipient.clone(), - store_id: args.store_id.clone(), - dig_amount: args.amount, - ts, - day: day.clone(), - txid: None, - trigger: args.trigger, - kind: args.kind, - status: TipStatus::Pending, - }); - // Persist the reservation BEFORE the broadcast (crash-safety). - self.persist_ledger(&st)?; - id - }; - self.spend_and_reconcile(id, args.recipient, args.amount, fee) - .await - } - - /// Broadcast the reserved tip (money moves here) and reconcile the reservation: confirm on a - /// broadcast, roll back on a definitively-pre-broadcast NotExecutable (retryable), or keep as - /// Failed on an ambiguous error (fail-closed — never retried that day). - async fn spend_and_reconcile( - &self, - id: u64, - recipient: String, - amount: u64, - fee: u64, - ) -> Result { - let outcome = self.spender.send_dig_tip(&recipient, amount, fee).await; - let mut st = self.state.lock().await; - match outcome { - Ok(TipSpendOutcome::Broadcast { txid, confirmed }) => { - if let Some(e) = st.entry_mut(id) { - // Confirm-before-marking-confirmed (§18.12): a broadcast that was included in a - // block is `Confirmed`; one accepted into the mempool but not yet confirmed - // stays `Pending` with its txid (money moved — the reservation still blocks a - // same-day retry, and Pending amounts count toward the caps, so this never - // enables a double-spend). - e.status = if confirmed { - TipStatus::Confirmed - } else { - TipStatus::Pending - }; - e.txid = Some(txid.clone()); - } - self.persist_ledger(&st)?; - let entry = st.ledger.iter().find(|e| e.id == id).cloned(); - drop(st); - if let Some(entry) = entry { - self.events.publish(TipEvent { entry }); - } - Ok(TipOutcome::Tipped { - txid, - dig_amount: amount, - recipient_ph: recipient, - }) - } - Ok(TipSpendOutcome::NotExecutable { reason }) => { - // No money moved → roll the reservation back so it can retry later. - st.remove(id); - self.persist_ledger(&st)?; - Ok(TipOutcome::skipped(format!("wallet-unavailable: {reason}"))) - } - Err(e) => { - // Ambiguous broadcast failure → keep the reservation (Failed) so this (site, day) - // is NEVER retried (fail-closed: never double-spend). - if let Some(entry) = st.entry_mut(id) { - entry.status = TipStatus::Failed; - } - self.persist_ledger(&st)?; - Ok(TipOutcome::skipped(format!( - "spend-failed-not-retried: {e}" - ))) - } - } - } - - /// Persist the ledger atomically (temp file + rename) while the state lock is held. - fn persist_ledger(&self, st: &TippingState) -> Result<()> { - let file = LedgerFile { - next_id: st.next_id, - entries: st.ledger.clone(), - }; - write_json(&self.ledger_path, &file) - } -} - -/// Arguments for [`TippingEngine::reserve_and_spend`] (grouped to keep the signature honest). -struct ReserveArgs { - kind: TipKind, - trigger: TipTrigger, - recipient: String, - store_id: Option, - amount: u64, - per_site_cap: u64, - mode: TipMode, - daily_total_cap: u64, -} - -// ───────────────────────────────────────────────────────────────────────────── -// Small helpers (hex normalization + atomic JSON persistence) -// ───────────────────────────────────────────────────────────────────────────── - -/// Normalize a puzzle-hash / store-id hex to lowercase without a `0x` prefix. -fn normalize_hex(s: &str) -> String { - s.strip_prefix("0x") - .or_else(|| s.strip_prefix("0X")) - .unwrap_or(s) - .to_ascii_lowercase() -} - -/// Accumulate a poison reason (comma-joined) so BOTH an unreadable config AND an unreadable ledger -/// are recorded. -fn add_poison(poison: &mut Option, reason: String) { - *poison = Some(match poison.take() { - Some(p) => format!("{p}; {reason}"), - None => reason, - }); -} - -/// Read + deserialize a JSON file, distinguishing ABSENT from PRESENT-BUT-UNREADABLE (FAIL-CLOSED, -/// the money-safety contract): -/// - `Ok(None)` — the file does not exist (a genuine first run: caller defaults/empties). -/// - `Ok(Some(T))` — the file exists and parsed. -/// - `Err(_)` — the file EXISTS but could not be read (locked/permission/IO) OR could not be parsed -/// (corrupt/truncated/forward-incompatible). The caller MUST fail closed — NEVER treat this as -/// "empty/default", which would reset caps + idempotency (over-spend) or re-enable a disabled -/// auto-tip. `unwrap_or_default()` on this result would reintroduce the fail-open bug. -fn read_json_strict(path: &Path) -> Result> { - match std::fs::read(path) { - Ok(bytes) => { - let value = serde_json::from_slice(&bytes).map_err(|e| { - Error::internal(format!( - "{} is present but unparseable: {e}", - path.display() - )) - })?; - Ok(Some(value)) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(Error::internal(format!( - "{} is present but unreadable: {e}", - path.display() - ))), - } -} - -/// Serialize + write a JSON file DURABLY: write a temp file in the same dir, `fsync` it, atomically -/// `rename` it into place, then best-effort `fsync` the parent directory — so a crash/power-loss -/// can never leave a truncated/zero-length money ledger (which would then hit the fail-closed -/// read path on the next load). Owner-only best effort. The wallet crate carries its own helper -/// (the node's `control::write_atomic` lives in the service crate). -fn write_json(path: &Path, value: &T) -> Result<()> { - use std::io::Write; - let bytes = serde_json::to_vec_pretty(value) - .map_err(|e| Error::internal(format!("serialize {}: {e}", path.display())))?; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let tmp = path.with_extension(format!("tmp-{}-{nanos}", std::process::id())); - { - let mut f = std::fs::File::create(&tmp) - .map_err(|e| Error::internal(format!("create {}: {e}", tmp.display())))?; - f.write_all(&bytes) - .map_err(|e| Error::internal(format!("write {}: {e}", tmp.display())))?; - // fsync the file contents BEFORE the rename so the rename can only ever expose a fully - // durable file. - f.sync_all() - .map_err(|e| Error::internal(format!("fsync {}: {e}", tmp.display())))?; - } - std::fs::rename(&tmp, path) - .map_err(|e| Error::internal(format!("rename into {}: {e}", path.display())))?; - // Best-effort fsync of the parent dir so the rename itself is durable (a no-op / not permitted - // on some platforms — e.g. opening a directory as a file on Windows — hence best-effort). - if let Some(parent) = path.parent() { - if let Ok(dir) = std::fs::File::open(parent) { - let _ = dir.sync_all(); - } - } - Ok(()) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Production seam implementations -// ───────────────────────────────────────────────────────────────────────────── - -/// The production owner resolver: resolves a store's owner puzzle hash from its on-chain CHIP-0035 -/// singleton via `digstore_chain::singleton::sync_datastore` — the SAME DataStore parser the node -/// already uses for store sync (never re-parsing a singleton by hand). The chain client is a -/// [`digstore_chain::coinset::ChainReads`] (coinset.org, [`Coinset::mainnet`]); it is a swappable -/// seam — a `chia-query`-backed `ChainReads` (decentralized peers + coinset fallback), which already -/// backs the node's coin-read fallback tier, is a drop-in when a full `ChainReads` over it lands. -pub struct ChainOwnerResolver { - chain: std::sync::Arc, -} - -impl ChainOwnerResolver { - /// Resolve owners against mainnet coinset.org. - pub fn mainnet() -> Self { - Self { - chain: std::sync::Arc::new(digstore_chain::coinset::Coinset::mainnet()), - } - } - - /// Resolve owners against a supplied chain client (tests / a custom substrate). - pub fn with_chain(chain: std::sync::Arc) -> Self { - Self { chain } - } -} - -#[async_trait] -impl OwnerResolver for ChainOwnerResolver { - async fn resolve_owner(&self, store_id_hex: &str) -> Result> { - let launcher = super::singleton::bytes32_from_hex(store_id_hex)?; - match digstore_chain::singleton::sync_datastore(self.chain.as_ref(), launcher).await { - Ok(store) => Ok(Some(hex::encode(store.info.owner_puzzle_hash))), - // A not-yet-minted / unknown store is a clean "no owner" (the engine skips, never spends). - Err(e) => Err(Error::api(format!("owner lookup failed: {e}"))), - } - } -} - -/// The production tip spender: builds+signs+validates+broadcasts via the node-custodied -/// [`super::rpc::WalletBackend`] (`build_and_broadcast_dig_tip`) with an injected broadcaster. -/// -/// The broadcaster is `None` on the offline-safe shipped bring-up (the wallet spend path's live -/// sync/lineage/broadcaster is the documented remaining integration, SPEC §18.12); until then a -/// tip cleanly reports [`TipSpendOutcome::NotExecutable`] (the engine skips — money never moves). -/// When the wallet spend bring-up attaches a real broadcaster (a `ChiaQueryBroadcaster`), tips -/// execute unchanged. -pub struct NodeTipSpender { - backend: std::sync::Arc, - broadcaster: Option>, - /// The on-chain confirmer (§18.12). `None` ⇒ a broadcast tip is recorded `Pending` (accepted, - /// not confirmed); `Some` ⇒ the tip waits for on-chain inclusion and is recorded `Confirmed` - /// once a block includes it. Shares the SAME `chia_query` client as the broadcaster. - confirmer: Option>, -} - -impl NodeTipSpender { - /// Build a spender over `backend`. The `backend` MUST NOT itself hold this engine (pass a clone - /// taken before `with_tipping`) to avoid a reference cycle. `broadcaster`/`confirmer` are - /// `None` on the offline-safe shipped bring-up (a tip then reports `NotExecutable` and money - /// never moves) and `Some` when live broadcast is enabled (§18.12). - pub fn new( - backend: std::sync::Arc, - broadcaster: Option>, - confirmer: Option>, - ) -> Self { - Self { - backend, - broadcaster, - confirmer, - } - } -} - -#[async_trait] -impl TipSpender for NodeTipSpender { - async fn send_dig_tip( - &self, - recipient_ph_hex: &str, - amount: u64, - fee: u64, - ) -> Result { - let Some(bc) = self.broadcaster.as_ref() else { - return Ok(TipSpendOutcome::NotExecutable { - reason: "no broadcaster configured (wallet spend path not yet wired)".into(), - }); - }; - // Point-read live sync before selecting (§18.12): refresh the wallet DB from the fallback so - // coin selection runs over current chain state. Best-effort — a sync failure is not a spend - // failure: `build_and_broadcast_dig_tip` then reports NotExecutable if no $DIG is selectable - // (retryable), never a false spend. - if let Err(e) = self.backend.refresh_tracked_coins().await { - eprintln!( - "dig-node: WARN tip pre-spend coin sync failed (continuing best-effort): {e}" - ); - } - let recipient = super::singleton::bytes32_from_hex(recipient_ph_hex)?; - self.backend - .build_and_broadcast_dig_tip( - recipient, - amount, - fee, - bc.as_ref(), - self.confirmer.as_deref(), - ) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{Arc, Mutex as StdMutex}; - - // ── test doubles (no chain — money-safe by construction) ────────────── - - /// Behaviour of a mock spend attempt. - #[derive(Clone, Copy, PartialEq, Eq)] - enum SpendBehaviour { - /// Accept + confirm on-chain: a fresh txid, `confirmed: true` (ledger `Confirmed`). - Broadcast, - /// Accept into the mempool but NOT confirmed within the window: a fresh txid, - /// `confirmed: false` (ledger `Pending`, txid set — §18.12). - BroadcastUnconfirmed, - /// Definitively pre-broadcast (locked/no-coins) — retryable. - NotExecutable, - /// Ambiguous broadcast error — fail-closed, no retry. - Ambiguous, - } - - /// A recording spender that NEVER touches a chain — the money-safety proofs run against it. - struct MockSpender { - calls: StdMutex>, - behaviour: StdMutex, - seq: AtomicU64, - } - impl MockSpender { - fn new() -> Arc { - Arc::new(Self { - calls: StdMutex::new(Vec::new()), - behaviour: StdMutex::new(SpendBehaviour::Broadcast), - seq: AtomicU64::new(0), - }) - } - fn calls(&self) -> Vec<(String, u64)> { - self.calls.lock().unwrap().clone() - } - fn call_count(&self) -> usize { - self.calls.lock().unwrap().len() - } - fn set(&self, b: SpendBehaviour) { - *self.behaviour.lock().unwrap() = b; - } - } - #[async_trait] - impl TipSpender for Arc { - async fn send_dig_tip( - &self, - recipient_ph_hex: &str, - amount: u64, - _fee: u64, - ) -> Result { - self.calls - .lock() - .unwrap() - .push((recipient_ph_hex.to_string(), amount)); - match *self.behaviour.lock().unwrap() { - SpendBehaviour::NotExecutable => Ok(TipSpendOutcome::NotExecutable { - reason: "locked".into(), - }), - SpendBehaviour::Ambiguous => Err(Error::internal("network rejected")), - SpendBehaviour::Broadcast => { - let n = self.seq.fetch_add(1, Ordering::SeqCst); - Ok(TipSpendOutcome::Broadcast { - txid: format!("tx{n}"), - confirmed: true, - }) - } - SpendBehaviour::BroadcastUnconfirmed => { - let n = self.seq.fetch_add(1, Ordering::SeqCst); - Ok(TipSpendOutcome::Broadcast { - txid: format!("tx{n}"), - confirmed: false, - }) - } - } - } - } - - /// A fixed owner resolver returning a preset ph (or `None`), counting calls (to prove caching). - struct MockOwner { - ph: Option, - calls: AtomicU64, - } - impl MockOwner { - fn some(ph: &str) -> Arc { - Arc::new(Self { - ph: Some(ph.to_string()), - calls: AtomicU64::new(0), - }) - } - fn none() -> Arc { - Arc::new(Self { - ph: None, - calls: AtomicU64::new(0), - }) - } - fn count(&self) -> u64 { - self.calls.load(Ordering::SeqCst) - } - } - #[async_trait] - impl OwnerResolver for Arc { - async fn resolve_owner(&self, _store_id_hex: &str) -> Result> { - self.calls.fetch_add(1, Ordering::SeqCst); - Ok(self.ph.clone()) - } - } - - /// A clock pinned to a fixed unix time (settable to advance days). - struct FixedClock(AtomicU64); - impl FixedClock { - fn at(secs: u64) -> Arc { - Arc::new(Self(AtomicU64::new(secs))) - } - fn set(&self, secs: u64) { - self.0.store(secs, Ordering::SeqCst); - } - } - impl Clock for Arc { - fn now_unix(&self) -> u64 { - self.0.load(Ordering::SeqCst) - } - } - - fn owner_hex() -> String { - "11".repeat(32) - } - const STORE: &str = "0xabc"; // arbitrary store id (normalized to "abc") - const DAY0: u64 = 1_700_000_000; // 2023-11-14 (a stable day) - const DAY1: u64 = DAY0 + 86_400; // the next day - - /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, - /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). - fn scratch() -> tempfile::TempDir { - tempfile::Builder::new() - .prefix("dig-tip-") - .tempdir() - .expect("a scratch dir") - } - - /// Build an engine over `dir` and seed its config (persisted). - async fn make( - dir: &Path, - owner: impl OwnerResolver + 'static, - spender: Arc, - clock: Arc, - config: TippingConfig, - ) -> TippingEngine { - let eng = TippingEngine::load( - dir, - Box::new(owner), - Box::new(spender), - Box::new(clock), - Arc::new(TipEventBus::default()), - ); - eng.set_config(config).await.unwrap(); - eng - } - - /// A config with small, cap-friendly numbers for deterministic tests. - fn test_config() -> TippingConfig { - let mut c = TippingConfig::default(); - c.creator.dig_amount = 100; - c.creator.per_site_cap = 100; - c.daily_total_cap = 250; - c.dev.dig_amount = 100; - c - } - - // ── unix_to_utc_date ───────────────────────────────────────────────── - - #[test] - fn utc_date_epoch_and_known_days() { - assert_eq!(unix_to_utc_date(0), "1970-01-01"); - assert_eq!(unix_to_utc_date(86_399), "1970-01-01"); - assert_eq!(unix_to_utc_date(86_400), "1970-01-02"); - assert_eq!(unix_to_utc_date(DAY0), "2023-11-14"); - assert_eq!(unix_to_utc_date(DAY1), "2023-11-15"); - } - - /// The canonical DIG treasury inner puzzle hash (the per-capsule-payment recipient) — the - /// dev-account tip's recipient. Byte-identical to the shared contract. - const TREASURY: &str = "ec7c304708c7d59c078d5ae098d0dea004decf47fa1cafebb266c10ad6466ce8"; - - // ── defaults + dev recipient ───────────────────────────────────────── - - #[test] - fn creator_and_dev_default_on() { - let c = TippingConfig::default(); - assert!(c.creator.enabled, "creator auto-tip is DEFAULT-ON (#377)"); - assert!( - c.dev.enabled, - "dev-account tip is DEFAULT-ON (#377) — real treasury recipient" - ); - assert!(c.creator.dig_amount > 0 && c.daily_total_cap >= c.creator.dig_amount); - } - - #[test] - fn dev_recipient_is_the_dig_treasury_shared_contract() { - // Sourced from digstore-chain (never re-hardcoded); must equal the per-capsule-payment PH. - assert_eq!(dig_treasury_ph_hex(), TREASURY); - } - - // ── owner-PH lookup (cached) ───────────────────────────────────────── - - #[tokio::test] - async fn owner_lookup_is_cached_per_store() { - let dir = scratch(); - let owner = MockOwner::some(&owner_hex()); - let sp = MockSpender::new(); - let eng = make(dir.path(), owner.clone(), sp, FixedClock::at(DAY0), test_config()).await; - - // Two auto-tips for the same store on the same day: the first tips, the second is an - // idempotent skip — but the owner is resolved only ONCE (cached). - eng.auto_tip_for_store(STORE).await.unwrap(); - eng.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(owner.count(), 1, "owner resolution is cached per store"); - } - - #[tokio::test] - async fn auto_tip_skips_when_owner_unresolved() { - let dir = scratch(); - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::none(), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - let out = eng.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(out, TipOutcome::skipped("owner-unresolved")); - assert_eq!(sp.call_count(), 0, "no spend without a recipient"); - } - - // ── disabled → skip ────────────────────────────────────────────────── - - #[tokio::test] - async fn disabled_creator_skips_without_spending() { - let dir = scratch(); - let mut cfg = test_config(); - cfg.creator.enabled = false; - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - cfg, - ) - .await; - let out = eng.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(out, TipOutcome::skipped("disabled")); - assert_eq!(sp.call_count(), 0); - } - - // ── the happy path: a creator auto-tip, recorded + pushed ──────────── - - #[tokio::test] - async fn creator_auto_tip_spends_records_and_pushes() { - let dir = scratch(); - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - let mut rx = eng.events().subscribe(); - - let out = eng.auto_tip_for_store(STORE).await.unwrap(); - match out { - TipOutcome::Tipped { - dig_amount, - recipient_ph, - .. - } => { - assert_eq!(dig_amount, 100); - assert_eq!(recipient_ph, owner_hex()); - } - other => panic!("expected Tipped, got {other:?}"), - } - assert_eq!(sp.calls(), vec![(owner_hex(), 100)]); - - // Ledger records one confirmed creator/auto entry with a txid. - let ledger = eng.get_ledger(None).await; - assert_eq!(ledger.len(), 1); - assert_eq!(ledger[0].status, TipStatus::Confirmed); - assert_eq!(ledger[0].kind, TipKind::Creator); - assert_eq!(ledger[0].trigger, TipTrigger::Auto); - assert!(ledger[0].txid.is_some()); - - // A tip event was pushed over the WS bus. - let ev = rx.try_recv().expect("a tip event was published"); - assert_eq!(ev.entry.recipient_ph, owner_hex()); - assert_eq!(ev.entry.status, TipStatus::Confirmed); - } - - // ── idempotency: never double-tip a site in a day ───────────────────── - - #[tokio::test] - async fn same_day_same_site_is_tipped_only_once() { - let dir = scratch(); - let sp = MockSpender::new(); - let clock = FixedClock::at(DAY0); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - clock.clone(), - test_config(), - ) - .await; - - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - let second = eng.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(second, TipOutcome::skipped("already-tipped-today")); - assert_eq!(sp.call_count(), 1, "spent exactly once for the site/day"); - - // A NEW day tips again. - clock.set(DAY1); - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert_eq!(sp.call_count(), 2); - } - - // ── crash-retry: reload from the persisted ledger → no double-spend ── - - #[tokio::test] - async fn crash_retry_does_not_double_spend() { - let dir = scratch(); - // Engine 1 tips once (reservation + confirm persisted to tip-ledger.json). - { - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert_eq!(sp.call_count(), 1); - } - // Engine 2 (simulating a restart) reloads the SAME dir + same day → the (site, day) is - // already reserved → SKIP, never a second spend. - let sp2 = MockSpender::new(); - let eng2 = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp2.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - let out = eng2.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(out, TipOutcome::skipped("already-tipped-today")); - assert_eq!( - sp2.call_count(), - 0, - "a restart never re-spends a reserved day" - ); - } - - // ── caps fail closed ───────────────────────────────────────────────── - - #[tokio::test] - async fn per_site_cap_blocks_over_the_ceiling() { - let dir = scratch(); - let mut cfg = test_config(); - cfg.creator.dig_amount = 100; - cfg.creator.per_site_cap = 100; // one tip fits; a second would exceed - cfg.daily_total_cap = 10_000; // not the binding constraint here - let sp = MockSpender::new(); - let clock = FixedClock::at(DAY0); - let eng = make(dir.path(), MockOwner::some(&owner_hex()), sp.clone(), clock, cfg).await; - - // First store → owner tipped. A DIFFERENT store with the SAME owner the same day would - // exceed the per-site cap (idempotency already blocks the same store; force a manual-style - // second reservation by using the cap path directly): the second auto for the same owner - // via a different store id still maps to the same owner → idempotency skip. To isolate the - // per-site cap we set the amount ABOVE the cap so even the first tip is blocked. - let mut cfg2 = test_config(); - cfg2.creator.dig_amount = 200; - cfg2.creator.per_site_cap = 100; // 200 > 100 → blocked - let sp2 = MockSpender::new(); - let dir2 = scratch(); - let eng2 = make( - dir2.path(), - MockOwner::some(&owner_hex()), - sp2.clone(), - FixedClock::at(DAY0), - cfg2, - ) - .await; - let out = eng2.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(out, TipOutcome::skipped("over-per-site-cap")); - assert_eq!(sp2.call_count(), 0, "over-cap fails closed — nothing spent"); - let _ = (eng, sp); - } - - #[tokio::test] - async fn daily_total_cap_blocks_across_sites() { - // daily_total_cap = 250, each tip = 100 → the 3rd distinct site is blocked. - let dir = scratch(); - let mut cfg = test_config(); - cfg.creator.dig_amount = 100; - cfg.creator.per_site_cap = 100_000; // not binding - cfg.daily_total_cap = 250; - let clock = FixedClock::at(DAY0); - let sp = MockSpender::new(); - // Three different owners for three different stores. - let a = "aa".repeat(32); - let b = "bb".repeat(32); - let c = "cc".repeat(32); - // Resolver maps each store to a distinct owner. - struct Multi(Vec<(String, String)>); - #[async_trait] - impl OwnerResolver for Multi { - async fn resolve_owner(&self, store: &str) -> Result> { - Ok(self - .0 - .iter() - .find(|(s, _)| *s == super::normalize_hex(store)) - .map(|(_, o)| o.clone())) - } - } - let resolver = Multi(vec![ - ("s1".into(), a.clone()), - ("s2".into(), b.clone()), - ("s3".into(), c.clone()), - ]); - let eng = make(dir.path(), resolver, sp.clone(), clock, cfg).await; - - assert!(matches!( - eng.auto_tip_for_store("s1").await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert!(matches!( - eng.auto_tip_for_store("s2").await.unwrap(), - TipOutcome::Tipped { .. } - )); - let third = eng.auto_tip_for_store("s3").await.unwrap(); - assert_eq!(third, TipOutcome::skipped("over-daily-cap")); - assert_eq!( - sp.call_count(), - 2, - "the daily total cap fails closed on the 3rd" - ); - } - - // ── dev-account tip: pays the real DIG treasury, once per day ──────── - - #[tokio::test] - async fn dev_daily_tip_pays_the_treasury_once_per_day() { - let dir = scratch(); - let mut cfg = test_config(); - cfg.creator.enabled = false; // isolate the dev tip - cfg.dev.enabled = true; - cfg.dev.dig_amount = 100; - cfg.daily_total_cap = 1_000; - let sp = MockSpender::new(); - let clock = FixedClock::at(DAY0); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - clock.clone(), - cfg, - ) - .await; - - // The dev tip pays the REAL DIG treasury shared-contract PH. - match eng.dev_daily_tip().await.unwrap() { - TipOutcome::Tipped { - recipient_ph, - dig_amount, - .. - } => { - assert_eq!(recipient_ph, TREASURY, "dev tip pays the DIG treasury PH"); - assert_eq!(dig_amount, 100); - } - other => panic!("expected Tipped to the treasury, got {other:?}"), - } - assert_eq!(sp.calls(), vec![(TREASURY.to_string(), 100)]); - - // Idempotent: a second dev tick the same day is a no-op (never double-tip the treasury/day). - assert_eq!( - eng.dev_daily_tip().await.unwrap(), - TipOutcome::skipped("already-tipped-today") - ); - assert_eq!(sp.call_count(), 1); - - // A new day tips again. - clock.set(DAY1); - assert!(matches!( - eng.dev_daily_tip().await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert_eq!(sp.call_count(), 2); - - // The ledger records dev-kind entries. - let ledger = eng.get_ledger(None).await; - assert!(ledger.iter().all(|e| e.kind == TipKind::Dev)); - assert_eq!(ledger.len(), 2); - } - - /// **Proves:** the daily total cap spans creator + dev — a dev tip counts against the same - /// budget, so the two together can never exceed the daily cap (fail-closed). - #[tokio::test] - async fn daily_total_cap_spans_creator_and_dev() { - let dir = scratch(); - let mut cfg = test_config(); - cfg.creator.enabled = true; - cfg.creator.dig_amount = 100; - cfg.creator.per_site_cap = 100_000; // not binding - cfg.dev.enabled = true; - cfg.dev.dig_amount = 100; - cfg.daily_total_cap = 150; // one 100 tip fits; the second (creator OR dev) is blocked - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - cfg, - ) - .await; - - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - // The dev tip would push the day's total to 200 > 150 → blocked (creator already spent 100). - let dev = eng.dev_daily_tip().await.unwrap(); - assert_eq!(dev, TipOutcome::skipped("over-daily-cap")); - assert_eq!( - sp.call_count(), - 1, - "the shared daily cap fails closed across creator + dev" - ); - } - - // ── NotExecutable → rolled back (retryable); Ambiguous → kept (no retry) - - #[tokio::test] - async fn not_executable_rolls_back_and_is_retryable() { - let dir = scratch(); - let sp = MockSpender::new(); - sp.set(SpendBehaviour::NotExecutable); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - let out = eng.auto_tip_for_store(STORE).await.unwrap(); - assert!(matches!(out, TipOutcome::Skipped { .. })); - assert!( - eng.get_ledger(None).await.is_empty(), - "a pre-broadcast skip leaves no reservation" - ); - // Now the wallet becomes executable → the same store/day tips (it was rolled back). - sp.set(SpendBehaviour::Broadcast); - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert_eq!(sp.call_count(), 2); - } - - #[tokio::test] - async fn ambiguous_broadcast_error_is_not_retried_that_day() { - let dir = scratch(); - let sp = MockSpender::new(); - sp.set(SpendBehaviour::Ambiguous); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - let out = eng.auto_tip_for_store(STORE).await.unwrap(); - assert!(matches!(out, TipOutcome::Skipped { .. })); - // The reservation is KEPT as Failed (fail-closed: never double-spend on an ambiguous error). - let ledger = eng.get_ledger(None).await; - assert_eq!(ledger.len(), 1); - assert_eq!(ledger[0].status, TipStatus::Failed); - // A retry the same day is refused (no second broadcast attempt). - sp.set(SpendBehaviour::Broadcast); - let retry = eng.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(retry, TipOutcome::skipped("already-tipped-today")); - assert_eq!(sp.call_count(), 1, "an ambiguous day is never retried"); - } - - /// A broadcast that was accepted into the mempool but NOT confirmed on-chain within the window - /// (§18.12) records the tip as `Pending` (money moved — outcome is `Tipped`, txid set), and the - /// reservation still blocks a same-day retry (Pending counts toward the caps + idempotency). - #[tokio::test] - async fn unconfirmed_broadcast_is_pending_with_txid_and_blocks_retry() { - let dir = scratch(); - let sp = MockSpender::new(); - sp.set(SpendBehaviour::BroadcastUnconfirmed); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - // Money moved (broadcast accepted) → the outcome is Tipped with a txid. - let out = eng.auto_tip_for_store(STORE).await.unwrap(); - assert!(matches!(out, TipOutcome::Tipped { .. })); - // But the ledger status is Pending (not yet confirmed on-chain) with the txid recorded. - let ledger = eng.get_ledger(None).await; - assert_eq!(ledger.len(), 1); - assert_eq!(ledger[0].status, TipStatus::Pending); - assert!(ledger[0].txid.is_some(), "the broadcast txid is recorded"); - // A same-day retry is refused: the Pending reservation blocks re-tipping (no double-spend). - sp.set(SpendBehaviour::Broadcast); - let retry = eng.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(retry, TipOutcome::skipped("already-tipped-today")); - assert_eq!( - sp.call_count(), - 1, - "a pending (unconfirmed) tip is never re-broadcast" - ); - } - - // ── manual tip: bypasses idempotency + caps, always executes ───────── - - #[tokio::test] - async fn manual_tip_bypasses_idempotency_and_caps() { - let dir = scratch(); - let mut cfg = test_config(); - cfg.creator.enabled = false; // even with auto off, a manual tip works - cfg.daily_total_cap = 0; // and it ignores the auto daily cap - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - cfg, - ) - .await; - - assert!(matches!( - eng.manual_tip(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert!(matches!( - eng.manual_tip(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert_eq!( - sp.call_count(), - 2, - "manual tips repeat freely (explicit consent)" - ); - let ledger = eng.get_ledger(None).await; - assert_eq!(ledger.len(), 2); - assert!(ledger.iter().all(|e| e.trigger == TipTrigger::Manual)); - } - - // ── config persistence ─────────────────────────────────────────────── - - #[tokio::test] - async fn config_persists_across_reload() { - let dir = scratch(); - let sp = MockSpender::new(); - let mut cfg = test_config(); - cfg.creator.dig_amount = 777; - { - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - cfg, - ) - .await; - assert_eq!(eng.get_config().await.creator.dig_amount, 777); - } - // A fresh engine over the same dir reads the persisted config. - let eng2 = TippingEngine::load( - dir.path(), - Box::new(MockOwner::some(&owner_hex())), - Box::new(sp), - Box::new(FixedClock::at(DAY0)), - Arc::new(TipEventBus::default()), - ); - assert_eq!(eng2.get_config().await.creator.dig_amount, 777); - } - - // ── FAIL-CLOSED on unreadable persisted state (money-safety regression) ── - - /// Load an engine over `dir` WITHOUT seeding config (so a poisoned load is observable — the - /// poison guard would reject `set_config`). - fn load_only( - dir: &Path, - owner: impl OwnerResolver + 'static, - spender: Arc, - clock: Arc, - ) -> TippingEngine { - TippingEngine::load( - dir, - Box::new(owner), - Box::new(spender), - Box::new(clock), - Arc::new(TipEventBus::default()), - ) - } - - /// **Proves (HIGH regression):** a PRESENT-but-corrupt `tip-ledger.json` FAILS CLOSED — the - /// engine does NOT reset the ledger to empty and re-tip. Without the fix, the corrupt ledger - /// would read as empty → `auto_already_reserved`=false → re-tip the full daily budget. - #[tokio::test] - async fn present_but_corrupt_ledger_fails_closed_no_retip() { - let dir = scratch(); - std::fs::write(dir.path().join("tip-ledger.json"), b"{ this is not valid json ]").unwrap(); - let sp = MockSpender::new(); - let eng = load_only( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - ); - let out = eng.auto_tip_for_store(STORE).await.unwrap(); - assert!( - matches!(&out, TipOutcome::Skipped { reason } if reason.starts_with("state-unreadable")), - "a corrupt ledger must fail closed: {out:?}" - ); - assert_eq!( - sp.call_count(), - 0, - "no spend while the ledger is unreadable" - ); - // A manual tip is refused too (it would clobber the unreadable ledger). - assert_eq!(sp.call_count(), 0); - assert!(matches!( - eng.manual_tip(STORE).await.unwrap(), - TipOutcome::Skipped { .. } - )); - assert_eq!(sp.call_count(), 0); - } - - /// **Proves (HIGH regression):** a truncated / zero-length ledger (an interrupted write / power- - /// loss artifact) is PRESENT-but-unparseable → fails closed, no re-tip. - #[tokio::test] - async fn truncated_zero_length_ledger_fails_closed() { - let dir = scratch(); - std::fs::write(dir.path().join("tip-ledger.json"), b"").unwrap(); // zero-length - let sp = MockSpender::new(); - let eng = load_only( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - ); - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Skipped { .. } - )); - assert_eq!(sp.call_count(), 0); - } - - /// **Proves (HIGH regression):** a real prior tip, then a CORRUPTED ledger on the next boot → - /// the reloaded engine REFUSES (fail closed) rather than double-spending the already-tipped - /// site. This is the concrete double-spend scenario the fail-open bug enabled. - #[tokio::test] - async fn corrupt_ledger_after_a_tip_prevents_double_spend() { - let dir = scratch(); - { - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert_eq!(sp.call_count(), 1); - } - // Corrupt the persisted ledger (e.g. AV/indexer lock recovery, partial write). - std::fs::write(dir.path().join("tip-ledger.json"), b"\x00\x00corrupt").unwrap(); - let sp2 = MockSpender::new(); - let eng2 = load_only( - dir.path(), - MockOwner::some(&owner_hex()), - sp2.clone(), - FixedClock::at(DAY0), - ); - let out = eng2.auto_tip_for_store(STORE).await.unwrap(); - assert!( - matches!(&out, TipOutcome::Skipped { reason } if reason.starts_with("state-unreadable")), - "a corrupt ledger after a tip must not re-tip: {out:?}" - ); - assert_eq!( - sp2.call_count(), - 0, - "NEVER double-spend on an unreadable ledger" - ); - } - - /// **Proves (HIGH regression):** a PRESENT-but-corrupt `tipping-config.json` does NOT silently - /// fall back to the DEFAULT-ON config — auto-tip is treated as DISABLED (never moves $DIG - /// against a user who had turned it off) and the engine is poisoned. - #[tokio::test] - async fn present_but_corrupt_config_does_not_reenable_autotip() { - let dir = scratch(); - std::fs::write(dir.path().join("tipping-config.json"), b"{ not: valid").unwrap(); - let sp = MockSpender::new(); - let eng = load_only( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - ); - // The effective config is fail-closed DISABLED (not the DEFAULT-ON default). - let cfg = eng.get_config().await; - assert!( - !cfg.creator.enabled, - "corrupt config must NOT re-enable creator auto-tip" - ); - assert!(!cfg.dev.enabled); - // And any auto-tip is refused (poisoned). - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Skipped { .. } - )); - assert_eq!(sp.call_count(), 0); - // set_config is refused while poisoned (resolve the file + restart). - assert!(eng.set_config(test_config()).await.is_err()); - } - - /// **Proves:** ABSENT files are a genuine first run — NOT poison. The engine tips normally - /// (creator DEFAULT-ON), so the fail-closed distinction doesn't over-block real first boots. - #[tokio::test] - async fn absent_files_are_a_clean_first_run_and_tip() { - let dir = scratch(); // fresh, empty dir — no config or ledger files - let sp = MockSpender::new(); - let eng = load_only( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - ); - // DEFAULT-ON config (absent config → default, not disabled). - assert!(eng.get_config().await.creator.enabled); - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - assert_eq!(sp.call_count(), 1, "a genuine first run still tips"); - } - - /// **Proves:** `write_json` produces a durable, re-readable file (the fsync path doesn't - /// corrupt output) — a tipped ledger reloads cleanly and is treated as already-tipped. - #[tokio::test] - async fn durable_write_reloads_cleanly() { - let dir = scratch(); - { - let sp = MockSpender::new(); - let eng = make( - dir.path(), - MockOwner::some(&owner_hex()), - sp.clone(), - FixedClock::at(DAY0), - test_config(), - ) - .await; - assert!(matches!( - eng.auto_tip_for_store(STORE).await.unwrap(), - TipOutcome::Tipped { .. } - )); - } - // Reload: the persisted ledger parses (not poisoned) and the day is already tipped. - let sp2 = MockSpender::new(); - let eng2 = load_only( - dir.path(), - MockOwner::some(&owner_hex()), - sp2.clone(), - FixedClock::at(DAY0), - ); - let out = eng2.auto_tip_for_store(STORE).await.unwrap(); - assert_eq!(out, TipOutcome::skipped("already-tipped-today")); - assert_eq!(sp2.call_count(), 0); - } - - // ── event bus does not leak the Sage SyncEvent union ───────────────── - - #[test] - fn tip_event_bus_publish_with_no_subscribers_is_noop() { - let bus = TipEventBus::default(); - bus.publish(TipEvent { - entry: TipLedgerEntry { - id: 0, - recipient_ph: owner_hex(), - store_id: None, - dig_amount: 1, - ts: 0, - day: "1970-01-01".into(), - txid: None, - trigger: TipTrigger::Auto, - kind: TipKind::Dev, - status: TipStatus::Pending, - }, - }); - assert_eq!(bus.subscriber_count(), 0); - } -} +//! The **tipping subsystem** (#378, child of the auto-tip epic #377). +//! +//! The dig-node OWNS tipping: it holds the wallet/keys and builds+signs+broadcasts the $DIG +//! spend. The extension (#379/#380) only CONFIGURES + DISPLAYS it over the WS wallet/control +//! transport (SPEC §4.8). This module is the node-side engine: +//! +//! - **Owner-PH lookup** — resolve a store's on-chain OWNER puzzle hash from its singleton +//! (the launcher id), cached per store ([`OwnerResolver`]). +//! - **Auto-tip policy engine** — a persisted [`TippingConfig`] (creator + dev-account policies) +//! with HARD budget caps (per-site/day AND a daily total) enforced FAIL-CLOSED, and +//! idempotency per `(site, day)` so a crash+retry never double-tips ([`TippingEngine`]). +//! - **Creator auto-tip is DEFAULT-ON** (a real on-chain-resolved recipient always exists). +//! - **DIG dev-account daily tip** — the SAME engine, a SEPARATE toggle. Recipient = the canonical +//! DIG treasury inner puzzle hash (the existing per-capsule-payment shared contract, sourced from +//! `digstore_chain::dig::treasury_inner_puzzle_hash()` via `dig_treasury_ph_hex`, never +//! re-hardcoded). A REAL recipient, so it is DEFAULT-ON with a small daily amount + the same caps. +//! - **Unattended execution** — when enabled + within budget the engine builds+signs+broadcasts +//! with NO user interaction, and skips cleanly when disabled/over-budget/already-tipped. +//! - **On-demand manual tip** — one-tap tip to a store's owner (explicit user consent; not +//! bounded by the auto caps, not subject to the once-per-day idempotency). +//! - **Tip ledger** — every reservation/tip is recorded (`recipient / amount / ts / txid / +//! auto|manual / creator|dev / status`), persisted, exposed via `get_ledger`, and PUSHED over +//! the WS wallet/control surface via a dedicated [`TipEventBus`] (kept OUT of the Sage-parity +//! `SyncEvent` union, [`super::events`]). +//! +//! ## Money-safety design (real mainnet $DIG) +//! +//! The engine is the single authorization+consent gate for a tip. Two properties are enforced +//! FAIL-CLOSED — a bug skips the tip, never over-spends: +//! +//! 1. **Hard caps** — a per-site/day cap AND a daily total cap (spanning creator + dev). Reserved +//! (pending), confirmed, AND ambiguous-failed amounts all count toward the caps, so an +//! in-flight or unknown-outcome tip can never be double-counted into an over-spend. +//! 2. **Crash-safe idempotency** — the ledger reservation (a `Pending` entry) is persisted to disk +//! IMMEDIATELY BEFORE the broadcast; the broadcast is the only money-moving step. So a crash at +//! ANY point leaves at most one reserved entry for a `(site, day)`, and on restart the engine +//! (re-loaded from the ledger file) treats that `(site, day)` as already tipped and SKIPS — it +//! errs toward under-tipping, never a double-spend. A definitively PRE-broadcast failure +//! (locked wallet / not-yet-synced / insufficient $DIG — [`TipSpendOutcome::NotExecutable`]) +//! rolls the reservation back so it can retry later; an AMBIGUOUS broadcast error keeps the +//! reservation (as `Failed`) so it is never retried that day. +//! +//! Broadcasting goes through the [`TipSpender`] seam. Tests inject a recording mock (or drive the +//! `chia-sdk-test` simulator via the underlying [`super::spend`] builders) — a real mainnet +//! broadcast is NEVER reached from a test. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, Mutex}; + +use super::{Error, Result}; + +// ───────────────────────────────────────────────────────────────────────────── +// Dev-account recipient — the existing canonical DIG treasury shared contract +// ───────────────────────────────────────────────────────────────────────────── + +/// The DIG treasury / dev-fee recipient the dev-account daily tip pays: the SAME canonical +/// shared-contract puzzle hash that receives every per-capsule $DIG payment +/// (`digstore_chain::dig::treasury_inner_puzzle_hash()`, decoded from `TREASURY_ADDRESS` +/// `xch1a37rq3cgcl2ecpudttsf35x75qzdan68lgw2l6ajvmqs44jxdn5qv6pk3y` = +/// `ec7c304708c7d59c078d5ae098d0dea004decf47fa1cafebb266c10ad6466ce8`; mirrored byte-identical in +/// chip35 + dighub-core). It is a REAL recipient, so the dev-account daily tip is DEFAULT-ON (#377) +/// with the same hard caps as creator tips. Sourced from the shared contract — NEVER re-hardcoded +/// here — so a payment-critical value can't drift into a 4th copy. The tip's CAT spend targets this +/// inner PH exactly as the per-capsule payment does (`Cat::spend_all` CAT-wraps it). +fn dig_treasury_ph_hex() -> String { + hex::encode(digstore_chain::dig::treasury_inner_puzzle_hash()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sensible small default amounts ($DIG has 3 decimals — 1 DIG = 1000 base units) +// ───────────────────────────────────────────────────────────────────────────── + +/// Base units per whole $DIG (`digstore_chain::dig::DIG_DECIMALS == 3`). +pub const DIG_BASE_UNITS: u64 = 1_000; + +/// Default creator tip per site/day = 0.1 $DIG. +pub const DEFAULT_CREATOR_TIP: u64 = DIG_BASE_UNITS / 10; +/// Default per-site/day cap for creator tips = 0.1 $DIG. +pub const DEFAULT_PER_SITE_CAP: u64 = DIG_BASE_UNITS / 10; +/// Default daily TOTAL cap across ALL auto tips (creator + dev) = 1 $DIG. +pub const DEFAULT_DAILY_TOTAL_CAP: u64 = DIG_BASE_UNITS; +/// Default dev-account daily tip = 0.1 $DIG. +pub const DEFAULT_DEV_TIP: u64 = DIG_BASE_UNITS / 10; +/// Default XCH fee per tip spend (0 — a low-priority tip needs no fee at normal congestion; the +/// user can raise it in config). +pub const DEFAULT_TIP_FEE: u64 = 0; + +// ───────────────────────────────────────────────────────────────────────────── +// Config +// ───────────────────────────────────────────────────────────────────────────── + +/// How an auto-tip policy meters spending across a day. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TipMode { + /// Tip each consumed site at most once per day, `dig_amount` each, bounded by the per-site + /// cap AND the daily total cap. + PerSitePerDay, + /// A single daily budget pool: tip each consumed site once per day drawing from the pool + /// until the daily total cap is exhausted (the per-site cap is not separately enforced). + DailyBudget, +} + +/// One auto-tip policy (creator OR dev). The two share the top-level [`TippingConfig::daily_total_cap`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AutoTipPolicy { + /// Whether this policy tips automatically (unattended). + pub enabled: bool, + /// The tip amount per site/day, in $DIG base units. + pub dig_amount: u64, + /// How the policy meters spend across a day. + pub mode: TipMode, + /// The hard per-site/day ceiling in base units (enforced in [`TipMode::PerSitePerDay`]). + pub per_site_cap: u64, + /// Per-site amount overrides (site key = owner puzzle-hash hex → base units). + #[serde(default)] + pub per_site_overrides: HashMap, +} + +/// The persisted tipping configuration. Both creator AND dev-account auto-tip are DEFAULT-ON (#377): +/// each has a real recipient — the creator's is the on-chain-resolved store owner PH, the +/// dev-account's is the existing DIG treasury inner PH shared contract +/// (`digstore_chain::dig::treasury_inner_puzzle_hash()`), never a placeholder. Safe out of the box +/// paired with the honest-default disclosure + one-click-off (§6.0, #207). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TippingConfig { + /// Creator auto-tip (pays the on-chain-resolved store owner). + pub creator: AutoTipPolicy, + /// DIG dev-account daily tip (pays the DIG treasury shared contract, `dig_treasury_ph_hex`). + pub dev: AutoTipPolicy, + /// The HARD daily total cap in base units, spanning creator + dev auto tips. + pub daily_total_cap: u64, + /// The XCH fee applied to each tip spend. + pub fee: u64, +} + +impl Default for TippingConfig { + fn default() -> Self { + Self { + creator: AutoTipPolicy { + enabled: true, // DEFAULT-ON (#377): a real on-chain recipient always exists. + dig_amount: DEFAULT_CREATOR_TIP, + mode: TipMode::PerSitePerDay, + per_site_cap: DEFAULT_PER_SITE_CAP, + per_site_overrides: HashMap::new(), + }, + dev: AutoTipPolicy { + // DEFAULT-ON (#377): the recipient is the REAL DIG treasury shared contract, so a + // small daily "support DIG itself" tip is safe out of the box (hard caps + ledger). + enabled: true, + dig_amount: DEFAULT_DEV_TIP, + mode: TipMode::PerSitePerDay, + per_site_cap: DEFAULT_DEV_TIP, + per_site_overrides: HashMap::new(), + }, + daily_total_cap: DEFAULT_DAILY_TOTAL_CAP, + fee: DEFAULT_TIP_FEE, + } + } +} + +impl TippingConfig { + /// The FAIL-CLOSED config: both policies DISABLED. Used when the persisted config is present + /// but unreadable — a corrupt/locked config file must NEVER silently fall back to the + /// DEFAULT-ON config (which would move real $DIG against a user who had disabled auto-tip). + fn disabled() -> Self { + let mut c = Self::default(); + c.creator.enabled = false; + c.dev.enabled = false; + c + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Ledger +// ───────────────────────────────────────────────────────────────────────────── + +/// Which policy a ledger entry belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TipKind { + /// A tip to a content creator (store owner). + Creator, + /// A tip to the DIG dev account. + Dev, +} + +/// Whether a tip was fired by the auto policy or by an explicit user tap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TipTrigger { + /// Unattended auto-tip (governed by caps + idempotency). + Auto, + /// Explicit one-tap manual tip (user consent; not bounded by the auto caps/idempotency). + Manual, +} + +/// The lifecycle status of a ledger entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TipStatus { + /// Reserved before broadcast (money may or may not have moved). Counts toward caps + blocks a + /// same-day retry — the crash-safety reservation. + Pending, + /// The broadcast was accepted by the network. `txid` is set. + Confirmed, + /// An AMBIGUOUS broadcast failure (the tx may have entered a mempool). Kept, counts toward + /// caps, and is NOT retried that day (fail-closed — never double-spend). + Failed, +} + +/// One tip ledger entry (`recipient / amount / ts / txid / auto|manual / creator|dev / status`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TipLedgerEntry { + /// A stable, monotonically-increasing id (the extension can key rows by it). + pub id: u64, + /// The recipient puzzle hash (lowercase hex, no `0x`). + pub recipient_ph: String, + /// The store the tip was for (launcher-id hex); `None` for a dev-account tip. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub store_id: Option, + /// The tip amount in $DIG base units. + pub dig_amount: u64, + /// Unix seconds when the entry was reserved. + pub ts: u64, + /// The UTC day bucket (`YYYY-MM-DD`) — the idempotency key alongside `recipient_ph`/`kind`. + pub day: String, + /// The broadcast transaction id (spend-bundle name hex); `None` until confirmed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub txid: Option, + /// Auto vs manual. + pub trigger: TipTrigger, + /// Creator vs dev. + pub kind: TipKind, + /// The lifecycle status. + pub status: TipStatus, +} + +/// The result of a tip decision — either a tip happened or it was skipped (with a machine-stable +/// reason so the extension can render "already tipped today", "over budget", etc.). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum TipOutcome { + /// A tip was built, signed, and broadcast. Money moved. + Tipped { + /// The broadcast transaction id. + txid: String, + /// The amount tipped, in base units. + dig_amount: u64, + /// The recipient puzzle hash (hex). + recipient_ph: String, + }, + /// No tip happened. `reason` is a stable machine token. + Skipped { + /// Why the tip was skipped. + reason: String, + }, +} + +impl TipOutcome { + fn skipped(reason: impl Into) -> Self { + TipOutcome::Skipped { + reason: reason.into(), + } + } +} + +/// The outcome of the wallet's attempt to build+broadcast a tip spend (the [`TipSpender`] result). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TipSpendOutcome { + /// The spend was built, signed, validated, and BROADCAST. Money moved. `txid` = bundle name. + Broadcast { + /// The broadcast transaction id (spend-bundle name hex). + txid: String, + /// Whether the spend was CONFIRMED on-chain within the confirmer's window (§18.12). `true` + /// ⇒ a block included it (ledger status `Confirmed`); `false` ⇒ accepted into the mempool + /// but not yet confirmed (ledger status `Pending`, txid set — money moved, confirmation is + /// asynchronous, and the reservation blocks a same-day retry either way). + confirmed: bool, + }, + /// The wallet cannot currently build/broadcast the tip (no signing key / not-yet-synced / no + /// lineage / insufficient $DIG). Definitively PRE-broadcast — no money moved; the caller may + /// retry later. + NotExecutable { + /// A human-readable reason. When the refusal is signer-absence, it is exactly one of + /// [`NO_SIGNER_CONFIGURED`], [`WALLET_ENROLLED_BUT_UNOPENABLE`] or [`NO_WALLET_ENROLLED`]. + reason: String, + }, +} + +/// Why a tip refusal happened when no signing key could be resolved (#410). +/// +/// These are the exact `TipSpendOutcome::NotExecutable::reason` strings for the three signer-absence +/// states a [`super::rpc::WalletBackend`] can actually OBSERVE, published as constants so a caller +/// (and a test) can match a refusal by equality rather than by reading prose. +/// +/// They exist because the single reason they replace — `"wallet is locked"` — was false in the state +/// a shipped node is always in. Nothing attaches a signer to the served backend (`with_signer` has no +/// non-test caller), so a user with a perfectly unlocked wallet was told to unlock it, would try, and +/// would get nowhere. Each string below therefore describes a state the user can check, and none of +/// them asks for an unlock that would not help. +/// +/// A fourth state, `Orphaned` (a sealed seed whose device key is gone, +/// [`crate::autoseed::BootstrapState::Orphaned`]), is deliberately NOT represented: it is decided at +/// bootstrap from paths the backend does not hold, and [`super::custody::CustodyState`] has no +/// variant for it. Minting a reason the backend cannot distinguish would reintroduce exactly the +/// defect this fixes. +pub mod refusal { + /// No signing key and no custody view at all — this backend was built without either, so it + /// could never spend. There is no wallet state for the user to change. + pub const NO_SIGNER_CONFIGURED: &str = + "no signing key is configured on this node, so it cannot sign a tip"; + + /// A wallet IS enrolled on this device, and the node cannot open its sealed seed. Node-managed + /// unlock was removed (SPEC §18.24), so this is not a lock the user can open from here. + pub const WALLET_ENROLLED_BUT_UNOPENABLE: &str = + "a wallet is enrolled on this device but this node cannot open its sealed seed, so it cannot sign a tip"; + + /// Custody is attached and holds no wallet — nothing is enrolled to sign with. + pub const NO_WALLET_ENROLLED: &str = + "no wallet is enrolled on this device, so it cannot sign a tip"; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Seams (traits) — injected so the money-safety logic is testable without a chain +// ───────────────────────────────────────────────────────────────────────────── + +/// Resolves a store's on-chain OWNER puzzle hash from its singleton (launcher id). +#[async_trait] +pub trait OwnerResolver: Send + Sync { + /// Return the owner puzzle hash (lowercase hex, no `0x`) of the store `store_id_hex` + /// (launcher-id hex), or `None` when the store singleton cannot be found on chain. + async fn resolve_owner(&self, store_id_hex: &str) -> Result>; +} + +/// Builds+signs+validates+broadcasts a $DIG tip. The ONLY component that moves money. +/// +/// Every caller has already enforced enabled + caps + idempotency (and the fail-closed +/// unreadable-state guard); the ledger reservation is persisted BEFORE this is invoked +/// (crash-safety). The contract: +/// `Ok(Broadcast)` = accepted by the network; `Ok(NotExecutable)` = definitively pre-broadcast +/// (safe to retry); `Err` = an AMBIGUOUS broadcast failure (the engine keeps the reservation and +/// does not retry that day — fail-closed). +#[async_trait] +pub trait TipSpender: Send + Sync { + /// Send `amount` base units of $DIG to `recipient_ph_hex` with an XCH `fee`. + async fn send_dig_tip( + &self, + recipient_ph_hex: &str, + amount: u64, + fee: u64, + ) -> Result; +} + +/// A wall clock (injected so tests can pin "today"). +pub trait Clock: Send + Sync { + /// Current unix time in seconds. + fn now_unix(&self) -> u64; + /// Today's UTC date as `YYYY-MM-DD` (the idempotency day bucket). + fn today_utc(&self) -> String { + unix_to_utc_date(self.now_unix()) + } +} + +/// The production system clock. +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_unix(&self) -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } +} + +/// Convert unix seconds to a UTC `YYYY-MM-DD` string (Howard Hinnant's civil-from-days algorithm — +/// dependency-free, so the day boundary needs no `chrono`). +fn unix_to_utc_date(secs: u64) -> String { + let days = (secs / 86_400) as i64; // days since 1970-01-01 (UTC) + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let mut y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + if m <= 2 { + y += 1; + } + format!("{y:04}-{m:02}-{d:02}") +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tip event bus (WS push) — SEPARATE from the Sage-parity SyncEvent union +// ───────────────────────────────────────────────────────────────────────────── + +/// A tip event pushed to connected WS clients when a tip is recorded (SPEC §4.8 `{type:"tip"}`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TipEvent { + /// The recorded ledger entry. + pub entry: TipLedgerEntry, +} + +/// An in-process publish/subscribe bus for [`TipEvent`]s. Deliberately DISTINCT from the +/// Sage-parity [`super::events::EventBus`] so DIG-specific tip events never leak into the +/// byte-parity Sage `SyncEvent` stream (`GET /events`). Cheap to clone; a publish with no +/// subscribers is a harmless no-op. +#[derive(Clone)] +pub struct TipEventBus { + tx: broadcast::Sender, +} + +impl TipEventBus { + /// A bus with the given per-subscriber buffer capacity. + pub fn with_capacity(capacity: usize) -> Self { + let (tx, _rx) = broadcast::channel(capacity.max(1)); + Self { tx } + } + /// Publish to every current subscriber (no-op with no listeners). + pub fn publish(&self, event: TipEvent) { + let _ = self.tx.send(event); + } + /// Subscribe to future tip events. + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + /// The current subscriber count (test/diagnostic). + pub fn subscriber_count(&self) -> usize { + self.tx.receiver_count() + } +} + +impl Default for TipEventBus { + fn default() -> Self { + Self::with_capacity(64) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Engine +// ───────────────────────────────────────────────────────────────────────────── + +/// The in-memory config + ledger, guarded as one unit so idempotency + cap checks and the +/// reservation write are atomic. +#[derive(Debug, Default)] +struct TippingState { + config: TippingConfig, + ledger: Vec, + next_id: u64, +} + +impl TippingState { + /// Sum of the amounts of all reserved/attempted AUTO entries on `day` (any kind) — the daily + /// total cap basis. Pending/Confirmed/Failed all count (fail-closed). + fn auto_spent_today(&self, day: &str) -> u64 { + self.ledger + .iter() + .filter(|e| e.trigger == TipTrigger::Auto && e.day == day) + .map(|e| e.dig_amount) + .fold(0, u64::saturating_add) + } + + /// Sum of reserved/attempted AUTO amounts for one `(kind, recipient, day)` — the per-site cap + /// basis. + fn auto_site_spent_today(&self, kind: TipKind, recipient: &str, day: &str) -> u64 { + self.ledger + .iter() + .filter(|e| { + e.trigger == TipTrigger::Auto + && e.kind == kind + && e.recipient_ph == recipient + && e.day == day + }) + .map(|e| e.dig_amount) + .fold(0, u64::saturating_add) + } + + /// Whether an AUTO tip for `(kind, recipient, day)` has already been reserved/attempted (the + /// once-per-site-per-day idempotency invariant). + fn auto_already_reserved(&self, kind: TipKind, recipient: &str, day: &str) -> bool { + self.ledger.iter().any(|e| { + e.trigger == TipTrigger::Auto + && e.kind == kind + && e.recipient_ph == recipient + && e.day == day + }) + } + + /// Push a reservation and return its id. + fn reserve(&mut self, mut entry: TipLedgerEntry) -> u64 { + let id = self.next_id; + self.next_id += 1; + entry.id = id; + self.ledger.push(entry); + id + } + + fn entry_mut(&mut self, id: u64) -> Option<&mut TipLedgerEntry> { + self.ledger.iter_mut().find(|e| e.id == id) + } + + fn remove(&mut self, id: u64) { + self.ledger.retain(|e| e.id != id); + } +} + +/// The persisted-on-disk ledger shape (a versioned wrapper so future fields are additive). +#[derive(Debug, Default, Serialize, Deserialize)] +struct LedgerFile { + #[serde(default)] + next_id: u64, + #[serde(default)] + entries: Vec, +} + +/// The node-side tipping engine (SPEC §18.23). Owns the persisted config + ledger, the owner +/// resolver (cached per store), the tip spender, and the tip-event bus. +pub struct TippingEngine { + config_path: PathBuf, + ledger_path: PathBuf, + state: Mutex, + owner: Box, + spender: Box, + clock: Box, + events: std::sync::Arc, + owner_cache: Mutex>, + /// FAIL-CLOSED poison: `Some(reason)` when the persisted config OR ledger was present on disk + /// but could not be read/parsed at load. A money ledger that can't be read MUST NEVER degrade + /// to "empty → tip freely" (that would reset the cap + idempotency accounting and re-tip the + /// full daily budget on every restart, double-spending sites already tipped). While poisoned, + /// EVERY tip (auto + manual) and config mutation is REFUSED until the operator resolves the + /// file and restarts. Set only at [`Self::load`]; immutable thereafter (no lock needed). + poison: Option, +} + +impl TippingEngine { + /// Load the engine from `` (reading `tipping-config.json` + `tip-ledger.json`), + /// wiring the owner resolver, tip spender, clock, and tip-event bus. + /// + /// FAIL-CLOSED distinction: a file that is ABSENT is a genuine first run (config → DEFAULT-ON, + /// ledger → empty). A file that is PRESENT but unreadable/unparseable POISONS the engine — the + /// config falls back to DISABLED (never re-enables auto-tip) and every tip/mutation is refused + /// until the operator resolves it. A transiently-locked or corrupt/truncated ledger can thus + /// never silently reset the caps + idempotency accounting. + pub fn load( + config_dir: &Path, + owner: Box, + spender: Box, + clock: Box, + events: std::sync::Arc, + ) -> Self { + let config_path = config_dir.join("tipping-config.json"); + let ledger_path = config_dir.join("tip-ledger.json"); + let mut poison: Option = None; + + // Config: absent → DEFAULT-ON (genuine first run). Present-but-unreadable → FAIL CLOSED + // (DISABLED) + poison, so a corrupt config never silently re-enables auto-tip. + let config = match read_json_strict::(&config_path) { + Ok(Some(c)) => c, + Ok(None) => TippingConfig::default(), + Err(e) => { + eprintln!( + "dig-node: WARN tipping config present but unreadable — auto-tip DISABLED \ + (fail-closed) until resolved: {e}" + ); + add_poison(&mut poison, format!("config unreadable: {e}")); + TippingConfig::disabled() + } + }; + + // Ledger: absent → empty (first run). Present-but-unreadable → FAIL CLOSED + poison, so the + // caps + idempotency accounting can NEVER reset to "empty → tip freely". + let (ledger, next_id) = match read_json_strict::(&ledger_path) { + Ok(Some(f)) => { + let next_id = f + .entries + .iter() + .map(|e| e.id + 1) + .chain(std::iter::once(f.next_id)) + .max() + .unwrap_or(0); + (f.entries, next_id) + } + Ok(None) => (Vec::new(), 0), + Err(e) => { + eprintln!( + "dig-node: WARN tip ledger present but unreadable — ALL tips REFUSED \ + (fail-closed) until resolved: {e}" + ); + add_poison(&mut poison, format!("ledger unreadable: {e}")); + (Vec::new(), 0) + } + }; + + let state = TippingState { + config, + ledger, + next_id, + }; + Self { + config_path, + ledger_path, + state: Mutex::new(state), + owner, + spender, + clock, + events, + owner_cache: Mutex::new(HashMap::new()), + poison, + } + } + + /// If the engine is poisoned (unreadable persisted state at load), the machine-stable skip + /// reason; else `None`. Every spend/mutation path consults this FIRST and fails closed. + fn poisoned(&self) -> Option { + self.poison + .as_ref() + .map(|r| TipOutcome::skipped(format!("state-unreadable: {r}"))) + } + + /// The tip-event bus WS sessions subscribe to (SPEC §4.8 `{type:"tip"}` push). + pub fn events(&self) -> &std::sync::Arc { + &self.events + } + + /// The current tipping configuration. + pub async fn get_config(&self) -> TippingConfig { + self.state.lock().await.config.clone() + } + + /// Replace + persist the tipping configuration. REFUSED while poisoned — writing a fresh config + /// over an unreadable persisted state would mask the problem (and could clobber a ledger whose + /// contents we could not read); the operator must resolve the file and restart. + pub async fn set_config(&self, config: TippingConfig) -> Result<()> { + if let Some(reason) = &self.poison { + return Err(Error::internal(format!( + "tipping state is unreadable ({reason}); resolve the file and restart before \ + changing config" + ))); + } + let mut st = self.state.lock().await; + st.config = config; + write_json(&self.config_path, &st.config) + } + + /// The tip ledger, newest first. `since_ts` (unix seconds) optionally filters older entries. + pub async fn get_ledger(&self, since_ts: Option) -> Vec { + let st = self.state.lock().await; + let mut out: Vec = st + .ledger + .iter() + .filter(|e| match since_ts { + Some(t) => e.ts >= t, + None => true, + }) + .cloned() + .collect(); + out.sort_by(|a, b| b.ts.cmp(&a.ts).then(b.id.cmp(&a.id))); + out + } + + /// Resolve `store_id_hex`'s owner puzzle hash (hex), caching the result per store. + async fn resolve_owner_cached(&self, store_id_hex: &str) -> Result> { + let key = normalize_hex(store_id_hex); + if let Some(ph) = self.owner_cache.lock().await.get(&key).cloned() { + return Ok(Some(ph)); + } + match self.owner.resolve_owner(&key).await? { + Some(ph) => { + let ph = normalize_hex(&ph); + self.owner_cache.lock().await.insert(key, ph.clone()); + Ok(Some(ph)) + } + None => Ok(None), + } + } + + /// Run the CREATOR auto-tip for a consumed store. Resolves the owner, then tips per the creator + /// policy — idempotent per `(owner, day)`, fail-closed on the per-site + daily caps. A no-op + /// (clean `Skipped`) when disabled / over-budget / already-tipped / owner-unresolvable. + pub async fn auto_tip_for_store(&self, store_id_hex: &str) -> Result { + // FAIL CLOSED: unreadable persisted state → refuse (never re-tip a possibly-already-tipped + // site with a reset ledger). + if let Some(skip) = self.poisoned() { + return Ok(skip); + } + let (enabled, amount, per_site_cap, mode, daily_total_cap) = { + let st = self.state.lock().await; + let c = &st.config.creator; + ( + c.enabled, + c.dig_amount, + c.per_site_cap, + c.mode, + st.config.daily_total_cap, + ) + }; + if !enabled { + return Ok(TipOutcome::skipped("disabled")); + } + let Some(owner) = self + .resolve_owner_cached(store_id_hex) + .await + .unwrap_or(None) + else { + return Ok(TipOutcome::skipped("owner-unresolved")); + }; + let amount = { + let st = self.state.lock().await; + *st.config + .creator + .per_site_overrides + .get(&owner) + .unwrap_or(&amount) + }; + self.reserve_and_spend(ReserveArgs { + kind: TipKind::Creator, + trigger: TipTrigger::Auto, + recipient: owner, + store_id: Some(normalize_hex(store_id_hex)), + amount, + per_site_cap, + mode, + daily_total_cap, + }) + .await + } + + /// Run the DIG dev-account daily tip — the "support DIG itself" contribution. Recipient = the + /// canonical DIG treasury shared contract (`dig_treasury_ph_hex`); idempotent per day, bounded + /// by the daily total cap. A no-op when disabled / over-budget / already-tipped-today, and + /// fail-closed while the persisted state is unreadable. + pub async fn dev_daily_tip(&self) -> Result { + if let Some(skip) = self.poisoned() { + return Ok(skip); + } + let (enabled, amount, mode, daily_total_cap) = { + let st = self.state.lock().await; + let d = &st.config.dev; + (d.enabled, d.dig_amount, d.mode, st.config.daily_total_cap) + }; + if !enabled { + return Ok(TipOutcome::skipped("disabled")); + } + self.reserve_and_spend(ReserveArgs { + kind: TipKind::Dev, + trigger: TipTrigger::Auto, + recipient: dig_treasury_ph_hex(), + store_id: None, + amount, + per_site_cap: u64::MAX, // the dev tip is bounded only by the daily total cap. + mode, + daily_total_cap, + }) + .await + } + + /// A one-tap MANUAL tip to a store's owner. Explicit user consent: NOT bounded by the auto + /// caps and NOT subject to the once-per-day idempotency (a user may tip repeatedly). Still + /// recorded + crash-safe (reservation before broadcast). + pub async fn manual_tip(&self, store_id_hex: &str) -> Result { + // FAIL CLOSED even for a manual tip: an unreadable ledger means we can't safely append + // (we'd clobber entries we couldn't read); refuse until resolved. + if let Some(skip) = self.poisoned() { + return Ok(skip); + } + let Some(owner) = self + .resolve_owner_cached(store_id_hex) + .await + .unwrap_or(None) + else { + return Ok(TipOutcome::skipped("owner-unresolved")); + }; + let (amount, fee) = { + let st = self.state.lock().await; + (st.config.creator.dig_amount, st.config.fee) + }; + let amount = { + let st = self.state.lock().await; + *st.config + .creator + .per_site_overrides + .get(&owner) + .unwrap_or(&amount) + }; + // Reserve (always — no idempotency/caps for a manual tip), then spend + reconcile. + let day = self.clock.today_utc(); + let ts = self.clock.now_unix(); + let id = { + let mut st = self.state.lock().await; + let id = st.reserve(TipLedgerEntry { + id: 0, + recipient_ph: owner.clone(), + store_id: Some(normalize_hex(store_id_hex)), + dig_amount: amount, + ts, + day, + txid: None, + trigger: TipTrigger::Manual, + kind: TipKind::Creator, + status: TipStatus::Pending, + }); + self.persist_ledger(&st)?; + id + }; + self.spend_and_reconcile(id, owner, amount, fee).await + } + + /// The shared auto-tip reserve→spend→reconcile path: authoritative idempotency + cap checks + /// under the lock, a persisted PENDING reservation BEFORE the broadcast, then reconcile. + async fn reserve_and_spend(&self, args: ReserveArgs) -> Result { + let day = self.clock.today_utc(); + let ts = self.clock.now_unix(); + let fee = { self.state.lock().await.config.fee }; + let id = { + let mut st = self.state.lock().await; + // Idempotency (never double-tip a site in a day) — authoritative under the lock. + if st.auto_already_reserved(args.kind, &args.recipient, &day) { + return Ok(TipOutcome::skipped("already-tipped-today")); + } + // Per-site cap (PerSitePerDay mode only) — fail-closed. + if args.mode == TipMode::PerSitePerDay { + let site = st.auto_site_spent_today(args.kind, &args.recipient, &day); + if site.saturating_add(args.amount) > args.per_site_cap { + return Ok(TipOutcome::skipped("over-per-site-cap")); + } + } + // Daily total cap (creator + dev) — fail-closed. + let total = st.auto_spent_today(&day); + if total.saturating_add(args.amount) > args.daily_total_cap { + return Ok(TipOutcome::skipped("over-daily-cap")); + } + let id = st.reserve(TipLedgerEntry { + id: 0, + recipient_ph: args.recipient.clone(), + store_id: args.store_id.clone(), + dig_amount: args.amount, + ts, + day: day.clone(), + txid: None, + trigger: args.trigger, + kind: args.kind, + status: TipStatus::Pending, + }); + // Persist the reservation BEFORE the broadcast (crash-safety). + self.persist_ledger(&st)?; + id + }; + self.spend_and_reconcile(id, args.recipient, args.amount, fee) + .await + } + + /// Broadcast the reserved tip (money moves here) and reconcile the reservation: confirm on a + /// broadcast, roll back on a definitively-pre-broadcast NotExecutable (retryable), or keep as + /// Failed on an ambiguous error (fail-closed — never retried that day). + async fn spend_and_reconcile( + &self, + id: u64, + recipient: String, + amount: u64, + fee: u64, + ) -> Result { + let outcome = self.spender.send_dig_tip(&recipient, amount, fee).await; + let mut st = self.state.lock().await; + match outcome { + Ok(TipSpendOutcome::Broadcast { txid, confirmed }) => { + if let Some(e) = st.entry_mut(id) { + // Confirm-before-marking-confirmed (§18.12): a broadcast that was included in a + // block is `Confirmed`; one accepted into the mempool but not yet confirmed + // stays `Pending` with its txid (money moved — the reservation still blocks a + // same-day retry, and Pending amounts count toward the caps, so this never + // enables a double-spend). + e.status = if confirmed { + TipStatus::Confirmed + } else { + TipStatus::Pending + }; + e.txid = Some(txid.clone()); + } + self.persist_ledger(&st)?; + let entry = st.ledger.iter().find(|e| e.id == id).cloned(); + drop(st); + if let Some(entry) = entry { + self.events.publish(TipEvent { entry }); + } + Ok(TipOutcome::Tipped { + txid, + dig_amount: amount, + recipient_ph: recipient, + }) + } + Ok(TipSpendOutcome::NotExecutable { reason }) => { + // No money moved → roll the reservation back so it can retry later. + st.remove(id); + self.persist_ledger(&st)?; + Ok(TipOutcome::skipped(format!("wallet-unavailable: {reason}"))) + } + Err(e) => { + // Ambiguous broadcast failure → keep the reservation (Failed) so this (site, day) + // is NEVER retried (fail-closed: never double-spend). + if let Some(entry) = st.entry_mut(id) { + entry.status = TipStatus::Failed; + } + self.persist_ledger(&st)?; + Ok(TipOutcome::skipped(format!( + "spend-failed-not-retried: {e}" + ))) + } + } + } + + /// Persist the ledger atomically (temp file + rename) while the state lock is held. + fn persist_ledger(&self, st: &TippingState) -> Result<()> { + let file = LedgerFile { + next_id: st.next_id, + entries: st.ledger.clone(), + }; + write_json(&self.ledger_path, &file) + } +} + +/// Arguments for [`TippingEngine::reserve_and_spend`] (grouped to keep the signature honest). +struct ReserveArgs { + kind: TipKind, + trigger: TipTrigger, + recipient: String, + store_id: Option, + amount: u64, + per_site_cap: u64, + mode: TipMode, + daily_total_cap: u64, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Small helpers (hex normalization + atomic JSON persistence) +// ───────────────────────────────────────────────────────────────────────────── + +/// Normalize a puzzle-hash / store-id hex to lowercase without a `0x` prefix. +fn normalize_hex(s: &str) -> String { + s.strip_prefix("0x") + .or_else(|| s.strip_prefix("0X")) + .unwrap_or(s) + .to_ascii_lowercase() +} + +/// Accumulate a poison reason (comma-joined) so BOTH an unreadable config AND an unreadable ledger +/// are recorded. +fn add_poison(poison: &mut Option, reason: String) { + *poison = Some(match poison.take() { + Some(p) => format!("{p}; {reason}"), + None => reason, + }); +} + +/// Read + deserialize a JSON file, distinguishing ABSENT from PRESENT-BUT-UNREADABLE (FAIL-CLOSED, +/// the money-safety contract): +/// - `Ok(None)` — the file does not exist (a genuine first run: caller defaults/empties). +/// - `Ok(Some(T))` — the file exists and parsed. +/// - `Err(_)` — the file EXISTS but could not be read (locked/permission/IO) OR could not be parsed +/// (corrupt/truncated/forward-incompatible). The caller MUST fail closed — NEVER treat this as +/// "empty/default", which would reset caps + idempotency (over-spend) or re-enable a disabled +/// auto-tip. `unwrap_or_default()` on this result would reintroduce the fail-open bug. +fn read_json_strict(path: &Path) -> Result> { + match std::fs::read(path) { + Ok(bytes) => { + let value = serde_json::from_slice(&bytes).map_err(|e| { + Error::internal(format!( + "{} is present but unparseable: {e}", + path.display() + )) + })?; + Ok(Some(value)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(Error::internal(format!( + "{} is present but unreadable: {e}", + path.display() + ))), + } +} + +/// Serialize + write a JSON file DURABLY: write a temp file in the same dir, `fsync` it, atomically +/// `rename` it into place, then best-effort `fsync` the parent directory — so a crash/power-loss +/// can never leave a truncated/zero-length money ledger (which would then hit the fail-closed +/// read path on the next load). Owner-only best effort. The wallet crate carries its own helper +/// (the node's `control::write_atomic` lives in the service crate). +fn write_json(path: &Path, value: &T) -> Result<()> { + use std::io::Write; + let bytes = serde_json::to_vec_pretty(value) + .map_err(|e| Error::internal(format!("serialize {}: {e}", path.display())))?; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp = path.with_extension(format!("tmp-{}-{nanos}", std::process::id())); + { + let mut f = std::fs::File::create(&tmp) + .map_err(|e| Error::internal(format!("create {}: {e}", tmp.display())))?; + f.write_all(&bytes) + .map_err(|e| Error::internal(format!("write {}: {e}", tmp.display())))?; + // fsync the file contents BEFORE the rename so the rename can only ever expose a fully + // durable file. + f.sync_all() + .map_err(|e| Error::internal(format!("fsync {}: {e}", tmp.display())))?; + } + std::fs::rename(&tmp, path) + .map_err(|e| Error::internal(format!("rename into {}: {e}", path.display())))?; + // Best-effort fsync of the parent dir so the rename itself is durable (a no-op / not permitted + // on some platforms — e.g. opening a directory as a file on Windows — hence best-effort). + if let Some(parent) = path.parent() { + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Production seam implementations +// ───────────────────────────────────────────────────────────────────────────── + +/// The production owner resolver: resolves a store's owner puzzle hash from its on-chain CHIP-0035 +/// singleton via `digstore_chain::singleton::sync_datastore` — the SAME DataStore parser the node +/// already uses for store sync (never re-parsing a singleton by hand). The chain client is a +/// [`digstore_chain::coinset::ChainReads`] (coinset.org, [`Coinset::mainnet`]); it is a swappable +/// seam — a `chia-query`-backed `ChainReads` (decentralized peers + coinset fallback), which already +/// backs the node's coin-read fallback tier, is a drop-in when a full `ChainReads` over it lands. +pub struct ChainOwnerResolver { + chain: std::sync::Arc, +} + +impl ChainOwnerResolver { + /// Resolve owners against mainnet coinset.org. + pub fn mainnet() -> Self { + Self { + chain: std::sync::Arc::new(digstore_chain::coinset::Coinset::mainnet()), + } + } + + /// Resolve owners against a supplied chain client (tests / a custom substrate). + pub fn with_chain(chain: std::sync::Arc) -> Self { + Self { chain } + } +} + +#[async_trait] +impl OwnerResolver for ChainOwnerResolver { + async fn resolve_owner(&self, store_id_hex: &str) -> Result> { + let launcher = super::singleton::bytes32_from_hex(store_id_hex)?; + match digstore_chain::singleton::sync_datastore(self.chain.as_ref(), launcher).await { + Ok(store) => Ok(Some(hex::encode(store.info.owner_puzzle_hash))), + // A not-yet-minted / unknown store is a clean "no owner" (the engine skips, never spends). + Err(e) => Err(Error::api(format!("owner lookup failed: {e}"))), + } + } +} + +/// The production tip spender: builds+signs+validates+broadcasts via the node-custodied +/// [`super::rpc::WalletBackend`] (`build_and_broadcast_dig_tip`) with an injected broadcaster. +/// +/// The broadcaster is `None` on the offline-safe shipped bring-up (the wallet spend path's live +/// sync/lineage/broadcaster is the documented remaining integration, SPEC §18.12); until then a +/// tip cleanly reports [`TipSpendOutcome::NotExecutable`] (the engine skips — money never moves). +/// When the wallet spend bring-up attaches a real broadcaster (a `ChiaQueryBroadcaster`), tips +/// execute unchanged. +pub struct NodeTipSpender { + backend: std::sync::Arc, + broadcaster: Option>, + /// The on-chain confirmer (§18.12). `None` ⇒ a broadcast tip is recorded `Pending` (accepted, + /// not confirmed); `Some` ⇒ the tip waits for on-chain inclusion and is recorded `Confirmed` + /// once a block includes it. Shares the SAME `chia_query` client as the broadcaster. + confirmer: Option>, +} + +impl NodeTipSpender { + /// Build a spender over `backend`. The `backend` MUST NOT itself hold this engine (pass a clone + /// taken before `with_tipping`) to avoid a reference cycle. `broadcaster`/`confirmer` are + /// `None` on the offline-safe shipped bring-up (a tip then reports `NotExecutable` and money + /// never moves) and `Some` when live broadcast is enabled (§18.12). + pub fn new( + backend: std::sync::Arc, + broadcaster: Option>, + confirmer: Option>, + ) -> Self { + Self { + backend, + broadcaster, + confirmer, + } + } +} + +#[async_trait] +impl TipSpender for NodeTipSpender { + async fn send_dig_tip( + &self, + recipient_ph_hex: &str, + amount: u64, + fee: u64, + ) -> Result { + let Some(bc) = self.broadcaster.as_ref() else { + return Ok(TipSpendOutcome::NotExecutable { + reason: "no broadcaster configured (wallet spend path not yet wired)".into(), + }); + }; + // Point-read live sync before selecting (§18.12): refresh the wallet DB from the fallback so + // coin selection runs over current chain state. Best-effort — a sync failure is not a spend + // failure: `build_and_broadcast_dig_tip` then reports NotExecutable if no $DIG is selectable + // (retryable), never a false spend. + if let Err(e) = self.backend.refresh_tracked_coins().await { + eprintln!( + "dig-node: WARN tip pre-spend coin sync failed (continuing best-effort): {e}" + ); + } + let recipient = super::singleton::bytes32_from_hex(recipient_ph_hex)?; + self.backend + .build_and_broadcast_dig_tip( + recipient, + amount, + fee, + bc.as_ref(), + self.confirmer.as_deref(), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex as StdMutex}; + + // ── test doubles (no chain — money-safe by construction) ────────────── + + /// Behaviour of a mock spend attempt. + #[derive(Clone, Copy, PartialEq, Eq)] + enum SpendBehaviour { + /// Accept + confirm on-chain: a fresh txid, `confirmed: true` (ledger `Confirmed`). + Broadcast, + /// Accept into the mempool but NOT confirmed within the window: a fresh txid, + /// `confirmed: false` (ledger `Pending`, txid set — §18.12). + BroadcastUnconfirmed, + /// Definitively pre-broadcast (locked/no-coins) — retryable. + NotExecutable, + /// Ambiguous broadcast error — fail-closed, no retry. + Ambiguous, + } + + /// A recording spender that NEVER touches a chain — the money-safety proofs run against it. + struct MockSpender { + calls: StdMutex>, + behaviour: StdMutex, + seq: AtomicU64, + } + impl MockSpender { + fn new() -> Arc { + Arc::new(Self { + calls: StdMutex::new(Vec::new()), + behaviour: StdMutex::new(SpendBehaviour::Broadcast), + seq: AtomicU64::new(0), + }) + } + fn calls(&self) -> Vec<(String, u64)> { + self.calls.lock().unwrap().clone() + } + fn call_count(&self) -> usize { + self.calls.lock().unwrap().len() + } + fn set(&self, b: SpendBehaviour) { + *self.behaviour.lock().unwrap() = b; + } + } + #[async_trait] + impl TipSpender for Arc { + async fn send_dig_tip( + &self, + recipient_ph_hex: &str, + amount: u64, + _fee: u64, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((recipient_ph_hex.to_string(), amount)); + match *self.behaviour.lock().unwrap() { + SpendBehaviour::NotExecutable => Ok(TipSpendOutcome::NotExecutable { + reason: "locked".into(), + }), + SpendBehaviour::Ambiguous => Err(Error::internal("network rejected")), + SpendBehaviour::Broadcast => { + let n = self.seq.fetch_add(1, Ordering::SeqCst); + Ok(TipSpendOutcome::Broadcast { + txid: format!("tx{n}"), + confirmed: true, + }) + } + SpendBehaviour::BroadcastUnconfirmed => { + let n = self.seq.fetch_add(1, Ordering::SeqCst); + Ok(TipSpendOutcome::Broadcast { + txid: format!("tx{n}"), + confirmed: false, + }) + } + } + } + } + + /// A fixed owner resolver returning a preset ph (or `None`), counting calls (to prove caching). + struct MockOwner { + ph: Option, + calls: AtomicU64, + } + impl MockOwner { + fn some(ph: &str) -> Arc { + Arc::new(Self { + ph: Some(ph.to_string()), + calls: AtomicU64::new(0), + }) + } + fn none() -> Arc { + Arc::new(Self { + ph: None, + calls: AtomicU64::new(0), + }) + } + fn count(&self) -> u64 { + self.calls.load(Ordering::SeqCst) + } + } + #[async_trait] + impl OwnerResolver for Arc { + async fn resolve_owner(&self, _store_id_hex: &str) -> Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.ph.clone()) + } + } + + /// A clock pinned to a fixed unix time (settable to advance days). + struct FixedClock(AtomicU64); + impl FixedClock { + fn at(secs: u64) -> Arc { + Arc::new(Self(AtomicU64::new(secs))) + } + fn set(&self, secs: u64) { + self.0.store(secs, Ordering::SeqCst); + } + } + impl Clock for Arc { + fn now_unix(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } + } + + fn owner_hex() -> String { + "11".repeat(32) + } + const STORE: &str = "0xabc"; // arbitrary store id (normalized to "abc") + const DAY0: u64 = 1_700_000_000; // 2023-11-14 (a stable day) + const DAY1: u64 = DAY0 + 86_400; // the next day + + /// The directory is OWNED by the returned guard: `TempDir`'s `Drop` removes the tree, + /// including on an unwind, so a failing assertion cannot leak it (dig-node#370). + fn scratch() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("dig-tip-") + .tempdir() + .expect("a scratch dir") + } + + /// Build an engine over `dir` and seed its config (persisted). + async fn make( + dir: &Path, + owner: impl OwnerResolver + 'static, + spender: Arc, + clock: Arc, + config: TippingConfig, + ) -> TippingEngine { + let eng = TippingEngine::load( + dir, + Box::new(owner), + Box::new(spender), + Box::new(clock), + Arc::new(TipEventBus::default()), + ); + eng.set_config(config).await.unwrap(); + eng + } + + /// A config with small, cap-friendly numbers for deterministic tests. + fn test_config() -> TippingConfig { + let mut c = TippingConfig::default(); + c.creator.dig_amount = 100; + c.creator.per_site_cap = 100; + c.daily_total_cap = 250; + c.dev.dig_amount = 100; + c + } + + // ── unix_to_utc_date ───────────────────────────────────────────────── + + #[test] + fn utc_date_epoch_and_known_days() { + assert_eq!(unix_to_utc_date(0), "1970-01-01"); + assert_eq!(unix_to_utc_date(86_399), "1970-01-01"); + assert_eq!(unix_to_utc_date(86_400), "1970-01-02"); + assert_eq!(unix_to_utc_date(DAY0), "2023-11-14"); + assert_eq!(unix_to_utc_date(DAY1), "2023-11-15"); + } + + /// The canonical DIG treasury inner puzzle hash (the per-capsule-payment recipient) — the + /// dev-account tip's recipient. Byte-identical to the shared contract. + const TREASURY: &str = "ec7c304708c7d59c078d5ae098d0dea004decf47fa1cafebb266c10ad6466ce8"; + + // ── defaults + dev recipient ───────────────────────────────────────── + + #[test] + fn creator_and_dev_default_on() { + let c = TippingConfig::default(); + assert!(c.creator.enabled, "creator auto-tip is DEFAULT-ON (#377)"); + assert!( + c.dev.enabled, + "dev-account tip is DEFAULT-ON (#377) — real treasury recipient" + ); + assert!(c.creator.dig_amount > 0 && c.daily_total_cap >= c.creator.dig_amount); + } + + #[test] + fn dev_recipient_is_the_dig_treasury_shared_contract() { + // Sourced from digstore-chain (never re-hardcoded); must equal the per-capsule-payment PH. + assert_eq!(dig_treasury_ph_hex(), TREASURY); + } + + // ── owner-PH lookup (cached) ───────────────────────────────────────── + + #[tokio::test] + async fn owner_lookup_is_cached_per_store() { + let dir = scratch(); + let owner = MockOwner::some(&owner_hex()); + let sp = MockSpender::new(); + let eng = make( + dir.path(), + owner.clone(), + sp, + FixedClock::at(DAY0), + test_config(), + ) + .await; + + // Two auto-tips for the same store on the same day: the first tips, the second is an + // idempotent skip — but the owner is resolved only ONCE (cached). + eng.auto_tip_for_store(STORE).await.unwrap(); + eng.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(owner.count(), 1, "owner resolution is cached per store"); + } + + #[tokio::test] + async fn auto_tip_skips_when_owner_unresolved() { + let dir = scratch(); + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::none(), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + let out = eng.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(out, TipOutcome::skipped("owner-unresolved")); + assert_eq!(sp.call_count(), 0, "no spend without a recipient"); + } + + // ── disabled → skip ────────────────────────────────────────────────── + + #[tokio::test] + async fn disabled_creator_skips_without_spending() { + let dir = scratch(); + let mut cfg = test_config(); + cfg.creator.enabled = false; + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + cfg, + ) + .await; + let out = eng.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(out, TipOutcome::skipped("disabled")); + assert_eq!(sp.call_count(), 0); + } + + // ── the happy path: a creator auto-tip, recorded + pushed ──────────── + + #[tokio::test] + async fn creator_auto_tip_spends_records_and_pushes() { + let dir = scratch(); + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + let mut rx = eng.events().subscribe(); + + let out = eng.auto_tip_for_store(STORE).await.unwrap(); + match out { + TipOutcome::Tipped { + dig_amount, + recipient_ph, + .. + } => { + assert_eq!(dig_amount, 100); + assert_eq!(recipient_ph, owner_hex()); + } + other => panic!("expected Tipped, got {other:?}"), + } + assert_eq!(sp.calls(), vec![(owner_hex(), 100)]); + + // Ledger records one confirmed creator/auto entry with a txid. + let ledger = eng.get_ledger(None).await; + assert_eq!(ledger.len(), 1); + assert_eq!(ledger[0].status, TipStatus::Confirmed); + assert_eq!(ledger[0].kind, TipKind::Creator); + assert_eq!(ledger[0].trigger, TipTrigger::Auto); + assert!(ledger[0].txid.is_some()); + + // A tip event was pushed over the WS bus. + let ev = rx.try_recv().expect("a tip event was published"); + assert_eq!(ev.entry.recipient_ph, owner_hex()); + assert_eq!(ev.entry.status, TipStatus::Confirmed); + } + + // ── idempotency: never double-tip a site in a day ───────────────────── + + #[tokio::test] + async fn same_day_same_site_is_tipped_only_once() { + let dir = scratch(); + let sp = MockSpender::new(); + let clock = FixedClock::at(DAY0); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + clock.clone(), + test_config(), + ) + .await; + + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + let second = eng.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(second, TipOutcome::skipped("already-tipped-today")); + assert_eq!(sp.call_count(), 1, "spent exactly once for the site/day"); + + // A NEW day tips again. + clock.set(DAY1); + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert_eq!(sp.call_count(), 2); + } + + // ── crash-retry: reload from the persisted ledger → no double-spend ── + + #[tokio::test] + async fn crash_retry_does_not_double_spend() { + let dir = scratch(); + // Engine 1 tips once (reservation + confirm persisted to tip-ledger.json). + { + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert_eq!(sp.call_count(), 1); + } + // Engine 2 (simulating a restart) reloads the SAME dir + same day → the (site, day) is + // already reserved → SKIP, never a second spend. + let sp2 = MockSpender::new(); + let eng2 = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp2.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + let out = eng2.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(out, TipOutcome::skipped("already-tipped-today")); + assert_eq!( + sp2.call_count(), + 0, + "a restart never re-spends a reserved day" + ); + } + + // ── caps fail closed ───────────────────────────────────────────────── + + #[tokio::test] + async fn per_site_cap_blocks_over_the_ceiling() { + let dir = scratch(); + let mut cfg = test_config(); + cfg.creator.dig_amount = 100; + cfg.creator.per_site_cap = 100; // one tip fits; a second would exceed + cfg.daily_total_cap = 10_000; // not the binding constraint here + let sp = MockSpender::new(); + let clock = FixedClock::at(DAY0); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + clock, + cfg, + ) + .await; + + // First store → owner tipped. A DIFFERENT store with the SAME owner the same day would + // exceed the per-site cap (idempotency already blocks the same store; force a manual-style + // second reservation by using the cap path directly): the second auto for the same owner + // via a different store id still maps to the same owner → idempotency skip. To isolate the + // per-site cap we set the amount ABOVE the cap so even the first tip is blocked. + let mut cfg2 = test_config(); + cfg2.creator.dig_amount = 200; + cfg2.creator.per_site_cap = 100; // 200 > 100 → blocked + let sp2 = MockSpender::new(); + let dir2 = scratch(); + let eng2 = make( + dir2.path(), + MockOwner::some(&owner_hex()), + sp2.clone(), + FixedClock::at(DAY0), + cfg2, + ) + .await; + let out = eng2.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(out, TipOutcome::skipped("over-per-site-cap")); + assert_eq!(sp2.call_count(), 0, "over-cap fails closed — nothing spent"); + let _ = (eng, sp); + } + + #[tokio::test] + async fn daily_total_cap_blocks_across_sites() { + // daily_total_cap = 250, each tip = 100 → the 3rd distinct site is blocked. + let dir = scratch(); + let mut cfg = test_config(); + cfg.creator.dig_amount = 100; + cfg.creator.per_site_cap = 100_000; // not binding + cfg.daily_total_cap = 250; + let clock = FixedClock::at(DAY0); + let sp = MockSpender::new(); + // Three different owners for three different stores. + let a = "aa".repeat(32); + let b = "bb".repeat(32); + let c = "cc".repeat(32); + // Resolver maps each store to a distinct owner. + struct Multi(Vec<(String, String)>); + #[async_trait] + impl OwnerResolver for Multi { + async fn resolve_owner(&self, store: &str) -> Result> { + Ok(self + .0 + .iter() + .find(|(s, _)| *s == super::normalize_hex(store)) + .map(|(_, o)| o.clone())) + } + } + let resolver = Multi(vec![ + ("s1".into(), a.clone()), + ("s2".into(), b.clone()), + ("s3".into(), c.clone()), + ]); + let eng = make(dir.path(), resolver, sp.clone(), clock, cfg).await; + + assert!(matches!( + eng.auto_tip_for_store("s1").await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert!(matches!( + eng.auto_tip_for_store("s2").await.unwrap(), + TipOutcome::Tipped { .. } + )); + let third = eng.auto_tip_for_store("s3").await.unwrap(); + assert_eq!(third, TipOutcome::skipped("over-daily-cap")); + assert_eq!( + sp.call_count(), + 2, + "the daily total cap fails closed on the 3rd" + ); + } + + // ── dev-account tip: pays the real DIG treasury, once per day ──────── + + #[tokio::test] + async fn dev_daily_tip_pays_the_treasury_once_per_day() { + let dir = scratch(); + let mut cfg = test_config(); + cfg.creator.enabled = false; // isolate the dev tip + cfg.dev.enabled = true; + cfg.dev.dig_amount = 100; + cfg.daily_total_cap = 1_000; + let sp = MockSpender::new(); + let clock = FixedClock::at(DAY0); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + clock.clone(), + cfg, + ) + .await; + + // The dev tip pays the REAL DIG treasury shared-contract PH. + match eng.dev_daily_tip().await.unwrap() { + TipOutcome::Tipped { + recipient_ph, + dig_amount, + .. + } => { + assert_eq!(recipient_ph, TREASURY, "dev tip pays the DIG treasury PH"); + assert_eq!(dig_amount, 100); + } + other => panic!("expected Tipped to the treasury, got {other:?}"), + } + assert_eq!(sp.calls(), vec![(TREASURY.to_string(), 100)]); + + // Idempotent: a second dev tick the same day is a no-op (never double-tip the treasury/day). + assert_eq!( + eng.dev_daily_tip().await.unwrap(), + TipOutcome::skipped("already-tipped-today") + ); + assert_eq!(sp.call_count(), 1); + + // A new day tips again. + clock.set(DAY1); + assert!(matches!( + eng.dev_daily_tip().await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert_eq!(sp.call_count(), 2); + + // The ledger records dev-kind entries. + let ledger = eng.get_ledger(None).await; + assert!(ledger.iter().all(|e| e.kind == TipKind::Dev)); + assert_eq!(ledger.len(), 2); + } + + /// **Proves:** the daily total cap spans creator + dev — a dev tip counts against the same + /// budget, so the two together can never exceed the daily cap (fail-closed). + #[tokio::test] + async fn daily_total_cap_spans_creator_and_dev() { + let dir = scratch(); + let mut cfg = test_config(); + cfg.creator.enabled = true; + cfg.creator.dig_amount = 100; + cfg.creator.per_site_cap = 100_000; // not binding + cfg.dev.enabled = true; + cfg.dev.dig_amount = 100; + cfg.daily_total_cap = 150; // one 100 tip fits; the second (creator OR dev) is blocked + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + cfg, + ) + .await; + + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + // The dev tip would push the day's total to 200 > 150 → blocked (creator already spent 100). + let dev = eng.dev_daily_tip().await.unwrap(); + assert_eq!(dev, TipOutcome::skipped("over-daily-cap")); + assert_eq!( + sp.call_count(), + 1, + "the shared daily cap fails closed across creator + dev" + ); + } + + // ── NotExecutable → rolled back (retryable); Ambiguous → kept (no retry) + + #[tokio::test] + async fn not_executable_rolls_back_and_is_retryable() { + let dir = scratch(); + let sp = MockSpender::new(); + sp.set(SpendBehaviour::NotExecutable); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + let out = eng.auto_tip_for_store(STORE).await.unwrap(); + assert!(matches!(out, TipOutcome::Skipped { .. })); + assert!( + eng.get_ledger(None).await.is_empty(), + "a pre-broadcast skip leaves no reservation" + ); + // Now the wallet becomes executable → the same store/day tips (it was rolled back). + sp.set(SpendBehaviour::Broadcast); + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert_eq!(sp.call_count(), 2); + } + + #[tokio::test] + async fn ambiguous_broadcast_error_is_not_retried_that_day() { + let dir = scratch(); + let sp = MockSpender::new(); + sp.set(SpendBehaviour::Ambiguous); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + let out = eng.auto_tip_for_store(STORE).await.unwrap(); + assert!(matches!(out, TipOutcome::Skipped { .. })); + // The reservation is KEPT as Failed (fail-closed: never double-spend on an ambiguous error). + let ledger = eng.get_ledger(None).await; + assert_eq!(ledger.len(), 1); + assert_eq!(ledger[0].status, TipStatus::Failed); + // A retry the same day is refused (no second broadcast attempt). + sp.set(SpendBehaviour::Broadcast); + let retry = eng.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(retry, TipOutcome::skipped("already-tipped-today")); + assert_eq!(sp.call_count(), 1, "an ambiguous day is never retried"); + } + + /// A broadcast that was accepted into the mempool but NOT confirmed on-chain within the window + /// (§18.12) records the tip as `Pending` (money moved — outcome is `Tipped`, txid set), and the + /// reservation still blocks a same-day retry (Pending counts toward the caps + idempotency). + #[tokio::test] + async fn unconfirmed_broadcast_is_pending_with_txid_and_blocks_retry() { + let dir = scratch(); + let sp = MockSpender::new(); + sp.set(SpendBehaviour::BroadcastUnconfirmed); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + // Money moved (broadcast accepted) → the outcome is Tipped with a txid. + let out = eng.auto_tip_for_store(STORE).await.unwrap(); + assert!(matches!(out, TipOutcome::Tipped { .. })); + // But the ledger status is Pending (not yet confirmed on-chain) with the txid recorded. + let ledger = eng.get_ledger(None).await; + assert_eq!(ledger.len(), 1); + assert_eq!(ledger[0].status, TipStatus::Pending); + assert!(ledger[0].txid.is_some(), "the broadcast txid is recorded"); + // A same-day retry is refused: the Pending reservation blocks re-tipping (no double-spend). + sp.set(SpendBehaviour::Broadcast); + let retry = eng.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(retry, TipOutcome::skipped("already-tipped-today")); + assert_eq!( + sp.call_count(), + 1, + "a pending (unconfirmed) tip is never re-broadcast" + ); + } + + // ── manual tip: bypasses idempotency + caps, always executes ───────── + + #[tokio::test] + async fn manual_tip_bypasses_idempotency_and_caps() { + let dir = scratch(); + let mut cfg = test_config(); + cfg.creator.enabled = false; // even with auto off, a manual tip works + cfg.daily_total_cap = 0; // and it ignores the auto daily cap + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + cfg, + ) + .await; + + assert!(matches!( + eng.manual_tip(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert!(matches!( + eng.manual_tip(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert_eq!( + sp.call_count(), + 2, + "manual tips repeat freely (explicit consent)" + ); + let ledger = eng.get_ledger(None).await; + assert_eq!(ledger.len(), 2); + assert!(ledger.iter().all(|e| e.trigger == TipTrigger::Manual)); + } + + // ── config persistence ─────────────────────────────────────────────── + + #[tokio::test] + async fn config_persists_across_reload() { + let dir = scratch(); + let sp = MockSpender::new(); + let mut cfg = test_config(); + cfg.creator.dig_amount = 777; + { + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + cfg, + ) + .await; + assert_eq!(eng.get_config().await.creator.dig_amount, 777); + } + // A fresh engine over the same dir reads the persisted config. + let eng2 = TippingEngine::load( + dir.path(), + Box::new(MockOwner::some(&owner_hex())), + Box::new(sp), + Box::new(FixedClock::at(DAY0)), + Arc::new(TipEventBus::default()), + ); + assert_eq!(eng2.get_config().await.creator.dig_amount, 777); + } + + // ── FAIL-CLOSED on unreadable persisted state (money-safety regression) ── + + /// Load an engine over `dir` WITHOUT seeding config (so a poisoned load is observable — the + /// poison guard would reject `set_config`). + fn load_only( + dir: &Path, + owner: impl OwnerResolver + 'static, + spender: Arc, + clock: Arc, + ) -> TippingEngine { + TippingEngine::load( + dir, + Box::new(owner), + Box::new(spender), + Box::new(clock), + Arc::new(TipEventBus::default()), + ) + } + + /// **Proves (HIGH regression):** a PRESENT-but-corrupt `tip-ledger.json` FAILS CLOSED — the + /// engine does NOT reset the ledger to empty and re-tip. Without the fix, the corrupt ledger + /// would read as empty → `auto_already_reserved`=false → re-tip the full daily budget. + #[tokio::test] + async fn present_but_corrupt_ledger_fails_closed_no_retip() { + let dir = scratch(); + std::fs::write( + dir.path().join("tip-ledger.json"), + b"{ this is not valid json ]", + ) + .unwrap(); + let sp = MockSpender::new(); + let eng = load_only( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + ); + let out = eng.auto_tip_for_store(STORE).await.unwrap(); + assert!( + matches!(&out, TipOutcome::Skipped { reason } if reason.starts_with("state-unreadable")), + "a corrupt ledger must fail closed: {out:?}" + ); + assert_eq!( + sp.call_count(), + 0, + "no spend while the ledger is unreadable" + ); + // A manual tip is refused too (it would clobber the unreadable ledger). + assert_eq!(sp.call_count(), 0); + assert!(matches!( + eng.manual_tip(STORE).await.unwrap(), + TipOutcome::Skipped { .. } + )); + assert_eq!(sp.call_count(), 0); + } + + /// **Proves (HIGH regression):** a truncated / zero-length ledger (an interrupted write / power- + /// loss artifact) is PRESENT-but-unparseable → fails closed, no re-tip. + #[tokio::test] + async fn truncated_zero_length_ledger_fails_closed() { + let dir = scratch(); + std::fs::write(dir.path().join("tip-ledger.json"), b"").unwrap(); // zero-length + let sp = MockSpender::new(); + let eng = load_only( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + ); + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Skipped { .. } + )); + assert_eq!(sp.call_count(), 0); + } + + /// **Proves (HIGH regression):** a real prior tip, then a CORRUPTED ledger on the next boot → + /// the reloaded engine REFUSES (fail closed) rather than double-spending the already-tipped + /// site. This is the concrete double-spend scenario the fail-open bug enabled. + #[tokio::test] + async fn corrupt_ledger_after_a_tip_prevents_double_spend() { + let dir = scratch(); + { + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert_eq!(sp.call_count(), 1); + } + // Corrupt the persisted ledger (e.g. AV/indexer lock recovery, partial write). + std::fs::write(dir.path().join("tip-ledger.json"), b"\x00\x00corrupt").unwrap(); + let sp2 = MockSpender::new(); + let eng2 = load_only( + dir.path(), + MockOwner::some(&owner_hex()), + sp2.clone(), + FixedClock::at(DAY0), + ); + let out = eng2.auto_tip_for_store(STORE).await.unwrap(); + assert!( + matches!(&out, TipOutcome::Skipped { reason } if reason.starts_with("state-unreadable")), + "a corrupt ledger after a tip must not re-tip: {out:?}" + ); + assert_eq!( + sp2.call_count(), + 0, + "NEVER double-spend on an unreadable ledger" + ); + } + + /// **Proves (HIGH regression):** a PRESENT-but-corrupt `tipping-config.json` does NOT silently + /// fall back to the DEFAULT-ON config — auto-tip is treated as DISABLED (never moves $DIG + /// against a user who had turned it off) and the engine is poisoned. + #[tokio::test] + async fn present_but_corrupt_config_does_not_reenable_autotip() { + let dir = scratch(); + std::fs::write(dir.path().join("tipping-config.json"), b"{ not: valid").unwrap(); + let sp = MockSpender::new(); + let eng = load_only( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + ); + // The effective config is fail-closed DISABLED (not the DEFAULT-ON default). + let cfg = eng.get_config().await; + assert!( + !cfg.creator.enabled, + "corrupt config must NOT re-enable creator auto-tip" + ); + assert!(!cfg.dev.enabled); + // And any auto-tip is refused (poisoned). + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Skipped { .. } + )); + assert_eq!(sp.call_count(), 0); + // set_config is refused while poisoned (resolve the file + restart). + assert!(eng.set_config(test_config()).await.is_err()); + } + + /// **Proves:** ABSENT files are a genuine first run — NOT poison. The engine tips normally + /// (creator DEFAULT-ON), so the fail-closed distinction doesn't over-block real first boots. + #[tokio::test] + async fn absent_files_are_a_clean_first_run_and_tip() { + let dir = scratch(); // fresh, empty dir — no config or ledger files + let sp = MockSpender::new(); + let eng = load_only( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + ); + // DEFAULT-ON config (absent config → default, not disabled). + assert!(eng.get_config().await.creator.enabled); + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + assert_eq!(sp.call_count(), 1, "a genuine first run still tips"); + } + + /// **Proves:** `write_json` produces a durable, re-readable file (the fsync path doesn't + /// corrupt output) — a tipped ledger reloads cleanly and is treated as already-tipped. + #[tokio::test] + async fn durable_write_reloads_cleanly() { + let dir = scratch(); + { + let sp = MockSpender::new(); + let eng = make( + dir.path(), + MockOwner::some(&owner_hex()), + sp.clone(), + FixedClock::at(DAY0), + test_config(), + ) + .await; + assert!(matches!( + eng.auto_tip_for_store(STORE).await.unwrap(), + TipOutcome::Tipped { .. } + )); + } + // Reload: the persisted ledger parses (not poisoned) and the day is already tipped. + let sp2 = MockSpender::new(); + let eng2 = load_only( + dir.path(), + MockOwner::some(&owner_hex()), + sp2.clone(), + FixedClock::at(DAY0), + ); + let out = eng2.auto_tip_for_store(STORE).await.unwrap(); + assert_eq!(out, TipOutcome::skipped("already-tipped-today")); + assert_eq!(sp2.call_count(), 0); + } + + // ── event bus does not leak the Sage SyncEvent union ───────────────── + + #[test] + fn tip_event_bus_publish_with_no_subscribers_is_noop() { + let bus = TipEventBus::default(); + bus.publish(TipEvent { + entry: TipLedgerEntry { + id: 0, + recipient_ph: owner_hex(), + store_id: None, + dig_amount: 1, + ts: 0, + day: "1970-01-01".into(), + txid: None, + trigger: TipTrigger::Auto, + kind: TipKind::Dev, + status: TipStatus::Pending, + }, + }); + assert_eq!(bus.subscriber_count(), 0); + } +} diff --git a/crates/dig-wallet/tests/scratch_addr.rs b/crates/dig-wallet/tests/scratch_addr.rs deleted file mode 100644 index 79b605e7..00000000 --- a/crates/dig-wallet/tests/scratch_addr.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Scratch tool (not committed): print each watched public key's p2 puzzle hash, its bech32m -//! address, and the $DIG CAT outer puzzle hash it owns coins at. - -use chia_bls::PublicKey; -use chia_puzzle_types::standard::StandardArgs; - -const KEYS: [&str; 3] = [ - "82a042f2a57c2863862a061700d9cf2650adede6f90aff662a73ed31c47f60511ac9dc4b8f276477c606ba9272a43de9", - "91e72e437529e82ebea7e4f973939791b5c7bca07a28862cfe2b96dbe4c8785650fbf4d37cfc7ee35632c028bf90a899", - "a652cdf7278788fc26be31b2a7935ebca074ae352d2dbf098e78a0ad3568fc48002cc6f02af37591f721e7d67a76ba64", -]; - -#[test] -fn print_addresses() { - for k in KEYS { - let bytes: [u8; 48] = hex::decode(k).unwrap().try_into().unwrap(); - let pk = PublicKey::from_bytes(&bytes).unwrap(); - let ph = StandardArgs::curry_tree_hash(pk); - let ph32 = chia_protocol::Bytes32::from(ph.to_bytes()); - let cat = digstore_chain::cat::cat_puzzle_hash(ph32, digstore_chain::dig::DIG_ASSET_ID); - println!( - "key={k}\n p2={}\n addr={}\n cat={}", - hex::encode(ph32), - chia_wallet_sdk::utils::Address::new(ph32, "xch".to_string()) - .encode() - .unwrap(), - hex::encode(cat) - ); - } -} From a8449abb3cf2d3efaa402e978816595a841e6f10 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 04:53:58 -0700 Subject: [PATCH 21/21] fix(test): use the control scratch guard's path; clear the pairing needless borrows --- crates/dig-node-service/src/control.rs | 10 +++++----- crates/dig-node-service/src/pairing.rs | 23 ++++++++++------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index 46e33423..b795cb39 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -6076,7 +6076,7 @@ mod tests { .prefix("dig-node-token-untrusted-") .tempdir() .expect("a scratch dir"); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); std::fs::create_dir_all(&dir).unwrap(); let planted = "planted0".repeat(8); // a KNOWN 64-char attacker value (non-empty) std::fs::write(&path, &planted).unwrap(); @@ -6097,7 +6097,7 @@ mod tests { // The real second case. Only root can hand a file to another uid, which is exactly // the planting an unprivileged attacker would have to achieve — and it is what the // guard exists to refuse. - let foreign = dir.join("foreign").join(CONTROL_TOKEN_FILE); + let foreign = dir.path().join("foreign").join(CONTROL_TOKEN_FILE); std::fs::create_dir_all(foreign.parent().unwrap()).unwrap(); std::fs::write(&foreign, &planted).unwrap(); std::fs::set_permissions(&foreign, std::fs::Permissions::from_mode(0o600)).unwrap(); @@ -6192,7 +6192,7 @@ mod tests { .prefix("dig-node-token-trusted-") .tempdir() .expect("a scratch dir"); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); std::fs::create_dir_all(&dir).unwrap(); let existing = "a".repeat(64); std::fs::write(&path, &existing).unwrap(); @@ -6218,7 +6218,7 @@ mod tests { .prefix("dig-node-token-perms-") .tempdir() .expect("a scratch dir"); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); load_or_create_token_at(&path).unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; assert_eq!( @@ -6378,7 +6378,7 @@ mod tests { .prefix("dig-node-token-denied-") .tempdir() .expect("a scratch dir"); - let path = dir.join(CONTROL_TOKEN_FILE); + let path = dir.path().join(CONTROL_TOKEN_FILE); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(&path, "a".repeat(64)).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); diff --git a/crates/dig-node-service/src/pairing.rs b/crates/dig-node-service/src/pairing.rs index 35a436df..9388ea85 100644 --- a/crates/dig-node-service/src/pairing.rs +++ b/crates/dig-node-service/src/pairing.rs @@ -563,7 +563,7 @@ mod tests { assert_eq!(pend["result"]["status"], json!("pending")); // Approve (master path) → the token is minted + persisted. - let ap = approve(&p, &config, json!(4), &json!({ "pairing_id": pid })); + let ap = approve(&p, config, json!(4), &json!({ "pairing_id": pid })); assert_eq!(ap["result"]["approved"], json!(true)); let token_id = ap["result"]["token_id"].as_str().unwrap().to_string(); @@ -574,20 +574,17 @@ mod tests { assert_eq!(token.len(), 64, "64-hex scoped token"); // The token is a valid paired token; a wrong one is not. - assert!(is_paired_token(&paired_tokens_path(&config), &token)); - assert!(!is_paired_token( - &paired_tokens_path(&config), - "not-a-token" - )); + assert!(is_paired_token(&paired_tokens_path(config), &token)); + assert!(!is_paired_token(&paired_tokens_path(config), "not-a-token")); // Delivered ONCE: a second poll no longer knows the id. let again = poll(&p, json!(6), &json!({ "pairing_id": pid })); assert_eq!(again["result"]["status"], json!("unknown")); // Revoke → the token stops authorizing. - let rv = revoke(&config, json!(7), &json!({ "token_id": token_id })); + let rv = revoke(config, json!(7), &json!({ "token_id": token_id })); assert_eq!(rv["result"]["revoked"], json!(true)); - assert!(!is_paired_token(&paired_tokens_path(&config), &token)); + assert!(!is_paired_token(&paired_tokens_path(config), &token)); } #[test] @@ -595,7 +592,7 @@ mod tests { let scratch = tmp_config(); let config = scratch.path(); let p = pending(); - let resp = approve(&p, &config, json!(1), &json!({ "pairing_id": "nope" })); + let resp = approve(&p, config, json!(1), &json!({ "pairing_id": "nope" })); assert_eq!( resp["error"]["code"], json!(ErrorCode::InvalidParams.code()) @@ -611,17 +608,17 @@ mod tests { let pid = req["result"]["pairing_id"].as_str().unwrap().to_string(); // Before approval: one pending, no tokens. - let l1 = list(&p, &config, json!(2)); + let l1 = list(&p, config, json!(2)); assert_eq!(l1["result"]["pending"].as_array().unwrap().len(), 1); assert_eq!(l1["result"]["pending"][0]["client_name"], json!("ext-A")); assert_eq!(l1["result"]["tokens"].as_array().unwrap().len(), 0); - approve(&p, &config, json!(3), &json!({ "pairing_id": pid.clone() })); + approve(&p, config, json!(3), &json!({ "pairing_id": pid.clone() })); // consume the pending via poll poll(&p, json!(4), &json!({ "pairing_id": pid })); // After: no pending, one issued token (value never listed). - let l2 = list(&p, &config, json!(5)); + let l2 = list(&p, config, json!(5)); assert_eq!(l2["result"]["pending"].as_array().unwrap().len(), 0); let tokens = l2["result"]["tokens"].as_array().unwrap(); assert_eq!(tokens.len(), 1); @@ -660,7 +657,7 @@ mod tests { fn load_paired_tokens_tolerates_missing_and_malformed() { let scratch = tmp_config(); let config = scratch.path(); - let path = paired_tokens_path(&config); + let path = paired_tokens_path(config); assert!(load_paired_tokens(&path).is_empty(), "missing file → empty"); std::fs::write(&path, b"not json").unwrap(); assert!(load_paired_tokens(&path).is_empty(), "malformed → empty");