# Initializerless contracts: the immutables macro

**URL:** <https://forum.aztec.network/t/initializerless-contracts-the-immutables-macro/8512>\
**Category:** Aztec\
**Created:** [March 3, 2026, 2:13pm UTC](https://forum.aztec.network/t/initializerless-contracts-the-immutables-macro/8512 "2026-03-03T14:13:22Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![wonderland](https://dub1.discourse-cdn.com/flex013/user_avatar/forum.aztec.network/wonderland/32/2437_2.png) [@wonderland](https://forum.aztec.network/u/wonderland)\
**Post date:** [March 3, 2026, 2:13pm UTC](https://forum.aztec.network/t/initializerless-contracts-the-immutables-macro/8512/1 "2026-03-03T14:13:22Z")

</div>

### TL;DR

For contracts with private immutable state variables, we developed an `immutables` macro that parallels `storage` but doesn’t require an initialization transaction, and we wrapped it in a library. The immutable values are committed to in the contract address instead, and its contents are fed via capsule and constrained to the commitment afterwards. As a proof of concept, we developed an initializerless Schnorr account contract.

The latest working version can be found [here](https://github.com/defi-wonderland/aztec-immutables-macro/tree/1018c1f265c9d14a7130846c6757268441f7bae5), along with extensive usage documentation. The `dev` and `main` branches are pending deployment.

## Context

Contract addresses on Aztec are [derived](https://docs.aztec.network/developers/docs/foundational-topics/accounts/keys#address-derivation) as a commitment to

- Protocol-enshrined public keys: nullifier key, viewing key, and other.
- Contract class: public functions bytecode, private functions’ verifying keys’ tree’s root, etc
- Initialization data: constructor selector and arguments, deployer, salt

This means all valid contracts already exist, without the need of any deployment. We’ll use the term contract instance to refer to a contract that’s committed to in a specific address.

There are a few deploy-like operations that developers may require in order to operate said contracts:

- [Contract class publication](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-nightly.20260205/yarn-project/aztec.js/src/deployment/publish_class.ts#L16): registers a specific contract class ID in the [`ContractClassRegistry`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-nightly.20260205/noir-projects/noir-contracts/contracts/protocol/contract_class_registry_contract/src/main.nr#L3), which is then referenced by published contract instances
- [Contract instance publication](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-nightly.20260205/yarn-project/aztec.js/src/deployment/publish_instance.ts#L12): registers a specific contract instance in the [`ContractInstanceRegistry`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-nightly.20260205/noir-projects/noir-contracts/contracts/protocol/contract_instance_registry_contract/src/main.nr#L6). This is needed for the proving of public function execution by provers. It also allows contract class [upgradeability](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-nightly.20260205/noir-projects/noir-contracts/contracts/protocol/contract_instance_registry_contract/src/main.nr#L186).
- Initialization: required to define the starting state of the contract, including the values that will remain constant throughout the use of the contract.
- [Contract registration in the PXE](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-nightly.20260205/yarn-project/pxe/src/storage/contract_store/contract_store.ts#L57): in order to interact locally with the contract. This may involve either registering the full artifact (with function signatures, etc) or just the contract instance

The following table summarizes what is required in order to use different kind of objects

| Action | Requires publishing | Requires initialization | Requires PXE registration |
| --- | --- | --- | --- |
| Public functions | ✅ | ❌ | ✅ |
| Private functions | ❌ | ❌ | ✅ |
| Contracts with setup | ❌ | ✅ | ✅ |
| Contracts with no setup | ❌ | ❌ | ✅ |

Everything requires PXE registration because TXs must be simulated in order to know what they do. Everything that requires the _prover_ and _sequencer_ to know something requires publishing, and everything that requires non-trivial setup requires initialization.

❕

Some variable types, like `BalanceSet`, which leverages oracle calls in order to let the user prove its value is higher than a certain value, aren’t _constant_ but don’t really need initialization.

However, some contract’s setup only involves setting up instance-related constant values into their storage, for further use.

For example, the following code is an extract of the Schnorr account contract’s code

```rust
#[derive(Eq, Packable)]
#[note]
pub struct PublicKeyNote {
    pub x: Field,
    pub y: Field,
}

#[storage]
struct Storage<Context> {
    signing_public_key: SinglePrivateImmutable<PublicKeyNote, Context>,
}

#[external("private")]
#[initializer]
fn constructor(signing_pub_key_x: Field, signing_pub_key_y: Field) {
    let pub_key_note = PublicKeyNote { x: signing_pub_key_x, y: signing_pub_key_y };
    // boilerplate code omitted
    self.storage.signing_public_key.initialize(pub_key_note).deliver(
        MessageDelivery.ONCHAIN_CONSTRAINED,
    );
}

#[contract_library_method]
fn is_valid_impl(context: &mut PrivateContext, outer_hash: Field) -> bool {
    let storage = Storage::init(context);
    let public_key = storage.signing_public_key.get_note();
    
    // omitting code that uses the `public_key`
}

```

The only purpose for the initializer is to set the value for `signing_public_key`, which emits a single nullifier and a note hash, which later allows for the proving of the account contract’s public key in order to pass the `is_valid_impl` test.

### Initialization in detail

Contracts that have at least one `#[initializer]` function are expected to be initialized before being used. To this end, all `#[initializer]` functions emit a deterministic nullifier associated solely with the contract’s address — specifically, the contract address itself, which gets siloed by the kernel and ends up being `poseidon2_hash([DOM_SEP__SILOED_NULLIFIER, contract_address, contract_address])`.

This prevents further calls to `#[initializer]` methods. Furthermore, all functions that aren’t marked with the `#[noinitcheck]` macro are prepended an initialization check (via constraining the existence of said nullifier).

We Wondered whether we could avoid this initialization in contracts whose state is constant, and came up with the idea of the `immutables` macro that we’re presenting below.

## The `immutables` macro

With the `immutables` macro, the above code becomes

```rust
#[derive(Eq, Packable)]
#[note]
pub struct PublicKey {
    pub x: Field,
    pub y: Field,
}

#[immutables]
struct Immutables<Context> {
    signing_public_key: PublicKey,
}

#[contract_library_method]
fn is_valid_impl(context: &mut PrivateContext, outer_hash: Field) -> bool {
    let immutables = Immutables::init(context);
    let public_key = immutables.signing_public_key;
    
    // omitting code that uses the `public_key`
}

```

We’re doing without the `constructor` function, while the remaining code behaves similarly.

We made the following key design choices:

- It works through a commitment to _values_, and is thus not memory-layout aware
- It’s interoperable with `storage`
- It works even for contracts that use `initializer`s

The first decision implies the type of the members cannot have a storage slot. Instead of representing stored notes (e.g., `SinglePrivateImmutable<PublicKeyNote, Context>`), the values are stored themselves (`PublicKey`). This aligns semantically with the struct being named `Immutables`.

The second decision allows a developer to use `storage` for that purpose, and even being able to develop contracts that have both of them and no initializer.

### Commitment and retrieval

[Contract instances](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-nightly.20260205/noir-projects/noir-protocol-circuits/crates/types/src/contract_instance.nr#L9-L16) are defined by the following fields:

- `salt`
- `deployer`
- `initialization_hash`
- `contract_class_id`
- `public_keys`

Ideally, we’d also have a commitment field to the immutables during address derivation. In its absence, the `salt` and the `initialization_hash` can be _abused_ to this end, as their values aren’t relevant to any logic external to the contract.

The decision doesn’t matter for contracts that don’t need initialization, but using the `initialization_hash` would make this macro incompatible with contracts that need initialization. Hence, we decided to abuse the `salt` [as follows](https://github.com/defi-wonderland/aztec-immutables-macro/blob/1018c1f265c9d14a7130846c6757268441f7bae5/src/nr/immutables/src/macro.nr#L116-L146) (pseudocode):

```rust
salt = poseidon2(concatenate([actual_salt], serialized_immutables))

```

I.e., the concatenation of a meaningless value that acts as the actual salt, and the serialized `Immutables` struct. Paraphrasing the actual code:

```rust
// Load capsule. First element is the *actual salt*. The remaining is the serialized data.
let capsule_data: [Field; 1 + $serialized_len] = unsafe { ... };

let salt = poseidon2_hash(capsule_data);

let instance = get_contract_instance(address);

assert_eq(salt, instance.salt, "Immutables do not match contract salt");

let mut serialized_immutables: [Field; $serialized_len] = [0; $serialized_len];
for i in 0..$serialized_len {
    serialized_immutables[i] = capsule_data[i + 1];
}

deserialize(serialized_immutables)

```

### JS/TS syntax

```rust
import { Fr } from "@aztec/aztec.js/fields";
import { deployWithImmutables, serializeFromLayout } from "./immutables/utils.js";
import { MyContractArtifact } from "../artifacts/MyContract.js";

// Serialize using the artifact's #[abi(immutables)] layout
// Field names must match the Noir struct (snake_case)
const serialized = serializeFromLayout(MyContractArtifact, {
  signing_key_x: new Fr(111n),
  signing_key_y: new Fr(222n),
});

// Deploy — handles salt derivation, PXE registration, and store_immutables
const { instance, capsuleData } = await deployWithImmutables(
  wallet,
  MyContractArtifact,
  serialized,
  { publishClass: true, publishInstance: true }, // optional
);

// Use instance at will

```

The deployment function persistently `store`s the capsule’s value in the PXE, so that further uses needn’t re-loading the contents.

### Benchmark

We ran benchmarks by calling the standard token’s `transfer_private_to_private` and `transfer_private_to_public` both with a standard Schnorr account contract which uses `storage` and a variation that uses `immutables`.

Both methods differed only in the gate count for the `entrypoint` function, which fetches the public key in order to authorize the TXs, with the `immutables` approach taking 1098 more gates out of approx 500 k gates. This represents an increase in proving time of 0.2%.

On the other hand, the use of `storage` requires a one-time call to the `constructor` function. We also ran a benchmark on the initialization TX itself, which consumed:

- Total DA gas: 12288
- Total L2 gas: 512
- Total gates: 516258

Considering that initialization can be batched with another call, the actual contribution is smaller, and pertains the proving of the `constructor` function call as well as the ensuing `private_kernel_inner` call, for which the following gate count has been obtained:

- `constructor`: 8636
- The next `private_kernel_inner`: 102289

This gives a lower bound of 110925 gates of difference for initialization, which implies the _total_ gate count over time will be lower for immutables until a certain number of calls to `Immutables::init` are performed. In this specific case, this number is around 100 calls.

## Conclusions

Regarding devex, the `immutables` macro avoids a client-side initialization check, and the one-time initialization transaction. Instead, stateless contracts just “exist”.

Regarding performance, we found that `immutables` does marginally worse than `storage` per-use, but avoids the one-time performance penalty of the initialization transaction.
