Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
093ef52
chore(wallet): open the NC-12 provider-registry lane
MichaelTaylor3d Aug 25, 2026
c6081e4
feat(wallet): give the node one provider registry that owns its peer …
MichaelTaylor3d Aug 25, 2026
cd6817f
feat(wallet): settle a chain height from several peers, or refuse
MichaelTaylor3d Aug 25, 2026
0f82f51
feat(wallet): report the peak the node's own peers agree on, not the …
MichaelTaylor3d Aug 25, 2026
47156a2
test(wallet): assert the placement before the value in the peak proofs
MichaelTaylor3d Aug 25, 2026
3257d1e
docs(spec): state that the node's peak is its own peers' settled view
MichaelTaylor3d Aug 25, 2026
3f04462
chore: drop the lane marker
MichaelTaylor3d Aug 25, 2026
2708f16
chore(release): bump to 0.152.0 / dig-wallet 0.35.0
MichaelTaylor3d Aug 25, 2026
9e51ac0
fix(wallet): make the sole-owner sweep see the file it was blind to
MichaelTaylor3d Aug 25, 2026
6179edc
fix(wallet): group the peer provider by what it can reach, not by its…
MichaelTaylor3d Aug 25, 2026
bb0728e
docs(wallet): state settled_peak's real bound instead of an unconditi…
MichaelTaylor3d Aug 25, 2026
5c0335a
fix(wallet): stop the sole-owner sweep counting braces inside string …
MichaelTaylor3d Aug 25, 2026
5a1f5f0
docs(wallet): say plainly that the sweep enforces NC-12, not the regi…
MichaelTaylor3d Aug 25, 2026
ab026e9
fix(wallet): end a one-line test item on its own line
MichaelTaylor3d Aug 25, 2026
c2038eb
docs(wallet): bound the sweep's enumeration instead of claiming it is…
MichaelTaylor3d Aug 25, 2026
21c7f45
docs(wallet): the one-line fixture's flag assertion is live, not docu…
MichaelTaylor3d Aug 25, 2026
b1d3381
chore(release): bump to 0.154.0 after rebasing onto 07456b94
MichaelTaylor3d Aug 25, 2026
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.153.0"
version = "0.154.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
7 changes: 5 additions & 2 deletions SPEC.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/dig-wallet/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "dig-wallet"
version = "0.34.0"
version = "0.35.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."
Expand Down
219 changes: 193 additions & 26 deletions crates/dig-wallet/src/sage/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ use std::sync::Arc;

use async_trait::async_trait;
use chia_protocol::SpendBundle;
use tokio::sync::Mutex;

use super::fallback::{
ChainFallback, ChainPeerTier, CoinsetFallback, FallbackCoin, FallbackCoinSpend,
Expand Down Expand Up @@ -76,9 +75,11 @@ pub trait SignedBundlePusher: Send + Sync {
/// Also the wallet's [`ChainFallback`] tier, so a balance, a coin read and a push all speak to ONE
/// client rather than three — and so the tier a read reports (`"fallback"`) stays truthful.
pub struct ChainTransport {
/// `None` until the first use builds it. Behind a `Mutex` rather than a `OnceCell` because a
/// FAILED build must not be remembered as the answer forever (see the module docs).
client: Mutex<Option<Arc<chia_query::ChiaQuery>>>,
/// The node's chain sources — the sole owner of the peer fabric and the registry that names
/// it ([`super::sources`]). The transport reads THROUGH it rather than building its own, which
/// is what makes NC-12's "no path constructs its own peer fabric outside the registry" a
/// property of the code instead of a statement about a registry that did not exist.
sources: Arc<super::sources::NodeChainSources>,
/// ARBITRARY coin reads, served by this node's own peers and believed only on agreement
/// (dig_ecosystem#3032).
///
Expand All @@ -100,7 +101,16 @@ impl ChainTransport {
/// A transport that has not yet dialed anything.
pub fn new() -> Self {
Self {
client: Mutex::new(None),
sources: Arc::new(super::sources::NodeChainSources::new()),
peer_reads: None,
}
}

/// A transport reading through `sources` — the node's one registry-owned fabric.
#[must_use]
pub fn with_sources(sources: Arc<super::sources::NodeChainSources>) -> Self {
Self {
sources,
peer_reads: None,
}
}
Expand All @@ -124,16 +134,7 @@ impl ChainTransport {
/// A failure to build is an `Err` and is NOT cached — the next call tries again, so a node that
/// starts offline becomes useful the moment its network does.
async fn client(&self) -> Result<Arc<chia_query::ChiaQuery>> {
let mut slot = self.client.lock().await;
if let Some(existing) = slot.as_ref() {
return Ok(existing.clone());
}
let built = chia_query::ChiaQuery::new(chia_query::ChiaQueryConfig::default())
.await
.map_err(|e| Error::internal(format!("no chain source could be reached: {e}")))?;
let built = Arc::new(built);
*slot = Some(built.clone());
Ok(built)
self.sources.client().await
}

/// The one shared client, for a consumer that needs the client ITSELF rather than a read.
Expand All @@ -156,16 +157,46 @@ impl ChainTransport {
#[cfg(test)]
pub(crate) fn with_client(client: Arc<chia_query::ChiaQuery>) -> Self {
Self {
client: Mutex::new(Some(client)),
sources: Arc::new(super::sources::NodeChainSources::with_client(client)),
peer_reads: None,
}
}

/// The chain's current peak height, or `Ok(None)` when the source tracks none.
/// Serve the corroborated reads from `reads`, so a test can script the peers a round hears.
#[cfg(test)]
pub(crate) fn with_peer_reads_arc(
mut self,
reads: Arc<super::peer_reads::PeerCorroboratedReads>,
) -> Self {
self.peer_reads = Some(reads);
self
}

/// The chain height this node reports, or `Ok(None)` when it does not know one.
///
/// `Ok(None)` is an honest "no height known" — never height zero, which every block is
/// trivially above and which would silently satisfy any "is it buried yet" comparison.
///
/// # It is the node's OWN peers that answer this (dig_ecosystem#2790)
///
/// When the corroborated peer reads are attached — which is every production transport — the
/// height is settled across the peers this node dialled itself
/// ([`super::quorum::settled_peak`]), and their failure to agree is reported as not knowing.
///
/// The alternative it replaces was worse than it looked: `chia-query`'s router answers a peak
/// by asking `api.coinset.org` FIRST and consulting this node's peers only when that fails, so
/// the node's headline chain fact was one HTTPS endpoint's opinion even on a node holding five
/// peers — and that number divides into the confirmation counts served over RPC. NC-12 asks
/// for agreement across several concurrently-queried untrusted peers; a single third party is
/// the shape it exists to prevent.
///
/// A transport with no peer reads — a bare one built by a test — still falls through to the
/// router. That path is the oracle-first one, and it is documented as such rather than
/// silently retained: nothing in production takes it.
pub async fn peak_height(&self) -> Result<Option<u32>> {
if let Some(peers) = &self.peer_reads {
return Ok(peers.peak_height().await);
}
self.client()
.await?
.peak_height_opt()
Expand Down Expand Up @@ -198,8 +229,7 @@ impl ChainTransport {
/// Unbuildable is [`ChainPeerTier::UNOBSERVABLE`], never a zero count: a node that could not
/// look has not looked and found none.
pub async fn peer_tier(&self) -> ChainPeerTier {
let existing = self.client.lock().await.clone();
let Some(client) = existing else {
let Some(client) = self.sources.existing_client().await else {
return ChainPeerTier::UNOBSERVABLE;
};
ChainPeerTier {
Expand Down Expand Up @@ -244,10 +274,7 @@ impl ChainTransport {
if client.peer_count().await > 0 {
return true;
}
let mut slot = self.client.lock().await;
if slot.as_ref().is_some_and(|held| Arc::ptr_eq(held, &client)) {
*slot = None;
}
self.sources.discard_if_current(&client).await;
false
}

Expand Down Expand Up @@ -469,7 +496,7 @@ mod tests {

assert_eq!(transport.peer_tier().await, ChainPeerTier::UNOBSERVABLE);
assert!(
transport.client.lock().await.is_none(),
transport.sources.existing_client().await.is_none(),
"asking for the peer tier must not be what makes the node hold peers"
);
}
Expand Down Expand Up @@ -527,7 +554,7 @@ mod tests {
assert_eq!(answer.amount, 1234);
assert_eq!(answer.spent_height, Some(9_000_050));
assert!(
transport.client.lock().await.is_none(),
transport.sources.existing_client().await.is_none(),
"an arbitrary coin read must no longer route through the third-party oracle"
);
}
Expand All @@ -539,6 +566,146 @@ mod tests {
#[tokio::test]
async fn a_new_transport_holds_no_client_until_it_is_used() {
let transport = ChainTransport::new();
assert!(transport.client.lock().await.is_none());
assert!(transport.sources.existing_client().await.is_none());
}
}

#[cfg(test)]
mod corroborated_peak_tests {
//! The height the node reports is its OWN peers' settled view, not a third party's
//! (dig_ecosystem#2790).
//!
//! # What these catch that the previous code did not
//!
//! `peak_height` used to call `chia-query`'s router, which asks `api.coinset.org` FIRST and
//! consults this node's peers only when that fails. Under the old code every assertion here
//! would have been decided by one HTTPS endpoint: the collapsed-round cases would have
//! returned a number rather than refusing, and all of them would have built a chain client.
//!
//! So the load-bearing assertion in each test is the SECOND one — that no client was ever
//! built. "The value came back `None`" is satisfied identically by a transport that consulted
//! the oracle, failed to reach it, and reported nothing; asserting that the oracle was never
//! reached for is what makes the placement observable rather than inferred from an equal
//! value.

use super::*;
use crate::sage::peer_reads::{CoinPeer, PeerCorroboratedReads, PeerSample};
use crate::sage::quorum::{PeakClaim, SETTLED_LAG};
use async_trait::async_trait;
use chia::protocol::Bytes32;

/// A peer that announces one tip and answers no coin question.
///
/// Coin silence isolates the peak round: nothing here can pass because of a coin answer.
struct PeakOnlyPeer {
id: String,
claim: Option<PeakClaim>,
}

#[async_trait]
impl CoinPeer for PeakOnlyPeer {
fn id(&self) -> String {
self.id.clone()
}

async fn peak_claim(&self) -> Option<PeakClaim> {
self.claim
}

async fn coin_record(&self, _coin_id: Bytes32) -> Result<Option<FallbackCoin>> {
Err(Error::internal("this peer answers no coin questions"))
}

async fn coin_spend(&self, _coin_id: Bytes32) -> Result<Option<FallbackCoinSpend>> {
Err(Error::internal("this peer answers no coin questions"))
}
}

/// A draw of peers claiming the given heights, each with a distinct id.
struct ScriptedTips(Vec<u32>);

#[async_trait]
impl PeerSample for ScriptedTips {
async fn draw(&self) -> Vec<Arc<dyn CoinPeer>> {
self.0
.iter()
.enumerate()
.map(|(i, height)| {
let mut hash = [0u8; 32];
hash[..4].copy_from_slice(&height.to_be_bytes());
Arc::new(PeakOnlyPeer {
id: format!("10.0.0.{i}:8444"),
claim: Some(PeakClaim {
height: *height,
header_hash: Bytes32::from(hash),
}),
}) as Arc<dyn CoinPeer>
})
.collect()
}
}

async fn transport_over(tips: Vec<u32>) -> ChainTransport {
let db = crate::sage::db::WalletDb::open_in_memory().await.unwrap();
ChainTransport::new().with_peer_reads_arc(Arc::new(PeerCorroboratedReads::new(
Arc::new(ScriptedTips(tips)),
db,
)))
}

/// The control: agreeing peers produce a height, and still nothing dials an oracle.
///
/// Without this every test below is satisfied by a `peak_height` that returns `None`
/// unconditionally, which would pass the refusals and break the node.
#[tokio::test]
async fn agreeing_peers_give_the_node_a_height_without_asking_a_third_party() {
let transport = transport_over(vec![9_000_000; 4]).await;
let reported = transport.peak_height().await.unwrap();

// The PLACEMENT assertion goes first, deliberately. Asserted after the value it would
// never be REACHED on a regression: the value assertion fires, the placement one is never
// evaluated, and the property it exists to pin stays unproven while the test still turns
// red for a reason that looks convincing. Measured — all three of these tests originally
// failed their revert-proof on the value line and proved nothing about the oracle.
assert!(
transport.sources.existing_client().await.is_none(),
"the node's own peers answered, and it consulted the public oracle anyway"
);
assert_eq!(reported, Some(9_000_000 - SETTLED_LAG));
}

/// A round that collapses to ONE voice reports no height, and does NOT fall through to the
/// oracle to find one.
///
/// Falling through is the tempting repair and it is the defect: it would let a single HTTPS
/// endpoint decide the height precisely when the peers failed to agree — which is the
/// single-source dependency NC-12 exists to remove, arriving at exactly the moment it matters.
#[tokio::test]
async fn a_single_peer_yields_no_height_and_no_fallback_to_the_oracle() {
let transport = transport_over(vec![9_000_000]).await;
let reported = transport.peak_height().await.unwrap();

assert!(
transport.sources.existing_client().await.is_none(),
"the peers did not agree and the node asked a third party instead, which is the \
single source this change removes"
);
assert_eq!(
reported, None,
"one peer was allowed to tell this node where the chain is"
);
}

/// A split sample is an unknown height, reported as unknown — again without an oracle read.
#[tokio::test]
async fn a_split_sample_yields_no_height_and_no_fallback_to_the_oracle() {
let transport = transport_over(vec![9_000_000, 9_000_000, 8_000_000, 8_000_000]).await;
let reported = transport.peak_height().await.unwrap();

assert!(
transport.sources.existing_client().await.is_none(),
"a partition was resolved by asking a third party which side to believe"
);
assert_eq!(reported, None);
}
}
1 change: 1 addition & 0 deletions crates/dig-wallet/src/sage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ pub mod routing;
pub mod rpc;
pub mod service;
pub mod singleton;
pub mod sources;
pub mod spend;
pub mod sync;
pub mod sync_supervisor;
Expand Down
Loading
Loading