Initializerless contracts: the immutables macro

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, along with extensive usage documentation. The dev and main branches are pending deployment.

Context

Contract addresses on Aztec are derived 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:

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 :white_check_mark: :cross_mark: :white_check_mark:
Private functions :cross_mark: :cross_mark: :white_check_mark:
Contracts with setup :cross_mark: :white_check_mark: :white_check_mark:
Contracts with no setup :cross_mark: :cross_mark: :white_check_mark:

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.

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

#[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

#[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 initializers

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 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 (pseudocode):

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:

// 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

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 stores 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.

6 Likes