Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d588e8c
feat!: auto-enqueue public init nullifier for contracts with public f…
nchamo Feb 23, 2026
0e5db75
fix: remove skip flags and fix updatable contract for public init nul…
nchamo Feb 23, 2026
ad2b242
fix: remove skip flags from e2e_ha_full and pre-publish class in web3…
nchamo Feb 23, 2026
6a622ce
fix: actually send publishContractClass in web3signer e2e test
nchamo Feb 23, 2026
f748e4b
fix: pr review changes
nchamo Feb 27, 2026
c2184a6
fix: update comments on test contracts and initialization_utils
nchamo Feb 27, 2026
e2d3423
Merge remote-tracking branch 'origin/merge-train/fairies' into feat/p…
nchamo Feb 27, 2026
bcc1d63
fix: use onchain instead of on-chain in migration notes
nchamo Feb 27, 2026
7cb5443
refactor: pass InjectedPublicFunction struct to generate_public_dispatch
nchamo Mar 2, 2026
1488a52
fix: address PR comments
nchamo Mar 3, 2026
c3bcee1
fix: remove unnecessary mut in dispatch.nr
nchamo Mar 3, 2026
e5894af
Apply suggestions from code review
nchamo Mar 4, 2026
528e38f
docs: improve only_self noinitcheck doc comment
nchamo Mar 4, 2026
772302b
test: update private initialization e2e test
nchamo Mar 4, 2026
7e8877d
Merge remote-tracking branch 'origin/merge-train/fairies' into feat/p…
nchamo Mar 4, 2026
5b84fbe
docs: reflow only_self doc comment to 120 char width
nchamo Mar 4, 2026
39f5d57
refactor: inline dispatch logic, extract emit_public_init_nullifier m…
nchamo Mar 6, 2026
c389405
docs: fix dispatch comment grammar
nchamo Mar 6, 2026
3535ec3
Apply suggestions from code review
nchamo Mar 9, 2026
20249d5
Merge remote-tracking branch 'origin/merge-train/fairies' into feat/p…
nchamo Mar 9, 2026
8aec344
fix(e2e): use DEFAULT_DA_GAS_LIMIT in e2e_sequencer_config test
nchamo Mar 9, 2026
91cf5c7
fix: address self-review items on PR #20775
nchamo Mar 10, 2026
dc54c88
fix: address review comments on PR #20775
nchamo Mar 12, 2026
a122c76
fix: reflow doc comment to avoid awkward nargo fmt line break
nchamo Mar 12, 2026
760a963
Merge remote-tracking branch 'origin/merge-train/fairies' into feat/p…
nchamo Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/docs-developers/docs/resources/migration_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ Aztec is in active development. Each version may introduce breaking changes that

## TBD

### Two separate init nullifiers for private and public

Contract initialization now emits two separate nullifiers instead of one: a **private init nullifier** and a **public init nullifier**. Each nullifier gates its respective execution domain:

- Private external functions check the private init nullifier.
- Public external functions check the public init nullifier.

**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
Copy Markdown
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.

- **Public initializers** emit both nullifiers directly.
- Contracts with no public functions only emit the private 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]` or `#[noinitcheck]`.** 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.
Comment thread
nchamo marked this conversation as resolved.
Outdated

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

```diff
const deployed = await MyContract.deploy(wallet, ...args).send({
- skipClassPublication: true,
}).deployed();
```

### `aztec new` and `aztec init` now create a 2-crate workspace

`aztec new` and `aztec init` now create a workspace with two crates instead of a single contract crate:
Expand Down
62 changes: 58 additions & 4 deletions noir-projects/aztec-nr/aztec/src/macros/aztec.nr
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ use crate::macros::{
internal_functions::generate_call_internal_struct,
},
dispatch::generate_public_dispatch,
internals_functions_generation::{create_fn_abi_exports, process_functions},
functions::initialization_utils::EMIT_PUBLIC_INIT_NULLIFIER_FN_NAME,
internals_functions_generation::{
create_fn_abi_exports,
external_functions_registry::{get_private_functions, get_public_functions},
process_functions,
},
notes::NOTES,
storage::STORAGE_LAYOUT_NAME,
utils::{
get_trait_impl_method, is_fn_contract_library_method, is_fn_external, is_fn_internal, is_fn_test,
module_has_storage,
get_trait_impl_method, is_fn_contract_library_method, is_fn_external, is_fn_initializer, is_fn_internal,
is_fn_test, module_has_storage,
},
};

