For the complete documentation index, see llms.txt. This page is also available as Markdown.

89065 bc high sub fee exitable wormhole deposits are accepted and recorded but can never be spent or exited permanently freezing user funds

Submitted on Aug 16th 2026 at 22:55:22 UTC by @n3310y for Audit Comp | Quantus

  • Report ID: #89065

  • Report Type: Blockchain/DLT

  • Report severity: High

  • Target: https://github.com/immunefi-team/audit-comp-quantus-chain

  • Impacts:

    • Permanent freezing of funds

Description

Brief/Intro

The choice to set the wormhole leaf quantum at 10^10 planck (0.01 QUAN, the documented unit) while the circuit fee constraint requires a quantized input of at least 2 (0.02 QUAN) to produce any positive exit, combined with a recorder that inserts a ZK leaf for every non-zero native transfer and with no wormhole.deposit() gate, causes permanent freezing of funds for users of Poseidon-derived wormhole accounts whenever an unprivileged sender uses balances.transfer_keep_alive to send one quantum — or any amount in [EXISTENTIAL_DEPOSIT, 2×10^10) — to that keyless destination.

The deposit sits on an account with no ML-DSA key; the matching leaf commits a quantized amount of 0 or 1; neither can satisfy (out₁ + out₂) × 10000 ≤ input × (10000 − 4); the pallet then skips summed_output_amount == 0 and mints nothing back.

Leaves cannot be merged: ten separate 0.01 QUAN deposits leave 0.10 QUAN on the destination as ten unexitable leaves, while one 0.10 QUAN transfer of the same total can exit 0.09 QUAN.

This is a vulnerability in the documented honest-user deposit path, not a theft attack, not a user sending to the wrong address, and not a future mining-reward floor. No privileged origin is required. The freeze is the entire deposit, not a remainder.

Vulnerability Details

Quantus spends value on a keyless (wormhole) account through a ZK Merkle tree, not through a Dilithium signature on that account. A credit is wormhole-exitable if and only if pallet-zk-tree stores a leaf (to, transfer_count, asset_id, amount) whose quantized amount can satisfy the circuit fee inequality for some output ≥ 1. Dilithium accounts are not affected: they spend with balances.transfer regardless of the leaf.

Live runtime constants (not pallet-mock defaults — the wormhole mock’s ED is 1, which hides this bug):

The documented wormhole unit (one quantum = 0.01 QUAN) is below the amount the circuit needs to exit one quantum. EXISTENTIAL_DEPOSIT is twenty times smaller still. There is no wormhole.deposit() extrinsic: balances.transfer_keep_alive is the deposit API, and the pallet records a real leaf for it.

1. Relevant direct native transfers are recorded, including sub-quantum amounts

WormholeProofRecorderExtension is in the production TxExtension tuple. After a successful balances.transfer_keep_alive it scans Balances::Transfer events and calls record_transfer_proof. The only amount it drops is exact zero:

The pallet test zero_amount_credit_is_not_recorded uses amount = 1 as the positive control that a sub-quantum credit is inserted. There is no check that amount >= SCALE_DOWN_FACTOR, and no check that amount >= 2 * SCALE_DOWN_FACTOR.

For this direct transfer path, the scanner records successful native transfers regardless of whether the destination is a wormhole or Dilithium account. Account IDs are 32-byte values; a Poseidon wormhole address is indistinguishable from a Dilithium hash(pk) at the Balances layer. Filtering “transfers to wormhole accounts” is not a valid fix.

2. hash_leaf commits amount / 10^10: one quantum hashes as 1, ED hashes as 0

A deposit of exactly 10^10 planck (one quantum, 0.01 QUAN — the documented wormhole unit) hashes as quantized 1. That is on the quantization grid. It is still not fee-exitable. This is the headline case, not a sub-quantum rounding leftover.

A deposit of 1 … 10^10 − 1 planck (including ED = 10^9) is stored as raw planck on the destination and as a leaf whose Poseidon commitment is identical to a leaf with amount = 0. The PoC prints hash_leaf(stored) == hash_leaf(amount=0) for the ED case. The prover can only witness the quantized value the circuit binds, which is 0.

3. The circuit forbids any positive output for quantized input 0 or 1

With live VolumeFeeRateBps = 4:

Quantized input
Max integer output
1-quantum exit possible?

0 (1 … 10^10−1 planck, including ED)

0

No

1 (10^10 … 2×10^10−1 planck)

0

No

2 (≥ 2×10^10 planck)

1

Yes

The pallet already documents this floor:

The runtime config comment says the same: “the circuit already enforces a one-quantum (0.01 QUAN) minimum fee … There is no separate on-chain minimum exit amount.” There is also no on-chain minimum deposit amount matching that floor.

A Plonky2 proof with output ≥ 1 against these leaves cannot exist: the range-checked fee constraint is unsatisfiable. Generating a dummy 0-output proof would not recover funds either.

