Aztec Inheritance

Aztec Template Composition

What is this?

Aztec Template Composition is a contract-reuse mechanism for Noir/Aztec contracts. It lets a contract — the host — absorb the full callable surface of one or more template contracts, as if the template functions had been written directly in the host.

No new Noir syntax is introduced. The mechanism operates entirely at macro level, using Noir’s comptime system to capture function wrappers in the template crate and replay them into the host at codegen time. The key trick is that a template contract is processed by #[aztec] twice: once in its own crate (where names are in scope and wrappers are generated and captured), and once as pre-generated code replayed into the host. This is what allows function bodies to cross the crate boundary without new language features.

A practical consequence: turning an existing Aztec contract into a reusable template requires only adding #[contract_template("my_id")] above its existing #[aztec] annotation. The contract continues to compile and deploy as before, and simultaneously becomes composable by any host.

The result looks like inheritance from the outside: a host composed from a TokenTemplate exposes transfer(), mint(), balance_of(), and the rest of the token surface, even though none of those appear in the host’s source file.

In practice

// ── Template crate ──────────────────────────────────────────────────────────

#[contract_template("aip20_token")]   // step 1: register — capture wrappers into global registry
#[aztec]                              // step 2: also compile as a real contract (type-checks bodies)
pub contract TokenTemplate {

    #[external("public")]
    fn transfer(from: AztecAddress, to: AztecAddress, amount: u128) {
        // body captured here, in template scope where all names resolve
        self.internal._decrease_private_balance(from, amount, MAX_NOTES);
        self.enqueue_self.increase_public_balance_internal(to, amount);
    }

    #[external("public")]
    #[view]
    fn balance_of_public(owner: AztecAddress) -> u128 {
        self.storage.public_balances.at(owner).read()
    }

    // ... full AIP-20 surface
}

// ── Host crate ───────────────────────────────────────────────────────────────

#[aztec(AztecConfig::new().compose("aip20_token"))]  // step 3: replay captured wrappers into host codegen
pub contract Amm {
    // Only AMM-specific logic is written here.
    // transfer(), balance_of_public(), and the rest of the AIP-20 surface
    // are NOT written here — yet all appear in the compiled Amm ABI and dispatch table.

    #[external("private")]
    fn swap(amount_in: u128, min_out: u128) {
        // can call composed token internals directly
        self.internal._decrease_private_balance(self.msg_sender(), amount_in, MAX_NOTES);
        // ...
    }
}

The template compiles and deploys as a standalone contract. The host compiles to a fully self-contained contract that includes every composed function as if written there. The two share no runtime relationship — the template address is not referenced anywhere in the host’s bytecode.

Why it matters

Before template composition, building a token variant — say, a pausable token or a capped token — meant copying the entire token contract and modifying it. Every variant was its own full codebase: thousands of lines duplicated, diverging over time, each requiring independent audits and maintenance. Two variants meant two copies; ten variants meant ten copies. The combinatorial surface grew with every new dimension of variation (pausable + capped + mintable-by-role), and any fix to the underlying token logic had to be propagated manually across all copies.

Template composition may change the economics of variation. The canonical token implementation could live once, as a template. A pausable variant might be a host contract that composes the token template and overrides the transfer internals to add a pause check — a few dozen lines, not a few thousand. A capped variant might override _increase_total_supply. A role-gated mint variant might override mint_to_public. Each variant could be expressed only as its delta from the base, with the base shared and maintained in one place.

This might also shift how contracts are designed in the first place. A contract authored with composition in mind could expose deliberate override points — virtual hooks at the boundaries where behavior is expected to vary. A token that marks _before_transfer as virtual may become an extensible primitive rather than a fixed implementation. DEX liquidity accounting, fee-on-transfer mechanics, transfer restrictions, and compliance hooks could become composable layers rather than forks — though how far this pattern extends in practice remains to be seen.

The mental model: merge-and-replay, not parent chains