Expand Down Expand Up @@ -53,7 +58,9 @@ pub comptime fn aztec(m: Module) -> Quoted {
} else {
quote {}
};
let public_dispatch = generate_public_dispatch(m);
let (injected_fn_names, injected_fn_bodies) = generate_injected_public_fns(m);
let injected_fn_bodies = injected_fn_bodies.join(quote {});
let public_dispatch = generate_public_dispatch(m, injected_fn_names);

quote {
$interface
Expand All @@ -65,6 +72,7 @@ pub comptime fn aztec(m: Module) -> Quoted {
$public_dispatch
$sync_state_fn_and_abi_export
$process_message_fn_and_abi_export
$injected_fn_bodies
}
}

Expand Down Expand Up @@ -314,6 +322,52 @@ comptime fn generate_process_message() -> Quoted {
}
}

/// Returns injected public function names and their definitions.
///
/// Sometimes we need to inject public functions into contracts that can be called by other functions
/// (e.g. a private initializer enqueuing a public call). We can't add these via `Module.add_item()`
/// because Noir resolves code immediately on injection, and resolved functions behave differently
/// from unresolved ones (e.g. you can't read their body or mutate their properties). Instead, we
/// return the function definitions as quoted code to be spliced into the module, and handle their
/// dispatch explicitly via `generate_public_dispatch`.
comptime fn generate_injected_public_fns(m: Module) -> ([Quoted], [Quoted]) {
let mut names: [Quoted] = @[];
let mut bodies: [Quoted] = @[];

let has_private_initializer = get_private_functions(m).any(|f: FunctionDefinition| is_fn_initializer(f));
let has_public_functions = get_public_functions(m).len() > 0;
if has_private_initializer & has_public_functions {
let (name, body) = generate_emit_public_init_nullifier();
names = names.push_back(name);
bodies = bodies.push_back(body);
}

(names, bodies)
}

/// Generates a function that emits the public init nullifier.
///
/// Needed when a contract has a private initializer and public functions. Private initializers only
/// emit the private init nullifier during private execution. The public init nullifier must be
/// emitted during public execution, so the private initializer enqueues a call to this
/// auto-generated function to do it.
comptime fn generate_emit_public_init_nullifier() -> (Quoted, Quoted) {
let name = EMIT_PUBLIC_INIT_NULLIFIER_FN_NAME;
let assertion_message = f"Function {name} can only be called by the same contract".as_quoted_str();
let body = quote {
#[aztec::macros::internals_functions_generation::abi_attributes::abi_public]
unconstrained fn $name() {
let context = aztec::context::PublicContext::new(|| { 0 });
assert(
context.maybe_msg_sender().unwrap() == context.this_address(),
$assertion_message,
);
aztec::macros::functions::initialization_utils::mark_as_initialized_public(context);
}
};
(name, body)
}

/// Checks that all functions in the module have a context macro applied.
///
/// Non-macroified functions are not allowed in contracts. They must all be one of
Expand Down
215 changes: 124 additions & 91 deletions noir-projects/aztec-nr/aztec/src/macros/dispatch.nr
Original file line number Diff line number Diff line change
Expand Up @@ -2,116 +2,149 @@ use crate::macros::internals_functions_generation::external_functions_registry::
use crate::protocol::meta::utils::get_params_len_quote;
use crate::utils::cmap::CHashMap;
use super::utils::compute_fn_selector;
use std::panic;
use std::meta::{ctstring::AsCtString, unquote};

