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
2 changes: 1 addition & 1 deletion 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.178.0"
version = "0.179.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
21 changes: 15 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -8119,12 +8119,21 @@ address the wallet already tracks.
> no confirmation is observed, and nothing reconciles an `unresolved` or `failed` one. Both are
> tracked as <https://github.com/DIG-Network/dig-node/issues/412>.
>
> **A mirror spend is BUILT and not SENT.** The node wires no production broadcaster for this
> lifecycle, so a planned reclaim refuses by name before it signs and no mirror spend reaches the
> mempool — the create half refuses separately, for the coin selector
> (<https://github.com/DIG-Network/dig-node/issues/421>). The refusal is reported rather than
> silent, and the capability the node announces is derived from the same seam, so it cannot claim a
> power it does not have. Tracked as <https://github.com/DIG-Network/dig-node/issues/424>.
> **A mirror spend is SENT only when the operator has enabled live broadcast.** The lifecycle
> builds its own `Broadcaster` on this node's ONE shared chain client, and it is built only when
> `DIG_WALLET_ENABLE_LIVE_BROADCAST` is on. On a default install — the flag defaults off — no
> broadcaster is constructed and no chain is dialed for one, so a planned reclaim refuses by name
> before it signs and no mirror spend reaches the mempool. The refusal is reported rather than
> silent, and the capability the node announces is derived from the SAME seam the money path is
> handed, so it cannot claim a power it does not have: `Available` holds exactly when a broadcaster
> is handed over.
>
> The broadcaster is built per pass rather than once at bring-up, because the shared client does not
> cache a failure — a node that started with no network broadcasts as soon as its network returns,
> and a node that cannot reach a chain reports that distinctly from a switched-off flag.
>
> Nothing is attached to the served `WalletBackend`: the broadcaster is scoped to the mirror
> lifecycle, signs only from the §16.4 operator wallet, and never acts on a user's behalf (§908).

**Every spend is audited, structurally — exactly ONE entry per signature, and it cannot lie about
the spend.** The signer takes the `SpendJournal` (§23.3) and opens the record itself, returning the
Expand Down
390 changes: 293 additions & 97 deletions crates/dig-node-service/src/mirror/lifecycle.rs

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions crates/dig-node-service/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2688,7 +2688,7 @@ fn spawn_mirror_passes(

tokio::spawn(async move {
let paths = dig_wallet::autoseed::default_paths();
let (signer, capability) = lifecycle::open_signer(&paths, live_broadcast);
let (signer, capability) = lifecycle::open_signer(&paths, live_broadcast, &chain).await;

// The owner puzzle hash comes from the SIGNER when there is one, so the key a spend is built
// for and the address its bonds are observed under cannot be two different values. Without a
Expand Down Expand Up @@ -2724,11 +2724,11 @@ fn spawn_mirror_passes(
),
// Deliberately NOT phrased as a flag to set: the operator has already set
// DIG_WALLET_ENABLE_LIVE_BROADCAST to reach this arm at all.
SpendCapability::BroadcasterUnwired => tracing::info!(
SpendCapability::ChainClientUnavailable => tracing::warn!(
target: "mirror",
"the mirror lifecycle OBSERVES only: the wallet opened and live broadcast is on, \
but this build wires no broadcaster (dig-node#424), so a reclaim is planned and \
reported and no spend is sent"
but this node could not build the shared chain client a broadcaster is made from, \
so a reclaim is planned and reported and no spend is sent"
),
}

Expand Down Expand Up @@ -2772,6 +2772,11 @@ fn spawn_mirror_passes(

match chain.chain_source(tokio::runtime::Handle::current()).await {
Ok(source) => {
// Re-read per pass, deliberately: `ChainTransport::broadcaster` does not
// cache a failure, so a node that started offline can broadcast the moment
// its network returns. Holding one built at bring-up would silently make
// that node one that never broadcasts again.
let broadcast = lifecycle::production_broadcaster(&chain, live_broadcast).await;
let runtime = tokio::runtime::Handle::current();
let signer_ref = signer.as_ref();
let ctx = PassContext {
Expand Down Expand Up @@ -2803,7 +2808,7 @@ fn spawn_mirror_passes(
// The SAME seam `open_signer` derived the reported capability from, so
// what this node says it can do and what a spend can actually reach
// cannot be two different answers (dig-node#424).
lifecycle::production_broadcaster(),
broadcast.broadcaster(),
runtime,
);
let mut pass =
Expand Down
28 changes: 27 additions & 1 deletion crates/dig-wallet/src/sage/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,28 @@ impl ChainTransport {
self.client().await
}

/// A [`Broadcaster`](super::spend::Broadcaster) that pushes through the ONE shared client.
///
/// The narrow counterpart to [`Self::chain_source`], and it exists for the same reason: a
/// consumer that needs to SEND a signed bundle needs the ability to push, not the client
/// itself. Handing out [`Self::shared_client`] would hand out every other power the client has,
/// and building a second client is what gave a live node two independent sets of full-node
/// sessions with two notions of the peak (dig_ecosystem#2761).
///
/// It is deliberately NOT an `impl Broadcaster for ChainTransport`. An unused one sat in this
/// file once and made a one-line `.with_broadcaster(chain.clone())` compile, pass every test,
/// and silently enable node-custodied sending on a default install. A caller must ask for a
/// broadcaster by name, and asking is a visible line in a diff.
///
/// # Errors
///
/// The lazy client build — this node could not reach a chain at all. Not cached, so a later
/// call tries again; a node that starts offline can broadcast the moment its network returns.
pub async fn broadcaster(&self) -> Result<Arc<dyn super::spend::Broadcaster>> {
let client = self.shared_client().await?;
Ok(Arc::new(super::spend::ChiaQueryBroadcaster::new(client)))
}

/// This transport's chain reads presented as the canonical
/// [`ChainSource`](chia_query::provider_registry::interface::ChainSource) — the trait every DIG consumer of
/// chain state depends on.
Expand Down Expand Up @@ -582,7 +604,11 @@ impl ChainFallback for ChainTransport {
// `submit_transaction` for the node's custodied key, which is the decision
// `DIG_WALLET_ENABLE_LIVE_BROADCAST` owns. An unused `impl Broadcaster for ChainTransport` sat here
// and made a one-line `.with_broadcaster(chain.clone())` compile, pass every test, and silently
// enable node-custodied sending on a default install. The transport is reachable only as a
// enable node-custodied sending on a default install.
//
// A `Broadcaster` is reachable from the transport, but only by NAME, through
// [`ChainTransport::broadcaster`] — a visible line in a diff that a caller has to write on purpose.
// The `impl` is what made it ambient; asking for one is not. Its other route out is as a
// `SignedBundlePusher`, whose contract is a bundle somebody already signed.

/// Decode a hex-encoded, already-signed spend bundle.
Expand Down
107 changes: 99 additions & 8 deletions crates/dig-wallet/src/sage/spend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,47 @@ pub(crate) fn to_query_bundle(bundle: &SpendBundle) -> Result<chia_query::SpendB
})
}

/// The ONE reading of a `push_tx` answer, and the reason it is not `status.success`.
///
/// A Chia `TransactionAck` carries three outcomes, and `chia_query` collapses two of them:
/// `ack_to_tx_status` sets `success: true` for status **1 (SUCCESS)** *and* status **2 (PENDING)**.
/// Those are not the same event. `SUCCESS` means the full node admitted the bundle to its mempool;
/// `PENDING` means it did **not** — it is holding the bundle for an unknown parent, or refusing it
/// for a fee below the mempool floor — and a held bundle may never be admitted at all.
///
/// [`Broadcaster::broadcast`] promises `Ok` "once the network has accepted it for the mempool", so
/// only `SUCCESS` may return `Ok` here. Reading `success` instead would report a submission that
/// never happened: the spend journal would record it, and the intra-pass reservation would strand
/// the funding coin against a spend the network is not holding.
///
/// Kept as a PURE function of the status rather than an inline branch so every ack shape is
/// exercised directly — the mempool cannot be asked to produce a `PENDING` on demand.
///
/// A refused push is FINAL, which is why this reads as a refusal rather than as "try again":
/// `chia_query`'s router returns the FIRST `Ok` from `push_tx`, so a PENDING ack ends the push —
/// there is no coinset second opinion and no retry against another peer.
///
/// It cannot say WHY, and that is a limitation of the crate rather than a choice here: the
/// `TransactionAck`'s own error text is discarded by `ack_to_tx_status`, so the ack NAME is the
/// most specific thing available. An unknown parent and a fee below the mempool floor are
/// different problems with different operator actions, and both arrive here as `PENDING`.
///
/// The comparison is on the status NAME, not the boolean: `chia_query`'s `success` is deliberately
/// left alone. It is a published crate at a lower level whose other consumers may legitimately be
/// asking "did the node take it" rather than "is it in the mempool"; widening this fix into that
/// crate is a release-first cascade, tracked as DIG-Network/chia-query#48 — which also carries the
/// discarded `ack.error`.
pub(crate) fn accepted_by_mempool(status: &chia_query::TxStatus) -> Result<()> {
if status.status == "SUCCESS" {
return Ok(());
}
Err(Error::api(format!(
"the network did not admit the transaction to its mempool (ack: {}); nothing is pending on \
chain for this bundle",
status.status
)))
}

#[async_trait]
impl Broadcaster for ChiaQueryBroadcaster {
async fn broadcast(&self, bundle: &SpendBundle) -> Result<()> {
Expand All @@ -262,14 +303,8 @@ impl Broadcaster for ChiaQueryBroadcaster {
.push_tx(&wire)
.await
.map_err(|e| Error::internal(format!("broadcast (push_tx) failed: {e}")))?;
// Fail closed: a non-success mempool status is an error, not a silent no-op.
if !status.success {
return Err(Error::api(format!(
"the network rejected the transaction: {}",
status.status
)));
}
Ok(())
// Fail closed, and on ADMISSION rather than on `success` — see `accepted_by_mempool`.
accepted_by_mempool(&status)
}
}

Expand Down Expand Up @@ -970,6 +1005,62 @@ mod tests {
use chia_sdk_test::Simulator;
use chia_wallet_sdk::types::TESTNET11_CONSTANTS;

/// The exact ack `chia_query` produces for each `TransactionAck` status byte.
///
/// Built from `chia_query::peer::translate::ack_to_tx_status`'s own mapping rather than from
/// what this module would like it to be, so a change to that mapping shows up here as a
/// failure instead of being silently accommodated.
fn ack(status: &str, success: bool) -> chia_query::TxStatus {
chia_query::TxStatus {
status: status.to_string(),
success,
}
}

/// A PENDING ack is a REFUSAL, and it is the one ack that distinguishes this check from the
/// obvious wrong one.
///
/// `chia_query` reports status 2 as `TxStatus { status: "PENDING", success: true }` — so a
/// broadcaster that reads `success` returns `Ok(())` for a bundle the full node never admitted
/// to its mempool. Every other ack shape agrees between the two readings; this fixture is the
/// only input that tells them apart, which is why it is written first and named for it.
#[test]
fn a_pending_ack_is_not_an_accepted_broadcast() {
let pending = ack("PENDING", true);
assert!(
pending.success,
"the fixture must carry the conflation it exists to catch: chia_query really does set \
success=true on PENDING, and a fixture with success=false would pass against the \
defect"
);

let err = accepted_by_mempool(&pending)
.expect_err("a bundle the mempool did not admit must not report as broadcast");
assert!(
err.to_string().contains("PENDING"),
"the refusal must NAME the ack it saw, or an operator cannot tell a held bundle from a \
rejected one: {err}"
);
}

/// SUCCESS, and only SUCCESS, is admission.
#[test]
fn only_a_success_ack_reports_an_accepted_broadcast() {
accepted_by_mempool(&ack("SUCCESS", true)).expect("status 1 is mempool admission");

for refused in [
ack("PENDING", true),
ack("FAILED", false),
ack("UNKNOWN", false),
] {
let name = refused.status.clone();
assert!(
accepted_by_mempool(&refused).is_err(),
"{name} is not mempool admission and must not return Ok"
);
}
}

/// A signer whose single key owns `alice`'s simulator coin, using the testnet11 agg-sig
/// domain (the domain the simulator validates against).
fn signer_for(sk: SecretKey) -> WalletSigner {
Expand Down
Loading