Understand/ZK-01 double credit

ZK-01 · merged-mining double credit

A flaw in the pre-fork merge-mining rule let one proof-of-work solution pay for two ZKas blocks. It was closed by the security fork, and it was never used on mainnet. This page states the flaw, explains why using it would leave a permanent public mark, gives the result of a scan of every block ever mined, and ships the scanner so you can repeat that scan yourself.

StatusfixedDAA 6,983,500, 15 Oct 2026
Blocks scanned6,984,673genesis through the fork, bodies included
Traces found0of three independent fingerprints
Supply effectnoneno extra coins exist

The flaw

ZKas is merge-mined with Kaspa. A miner hashes a Kaspa header whose coinbase payload carries ZKMM‖hex(H), where H is a ZKas block hash. The same kHeavyHash solution is tested against both chains, and because the ZKas target is far easier than Kaspa's, a hash that loses on Kaspa routinely wins a ZKas block. That is the intended design: one hash, two chances, one block each.

The pre-fork rule checked that the parent's coinbase committed to the block exactly once and that the parent's hash cleared the ZKas target. It never required the parent to be a Kaspa block. So a miner could:

  1. mine a ZKas block P normally, putting ZKMM‖hex(HQ) in P's own coinbase;
  2. submit a second ZKas block Q that presents P as its merge-mining parent.

Both blocks are accepted and both pay, off a single solution. Reusing one parent for two blocks was never possible — a second commitment anywhere in the payload makes the parent unusable, and editing a coinbase changes the parent's hash and destroys its proof of work — so self-parenting was the only shape this flaw had.

Both halves are now closed. From DAA 6,897,100 a ZKas coinbase carrying any merge-mining tag is invalid outright (RuleError::CoinbaseCarriesMergeMiningCommitment), so the setup block cannot be mined. From DAA 6,983,500 the ZKM1 binding applies: the commitment is bound to the genesis hash and the nonce-free header, the ZKas nonce must equal the parent binding, and a parent carrying a legacy tag is refused. One solution, one block, structurally.

Using it cannot be hidden

The setup block's coinbase payload is not loose metadata. It is inside a transaction, the transaction is inside the block's hash_merkle_root, the root is inside the header, and the header is what the proof of work is computed over and what every later block references by hash.

Three consequences, and they are what make an audit of this flaw decisive rather than circumstantial:

The mark cannot be forged into a block
adding the tag changes the block's hash, which invalidates its proof of work
The mark cannot be removed later
same reason, and every child block commits to the parent hash. A relay cannot strip it in transit
The mark is permanent and public
it is in the coinbase of a block that must be accepted to pay out, so it stays in the chain history forever

Why it was never worth doing

It gives up the Kaspa half of merged mining
the setup block P is a ZKas header, and a ZKas header is not a valid Kaspa block — its parents are ZKas blocks. Every solution spent this way is a solution that can never win a KAS block reward, which is the larger of the two prizes
Difficulty takes the gain back
the extra blocks count as work, so the ZKas difficulty adjustment raises the target difficulty and the chain returns to one block per second. The subsidy is per block, so a sustained attack does not inflate total supply — it dilutes honest miners' share, and raises the difficulty the attacker's own blocks must clear
It announces itself
see above: the tag in a ZKas coinbase is a permanent public marker, visible to anyone scanning the chain, including with the script below

What was checked, and what was found

An archival node holding every block and every block body from genesis was scanned for three fingerprints. They are independent: the first needs only the setup block, the second and third need only the claiming block, so no single omission can hide the attack.

A — a ZKas coinbase carrying ZKMM or ZKM1
What it catchesthe setup block, directly. The whole payload is printed on a hitFound0
B — a merge-mining parent that is one of our own blocks, or whose own parents are
What it catchesthe claiming block, even if the setup block were missing from the archive: the parent header and coinbase are carried inside the claiming block's witnessFound0
C — one merge-mining parent used by two ZKas blocks
What it catchesany reuse of a single solution, whatever its shapeFound0
ScanValue
Headers read6,984,673 — every block from genesis, DAA 0 to 6,983,733, past the fork score
Blocks with a merge-mining witness6,978,482
Blocks mined natively6,191
Headers whose body was missing0 — every coinbase was actually read, nothing was skipped
Distinct merge-mining parents6,978,482 — equal to the witness count, so not one parent was ever reused
Total hits0

The figures above are from the run of 15 October 2026, which covered every block from genesis through the fork itself. Two earlier runs, on 8 and 11 October, over shorter ranges of the same chain, agreed: zero.

Verify it yourself

Do not take this on trust. The scanner is one file with no dependencies beyond the node's own crates: zkmm_scan.rs. Drop it into consensus/examples/ in a zkas-rusty checkout and build it.

It opens the consensus database as a RocksDB secondary: read-only, no lock taken, safe to run against a node that is live. You need an archival node, or the early bodies will be missing and the scan will say so in no_body.

A pruned node cannot run this scan. A default node keeps only the last 108,000 blocks (about 30 hours) and discards everything older, so it holds nothing from before the fork at DAA 6,983,500. It still enforces the new rule on every block it validates, so it would reject any new attempt to use the flaw. It just cannot look back at the old chain. To check that history you need a node that kept every block from before the fork with --archival, or a copy of such a node's database. Switching a pruned node to --archival later does not bring back blocks it already discarded.

cargo build --release -p kaspa-consensus --example zkmm_scan

# RocksDB opens every SST file at once, so raise the limit first
ulimit -n 900000
./target/release/examples/zkmm_scan \
  ~/.rusty-kaspa/zkas-mainnet/datadir/consensus/consensus-001 \
  /tmp/zkmm-secondary

A clean chain ends in a single summary line and nothing else:

SUMMARY headers=6984673 aux=6978482 native=6191 no_body=0 \
        daa_range=0..6983733 distinct_aux_parents=6978482 HITS=0

Any trace of the flaw prints a line of its own before that summary — A-COINBASE-MAGIC, B-AUX-PARENT-IS-ZKAS, B-AUX-PARENT-ZKAS-SHAPED or C-SHARED-AUX-PARENT — naming the block hashes involved, and HITS is non-zero. Check two things in the summary: no_body=0, which means every coinbase was really examined, and distinct_aux_parents equal to aux, which means no solution was used twice.

To check that a node refuses the attack rather than that nobody tried it, the repository also carries a probe that performs it: zkas-api/examples/auxpow_double_credit.rs, in both the legacy and the ZKM1 form. Run it only against an isolated devnet or testnet node of your own. A fixed node accepts one block and rejects the second.

What this proves, and what it does not

It proves that in a complete archive of this chain — every header from genesis to the tip, with every body present — there is no trace of the flaw being used, under three fingerprints that cannot be scrubbed from a block without destroying it. No extra coins were created, and no block was paid twice.

It does not prove anything about other flaws; this scan looks for this one. And it is one archive, read on one machine: a scan is only as good as the copy of the chain it reads. That is the reason the scanner is published rather than only its result — anyone running an archival node can reproduce it independently, and should.

Verified against zkas-rusty at zkas-v1.0.12. If this page contradicts the code, the code is right. Report an error.