/// Returns an `fn public_dispatch(...)` function for the given module that's assumed to be an Aztec contract.
Comment thread
nchamo marked this conversation as resolved.
Outdated
pub comptime fn generate_public_dispatch(m: Module) -> Quoted {
let functions = get_public_functions(m);

let unit = get_type::<()>();
///
/// `injected_fn_names` is a list of function names for auto-generated dispatch cases. Each name is
/// used to compute a no-arg selector and check for collisions with contract functions. The generated
/// dispatch case calls the function (which must already exist in the module) and returns.
pub comptime fn generate_public_dispatch(m: Module, injected_fn_names: [Quoted]) -> Quoted {
Comment thread
nchamo marked this conversation as resolved.
Outdated
let contract_functions = get_public_functions(m);

let seen_selectors = &mut CHashMap::<Field, Quoted>::new();

let ifs = functions.map(|function: FunctionDefinition| {
let parameters = function.parameters();
let return_type = function.return_type();

let selector: Field = compute_fn_selector(function);
let mut dispatch_cases: [Quoted] = contract_functions.map(|function: FunctionDefinition| {
let fn_name = function.name();
let call_name = f"__aztec_nr_internals__{fn_name}".quoted_contents();
generate_dispatch_case(
compute_fn_selector(function),
fn_name,
call_name,
function.parameters(),
function.return_type(),
seen_selectors,
)
});

// Since function selectors are computed as the first 4 bytes of the hash of the function signature, it's
// possible to have collisions. With the following check, we ensure it doesn't happen within the same contract.
let existing_fn = seen_selectors.get(selector);
if existing_fn.is_some() {
let existing_fn = existing_fn.unwrap();
panic(
f"Public function selector collision detected between functions '{fn_name}' and '{existing_fn}'",
);
}
seen_selectors.insert(selector, fn_name);

let params_len_quote = get_params_len_quote(parameters);

let initial_read = if parameters.len() == 0 {
quote {}
} else {
// The initial calldata_copy offset is 1 to skip the Field selector The expected calldata is the
// serialization of
// - FunctionSelector: the selector of the function intended to dispatch
// - Parameters: the parameters of the function intended to dispatch That is, exactly what is expected for
// a call to the target function, but with a selector added at the beginning.
quote {
let input_calldata: [Field; $params_len_quote] = aztec::oracle::avm::calldata_copy(1, $params_len_quote);
let mut reader = aztec::protocol::utils::reader::Reader::new(input_calldata);
}
};

let parameter_index: &mut u32 = &mut 0;
let reads = parameters.map(|param: (Quoted, Type)| {
let parameter_index_value = *parameter_index;
let param_name = f"arg{parameter_index_value}".quoted_contents();
let param_type = param.1;
let read = quote {
let $param_name: $param_type = aztec::protocol::traits::Deserialize::stream_deserialize(&mut reader);
};
*parameter_index += 1;
quote { $read }
});
let read = reads.join(quote { });

let mut args = @[];
for parameter_index in 0..parameters.len() {
let param_name = f"arg{parameter_index}".quoted_contents();
args = args.push_back(quote { $param_name });
}

// We call a function whose name is prefixed with `__aztec_nr_internals__`. This is necessary because the
// original function is intentionally made uncallable, preventing direct invocation within the contract.
// Instead, a new function with the same name, but prefixed by `__aztec_nr_internals__`, has been generated to
// be called here. For more details see the `process_functions` function.
let name = f"__aztec_nr_internals__{fn_name}".quoted_contents();
let args = args.join(quote { , });
let call = quote { $name($args) };

let return_code = if return_type == unit {
quote {
$call;
// Force early return.
aztec::oracle::avm::avm_return([]);
}
} else {
quote {
let return_value = aztec::protocol::traits::Serialize::serialize($call);
aztec::oracle::avm::avm_return(return_value.as_vector());
}
};

let if_ = quote {
if selector == $selector {
$initial_read
$read
$return_code
}
};
if_
let injected_cases: [Quoted] = injected_fn_names.map(|fn_name: Quoted| {
generate_dispatch_case(
compute_no_arg_selector(fn_name),
fn_name,
fn_name,
@[],
get_type::<()>(),
seen_selectors,
)
});

if ifs.len() == 0 {
// No dispatch function if there are no public functions
dispatch_cases = dispatch_cases.append(injected_cases);

if dispatch_cases.len() == 0 {
quote {}
} else {
let ifs = ifs.push_back(quote { panic(f"Unknown selector {selector}") });
let dispatch = ifs.join(quote { });
dispatch_cases = dispatch_cases.push_back(quote { panic(f"Unknown selector {selector}") });
let dispatch = dispatch_cases.join(quote { });

let body = quote {
quote {
// We mark this as public because our whole system depends on public functions having this attribute.
#[aztec::macros::internals_functions_generation::abi_attributes::abi_public]
pub unconstrained fn public_dispatch(selector: Field) {
$dispatch
}
}
}
}

comptime fn compute_no_arg_selector(fn_name: Quoted) -> Field {
let signature = f"{fn_name}()".as_ctstring();
let computation_quote = quote {
crate::protocol::traits::ToField::to_field(crate::protocol::abis::function_selector::FunctionSelector::from_signature($signature))
};
unquote!(computation_quote)
}

/// Generates a single dispatch case: an `if` block that matches on the selector, deserializes
/// calldata into arguments, calls the target function, and returns the result.
comptime fn generate_dispatch_case(
selector: Field,
display_name: Quoted,
call_name: Quoted,
parameters: [(Quoted, Type)],
return_type: Type,
seen_selectors: &mut CHashMap<Field, Quoted>,
) -> Quoted {
let unit = get_type::<()>();

// Since function selectors are computed as the first 4 bytes of the hash of the function signature, it's
// possible to have collisions. With the following check, we ensure it doesn't happen within the same contract.
let existing_fn = seen_selectors.get(selector);
if existing_fn.is_some() {
let existing_fn = existing_fn.unwrap();
panic(
f"Public function selector collision detected between functions '{display_name}' and '{existing_fn}'",
);
}
seen_selectors.insert(selector, display_name);

let params_len_quote = get_params_len_quote(parameters);

let initial_read = if parameters.len() == 0 {
quote {}
} else {
// The initial calldata_copy offset is 1 to skip the Field selector. The expected calldata is the
// serialization of
// - FunctionSelector: the selector of the function intended to dispatch
// - Parameters: the parameters of the function intended to dispatch
// That is, exactly what is expected for a call to the target function, but with a selector added
// at the beginning.
quote {
let input_calldata: [Field; $params_len_quote] = aztec::oracle::avm::calldata_copy(1, $params_len_quote);
let mut reader = aztec::protocol::utils::reader::Reader::new(input_calldata);
}
};

let parameter_index: &mut u32 = &mut 0;
let reads = parameters.map(|param: (Quoted, Type)| {
let parameter_index_value = *parameter_index;
let param_name = f"arg{parameter_index_value}".quoted_contents();
let param_type = param.1;
let read = quote {
let $param_name: $param_type = aztec::protocol::traits::Deserialize::stream_deserialize(&mut reader);
};
*parameter_index += 1;
quote { $read }
});
let read = reads.join(quote { });

let mut args = @[];
for parameter_index in 0..parameters.len() {
let param_name = f"arg{parameter_index}".quoted_contents();
args = args.push_back(quote { $param_name });
}

let args = args.join(quote { , });
let call = quote { $call_name($args) };

body
let return_code = if return_type == unit {
quote {
$call;
// Force early return.
aztec::oracle::avm::avm_return([]);
}
} else {
quote {
let return_value = aztec::protocol::traits::Serialize::serialize($call);
aztec::oracle::avm::avm_return(return_value.as_vector());
}
};

quote {
if selector == $selector {
$initial_read
$read
$return_code
}
}
}

Expand Down
Loading