Unlike Solidity’s is-based inheritance, which builds a parent chain and dispatches calls through it, template composition is a flat merge:

  1. Each composed template’s function wrappers are captured in the template crate, where names are in scope.
  2. Those wrappers are replayed into the host’s codegen output, appearing as if written there.
  3. The host’s codegen sees the union of all template functions plus its own, and generates dispatch, call interfaces, and ABI from that union.

The compiled host contract is entirely self-contained. There is no parent contract address, no DELEGATECALL chain, and no dispatch indirection — the host’s bytecode already includes every composed function directly.

What this is not

  • Not siloed storage. Templates have no private storage slots. All storage is declared and owned by the host; composed functions access it by name alongside any other host or template function.
  • Not a dispatch chain. There is no C3 linearization, no MRO, and no concept of ordering between composed templates.
  • Not super chaining. Overrides are full replacements. There is no way to call the original template implementation from a host override.
  • Not compiler-enforced initialization. Templates document a required initializer call; nothing prevents a host from skipping it.
  • Not abstract contracts. Every template is a valid, deployable Noir contract. There is no mechanism to prevent standalone deployment.
  • Not private scope. Composed function bodies are flat-copied into the host. There is no visibility barrier between the host and its composed templates.
  • Not an interface or ABI standard. Composing a token template into a contract does not make that contract discoverable or callable as a token by external parties. Callers need the full contract class ID and private method commitments of the specific deployed host — there is no “I know this contract implements ARC-20 so I can interact with its token surface” mechanism. Composition is a code-reuse tool, not a runtime interface declaration.

From a template writer’s perspective

When writing a template, the key mental shift is: your function bodies will execute inside a contract you do not control. This has direct consequences:

What you own:

  • Your function logic and the wrappers generated from it.
  • Constants and helpers declared as #[contract_library_method] — these migrate via typed reference and always resolve back to your implementation.

