Skip to content

feat(collateral)!: remove the two-term mirror morph and its mint path - #39

Merged
MichaelTaylor3d merged 1 commit into
mainfrom
loop/3168-retire-two-term-mirror-morph
Aug 30, 2026
Merged

feat(collateral)!: remove the two-term mirror morph and its mint path#39
MichaelTaylor3d merged 1 commit into
mainfrom
loop/3168-retire-two-term-mirror-morph

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

DRAFT — gate round not yet returned. DO NOT MERGE.

Retires the two-term mirror morph. Part of
https://github.com/DIG-Network/dig_ecosystem/issues/3168 (epic
https://github.com/DIG-Network/dig_ecosystem/issues/3166).

What was wrong

DigCollateralCoin::morph_store_launcher_id_for_mirror derived a mirror hint from
morph(store_launcher_id + epoch) under the tag DIG_STORE_MIRROR_COLLATERAL. dig-mirror-coin
0.7.0 — the canonical owner of mirror collateral — derives one from
morph(store + root + owner + epoch) under that identical tag.

The ticket framed this as two schemes mislabelling themselves. It is worse: they are one
namespace.
Both morphs hash an additive sum, so the extra terms are absorbed rather than
separating them. Measured on computed 32-byte values (dig-collateral-coin,
tests/mirror_namespace_separation.rs, against datalayer-driver 3.0.0 + dig-mirror-coin 0.7.0):

  • a four-term advertisement with a zero root and owner lands byte-identically on the two-term
    hint — differing arity does not separate the schemes;
  • an attacker choosing only the epoch solves e' = store + epoch - store' - root' - owner' and
    lands a coin bonding their own store and root exactly on a victim's two-term hint. Every other
    term there is attacker-chosen and distinct from the victim's, so this is not the zero case
    restated: a fix that stopped the zero reduction would leave it standing.

Two controls in the same file pass, so the harness is attached rather than returning a constant.

Why removal rather than a retag or a four-term rewrite

  • Rewriting this crate to the four-term form would ship the canonical arithmetic without
    MirrorCoin::advertises, the declared-tuple check that is the only thing defeating the
    solved-epoch attack. It would look canonical, genuinely collide with real mirror hints, and be
    silently weaker than the crate it imitates.
  • Retagging keeps a declaration-less scheme minting collateral coins under a new name, and
    silently changes on-chain coin identity for anyone already calling create_mirror. A silent
    behaviour change on a money path is worse than a compile error.
  • Documenting it cannot separate two values that are equal.

A #[deprecated] would reach existing users, which is a genuine argument for it. But this is a
collateral money path with a constructible collision and no declaration check, and a compile error
at upgrade — beside a changelog naming the replacement — is the right volume. Pinned consumers are
untouched until they choose to upgrade.

Blast radius

