Skip to content

wormhole: write nullifiers after settlement checks + rollback regression tests#568

Open
n13 wants to merge 2 commits into
mainfrom
nullifier-audit-item
Open

wormhole: write nullifiers after settlement checks + rollback regression tests#568
n13 wants to merge 2 commits into
mainfrom
nullifier-audit-item

Conversation

@n13
Copy link
Copy Markdown
Collaborator

@n13 n13 commented May 25, 2026

Audit item

Title: Wormhole marks nullifiers used before all settlement checks and balance operations complete
Component: Wormhole nullifier lifecycle
Affected commit: e661b3cee32769fa137526b7d38306a8c9c95b87
Severity: High — Likelihood: Medium — Evidence level: E2

Impact: A valid proof that reaches a later failure path can consume nullifiers without releasing value, permanently preventing the legitimate withdrawal tied to those nullifiers.

Exploit scenario: An attacker or relayer submits a proof that passes validate_proof and has unused nullifiers but triggers a later settlement error, such as TransferAmountBelowMinimum or a fallible balance/fee operation. Because nullifiers were written first and the call is not visibly transactional, those nullifiers can remain consumed even though value was not released.

Reasoning: The code order writes UsedNullifiers before several fallible operations. FRAME dispatchables do not automatically promise rollback unless explicitly wrapped transactionally. Existing tests cover replay and pre-insertion failures, but do not cover post-nullifier failure rollback.

Why the bug as described is not exploitable

The audit's premise — "FRAME dispatchables do not automatically promise rollback unless explicitly wrapped transactionally" — has not been true for #[pallet::call] dispatchables since paritytech/substrate#11431 (followed by #11927, which moved the wrap directly into each call body).

Every #[pallet::call] dispatchable is automatically rewritten by the macro to be:

with_storage_layer::<Ok, Err, _>(|| { /* original body */ })

This is visible in frame-support-procedural-36.0.0/src/pallet/expand/call.rs:241-247 (the version this workspace uses) and documented on the #[pallet::call] rustdoc:

"The macro also ensures that the extrinsic when invoked will be wrapped via frame_support::storage::with_storage_layer to make it transactional. Thus if the extrinsic returns with an error any state changes that had already occurred will be rolled back."

So if the original verify_aggregated_proof returned Err(TransferAmountBelowMinimum) or Err(ArithmeticError::Overflow) after writing UsedNullifiers, the macro's storage layer would already roll those writes back. We confirmed this empirically while building the regression tests — both attacker-grief and normal-user failure modes correctly roll nullifiers back without any code change.

What this PR does anyway (defense-in-depth)

Even though the bug isn't exploitable today, the audit raises two genuine concerns we want to lock in:

  1. Order of operations is not self-evident. A future maintainer reading verify_aggregated_proof shouldn't have to know about #[pallet::call]'s implicit wrap to understand why writing nullifiers before fallible work is safe. So we reorder the write to happen only after all cheap checks pass (TransferAmountBelowMinimum, account decoding, balance conversion). After this PR, even if the storage layer were ever removed, the most obvious failure modes can never consume nullifiers.

  2. No regression test pinned the property. We add two tests covering both scenarios the audit calls out:

    • ..._below_minimum_does_not_consume_nullifiers — normal-user case: proof becomes under-minimum at settlement time, nullifiers must remain spendable.
    • ..._settlement_overflow_rolls_back_nullifiers — attacker grief case: an exit account is pre-funded to Balance::MAX so the settlement mint overflows after nullifiers are written, validating the storage-layer rollback works end-to-end.

If #[pallet::call]'s auto-wrap is ever removed in a future FRAME upgrade, or if anyone moves the nullifier write back to the top of the dispatchable, both tests fail loudly.

Files changed

  • pallets/wormhole/src/lib.rs — move UsedNullifiers::<T>::insert loop to after the MinimumTransferAmount check (the only substantive behavior change).
  • pallets/wormhole/src/tests.rs — two new regression tests; small DRY refactor that extracts setup_valid_proof_state, nullifier_bytes, exit_accounts, and assert_nullifiers_unused helpers and uses them in three existing tests as well.
  • pallets/wormhole/src/mock.rsMinimumTransferAmount becomes a pub storage parameter so the new test can override it (same pattern as pallets/multisig/src/mock.rs).

Test plan

  • cargo test -p pallet-wormhole — 22/22 pass.
  • cargo check -p quantus-runtime — builds clean.
  • Verified empirically that removing the reorder (putting the nullifier write back at the top) still passes — confirming #[pallet::call]'s storage layer protects the existing code.

n13 added 2 commits May 25, 2026 10:03
adding unit tests making sure failures roll back correctly
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant