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

**URL:** <https://forum.aztec.network/t/delegate-schnorr-account-allowance-gated-authwits-aztec-devnet-v3-0-0-review-request/8242>\
**Category:** Aztec\
**Created:** [December 22, 2025, 12:39pm UTC](https://forum.aztec.network/t/delegate-schnorr-account-allowance-gated-authwits-aztec-devnet-v3-0-0-review-request/8242 "2025-12-22T12:39:57Z")\
**Posts on this page:** 4\
**Page:** 1

<div class="post-metadata">

**Author:** ![zkDomson](https://avatars.discourse-cdn.com/v4/letter/z/f6c823/32.png) [@zkDomson](https://forum.aztec.network/u/zkDomson)\
**Post date:** [December 22, 2025, 12:39pm UTC](https://forum.aztec.network/t/delegate-schnorr-account-allowance-gated-authwits-aztec-devnet-v3-0-0-review-request/8242/1 "2025-12-22T12:39:57Z")

</div>

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

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

```rust
/// 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**

```rust
#[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.

```rust
// 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`.

```rust
#[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.

```rust
#[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.

```rust
/// 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)`.

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

```

```rust
#[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.

```rust
#[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:

```rust
#[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** :

---

<div class="post-metadata">

**Author:** ![ametel01](https://dub1.discourse-cdn.com/flex013/user_avatar/forum.aztec.network/ametel01/32/2787_2.png) [@ametel01](https://forum.aztec.network/u/ametel01)\
**Post date:** [January 13, 2026, 10:01am UTC](https://forum.aztec.network/t/delegate-schnorr-account-allowance-gated-authwits-aztec-devnet-v3-0-0-review-request/8242/2 "2026-01-13T10:01:11Z")

</div>

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:

```auto
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:

```auto
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:

```auto
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.

---

<div class="post-metadata">

**Author:** ![zkDomson](https://avatars.discourse-cdn.com/v4/letter/z/f6c823/32.png) [@zkDomson](https://forum.aztec.network/u/zkDomson)\
**Post date:** [January 21, 2026, 9:40am UTC](https://forum.aztec.network/t/delegate-schnorr-account-allowance-gated-authwits-aztec-devnet-v3-0-0-review-request/8242/3 "2026-01-21T09:40:12Z")

</div>

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!

---

<div class="post-metadata">

**Author:** ![ametel01](https://dub1.discourse-cdn.com/flex013/user_avatar/forum.aztec.network/ametel01/32/2787_2.png) [@ametel01](https://forum.aztec.network/u/ametel01)\
**Post date:** [February 10, 2026, 8:01pm UTC](https://forum.aztec.network/t/delegate-schnorr-account-allowance-gated-authwits-aztec-devnet-v3-0-0-review-request/8242/4 "2026-02-10T20:01:51Z")

</div>

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.
