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.185.0"
version = "0.187.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
12 changes: 12 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -8112,6 +8112,17 @@ Mirror spends do not transit `control.wallet.broadcast`, and `DIG_WALLET_ENABLE_
does not govern them: that flag gates the GENERAL node-custodied wallet surface, while this
lifecycle is governed by §25.7's switch and §23's audit contract.

**Mirror spends MUST be signed under CHIA MAINNET's `AGG_SIG_ME` domain.** A mirror coin is an
ordinary Chia L1 CAT, so the consensus that validates its spend appends Chia mainnet's genesis
challenge (`ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb`) to every `AGG_SIG_ME`
message. The `agg_sig_data` the operator wallet is opened with MUST therefore be
`MAINNET_CONSTANTS.genesis_challenge`, and MUST NOT be any `dig-constants` value: `dig-constants`
describes the **DIG L2** chain, and its genesis is the DIG PEER network id, not an L1 CAT's signing
domain. Signing under any other domain produces a valid signature over a message the network does
not check, so the bundle builds, signs and broadcasts and is then refused as
`BAD_AGGREGATE_SIGNATURE` by every peer, on every retry — with the collateral left locked and no
local error to see (dig-node#447).

**Key derivation MUST be the standard Chia HD derivation** from the operator mnemonic, so the phrase
exported by `dign wallet export-seed` (§16.3) recovers the collateral wallet — including anything
locked in unreclaimed mirror coins, via any standard wallet — with no dig-node code involved. The
Expand Down Expand Up @@ -8407,3 +8418,4 @@ on a claim the node cannot keep, which this section penalises. The operator sets
Changing the value affects only coins created after the change. Bringing an existing coin into line
means reclaiming and re-creating it — a round trip and a fee — and the node MUST NOT reclaim in
response to a configuration edit.

5 changes: 5 additions & 0 deletions crates/dig-node-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ dig-chainsource-interface = "0.3"
chia-bls = "0.36.1"
chia-protocol = "0.36.1"
chia-sdk-driver = { version = "0.36.0", features = ["chip-0035", "action-layer"] }
# `MAINNET_CONSTANTS` -- the Chia L1 `AGG_SIG_ME` domain every mirror-coin spend is signed under
# (`mirror::lifecycle::mirror_agg_sig_data`, dig-node#447). This is the crate `chia_wallet_sdk::types`
# re-exports, named directly so this crate does not pull the whole SDK for one constant. Already a
# dev-dependency at this exact version, so no second line enters the tree.
chia-sdk-types = { version = "0.36.0", features = ["chip-0035", "action-layer"] }
# The CAT puzzle-hash currying, for deriving the coin a mirror RECLAIM creates before it exists.
# Same chia 0.36 set as the crates around it -- a mirror coin's collateral is $DIG, so the returned
# coin sits at the CAT puzzle hash and never at the bare owner puzzle hash.
Expand Down
22 changes: 20 additions & 2 deletions crates/dig-node-service/src/mirror/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,25 @@ pub async fn production_broadcaster(
}
}

/// The `AGG_SIG_ME` domain every mirror-coin spend must be signed under: **Chia mainnet's**.
///
/// A mirror coin is an ordinary Chia L1 CAT, so the consensus that validates its spend appends
/// Chia mainnet's genesis challenge to every `AGG_SIG_ME` message. Signing under any other domain
/// produces a signature over a different message, and Chia's mempool answers
/// `BAD_AGGREGATE_SIGNATURE` -- deterministically, from every peer, on every retry.
///
/// **`dig-constants` is the DIG L2 chain's constants crate and has no business in an L1 CAT spend.**
/// `DIG_MAINNET.genesis_challenge()` is the L2 genesis anchor; it is the right value for the DIG
/// peer network id (`peer::genesis_challenge_from_env`) and the wrong one here. Passing it locked
/// 1010 $DIG base units in an unspendable mirror coin on mainnet (dig-node#447), and the failure is
/// invisible locally: the bundle builds, signs, and broadcasts, and only the network disagrees.
///
/// This is a function rather than an inline constant so the choice has a name a test can assert on,
/// and so the reason above lives beside the value instead of at one call site.
pub fn mirror_agg_sig_data() -> chia_protocol::Bytes32 {
chia_sdk_types::MAINNET_CONSTANTS.genesis_challenge
}

/// Open the operator wallet, or say why the lifecycle cannot spend.
///
/// [`OperatorWallet::open`] returns `None` for BOTH §16.4 `Locked` and `Orphaned`, which is the
Expand All @@ -666,8 +685,7 @@ pub async fn open_signer(
live_broadcast: bool,
chain: &ChainTransport,
) -> (Option<MirrorSigner>, SpendCapability) {
let Some(wallet) = OperatorWallet::open(paths, dig_constants::DIG_MAINNET.genesis_challenge())
else {
let Some(wallet) = OperatorWallet::open(paths, mirror_agg_sig_data()) else {
return (None, SpendCapability::WalletUnavailable);
};
if !live_broadcast {
Expand Down
184 changes: 184 additions & 0 deletions crates/dig-node-service/tests/mirror_l1_genesis.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! A mirror-coin spend is signed under **Chia mainnet's** `AGG_SIG_ME` domain, never the DIG L2 one.
//!
//! A mirror coin is an ordinary Chia L1 CAT. The consensus that validates its spend appends Chia
//! mainnet's genesis challenge to every `AGG_SIG_ME` message, so a signature produced under any
//! other domain commits to a different message and the mempool answers `BAD_AGGREGATE_SIGNATURE`
//! from every peer, on every retry.
//!
//! dig-node#447 shipped exactly that: `open_signer` opened the operator wallet with
//! `dig_constants::DIG_MAINNET.genesis_challenge()`, the **L2** anchor, which locked 1010 $DIG base
//! units in an unspendable mirror coin on mainnet. Nothing local disagreed — the bundle built,
//! signed and broadcast; only the network refused.
//!
//! # Why these tests are shaped the way they are
//!
//! Asserting that the trailing 32 bytes of a required message equal a constant is circular when the
//! same constant is fed to the extractor. So the load-bearing test below signs with the wallet the
//! PRODUCTION selector builds, and then verifies that signature against the message Chia's own
//! consensus requires — a domain this file states independently of the code under test. A signature
//! made under the L2 domain cannot verify against the L1 message, so it cannot pass on the defect.

mod support;

use chia_bls::PublicKey;
use chia_protocol::{Bytes32, Coin, CoinSpend};
use chia_sdk_types::MAINNET_CONSTANTS;
use dig_mirror_coin::MirrorCoin;
use dig_node_service::mirror::lifecycle::mirror_agg_sig_data;
use dig_node_service::mirror::spends::build_reclaim;
use dig_wallet::operator_wallet::OperatorWallet;
use dig_wallet::sage::spend::required_bls_signatures;
use support::{creating_spend, mirror_memos, root_1, store_a, Wallet};

const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \
abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \
abandon abandon abandon art";

/// Chia mainnet's genesis challenge, stated here rather than read from the code under test.
///
/// This is the value the Chia mempool actually appends. Restating it makes every assertion below a
/// comparison against the NETWORK's constant instead of against the program's opinion of it.
///
/// Decoded from the published hex rather than written as 32 byte literals so a transcription slip
/// cannot happen silently, and so the source reads as the value operators quote.
fn chia_mainnet_genesis() -> Bytes32 {
let mut out = [0u8; 32];
hex::decode_to_slice(
"ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb",
&mut out,
)
.expect("the published Chia mainnet genesis challenge is 32 bytes of hex");
Bytes32::new(out)
}

/// The wallet the PRODUCTION selector builds: the fixture phrase, opened under
/// `mirror_agg_sig_data()`. Nothing here restates the domain — that is the point.
fn production_wallet() -> OperatorWallet {
OperatorWallet::from_phrase(PHRASE, mirror_agg_sig_data())
.expect("the fixture phrase derives an operator wallet")
}

fn fixture_wallet() -> Wallet {
let keys = digstore_chain::keys::derive_wallet_keys(PHRASE).expect("the phrase derives");
Wallet {
public_key: keys.synthetic_sk.public_key(),
puzzle_hash: keys.owner_puzzle_hash,
}
}

/// A genuine mirror coin this wallet owns, from a real CAT spend executed to produce its conditions.
fn owned_mirror_coin(owner: &Wallet) -> MirrorCoin {
let memos = mirror_memos(owner, store_a(), root_1(), &["https://example.invalid"]);
let (spend, coin) = creating_spend(owner, &memos);

MirrorCoin::from_creating_spend(&spend, coin.coin_id())
.expect("the fixture spend decodes")
.expect("and it is a mirror coin")
}

/// The spends of a real, zero-fee reclaim of a coin this wallet owns.
fn reclaim_spends() -> Vec<CoinSpend> {
let owner = fixture_wallet();
let coin = owned_mirror_coin(&owner);
build_reclaim(&coin, owner.public_key, Vec::<Coin>::new(), 0)
.expect("a zero-fee reclaim builds")
.coin_spends()
.to_vec()
}

/// Every BLS signature a reclaim requires under the Chia L1 domain, as `(key, message)`.
fn required_under_chia_l1() -> Vec<(PublicKey, Vec<u8>)> {
required_bls_signatures(&reclaim_spends(), chia_mainnet_genesis())
.expect("required signatures extract")
}

/// **The regression.** The signature the production wallet produces verifies against the message
/// Chia's consensus requires.
///
/// This is the assertion the network was making and we were not. Under the L2 domain the wallet
/// signs a message ending in `0af98186…` while Chia checks one ending in `ccd5bb71…`, so the
/// aggregate cannot verify — and this test fails on its own assertion, not on a build error and not
/// on a missing fixture.
#[test]
fn a_reclaim_signed_by_the_production_wallet_verifies_under_the_chia_l1_domain() {
let required = required_under_chia_l1();
assert!(
!required.is_empty(),
"a reclaim must require at least one BLS signature, or this test proves nothing"
);

let signature = production_wallet()
.signer()
.sign(&reclaim_spends())
.expect("the production wallet signs its own reclaim");

let pairs = required
.iter()
.map(|(key, message)| (*key, message.as_slice()));

assert!(
chia_bls::aggregate_verify(&signature, pairs),
"the operator wallet must sign the message CHIA validates -- a mirror coin is an L1 CAT, \
and a signature made under any other genesis is rejected as BAD_AGGREGATE_SIGNATURE"
);
}

/// The message's trailing 32 bytes ARE the domain, and the domain the production selector chooses is
/// Chia mainnet's.
///
/// Stated separately from the verification above so a failure says WHICH half broke: a wrong domain
/// here, or a wrong key or derivation there.
#[test]
fn the_required_message_ends_in_the_chia_mainnet_genesis_challenge() {
let required = required_under_chia_l1();
assert!(!required.is_empty(), "no required signature to inspect");

for (_, message) in &required {
assert!(
message.len() > 32,
"an AGG_SIG_ME message carries a 32-byte domain after its payload"
);
assert_eq!(
&message[message.len() - 32..],
chia_mainnet_genesis().as_ref(),
"the trailing 32 bytes of an AGG_SIG_ME message are the network domain"
);
}

assert_eq!(
mirror_agg_sig_data(),
chia_mainnet_genesis(),
"the production selector must choose the domain those messages are built from"
);
assert_eq!(
mirror_agg_sig_data(),
MAINNET_CONSTANTS.genesis_challenge,
"and it must be the SDK's mainnet constant, not a second copy of the same bytes"
);
}

/// **The class guard.** No `dig-constants` genesis may ever be the mirror signing domain.
///
/// The test above pins one wrong value; this pins the family it came from. `dig-constants` describes
/// the **DIG L2** chain, and nothing in it is an `AGG_SIG_ME` domain for a Chia L1 CAT. An edit that
/// reintroduces this genesis — or reaches for a different `dig_constants` network — fails here even
/// if it happens to satisfy nothing else.
#[test]
fn the_mirror_signing_domain_is_never_a_dig_constants_genesis() {
let chosen = mirror_agg_sig_data();

// A `Vec` rather than an array literal so the guard reads as a LIST that grows: adding a network
// to `dig-constants` and reaching for it here should be caught, not silently outside the guard.
let l2_genesis_values: Vec<(&str, Bytes32)> = vec![(
"DIG_MAINNET",
dig_constants::DIG_MAINNET.genesis_challenge(),
)];

for (name, genesis) in l2_genesis_values {
assert_ne!(
chosen, genesis,
"dig_constants::{name} is a DIG L2 anchor and must never be the mirror signing \
domain; using it locked 1010 $DIG in an unspendable mainnet coin (dig-node#447)"
);
}
}
20 changes: 20 additions & 0 deletions crates/dig-wallet/src/sage/spend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,26 @@ pub fn required_signatures(
.map_err(|e| Error::internal(format!("required-signature extraction: {e:?}")))
}

/// Every BLS signature `coin_spends` requires, as `(public_key, message)` pairs.
///
/// The message is what the CONSENSUS will hash under `agg_sig_data`, so a caller holding these can
/// check a produced signature against the network's own requirement rather than against the
/// signer's opinion of it. That distinction is the whole point: a signer asked for the message it
/// already believes in will always agree with itself, and the mirror-coin L1/L2 genesis regression
/// (dig-node#447) was invisible to every such self-consistent check.
pub fn required_bls_signatures(
coin_spends: &[CoinSpend],
agg_sig_data: Bytes32,
) -> Result<Vec<(PublicKey, Vec<u8>)>> {
Ok(required_signatures(coin_spends, agg_sig_data)?
.into_iter()
.filter_map(|req| match req {
RequiredSignature::Bls(bls) => Some((bls.public_key, bls.message().to_vec())),
_ => None,
})
.collect())
}

/// The BLS public keys that must sign `coin_spends` for it to be valid.
///
/// The push guard asks this instead of inspecting puzzle hashes, because a puzzle hash tells you
Expand Down
Loading