# On duplicate private revertible nullifiers

**URL:** <https://forum.aztec.network/t/on-duplicate-private-revertible-nullifiers/8520>\
**Category:** Aztec\
**Tags:** specs\
**Created:** [March 10, 2026, 7:45pm UTC](https://forum.aztec.network/t/on-duplicate-private-revertible-nullifiers/8520 "2026-03-10T19:45:44Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![spalladino](https://dub1.discourse-cdn.com/flex013/user_avatar/forum.aztec.network/spalladino/32/32_2.png) [@spalladino](https://forum.aztec.network/u/spalladino)\
**Post date:** [March 10, 2026, 7:45pm UTC](https://forum.aztec.network/t/on-duplicate-private-revertible-nullifiers/8520/1 "2026-03-10T19:45:44Z")

</div>

_Discussion and suggestion for a pretty niche topic: handling duplicate private revertible nullifiers._

# What is a duplicate private revertible nullifier

A nullifer emitted by a transaction during its **private** execution on its **revertible** phase, that already exists in world-state when the tx is added to a block.

For context, the **revertible** phase of a tx is what happens after **setup**. While setup is typically used for authentication and fee payment, the revertible phases of a tx usually deal with application logic. Setup is non-revertible: if there’s a `REVERT` during public function execution in the revertible phase, then whatever happened in setup is **not** reverted. If there’s a `REVERT`in public function execution during setup, then the tx is considered invalid and cannot be included in a block. Note that txs only have a revertible phase if they have at least one public function call (this last part will be important very soon).

# What happens today with a tx with a duplicate nullifier

Duplicate nullifiers are always rejected. What changes is what causes a tx that emits a duplicate nullifier to be **reverted** vs being considered **invalid**. A reverted tx is a tx that gets included in a block, its fee payer is charged, but its revertible effects are not included in world-state. An invalid tx is a tx that cannot be included in a block.

- A private-only tx (ie a tx with no public function calls) that contains a duplicate nullifier (ie a nullifier that already exists in world-state) is **invalid**.
- A non-private-only tx that contains a duplicate nullifier emitted from a **non-revertible private function** is **invalid**.
- A non-private-only tx that contains a duplicate nullifier emitted from a **revertible private function** is **reverted**.
- A non-private-only tx that contains a duplicate nullifier emitted from a **non-revertible public function** is **invalid**.
- A non-private-only tx that contains a duplicate nullifier emitted from a **revertible public function** is **reverted**.

In general, what we’re saying is: _a duplicate nullifier in a non-revertible phase causes the tx to become invalid, a duplicate nullifier from a revertible phase causes the tx to revert_.

However, we think the first scenario can lead to confusion. A tx that does not have any public function calls will be invalid if it has a duplicate nullifier, but the same tx _with a completely unrelated public function call_ will be reverted instead. This means that, if you’re writing a contract that emits a nullifier in private, you don’t know whether that will cause your tx to revert or invalidate it. While this should have no implications for app developers, it does for paymasters (aka fee-payers).

# Unifying criteria

We think we should unify the criteria for what happens with a duplicate private nullfier, regardless of whether the tx has public function calls or not. We can either:

- Make it so a private-only tx with a duplicate nullifier emitted during its revertible phase can revert
- Or make it so a tx with a private duplicate nullifier, no matter the phase it was emitted from, will always be invalid

We propose following the latter. Nullifier collisions are cheap to check, so when we pick up a tx, we can easily validate whether any of the nullifiers emitted from private execution collide with any from world-state, and discard the tx immediately. This would lead to fewer reverted txs, which are a nuisance to users who get charged for a tx that didn’t do anything for them.

It was argued that adding a criteria for invalidation makes things more difficult for block builders. For instance, a tx during its public execution could emit nullifiers that were present in the private nullifier set of other txs in the mempool, invalidating them. However, this is already the case for private non-revertible nullifiers, so we are in no worse situation than before by introducing this change.

# Unlocking new patterns

Making this change unlocks new patterns for fee payment contracts. Let’s say we have an application that wants to sponsor their users’ txs, like the following:

```auto
contract SponsoredApp:
  fn entrypoint:
    check user authorization
    set self as fee payer
    ensure enough gas for execution
    end setup phase
    call private_stuff on self
    call public_stuff on self

  fn private_stuff:
    emit a nullifier

  fn public_stuff:
    update a public value which the app knows should not revert

```

This contract is willing to sponsor any user tx that executes the private and public actions in the contract, and nothing more.

If the nullifier emitted from `private_stuff` in the example would trigger a revert rather than an invalidation, then a malicious user could grief the sponsor by sending txs that would emit a duplicate nullifier.

Note that moving `private_stuff` and `public_stuff` to the setup phase doesn’t work, since `SponsoredApp.public_stuff` would need to be whitelisted by nodes for the tx to be accepted.

Also note that moving `private_stuff`only to setup is risky for the app, since an unexpected bug in `public_stuff` that causes it to revert means that any invariant that requires `private_stuff` and `public_stuff` to be executed as a unit (ie all-or-nothing) would break, since only the effects from `public_stuff` would be undone.

# A caveat on public setup nullifiers

There’s a consideration to be made on public setup functions if we proceed with this change. Nullifiers from the setup phase are considered to be included _before_ the nullifiers from the application phase. This means that a nullifier emitted from public-setup technically happens _before_ a nullifier emitted from private-application, so it could invalidate the tx during public setup execution by emitting the same nullifier.

The solution is straightforward: do not whitelist public functions for setup that emit nullifiers. None of the public setup functions in the current whitelist do. And if we ever need to, we can add a manual check that the nullifier that a public setup function _could_ emit is not part of the tx’s private nullifier set.

# Next steps

Assuming no objections to the change above, next steps would be to update tx validation in the nodes so they reject txs with duplicate private nullifiers, regardless in which phase they occur. And in a future version, update the AVM to align with this behaviour, so we don’t have nodes that restrict txs that can technically be valid according to the protocol circuits.
