# Merkle note position alias postmortem **Status:** Draft technical findings. The fix was implemented and locally tested on 2026-10-28. Historical transaction attribution remains open. ## Summary Iron Fish used a note commitment tree that filled an absent right branch by copying its populated left branch. A spend proof authenticates a note against a public tree root, but keeps the note's tree position private. The same private position determines the note's public nullifier, which is supposed to identify a spent note uniquely. At a copied branch, changing one position bit still produces the same root. A holder of a note's spending authority can therefore prove one inserted output at a position with no separately funded output and derive a different nullifier. The nullifier set then fails to recognize the second spend as a repeat. The fix replaces copied branches with a fixed empty value throughout tree root and witness construction. It changes consensus roots, so it is intended **only for a new chain starting with a new genesis**. It does not make the existing mainnet or testnet history compatible with the new rules. The spend circuit and its proving parameters are unchanged; the fix restores position binding for each inserted leaf that the circuit assumes. ## Intended invariant For each accepted note commitment root, a leaf insertion should authenticate at its actual position and not at an unpopulated position. A spend proof uses the authentication path to show membership in that root. It derives a nullifier from the note commitment, the private position, and the holder's nullifier key. Validators reject repeated nullifiers. This prevents repeated use of an inserted output only when the Merkle root binds it to its position. Identical note commitments can be inserted at two real positions. Those are two separately accounted outputs, and spending each at its own position legitimately produces a different nullifier. The flaw here is an additional path through a copied but unpopulated branch: it creates a spendable position without another output being counted in the transaction balance. The [Zcash security analysis](https://github.com/daira/zcash-security/blob/main/zcash-security.pdf) makes the same distinction between a note commitment and a positioned note. Iron Fish carries the tree size in the public spend data. The [spend builder](../ironfish-rust/src/transaction/spends.rs) explicitly says that size is not incorporated into the proof. The [validator](../ironfish/src/consensus/verifier.ts) checks that the size is no larger than the current tree and that the supplied root matches the historical root for that size. It cannot inspect the private position. In the [spend circuit](../ironfish-zkp/src/circuits/spend.rs), the path direction bits both select the hashing order and determine the position used for the nullifier; there is no constraint that this position is below the public tree size. That design relies on the Merkle root binding a proved leaf to a position containing an actual output. ## Failure mechanism The old [Merkle tree implementation](../ironfish/src/merkletree/merkletree.ts) computed a parent of a missing right branch as `H(left, left)` and placed `left` in the witness as the right sibling. Because both inputs are equal, swapping their order does not change the parent hash. The swap changes the path's direction bit, and therefore changes the hidden position. For example, consider a depth-four tree whose first four leaves form a subtree with hash `T`. At the next level, the old padding produced `H(T, T)`. A proof for a note in the left `T` can take the right-hand side at that level instead. Its recomputed root remains `H(T, T)`, but its position increases by four. The higher levels can repeat this ambiguity. When a complete upper half of the fixed-depth tree is still empty, every note in the populated half has at least one such alternate position. A note near an incomplete right edge may have additional aliases at lower levels. This is why the problem is broader than the final leaf of an odd-sized tree. The [nullifier calculation](../ironfish-rust/src/note.rs) incorporates the position. The two valid paths for one inserted output therefore yield different nullifiers with overwhelming probability. Neither the root comparison nor the duplicate-nullifier check identifies the second spend as reuse of that output. The proof itself need not be forged: each path satisfies the circuit's statement under the old tree rule. The existing checks each validated a narrower property. The validator matched a public root and tree size, but the position remained private to the proof. The circuit checked a path to that root, but the copied branches made two positions produce the same root. The nullifier set checked byte-for-byte uniqueness, but the altered position changed those bytes. Earlier tree tests checked normal witnesses against expected roots; they did not enumerate alternative positions for the same leaf. The failure was the missing invariant across these components. ## Security impact and evidence limits An attacker must control the note's spending authority and know the note contents. This path does not let them spend an arbitrary other user's note. It can let them spend their own nonzero-value note more than once, potentially creating outputs whose combined value exceeds the value originally held. The transaction's ordinary value-balance checks do not correct a repeated input that appears under distinct nullifiers. The code-level alias follows directly from the old self-copy rule and was reproduced during the investigation. The [adversarial tree tests](../ironfish/src/merkletree/security.test.ts) try every path position for every distinct test leaf at every size of depth-one through depth-six trees. After the fix, each test leaf authenticates only at its inserted index. The tests also reject flipped directions at previously copied levels, compare historical roots and witnesses against an independent oracle, and exercise append, rollback, abort, and reopen sequences. A [real-proof test](../ironfish/src/merkletree/positionProof.test.slow.ts) constructs two old-rule spends of the same owned note with different nullifiers. Both satisfy the unchanged proof verifier; consensus rejects their old-rule anchors and rejects a proposed block containing one of those spends, while a spend using the corrected witness passes. These tests do not identify which private witness was used in any historical shielded spend. Valid proofs, real anchors, and distinct public nullifiers in a historical transaction would be consistent with this mechanism. Because the note position is private, those observations alone do not attribute a particular on-chain event or amount to it. Historical incident attribution requires separate forensic evidence and should be reported separately from this code-level finding. ## Origin and relationship to Zcash The self-copy padding rule is visible in the earlier `merkle_notes` prototype commit `53900b52517592ae3c8b483147f1749ea144901d` of 2019-02-18. Its `src/vector.rs` describes hashing a node with itself when the right child is empty. The production TypeScript tree also used this rule. The prototype establishes that the design choice is old; it does not, by itself, establish the complete path by which that code entered production. Iron Fish's spend circuit follows the Sapling pattern of deriving a nullifier from a positioned note. A [Zcash security analysis](https://github.com/daira/zcash-security/blob/main/zcash-security.pdf) explicitly identifies position binding as necessary to prevent one positioned Sapling note from having multiple spendable nullifiers. Zcash's [Sapling protocol specification, section 4.8](https://zips.z.cash/protocol/sapling.pdf) defines unused leaves with a distinguished `Uncommitted` value and hashes each internal node from its two children. It states that Sapling's uncommitted leaf representation cannot be a valid Sapling note commitment. That construction does not copy a populated subtree into an empty sibling, so the specific position alias above does not follow from Sapling's protocol rules. The [Sapling circuit audit](https://qed-it.com/services/audits/sapling/) reported a separate, low-severity bit-unpacking issue between Merkle tree layers, not this copied-branch alias. There **is** a related Zcash outcome from a different cause. A [2026 Zcash Orchard advisory](https://github.com/zcash/zcash/security/advisories/GHSA-ghc3-g8w4-whf9) describes an under-constrained circuit gadget that could allow the same note to be spent under multiple nullifiers. That flaw could also permit unauthorized spends of other users' notes, which this Iron Fish Merkle alias does not. Zcash's pool turnstile bounded the amount that could leave Orchard, according to the advisory. Zcash also [disclosed a Sprout counterfeiting flaw in 2019](https://zodl.com/zcash-counterfeiting-vulnerability-successfully-remediated/); it was unrelated to tree padding. These precedents show why an audit of one protocol's circuit does not establish the safety of a different protocol's tree and validator integration. ## Fix implemented The [Merkle tree](../ironfish/src/merkletree/merkletree.ts) now uses its designated `defaultValue` for an absent right sibling at every level. Root construction, historical root calculation, witness paths, and incremental rehashing use the same rule. A siblingless stored node may retain its own hash for a later append, but a spend witness for the current smaller tree uses the empty value at that position. The root-depth calculation was corrected so a complete subtree is combined with an empty sibling at the right level. The review also found and fixed duplicate-commitment membership after truncation, access to physically retained leaves outside the current tree size, invalid size handling, leaf and internal-node counter exhaustion, acceptance of a leaf equal to the empty marker, and root-cache invalidation races. The [devnet genesis definition](../ironfish/src/networks/definitions/devnet.ts) was regenerated for the new root rule, and dependent test fixtures were refreshed. Decrypting the old and new allocation notes with `DEV_GENESIS_ACCOUNT` confirmed the same 42,000,000 IRON amount, recipient, and memo. The current genesis generator creates V2 transactions, so the regenerated genesis transactions are V2 rather than the old genesis's V1; the genesis bytes changed beyond the Merkle root. No change to the Rust spend circuit, Groth16 proving parameters, or transaction serialization rules is included in this fix. In particular, the circuit still does not compare its private position with the public tree size. Safety now depends on the tree construction binding each insertion to its actual position, under the hash function's collision and preimage resistance assumptions. A circuit-level position bound would be a separate defense-in-depth change requiring new proof parameters and deployment work. ## Verification The adversarial tests check every position for distinct leaves in trees of depth one through six, with additional depth-32 boundary cases. Historical witnesses, truncation and append sequences, delayed cache promotion, real spend proofs, and a spend of the regenerated devnet genesis allocation were also tested. The standard and slow SDK test shards passed in GitHub CI, as did local TypeScript checking, builds, and linting. These checks exercise the intended fresh-chain behavior. They are not an independent cryptographic audit, nor do they make old chains valid under the changed roots. ## Compatibility and deployment boundary Merkle roots are consensus commitments. Changing the empty-branch rule changes the root for existing note-tree states, beginning at genesis, and changes later block commitments and witness anchors. Existing mainnet and testnet histories were produced under the old rule and are not compatible with this patch. Their historical genesis definitions were not migrated. A node using the corrected tree cannot simply switch rules at an arbitrary height and continue validating those chains. This patch is suitable for a **new chain with a newly generated genesis and coordinated network parameters**. It contains no activation-height rule, dual-root verifier, legacy proof migration, or historical accounting plan. Any proposal to preserve an existing chain would require a separate consensus upgrade design and security review. The 2026 Orchard case illustrates that such deployments may require more than a code change: Zcash used a protocol upgrade and [restricted its old pool](https://zodl.com/ironwood-is-live-on-zcash) after a separate soundness flaw. A different fix could retain the old tree roots by changing the spend circuit. The public spend already contains a tree size, so a new circuit could take that size as a proof input and require the private path position to be smaller. Deploying it would require new spend proving parameters, a new verifying key, and a consensus rule that accepts old proofs only for historical blocks and new proofs after activation. Iron Fish uses circuit-specific Groth16 parameters, so production deployment would also need a trusted parameter-generation process; [Zcash's Orchard uses Halo 2](https://zodl.com/new-releases-to-help-enable-zcash-shielded-by-default/), which does not require a trusted setup. Honest notes at their real positions could then still be spent from the existing tree. This is an alternative upgrade design, not part of the implemented patch. That circuit change would not identify outputs created by earlier alias spends. Those outputs are ordinary note commitments at real positions and would satisfy the new position bound. An earlier spend made at a copied position might also leave the note's real-position nullifier unused, allowing one further spend under the new rule. Distinguishing such history from legitimate notes requires evidence outside the proposed circuit constraint; the proof's privacy hides the position used in the old spend. ## Follow-up work Before a new chain is launched, review the tree, proof, validator, and nullifier rules as one security boundary. Test position uniqueness across tree sizes and depths, including power-of-two boundaries, past roots, and append transitions. Obtain independent review of the fixed consensus construction and its genesis and network parameters. Keep any investigation of historical transactions separate from the implementation fix, with conclusions tied to evidence that can actually distinguish this mechanism from other causes.