Delegate Schnorr Account + Allowance-Gated AuthWits (Aztec devnet v3.0.0) — review request

Context

In our regulated private trading setup, an orchestrator/settlement agent must move assets in private on behalf of participants (fast settlement, operational/legal reasons).
On Aztec, private balance updates require authwits, so we explored a pattern:

  • Owner keeps normal tx signing (entrypoint) owner-only

  • Owner can add delegate pubkeys that can sign authwits for a specific consumer

  • Token contract enforces:

    • authwit required for private actions (transfer_from / escrow_open, etc.)

    • allowance as a public spending cap (approve/allowance + reduce_allowance)

I’d appreciate expert feedback on whether this design is sound and aligns with common Aztec/Noir best practices.


A) Delegate policy note

Purpose: bind delegate Schnorr pubkey → single allowed consumer → owner account

/// Note tying a delegate Schnorr pubkey to a single allowed consumer (dApp)
#[derive(Eq, Packable)]
#[note]
pub struct DelegatePolicyNote {
    pub x: Field,
    pub y: Field,
    /// Contract that this delegate is allowed to sign authwits for
    pub allowed_consumer: AztecAddress,
    /// The owning account (this account contract address)
    pub owner: AztecAddress,
}

impl DelegatePolicyNote {
    pub fn new(
        x: Field,
        y: Field,
        allowed_consumer: AztecAddress,
        owner: AztecAddress,
    ) -> Self {
        DelegatePolicyNote {
            x,
            y,
            allowed_consumer,
            owner,
        }
    }
}


B) DelegateSchnorrAccount (your full pattern)

B1) Storage

Owner key + private set of delegate policies

#[storage]
struct Storage<Context> {
    // Owner Schnorr key
    signing_public_key: PrivateImmutable<PublicKeyNote, Context>,
    // Delegate policies: each note ties a delegate key to a single allowed consumer
    delegate_policies: PrivateSet<DelegatePolicyNote, Context>,
}

B2) Tx-level signing remains owner-only

Delegates cannot sign transactions, only authwits.

// Core is_valid_impl (tx-level signer check)
// NOW: OWNER-ONLY. Delegates cannot sign transactions.

#[contract_library_method]
fn is_valid_impl(context: &mut PrivateContext, outer_hash: Field) -> bool {
    let storage = Storage::init(context);
    let owner_key_note = storage.signing_public_key.get_note();

    // Safety: witness only used to obtain signature bytes
    let witness: [Field; 64] = unsafe { get_auth_witness(outer_hash) };
    let mut signature: [u8; 64] = [0; 64];
    for i in 0..64 {
        signature[i] = witness[i] as u8;
    }

    let msg_bytes: [u8; 32] = outer_hash.to_be_bytes::<32>();

    // Only owner key is accepted for txs
    let owner_point = std::embedded_curve_ops::EmbeddedCurvePoint {
        x: owner_key_note.x,
        y: owner_key_note.y,
        is_infinite: false,
    };
    schnorr::verify_signature(owner_point, signature, msg_bytes)
}

B3) Authwit verification accepts owner OR delegate (scoped by allowed_consumer)

This is the core behavior change: delegate signatures are accepted only when:
note.allowed_consumer == consumer.

#[external("private")]
#[noinitcheck]
#[view]
fn verify_private_authwit(inner_hash: Field) -> Field {
    let consumer: AztecAddress = context.msg_sender().unwrap();

    let message_hash = compute_authwit_message_hash(
        consumer,
        context.chain_id(),
        context.version(),
        inner_hash,
    );

    // Safety: witness only used to obtain signature bytes
    let witness: [Field; 64] = unsafe { get_auth_witness(message_hash) };
    let mut signature: [u8; 64] = [0; 64];
    for i in 0..64 {
        signature[i] = witness[i] as u8;
    }

    let mut storage = Storage::init(&mut context);

    // 1) Owner is always allowed for any consumer
    let owner_note = storage.signing_public_key.get_note();
    let owner_point = std::embedded_curve_ops::EmbeddedCurvePoint {
        x: owner_note.x,
        y: owner_note.y,
        is_infinite: false,
    };

    let mut ok =
        schnorr::verify_signature(owner_point, signature, message_hash.to_be_bytes::<32>());

    // 2) Delegate keys: only if consumer == allowed_consumer
    if !ok {
        let options = NoteGetterOptions::new();
        let delegate_notes = storage.delegate_policies.get_notes(options);

        for i in 0..delegate_notes.max_len() {
            if i < delegate_notes.len() {
                // RetrievedNote<DelegatePolicyNote>
                let retrieved = delegate_notes.get(i);
                let note = retrieved.note;

                if note.allowed_consumer == consumer {
                    let delegate_point = std::embedded_curve_ops::EmbeddedCurvePoint {
                        x: note.x,
                        y: note.y,
                        is_infinite: false,
                    };

                    if schnorr::verify_signature(
                        delegate_point,
                        signature,
                        message_hash.to_be_bytes::<32>(),
                    ) {
                        ok = true;
                    }
                }
            }
        }
    }

    assert(ok, "Message not authorized by account");
    IS_VALID_SELECTOR
}