4. The pallet skips zero-output slots, so nothing is minted back

Even if a prover submitted a valid 0-output proof (padding / dummy slot), settlement mints nothing. Combined with the circuit constraint, there is no wormhole recovery path.

5. The destination cannot Dilithium-spend

Wormhole addresses are H(H("wormhole" || secret)) (Poseidon2 hash_no_pad). Production Balances::transfer requires an ML-DSA-65 signature whose public-key hash equals the account id. No such key exists for a Poseidon wormhole account.

The pallet mock does not check Dilithium signatures. A RawOrigin::Signed(wormhole) transfer in a unit test would falsely succeed and must not be used as a spendability proof. The PoC uses Executive::apply_extrinsic with the live TxExtension tuple: an extrinsic claiming the wormhole destination as sender, signed by a real Dilithium key, is rejected as InvalidTransaction::BadProof.

The same ED transferred to a Dilithium account is immediately spendable. The freeze is destination-type-specific, not a Balances bug.

6. Vesting was patched for this exact hazard; generic Balances was not

docs/RUNTIME_SURFACE.md repeats it: “a sub-quantum payout would create a zero-value leaf and strand funds on a keyless beneficiary,” and claims must pay at least 1 QUAN. The team already named both the quantized-zero leaf and the fee-consuming minimum. Those gates were applied to vesting payouts only. Permissionless balances.transfer_keep_alive to the same class of keyless destination remains ungated.

This is not a missing user-input check on a dedicated deposit function (Immunefi AI-16). Pallet-wormhole exposes no deposit() extrinsic and no on-chain minimum deposit. The privacy deposit path is a plain balances.transfer_keep_alive. The recorder then inserts a NativeTransferred leaf, so the protocol treated the credit as a valid wormhole deposit. Vesting already enforces the fee-consuming minimum for the same keyless destination class; generic Balances does not.

7. Leaves cannot be merged: depositing more in separate transfers does not unfreeze earlier credits

A leaf proof is 1 input → ≤2 outputs. Ten separate 0.01 QUAN transfers to the same wormhole destination produce ten quantized-1 leaves. Destination free_balance is 0.10 QUAN — five times the fee-exitable floor — and none of the leaves can exit. The same 0.10 QUAN as one transfer quantizes to 10 and can exit 9. The PoC prints both sides. This is not “the user should have deposited more”: they did, in aggregate, and the entire 0.10 QUAN is still frozen.

Root Cause

Two stacked mismatches, both live today:

  1. The circuit fee constraint (and the pallet’s own “input ≥ 2 quanta” comment) freezes on-grid 1-quantum deposits (10^10 planck) — the documented wormhole unit. record_transfer_proof only drops amount == 0, so that credit is recorded and stranded on any keyless destination.

  2. In runtime/src/lib.rs EXISTENTIAL_DEPOSIT = MILLI_UNIT = 10^9, while pallets/zk-tree/src/tree.rs hash_leaf commits amount / 10^10. An account can be created with exactly the existential deposit; that amount is below one quantum and hashes as 0.

This is Quantus-specific code (recorder + quantization + ED vs quantum + fee floor), not unmodified Substrate. It is a user-deposit path. It is not a miner volume-fee miss and not a miner transaction-fee split-mint.

Relationship to public known issue #3

Public known issue #3:

At some point the volume of transactions will cause the zk-tree depth to exceed the current maximum, both on chain and in circuit. When that moment approaches we will do a runtime upgrade to update both the circuit and the chain. A similar reasoning works for when / if mining rewards ever drop well below the quantized minimum for the circuit.

This finding is not that issue.

  • #3 is a future mining-reward floor (“when / if mining rewards ever drop”). This is today’s permissionless user Balances deposit path. Mining rewards on this revision are far above 0.02 QUAN.

  • #3’s “quantized minimum” is the leaf quantum (10^10). Attack B deposits exactly one quantum — on the grid, not “below the quantized minimum” — and still cannot exit because of the fee floor (input ≥ 2). Treating “quantized minimum” as “fee-exitable minimum” rewrites the known issue.

  • Attack C then freezes 0.10 QUAN as ten on-grid 1-quantum leaves. That aggregate is five times the fee floor and is still not a mining-reward issue.

  • #3’s remedy is a later runtime upgrade when the tree is years from depth 16, or if emissions later fall. User funds deposited now are already stranded; “we will upgrade later” is not an operational mitigation for current deposits.

  • The runtime already patched vesting for this keyless-fund hazard (PayoutQuantum = SCALE_DOWN_FACTOR, MinimumPayout = UNIT) and left generic Balances unrestricted. That is the opposite of a conscious decision to accept ungated user deposits.

The PoC freezes live-runtime user deposits on the official wormhole address, including 0.10 QUAN as ten un-mergeable 1-quantum leaves.

