From c9b5a16d831ec58a5f815d88fc4ee12c245e404e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 13:23:22 -0700 Subject: [PATCH 1/6] chore(392): lane anchor for the silent wallet-start investigation Salvage anchor so a cap is never lossy. No production change yet. Refs #392 Co-Authored-By: Claude From 2d1f78de065ea85bdbf8fcc96b20714d36620d77 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 13:50:56 -0700 Subject: [PATCH 2/6] feat(wallet): announce a split per-user root and refuse to mint into it Two resolvers decide "the per-user base directory" in one dig-node process and disagree under a LOCALAPPDATA override: dig_wallet's is env-first and owns the seed/meta/device key, while dig-node-core's asks the OS known-folder API first and owns cache/config and therefore wallet.sqlite. The node came up with a newly minted seed under one root and a coin replica under the other, saying only that it had minted a wallet. Resolution is deliberately unchanged -- deriving the wallet base from the node's cache dir would move the seed off every existing service install and mint a fresh wallet there. Instead the split is announced, and the one irreversible consequence (minting into a split layout) is refused. Also announces an inert DIG_WALLET_PORT: dig-node never starts a wallet host, so nothing binds the port an operator set. Co-Authored-By: Claude --- SPEC.md | 15 +- crates/dig-node-core/src/lib.rs | 23 +- crates/dig-node-service/src/entrypoint.rs | 4 + crates/dig-node-service/src/lib.rs | 4 + .../dig-node-service/src/wallet_bootstrap.rs | 68 ++++- crates/dig-node-service/src/wallet_env.rs | 262 ++++++++++++++++++ crates/dig-node-service/src/win_service.rs | 5 +- crates/dig-wallet/src/autoseed.rs | 9 +- 8 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 crates/dig-node-service/src/wallet_env.rs diff --git a/SPEC.md b/SPEC.md index 19dc5e23..9ff5688c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4378,9 +4378,22 @@ has run. `dig-wallet` is the DIG Browser's built-in Chia wallet host: a loopback `axum` server bound `127.0.0.1:` (default `9777`) serving the wallet UI and a dapp-facing JSON-RPC -surface, with native BLS signing. In the native browser it ALSO runs in-process via the §15 FFI +surface. That surface is a ROUTER, not a signer: every key/sign method is forwarded to the user's +Sage wallet over the WalletConnect delegate bridge, and NO signing happens in this process, which +holds no user key (§908). In the native browser it ALSO runs in-process via the §15 FFI (`dig_wallet_rpc`), sharing one process-global wallet state with the loopback UI. +`DIG_WALLET_PORT` is read ONLY by `dig_wallet::run` — that is, by the DIG Browser runtime and by the +standalone `dig-wallet` binary. The `dig-node` binary NEVER starts a wallet host, so the variable is +INERT there and nothing binds the port; a `dig-node` run that finds it set says so on start-up. + +Likewise `LOCALAPPDATA` relocates only the wallet's OWN artifacts — `DigWallet/seed.bin`, +`wallet.meta.json` and `DigNode/device/device.key`, which resolve env-first. It does NOT relocate the +node's cache, `config.json`, or the `wallet.sqlite` coin replica, which resolve through the OS +known-folder API; `DIG_NODE_CACHE` is the variable that moves those. A `dig-node` start-up that +resolves the two roots differently WARNS, and REFUSES to mint a new seed into the split layout +(nothing is written). + ### 16.1. Method surface + dispatch The advertised dapp JSON-RPC method catalogue is the crate's `WC_METHOD_CATALOGUE` — the single source diff --git a/crates/dig-node-core/src/lib.rs b/crates/dig-node-core/src/lib.rs index eb5312f4..d5aa90dd 100644 --- a/crates/dig-node-core/src/lib.rs +++ b/crates/dig-node-core/src/lib.rs @@ -611,6 +611,23 @@ fn canonical_cache_dir() -> PathBuf { { return PathBuf::from(env); } + platform_user_base().join("DigNode").join("cache") +} + +/// The per-user base directory this node resolves everything under: the cache, `config.json`, and +/// therefore the `wallet.sqlite` coin replica that hangs off the config's directory. +/// +/// It asks the OS for the known folder FIRST and only then falls back to the environment. That +/// ordering is deliberate and load-bearing for the shared-cache guarantee above -- it is correct on +/// a Windows host whose raw env vars are unset -- but it also means an operator who overrides +/// `LOCALAPPDATA` does NOT move this path, while the wallet's own env-first resolver +/// (`dig_wallet::autoseed::user_base`) does. The two halves of that disagreement are announced by +/// `dig_node_service::wallet_env` (dig-node#392); this function is public so that comparison can be +/// made against the real resolver rather than a second spelling of it. +/// +/// Extracted verbatim from [`canonical_cache_dir`], which still calls it: the resolution order is +/// unchanged in every arm. +pub fn platform_user_base() -> PathBuf { let base = directories::BaseDirs::new().map(|b| { if cfg!(windows) { b.data_local_dir().to_path_buf() @@ -620,11 +637,9 @@ fn canonical_cache_dir() -> PathBuf { b.home_dir().to_path_buf() } }); - let root = base - .or_else(|| std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)) + base.or_else(|| std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)) .or_else(|| std::env::var("HOME").ok().map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from(".")); - root.join("DigNode").join("cache") + .unwrap_or_else(|| PathBuf::from(".")) } /// A deterministic process-private fallback cache dir, used only when the diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 804ebf91..f1943d4c 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -1412,6 +1412,10 @@ fn live_apply_level(logs_matches: &clap::ArgMatches) { /// dev dir. fn block_on_serve(config: Config) -> std::io::Result<()> { crate::logging::init(crate::logging::run_context()); + // Say what this process actually resolved BEFORE anything is minted (#392): an operator who + // overrode `LOCALAPPDATA` has split the seed away from the `wallet.sqlite` replica, and must + // read that before - not after - a line reporting a freshly minted wallet. + crate::wallet_env::announce_from_env(); // A seed must exist before anything can use the wallet, and there is no user here to create // one — so check on EVERY start (first install, post-update, ordinary boot) and mint one when // there is definitely none (#277). Never fatal: a node that cannot establish a wallet still diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 6ad91814..c03a863a 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -151,6 +151,10 @@ pub mod wallet_authz; /// Never fatal, never a fallback. See [`wallet_bootstrap`]. pub mod wallet_bootstrap; +/// Announcing the wallet-related environment this process resolved, and refusing the one +/// destructive consequence of a split per-user root (dig-node#392). See [`wallet_env`]. +pub mod wallet_env; + /// Latching the fact that the node's own wallet has held funds, so no surface calls a funded /// auto-created wallet disposable (dig-node#286). See [`wallet_funded`]. pub mod wallet_funded; diff --git a/crates/dig-node-service/src/wallet_bootstrap.rs b/crates/dig-node-service/src/wallet_bootstrap.rs index 4572825f..d014633f 100644 --- a/crates/dig-node-service/src/wallet_bootstrap.rs +++ b/crates/dig-node-service/src/wallet_bootstrap.rs @@ -18,13 +18,51 @@ //! without the device key". A fallback would quietly become the real design on exactly the //! constrained hosts this is meant to serve. +use dig_node_core::shared::at_rest::{presence, Presence}; use dig_wallet::autoseed::{self, BootstrapState, WalletPaths}; +use crate::wallet_env::{self, MintDecision}; + /// Ensure a wallet seed exists at the node's real per-user location, logging the outcome. /// /// Returns the state so a caller can surface it; callers must not treat any outcome as fatal. +/// +/// Minting is REFUSED when the wallet's per-user root and the node's disagree and there is no seed +/// yet (dig-node#392): the new seed would land under one root while the `wallet.sqlite` coin +/// replica opens under the other, and the operator would be told only that a wallet was minted. A +/// host that ALREADY has a seed proceeds unchanged - it is running, and refusing would break a +/// working install to enforce a layout rule. pub fn ensure_wallet_seed() -> Option { - ensure_wallet_seed_at(&autoseed::default_paths()) + ensure_wallet_seed_unless_split(&autoseed::default_paths(), wallet_env::wallet_root_split()) +} + +/// [`ensure_wallet_seed`] against an explicit layout and an explicit split verdict. +/// +/// The split is a PARAMETER for the same reason `ensure_wallet_seed_at` takes its paths: proving +/// that the refusal writes nothing means running it with a split present and no seed on disk, and a +/// test that produced that state by setting the real `LOCALAPPDATA` would be exercising the +/// developer's own wallet directory to assert a property about a refusal. +pub fn ensure_wallet_seed_unless_split( + paths: &WalletPaths, + split: Option, +) -> Option { + if let MintDecision::RefuseSplitRoot = + wallet_env::mint_decision(split.as_ref(), seed_present(paths)) + { + tracing::error!("{}", wallet_env::REFUSED_SPLIT_MINT); + return None; + } + ensure_wallet_seed_at(paths) +} + +/// Whether a seed is on disk, taking "the question could not be answered" as PRESENT. +/// +/// An unreadable seed file - a locked file, an AV scanner, an ACL an OS update changed - must never +/// read as "there is no wallet here", because the only thing that follows from "no wallet" is +/// minting one. [`dig_wallet::autoseed`]'s own `wallet_exists` takes the same unknown-means-present +/// direction, and it is the direction that cannot lose a wallet. +fn seed_present(paths: &WalletPaths) -> bool { + !matches!(presence(&paths.seed), Ok(Presence::Absent)) } /// [`ensure_wallet_seed`] against an explicit layout. @@ -85,3 +123,31 @@ pub fn ensure_wallet_seed_at(paths: &WalletPaths) -> Option { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + /// The refusal must leave the disk exactly as it found it. Asserted on the seed file itself, + /// not on the returned state: a decision that returned `None` while still minting would satisfy + /// any assertion about the return value alone. + #[test] + fn a_split_root_with_no_seed_mints_nothing() { + let td = tempfile::tempdir().expect("tempdir"); + let paths = WalletPaths { + seed: td.path().join("DigWallet").join("seed.bin"), + device_key: td.path().join("DigNode").join("device").join("device.key"), + meta: td.path().join("DigWallet").join("wallet.meta.json"), + }; + let split = wallet_env::split_of(Path::new("/wallet-root"), Path::new("/node-root")); + assert!(split.is_some(), "the fixture must actually be split"); + + let state = ensure_wallet_seed_unless_split(&paths, split); + + assert!(state.is_none(), "a refused mint reports no wallet state"); + assert!(!paths.seed.exists(), "the seed file was NOT created"); + assert!(!paths.meta.exists(), "no metadata was written either"); + assert!(!paths.device_key.exists(), "no device key was written"); + } +} diff --git a/crates/dig-node-service/src/wallet_env.rs b/crates/dig-node-service/src/wallet_env.rs new file mode 100644 index 00000000..08478c61 --- /dev/null +++ b/crates/dig-node-service/src/wallet_env.rs @@ -0,0 +1,262 @@ +//! Announce the wallet-related environment this process actually resolved (#392). +//! +//! Two independent resolvers decide "the per-user base directory" inside one `dig-node` process, +//! and they disagree the moment an operator overrides `LOCALAPPDATA`: +//! +//! - [`dig_wallet::autoseed::user_base`] is ENV-FIRST, so it honours the override. It owns +//! `DigWallet/seed.bin`, `wallet.meta.json` and `DigNode/device/device.key`. +//! - [`dig_node_core::platform_user_base`] asks the OS for the Known Folder and only falls back to +//! the environment, so on Windows it ignores the override. It owns `cache/`, `config.json` and +//! therefore `wallet.sqlite`, the coin replica. +//! +//! Under an override the node came up with a NEWLY MINTED seed under one root and a coin replica +//! under another, and the only thing it said was that it had minted a wallet - which reads as a +//! clean first run rather than as a wallet split in half. +//! +//! # Why this module announces instead of unifying +//! +//! Making either resolver defer to the other is the obvious fix and it is the destructive one. On a +//! service run [`crate::state::anchor_service_data_dirs`] points `DIG_NODE_CACHE` at +//! `C:\ProgramData\DigNode\cache`, so deriving the wallet base from the node's cache dir would move +//! the seed off `...systemprofile\AppData\Local\DigWallet\seed.bin`, find nothing there, and mint a +//! FRESH wallet on every existing install - orphaning the operator wallet and any $DIG in it. +//! Resolution therefore stays exactly as it is. What changes is that the split is said out loud, +//! and that the one irreversible consequence - minting a brand new seed into a split layout - is +//! refused rather than performed quietly. +//! +//! # Shape +//! +//! A pure decision core ([`split_of`], [`mint_decision`], [`inert_wallet_port`]) that takes its +//! inputs as arguments, plus thin env-reading and log-emitting wrappers - the same split +//! [`crate::logging::degrade_announcement`] and [`crate::state::service_data_dir_overrides`] use, +//! and for the same reason: the interesting branch is the one that does NOT occur on the machine +//! the tests run on, so it must be reachable without mutating the process environment. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// The two disagreeing roots, as this process resolved them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WalletRootSplit { + /// Where the seed, its metadata and the device key live (env-first, honours the override). + pub wallet_base: PathBuf, + /// Where the cache, config and the `wallet.sqlite` replica live (OS-first, ignores it). + pub node_base: PathBuf, +} + +/// What start-up should do about a split, given whether a seed already exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MintDecision { + /// Run the ordinary bootstrap. + Proceed, + /// Do not mint: there is no wallet yet and minting one now would write it into a layout whose + /// two halves are already known to disagree. + RefuseSplitRoot, +} + +/// The prose for the split warning. See [`crate::logging::FILE_LOGGING_DEGRADED`] for why these are +/// `concat!` constants and never `\`-continued string literals: a formatter run rejoins a +/// continuation and materialises the source indentation into the string, which no assertion on the +/// surrounding code can see. `the_announcements_have_no_lost_string_continuation` guards it. +pub const SPLIT_ROOTS: &str = concat!( + "the wallet's per-user root and the node's per-user root DISAGREE in this process. ", + "LOCALAPPDATA relocated the seed, its metadata and the device key, but NOT the node's ", + "cache, config.json, or the wallet.sqlite coin replica - those resolve through the OS ", + "known-folder API, which does not read that variable. Set DIG_NODE_CACHE to move the ", + "replica and cache alongside the seed, or unset LOCALAPPDATA to leave both at the ", + "machine default." +); + +/// The prose for the refusal. It must leave no doubt that the disk was not touched: an operator who +/// reads a mint failure as a partial write goes looking for something to delete, and deleting is +/// the one act this refusal exists to prevent. +pub const REFUSED_SPLIT_MINT: &str = concat!( + "no wallet exists yet and the wallet and node roots disagree, so NOTHING was minted and ", + "NOTHING was written. Minting here would put the seed under one root and the wallet.sqlite ", + "replica under another. Set DIG_NODE_CACHE alongside LOCALAPPDATA so both halves land in ", + "one place, or unset LOCALAPPDATA, then start the node again." +); + +/// The prose for an inert `DIG_WALLET_PORT`. A variable that is read by nobody is worse than an +/// unsupported one, because it looks configured. +pub const INERT_WALLET_PORT: &str = concat!( + "DIG_WALLET_PORT is set, but dig-node serves NO wallet UI and nothing will listen on that ", + "port. The variable is honoured only by the DIG Browser runtime and by the standalone ", + "dig-wallet binary." +); + +/// Whether the two roots disagree. `Some` iff they differ. Pure. +pub fn split_of(wallet_base: &Path, node_base: &Path) -> Option { + if wallet_base == node_base { + return None; + } + Some(WalletRootSplit { + wallet_base: wallet_base.to_path_buf(), + node_base: node_base.to_path_buf(), + }) +} + +/// [`split_of`] against the two resolvers this process actually uses. +/// +/// The one place the pure core is bound to the real functions - and therefore the one place a +/// wrong pair of functions would hide, which is why it has its own environment-level test. +pub fn wallet_root_split() -> Option { + split_of( + &dig_wallet::autoseed::user_base(), + &dig_node_core::platform_user_base(), + ) +} + +/// Whether start-up may mint. Pure. +/// +/// A split with a seed ALREADY on disk proceeds: that host is running, its two halves are whatever +/// they are, and refusing would break a working install to enforce a layout rule. The refusal is +/// reserved for the irreversible case - creating a new wallet into a layout already known to be +/// split. +pub fn mint_decision(split: Option<&WalletRootSplit>, seed_present: bool) -> MintDecision { + match (split, seed_present) { + (Some(_), false) => MintDecision::RefuseSplitRoot, + _ => MintDecision::Proceed, + } +} + +/// The port an operator set that nothing in this binary will bind. Pure; empty is unset. +pub fn inert_wallet_port(raw: Option<&str>) -> Option<&str> { + raw.filter(|p| !p.is_empty()) +} + +/// Emitted once per process; a serve entrypoint that runs twice must not say it twice. +static ANNOUNCED: AtomicBool = AtomicBool::new(false); + +/// Warn about every wallet-environment condition this run resolved into. +/// +/// Called from the serve entrypoints BEFORE the bootstrap, so an operator reads why a wallet was +/// refused before - not after - the line that would have said one was minted. +pub fn announce(split: Option<&WalletRootSplit>, wallet_port: Option<&str>) { + if ANNOUNCED.swap(true, Ordering::SeqCst) { + return; + } + if let Some(split) = split { + tracing::warn!( + wallet_base = %split.wallet_base.display(), + node_base = %split.node_base.display(), + "{SPLIT_ROOTS}" + ); + } + if let Some(port) = wallet_port { + tracing::warn!(port = %port, "{INERT_WALLET_PORT}"); + } +} + +/// The environment reader for [`announce`], kept beside it so a caller passes no arguments and +/// cannot accidentally announce a condition it computed some other way. +pub fn announce_from_env() { + let split = wallet_root_split(); + let raw = std::env::var("DIG_WALLET_PORT").ok(); + announce(split.as_ref(), inert_wallet_port(raw.as_deref())); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// Serializes every test that mutates `LOCALAPPDATA` (the `ENV_LOCK` idiom `dig-wallet`'s own + /// tests use): the lib tests share one process, so an unguarded override leaks into any + /// concurrent test resolving a per-user path. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn equal_roots_are_not_a_split() { + assert_eq!(split_of(Path::new("/a"), Path::new("/a")), None); + } + + #[test] + fn differing_roots_are_a_split_naming_both() { + let split = split_of(Path::new("/wallet"), Path::new("/node")).expect("a split"); + assert_eq!(split.wallet_base, PathBuf::from("/wallet")); + assert_eq!(split.node_base, PathBuf::from("/node")); + } + + /// All four arms. A table with three rows leaves the interesting one untested, and the + /// interesting one is (split, seed present) - the arm that must NOT refuse. + #[test] + fn mint_is_refused_only_when_a_split_would_create_a_new_wallet() { + let split = split_of(Path::new("/wallet"), Path::new("/node")).unwrap(); + assert_eq!( + mint_decision(Some(&split), false), + MintDecision::RefuseSplitRoot + ); + assert_eq!(mint_decision(Some(&split), true), MintDecision::Proceed); + assert_eq!(mint_decision(None, false), MintDecision::Proceed); + assert_eq!(mint_decision(None, true), MintDecision::Proceed); + } + + #[test] + fn an_empty_wallet_port_is_unset() { + assert_eq!(inert_wallet_port(Some("9877")), Some("9877")); + assert_eq!(inert_wallet_port(None), None); + assert_eq!(inert_wallet_port(Some("")), None); + } + + #[test] + fn the_announcements_name_what_an_operator_must_change() { + assert!(SPLIT_ROOTS.contains("LOCALAPPDATA")); + assert!(SPLIT_ROOTS.contains("DIG_NODE_CACHE")); + assert!(SPLIT_ROOTS.contains("wallet.sqlite")); + assert!(REFUSED_SPLIT_MINT.contains("DIG_NODE_CACHE")); + assert!(REFUSED_SPLIT_MINT.contains("LOCALAPPDATA")); + assert!(REFUSED_SPLIT_MINT.contains("NOTHING was written")); + assert!(INERT_WALLET_PORT.contains("DIG_WALLET_PORT")); + assert!(INERT_WALLET_PORT.contains("dig-wallet")); + } + + /// The one test that reproduces the REPORTED condition rather than a property of the pure + /// core: with `LOCALAPPDATA` overridden, the wrapper must report a split naming that override. + /// Without it the pure tests would all pass over a wrapper wired to the wrong pair of + /// functions - which is precisely the defect, one layer up. + /// + /// Serialized on a module-local lock and restores the variable, because `cargo test` runs the + /// lib tests in one process and a mid-flight `LOCALAPPDATA` would be read by anything else + /// resolving a per-user path. Nothing here writes to disk: it compares two resolved paths. + #[test] + fn an_overridden_localappdata_is_reported_as_a_split() { + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let restore = std::env::var("LOCALAPPDATA").ok(); + let td = tempfile::tempdir().expect("tempdir"); + + std::env::set_var("LOCALAPPDATA", td.path()); + let split = wallet_root_split(); + + match restore { + Some(v) => std::env::set_var("LOCALAPPDATA", v), + None => std::env::remove_var("LOCALAPPDATA"), + } + + let split = split.expect("an overridden LOCALAPPDATA splits the two roots"); + assert_eq!( + split.wallet_base, + td.path(), + "the wallet root follows the override" + ); + assert_ne!( + split.node_base, + td.path(), + "the node root does not follow it" + ); + assert_eq!( + wallet_root_split(), + None, + "restoring the variable removes the split" + ); + } + + /// A `\`-continued literal carries its source indentation into the string. Assert on the + /// rendered text, because that is the only artifact a formatter cannot rewrite behind us. + #[test] + fn the_announcements_have_no_lost_string_continuation() { + for text in [SPLIT_ROOTS, REFUSED_SPLIT_MINT, INERT_WALLET_PORT] { + assert!(!text.contains(" "), "run of 4+ spaces in: {text}"); + } + } +} diff --git a/crates/dig-node-service/src/win_service.rs b/crates/dig-node-service/src/win_service.rs index 03a72b5e..59362b2b 100644 --- a/crates/dig-node-service/src/win_service.rs +++ b/crates/dig-node-service/src/win_service.rs @@ -122,7 +122,10 @@ fn run_service() -> std::io::Result<()> { // Same start-up wallet check the foreground entrypoint runs (#277). The SCM path does not go // through `block_on_serve`, so it needs its own call — a service install is the case where - // there is most certainly no user present to create a seed. + // there is most certainly no user present to create a seed. The environment announcement (#392) + // goes with it for the same reason: an announcement that exists on only one of the two serve + // entrypoints is silent on exactly the unattended run nobody is watching. + crate::wallet_env::announce_from_env(); crate::wallet_bootstrap::ensure_wallet_seed(); // Build the runtime and serve, shutting down when the control handler fires. diff --git a/crates/dig-wallet/src/autoseed.rs b/crates/dig-wallet/src/autoseed.rs index b6288c96..6a9e534d 100644 --- a/crates/dig-wallet/src/autoseed.rs +++ b/crates/dig-wallet/src/autoseed.rs @@ -107,7 +107,14 @@ pub fn default_paths() -> WalletPaths { } /// The per-user, non-roaming base directory both roots hang off (NC-3's location contract). -fn user_base() -> PathBuf { +/// +/// ENV-FIRST BY DESIGN: an operator who sets `LOCALAPPDATA` relocates the seed, its metadata and +/// the device key, and that is the behaviour installs depend on. The node's own base +/// (`dig_node_core::platform_user_base`) asks the OS known-folder API first and so does NOT move +/// with it, which means the two can disagree. Public so `dig_node_service::wallet_env` can compare +/// them and say so out loud rather than letting a start-up mint a seed under one root while the +/// coin replica opens under another (dig-node#392). The body is unchanged. +pub fn user_base() -> PathBuf { let base = std::env::var("LOCALAPPDATA") .or_else(|_| std::env::var("HOME")) .unwrap_or_else(|_| ".".to_string()); From 7f6608b7d0945d43237160c9ab282cac34a67625 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 14:01:21 -0700 Subject: [PATCH 3/6] fix(wallet): name the resolved wallet.sqlite path in the split warning The replica is a SIBLING of the config file, not a child of the cache dir, so an operator who sets DIG_NODE_CACHE and then looks for wallet.sqlite inside that directory concludes the second lever failed too. Name the resolved file. Co-Authored-By: Claude --- crates/dig-node-service/src/wallet_env.rs | 45 ++++++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/dig-node-service/src/wallet_env.rs b/crates/dig-node-service/src/wallet_env.rs index 08478c61..fd40d5e0 100644 --- a/crates/dig-node-service/src/wallet_env.rs +++ b/crates/dig-node-service/src/wallet_env.rs @@ -62,9 +62,10 @@ pub const SPLIT_ROOTS: &str = concat!( "the wallet's per-user root and the node's per-user root DISAGREE in this process. ", "LOCALAPPDATA relocated the seed, its metadata and the device key, but NOT the node's ", "cache, config.json, or the wallet.sqlite coin replica - those resolve through the OS ", - "known-folder API, which does not read that variable. Set DIG_NODE_CACHE to move the ", - "replica and cache alongside the seed, or unset LOCALAPPDATA to leave both at the ", - "machine default." + "known-folder API, which does not read that variable. The replica this run will open is ", + "logged as `replica` below; note it sits BESIDE the cache directory, not inside it. To put ", + "both halves in one place set DIG_NODE_CACHE as well, or unset LOCALAPPDATA to leave both ", + "at the machine default." ); /// The prose for the refusal. It must leave no doubt that the disk was not touched: an operator who @@ -73,7 +74,7 @@ pub const SPLIT_ROOTS: &str = concat!( pub const REFUSED_SPLIT_MINT: &str = concat!( "no wallet exists yet and the wallet and node roots disagree, so NOTHING was minted and ", "NOTHING was written. Minting here would put the seed under one root and the wallet.sqlite ", - "replica under another. Set DIG_NODE_CACHE alongside LOCALAPPDATA so both halves land in ", + "replica under another. Set DIG_NODE_CACHE as well as LOCALAPPDATA so both halves land in ", "one place, or unset LOCALAPPDATA, then start the node again." ); @@ -125,6 +126,24 @@ pub fn inert_wallet_port(raw: Option<&str>) -> Option<&str> { raw.filter(|p| !p.is_empty()) } +/// The coin replica that hangs off the node's config, given that config's path. +/// +/// A SIBLING of the config file, which is itself a sibling of the cache directory - so an operator +/// who sets `DIG_NODE_CACHE` and then looks for `wallet.sqlite` INSIDE that directory does not find +/// it, and concludes the second lever failed too. Naming the resolved file is worth more than +/// naming the variable. Pure, so the derivation is checkable without resolving anything. +pub fn replica_beside(config: &Path) -> PathBuf { + config + .parent() + .map(|p| p.join("wallet.sqlite")) + .unwrap_or_else(|| PathBuf::from("wallet.sqlite")) +} + +/// [`replica_beside`] against the config path this process resolves. +pub fn replica_path() -> PathBuf { + replica_beside(&dig_node_core::config_path()) +} + /// Emitted once per process; a serve entrypoint that runs twice must not say it twice. static ANNOUNCED: AtomicBool = AtomicBool::new(false); @@ -132,7 +151,7 @@ static ANNOUNCED: AtomicBool = AtomicBool::new(false); /// /// Called from the serve entrypoints BEFORE the bootstrap, so an operator reads why a wallet was /// refused before - not after - the line that would have said one was minted. -pub fn announce(split: Option<&WalletRootSplit>, wallet_port: Option<&str>) { +pub fn announce(split: Option<&WalletRootSplit>, replica: &Path, wallet_port: Option<&str>) { if ANNOUNCED.swap(true, Ordering::SeqCst) { return; } @@ -140,6 +159,7 @@ pub fn announce(split: Option<&WalletRootSplit>, wallet_port: Option<&str>) { tracing::warn!( wallet_base = %split.wallet_base.display(), node_base = %split.node_base.display(), + replica = %replica.display(), "{SPLIT_ROOTS}" ); } @@ -153,7 +173,11 @@ pub fn announce(split: Option<&WalletRootSplit>, wallet_port: Option<&str>) { pub fn announce_from_env() { let split = wallet_root_split(); let raw = std::env::var("DIG_WALLET_PORT").ok(); - announce(split.as_ref(), inert_wallet_port(raw.as_deref())); + announce( + split.as_ref(), + &replica_path(), + inert_wallet_port(raw.as_deref()), + ); } #[cfg(test)] @@ -199,6 +223,15 @@ mod tests { assert_eq!(inert_wallet_port(Some("")), None); } + /// The replica is a SIBLING of the config file, never a child of the cache directory. + #[test] + fn the_replica_sits_beside_the_config_not_inside_the_cache() { + assert_eq!( + replica_beside(Path::new("/iso/config.json")), + PathBuf::from("/iso/wallet.sqlite") + ); + } + #[test] fn the_announcements_name_what_an_operator_must_change() { assert!(SPLIT_ROOTS.contains("LOCALAPPDATA")); From 2b22c7aa7bed9fe25e1be4df2d4f3fc27eb928fc Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 14:17:17 -0700 Subject: [PATCH 4/6] chore(release): dig-node 0.235.0, dig-node-core 0.66.0, dig-wallet 0.47.0 Minor on both libraries: each gains an additive public fn (platform_user_base, autoseed::user_base) with no behaviour change. Co-Authored-By: Claude --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/dig-node-core/Cargo.toml | 2 +- crates/dig-wallet/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 085b6050..657defc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2969,7 +2969,7 @@ dependencies = [ [[package]] name = "dig-node-core" -version = "0.65.0" +version = "0.66.0" dependencies = [ "async-trait", "axum", @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.229.0" +version = "0.235.0" dependencies = [ "async-trait", "axum", @@ -3337,7 +3337,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.46.0" +version = "0.47.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index e2130624..d3467879 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.229.0" +version = "0.235.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/crates/dig-node-core/Cargo.toml b/crates/dig-node-core/Cargo.toml index 10e7f31a..d55a5fa8 100644 --- a/crates/dig-node-core/Cargo.toml +++ b/crates/dig-node-core/Cargo.toml @@ -30,7 +30,7 @@ name = "dig-node-core" # dig-node#276/#296). Changing a public return type is BREAKING for an out-of-workspace implementor; # this crate is consumed in-workspace only and is pre-1.0, so it is a MINOR bump under SemVer's 0.x # rule -- recorded here rather than letting the number imply the locator surface held still. -version = "0.65.0" +version = "0.66.0" edition = "2021" license = "GPL-2.0-only" description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works." diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index 86cca621..a7856032 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.46.0" +version = "0.47.0" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." From 404076d84643f1db362e69bceed008ddd0e4372a Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 15:04:08 -0700 Subject: [PATCH 5/6] fix(wallet): refuse a split mint only for an unanswered LOCALAPPDATA override The refusal shipped in this branch fired on every stock Linux `.deb` install and named a remedy that did nothing, reintroducing the silent-start defect (#392) one layer up. Both halves were wrong for the same reason: the predicate asked whether the two per-user roots DIFFER, when the question it needed to ask was whether an operator made them differ. - `directories::BaseDirs` reads `$HOME` and then falls back to `getpwuid_r`, while `dig_wallet::autoseed::user_base` has no such fallback. The shipped systemd unit sets no `User=`, no `Group=` and no `HOME=`, so on every stock install the roots diverged with nobody at fault and the node came up wallet-less. - `REFUSED_SPLIT_MINT` told the operator to set `DIG_NODE_CACHE`, which neither resolver reads. On a service run the anchor has already set it and the refusal fired anyway. The predicate now refuses on the conjunction of an OVERRIDE-caused split, no seed, and no `DIG_NODE_CACHE` - so the escape it names is one it honours. Roots are compared through a pure `same_root` that ignores trailing separators and, when asked, case; case-insensitivity is a PARAMETER rather than a `cfg!(windows)` read, because CI runs on ubuntu-latest only and an internal `cfg!` would leave the Windows arm untested on every runner this repo has. Exact equality would have reported a false split, and therefore a permanent refusal to mint, on an ordinary Windows host whose environment and known-folder result differ only in spelling. An ambient divergence now warns with its own sentence, which names both resolved roots and the seed file and does NOT prescribe the inert remedy. Also states the `anchor_service_data_dirs` mechanism conditionally in the module doc - it is gated on `state::running_as_service` - and corrects SPEC 16 to the narrowed predicate. Co-Authored-By: Claude --- SPEC.md | 16 +- .../dig-node-service/src/wallet_bootstrap.rs | 93 +++- crates/dig-node-service/src/wallet_env.rs | 449 +++++++++++++++--- .../tests/wallet_bootstrap_wiring.rs | 46 ++ 4 files changed, 521 insertions(+), 83 deletions(-) diff --git a/SPEC.md b/SPEC.md index 9ff5688c..10df0bc8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -4391,8 +4391,20 @@ Likewise `LOCALAPPDATA` relocates only the wallet's OWN artifacts — `DigWallet `wallet.meta.json` and `DigNode/device/device.key`, which resolve env-first. It does NOT relocate the node's cache, `config.json`, or the `wallet.sqlite` coin replica, which resolve through the OS known-folder API; `DIG_NODE_CACHE` is the variable that moves those. A `dig-node` start-up that -resolves the two roots differently WARNS, and REFUSES to mint a new seed into the split layout -(nothing is written). +resolves the two roots differently MUST WARN, naming both resolved roots. + +It MUST additionally REFUSE to mint a new seed (writing nothing) when, and ONLY when, all three of +the following hold: `LOCALAPPDATA` is set to a root other than the node's own, no seed exists yet, +and `DIG_NODE_CACHE` is unset. Roots MUST be compared ignoring trailing separators, and ignoring +case on Windows, so a host whose environment and known-folder result differ only in spelling is NOT +a split. + +Every other divergence MUST proceed and warn only. In particular, roots that diverge with NO +override — the wallet's env-first resolver falling back to the working directory while the node's +falls through the platform's passwd entry, which is what a service unit with no `HOME=` produces — +MUST mint normally: there is no override to undo, `DIG_NODE_CACHE` would not address it, and +refusing would leave such an install permanently without a wallet. The warning on that path MUST NOT +prescribe `DIG_NODE_CACHE`, because setting it changes nothing an operator would observe. ### 16.1. Method surface + dispatch diff --git a/crates/dig-node-service/src/wallet_bootstrap.rs b/crates/dig-node-service/src/wallet_bootstrap.rs index d014633f..cb2f8f59 100644 --- a/crates/dig-node-service/src/wallet_bootstrap.rs +++ b/crates/dig-node-service/src/wallet_bootstrap.rs @@ -27,27 +27,34 @@ use crate::wallet_env::{self, MintDecision}; /// /// Returns the state so a caller can surface it; callers must not treat any outcome as fatal. /// -/// Minting is REFUSED when the wallet's per-user root and the node's disagree and there is no seed -/// yet (dig-node#392): the new seed would land under one root while the `wallet.sqlite` coin -/// replica opens under the other, and the operator would be told only that a wallet was minted. A -/// host that ALREADY has a seed proceeds unchanged - it is running, and refusing would break a -/// working install to enforce a layout rule. +/// Minting is REFUSED only when an OVERRIDDEN `LOCALAPPDATA` split the wallet root away from the +/// node root, no seed exists yet, and `DIG_NODE_CACHE` is unset (dig-node#392): the new seed would +/// land under one root while the `wallet.sqlite` coin replica opens under the other, and the +/// operator would be told only that a wallet was minted. Every other shape proceeds - see +/// [`wallet_env::mint_decision`] for why each one must, and in particular why the stock Linux +/// service unit, whose roots diverge with nobody overriding anything, mints normally. pub fn ensure_wallet_seed() -> Option { - ensure_wallet_seed_unless_split(&autoseed::default_paths(), wallet_env::wallet_root_split()) + ensure_wallet_seed_unless_split( + &autoseed::default_paths(), + wallet_env::wallet_root_split(), + wallet_env::cache_override_set(), + ) } /// [`ensure_wallet_seed`] against an explicit layout and an explicit split verdict. /// -/// The split is a PARAMETER for the same reason `ensure_wallet_seed_at` takes its paths: proving +/// The split and the cache override are PARAMETERS for the same reason `ensure_wallet_seed_at` +/// takes its paths: proving /// that the refusal writes nothing means running it with a split present and no seed on disk, and a /// test that produced that state by setting the real `LOCALAPPDATA` would be exercising the /// developer's own wallet directory to assert a property about a refusal. pub fn ensure_wallet_seed_unless_split( paths: &WalletPaths, split: Option, + cache_override: bool, ) -> Option { if let MintDecision::RefuseSplitRoot = - wallet_env::mint_decision(split.as_ref(), seed_present(paths)) + wallet_env::mint_decision(split.as_ref(), seed_present(paths), cache_override) { tracing::error!("{}", wallet_env::REFUSED_SPLIT_MINT); return None; @@ -140,14 +147,78 @@ mod tests { device_key: td.path().join("DigNode").join("device").join("device.key"), meta: td.path().join("DigWallet").join("wallet.meta.json"), }; - let split = wallet_env::split_of(Path::new("/wallet-root"), Path::new("/node-root")); - assert!(split.is_some(), "the fixture must actually be split"); + let split = wallet_env::split_of( + Path::new("/wallet-root"), + Path::new("/node-root"), + Some(Path::new("/wallet-root")), + false, + ); + assert_eq!( + split.as_ref().map(|s| s.cause), + Some(wallet_env::SplitCause::Overridden), + "the fixture must be the OVERRIDE-caused split, the only one that refuses" + ); - let state = ensure_wallet_seed_unless_split(&paths, split); + let state = ensure_wallet_seed_unless_split(&paths, split, false); assert!(state.is_none(), "a refused mint reports no wallet state"); assert!(!paths.seed.exists(), "the seed file was NOT created"); assert!(!paths.meta.exists(), "no metadata was written either"); assert!(!paths.device_key.exists(), "no device key was written"); } + + /// A layout under a temporary directory, so nothing here touches the real per-user profile. + fn scratch_paths(td: &Path) -> WalletPaths { + WalletPaths { + seed: td.join("DigWallet").join("seed.bin"), + device_key: td.join("DigNode").join("device").join("device.key"), + meta: td.join("DigWallet").join("wallet.meta.json"), + } + } + + /// **Proves:** a stock Linux `.deb` service still mints. + /// + /// The shipped unit sets no `User=`, no `HOME=` and no `LOCALAPPDATA`, so the wallet root + /// collapses to "." while the node root falls through `getpwuid_r` to the account home. Nobody + /// overrode anything, so a refusal here would leave every such install permanently wallet-less + /// - the exact silent-start shape this ticket exists to remove. + #[test] + fn a_stock_linux_service_shaped_split_still_mints() { + let td = tempfile::tempdir().expect("tempdir"); + let paths = scratch_paths(td.path()); + let split = wallet_env::split_of(Path::new("."), Path::new("/root"), None, false); + assert_eq!( + split.as_ref().map(|s| s.cause), + Some(wallet_env::SplitCause::Ambient), + "the fixture must be the ambient split, not an override" + ); + + let state = ensure_wallet_seed_unless_split(&paths, split, false); + + assert!(state.is_some(), "the bootstrap must have run"); + assert!(paths.seed.exists(), "a wallet was minted"); + } + + /// **Proves:** the escape the refusal NAMES is one this caller HONOURS. + /// + /// Same override-shaped split as the refusal test above, differing only in `DIG_NODE_CACHE` + /// being set - so an operator who follows the error message gets a wallet rather than the same + /// error a second time. Asserted on the seed file, because that is what the operator was + /// promised. + #[test] + fn setting_the_cache_override_lets_the_split_root_mint() { + let td = tempfile::tempdir().expect("tempdir"); + let paths = scratch_paths(td.path()); + let split = wallet_env::split_of( + Path::new("/wallet-root"), + Path::new("/node-root"), + Some(Path::new("/wallet-root")), + false, + ); + + let state = ensure_wallet_seed_unless_split(&paths, split, true); + + assert!(state.is_some(), "the named remedy must lift the refusal"); + assert!(paths.seed.exists(), "and it must actually produce a wallet"); + } } diff --git a/crates/dig-node-service/src/wallet_env.rs b/crates/dig-node-service/src/wallet_env.rs index fd40d5e0..d23bb749 100644 --- a/crates/dig-node-service/src/wallet_env.rs +++ b/crates/dig-node-service/src/wallet_env.rs @@ -1,40 +1,93 @@ //! Announce the wallet-related environment this process actually resolved (#392). //! //! Two independent resolvers decide "the per-user base directory" inside one `dig-node` process, -//! and they disagree the moment an operator overrides `LOCALAPPDATA`: +//! and they can disagree: //! -//! - [`dig_wallet::autoseed::user_base`] is ENV-FIRST, so it honours the override. It owns -//! `DigWallet/seed.bin`, `wallet.meta.json` and `DigNode/device/device.key`. -//! - [`dig_node_core::platform_user_base`] asks the OS for the Known Folder and only falls back to -//! the environment, so on Windows it ignores the override. It owns `cache/`, `config.json` and -//! therefore `wallet.sqlite`, the coin replica. +//! - [`dig_wallet::autoseed::user_base`] is ENV-FIRST (`LOCALAPPDATA`, then `HOME`, then `"."`), so +//! it honours an override. It owns `DigWallet/seed.bin`, `wallet.meta.json` and +//! `DigNode/device/device.key`. +//! - [`dig_node_core::platform_user_base`] asks the OS for the Known Folder and only then falls +//! back to the environment. It owns `cache/`, `config.json` and therefore `wallet.sqlite`, the +//! coin replica. //! //! Under an override the node came up with a NEWLY MINTED seed under one root and a coin replica //! under another, and the only thing it said was that it had minted a wallet - which reads as a //! clean first run rather than as a wallet split in half. //! +//! # Two causes of a split, and only one of them is anybody's fault +//! +//! The roots can differ WITHOUT anyone overriding anything, and that case is ordinary rather than +//! dangerous. On Linux `directories::BaseDirs` resolves the home directory by reading `$HOME` and +//! then falling back to `getpwuid_r`, while `autoseed::user_base` has no such fallback - so in a +//! unit with no `HOME=` in its environment the node base resolves to the passwd entry (`/root` for +//! a root-run service) while the wallet base collapses to `"."`. The shipped +//! `packaging/linux/systemd/net.dignetwork.dig-node.service` is exactly that shape: no `User=`, no +//! `Group=`, no `HOME=`, its only `Environment=` line being `DIG_NODE_RUN_CONTEXT=service`. +//! +//! Refusing to mint there would leave every stock `.deb` install wallet-less, which is the failure +//! this ticket exists to remove rather than one to introduce. So the two causes are distinguished: +//! +//! | case | verdict | +//! |---|---| +//! | stock Linux `.deb` service (`LOCALAPPDATA` unset, `HOME` unset) | Proceed - no override | +//! | ordinary Windows host / LocalSystem service (env equals the API, modulo case) | Proceed - not a split after normalization | +//! | `LOCALAPPDATA` overridden, no `DIG_NODE_CACHE`, no seed | REFUSE - and the named remedy works | +//! | `LOCALAPPDATA` and `DIG_NODE_CACHE` both set | Proceed, warn only | +//! | container with neither `HOME` nor `LOCALAPPDATA` | Proceed, warn only | +//! +//! Ambiguity resolves toward Proceed. Refusing to mint is the destructive direction here; a warning +//! nobody needed costs a log line. +//! //! # Why this module announces instead of unifying //! //! Making either resolver defer to the other is the obvious fix and it is the destructive one. On a -//! service run [`crate::state::anchor_service_data_dirs`] points `DIG_NODE_CACHE` at -//! `C:\ProgramData\DigNode\cache`, so deriving the wallet base from the node's cache dir would move -//! the seed off `...systemprofile\AppData\Local\DigWallet\seed.bin`, find nothing there, and mint a -//! FRESH wallet on every existing install - orphaning the operator wallet and any $DIG in it. +//! service run [`crate::state::anchor_service_data_dirs`] points `DIG_NODE_CACHE` at the machine +//! state dir, so deriving the wallet base from the node's cache dir would move the seed off +//! `...systemprofile\AppData\Local\DigWallet\seed.bin`, find nothing there, and mint a FRESH wallet +//! on every existing install - orphaning the operator wallet and any $DIG in it. That anchoring is +//! CONDITIONAL, not unconditional: it returns early unless [`crate::state::running_as_service`] is +//! true, which reads `DIG_NODE_RUN_CONTEXT` (`entrypoint.rs`, before the dispatch to serve). The +//! systemd unit bakes that variable in, so on Linux the anchor definitely fires; a Windows service +//! whose registered environment lacks it does NOT anchor, and its cache stays under the +//! systemprofile path. The conclusion is unchanged either way - the seed must not be re-rooted onto +//! the cache - because it only has to hold on the installs where the anchor DOES fire. +//! //! Resolution therefore stays exactly as it is. What changes is that the split is said out loud, -//! and that the one irreversible consequence - minting a brand new seed into a split layout - is -//! refused rather than performed quietly. +//! and that the one irreversible consequence - minting a brand new seed into a deliberately split +//! layout - is refused rather than performed quietly. //! //! # Shape //! -//! A pure decision core ([`split_of`], [`mint_decision`], [`inert_wallet_port`]) that takes its -//! inputs as arguments, plus thin env-reading and log-emitting wrappers - the same split -//! [`crate::logging::degrade_announcement`] and [`crate::state::service_data_dir_overrides`] use, -//! and for the same reason: the interesting branch is the one that does NOT occur on the machine -//! the tests run on, so it must be reachable without mutating the process environment. +//! A pure decision core ([`same_root`], [`split_of`], [`mint_decision`], [`inert_wallet_port`]) +//! that takes its inputs as arguments, plus thin env-reading and log-emitting wrappers - the same +//! split [`crate::logging::degrade_announcement`] and [`crate::state::service_data_dir_overrides`] +//! use, and for the same reason: the interesting branch is the one that does NOT occur on the +//! machine the tests run on, so it must be reachable without mutating the process environment. +//! Case-insensitivity is a PARAMETER rather than a `cfg!(windows)` read inside the comparison, so +//! both arms are exercised by the Linux CI runners that are the only ones this repo has. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; +/// The env var an operator sets to place the node's cache - and therefore `config.json` and the +/// `wallet.sqlite` replica - deliberately. Setting it is how the refusal below is answered. +pub const CACHE_DIR_ENV: &str = "DIG_NODE_CACHE"; + +/// The env var whose override moves the wallet half and not the node half. +pub const LOCAL_APP_DATA_ENV: &str = "LOCALAPPDATA"; + +/// Why the two roots differ. The distinction decides whether anything may be refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SplitCause { + /// An operator set `LOCALAPPDATA` to something other than the node's own base. They moved one + /// half deliberately, so telling them to move the other half is advice they can act on. + Overridden, + /// The two resolvers landed apart with nobody overriding anything - the `HOME`-unset service + /// unit, or a container with neither variable. Nothing here is refused: there is no override to + /// undo, and `DIG_NODE_CACHE` would not address it. + Ambient, +} + /// The two disagreeing roots, as this process resolved them. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WalletRootSplit { @@ -42,6 +95,8 @@ pub struct WalletRootSplit { pub wallet_base: PathBuf, /// Where the cache, config and the `wallet.sqlite` replica live (OS-first, ignores it). pub node_base: PathBuf, + /// Whether an operator caused this, or the environment did. + pub cause: SplitCause, } /// What start-up should do about a split, given whether a seed already exists. @@ -49,13 +104,13 @@ pub struct WalletRootSplit { pub enum MintDecision { /// Run the ordinary bootstrap. Proceed, - /// Do not mint: there is no wallet yet and minting one now would write it into a layout whose - /// two halves are already known to disagree. + /// Do not mint: there is no wallet yet, an override split the two halves, and the operator has + /// not taken control of the replica location. RefuseSplitRoot, } -/// The prose for the split warning. See [`crate::logging::FILE_LOGGING_DEGRADED`] for why these are -/// `concat!` constants and never `\`-continued string literals: a formatter run rejoins a +/// The prose for an OVERRIDE-caused split. See [`crate::logging::FILE_LOGGING_DEGRADED`] for why +/// these are `concat!` constants and never `\`-continued string literals: a formatter run rejoins a /// continuation and materialises the source indentation into the string, which no assertion on the /// surrounding code can see. `the_announcements_have_no_lost_string_continuation` guards it. pub const SPLIT_ROOTS: &str = concat!( @@ -68,14 +123,34 @@ pub const SPLIT_ROOTS: &str = concat!( "at the machine default." ); -/// The prose for the refusal. It must leave no doubt that the disk was not touched: an operator who -/// reads a mint failure as a partial write goes looking for something to delete, and deleting is -/// the one act this refusal exists to prevent. +/// The prose for an AMBIENT split - the roots differ and nobody overrode anything. +/// +/// It must NOT name `DIG_NODE_CACHE` as the remedy, because setting it does not change where the +/// seed resolves and the predicate does not consult it on this path. Nothing is refused here; this +/// is purely the honest version of the silence this ticket is about. +pub const AMBIENT_SPLIT_ROOTS: &str = concat!( + "the wallet's per-user root and the node's per-user root resolved to DIFFERENT places, and ", + "no override caused it: neither LOCALAPPDATA nor HOME is set in this environment, so the ", + "wallet's env-first resolver fell back to the working directory while the node's asked the ", + "OS for the account's home. Both resolved roots are logged below as `wallet_base` and ", + "`node_base`, and the seed file this run will read or create is logged as `seed`. Nothing ", + "is refused and nothing is wrong with the wallet; set HOME in the service environment if you ", + "want the two halves to share one root." +); + +/// The prose for the refusal. +/// +/// It must leave no doubt that the disk was not touched: an operator who reads a mint failure as a +/// partial write goes looking for something to delete, and deleting is the one act this refusal +/// exists to prevent. Every escape it names is one [`mint_decision`] actually honours - setting +/// `DIG_NODE_CACHE` flips the verdict to `Proceed`, and so does unsetting `LOCALAPPDATA`, because +/// that removes the override the refusal is conditioned on. pub const REFUSED_SPLIT_MINT: &str = concat!( - "no wallet exists yet and the wallet and node roots disagree, so NOTHING was minted and ", - "NOTHING was written. Minting here would put the seed under one root and the wallet.sqlite ", - "replica under another. Set DIG_NODE_CACHE as well as LOCALAPPDATA so both halves land in ", - "one place, or unset LOCALAPPDATA, then start the node again." + "no wallet exists yet and an overridden LOCALAPPDATA has split the wallet root away from the ", + "node root, so NOTHING was minted and NOTHING was written. Minting here would put the seed ", + "under one root and the wallet.sqlite replica under another. Set DIG_NODE_CACHE as well as ", + "LOCALAPPDATA so both halves land in one place, or unset LOCALAPPDATA, then start the node ", + "again." ); /// The prose for an inert `DIG_WALLET_PORT`. A variable that is read by nobody is worse than an @@ -86,14 +161,61 @@ pub const INERT_WALLET_PORT: &str = concat!( "dig-wallet binary." ); -/// Whether the two roots disagree. `Some` iff they differ. Pure. -pub fn split_of(wallet_base: &Path, node_base: &Path) -> Option { - if wallet_base == node_base { +/// Whether two paths name the same root, ignoring trailing separators and - when asked - case. +/// +/// `case_insensitive` is a PARAMETER, never a `cfg!(windows)` read in the body: CI runs on +/// `ubuntu-latest` only, so an internal `cfg!` would leave the Windows arm of this comparison +/// untested on every runner this repo has. Exact `PathBuf` equality here would report a FALSE split +/// on an ordinary Windows host whose `LOCALAPPDATA` differs from the Known Folder result by case or +/// by a trailing backslash - and on a fresh install that is a permanent refusal to mint. +/// +/// Deliberately does NOT canonicalize: [`std::fs::canonicalize`] requires the paths to exist and +/// returns `\\?\`-prefixed results on Windows, so it would introduce two new ways to disagree. +pub fn same_root(a: &Path, b: &Path, case_insensitive: bool) -> bool { + let (a, b) = (trim_trailing_separators(a), trim_trailing_separators(b)); + if case_insensitive { + a.eq_ignore_ascii_case(&b) + } else { + a == b + } +} + +/// A path as a string with any trailing separators removed, keeping a bare root as-is. +fn trim_trailing_separators(path: &Path) -> String { + let raw = path.to_string_lossy(); + let trimmed = raw.trim_end_matches(['/', '\\']); + if trimmed.is_empty() { + raw.to_string() + } else { + trimmed.to_string() + } +} + +/// Whether the two roots disagree, and why. `Some` iff they are genuinely different roots. Pure. +/// +/// `local_app_data` is the raw `LOCALAPPDATA` of the environment being described - `None` when it +/// is unset. A split is [`SplitCause::Overridden`] only when that variable is set AND points +/// somewhere other than the node's own base: Windows sets the two identically, so an ordinary +/// Windows host is not an override, and Linux normally leaves the variable unset entirely. +pub fn split_of( + wallet_base: &Path, + node_base: &Path, + local_app_data: Option<&Path>, + case_insensitive: bool, +) -> Option { + if same_root(wallet_base, node_base, case_insensitive) { return None; } + let overridden = + local_app_data.is_some_and(|value| !same_root(value, node_base, case_insensitive)); Some(WalletRootSplit { wallet_base: wallet_base.to_path_buf(), node_base: node_base.to_path_buf(), + cause: if overridden { + SplitCause::Overridden + } else { + SplitCause::Ambient + }, }) } @@ -102,21 +224,45 @@ pub fn split_of(wallet_base: &Path, node_base: &Path) -> Option /// The one place the pure core is bound to the real functions - and therefore the one place a /// wrong pair of functions would hide, which is why it has its own environment-level test. pub fn wallet_root_split() -> Option { + let local_app_data = std::env::var_os(LOCAL_APP_DATA_ENV) + .filter(|v| !v.is_empty()) + .map(PathBuf::from); split_of( &dig_wallet::autoseed::user_base(), &dig_node_core::platform_user_base(), + local_app_data.as_deref(), + cfg!(windows), ) } +/// Whether the operator has taken control of the replica location with `DIG_NODE_CACHE`. An empty +/// value is unset, matching how [`crate::state`] parses its own overrides. +pub fn cache_override_set() -> bool { + std::env::var(CACHE_DIR_ENV) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + /// Whether start-up may mint. Pure. /// -/// A split with a seed ALREADY on disk proceeds: that host is running, its two halves are whatever -/// they are, and refusing would break a working install to enforce a layout rule. The refusal is -/// reserved for the irreversible case - creating a new wallet into a layout already known to be -/// split. -pub fn mint_decision(split: Option<&WalletRootSplit>, seed_present: bool) -> MintDecision { - match (split, seed_present) { - (Some(_), false) => MintDecision::RefuseSplitRoot, +/// Refuses on the conjunction of three things and nothing less: an OVERRIDE-caused split, no seed +/// on disk, and no `DIG_NODE_CACHE`. Every other combination proceeds. +/// +/// - A split with a seed ALREADY on disk proceeds: that host is running, its two halves are +/// whatever they are, and refusing would break a working install to enforce a layout rule. +/// - An AMBIENT split proceeds, because there is no override to undo and the refusal's own remedy +/// would not address it. This is the stock Linux `.deb` service, and refusing there would leave +/// every such install wallet-less. +/// - A split with `DIG_NODE_CACHE` set proceeds, because the operator has placed the replica +/// deliberately - that is precisely the remedy the refusal names, so honouring it is what makes +/// that sentence true. +pub fn mint_decision( + split: Option<&WalletRootSplit>, + seed_present: bool, + cache_override: bool, +) -> MintDecision { + match (split.map(|s| s.cause), seed_present, cache_override) { + (Some(SplitCause::Overridden), false, false) => MintDecision::RefuseSplitRoot, _ => MintDecision::Proceed, } } @@ -151,17 +297,30 @@ static ANNOUNCED: AtomicBool = AtomicBool::new(false); /// /// Called from the serve entrypoints BEFORE the bootstrap, so an operator reads why a wallet was /// refused before - not after - the line that would have said one was minted. -pub fn announce(split: Option<&WalletRootSplit>, replica: &Path, wallet_port: Option<&str>) { +pub fn announce( + split: Option<&WalletRootSplit>, + replica: &Path, + seed: &Path, + wallet_port: Option<&str>, +) { if ANNOUNCED.swap(true, Ordering::SeqCst) { return; } if let Some(split) = split { - tracing::warn!( - wallet_base = %split.wallet_base.display(), - node_base = %split.node_base.display(), - replica = %replica.display(), - "{SPLIT_ROOTS}" - ); + match split.cause { + SplitCause::Overridden => tracing::warn!( + wallet_base = %split.wallet_base.display(), + node_base = %split.node_base.display(), + replica = %replica.display(), + "{SPLIT_ROOTS}" + ), + SplitCause::Ambient => tracing::warn!( + wallet_base = %split.wallet_base.display(), + node_base = %split.node_base.display(), + seed = %seed.display(), + "{AMBIENT_SPLIT_ROOTS}" + ), + } } if let Some(port) = wallet_port { tracing::warn!(port = %port, "{INERT_WALLET_PORT}"); @@ -176,6 +335,7 @@ pub fn announce_from_env() { announce( split.as_ref(), &replica_path(), + &dig_wallet::autoseed::default_paths().seed, inert_wallet_port(raw.as_deref()), ); } @@ -190,30 +350,151 @@ mod tests { /// concurrent test resolving a per-user path. static ENV_LOCK: Mutex<()> = Mutex::new(()); + /// An override-shaped split: the operator moved the wallet half somewhere of their own. + fn overridden() -> WalletRootSplit { + split_of( + Path::new("/scratch"), + Path::new("/node"), + Some(Path::new("/scratch")), + false, + ) + .expect("an override splits the roots") + } + + /// The stock Linux `.deb` shape: no `LOCALAPPDATA`, no `HOME`, so the wallet half collapses to + /// "." while the node half falls through `getpwuid_r` to the account home. + fn ambient() -> WalletRootSplit { + split_of(Path::new("."), Path::new("/root"), None, false) + .expect("divergent roots are still a split") + } + #[test] fn equal_roots_are_not_a_split() { - assert_eq!(split_of(Path::new("/a"), Path::new("/a")), None); + assert_eq!( + split_of(Path::new("/a"), Path::new("/a"), None, false), + None + ); } #[test] fn differing_roots_are_a_split_naming_both() { - let split = split_of(Path::new("/wallet"), Path::new("/node")).expect("a split"); + let split = split_of(Path::new("/wallet"), Path::new("/node"), None, false).expect("split"); assert_eq!(split.wallet_base, PathBuf::from("/wallet")); assert_eq!(split.node_base, PathBuf::from("/node")); } - /// All four arms. A table with three rows leaves the interesting one untested, and the - /// interesting one is (split, seed present) - the arm that must NOT refuse. + /// Both arms of the case parameter, so the Windows behaviour is exercised on a Linux runner. + /// Trailing separators never matter; genuine difference always does. #[test] - fn mint_is_refused_only_when_a_split_would_create_a_new_wallet() { - let split = split_of(Path::new("/wallet"), Path::new("/node")).unwrap(); + fn same_root_normalizes_trailing_separators_and_optionally_case() { + assert!(same_root(Path::new("/a/b"), Path::new("/a/b"), false)); + assert!(same_root(Path::new("/a/b"), Path::new("/a/b"), true)); + + assert!(!same_root( + Path::new("C:\\Users\\Micha"), + Path::new("C:\\Users\\micha"), + false + )); + assert!(same_root( + Path::new("C:\\Users\\Micha"), + Path::new("C:\\Users\\micha"), + true + )); + + assert!(same_root(Path::new("/a/b/"), Path::new("/a/b"), false)); + assert!(same_root( + Path::new("C:\\Users\\micha\\"), + Path::new("C:\\Users\\micha"), + true + )); + + assert!(!same_root(Path::new("/a/b"), Path::new("/a/c"), false)); + assert!(!same_root(Path::new("/a/b"), Path::new("/a/c"), true)); + } + + /// An ordinary Windows host has `LOCALAPPDATA` equal to the Known Folder result modulo case, so + /// there must be no split at all - not merely no refusal. Exact equality reported one, and on a + /// fresh install that was a permanent refusal to mint. + #[test] + fn a_case_differing_windows_root_is_not_a_split() { + let env = Path::new("C:\\Users\\Micha\\AppData\\Local"); + let api = Path::new("C:\\Users\\micha\\AppData\\Local\\"); + assert_eq!(split_of(env, api, Some(env), true), None); + } + + /// A split with nobody overriding anything is AMBIENT, and one with an override is not. + #[test] + fn the_cause_tracks_whether_localappdata_actually_overrode_anything() { + assert_eq!(ambient().cause, SplitCause::Ambient); + assert_eq!(overridden().cause, SplitCause::Overridden); + // Set, but to the node's own base: nobody moved anything. + let split = split_of( + Path::new("."), + Path::new("/node"), + Some(Path::new("/node")), + false, + ) + .expect("still a split"); + assert_eq!(split.cause, SplitCause::Ambient); + } + + /// **Proves:** the stock Linux `.deb` service mints. The shipped unit carries no `User=`, no + /// `HOME=` and no `LOCALAPPDATA`, so the roots diverge with nobody at fault; refusing there + /// would leave every such install wallet-less, which is the #1928 shape this ticket removes. + #[test] + fn a_stock_linux_service_shaped_split_proceeds() { assert_eq!( - mint_decision(Some(&split), false), - MintDecision::RefuseSplitRoot + mint_decision(Some(&ambient()), false, false), + MintDecision::Proceed ); - assert_eq!(mint_decision(Some(&split), true), MintDecision::Proceed); - assert_eq!(mint_decision(None, false), MintDecision::Proceed); - assert_eq!(mint_decision(None, true), MintDecision::Proceed); + } + + /// **Proves:** the remedy the refusal NAMES is one the predicate HONOURS. + /// + /// Bound to `mint_decision` rather than to the text: a test that only grepped + /// `REFUSED_SPLIT_MINT` for "DIG_NODE_CACHE" passed against the version where setting it + /// changed nothing, which is exactly the lying-error-message defect this round fixes. + #[test] + fn setting_the_named_cache_override_lifts_the_refusal() { + let split = overridden(); + assert_eq!( + mint_decision(Some(&split), false, false), + MintDecision::RefuseSplitRoot, + "the measured defect must still be caught" + ); + assert_eq!( + mint_decision(Some(&split), false, true), + MintDecision::Proceed, + "DIG_NODE_CACHE is named as the escape, so it must actually be one" + ); + assert!(REFUSED_SPLIT_MINT.contains(CACHE_DIR_ENV)); + } + + /// Every arm of the three-input table. A table missing an arm leaves the interesting one + /// untested, and the interesting ones are all the arms that must NOT refuse. + #[test] + fn mint_is_refused_only_for_an_unanswered_override_with_no_wallet() { + for (split, seed, cache, want) in [ + ( + Some(overridden()), + false, + false, + MintDecision::RefuseSplitRoot, + ), + (Some(overridden()), true, false, MintDecision::Proceed), + (Some(overridden()), false, true, MintDecision::Proceed), + (Some(overridden()), true, true, MintDecision::Proceed), + (Some(ambient()), false, false, MintDecision::Proceed), + (Some(ambient()), true, false, MintDecision::Proceed), + (None, false, false, MintDecision::Proceed), + (None, true, false, MintDecision::Proceed), + ] { + assert_eq!( + mint_decision(split.as_ref(), seed, cache), + want, + "split={split:?} seed={seed} cache={cache}" + ); + } } #[test] @@ -244,10 +525,25 @@ mod tests { assert!(INERT_WALLET_PORT.contains("dig-wallet")); } + /// The ambient sentence must NOT prescribe the override remedy, because the predicate does not + /// consult `DIG_NODE_CACHE` on that path and setting it would change nothing an operator sees. + /// An error message naming an inert escape is the defect this ticket fixes, one layer up. + #[test] + fn the_ambient_announcement_does_not_prescribe_an_inert_remedy() { + assert!( + !AMBIENT_SPLIT_ROOTS.contains(CACHE_DIR_ENV), + "DIG_NODE_CACHE does not address an ambient split" + ); + assert!(AMBIENT_SPLIT_ROOTS.contains("HOME")); + assert!(AMBIENT_SPLIT_ROOTS.contains("LOCALAPPDATA")); + assert!(AMBIENT_SPLIT_ROOTS.contains("seed")); + assert!(AMBIENT_SPLIT_ROOTS.contains("Nothing is refused")); + } + /// The one test that reproduces the REPORTED condition rather than a property of the pure - /// core: with `LOCALAPPDATA` overridden, the wrapper must report a split naming that override. - /// Without it the pure tests would all pass over a wrapper wired to the wrong pair of - /// functions - which is precisely the defect, one layer up. + /// core: with `LOCALAPPDATA` overridden, the wrapper must report an OVERRIDE-caused split + /// naming it. Without it the pure tests would all pass over a wrapper wired to the wrong pair + /// of functions - which is precisely the defect, one layer up. /// /// Serialized on a module-local lock and restores the variable, because `cargo test` runs the /// lib tests in one process and a mid-flight `LOCALAPPDATA` would be read by anything else @@ -255,16 +551,18 @@ mod tests { #[test] fn an_overridden_localappdata_is_reported_as_a_split() { let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let restore = std::env::var("LOCALAPPDATA").ok(); + let restore = std::env::var(LOCAL_APP_DATA_ENV).ok(); let td = tempfile::tempdir().expect("tempdir"); - std::env::set_var("LOCALAPPDATA", td.path()); + std::env::set_var(LOCAL_APP_DATA_ENV, td.path()); let split = wallet_root_split(); - - match restore { - Some(v) => std::env::set_var("LOCALAPPDATA", v), - None => std::env::remove_var("LOCALAPPDATA"), - } + let restored = { + match &restore { + Some(v) => std::env::set_var(LOCAL_APP_DATA_ENV, v), + None => std::env::remove_var(LOCAL_APP_DATA_ENV), + } + wallet_root_split() + }; let split = split.expect("an overridden LOCALAPPDATA splits the two roots"); assert_eq!( @@ -278,17 +576,28 @@ mod tests { "the node root does not follow it" ); assert_eq!( - wallet_root_split(), - None, - "restoring the variable removes the split" + split.cause, + SplitCause::Overridden, + "an override is not an ambient divergence" + ); + assert_eq!( + mint_decision(Some(&split), false, false), + MintDecision::RefuseSplitRoot, + "the reported condition is the one that refuses" ); + assert_eq!(restored, None, "restoring the variable removes the split"); } /// A `\`-continued literal carries its source indentation into the string. Assert on the /// rendered text, because that is the only artifact a formatter cannot rewrite behind us. #[test] fn the_announcements_have_no_lost_string_continuation() { - for text in [SPLIT_ROOTS, REFUSED_SPLIT_MINT, INERT_WALLET_PORT] { + for text in [ + SPLIT_ROOTS, + AMBIENT_SPLIT_ROOTS, + REFUSED_SPLIT_MINT, + INERT_WALLET_PORT, + ] { assert!(!text.contains(" "), "run of 4+ spaces in: {text}"); } } diff --git a/crates/dig-node-service/tests/wallet_bootstrap_wiring.rs b/crates/dig-node-service/tests/wallet_bootstrap_wiring.rs index 5d8be36a..0af2a91f 100644 --- a/crates/dig-node-service/tests/wallet_bootstrap_wiring.rs +++ b/crates/dig-node-service/tests/wallet_bootstrap_wiring.rs @@ -19,6 +19,12 @@ const SERVER: &str = include_str!("../src/server.rs"); /// The exact call the guards look for. const CALL: &str = "wallet_bootstrap::ensure_wallet_seed()"; +/// The environment announcement that must run BEFORE the bootstrap on every serve entrypoint +/// (#392). Guarded for the same reason as `CALL`: an announcement present on only one of the two +/// entrypoints is silent on exactly the unattended service run nobody is watching, and no +/// behavioural test can see that - `announce_from_env` is correct in isolation either way. +const ANNOUNCE: &str = "wallet_env::announce_from_env()"; + /// **Proves:** the foreground entrypoint checks for a seed on start. #[test] fn the_foreground_entrypoint_ensures_a_wallet_seed() { @@ -30,6 +36,46 @@ fn the_foreground_entrypoint_ensures_a_wallet_seed() { ); } +/// **Proves:** the foreground entrypoint says what it resolved before it mints anything. +/// +/// Order matters and is asserted: an operator must read WHY a wallet was refused before, not +/// after, the line that would otherwise have reported one minted. +#[test] +fn the_foreground_entrypoint_announces_the_wallet_environment_first() { + let body = between( + ENTRYPOINT, + "fn block_on_serve(", + " +} +", + ) + .expect("block_on_serve exists"); + let announce = body.find(ANNOUNCE).expect( + "`block_on_serve` must announce the resolved wallet environment on every start (#392)", + ); + let bootstrap = body.find(CALL).expect("and it must still bootstrap"); + assert!( + announce < bootstrap, + "the announcement must precede the mint, or the refusal is explained after the fact (#392)" + ); +} + +/// **Proves:** the Windows SCM entrypoint announces too. +/// +/// It does not share `block_on_serve`, so it carries its own call - and it is the unattended path, +/// where a missing warning has nobody to notice it. +#[test] +fn the_windows_service_entrypoint_announces_the_wallet_environment_first() { + let announce = WIN_SERVICE + .find(ANNOUNCE) + .expect("the Windows SCM entrypoint must announce the wallet environment (#392)"); + let bootstrap = WIN_SERVICE.find(CALL).expect("and must still bootstrap"); + assert!( + announce < bootstrap, + "the announcement must precede the mint on the SCM path too (#392)" + ); +} + /// **Proves:** the Windows service entrypoint checks too. /// /// A separate assertion rather than a repo-wide grep, because this is the path where the guarantee From 547a44f239518e94e24c267a633d2e3a2a4b9a67 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Tue, 1 Sep 2026 18:44:42 -0700 Subject: [PATCH 6/6] chore(release): dig-node 0.239.0 Merge origin/main and take the next unclaimed version; 0.235.0 collided with main after #488 landed. Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 657defc3..9bcb06d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3031,7 +3031,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.235.0" +version = "0.239.0" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index d3467879..9adcb59e 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.235.0" +version = "0.239.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over