What the host owns:

  • All storage. Your functions reference storage fields by name; the host must declare them with matching names and types. If the host gets this wrong, your composed body fails at compile time inside the host, not inside your crate.

    Crucially, if two composed templates reference the same storage field name (and the host declares it), both templates will read and write the same slot — there is no per-template ownership of storage. In Solidity this situation is governed by the inheritance line: a balance declared in base A is only directly modified by A’s functions. In template composition there is no such line; two unrelated templates sharing a field name are silently sharing a slot. This is a feature when intentional (e.g. a mint template and a transfer template both operating on the same total_supply) and a bug when accidental — which is why namespace-prefixed field names are strongly recommended.

  • All global name bindings. Any unqualified symbol your body references (other than #[contract_library_method] helpers) is resolved in the host’s scope at replay time. This is intentional: it lets the host configure behavior by providing or importing the right binding, but it means you cannot rely on module-level globals from your own crate.
  • The initializer call. Your template can publish an #[internal] initializer and document that the host must call it from its constructor — but nothing enforces this. Uninitialized storage is a silent runtime failure.

Patterns to follow:

  • Use #[contract_library_method] for any constant or helper that is an implementation detail of your template and must not be configurable by the host.
  • Use unqualified global references for values you expect the host to provide or customize.
  • Prefix all your function and storage names to avoid collisions when multiple templates are composed together.
  • Document every storage field, required import, and initializer call as part of your template’s host contract.

Antipatterns to avoid:

  • Referencing module-level globals from your own crate in composable function bodies — they will not resolve in host scope.
  • Assuming the host called your initializer — document it and fail loudly in the initializer itself if preconditions can be checked.
  • Using generic names (value, counter, admin) for storage fields or functions — collisions with the host or other templates are a hard compile error.
  • Marking a function #[template_virtual] without documenting what the expected override contract is — the host has no type-level guarantee that its replacement is semantically compatible.

Template Composition vs Solidity Inheritance

The table below compares every major Solidity inheritance concept against what template composition provides:

  • IMPLEMENTED — supported
  • BY_DESIGN — intentionally handled differently
  • OUT_OF_SCOPE — concept does not map onto this model
# Concept Solidity is inheritance Aztec template composition Status
1 Reuse mechanism contract B is A copies A’s ABI and dispatch into B via a parent chain AztecConfig::new().compose("template_id") replays pre-generated wrappers into host codegen IMPLEMENTED
2 Multiple inheritance contract C is A, B with C3 linearization to resolve ordering .compose("a").compose("b") — flat union; no ordering needed because there is no dispatch chain IMPLEMENTED
3 Transitive inheritance Grandparent functions are automatically available Composing a template recursively injects its own transitive dependencies (flattened) IMPLEMENTED
4 External surface reuse Inherited public/external functions appear in child ABI Composed externals appear in host ABI and dispatch exactly like host-authored functions IMPLEMENTED
5 Internal function reuse internal functions are accessible in subcontracts Composed internals are available via self.internal._fn() in host or other composed bodies IMPLEMENTED
6 Library/helper constants pure/view functions or constants defined in base, inherited by child #[contract_library_method] functions migrate via typed reference (f.as_typed_expr()); template-owned constants do not require host redeclaration IMPLEMENTED
7 Constructor chaining Base constructor called with Base(args) syntax; child chain is automatic and compiler-enforced No automatic chaining exists; templates publish a named init internal (e.g. _initialize_token) that the host constructor is expected to call manually. Skipping it silently produces uninitialized storage — there is no compile-time enforcement OUT_OF_SCOPE
8 Virtual functions virtual keyword marks overridable functions #[template_virtual] attribute on template function; host declares override in config IMPLEMENTED
9 Overriding a function override keyword in subcontract; replaces dispatch chain entry override_template("template_id", "fn_name") in AztecConfig; host provides a replacement function body IMPLEMENTED
10 Overriding internal helpers Same as externals: override keyword override_internal_template("template_id", "fn_name") in AztecConfig IMPLEMENTED
11 Abstract contracts abstract keyword; contract cannot be deployed; subcontract must implement all virtuals before deployment is allowed Every template must be a valid, compilable, deployable Noir contract — the language has no concept of an unimplemented function, so there is no mechanism to prevent a template from being deployed standalone OUT_OF_SCOPE
12 Collision detection C3 linearization resolves or rejects ambiguous same-name functions Any duplicate function selector in the merged surface is a hard compile error, direct or transitive IMPLEMENTED
13 Storage inheritance Storage layout is inherited; child can add slots Host fully owns storage; templates document required field names/types; host declares them manually BY_DESIGN
14 Storage slot ordering Defined by inheritance order (parent slots first) Defined entirely by the host’s Storage struct field order BY_DESIGN
15 super calls super.fn() calls parent implementation from override There is no parent — composition is a flat merge with no hierarchy, so super has no referent OUT_OF_SCOPE
16 Dispatch chain / linearization MRO (C3) produces a deterministic call-through chain for super No dispatch chain exists; all functions land in a single flat surface; C3/MRO has no meaning in a flat merge OUT_OF_SCOPE
17 private visibility propagation private functions are not accessible in subcontracts All composed function bodies are flat-copied into host scope; there is no private barrier in the copy model OUT_OF_SCOPE
18 Modifiers / modifier inheritance Modifiers defined in base are usable/overridable in child Aztec uses function-level attributes (#[only_self], #[authorize_once]); no modifier chain concept OUT_OF_SCOPE
19 Global name resolution Base-scope names auto-resolve in child due to shared scope Composed function bodies resolve names in host scope; template module globals are not auto-inherited — use #[contract_library_method] for template-owned constants BY_DESIGN
20 Event struct inheritance Child inherits parent events implicitly Template event structs are automatically replayed into host codegen; no manual host redeclaration needed IMPLEMENTED

Why these differences?

Storage is host-owned (rows 13–14)

In Solidity, a base contract’s state variables are allocated in the child’s storage layout automatically. In Noir/Aztec, the comptime API does not currently expose a way to inject fields into an existing TypeDefinition. Until that upstream capability lands, hosts must manually declare the storage fields that template bodies reference.

The side effect of this design is that hosts get explicit control over slot ordering, which is preferable for upgrade-safety in production contracts.

Note on Solidity storage “ownership”: Solidity’s private and internal visibility on state variables is a language-level access control, not a storage isolation guarantee. Any code with access to the contract’s storage — including inline assembly — can read or write any slot by raw index regardless of declared visibility. The “inheritance” of storage is therefore also a language facility: the compiler allocates slots and enforces access rules, but there is no EVM mechanism that actually siloes a base contract’s slots from the child. Template composition takes the same pragmatic view: the host owns the full storage layout and declares every field explicitly, with no illusion of encapsulation below the language level.

No super, no dispatch chain (rows 15–16)

Template composition is a flat merge — there is no parent contract, no hierarchy, and no chain to traverse. super has no referent because the concept of a “parent implementation” does not exist in this model. C3 linearization and MRO are equally inapplicable for the same reason.

If a host needs to reuse part of a template function’s logic alongside its own override, the pattern is to extract that logic into a #[contract_library_method] helper in the template and call it directly from the host’s override body.

Global name resolution in host scope (row 19)

Composed function bodies are captured as raw token streams in the template crate and replayed in the host. At replay time, identifiers are resolved in the host module’s scope, not the template’s. This gives two distinct patterns depending on whether the template author wants the value to be fixed or host-configurable.

Option A — host-configurable global. The template references an unqualified symbol in its body and documents it as a host requirement. The host decides the value, either by importing it from the template crate or declaring its own:

// template body
#[external("public")]
fn use_config() -> u32 {
    FOO_CONFIG   // resolved in host scope at replay time
}

// host: import the template's own default
use my_template::FOO_CONFIG;

// host: or declare a local override
pub global FOO_CONFIG: u32 = 99;

Option B — template-owned constant, not configurable by the host. The template wraps the constant in a #[contract_library_method] function. These migrate via a typed reference (f.as_typed_expr()), so the call resolves back to the original template function and is never re-resolved in host scope:

// template: constant the host cannot override
#[contract_library_method]
fn _fee_denominator() -> u32 { 10_000 }

#[external("public")]
fn compute_fee(amount: u32) -> u32 {
    amount / _fee_denominator()   // always calls the template's own function
}

The key difference: raw globals are a seam the host is expected to fill; #[contract_library_method] functions are opaque to the host and carry their implementation across the crate boundary intact.


Open design points

Virtual #[contract_library_method] functions

Currently, #[contract_library_method] functions are not subject to the virtual/override mechanism. They migrate via typed reference and always resolve to the original template implementation — the host has no way to replace them.

There is an open question whether this should change. A template author may want to expose a helper as overridable (e.g. a fee formula used internally by composed bodies) while still keeping it out of the external ABI. The virtual/override machinery today only covers #[external] and #[internal] functions, so supporting virtual library methods would require extending that mechanism to a third function kind.

Whether this is worth the added complexity — and whether the right surface is a new attribute, an extension of override_template, or something else — is not yet decided.

Multi-layer override semantics

The override mechanism is currently single-level: a host can replace a virtual function from a directly composed template, but there is no concept of an override chain. If a mid-template overrides a function from one of its own composed templates, that override is baked in — a host composing the mid-template cannot then override the same function again.

Case 1 — mid locks the override in. The host has no way to replace fee_bps further:

// base_template: declares a virtual function
#[contract_template("base_template")]
#[aztec]
pub contract BaseTemplate {
    #[external("public")]
    #[template_virtual]
    #[view]
    fn fee_bps() -> u16 { 30 }
}

// mid_template: overrides and bakes in the value — not re-declared as virtual
#[contract_template("mid_template")]
#[aztec(AztecConfig::new()
    .compose("base_template")
    .override_template("base_template", "fee_bps")
)]
pub contract MidTemplate {
    #[external("public")]
    #[view]
    fn fee_bps() -> u16 { 20 }  // baked in — host cannot touch this anymore
}