What this is not

  • Not Critical item 4 “permanent freezing of funds (fix requires hardfork)”. A runtime upgrade can raise ED, reject sub-floor credits, or force_transfer stranded balances. Item 9 exists on the program list for exactly that situation.

  • Not High “permanent freezing of unclaimed yield”. These are user funds at rest after a completed balances.transfer, not miner/protocol yield.

  • Not theft. The sender’s funds move to the destination; they cannot leave it.

  • Not a remainder-precision complaint about a 1.001 QUAN transfer (the 0.001 leftover). The headline is any entire deposit in [ED, 2×10^10).

  • Not “the user sent too little / missing input validation.” There is no wormhole.deposit(). transfer_keep_alive is the API; the pallet records a real leaf; vesting already enforces the fee floor for the same destination class. Attack C shows the user did deposit more in aggregate (0.10 QUAN) and still recovered nothing.

  • Not a miner volume-fee missing-leaf issue and not a miner transaction-fee split-mint. Those are different paths. This report is only ungated user Balances deposits to a keyless destination.

Previous-audit / duplicate boundary

This is also not the same root cause as the prior public wormhole findings:

  • Prior F-05 covered transfer variants that were not recorded by the old recorder. This bug is the opposite shape: the live event-based recorder does record the deposit and emits NativeTransferred, but the recorded amount is not fee-exitable.

  • Prior leftover / remainder discussions concern value left behind after a user chooses outputs smaller than the leaf can support. Here the maximum possible positive output is already zero before the user chooses anything. The whole accepted deposit is frozen.

  • Known issue #3 mentions future tree-depth pressure and future mining rewards if emissions ever fall below a quantized minimum. This bug uses current user deposits, current constants, and one-quantum on-grid leaves that fail because of the fee floor, not because they are below the leaf quantum.

The root cause is therefore the current mismatch between Balances acceptance / wormhole recording and the circuit's fee-exitable minimum for user deposits to keyless accounts.

Vulnerability Path

1

Send a normal transfer to a wormhole address

User builds a normal ML-DSA-65 signed balances.transfer_keep_alive to their wormhole address for 0.01 QUAN (1 quantum, the documented unit), or repeats that transfer ten times, or sends ED (0.001 QUAN).

2

Record a ZK leaf

Executive::apply_extrinsic runs the production TxExtension tuple. Balances creates/credits the destination. WormholeProofRecorderExtension::post_dispatch records a ZK leaf for the raw planck of each transfer.

3

Quantize each deposit separately

Destination free_balance equals the sum of those deposits. hash_leaf commits quantized 1 (one quantum) or 0 (ED). Ten 1-quantum deposits are ten separate leaves, not one 10-quantum leaf.

4

Dilithium spending fails

Dilithium spend fails: no key hashes to the Poseidon address (BadProof).

5

Wormhole exit fails

Wormhole exit fails: circuit fee constraint rejects output ≥ 1 for input 0 or 1; pallet skips summed_output_amount == 0; leaves cannot be merged.

6

Funds remain stranded

The deposit remains on the destination until a runtime upgrade moves it.

Impact Details

The affected party is any user (or protocol pot that is not the vesting pot, which the scanner already skips) who receives a native credit below 0.02 QUAN on a keyless / wormhole account. They cannot Dilithium-transfer it and cannot wormhole-exit it. The entire credit is frozen. Repeating the documented 0.01 QUAN unit does not help: leaves do not merge.

Selected in-scope impact: Critical — Permanent freezing of funds.

Why this impact, and not a High/Medium one:

  • The funds are user deposits at rest, not unclaimed yield.

  • The freeze has no timeout. There is no user action that recovers it, including depositing more in later transfers.

  • Likelihood is permissionless and one-shot (a single ordinary transfer of the documented wormhole unit).

  • The program lists Critical item 9 separately from High “temporary freezing of funds for at least 24 hours” and separately from Critical item 4 (hardfork). A later governance upgrade does not reclassify user-fund freeze as High.

Loss size:

  • Per transfer: the full deposited amount, for every amount ∈ [10^9, 2×10^10) planck to a keyless destination.

  • PoC Attack B freezes 10_000_000_000 planck = 0.01 QUAN — one quantum, on the grid.

  • PoC Attack A freezes 1_000_000_000 planck = 0.001 QUAN.

  • PoC Attack C freezes 100_000_000_000 planck = 0.10 QUAN as ten un-mergeable 1-quantum leaves. Destination free_balance is 5× the fee-exitable floor and recoverable exit is 0. The same 0.10 QUAN as one transfer can exit 0.09 QUAN.

  • Repeatable without bound: each additional sub-floor transfer to the same destination adds another unexitable leaf.

  • USD is not computed: the competition snapshot does not pin a QUAN market price.

The 2-quantum contrast in the PoC (0.02 QUAN) is not frozen by this bug: quantized input 2 can exit 1 quantum. That is the fee-exitable floor the rest of the runtime already knows about and applied to vesting.

Risk Breakdown

  • Likelihood: Certain for any wormhole deposit in the freeze range. No attacker skill, no race, no special block. The documented deposit path is a plain signed transfer.

  • Privileges: None.

  • Repeatability: Once per transfer, indefinitely. Separate transfers to the same destination do not merge; each adds another frozen leaf.

  • Classification: Immunefi Vulnerability Severity Classification System v2.2 / this program’s Critical list item 9, “Permanent freezing of funds”.

References

Proof of Concept

This is a local Substrate / FRAME runtime integration test, not a live-network test or an EVM mainnet fork. The program’s own build instructions are cargo test --release in the chain repo.

The PoC executes the production runtime machinery in a local test externality through Executive::apply_extrinsic, with a real ML-DSA-65 signature and the production TxExtension tuple (including WormholeProofRecorderExtension). It is not a pallet-mock unit test: it does not call pallet internals or use RawOrigin::Signed in the wormhole mock (that mock does not check Dilithium and would be a false spend; its ED default of 1 also cannot show the ED-vs-quantum mismatch).

The PoC:

  • Uses live EXISTENTIAL_DEPOSIT, VestingPayoutQuantum / SCALE_DOWN_FACTOR, and VolumeFeeRateBps.

  • Uses the official Poseidon test wormhole address from genesis presets.

  • Uses real Dilithium-65 keys for Alice and Bob.

  • Shows the same ED is spendable on a Dilithium destination.

  • Shows a wormhole-as-sender extrinsic is BadProof.

  • Records leaves via the production extension — transfer_keep_alive is the deposit API; there is no wormhole.deposit().

  • Asserts the Wormhole::NativeTransferred recorder event for the accepted deposit, including amount, transfer count, and leaf index.

  • Leads with Attack B: one quantum (documented unit) hashes as 1, not 0, and still cannot exit.

  • Then shows Attack A: ED hashes as 0.

  • Independently evaluates the circuit fee inequality.

  • Contrasts 2 quanta, which can exit 1 quantum.

  • Shows Attack C: ten separate 1-quantum deposits freeze 0.10 QUAN as ten unexitable leaves.

  • Contrasts the same 0.10 QUAN as one transfer, which can exit 9 quanta.

Nothing in this PoC talks to mainnet or a public testnet.

1

Reproduce

From the audit-comp-quantus-chain repository root (this workspace: codebases/chain), on commit 3b243b870a5442a6e5f443056f67bbba87d472f5 plus the three additions below:

This matches the program README's release-test flow. The same test also reproduces in debug mode without --release.

Requirements: Rust toolchain able to build this workspace (see the chain README), no extra environment variables, and no network. The first build of pallet-wormhole regenerates ZK verifier artifacts via build.rs. Subsequent runs reuse them. The integration test binary is named mod because the crate entry is runtime/tests/mod.rs.

2

Add the development dependency

runtime/Cargo.toml — add one [dev-dependencies] line so the integration test can call pallet_zk_tree::tree::hash_leaf:

3

Register the test module

runtime/tests/transactions/mod.rs — add one module line:

4

Add the PoC source file

New file: runtime/tests/transactions/poc_fee_exitable_wormhole_freeze.rs

5

Run the test

Full log

The log proves:

  1. Live ED is 0.001 QUAN and the fee-exitable floor is 0.02 QUAN — a 20× gap, not a mock artifact.

  2. Victim 0xbe13a189…403d is the official Poseidon wormhole address, not Alice’s or Bob’s Dilithium account.

  3. The same ED is spent twice on Dilithium accounts (Alice → Bob → Carol). The freeze is not “ED transfers are broken”.

  4. Claiming the wormhole destination as signer is BadProof. There is no Dilithium spend path.

  5. Attack B: 1 quantum lands, emits the wormhole NativeTransferred recorder event, hashes as 1, max circuit output is 0, and 0.01 QUAN is frozen.

  6. Attack A: ED lands, hash_leaf(stored) == hash_leaf(0), max circuit output is 0, and 0.001 QUAN is frozen.

  7. Two quanta can exit one quantum. The bug boundary is exact.

  8. Attack C: ten separate 1-quantum deposits to the same destination result in free_balance = 0.10 QUAN, leaf_count = 10, and ten recorder events. Every leaf is quantized as 1 and unexitable, so 0.10 QUAN is frozen.

  9. The same 0.10 QUAN as one transfer produces quantized input 10 and max output 9. Depositing the same total in one leaf is exitable; dripping it is not.

  10. The printed headline loss is 100_000_000_000 planck = 0.10 QUAN, with freeze range [10^9, 2×10^10) planck.

Was this helpful?