Skip to content

feat!: auto-enqueue public init nullifier for contracts with public functions#20775

Merged
nchamo merged 25 commits intomerge-train/fairiesfrom
feat/public-init-nullifier
Mar 12, 2026
Merged

feat!: auto-enqueue public init nullifier for contracts with public functions#20775
nchamo merged 25 commits intomerge-train/fairiesfrom
feat/public-init-nullifier

Conversation

@nchamo
Copy link
Contributor

@nchamo nchamo commented Feb 23, 2026

Closes F-239

Why we are doing this

Right now, when a contract is initialized (either privately or publicly), it emits a nullifier that is just the contract's address. The main issue with this approach is that it can lead to weird situations like the following:

// Contract A
#[external("private")]
fn should_be_invalid_but_is_not() {
    self.enqueue_self.call_b_public();
    b_contract.private_initializer();
}

#[external("public")]
#[only_self]
fn call_b_public() {
    b_contract.public_call();
}

// Contract B
#[external("private")]
#[initializer]
fn private_initializer() {
    self.enqueue_self.initialize_public();
}

#[external("public")]
#[only_self]
fn initialize_public() {
    ...
}

#[external("public")]
#[only_self]
fn public_call() {
   ...
}

In this scenario, the contract A's should_be_invalid_but_is_not would call contract B's public_call before contract B actually calls it's own initialize_public. This is because:

  • Contract B's initializer is emitted at private_initializer
  • Contract A's call_b_public was enqueued before contract B's initialize_public
  • When contract A calls contract B, public_call's init check believes it is initialized

Our fix

Our fix is quite simple. We'll use two different initializers: a private one, and a public one, and this is how it will work:

  • All external functions check their corresponding init nullifier
  • Public initializers emit both nullifiers (at the end to avoid others calling us mid init)
  • Private initializers emit private nullifier (at the end too), and enqueue a call to an autogen fn which emits pub (also at the end),
    • Unless the contract does not have any external public functions, in which case we emit the private nullifier and that's it
  • only_self functions do not check the initializer (i.e. they're noinitcheck)
    • We realized that it didn't make much sense for only_self to have these init checks, and based on our change, we decided to remove them

Pros

  • We are fixing the issue we wanted to fix since now public functions will fail if called before initialization finishes
  • This requires no syntax changes

Cons

Contracts that have public external functions a private initializer now:

  • Perform a public function when initialized
    • This leaks privacy. But, if you think about it, any initializer check that public functions make would have made the nullifier hash public, so anyone could have already checked if it was initialized or not
  • They must be registered before initialized (since we are adding a public call on the initializer)
    • While not ideal to make this required now, it does make sense that a contract that has public functions would have to be registered before interacting with it
  • Must only call only_self or noinitcheck public functions during initialization

Extra notes

We should make utility functions check both initializers, but they check none at the time, so will do that in another PR

@nchamo nchamo added the ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure label Feb 23, 2026
@nchamo nchamo marked this pull request as draft February 23, 2026 16:58
@nchamo nchamo added the ci-draft Run CI on draft PRs. label Feb 23, 2026
);
}

if f.name() == quote { __emit_public_init_nullifier } {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are now adding this check and making sure that end users can't write a function with this name. Are we ok with this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this check is a good idea. Are we following this pattern elsewhere?

This is the kind of error that should have a link to the docsite with an error code.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we are ever checking a name as the only external functions we are generating are _compute_note_hash_and_nullifier, process_message, sync_state and public_dispatch and for the first 3 we generate them only if they don't exist in the contract (to allow devs to have custom implementations) and for public dispatch the compiler catches it on its own:

Image

Sounds like a good idea to also add here a check for the public dispatch name collision to get the same kind of error message.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nventuro, are we doing that somewhere else so that I can understand how to properly link to docs with an error code?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed in #20893, I don't like the mechanism of not injecting if the fn name already exists - it's too implicit. I'm in favor of erroring if the user defines, and providing an explicit mechanism for an alterantive impl.

// No dispatch function if there are no public functions
quote {}
} else {
// If the module has an initializer, add a dispatch case for the auto-generated
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would the the auto generated function that set the public initializer nullifier. I tried different approaches and this one was the one that required the less changes. I don't love it if I'm being honest, so I'm more than open to suggestions

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not simply create an external only self function and add it to the contract? This is much more fragile

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, it's not that simple. I explained the issue in generate_injected_public_fns

@nchamo nchamo marked this pull request as ready for review February 23, 2026 20:05
@nchamo nchamo requested review from benesjan and mverzilli February 23, 2026 20:05
Copy link
Contributor

@nventuro nventuro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good first pass! Left some comments re. docs, implemantation notes and tone.

This needs to be accompanied with some blurb somewhere explaining what these things do, e.g. in the docs for the initializer fn or in some section (which is e.g. where the error codes would link, and where we'd explain that initializing pub storage from a pub init fn requires only self). You can do a simple braindump if you want and I'll do a proper pass with the current style etc later.

Also, why did you tag the PR as a breaking change?

@nchamo
Copy link
Contributor Author

nchamo commented Feb 23, 2026

@nventuro , I marked this as a breaking change because contracts that previously enqueued calls to public non-only_self functions as part of the private initializer would now stop working

Basically what happened in noir-projects/noir-contracts/contracts/test/updatable_contract/src/main.nr

Copy link
Contributor

@benesjan benesjan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both Nicos are very smart, glorious and beautiful

);
}