B4) Utility lookup_validity also checks delegates + nullifier spent

Used for checking whether an authwit can be consumed; same delegate logic (consumer match) + spent check.

#[external("utility")]
unconstrained fn lookup_validity(consumer: AztecAddress, inner_hash: Field) -> bool {
    let public_key = storage.signing_public_key.view_note();

    let message_hash = compute_authwit_message_hash(
        consumer,
        context.chain_id(),
        context.version(),
        inner_hash,
    );

    let witness: [Field; 64] = get_auth_witness(message_hash);
    let mut signature: [u8; 64] = [0; 64];
    for i in 0..64 {
        signature[i] = witness[i] as u8;
    }

    // 1) Owner
    let owner_point = std::embedded_curve_ops::EmbeddedCurvePoint {
        x: public_key.x,
        y: public_key.y,
        is_infinite: false,
    };
    let mut valid_in_private =
        schnorr::verify_signature(owner_point, signature, message_hash.to_be_bytes::<32>());

    // 2) Delegates: only if consumer matches allowed_consumer
    if !valid_in_private {
        let options = NoteViewerOptions::new();
        let delegate_notes = storage.delegate_policies.view_notes(options);

        for i in 0..options.limit {
            if i < delegate_notes.len() {
                // view_notes returns DelegatePolicyNote directly
                let note = delegate_notes.get_unchecked(i);

                if note.allowed_consumer == consumer {
                    let delegate_point = std::embedded_curve_ops::EmbeddedCurvePoint {
                        x: note.x,
                        y: note.y,
                        is_infinite: false,
                    };

                    if schnorr::verify_signature(
                        delegate_point,
                        signature,
                        message_hash.to_be_bytes::<32>(),
                    ) {
                        valid_in_private = true;
                    }
                }
            }
        }
    }

    // Nullifier spent check - same as vanilla SchnorrAccount
    let nullifier = compute_authwit_nullifier(context.this_address(), inner_hash);
    let siloed_nullifier = compute_siloed_nullifier(consumer, nullifier);
    let lower_wit =
        get_low_nullifier_membership_witness(context.block_number(), siloed_nullifier);
    let is_spent = lower_wit.leaf_preimage.nullifier == siloed_nullifier;

    !is_spent & valid_in_private
}

B5) Delegate management

Owner adds/removes delegate policy notes.

/// Add a new delegate Schnorr public key with a policy restricting which consumer
/// it can sign authwits for.
#[external("private")]
fn add_delegate(pub_x: Field, pub_y: Field, allowed_consumer: AztecAddress) {
    let this = context.this_address();
    let note = DelegatePolicyNote::new(pub_x, pub_y, allowed_consumer, this);

    let emission = storage.delegate_policies.insert(note);
    emission.emit(this, MessageDelivery.CONSTRAINED_ONCHAIN);
}

/// Remove an existing delegate policy for a given (pub_x, pub_y, allowed_consumer).
#[external("private")]
fn remove_delegate(pub_x: Field, pub_y: Field, allowed_consumer: AztecAddress) {
    let options = NoteGetterOptions::new();
    let delegate_notes = storage.delegate_policies.get_notes(options);

    let mut removed = false;
    for i in 0..delegate_notes.max_len() {
        if (i < delegate_notes.len()) & (!removed) {
            let retrieved = delegate_notes.get(i);
            let note = retrieved.note;
            if (note.x == pub_x) & (note.y == pub_y) & (note.allowed_consumer == allowed_consumer) {
                // remove expects the RetrievedNote
                storage.delegate_policies.remove(retrieved);
                removed = true;
            }
        }
    }
}


C) Token contract (consumer): allowance + authwit-gated private movement

C1) Allowance storage + approve/allowance

Allowance is public and per (owner, spender).

public_allowances: Map<AztecAddress, Map<AztecAddress, PublicMutable<u128, Context>, Context>, Context>,

#[external("public")]
fn approve(spender: AztecAddress, amount: u128) {
    let owner = context.msg_sender().unwrap();
    storage.public_allowances.at(owner).at(spender).write(amount);
}

#[external("public")]
fn allowance(owner: AztecAddress, spender: AztecAddress) -> u128 {
    storage.public_allowances.at(owner).at(spender).read()
}

C2) transfer_from: authwit required + allowance reduced (public internal)

This is the “approve-like” spending path.

#[external("private")]
fn transfer_from(from: AztecAddress, to: AztecAddress, amount: u128, nonce: Field) {
    // 1) Authwit: "from" must have authorized this call via their account
    assert_current_call_valid_authwit::<4>(&mut context, from);

    let spender = context.msg_sender().unwrap();

    // 2) Perform the transfer
    Token::at(context.this_address())._transfer_internal(from, to, amount).call(&mut context);
    
    // 3) Enqueue public calls
    Token::at(context.this_address())
        ._reduce_allowance(from, spender, amount)
        .enqueue(&mut context);
    Token::at(context.this_address())._transfer().enqueue(&mut context);

    // ... private event emission omitted here ...
}

Allowance enforcement:

#[external("public")]
#[internal]
fn _reduce_allowance(owner: AztecAddress, spender: AztecAddress, amount: u128) {
    let current = storage.public_allowances.at(owner).at(spender).read();
    assert(current >= amount, "allowance too low");
    storage.public_allowances.at(owner).at(spender).write(current - amount);
}


Intended usage

  1. Owner deploys DelegateSchnorrAccount with owner pubkey

  2. Owner calls add_delegate(delegate_pubkey, allowed_consumer = Token)

  3. Owner calls Token.approve(spender, amount)

  4. Delegate/orchestrator generates authwits (using delegate key) consumable by Token

  5. Token.transfer_from / burn (etc.) succeed only if:

    • authwit valid under owner or delegate policy (allowed_consumer == Token)

    • public allowance is sufficient and gets reduced

1 Like

Nice work on this pattern. You’re essentially internalizing ERC20-style approve/transferFrom into Aztec’s private execution model — trading per-action authwit signing for capability-based delegation with spending limits. This makes sense for regulated settlement where the orchestrator needs to act autonomously without users being online.

What’s Sound

  1. delegates can only sign authwits, not initiate transactions. Good separation.

  2. binding delegate keys to specific contracts via allowed_consumer prevents overly permissive delegation.

  3. requiring both valid authwit AND sufficient public allowance means compromise of one layer isn’t catastrophic.

  4. your verify_private_authwit correctly uses #[view], preventing reentrancy during verification.

Suggestions

1. Optimize note getter

Your current implementation fetches all delegate notes then filters in-circuit:

let options = NoteGetterOptions::new();
let delegate_notes = storage.delegate_policies.get_notes(options);
for i in 0..delegate_notes.max_len() {
    if note.allowed_consumer == consumer { ... }
}

Consider filtering at the note getter level:

let options = NoteGetterOptions::new()
    .select(DelegatePolicyNote::properties().allowed_consumer, Comparator.EQ, consumer);

This reduces proving costs, especially if users accumulate many delegate policies.

2. function selector scoping

Currently a delegate can authorize ANY authwit-gated function on the consumer contract (transfer_from, burn, etc.). Consider restricting to specific selectors:

pub struct DelegatePolicyNote {
    pub x: Field,
    pub y: Field,
    pub allowed_consumer: AztecAddress,
    pub allowed_selectors: BoundedVec<Field, 4>,  <-- NEW
    pub owner: AztecAddress,
}

Then verify the called function is in the allowed list during authwit verification.