// host: override_template("mid_template", "fee_bps") would fail —
// fee_bps is no longer virtual in mid_template's surface
#[aztec(AztecConfig::new().compose("mid_template"))]
pub contract Host { }

Case 2 — mid re-exposes the override as virtual. The host must then override both layers explicitly:

// mid_template: overrides base but re-declares virtuality
#[contract_template("mid_template")]
#[aztec(AztecConfig::new()
    .compose("base_template")
    .override_template("base_template", "fee_bps")
)]
pub contract MidTemplate {
    #[external("public")]
    #[template_virtual]   // host can still replace this
    #[view]
    fn fee_bps() -> u16 { 20 }
}

// host must cover both virtual sources of fee_bps:
// - override_template("base_template", "fee_bps") for the base entry
// - override_template("mid_template", "fee_bps") for mid's entry
#[aztec(AztecConfig::new()
    .compose("mid_template")
    .override_template("base_template", "fee_bps")
    .override_template("mid_template", "fee_bps")
)]
pub contract Host {
    #[external("public")]
    #[view]
    fn fee_bps() -> u16 { 5 }
}

Whether multi-layer override semantics should be introduced (and how precedence and conflict resolution would work across layers) is an open question.

Override targeting transitive templates

Today, override_template("id", "fn") requires "id" to be a template that the host directly composes. Overriding a function that comes from a transitive dependency (a template composed by a composed template) is not supported — the host must also directly compose that template to target it.