Established by grep + direct read + the crates.io API; this repo is not present in any local
gitnexus index, so §2.0's sanctioned fallback was used and is stated here rather than implied.

  • In-repo: the only caller of the morph was create_mirror (removed with it). No napi binding, no
    test, and no README reference to either symbol — grep -rn 'create_mirror|morph_store_launcher_id_for_mirror'
    over *.rs/*.ts/*.js/*.md returns only the two definitions and that one call site.
  • crates.io reverse dependents of datalayer-driver: four, all DIG-Network's owndig-wallet
    2.0.0, dig-store-coin 2.1.0, dig-collateral-coin 0.1.4 (all ^3.0.0), digstore-chain 0.29.0
    (^5). No third-party dependent is registered.
  • gh search code --owner DIG-Network morph_store_launcher_id_for_mirror: zero hits outside this
    repo.

Nothing already on chain is stranded. from_coin_state reads the morphed id out of the coin's
memos and never recomputes it, and spend does not use the hint, so mirror coins minted through the
removed path remain readable and spendable.

Version

5.0.06.0.0 in Cargo.toml and napi/package.json (both files, same value). Major: a public
API is removed.

cargo check --lib green; cargo fmt --check green.

`DigCollateralCoin::morph_store_launcher_id_for_mirror` derived a mirror hint
from `morph(store_launcher_id + epoch)` under the tag
`DIG_STORE_MIRROR_COLLATERAL` — the same tag `dig-mirror-coin` uses for
`morph(store + root + owner + epoch)`.

These were not two namespaces sharing a tag. Both morphs hash an additive sum,
so the extra terms are absorbed rather than separating the two: measured on
computed values, a four-term advertisement with a zero root and owner lands
byte-identically on the two-term hint, and an attacker choosing only the epoch
can solve `e' = store + epoch - store' - root' - owner'` to land a coin bonding
their own store and root exactly on a victim's hint.

`dig-mirror-coin` closes that by having the coin declare its four terms and
checking the declaration as well as recomputing the hint. The two-term form had
no equivalent check and could not gain one, since the epoch a coin was really
built with is not recoverable from its hint. Rather than ship a weaker rival in
a namespace another crate owns, the morph and its `create_mirror` mint path are
removed; mirror collateral now has a single producer.

Nothing already on chain is stranded: `from_coin_state` reads the morphed id out
of memos and never recomputes it, and `spend` does not use the hint.

BREAKING CHANGE: `DigCollateralCoin::morph_store_launcher_id_for_mirror` and
`DigCollateralCoin::create_mirror` are removed. Use the `dig-mirror-coin` crate.

Refs: DIG-Network/dig_ecosystem#3168

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

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Gate review at head f46ca1dd.

1. The collision is independently REPRODUCED (not taken on trust)

I did not read the argument and agree with it. I reimplemented the CLVM tree hash from scratch in
Python — atom(b) = sha256(0x01||b), pair(l,r) = sha256(0x02||l||r), minimal two's-complement
big-endian atom encoding — and computed the values. With store = [0x11;32], tag
DIG_STORE_MIRROR_COLLATERAL:

  • two-term morph(store + 7) = 0819e926f8825e14e6e5d1b58f78cf88b8d4c595e0f899562e22baf27e32aa05
  • four-term morph(store + 0 + 0 + 7) = identical
  • attacker four-term with store'=[0xAA;32], root'=[0xBB;32], owner'=[0xCC;32] and
    e' = store + 7 - store' - root' - owner' = identical
  • controls: epoch 8 differs; tag DIG_STORE_COLLATERAL differs.

The equality is in fact encoding-independent: both derivations are the same function applied to a
scalar sum, so equal sums give equal bytes whatever the atom encoding. The lane's reframing —
one namespace, not two schemes sharing a tag — holds, and so does the corollary that the ticket's
"it fails closed today" is false for an adversary who chooses the epoch.

2. The transcription pin is real and it CONSTRAINS

tests/mirror_namespace_separation.rs:the_transcribed_retired_morph_reproduces_the_removed_upstream_function
pins OBSERVED_UPSTREAM for morph([0x11;32], 42). My independent Python reimplementation produces
byte-for-byte the same 32 values. So the transcription is checked against something other than
itself — by two independent implementations now, not one.

All seven assertions are on computed 32-byte values; none asserts a symbol's presence. The two
controls discriminate (the_canonical_morph_separates_two_epochs..., and the assert_ne! inside
the solved-epoch test that fails if the solve is not what produces the collision).

Still to check: deletion blast radius, on-chain spendability of already-minted coins, the manual
publish path.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: f46ca1dd498bd156cd0fc0dbb3ecd983966f9521 (resolved from gh pr view 39 --json headRefOid), base a53426dac3ec041b5a4ad45a13b7a4bde1fad1e2. Read from an isolated clone; no shared checkout touched.

Posting findings as they resolve so they survive an interruption.

1. Money path: existing mirror coins stay readable AND spendable — CONFIRMED

The highest-severity question was whether deleting the derivation strands funds already on chain. It does not.

  • src/dig_collateral_coin.rs:132-136from_coin_state takes the morphed id from memos_vec[0] and never recomputes it. Instantiation of an existing mirror-collateral coin is unaffected by the removal.
  • src/dig_collateral_coin.rs:187-237spend uses only self.inner, the synthetic_key, and proof.parent_inner_puzzle_hash. It never touches a hint or a morph. The reclaim path is intact.
  • The custody check at src/dig_collateral_coin.rs:196 ("Collateral coin controlled by another wallet") is unchanged.

So the CHANGELOG's claim holds for spend and read.

One narrowing the PR does not state, and should. Spendability is preserved given a CoinState. Discovery is not: the canonical way to find your mirror coin is to derive the hint and query by it, and that derivation is the thing being deleted. An operator holding an unspent mirror coin can still recover it (the coin id is in wallet history, the memo carries the morphed id, and the two-term formula is reproduced verbatim in both the CHANGELOG and the doc comment), so this is not fund loss — but "remain readable and spendable" is doing quiet work and a one-line migration note on discovery would make it honest.

2. Removal is clean — no dangling references, no build break

grep -rn -E 'create_mirror|createMirror|morph_store_launcher_id_for_mirror|morphStoreLauncherIdForMirror|MIRROR_COLLATERAL' napi/ src/ tests/ returns only the two doc-comment lines at src/dig_collateral_coin.rs:22-23. Nothing else in the repo referenced the removed symbols.

3. Blast radius is smaller than the major bump implies: the removed API was Rust-only

DigCollateralCoin has no napi binding at allgrep -i collateral napi/src/*.rs and napi/index.d.ts both return nothing. So create_mirror was never reachable from @dignetwork/datalayer-driver, and the npm 6.0.0 major carries no JS API change. The breaking surface is the Rust crate only.

4. No secrets introduced

The diff adds no key, seed, credential or test vector — it is a deletion plus a doc comment plus three version bumps. Nothing added logs or prints key material.

Still open: the class sweep for other additive morphs, whether advertises is bypassable on a hint-only read path, the DIG_STORE_COLLATERAL disjointness question, and the disclosure question on the vulnerable published versions.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS (2/2), not the verdict

Head f46ca1dd498bd156cd0fc0dbb3ecd983966f9521. Continuing from the previous comment.

5. The four-term form does NOT defeat the solve — arity was never the defence. CONFIRMED

The PR's rationale is correct and, importantly, it does not overclaim. dig-mirror-coin 0.7.0 asserts the solve against its own four-term form in src/namespace.rs:198, a_freely_chosen_epoch_solves_onto_any_other_advertisements_hint, and src/namespace.rs:167 asserts the four-term aliasing directly. So widening the morph widened the aliasing rather than removing it, and the crate says so in its own doc (namespace.rs:60-75).

What actually stops it is MirrorCoin::advertises, and I probed it for a bypass rather than taking the claim:

  • src/coin.rs:160-167 makes both comparisons — declared tuple equality and the recompute — joined by &&. Neither is reachable alone.
  • The recompute's owner comes from self.owner_puzzle_hash(), which is set at src/coin.rs:325 from inner.proof.parent_inner_puzzle_hash — the lineage proof, i.e. executed on-chain code, not a memo. Not forgeable by the coin's author.
  • self.namespace_hint is entries[0] (src/coin.rs:470-476), i.e. memos[0], which is the value the Chia hint index actually keys on (layout documented at coin.rs:445). So it is the coin's real bucket, not a free-floating memo field.

That last point is what makes check 2 load-bearing, and I tried to break it. The attack would be: place a coin in a victim's bucket, declare the victim's tuple in memos, and write a matching namespace_hint. It fails, because the two requirements are contradictory — to be returned the coin's memos[0] must equal mirror_hint(store, root, VICTIM_owner, epoch), while to pass check 2 that same memos[0] must equal mirror_hint(store, root, ATTACKER_owner, epoch). One field cannot be both. The owner is bound.

No read path accepts a coin on hint match alone. discover gates every candidate on advertises at src/query.rs:373 and drops the rest into rejected; list never consults a hint at all and keys on the lineage-proof owner at src/query.rs:291.

6. Question 4 — the surviving DIG_STORE_COLLATERAL namespace. It does NOT have the defect, and not for the epoch-0 reason

Answering this explicitly because the brief flagged it as a possible vacuous guarantee.

First, a misattribution worth correcting on the record: there is no epoch-0 test in this PR. There are no tests in this PR at all. The diff is five files — CHANGELOG, Cargo.lock, Cargo.toml, napi/package.json, src/dig_collateral_coin.rs — and src/dig_collateral_coin.rs has no #[cfg(test)] module. The epoch-0 test lives in dig-mirror-coin/src/namespace.rs:110-119 and is already released in 0.7.0. So it is not this PR's evidence and cannot be this PR's under-fix.

Second, the surviving morph has no freely-chosen term at any epoch. src/dig_collateral_coin.rs:57-61:

pub fn morph_store_launcher_id_for_collateral(store_launcher_id: Bytes32) -> Bytes32 {
    (store_launcher_id, "DIG_STORE_COLLATERAL").tree_hash().into()
}

One argument. No offset, no BigInt, no addition — the num_bigint::BigInt import was deleted from the file precisely because nothing left in it does arithmetic. There is no e' to solve for, so the attack is structurally inapplicable rather than merely harder. And because tree_hash is injective on its input, landing on a victim's collateral hint requires the same launcher id, i.e. the same store: collateral hints are 1:1 with stores. Disjointness from the mirror namespace holds for arbitrary epochs, by tag separation in the tree-hash preimage — the two tag atoms differ, so a cross-namespace collision is a SHA-256 second preimage.

So the PR does not under-fix, and the answer to question 4 is no.

7. But the brief's instinct about that test was right — it is weak, in dig-mirror-coin, not here

dig-mirror-coin/src/namespace.rs:110-119 is named mirror_namespace_is_disjoint_from_the_store_collateral_namespace and passes for the wrong reason. Mutate MIRROR_NAMESPACE to the literal "DIG_STORE_COLLATERAL" — collapsing the two namespaces into one, the exact defect the test is named for — and it still goes green, because the two hashes continue to differ in their first term (store vs store + root + owner). The assertion is carried by first-term inequality, never by the tag.

Its comment is also stale in a way that shows where it came from: "Even at epoch zero — where the offset leaves the launcher id untouched". At epoch zero the four-term sum is store + root + owner, so the launcher id is emphatically not untouched. That sentence describes the two-term ancestor this PR is deleting.

A test that would actually bind the property holds the first term fixed and varies only the tag: (x, MIRROR_NAMESPACE).tree_hash() != (x, STORE_COLLATERAL_NAMESPACE).tree_hash().

This is defense-in-depth in a different, already-released repo. I am not gating PR #39 on it — the property it fails to guard is independently true, and nothing in this diff touches it. Recommend a follow-up ticket against dig-mirror-coin.

@MichaelTaylor3d MichaelTaylor3d left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PASS

Head reviewed: f46ca1dd498bd156cd0fc0dbb3ecd983966f9521 (resolved from the remote, not from the dispatch brief). Fresh context; I verified every claim rather than accepting the lane's account.

I independently reproduced the collision. It is real.

This is the fact the whole PR turns on, so I did not read the argument and agree with it. I reimplemented the CLVM tree hash from scratch in Python — atom(b)=sha256(0x01||b), pair(l,r)=sha256(0x02||l||r), minimal two's-complement big-endian atom encoding — and computed values with no DIG code in the path. With store=[0x11;32] under tag DIG_STORE_MIRROR_COLLATERAL:

derivation 32 bytes
two-term morph(store + 7) 0819e926…7e32aa05
four-term morph(store + 0 + 0 + 7) 0819e926…7e32aa05identical
attacker four-term, store'=[0xAA;32] root'=[0xBB;32] owner'=[0xCC;32], e' = store+7-store'-root'-owner' 0819e926…7e32aa05identical
control: epoch 8 68f97104… differs
control: tag DIG_STORE_COLLATERAL d901bebc… differs

The equality is stronger than a computation: both derivations are the same function of a scalar sum (dig-mirror-coin/src/namespace.rs:82-87 vs the removed morph_store_launcher_id_for_mirror), so equal sums give equal bytes under any atom encoding. The lane's reframing is correct and the ticket's framing was not: these are one namespace, not two schemes sharing a label, and the ticket's "it fails closed today" is false against an adversary who chooses the epoch. A remedy selected from the ticket's framing — a retag, or a four-term rewrite — would have been the wrong remedy.

The tests are load-bearing, and the transcription pin genuinely constrains

dig-collateral-coin/tests/mirror_namespace_separation.rs: all seven assertions compare computed 32-byte values; none asserts a symbol's presence. Two controls discriminate — the_canonical_morph_separates_two_epochs_of_one_advertisement, and the assert_ne! inside the solved-epoch test that fails if the collision arrives for any reason other than the solve.

The transcription risk (a self-consistent fiction) is closed by the_transcribed_retired_morph_reproduces_the_removed_upstream_function, which pins the bytes observed from the real datalayer_driver 3.0.0 function. My independent Python implementation reproduces that pinned constant byte for byte — so the transcription is now checked against two implementations neither of which is itself.

Nothing on chain is stranded — checked in the code, not the doc comment

spend (src/dig_collateral_coin.rs:187) uses only the inner P2ParentCoin, its lineage proof, the synthetic key and the fee coins; the morphed id is absent from it. from_coin_state (:132) parses the morphed id out of memos_vec[0] and never recomputes or compares it. Rediscovery-by-hint also survives: dig_mirror_coin::mirror_hint(store, [0;32], [0;32], epoch) reproduces the retired hint byte-identically — the same collision that justified the removal guarantees no capability is lost. (Inline comment asks for that sentence in the CHANGELOG.)

Deletion over #[deprecated] — the judgement holds

All four crates.io reverse dependents (dig-wallet 2.0.0, dig-store-coin 2.1.0, dig-collateral-coin 0.1.4 at ^3.0.0; digstore-chain 0.29.0 at ^5) are semver-incompatible with 6.0.0, so a deprecation would warn nobody who is not already choosing to upgrade, while leaving a colliding money-path derivation callable. Compile error plus a changelog naming dig_mirror_coin::mirror_hint / ::create is the right volume.

Version and CHANGELOG

5.0.06.0.0 in Cargo.toml, Cargo.lock and napi/package.json, same value in all three; correct for a public-API removal. The CHANGELOG states the break and names the replacement. Checks: 18 SUCCESS, 2 SKIPPED, 0 failing.

Three non-gating notes (posted inline; I am resolving all three so none bars the merge)

  1. CHANGELOG.md:33 — an unchanged item listed under ### Removed (BREAKING).
  2. src/dig_collateral_coin.rs:37 — add the rediscovery sentence.
  3. CHANGELOG.md:27 — the PR body's "zero code hits" cites an instrument that also returns zero for a symbol known to exist; the conclusion is right but the cited evidence is not what establishes it.

Two conditions the orchestrator needs, neither caused by this PR

  • main is NOT branch-protected on this reporepos/DIG-Network/DataLayer-Driver/branches/main/protection returns 404 Branch not protected. check-merge-preconditions.sh therefore exits 2 (UNREADABLE), which is not a merge verdict. §3.6b requires protection on every submodule but dig-browser. Pre-existing; worth its own ticket.
  • Publish is manual — the repo has only CI.yml, no release workflow and no cliff.toml. Nothing in this PR assumes an automatic release (I checked the body and the diff). Merging does not put 6.0.0 on crates.io; latest published is still 5.0.0, and dig-collateral-coin PR#5 stays blocked until someone publishes by hand.

Blast radius established by grep + direct read + the crates.io API — this repo is in no local gitnexus index. The PR body states that fallback explicitly rather than implying an index was used, which is correct per §2.0.

Verdict: PASS. The premise is reproduced, the remedy follows from it, the money path is safe, and the tests would fail if the code were wrong.

Comment thread CHANGELOG.md
Comment thread src/dig_collateral_coin.rs
Comment thread CHANGELOG.md
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: f46ca1dd498bd156cd0fc0dbb3ecd983966f9521 (resolved from gh pr view --json headRefOid, base a53426da). Read-only; no checkout mutated. Posting findings as they resolve so nothing is lost to a cap — a previous audit of this PR died mid-verdict.


Q2 (asked first) — the surviving DIG_STORE_COLLATERAL namespace does NOT under-fix

The premise of the question does not hold, and the real answer is stronger than the one it was looking for.

There is no epoch-0 disjointness assertion in this PR. The diff is five files (CHANGELOG.md, Cargo.lock, Cargo.toml, napi/package.json, src/dig_collateral_coin.rs 21+/48-) and contains no test at all. The PR body has zero hits for epoch 0, disjoint or DIG_STORE_COLLATERAL. The dig-collateral-coin/tests/mirror_namespace_separation.rs cited in review thread 3 is in a different repository, and is not on that repo's main either — main there carries only tests/conformance.rs.

So there is no vacuous epoch-0 proof to find. The surviving namespace cannot carry the defect, for a structural reason rather than an empirical one:

src/dig_collateral_coin.rs:57-61 at head —

pub fn morph_store_launcher_id_for_collateral(store_launcher_id: Bytes32) -> Bytes32 {
    (store_launcher_id, "DIG_STORE_COLLATERAL")
        .tree_hash()
        .into()
}

It takes exactly one argument. There is no offset, no epoch, no caller-chosen term — and there has not been one since at least v3.0.0 (v3.0.0:src/dig_collateral_coin.rs:37, verified against the tag). The additive structure that made the mirror morph solvable — morph(a+b+c+d), where an author controlling one term absorbs the others — does not exist here, because there is no sum.

Disjointness from the mirror namespace is therefore general over every epoch, and it does not derive from the offset at all. It derives from the tag's fixed position in the tree hash. Both morphs are two-element pairs whose right element is the tag atom:

  • collateral: sha256(0x02 || sha256(0x01||store32) || sha256(0x01||"DIG_STORE_COLLATERAL"))
  • mirror: sha256(0x02 || sha256(0x01||sum) || sha256(0x01||"DIG_STORE_MIRROR_COLLATERAL"))

The trailing 32 bytes of the sha256 preimage differ for every input pair, because the tag atoms are hashed independently of anything a caller supplies. A cross-namespace collision needs a genuine sha256 collision, not an arithmetic solve — at arbitrary epoch, arbitrary store, arbitrary root, arbitrary owner. There is no distinguished point at which it is easier.

A second, independent separator: the left atoms use different encodings. Bytes32 serialises as a fixed 32-byte atom with leading zeros preserved; the mirror morph's BigInt sum serialises as a minimal signed big-endian atom. Even under a hypothetically shared tag the two would align only when the sum were exactly 32 bytes with the high bit clear.

Q2 answer: the surviving namespace is not proved disjoint "at epoch 0" — it has no epoch to prove anything at. This PR does not under-fix on that axis.


Q1 (partial) — the in-repo class sweep is clean; the class survives OUTSIDE this repo

In this repo, at this head: exactly one other additive morph exists — src/xch_server_coin.rs:84 morph_launcher_id(launcher_id, offset: &BigInt), re-exported to JS at napi/src/napi_lib.rs:1187. It is additive with a caller-chosen offset, but it is namespace-free: it applies no tag and returns raw morphed bytes, so it never enters the DIG_STORE_* namespaces and cannot collide into them. It is the upstream Chia DataLayer server-coin derivation, present at base and unchanged by this diff. Its inherent non-injectivity — (L, o) and (L+k, o-k) map to the same id — is upstream's design, not this PR's regression.

.tree_hash() call sites at head: 6 total, and only one (:59) is a namespace morph. Namespace tag literals at head: exactly one, "DIG_STORE_COLLATERAL" (:58). DIG_STORE_MIRROR_COLLATERAL no longer appears in any code path — only in the doc comment and CHANGELOG explaining the removal. The removal is complete within the repo.

But the class is not removed from the ecosystem, and that is the finding I am still sizing:

DIG-Network/dig-collateral-coin (public, crates.io 0.1.4, pushed today) has a src/lib.rs that is five lines and nothing but a re-export:

pub use datalayer_driver::{
    Bytes32, CoinState, DigCoin, DigCollateralCoin, Peer, PublicKey, SpendBundle, connect_random,
    get_fee_estimate, sign_coin_spends, wallet::broadcast_spend_bundle,
};

with datalayer-driver = "3.0.0". I verified against the v3.0.0 tag that 3.x still carries both retired methods — morph_store_launcher_id_for_mirror (v3.0.0:src/dig_collateral_coin.rs:44) and create_mirror (:178). Because they are public inherent methods on the re-exported DigCollateralCoin type, dig-collateral-coin re-exports the entire vulnerable mint path transitively, and its ^3.0.0 requirement is semver-incompatible with 6.0.0 — so merging this PR does not remove that path from the wild, and no future 6.x release will reach it.

crates.io, verified directly: datalayer-driver has 11,234 downloads across 8 versions, none yanked, and 3.0.0 alone accounts for 8,499 of them — the vulnerable line is the most-downloaded one by a wide margin.

Still open: Q3 (does MirrorCoin::advertises carry the load, and does any read path accept on hint match alone), Q4/Q5 (downgrade + disclosure, now materially sharper given the above), Q6 (secrets — noting the diff adds no test file, so there is no new fixture to check).

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS (2/2), not the verdict

Head re-confirmed f46ca1dd498bd156cd0fc0dbb3ecd983966f9521 — unmoved since my first comment. Checks at this head: 18 SUCCESS, 2 SKIPPED, 0 failing.


Q3 — CONFIRMED: the arity does not defeat the solve; advertises does. And no read path accepts on hint match alone.

The four-term form does not fix the arithmetic, and dig-mirror-coin says so itself. dig-mirror-coin/src/namespace.rs:198 a_freely_chosen_epoch_solves_onto_any_other_advertisements_hint asserts the solve within the four-term form: given a victim tuple, the attacker sets e' = store+root+owner+epoch − store' − root' − owner' and assert_eq! shows their own advertisement lands exactly on the victim's hint. :167 the_offset_is_arithmetic_so_hints_alias_across_all_four_terms asserts the aliasing directly. Widening two terms to four widened the aliasing rather than removing it — the crate documents this at namespace.rs:52-75 as "this value is an index, never evidence".

So the load is carried entirely by the declaration check. dig-mirror-coin/src/coin.rs:148-168:

self.declared == asked
    && self.namespace_hint
        == mirror_hint(store_launcher_id, root_hash, self.owner_puzzle_hash(), epoch)

Two non-redundant comparisons, and the owner in the recompute is taken from self.owner_puzzle_hash() — derived from the coin's lineage proof / executed CREATE_COIN, not from a memo — so a caller holding only a coin id and a tuple can close the loop without trusting whoever handed the coin over.

Every read path, checked:

path keyed on hint-only?
discover (query.rs:351) computes hint at :358, fetches unspent_coins_by_hint at :360, then filters through mirror.advertises(...) at :373 before pushing at :375 no
list (query.rs:269) mirror.owner_puzzle_hash() == owner_puzzle_hash at :291 — the lineage proof; never touches the hint no
census C8 (census.rs:723) coin.advertises(coin.store_launcher_id(), coin.root_hash(), coin.epoch()) — self-consistency: does the coin's own declaration reproduce its own hint no

The one consumer in the ecosystem is dig-node, via dig_mirror_coin::census (dig-node-service/src/collateral_census.rs:42). It does not call discover/list in non-test code and never reads namespace_hint() to make a trust decision. I found no path anywhere that accepts a coin on hint match without the declared-tuple equality.


Q6 — secrets: clean

The diff adds 56 lines across 5 files and adds no test file at all (the brief anticipated one; there is none). Scanning the added lines for key/seed/secret/token/mnemonic/PEM material and for 32+ byte hex or 40+ char base64 runs returns zero hits. The change is a deletion plus doc comments plus three version bumps. Nothing added logs anything, key material or otherwise.


Correction to the dispatch brief — merging can publish, and the stated reason was wrong

The brief says "Publish is manual — only CI.yml, no release workflow, no cliff.toml. Merging does not publish 6.0.0." The conclusion is right; the reason is not, and the difference matters operationally.

CI.yml does contain an automated publish path — two jobs, publish-npm (:269) and publish-crates (:325), both gated if: github.event_name == 'push' && github.ref == 'refs/heads/main'. They are the two SKIPPED checks on this PR. What actually withholds the publish is a second, inner gate: the job body requires the last commit message to match ^[0-9]\+\.[0-9]\+\.[0-9]\+$ — a bare version and nothing else (:339, :357, :312).

This PR's squash subject will be feat(collateral)!: remove the two-term mirror morph and its mint path, which does not match, so the merge itself publishes nothing. But releasing 6.0.0 is not a separate workflow dispatch — it is one push to main with the commit message 6.0.0. Whoever makes the yank/advisory call in Q4 should know the release trigger is that cheap, and that it is a push: main event rather than a reviewable dispatch.

NON-GATING, pre-existing, but on the path this version will take — CI.yml:373 publishes with cargo publish --no-verify --allow-dirty, immediately after a step that runs cargo update --dry-run || true and git commits any Cargo.lock change (:343-351). --no-verify skips building the packaged crate and --allow-dirty publishes uncommitted working-tree state, so the crates.io artifact is not guaranteed to correspond to the merged tree and is never verified to build from the package. That is the same shape recorded for chia-l2-consensus, where an --allow-dirty publish path let a published crate carry a different consensus rule than its source. It is worth a follow-up ticket precisely because the point of 6.0.0 is that a path is absent: a publish that can carry dirty state weakens the guarantee that the released 6.0.0 actually lacks it. Also permissions: contents: write, id-token: write (:6-8) is workflow-scoped rather than job-scoped, so every build job inherits write + OIDC.

Remaining: Q4 (downgrade exposure, reverse dependents) and Q5 (disclosure), then the verdict.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — VERDICT: PASS

Audited head: f46ca1dd498bd156cd0fc0dbb3ecd983966f9521 (resolved via gh pr view --json headRefOid, re-confirmed unmoved at the end of the audit; base a53426dac3ec041b5a4ad45a13b7a4bde1fad1e2). Read-only throughout — no checkout mutated, nothing edited, nothing merged, PR left in DRAFT.

Detail for each question is in my two interim comments above. This is the verdict.


Answering Q2 first: the surviving namespace does not under-fix

The premise did not hold, and the real answer is stronger than the one the question was hunting for.

There is no epoch-0 disjointness assertion in this PR. The diff is five files and contains no test at all; the PR body has zero hits for epoch 0, disjoint or DIG_STORE_COLLATERAL. The test that exists lives in a different repositorydig-mirror-coin/src/namespace.rs:110-119, mirror_namespace_is_disjoint_from_the_store_collateral_namespace — and it is indeed a single-point test at epoch 0.

But the property it under-tests is structurally general, so the weak test is a test-quality issue rather than a vacuous guarantee. Two independent reasons:

  1. The surviving morph has no caller-chosen term at all. src/dig_collateral_coin.rs:57-61 is (store_launcher_id, "DIG_STORE_COLLATERAL").tree_hash() — one argument, no offset, no epoch, and none since at least v3.0.0:src/dig_collateral_coin.rs:37. The additive structure that made the mirror morph solvable does not exist here because there is no sum. There is no epoch at which to prove anything, and nothing for an attacker to solve for.
  2. Separation comes from the tag, not the offset. Both morphs are two-element pairs whose right element is the tag atom, hashed independently of any caller input: sha256(0x02 | sha256(0x01|left) | sha256(0x01|tag)). The trailing 32 bytes of the preimage differ for every input pair, so a cross-namespace collision needs a real sha256 collision, not arithmetic — at arbitrary epoch, store, root and owner.

A third, incidental separator: the left atoms use different encodings (Bytes32 gives a fixed 32-byte atom with leading zeros; the mirror sum gives a minimal signed big-endian BigInt).

Conclusion: this PR fixes the class within its own repo, not merely the instance. The one namespace it retains cannot carry the defect by construction.

Non-gating, different repo: the comment at dig-mirror-coin/src/namespace.rs:112-114 justifies epoch 0 as "where the offset leaves the launcher id untouched". That was true of the two-term morph; it is false of the four-term one, where root and owner are non-zero at epoch 0. A stale rationale that makes a weak test read as a strong one — worth correcting where it lives.


Findings, ranked

1. NON-GATING (but the most consequential fact here) — the vulnerable path stays live on crates.io, re-exported by a maintained DIG crate

DIG-Network/dig-collateral-coin (public, crates.io 0.1.4) has a src/lib.rs that is five lines and nothing but a re-export of DigCollateralCoin and friends from datalayer_driver, with datalayer-driver = "3.0.0" — verified on its main and against the published 0.1.4 manifest on crates.io. Because morph_store_launcher_id_for_mirror (v3.0.0:src/dig_collateral_coin.rs:44) and create_mirror (:178) are public inherent methods on the re-exported DigCollateralCoin type, that crate re-exports the entire retired mint path transitively. Its ^3.0.0 requirement is semver-incompatible with 6.0.0, so no 6.x release will ever reach it.

crates.io, verified directly: datalayer-driver has 8 versions, none yanked, 11,234 downloads, of which 3.0.0 alone is 8,499 — the vulnerable line is the most-downloaded by a wide margin. Four reverse dependents, confirmed against the API rather than taken from review:

crate version requires downloads
dig-wallet 2.0.0 ^3.0.0 287
dig-store-coin 2.1.0 ^3.0.0 31
dig-collateral-coin 0.1.4 ^3.0.0 20
digstore-chain 0.29.0 ^5 11

This does not gate the PR, and gating on it would be actively wrong — removing the path is strictly an improvement, and blocking the removal would leave it present in 6.0.0 as well. It is a follow-up family: migrate the four dependents, then decide on the registry action.

2. NON-GATING — what actually remains exploitable in the wild (Q4), stated plainly

An attacker with 3.x/4.x/5.x can call create_mirror with a freely-solved epoch and land a coin in a victim's mirror hint bucket. What that buys them is bounded, because the acceptance check is not in this crate. dig-mirror-coin::MirrorCoin::advertises (coin.rs:148-168) requires the coin's declared four-term tuple to equal the tuple asked about, and the hint to recompute using the owner taken from the coin's lineage proof. A two-term coin minted by the retired path carries memos [hint, url...], and parse_memos (coin.rs:470-481) requires a leading chunk of 4 entries with entries 1 and 2 parsing as Bytes32 — so it returns Ok(None) and the coin is never accepted as a mirror.

So the residual harm is not attacker capability against the census; it is honest-user money visibility: anyone who mints mirror collateral through 3.x today produces coins that dig-mirror-coin 0.7 will not accept, staking $DIG that the census cannot count. That is the strongest argument for migrating the four dependents, and it is a better argument than the attack is.

On yank vs advisory — the facts, since the call is the user's. A yank of 3.0.0 would break fresh builds of the three ^3 dependents while doing nothing about the harm above (the defence already lives in advertises, deployed and correct). My recommendation is therefore advisory + coordinated migration, not a yank, with dig-collateral-coin first since it is a pure re-export and the cheapest to move — its local checkout already declares dig-mirror-coin = "0.7", so that work is started but unpushed.

3. NON-GATING — Q5, disclosure: do not narrow the PR description

The repo is public and the PR describes the attack in detail while vulnerable versions stay published, so the question is fair. My answer is that narrowing buys nothing real:

  • The identical attack is already public in more operational detail, with a working solver, in dig-mirror-coin's published source — namespace.rs:60-75 states the solve formula in prose and namespace.rs:198 ships it as an executable unit test (a_freely_chosen_epoch_solves_onto_any_other_advertisements_hint). That crate is on crates.io; an attacker can cargo add it and run the proof.
  • The canonical owner documents the property as intrinsic and permanent to the construction ("this value is an index, never evidence"), not as a secret.
  • Redaction would have to extend to the CHANGELOG, which ships inside the published crate — leaving 6.0.0 a breaking removal with no stated reason, which actively harms the four downstreams who must migrate.

Redacting a description whose contents are already executable in a sibling public crate would trade a real migration signal for no confidentiality.

4. NON-GATING, pre-existing — the publish path uses --no-verify --allow-dirty

Correcting the dispatch brief on a load-bearing point: it states "Publish is manual — only CI.yml, no release workflow. Merging does not publish 6.0.0." The conclusion is right, the reason is not. CI.yml does carry an automated publish path — publish-npm (:269) and publish-crates (:325), both gated on a push to main (they are the two SKIPPED checks here). What withholds the publish is an inner gate: the last commit message must be a bare MAJOR.MINOR.PATCH and nothing else (:339, :357). This PR's squash subject will not match, so the merge publishes nothing — but releasing 6.0.0 is one push to main with the commit message 6.0.0, not a reviewable dispatch.

On that path, CI.yml:373 runs cargo publish --no-verify --allow-dirty, right after a step that runs cargo update --dry-run || true and commits any Cargo.lock change (:343-351). --no-verify skips building the packaged crate; --allow-dirty publishes uncommitted working-tree state. The artifact is therefore not guaranteed to correspond to the merged tree and is never verified to build from the package — the same shape recorded for chia-l2-consensus, where an --allow-dirty publish path let a published crate carry a different consensus rule than its source. It deserves a ticket precisely because 6.0.0's whole value is that a path is absent: a publish that can carry dirty state weakens the only guarantee this release makes. Also permissions: contents: write, id-token: write (:6-8) is workflow-scoped, so every build job inherits write + OIDC.

5. NON-GATING, pre-existing, out of diff — xch_server_coin::morph_launcher_id

src/xch_server_coin.rs:84 is the only other additive morph, with a caller-chosen BigInt offset, re-exported to JS at napi/src/napi_lib.rs:1187. It is namespace-free — no tag, raw morphed bytes — so it never enters the DIG_STORE_* namespaces and cannot collide into them. It is the upstream Chia DataLayer derivation, present at base and unchanged. Its non-injectivity ((L,o) and (L+k,o-k) map to the same id) is upstream's design. Worth one note: :89-91 returns Bytes32::default()all zeros, a valid-looking id — when the sum exceeds 32 bytes, a silent sentinel rather than an error.


Areas checked, and why each is clear

area verdict
Class fix vs instance fix (Q1) Clear in-repo. DIG_STORE_MIRROR_COLLATERAL appears in no code path at head — only in the doc comment and CHANGELOG. One namespace tag literal remains (:58), one namespace morph among 6 .tree_hash() sites, and it takes no caller input. Residual is outside the repo (finding 1).
Surviving namespace disjointness (Q2) Clear, and general over all epochs — by tag position, not by offset. Not a vacuous epoch-0 guarantee.
Four-term form vs the solve (Q3) Confirmed as suspected: arity does not defeat it (namespace.rs:198 asserts the solve within the four-term form). advertises carries the load, with the owner from the lineage proof.
Read paths accepting on hint alone (Q3) None found. discover (query.rs:358 then :360 then :373) filters through advertises after the hint fetch; list (:291) is owner-keyed off the lineage proof and never touches the hint; census C8 (census.rs:723) checks self-consistency. Sole consumer dig-node uses census only and never reads namespace_hint() for a trust decision.
Custody / money paths Untouched. Independently spot-confirmed: morphed_store_id appears nowhere in spend; from_coin_state (:132-136) parses it from memos_vec[0] and never recomputes. No signing, key handling, or privilege change in the diff.
Secrets (Q6) Clean. 56 added lines, no test file added (the brief anticipated one — there is none), zero hits for key/seed/secret/token/mnemonic/PEM material or 32-byte-hex / 40-char-base64 runs. Nothing added logs anything.
Dependencies No dependency added, removed or loosened. The only Cargo.lock change is this crate's own 5.0.0 to 6.0.0. The num_bigint import is dropped in step with its last use.
Versioning Consistent at 6.0.0 across Cargo.toml, Cargo.lock and napi/package.json. Major is correct — two public methods removed.
CI 18 SUCCESS, 2 SKIPPED (the two publish jobs), 0 failing at f46ca1dd.

On merge preconditions: main on this repo is not branch-protected (/branches/main/protection returns 404), so check-merge-preconditions.sh exits 2 = UNREADABLE, which is not a merge verdict either way. I therefore asserted the checks by name from the rollup rather than relying on that script, and I am not representing its exit as a green. The unprotected main is itself worth a ticket — it means the required-checks and thread-resolution gates are not mechanically enforced on this repo.


Verdict

PASS. No security defect in this diff. It removes a genuine collision primitive, retains a namespace that cannot carry the same defect, touches no money path, adds no secret and no dependency, and is correctly versioned as a major.

The residual risk is entirely outside this PR — 3.x remains published and re-exported by dig-collateral-coin — and it is reduced, not created, by merging. Findings 1-4 belong on dig_ecosystem#3168 as follow-ups; none of them should hold this merge.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 30, 2026 01:53
@MichaelTaylor3d
MichaelTaylor3d merged commit bf5abaa into main Aug 30, 2026
20 checks passed
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.

1 participant