Skip to content

fix(mirror): sign mirror spends under the Chia L1 genesis, not the DIG L2 one - #448

Merged
MichaelTaylor3d merged 5 commits into
mainfrom
loop/mirror-l1-genesis
Aug 31, 2026
Merged

fix(mirror): sign mirror spends under the Chia L1 genesis, not the DIG L2 one#448
MichaelTaylor3d merged 5 commits into
mainfrom
loop/mirror-l1-genesis

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

DRAFT — do not merge. No gate round has run.

Closes #447. Parent epic: https://github.com/DIG-Network/dig_ecosystem/issues/3166

The defect

mirror::lifecycle::open_signer opened the operator wallet with
dig_constants::DIG_MAINNET.genesis_challenge()
0af981862a4df51f51ec59c312315d959931d917c375730b89b9e2b0854d1abf, the DIG L2 genesis anchor.

A mirror coin is an ordinary Chia L1 CAT, so the consensus that validates its spend appends
Chia mainnet's genesis (ccd5bb71...) to every AGG_SIG_ME message. The wallet therefore produced
a valid signature over a message the network does not check, and Chia's mempool answered
BAD_AGGREGATE_SIGNATURE -> TransactionAck.status = 3 -> ack: FAILED, deterministically, from
every peer. Nothing failed locally: the bundle built, signed and broadcast.