3. time-bounded delegation

Permanent delegation until explicit revocation increases exposure window. Adding valid_until: u64 to the policy note forces periodic re-authorization and limits damage from forgotten delegations.

4. Delegate key compromise surface

If the orchestrator uses one keypair across many user accounts, compromising that key lets an attacker drain everyone’s remaining allowance simultaneously. Mitigations to consider:

  • Per-user delegate keys — orchestrator manages unique keypair per user relationship

  • Rate limiting — cap spending velocity (amount per time period) so users have time to react

  • Recipient whitelist — users pre-approve valid destination addresses, preventing transfers to attacker-controlled addresses

5. Revocation visibility

When a user calls remove_delegate, the orchestrator won’t immediately know their delegation was revoked. In fast settlement contexts, this could cause failed transactions. Consider emitting an event or having a lookup mechanism.

6. Public allowance leaks information

Your public_allowances map reveals onchain how much each user trusts each spender. If this is sensitive, a commitment-based private allowance scheme (though it adds complexity and proving costs).

Trust Assumptions might be worth documenting

Users are placing significant trust in the orchestrator:

  • Orchestrator can move tokens to any address (within allowance)

  • Orchestrator controls timing of execution

  • Orchestrator could front-run or selectively delay settlements

Happy to expand on any of these points.

3 Likes

Hi @ametel01 , thank you for the detailed review and your suggestions right away. All the points seem to be valid but there are some questions left from my side.

So where do u consider the recipient whitelist restriction. From my understanding it should be in the consumer contract inside the allowance right? Because function params aren’t accessible inside authwits.

To build on the previous question is there a way to restrict amounts directly inside the delegation. This would make life much easier for users.

Also what do u think about aztecs authwit pattern overall? Will there be any improvements to create and share them on-chain somehow in the near future?

Happy to hear from you!

1 Like

Hey @zkDomson, sorry for the late reply great questions — let me address each one.

  1. Recipient Whitelist — Where to Enforce

You’re correct that function params aren’t directly accessible inside the authwit verification itself. The authwit only validates who is authorized to act and against which consumer contract — it doesn’t introspect the calldata being executed.

So yes, the consumer contract is the right place. Specifically, you’d maintain a mapping of owner => Set for approved recipients and check it inside the transfer logic (e.g. transfer_from) before executing. This keeps the delegation layer clean and composable — the account contract answers “is this delegate allowed to call this consumer?”, and the consumer contract answers “is this
action permitted given the owner’s policy?”. Separation of concerns stays intact.

One thing to consider: if the whitelist is stored publicly, it leaks who a user is willing to transact with. A commitment-based approach (store hashes of approved recipients, have the caller prove membership) would preserve privacy, at the cost of added proving overhead.

  1. Amount Restrictions Inside Delegation

This is trickier but doable. You could extend DelegatePolicyNote with a max_amount: Field and a spent_amount: Field, then during authwit verification, decode the amount from the inner call’s arguments and enforce spent_amount + amount <= max_amount. The challenge is that
authwit verification runs as a #[view] function — it can’t mutate state to update spent_amount.

A practical workaround: keep the delegation amount cap as a separate private note that gets nullified and re-created with updated spent amounts each time the delegate acts. This is essentially how private token balances work (nullify old note, create new note with
remaining balance). It adds circuit complexity but gives users fine-grained per-delegation spending control without relying solely on the public allowance layer, which as I mentioned leaks information.

  1. Aztec AuthWit Pattern — Future Direction

The current authwit pattern works but has friction — particularly around off-chain coordination to share authwit witnesses between parties. From what I’ve seen in the Aztec roadmap and protocol discussions:

  • Shared mutable state improvements could eventually allow on-chain authwit registration without the privacy tradeoffs of today’s public storage
  • Note discovery improvements (the note tagging / delivery system) should make it easier for delegates to discover authwits that have been created for them without direct off-chain communication
  • Capsule / data oracle patterns are being explored for passing private data between parties during execution, which could streamline authwit distribution

That said, the core pattern of “sign a message hash authorizing a specific action” is likely here to stay — it maps well to the private
execution model. The improvements will mostly be in the plumbing around discovery and distribution rather than the fundamental design.

1 Like