Function Attributes and Macros
On this page you will learn about function attributes and macros.
If you are looking for a reference of function macros, go here.
Private functions #[private]
A private function operates on private information, and is executed by the user on their device. Annotate the function with the #[private]
attribute to tell the compiler it's a private function. This will make the private context available within the function's execution scope. The compiler will create a circuit to define this function.
#[private]
is just syntactic sugar. At compile time, the Aztec.nr framework inserts code that allows the function to interact with the kernel.
To help illustrate how this interacts with the internals of Aztec and its kernel circuits, we can take an example private function, and explore what it looks like after Aztec.nr's macro expansion.
Before expansion
#[private]
fn simple_macro_example(a: Field, b: Field) -> Field {
a + b
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L286-L291
After expansion
fn simple_macro_example_expanded(
// ************************************************************
// The private context inputs are made available to the circuit by the kernel
inputs: PrivateContextInputs,
// ************************************************************
// Our original inputs!
a: Field,
b: Field, // The actual return type of our circuit is the PrivateCircuitPublicInputs struct, this will be the
// input to our kernel!
) -> pub PrivateCircuitPublicInputs {
// ************************************************************
// The hasher is a structure used to generate a hash of the circuits inputs.
let mut args_hasher = dep::aztec::hash::ArgsHasher::new();
args_hasher.add(a);
args_hasher.add(b);
// The context object is created with the inputs and the hash of the inputs
let mut context = PrivateContext::new(inputs, args_hasher.hash());
let mut storage = Storage::init(&mut context);
// ************************************************************
// Our actual program
let result = a + b;
// ************************************************************
// Return values are pushed into the context
let mut return_hasher = dep::aztec::hash::ArgsHasher::new();
return_hasher.add(result);
context.set_return_hash(return_hasher);
// The context is returned to be consumed by the kernel circuit!
context.finish()
// ************************************************************
}
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L292-L337
The expansion broken down
Viewing the expanded Aztec contract uncovers a lot about how Aztec contracts interact with the kernel. To aid with developing intuition, we will break down each inserted line.
Receiving context from the kernel.
inputs: PrivateContextInputs,
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L296-L298
Private function calls are able to interact with each other through orchestration from within the kernel circuits. The kernel circuit forwards information to each contract function (recall each contract function is a circuit). This information then becomes part of the private context.
For example, within each private function we can access some global variables. To access them we can call on the context
, e.g. context.chain_id()
. The value of the chain ID comes from the values passed into the circuit from the kernel.
The kernel checks that all of the values passed to each circuit in a function call are the same.
Returning the context to the kernel.
) -> pub PrivateCircuitPublicInputs {
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L304-L306
The contract function must return information about the execution back to the kernel. This is done through a rigid structure we call the PrivateCircuitPublicInputs
.
Why is it called the
PrivateCircuitPublicInputs
? When verifying zk programs, return values are not computed at verification runtime, rather expected return values are provided as inputs and checked for correctness. Hence, the return values are considered public inputs.
This structure contains a host of information about the executed program. It will contain any newly created nullifiers, any messages to be sent to l2 and most importantly it will contain the return values of the function.
Hashing the function inputs.
let mut args_hasher = dep::aztec::hash::ArgsHasher::new();
args_hasher.add(a);
args_hasher.add(b);
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L309-L313
What is the hasher and why is it needed?
Inside the kernel circuits, the inputs to functions are reduced to a single value; the inputs hash. This prevents the need for multiple different kernel circuits; each supporting differing numbers of inputs. The hasher abstraction that allows us to create an array of all of the inputs that can be reduced to a single value.
Creating the function's context.
let mut context = PrivateContext::new(inputs, args_hasher.hash());
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L315-L317
Each Aztec function has access to a context object. This object, although labelled a global variable, is created locally on a users' device. It is initialized from the inputs provided by the kernel, and a hash of the function's inputs.
let mut return_hasher = dep::aztec::hash::ArgsHasher::new();
return_hasher.add(result);
context.set_return_hash(return_hasher);
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L326-L330
We use the kernel to pass information between circuits. This means that the return values of functions must also be passed to the kernel (where they can be later passed on to another function). We achieve this by pushing return values to the execution context, which we then pass to the kernel.
Making the contract's storage available
let mut storage = Storage::init(&mut context);
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L318-L320
When a Storage
struct is declared within a contract, the storage
keyword is made available. As shown in the macro expansion above, this calls the init function on the storage struct with the current function's context.
Any state variables declared in the Storage
struct can now be accessed as normal struct members.
Returning the function context to the kernel.
context.finish()
Source code: noir-projects/noir-contracts/contracts/docs_example_contract/src/main.nr#L332-L334
This function takes the application context, and converts it into the PrivateCircuitPublicInputs
structure. This structure is then passed to the kernel circuit.
Unconstrained functions
Unconstrained functions are an underlying part of Noir. In short, they are functions which are not directly constrained and therefore should be seen as untrusted. That they are un-trusted means that the developer must make sure to constrain their return values when used. Note: Calling an unconstrained function from a private function means that you are injecting unconstrained values.
Defining a function as unconstrained
tells Aztec to simulate it completely client-side in the ACIR simulator without generating proofs. They are useful for extracting information from a user through an oracle.
When an unconstrained function is called, it prompts the ACIR simulator to
- generate the execution environment
- execute the function within this environment
To generate the environment, the simulator gets the blockheader from the PXE database and passes it along with the contract address to ViewDataOracle
. This creates a context that simulates the state of the blockchain at a specific block, allowing the unconstrained function to access and interact with blockchain data as it would appear in that block, but without affecting the actual blockchain state.
Once the execution environment is created, execute_unconstrained_function
is invoked:
/**
* Execute an unconstrained function and return the decoded values.
*/
export async function executeUnconstrainedFunction(
oracle: ViewDataOracle,
artifact: FunctionArtifact,
contractAddress: AztecAddress,
functionSelector: FunctionSelector,
args: Fr[],
log = createLogger('simulator:unconstrained_execution'),
): Promise<AbiDecoded> {
log.verbose(`Executing unconstrained function ${contractAddress}:${functionSelector}(${artifact.name})`);
const acir = artifact.bytecode;
const initialWitness = toACVMWitness(0, args);
const acirExecutionResult = await acvm(acir, initialWitness, new Oracle(oracle)).catch((err: Error) => {
err.message = resolveAssertionMessageFromError(err, artifact);
throw new ExecutionError(
err.message,
{
contractAddress,
functionSelector,
},
extractCallStack(err, artifact.debug),
{ cause: err },
);
});
const returnWitness = witnessMapToFields(acirExecutionResult.returnWitness);
return decodeFromAbi(artifact.returnTypes, returnWitness);
}
Source code: yarn-project/simulator/src/client/unconstrained_execution.ts#L11-L43
This:
- Prepares the ACIR for execution
- Converts
args
into a format suitable for the ACVM (Abstract Circuit Virtual Machine), creating an initial witness (witness = set of inputs required to compute the function).args
might be an oracle to request a user's balance - Executes the function in the ACVM, which involves running the ACIR with the initial witness and the context. If requesting a user's balance, this would query the balance from the PXE database
- Extracts the return values from the
partialWitness
and decodes them based on the artifact to get the final function output. The artifact is the compiled output of the contract, and has information like the function signature, parameter types, and return types
Beyond using them inside your other functions, they are convenient for providing an interface that reads storage, applies logic and returns values to a UI or test. Below is a snippet from exposing the balance_of_private
function from a token implementation, which allows a user to easily read their balance, similar to the balanceOf
function in the ERC20 standard.
pub(crate) unconstrained fn balance_of_private(owner: AztecAddress) -> pub Field {
storage.balances.at(owner).balance_of().to_field()
}
Source code: noir-projects/noir-contracts/contracts/token_contract/src/main.nr#L742-L746
Note, that unconstrained functions can have access to both public and private data when executed on the user's device. This is possible since it is not actually part of the circuits that are executed in contract execution.
Public
Functions #[public]
A public function is executed by the sequencer and has access to a state model that is very similar to that of the EVM and Ethereum. Even though they work in an EVM-like model for public transactions, they are able to write data into private storage that can be consumed later by a private function.
All data inserted into private storage from a public function will be publicly viewable (not private).
To create a public function you can annotate it with the #[public]
attribute. This will make the public context available within the function's execution scope.
#[public]
fn set_minter(minter: AztecAddress, approve: bool) {
assert(storage.admin.read().eq(context.msg_sender()), "caller is not admin");
storage.minters.at(minter).write(approve);
}
Source code: noir-projects/noir-contracts/contracts/token_contract/src/main.nr#L184-L194
Under the hood:
- Context Creation: The macro inserts code at the beginning of the function to create a
PublicContext
object:
let mut context = PublicContext::new(args_hasher);
This context provides access to public state and transaction information
- Storage Access: If the contract has a storage struct defined, the macro inserts code to initialize the storage:
let storage = Storage::init(&mut context);
- Function Body Wrapping: The original function body is wrapped in a new scope that handles the context and return value
- Visibility Control: The function is marked as pub, making it accessible from outside the contract.
- Unconstrained Execution: Public functions are marked as unconstrained, meaning they don't generate proofs and are executed directly by the sequencer.
Constrained view
Functions #[view]
The #[view]
attribute is used to define constrained view functions in Aztec contracts. These functions are similar to view functions in Solidity, in that they are read-only and do not modify the contract's state. They are similar to the unconstrained
keyword but are executed in a constrained environment. It is not possible to update state within an #[view]
function.
This means the results of these functions are verifiable and can be trusted, as they are part of the proof generation and verification process. This is unlike unconstrained functions, where results are provided by the PXE and are not verified.
This makes #[view]
functions suitable for critical read-only operations where the integrity of the result is crucial. Unconstrained functions, on the other hand, are executed entirely client-side without generating any proofs. It is better to use #[view]
if the result of the function will be used in another function that will affect state, and they can be used for cross-contract calls.
#[view]
functions can be combined with other Aztec attributes like #[private]
or #[public]
.
Initializer
Functions #[initializer]
This is used to designate functions as initializers (or constructors) for an Aztec contract. These functions are responsible for setting up the initial state of the contract when it is first deployed. The macro does two important things:
assert_initialization_matches_address_preimage(context)
: This checks that the arguments and sender to the initializer match the commitments from the address preimagemark_as_initialized(&mut context)
: This is called at the end of the function to emit the initialization nullifier, marking the contract as fully initialized and ensuring this function cannot be called again
Key things to keep in mind:
- A contract can have multiple initializer functions defined, but only one initializer function should be called for the lifetime of a contract instance
- Other functions in the contract will have an initialization check inserted, ie they cannot be called until the contract is initialized, unless they are marked with
#[noinitcheck]