if f.name() == quote { __emit_public_init_nullifier } {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we are ever checking a name as the only external functions we are generating are _compute_note_hash_and_nullifier, process_message, sync_state and public_dispatch and for the first 3 we generate them only if they don't exist in the contract (to allow devs to have custom implementations) and for public dispatch the compiler catches it on its own:

Image

Sounds like a good idea to also add here a check for the public dispatch name collision to get the same kind of error message.

…ublic-init-nullifier

# Conflicts:
#	docs/docs-developers/docs/resources/migration_notes.md
#	noir-projects/aztec-nr/aztec/src/macros/aztec.nr
@nchamo nchamo self-assigned this Feb 27, 2026
@nchamo nchamo requested review from benesjan and nventuro February 27, 2026 19:37
bot.updateConfig({
l2GasLimit: Number(totalManaUsed),
daGasLimit: Number(totalManaUsed),
daGasLimit: DEFAULT_DA_GAS_LIMIT,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change discussed with @spalladino to fix an unrelated error on the CI

Copy link
Contributor

@nventuro nventuro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!


**How initializers work:**

- **Private initializers** emit the private init nullifier. If the contract has any public functions, the protocol auto-enqueues a public call to emit the public init nullifier.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **Private initializers** emit the private init nullifier. If the contract has any public functions, the protocol auto-enqueues a public call to emit the public init nullifier.
- **Private initializers** emit the private init nullifier. If the contract has any external public functions, the protocol auto-enqueues a public call to emit the public init nullifier.


**`only_self` functions no longer have init checks.** They behave as if marked `noinitcheck`.

**External functions called during initialization must be `#[only_self]`.** Init nullifiers are emitted at the end of the initializer, so any external functions called on the initializing contract (e.g. via `enqueue_self` or `call_self`) during initialization will fail the init check unless they skip it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
**External functions called during initialization must be `#[only_self]`.** Init nullifiers are emitted at the end of the initializer, so any external functions called on the initializing contract (e.g. via `enqueue_self` or `call_self`) during initialization will fail the init check unless they skip it.
**External functions called during private initialization must be `#[only_self]`.*


**External functions called during initialization must be `#[only_self]`.** Init nullifiers are emitted at the end of the initializer, so any external functions called on the initializing contract (e.g. via `enqueue_self` or `call_self`) during initialization will fail the init check unless they skip it.

**Breaking change for deployment:** If your contract has public functions and a private initializer, the class must be registered onchain before initialization. You can no longer pass `skipClassPublication: true`, because the auto-enqueued public call requires the class to be available.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
**Breaking change for deployment:** If your contract has public functions and a private initializer, the class must be registered onchain before initialization. You can no longer pass `skipClassPublication: true`, because the auto-enqueued public call requires the class to be available.
**Breaking change for deployment:** If your contract has external public functions and a private initializer, the class must be registered onchain before initialization. You can no longer pass `skipClassPublication: true`, because the auto-enqueued public call requires the class to be available.

Comment on lines +26 to +28
/// Returns `true` if the module has any public functions that require initialization checks (i.e. that don't have
/// `#[noinitcheck]`). If all public functions skip init checks, there's no point emitting the public init nullifier
/// since nothing will check it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do this, it then means that mark_as_initialized_public is a dangerous functiont hat should never called manually. All of the functions in that mod are pub because technically they are in user code (used by macros). We should make it clear in their docs that they should not be used unless you know very well what you're doing.

Comment on lines +40 to +47
// Two separate init nullifiers exist because private nullifiers are committed before public execution begins. If a
// private initializer emitted a single nullifier and an initializer function enqueued a public call to do public
// initialization, then other previously enqueued public functions would observe the initialization nullifier as
// existing _before_ the public initialization code ran.
//
// Private initializers emit only the private nullifier, then enqueue `__emit_public_init_nullifier` (an auto-generated
// external public function) so the public nullifier is emitted in public. Public initializers emit both nullifiers
// directly via `mark_as_initialized_from_public_initializer`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not have a duplicate description. I think this is a bit more detailed in some respects than the apiref docs for the init fn, so I'd merge this with that, put it there and remove from here.

// TODO(F-194): This leaks whether a contract has been initialized, since anyone who knows the address can compute this
// nullifier and check for its existence
fn compute_private_init_nullifier(address: AztecAddress) -> Field {
address.to_field()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(not just that, this is also not domain separated)

Comment on lines +67 to +75
fn public_fn_init_check_write_value(owner: AztecAddress, value: Field) {
self.storage.public_values.at(owner).write(value);
}

#[external("public")]
#[noinitcheck]
#[view]
fn public_fn_no_init_check_read_value(owner: AztecAddress) -> pub Field {
self.storage.public_values.at(owner).read()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not call these 'pub_no_init_check' and 'pub_init_check'? It'd make these tests easier to read.

@nchamo nchamo enabled auto-merge (squash) March 12, 2026 11:18
@nchamo nchamo merged commit cee97a1 into merge-train/fairies Mar 12, 2026
10 checks passed
@nchamo nchamo deleted the feat/public-init-nullifier branch March 12, 2026 11:43
@AztecBot
Copy link
Collaborator

❌ Failed to cherry-pick to v4-next due to conflicts. (🤖) View backport run.

AztecBot pushed a commit that referenced this pull request Mar 12, 2026
…with public functions (#20775)

Cherry-pick of cee97a1 with conflict markers preserved for reviewer visibility.
AztecBot pushed a commit that referenced this pull request Mar 12, 2026
…with public functions (#20775)

Cherry-pick of cee97a1 with conflicts.
AztecBot added a commit that referenced this pull request Mar 12, 2026
Resolved conflicts in:
- docs/docs-developers/docs/resources/migration_notes.md
- noir-projects/aztec-nr/aztec/src/macros/aztec.nr
- noir-projects/noir-protocol-circuits/crates/types/src/constants.nr
- noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr
- yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts
ludamad pushed a commit that referenced this pull request Mar 12, 2026
…with public functions (#20775)

Cherry-pick of cee97a1 with conflicts.
ludamad pushed a commit that referenced this pull request Mar 12, 2026
Resolved conflicts in:
- docs/docs-developers/docs/resources/migration_notes.md
- noir-projects/aztec-nr/aztec/src/macros/aztec.nr
- noir-projects/noir-protocol-circuits/crates/types/src/constants.nr
- noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr
- yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts
ludamad pushed a commit that referenced this pull request Mar 12, 2026
…with public functions (#20775)

Cherry-pick of cee97a1 with conflict markers preserved for reviewer visibility.
github-merge-queue bot pushed a commit that referenced this pull request Mar 13, 2026
BEGIN_COMMIT_OVERRIDE
fix: skip oracle version check for pinned protocol contracts (#21349)
fix: not reusing tags of partially reverted txs (#20817)
feat: move storage_slot from partial commitment to completion hash
(#21351)
feat: offchain reception (#20893)
fix: handle workspace members in needsRecompile crate collection
(#21284)
fix(aztec-nr): return Option from decode functions and fix event
commitment capacity (#21264)
fix: handle bad note lengths on compute_note_hash_and_nullifier (#21271)
fix: address review feedback from PRs #21284 and #21237 (#21369)
fix: claim contract & improve nullif docs (#21234)
feat!: auto-enqueue public init nullifier for contracts with public
functions (#20775)
fix: search for all note nonces instead of just the one for the note
index (#21438)
fix: set anvilSlotsInAnEpoch in e2e_offchain_payment to prevent
finalization race (#21452)
fix: complete legacy oracle mappings for all pinned contracts (#21404)
fix: correct inverted constrained encryption check in message delivery
(#21399)
feat!: improve L2ToL1MessageWitness API (#21231)
END_COMMIT_OVERRIDE
AztecBot pushed a commit that referenced this pull request Mar 13, 2026
…with public functions (#20775)

Cherry-pick of cee97a1 with conflicts.
AztecBot added a commit that referenced this pull request Mar 13, 2026
Resolved conflicts in:
- docs/docs-developers/docs/resources/migration_notes.md
- noir-projects/aztec-nr/aztec/src/macros/aztec.nr
- noir-projects/noir-protocol-circuits/crates/types/src/constants.nr
- noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr
- yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts
AztecBot pushed a commit that referenced this pull request Mar 13, 2026
…with public functions (#20775)

Cherry-pick of cee97a1 with conflicts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-to-v4-next ci-draft Run CI on draft PRs. ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants