Skip to content

Silent shielded-supply inflation possible: no on-chain deposits−withdrawals invariant #563

@yi-here

Description

@yi-here

Problem

The shielded transfer system (pallet-wormhole + pallet-zk-tree) does not enforce an on-chain invariant that the shielded supply equals Σ deposits − Σ withdrawals per asset. Today this property holds only if every ZK proof verification path is correct, every domain-separation tag in the 4-ary Poseidon tree is unambiguous, and no upstream qp-poseidon-core change alters hash outputs.

If any one of those assumptions breaks — circuit bug, leaf-vs-internal-node encoding collision, dependency upgrade producing different Poseidon output — an attacker can construct a valid Merkle inclusion proof for a note that was never deposited. Because notes are spent via nullifier and inclusion proof (not by chain-of-custody tracing), the inflated balance is indistinguishable from legitimate balances. No on-chain observer can detect the discrepancy without reconstructing the full deposit/withdrawal ledger off-chain.

The pallet does maintain careful total_issuance accounting at the issuance layer (pallets/wormhole/src/lib.rs:436), but that is orthogonal: a fraudulent exit increases user balances via increase_balance, which does not move total_issuance, so the existing issuance discipline cannot catch the inflation. What is missing is a separate per-pool, per-asset counter tracked transparently outside the shielded set.

Failure mode: undetectable inflation of the shielded asset supply.

Recovery cost: chain halt + hard fork + reconstruction of the shielded set from external audit data, with non-zero risk of losses for honest holders whose deposits cannot be matched against on-chain records.

Precedent: Zcash discovered a Sprout-circuit inflation bug in 2018, undetected for ~18 months. The bug never produced visible coin inflation because the chain's turnstile mechanism caught the supply mismatch the moment any attacker tried to withdraw more than had been deposited. Quantus has no equivalent.

Solution

Add a turnstile counter: running deposits − withdrawals per shielded pool per asset, checked transparently at every withdrawal.

Concrete change

  1. In pallet-wormhole (owner of the shielded-balance accounting; move to pallet-zk-tree if cleaner), add storage:

    /// Per-asset turnstile: total currency value entered minus total withdrawn for each asset id.
    /// Must remain >= 0 for every entry; any withdrawal that would underflow indicates a forged
    /// inclusion proof and MUST be rejected before consuming a nullifier.
    #[pallet::storage]
    pub type ShieldedSupply<T: Config> = StorageMap<
        _,
        Blake2_128Concat,
        T::AssetId,
        AssetBalanceOf<T>,
        ValueQuery,
    >;
  2. Deposit path (transparent → shielded): increment ShieldedSupply::<T>::get(asset_id) by the deposited amount, in the same extrinsic that mints the commitment into the tree.

  3. Withdrawal path (shielded → transparent): gate on ShieldedSupply::<T>::get(asset_id) >= amount; fail with Error::ShieldedSupplyInvariantViolated if the check would underflow. Decrement only after the proof and the supply check both succeed.

  4. Genesis: ShieldedSupply is empty (all assets implicitly zero). No migration required — the invariant is maintained forward-only. Any pre-existing notes are covered because no withdrawal can succeed against a zero balance, surfacing the discrepancy immediately if the launch state was already wrong.

Behavior on violation

A withdrawal that would drop ShieldedSupply below zero indicates a forged proof. The extrinsic fails. The nullifier is not recorded. The chain does not halt. Operators get a clear, indexable error event that something is wrong with the circuit or the tree.

Stronger responses (auto-freeze the pool, emergency root) are a separate discussion; the turnstile by itself is sufficient to prevent any value loss from a circuit-level bug.

Acceptance criteria

  • ShieldedSupply storage exists, keyed by AssetId; incremented on every shielded deposit.
  • Every withdrawal path is gated by ShieldedSupply::<T>::get(asset_id) >= amount; fails with a distinct error if not.
  • Decrement happens only after both the proof and the supply check pass.
  • Property test: a sequence of (deposit, withdraw) operations across multiple assets leaves ShieldedSupply::<T>::get(asset_id) == Σ deposits(asset_id) − Σ withdrawals(asset_id) for every asset.
  • Adversarial test: a synthetic withdrawal with no matching deposit (proof rigged to verify) is rejected with ShieldedSupplyInvariantViolated and does not consume a nullifier.
  • Characterization test: a known good deposit/withdrawal sequence produces the same supply counters byte-for-byte across runs (no ordering or fee-handling drift).
  • No change to the proving or verification key of either circuit.
  • Storage layout is additive only; no migration of existing pallet-wormhole or pallet-zk-tree state.

References

  • pallets/wormhole/src/lib.rs:436 — existing issuance-level accounting (necessary, not sufficient).
  • pallets/wormhole/src/lib.rs:66-67BalanceOf<T> and AssetBalanceOf<T> type aliases.
  • pallets/zk-tree/src/lib.rs — Merkle tree accounting.
  • audits/EigerWormholeAudit.pdf — current audit coverage of the Wormhole pallet; worth re-reading for any prior turnstile discussion.
  • Zcash counterfeiting-vulnerability disclosure, Feb 2019 — historical precedent for the failure mode and the mitigation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions