Skip to content

fix(shielded-pool): reject two equal non-dummy nullifiers in private_transfer - #127

Merged
nol4lej merged 1 commit into
mainfrom
security/transfer-duplicate-nullifier
Aug 8, 2026
Merged

fix(shielded-pool): reject two equal non-dummy nullifiers in private_transfer#127
nol4lej merged 1 commit into
mainfrom
security/transfer-duplicate-nullifier

Conversation

@nol4lej

@nol4lej nol4lej commented Aug 8, 2026

Copy link
Copy Markdown
Member

Reject two equal non-dummy nullifiers in private_transfer

Defense-in-depth against spending one input twice within a single transfer.

The gap

private_transfer takes up to two nullifiers. If both slots hold the same non-dummy nullifier:

  • The used-set loop checks each against storage — neither is marked yet, so both clear it.
  • After proof verification, mark_as_used runs per nullifier, but the second write is idempotent: marking an already-marked nullifier is a no-op.

Nothing between those two points compared the nullifiers to each other, so one input was spent twice. Pool admission doesn't catch it either — validate_unsigned pushes each nullifier into and_provides, and two equal ones collapse to a single tag rather than flagging a duplicate.

The ZK proof is expected to bind the two inputs distinct. This does not remove that expectation; it stops the chain from depending on it, so a circuit that ever failed to enforce distinctness could not translate into a double-spend on-chain.

The fix

After the used-set check in execute, compare the non-dummy nullifiers directly. With at most two inputs, no set is needed: filter out the dummy (all-zero) nullifiers, and if two real ones remain, reject with NullifierAlreadyUsed when they are equal.

Dummy inputs (all-zero) are excluded — two dummies are legitimately equal and already handled by the all-dummy rejection downstream.

Verification

  • Unit execute_two_equal_nullifiers_fails — passes. Mutation-tested: neutralizing the guard makes the test fail (the transfer goes through), confirming it catches a real condition.

  • Production build (node without skip-proof-verification) compiles clean; clippy and fmt pass.

  • Dev-node adversarial suite (3/3) via unsigned extrinsic, with the transfer VK loaded so the tx clears validate_unsigned and actually reaches execute:

    • two equal nullifiers → rejected with NullifierAlreadyUsed
    • two distinct nullifiers → not rejected as duplicate (control)
    • one real + one dummy → not rejected as duplicate (control)

    The node runs skip-proof-verification on purpose: the guard sits behind the proof check, so this exercises the defense-in-depth layer on the assumption the proof did not bind the inputs distinct — exactly the case it protects against.

Version

pallet-shielded-pool 0.16.00.17.0; CHANGELOG entry under Security.

@nol4lej
nol4lej merged commit 63c6077 into main Aug 8, 2026
6 checks passed
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