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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ edition = "2021"
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
# keep their own independent versions — only the released binary tracks the workspace version.
version = "0.236.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
Expand Down
27 changes: 26 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -4378,9 +4378,34 @@ has run.

`dig-wallet` is the DIG Browser's built-in Chia wallet host: a loopback `axum` server bound
`127.0.0.1:<DIG_WALLET_PORT>` (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 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

The advertised dapp JSON-RPC method catalogue is the crate's `WC_METHOD_CATALOGUE` — the single source
Expand Down
23 changes: 19 additions & 4 deletions crates/dig-node-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,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()
Expand All @@ -621,11 +638,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
Expand Down
4 changes: 4 additions & 0 deletions crates/dig-node-service/src/entrypoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions crates/dig-node-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
139 changes: 138 additions & 1 deletion crates/dig-node-service/src/wallet_bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,58 @@
//! 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 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<BootstrapState> {
ensure_wallet_seed_at(&autoseed::default_paths())
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 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<wallet_env::WalletRootSplit>,
cache_override: bool,
) -> Option<BootstrapState> {
if let MintDecision::RefuseSplitRoot =
wallet_env::mint_decision(split.as_ref(), seed_present(paths), cache_override)
{
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.
Expand Down Expand Up @@ -85,3 +130,95 @@ pub fn ensure_wallet_seed_at(paths: &WalletPaths) -> Option<BootstrapState> {
}
}
}

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