Using mid_template from the example above (which transitively brings in base_template), the following is an antipattern that will fail:

// ANTIPATTERN: host only composes mid_template, but tries to override
// fee_bps from base_template — a transitive dependency it never directly composed
#[aztec(AztecConfig::new()
    .compose("mid_template")
    .override_template("base_template", "fee_bps")  // compile error: base_template not directly composed
)]
pub contract Host {
    #[external("public")]
    #[view]
    fn fee_bps() -> u16 { 5 }
}

The fix is to also directly compose base_template:

#[aztec(AztecConfig::new()
    .compose("mid_template")
    .compose("base_template")                        // now directly composed
    .override_template("base_template", "fee_bps")  // valid
)]
pub contract Host {
    #[external("public")]
    #[view]
    fn fee_bps() -> u16 { 5 }
}

Whether this restriction should be relaxed, and how that would interact with collision detection and override precedence, is undecided.

Template ID last-writer-wins and implementation selection

When two crates register a template under the same ID, the later registration silently wins. This becomes a meaningful design question in diamond-like composition graphs where two templates share a common dependency registered under the same ID but with different implementations.

Consider a host A that composes both B and D, where B internally composes C (a token template) and D internally composes C' — a different implementation registered under the same template ID "token":

A
├── B  (composes "token" → C)
└── D  (composes "token" → C')

C and C' share the same template ID. Because they are both meant to implement the same interface (e.g. a token standard), we can reasonably expect them to expose the same external method names — but this is a convention, not a guarantee. Currently only the external ABI of the token is being standardized; internal method names, helper signatures, and storage field names are not part of any enforced contract between C and C'.

For example, the aip20_token template today standardizes its external surface:

// standardized external ABI (callable from outside)
fn transfer_private_to_private(from, to, amount, _nonce)
fn transfer_private_to_public(from, to, amount, _nonce)
fn transfer_public_to_public(from, to, amount, _nonce)
fn transfer_public_to_private(from, to, amount, _nonce)
fn mint_to_private(to, amount)
fn mint_to_public(to, amount)
fn burn_private(from, amount, _nonce)
fn burn_public(from, amount, _nonce)
fn balance_of_public(owner) -> u128
fn total_supply() -> u128

But a template that composes aip20_token — say, an AMM that calls into the token’s internal helpers to move balances — depends on internal method names that are not standardized:

// NOT standardized — internal helpers used by composed bodies
self.internal._increase_private_balance(to, amount)
self.internal._decrease_private_balance(account, amount, max_notes)
self.internal._increase_public_balance(to, amount)
self.internal._decrease_public_balance(from, amount)

If an alternative token implementation C' renames _increase_private_balance to _credit_private(to, amount), any template that composed C and called self.internal._increase_private_balance will fail to compile against C' — even though both implementations are valid aip20_token tokens from the external ABI perspective.

When A flattens the full transitive graph, both C and C' contribute functions under "token" — but the registry can only hold one. The last-writer-wins rule determines which implementation survives, silently, based on registration order.

This surfaces two related open questions:

  • Should the host have an explicit mechanism to declare which implementation of "token" it wants — selecting C or C' by intent rather than by accident of load order — and should conflicting registrations under the same ID be a hard error unless the host resolves the conflict explicitly?
  • If two implementations are expected to be substitutable under the same template ID, should the template ID carry a stronger contract standardization — not just the external ABI but also internal method signatures and storage shape — so that substitutability is verifiable at compose time rather than assumed by convention?

If the language allowed it

The current implementation works within Noir’s existing comptime constraints. Several aspects of the developer experience and safety guarantees could improve significantly if the language gained the following capabilities:

Storage field injection (TypeDefinition::add_field)

The single largest ergonomics gap. Today, every host must manually copy the required storage field declarations from template documentation into its own Storage struct. If Noir exposed TypeDefinition::add_field at comptime, compose could inject template-required fields automatically — eliminating a whole class of silent runtime failures from missing or mismatched storage, and making template adoption a true one-liner.

Nicer composition syntax

.compose("aip20_token") as a string argument inside AztecConfig::new() is functional but not particularly readable, especially when multiple templates are composed or overrides are declared. A dedicated language construct — for example a mixin keyword or an is-like annotation with template semantics — would make the intent clearer at a glance:

// hypothetical — not current syntax
#[aztec]
#[mixin(aip20_token, amm_template)]
pub contract Amm { ... }

Hygienic cross-module Quoted

Currently, function bodies are captured as raw token streams and replayed in host scope, so all name resolution happens in the host. If Noir supported hygienic Quoted that preserved the originating crate’s identity for certain references, template-owned globals could be used directly in composable bodies without requiring #[contract_library_method] workarounds.

Compile-time compatibility validation

There is currently no dedicated pass that checks whether a host satisfies a template’s requirements before codegen. Errors surface as generic type or name-resolution failures deep inside composed bodies. A Noir macro API that could inspect a host’s storage, imports, and declared overrides at compose time would allow templates to emit clear, actionable diagnostics instead of opaque compiler errors.

Sealed globals

A pub global declared in a template crate is always re-resolved in host scope at replay time — it is inherently host-configurable. The only current way to have a template-owned constant that the host cannot override is to wrap it in a #[contract_library_method] function, which migrates via typed reference and always resolves back to the original implementation. This works, but forces every constant to be expressed as a zero-argument function call (_fee_denominator()) rather than a direct name reference (FEE_DENOMINATOR). A language-level annotation on pub global that opts it into typed-reference migration — analogous to what #[contract_library_method] does for functions — would eliminate this boilerplate.


Known issues

#[immutables] compatibility

The aztec-immutables-macro (#[immutables]) is a separate Wonderland library that commits deploy-time constants into the contract’s address via salt derivation. It is unrelated to template composition, but the two mechanisms interact in non-obvious ways when a template uses Immutables::init(context) in composable function bodies.

External and internal composed functions resolve all names in host scope. Immutables::init(context) in a composed external or internal body will call the host’s Immutables::init(), using the host’s struct layout. At runtime, the host’s instance.salt was derived from the host’s deployment — so the verification is self-consistent. This works, subject to the same host-requirement discipline as storage fields: the host must declare an #[immutables] pub struct Immutables { ... } that includes all fields the composed bodies access, merge fields from all composed templates into that single struct, and deploy with deployWithImmutables.

#[contract_library_method] functions are a different story. CLMs migrate via typed reference (f.as_typed_expr()), so they call through to the original template function and execute in the template crate’s scope. Any Immutables::init(context) inside a CLM body calls the template’s Immutables::init(), using the template’s struct layout for hash computation:

poseidon2_hash([actual_salt, template_field_0, ...]) == instance.salt

But instance.salt is the host’s salt, derived from the host’s merged Immutables struct. Unless the host’s struct is byte-for-byte identical to the template’s — same fields, same order, nothing added — the assertion fails silently at runtime. There is no compile-time signal.

Consequence: templates must not use Immutables::init() inside #[contract_library_method] functions. The salt-commitment pattern is fundamentally incompatible with CLMs across the crate boundary.

7 Likes

This is awesome!!
For our PoC on warptoad we literally manually copy pasted the ARC-20 standard and added cross-chain functions to it. It sucked a lott, spaghetti, hard to update and in the end we we’re not even compatible enough for the standard to work with azguard :sob:

Thinking now we might override the transfer function directly to do crosschain directly in there. Or maybe even make our own template!

2 Likes

Thanks for this thorough write-up! It’s amazing how far one could go with just meta-programming, and I can definitely see this save a lot of copy pasta in contract adaptation and make it much cleaner and less error-prone.

Some questions:

  • What’s needed to start using these macros? Is there a working version that can be shared publicly?
  • What’s the template publishing and consumption mechanism? I didn’t find it in the write-up, but I assume it probably follows the standard git-based Noir deps model. Is it correct?
    • If that’s the case, for Noir deps, all external contracts are naturally namespaced by the dep name. It appears template ids are NOT namespaced (which is what prompted the “last-writer-wins” rule). Is that a deliberate design choice or a compromise?
  • Does Aztec/Noir have any plan in extending the language / AVM to make contract inheritance a first class feature?
    • To offer a more streamlined and familiar syntax.
    • More importantly, to support runtime polymorphism and unlock much stronger contract interoperability and reusability.

Ey Jason, sorry for the late reply, currently we have a working PoC in a fork of aztec-nr, we’re working on making a PR to aztec-packages to be included on the official release because if you install this aztec-nr then all your dependencies need to use the same aztec-nr, else we face a import issue (“expected AztecAddress found AztecAddress” errors).

About template publishing and consumption, adding the #[contract_template("template_id")] tag at the start of the contract (before #[aztec]) publishes the contract as a template to your compiler, and adding #[aztec(AztecConfig::new().compose("template_id")] consumes it. It’s locally both “publishing” and “consuming” it.

For example, when you create a contract that imports the Token, because the Token has (or will have) the templating tag the compiler should be already aware how to process your contract that templates on top of it.

Template IDs are NOT namespaced, correct, that’s one of the edge cases we’re reviewing on the design. What happens if A templates B and C, and both B and C template some "token", but they use different implementations? Should we then standarize only the external surface of the Token contract (i.e. transfer_private_to_private), or should be standarize also internal methods for maximum compatibility (i.e. internal._increase_private_balance)? Or should we abort compilation if 2 template names overlap.

About “probably follows the standard git-based Noir deps model”, i’m not entirely sure i’m following, it’ll use the same import method as the Nargo files, only that when you import a contract you’re also signaling the compiler that it can be templated.

About Aztec/Noir having plan to extending the language, I really hope for, passing from a PoC to a production ready version requires some consensus on the expected behaviour of every edge case.

Here’s a concrete example of what I meant:

Suppose there are two different token contracts (in different git repos), each declaring themselves as a template named Token.

#[contract_template("Token")]
#[aztec]
pub contract Token {
  // Implementations vary
}

Suppose we need to use both token contracts in our swap contract. We can declare both dependencies under different names in Nargo.toml:

[dependencies]
token1 = { ... }
token2 = { ... }

We can then reference both token contracts without ambiguity in our contract:

#[aztec]
pub contract Swap {
  use token1::{Token as Token1};
  use token2::{Token as Token2};
  ...
}

In the same vein, if we need to compose a custom token contract out of one of the Token templates, why can’t we reference the template id in a namespaced manner as well to avoid ambiguity?

#[aztec(AztecConfig::new().compose("token1::Token")]
pub contract CustomToken {
  ...
}

Good question, we have it tracked as EC-24 (TBD) whether we prefer just a naming over full qualified paths for declaring a template.

Pros to just Token approach:

  • In general usage, why would a contract require to import more than one Token implementation?
  • If 2 contracts use some Token and they are fully compatible (internal methods surface is the same), the host contract could declare their own Token to replace with
    A is B and C (using TokenA "token")
     -> B is Token1 "token"
     -> C is Token2 "token"
    
  • This approach would require to over-standarize the Token to surface the same internal methods (i.e. _increase_private_balance)

Pros to fully qualified path:

  • Unambiguity of dependencies (a template defines the exact implementation)
  • Each Token may chose their own internal methods, as the host contract implements them ad-hoc (knowing which resolution is gonna be used)

Another other edge cases that may fall in a similar realm of implications is EC-19 (what do we do with cyclic declaration of templates). Or EC-23, what do we do with templates that get flattened many times.

A is B and C
  -> B is D
    -> D is Token
  -> C is Token

Edge Cases tracking table :

Description Status
EC-1: Two templates expose the same external selector To Fix
EC-2: Host fn selector matches non-virtual template fn To Fix
EC-3: Cross-kind selector collision (private vs public vs utility) To Fix
EC-4: Override target resolved by name, not signature Discussion
EC-5: Override of transitively-composed template Discussion
EC-6: Template overrides its own composed child — doesn’t propagate Discussion
EC-7: Override compatibility = name + param types only To Fix
EC-8: Partial override of a selector shared by two templates To Fix
EC-9: Storage — missing field Discussion
EC-10: Storage — compatible-enough wrong kind accepted Discussion
EC-11: Two templates require the same storage field name Discussion
EC-12: Event duplicate names (t-vs-t and host-vs-t) Discussion
EC-13: Event selector registry relaxed to idempotent re-registration Discussion
EC-14: Initialization-check bypass Discussion
EC-15: Constructor chaining is unenforced convention Discarded
EC-16: Raw template globals / host-provided bindings Discussion
EC-17: Library methods — same name, different params To Fix
EC-18: Compose the same id twice in one host config Discussion
EC-19: Cyclic template graphs To Fix
EC-20: Attribute-order coupling between #[contract_template] and #[aztec] Discussion
EC-21: #[template_virtual] on a #[contract_library_method] Discussion
EC-22: Missing replay payload panic leaks raw keys Discussion
EC-23: Template graph flattened three times, independently Discussion
EC-24: All identity is FNV-style hashing; collisions fail silently Discussion
EC-25: #[note] structs in templates — not replayed, globally registered anyway Discussion

:robot: Enriched edit: related edge cases.

EC What it is Example Relation to Token vs Token:token
EC-18 Same template id appears twice; should it dedup or reject? Host composes Token directly and also composes AMM, which internally composes Token. Namespacing doesn’t solve intentional duplicates, but avoids unrelated templates accidentally sharing Token.
EC-19 Cyclic template graph; templates compose each other forever unless detected. A composes B, and B composes A; or Token composes itself. Namespacing helps make cycles clearer, but the real fix is cycle detection by id/graph path.
EC-23 The template graph is flattened multiple times independently; if logic diverges, different compiler consumers may see different template sets/order. Functions flatten graph as [Token, Fee], events flatten separately as [Fee, Token], replay flattens again; diagnostics/behavior can drift. Namespaced ids make graph nodes unambiguous, but EC-23 is mainly about computing the graph once and reusing it everywhere.

Shortest explanation:

  • EC-18 = duplicate node

    Host
     ├─ Token
     └─ AMM
        └─ Token
    

    Same id appears twice; dedup may be OK if it’s truly the same template.

  • EC-19 = cycle

    Token
     └─ AMM
        └─ Token
    

    Not a duplicate to dedup casually; this is recursive composition and should be rejected.

  • EC-23 = inconsistent graph flattening

    Host
     └─ VaultBase
         └─ Token
             └─ virtual transfer()
    
    .compose("VaultBase")
      .override_template("Token", "transfer") // apply fee
    
    Host
     ├─ VaultBase
     │   └─ Token // overrides B
     └─ RewardsBase
         └─ Token // overrides A
    // which override to implement?
    

    If each recomputes the graph separately, they may disagree. Fix: flatten once, pass the resolved list around.