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 REVERTin 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:
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_stuffonly 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.