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
-
Owner deploys
DelegateSchnorrAccountwith owner pubkey -
Owner calls
add_delegate(delegate_pubkey, allowed_consumer = Token) -
Owner calls
Token.approve(spender, amount) -
Delegate/orchestrator generates authwits (using delegate key) consumable by
Token -
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
-