Regression from 5df3e34 (#419). v0.170.0/v0.172.0 clean; v0.179.0/v0.183.0 affected.

The fix

A named selector, mirror::lifecycle::mirror_agg_sig_data(), returning
chia_sdk_types::MAINNET_CONSTANTS.genesis_challenge — the crate chia_wallet_sdk::types
re-exports, named directly so this crate does not pull the whole SDK for one constant. It was
already a dev-dependency at 0.36.0, so no second chia line enters the tree.

The reason lives in the function's doc comment: dig-constants is the DIG L2 chain's constants
crate and has no business in an L1 CAT spend.
That is what stops it being re-introduced, and it is
now also normative in SPEC.md §25.2.

Blast radius

gitnexus's dig-node index predates open_signer (new in #419) and returned Target not found,
so this was scoped by grep + direct read — §2.0 permits the fallback and requires saying so.

  • open_signer has exactly one production caller, server.rs:2691 (bring-up), plus two in-file
    tests. Its signature is unchanged, so no caller adapts.
  • The value flows only into WalletSigner::new(agg_sig_data) and thence into the AGG_SIG_ME
    message domain. owner_puzzle_hash and synthetic_key derive from the phrase and are unaffected
    by it (operator_wallet.rs:82-88) — no address or derivation changes.
  • This was the sole use of dig_constants::DIG_MAINNET.genesis_challenge() in dig-node-service
    or dig-wallet. It remains in use for the DIG peer network id (peer.rs:509/542), which is
    correct there and untouched.
  • New: dig_wallet::sage::spend::required_bls_signatures, an additive extraction over the existing
    required_signatures, with no callers changed.

Risk read: LOW for the code radius, HIGH for the behaviour it restores — this turns an
unspendable money path back on, which is the point.

Tests — red proven by reverting the production line

crates/dig-node-service/tests/mirror_l1_genesis.rs, 3 tests. The load-bearing one is deliberately
not an assertion about a constant: it signs a real reclaim with the wallet the production
selector builds, then verifies that signature against the message Chia's own consensus requires,
with the L1 domain stated independently in the test file. A signature made under the L2 domain
cannot verify against the L1 message.

Green at 275e767running 3 tests / 3 passed; 0 failed.

Production line reverted to the L2 constant, same run, 0 passed; 3 failed, each on its own
assertion:

test failed on
a_reclaim_signed_by_the_production_wallet_verifies_under_the_chia_l1_domain the aggregate_verify assertion
the_required_message_ends_in_the_chia_mainnet_genesis_challenge left: 0af98186... right: ccd5bb71...
the_mirror_signing_domain_is_never_a_dig_constants_genesis assertion left != right failed

Restored, tree clean.

Proven on mainnet

Mirror coin 31e9077265a6254c32d3857a5cff3561c3b57d28e3bb7789b24173be6c89f14d, 1010 $DIG base
units, unspent since block 9,224,641, was reclaimed by a 0.187.0 binary from this branch.

The audit journal shows the exact change in the network's answer:

when build outcome
before 0.185.0 failed / broadcast"the network did not admit the transaction to its mempool (ack: FAILED)"
after 0.187.0 submitted

Verified on api.coinset.org, not from the node:

  • the mirror coin is spent: true, spent_block_index: 9225254
  • its only child is 1010 base units at 0xe0534fd8f369cf8a32646d131cf8593fb7e97f7e9aa1ec0ea901045fb05deffb — the operator's own $DIG address — confirmed in block 9225254, unspent

Version

Workspace 0.185.0 -> 0.187.0 (Cargo.toml + Cargo.lock). Patch would understate it: this
restores a capability that could not execute at all.

Follow-up found, not fixed here

After a successful broadcast the journal records unresolved: "the producer ended without recording an outcome" rather than a confirmation — the spend landed on chain while its own audit
entry never says so. Off the critical path per §2.6; filed rather than fixed.

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 4 commits August 30, 2026 22:27
…G L2 one

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.
open_signer opened the operator wallet with dig_constants::DIG_MAINNET's
genesis -- the DIG L2 anchor -- so every mirror create and reclaim committed to
a message Chia does not check, and the mempool answered BAD_AGGREGATE_SIGNATURE
deterministically from every peer.

dig-constants is the L2 chain's constants crate and has no business in an L1 CAT
spend. That sentence lives on the new mirror_agg_sig_data(), which gives the
choice a name a test can assert on.

Closes #447

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
… signed under

Co-Authored-By: Claude <noreply@anthropic.com>
…ass guard

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, NOT THE VERDICT

Auditing head bb5caafa4bf99919fdf2668f931f2c99bf44027b (resolved from gh pr view 448 --json headRefOid, PR still draft).

Item 1 of 6 — CONFIRMED CORRECT: the constant is the real Chia L1 domain

Read from the vendored crate source, not assumed:

~/.cargo/registry/src/index.crates.io-.../chia-sdk-types-0.36.0/src/constants.rs:8-10

const MAINNET_GENESIS_CHALLENGE: Bytes32 = Bytes32::new(hex!(
    "ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb"
));

constants.rs:85-86 builds MAINNET_CONSTANTS as default_constants(MAINNET_GENESIS_CHALLENGE, MAINNET_GENESIS_CHALLENGE), and constants.rs:42-43 assigns that first argument to genesis_challenge and the second to agg_sig_me_additional_data. So MAINNET_CONSTANTS.genesis_challenge is byte-for-byte ccd5bb71...5fbb, and on mainnet it is IDENTICAL to agg_sig_me_additional_data.

So mirror_agg_sig_data() (crates/dig-node-service/src/mirror/lifecycle.rs:93-95) returns the value Chia consensus actually appends. The fix reads correct at the constant level.

One precision note, NOT a defect and not gating: the strictly-correct field name for an AGG_SIG_ME domain is agg_sig_me_additional_data, not genesis_challenge. They coincide on mainnet and testnet11 by construction, so the chosen field is right today; it would only diverge under a custom default_constants(a, b) with a != b, which nothing here does.

Items 2-6 (derivation unchanged, peer network id untouched, single chia line in the lock, test non-circularity, required_bls_signatures exposure) still in progress.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, NOT THE VERDICT (2 of 3)

Head bb5caafa4bf99919fdf2668f931f2c99bf44027b. Items 2, 3 and 6 resolved. Method: grep + direct read of git objects at the head SHA (git show <sha>:<path>, git grep <sha>) — no gitnexus, no checkout, nothing written to the shared tree.

Item 2 — CONFIRMED: NO address and NO key derivation changed

Traced agg_sig_data from the changed call site to every field it can reach.

crates/dig-wallet/src/operator_wallet.rs:82-89:

pub fn from_phrase(phrase: &str, agg_sig_data: Bytes32) -> Option<Self> {
    let keys = digstore_chain::keys::derive_wallet_keys(phrase).ok()?;
    Some(Self {
        owner_puzzle_hash: keys.owner_puzzle_hash,
        synthetic_key: keys.synthetic_sk.public_key(),
        signer: WalletSigner::new(vec![keys.synthetic_sk], agg_sig_data),
    })
}

Both address-bearing fields come from derive_wallet_keys(phrase) and the parameter appears in exactly one position. Following it one level further, crates/dig-wallet/src/sage/spend.rs:71-84 (WalletSigner::new) derives each KeyEntry.puzzle_hash from StandardArgs::curry_tree_hash(p2_pk) — the KEY, not the domain — and merely stores agg_sig_data. Its only read is spend.rs:130 inside sign(), feeding required_signatures(coin_spends, self.agg_sig_data) and thence AggSigConstants::new(...).

So the value reaches the AGG_SIG_ME message and nothing else. owner_puzzle_hash(), synthetic_key(), puzzle_hashes(), public_keys(), key_map() and change_puzzle_hash() are all invariant under it. The operator's funds do not move address. A pre-existing test at operator_wallet.rs:239-246 already asserts exactly this invariance across two different agg_sig_data values.

Item 3 — CONFIRMED: the DIG peer network id was left alone

At the head SHA, crates/dig-node-core/src/peer.rs:509 and :542 still read dig_constants::DIG_MAINNET.genesis_challenge(), unmodified — correct, that is the DIG PEER network id. git grep over crates/*/src/*.rs at the head shows exactly one production OperatorWallet::open call site (mirror/lifecycle.rs:688), and it is the one that changed. dig-node-core/src/lib.rs:9072 still reports the L2 genesis as the peer network id, correctly untouched. No network id is repointed anywhere in the diff.

Corroboration worth recording: crates/dig-wallet/src/sage/peer_reads/dialed.rs:215 already used chia_wallet_sdk::types::MAINNET_CONSTANTS.genesis_challenge for its L1 chain reads, before this PR. So the L1 constant was the established in-repo pattern and mirror/lifecycle.rs was the single outlier. The fix converges on existing behaviour rather than inventing a stricter one — which is the direction §2.0's already-shipped-contract check wants.

Item 6 — CONFIRMED: required_bls_signatures exposes strictly LESS than what was already public

crates/dig-wallet/src/sage/spend.rs:315-326. It returns (PublicKey, Vec<u8>) pairs built from RequiredSignature::Bls, discarding everything else. Both components are public-by-construction: a BLS public key and the consensus message. No SecretKey, no seed, no phrase, no signature is reachable through it.

It is a strict projection of the already-pub required_signatures (spend.rs:302-307), which returns the full Vec<RequiredSignature> — a superset containing both fields this returns. The new function widens no surface; anything it can tell a caller, the pre-existing public function already told them. It has no production caller at this head (test-only).

Item 4 (single chia line in the resolved lock) and item 5 (test non-circularity) still in progress.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: PASS

Audited head: bb5caafa4bf99919fdf2668f931f2c99bf44027b (resolved myself via gh pr view 448 --json headRefOid; PR is draft, correctly, while gates run).

Nothing CRITICAL and nothing HIGH. NO address and NO key derivation changed -- the operator's funds stay at the same derived address, and I traced that mechanically rather than taking the claim (evidence in item 2).

Method: grep + direct read of git objects at the head SHA (git show <sha>:<path>, git grep <sha>) plus a read of the vendored crate source in the cargo registry. I did not use gitnexus -- the sanctioned fallback, declared per section 2.0. I cut no worktree, ran no build, and wrote nothing to any shared checkout.

One correction I had to make mid-audit, recorded because it nearly produced a wrong finding: my first call-site grep ran against the shared checkout's working tree, which sits at pre-fix main, and showed lifecycle.rs still carrying the defect. Every result below is re-derived against the head SHA.


The six checks

1. The constant is the real Chia L1 domain -- CORRECT

chia-sdk-types-0.36.0/src/constants.rs:8-10 defines MAINNET_GENESIS_CHALLENGE as ccd5bb71183532bff220ba46c268991a3ff07eb358e8255a65c30a2dce0e5fbb. constants.rs:85-86 builds MAINNET_CONSTANTS as default_constants(MAINNET_GENESIS_CHALLENGE, MAINNET_GENESIS_CHALLENGE), and constants.rs:42-43 binds the first argument to genesis_challenge and the second to agg_sig_me_additional_data. So mirror_agg_sig_data() (crates/dig-node-service/src/mirror/lifecycle.rs:674-675) returns exactly the bytes Chia consensus appends, and on mainnet that value is identical to agg_sig_me_additional_data.

2. The value reaches ONLY the AGG_SIG_ME domain -- CONFIRMED, no address moved

crates/dig-wallet/src/operator_wallet.rs:82-89 takes agg_sig_data and uses it in exactly one position: derive_wallet_keys(phrase) supplies owner_puzzle_hash and synthetic_key, while agg_sig_data goes only into WalletSigner::new(vec![keys.synthetic_sk], agg_sig_data).

One level down, crates/dig-wallet/src/sage/spend.rs:71-84 derives each KeyEntry.puzzle_hash from StandardArgs::curry_tree_hash(p2_pk) -- the KEY, never the domain -- and only stores agg_sig_data. Its single read is spend.rs:130 in sign(), into required_signatures(..., self.agg_sig_data) and AggSigConstants::new(...).

So owner_puzzle_hash(), synthetic_key(), puzzle_hashes(), public_keys(), key_map() and change_puzzle_hash() are all invariant under this change. A pre-existing test at operator_wallet.rs:239-246 already asserts that invariance across two different domains.

3. The DIG peer network id was left alone -- CONFIRMED

crates/dig-node-core/src/peer.rs:509 and :542 still read dig_constants::DIG_MAINNET.genesis_challenge(), unmodified and correct for the peer network id; dig-node-core/src/lib.rs:9072 still reports it as such. Nothing in the diff repoints a network id.

Stronger than "the diff did not touch it": git grep at the head SHA over crates/*/src/*.rs shows exactly one production OperatorWallet::open call site (mirror/lifecycle.rs:688) and exactly one production WalletSigner::new (operator_wallet.rs:87, reachable only through open/from_phrase). Every other WalletSigner::new -- in mint.rs, offers.rs, options.rs, db.rs, and spend.rs:1093 with TESTNET11_CONSTANTS -- sits inside a #[cfg(test)] module, each verified individually. There is one production signing domain and it is now the right one.

Two corroborations worth recording:

  • crates/dig-wallet/src/sage/peer_reads/dialed.rs:215 already used chia_wallet_sdk::types::MAINNET_CONSTANTS.genesis_challenge for its L1 chain reads, before this PR. The L1 constant was the established in-repo pattern and mirror/lifecycle.rs was the lone outlier, so the fix converges on existing behaviour instead of inventing a stricter one -- the direction section 2.0's already-shipped-contract check asks for.
  • mirror_agg_sig_data() has no environment override, unlike peer::genesis_challenge_from_env() which reads DIG_NETWORK_GENESIS. That is a genuine custody property, not an omission: no environment variable can repoint the domain a mirror spend is signed under. Please keep it that way.

4. One chia line, verified from the resolved lock -- CONFIRMED

The strongest available evidence, and it is decisive: the entire Cargo.lock delta from base 9fdd797006c8315a693d072c99f46792c1ca8f51 to head is one version string, 0.185.0 to 0.187.0. No package entry added, no resolution changed. That is exactly the signature of a dev-dependency promoted to a normal dependency, because Cargo.lock already merges dev-dependencies into a package's dependencies array -- so the Cargo.toml comment's claim ("already a dev-dependency at this exact version, so no second line enters the tree") is literally true and provable from the lock rather than asserted.

dig-node-service's resolved entry is a coherent set: chia-bls 0.36.1, chia-protocol 0.36.1, chia-puzzle-types 0.36.1, chia-sdk-driver 0.36.0, chia-sdk-types 0.36.0, chia-sha2 0.36.1. dig-wallet resolves the same chia-protocol 0.36.1 / chia-wallet-sdk 0.36.0 line, so the Bytes32 that mirror_agg_sig_data() returns is type-identical to the one OperatorWallet::open accepts across the crate boundary -- and Test + coverage passing (18m38s) is the compile-time proof of that identity.

The workspace does carry older chia lines (0.26 / 0.30 / 0.34) in other subtrees, but that is pre-existing, is not reachable from either crate on this path, and is the condition section 2.4b explicitly notes cargo tree -d cannot be used to gate. This PR adds nothing to it.

5. The test is genuinely non-circular -- CONFIRMED

This was the check most worth doing, and it holds.

crates/dig-node-service/tests/mirror_l1_genesis.rs:218-239 signs with production_wallet() -- the fixture phrase opened under mirror_agg_sig_data(), the production selector, restating nothing -- and verifies with chia_bls::aggregate_verify against pairs from required_bls_signatures(&reclaim_spends(), chia_mainnet_genesis()), where chia_mainnet_genesis() (:159-167) decodes the published hex stated in the test file, independently of the code under test.

So the signature commits to the message built under mirror_agg_sig_data() and is checked against the message built under chia_mainnet_genesis(). Those two messages differ in their trailing 32 bytes whenever the domains differ, which yields different hash-to-curve points, so a signature made under the L2 domain cannot verify against the L1 message. The test fails on the defect by construction -- not as an empirical accident. It is a real reclaim, too: MirrorCoin::from_creating_spend on a real CAT spend, through the production build_reclaim.

Three things that could have made it vacuous, each checked:

  • Empty-set vacuity. chia_bls::aggregate_verify with no pairs succeeds trivially. Guarded at :220-223 with an explicit non-empty assertion.
  • Non-determinism between the two reclaim_spends() calls. tests/support/mod.rs builds fixtures from fixed byte literals ([0x99; 32], salt-varied grandparents) with no RNG, so both calls produce identical spends.
  • A wallet holding no matching key. WalletSigner::sign skips required signatures it has no key for (spend.rs:132-136), so a missing key yields a default signature that fails against non-empty pairs. The test therefore also proves the required set was signed completely, not partially.

The second test (:247-273) and the class guard (:282-299) are useful but are not what carries the proof.

6. required_bls_signatures exposes strictly less than what was already public -- CONFIRMED

crates/dig-wallet/src/sage/spend.rs:315-326 returns (PublicKey, Vec<u8>) from RequiredSignature::Bls, discarding all else. Both components are public by construction -- a BLS public key and the consensus message. No SecretKey, seed, phrase or signature is reachable. It is a strict projection of the already-pub required_signatures (spend.rs:302-307), which returns the full superset. It widens no surface, and it has no production caller at this head.


Non-gating -- one follow-up worth a ticket

LOW / defense-in-depth -- two test fixtures still build an operator wallet under the L2 domain.

crates/dig-node-service/tests/mirror_advertised_urls.rs:165 and crates/dig-node-service/tests/mirror_intra_pass_reservation.rs:161 both call OperatorWallet::open(&paths, dig_constants::DIG_MAINNET.genesis_challenge()) rather than mirror_agg_sig_data().

This is not a live vulnerability and must not gate: both drive MockBroadcaster, never reach a network, and assert domain-independent properties (which coins a bundle spends, which URLs are advertised). They passed before the fix and pass after it, for the same reason -- they do not care about the domain.

The reason to fix it anyway is the shape. mirror_intra_pass_reservation.rs:154 describes its fixture as "A REAL operator wallet", and it is real in every respect except the one this PR is about. The new class-guard test pins mirror_agg_sig_data(), but nothing stops a fixture from reaching for the L2 constant -- so if anyone later adds a signature-validity assertion to either file, it would validate under L2 and quietly re-encode the defect this PR fixes. Pointing both at mirror_agg_sig_data() removes that possibility for about two lines.

INFO, no action needed. The strictly-correct field for an AGG_SIG_ME domain is agg_sig_me_additional_data, not genesis_challenge. They are equal on mainnet and testnet11 by construction, so the chosen field is right today and would only diverge under a custom default_constants(a, b) where the two arguments differ, which nothing here constructs.

On the known items I was told not to re-report

I agree with the recorded severity of the unresolved journal outcome and do not think it is worse than recorded, for one specific reason: it asserts its own ignorance ("the producer ended without recording an outcome") rather than asserting a false outcome. That is the honest-refusal shape, not a money lie, so the section 25.2 record stays true and the section 908 carve-out still rests on something sound. It is incomplete, not wrong. The re-attempt consequence is likewise bounded -- a reclaim targets a specific coin id, so an attempt against an already-spent coin wastes work and cannot spend anything else.

Verdict

PASS. The one-line change is correct in the direction that matters, correct in the direction it could have failed, and the test that guards it would actually catch the regression. Merge preconditions (all required checks green by name, threads resolved, undraft) remain the orchestrator's call, not mine.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 31, 2026 06:32
@MichaelTaylor3d
MichaelTaylor3d merged commit bcb9645 into main Aug 31, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/mirror-l1-genesis branch August 31, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(mirror): mirror spends are signed under the DIG L2 genesis, so every mirror create and reclaim is rejected by Chia

1 participant