Confidential Token Escrow Pattern for DvP Settlement (Aztec devnet v3.0.0) — review request

Context

In a regulated DvP flow on Aztec, private token balances must be escrowed while awaiting an external condition (e.g. fiat payment).
The escrow must:

  • lock private balances

  • be controlled by a settlement controller

  • release funds only to seller or buyer

  • integrate cleanly with orchestration contracts (DvP)

Below is the escrow pattern used inside a confidential token contract.


1) Escrow note (custom private note)

Purpose: represent escrowed private balance, bound to a single trade (escrow_id) and controlled by a controller via the nullifier.

#[derive(Eq, Serialize, Packable)]
#[custom_note]
pub struct EscrowNote {
    owner: AztecAddress,     // controller (nullifier authority)
    randomness: Field,
    seller: AztecAddress,
    buyer: AztecAddress,
    escrow_id: Field,
    value: u128,
}

Nullifier authority: only owner (controller) can nullify.

fn compute_nullifier(
    self,
    context: &mut PrivateContext,
    note_hash_for_nullify: Field,
) -> Field {
    let owner_npk_m = get_public_keys(self.owner).npk_m;
    let secret = context.request_nsk_app(owner_npk_m.hash());
    poseidon2_hash_with_separator(
        [note_hash_for_nullify, secret],
        GENERATOR_INDEX__NOTE_NULLIFIER,
    )
}


2) EscrowSet abstraction (one escrow per escrow_id)

Purpose: manage escrow lifecycle via note replacement (active → tombstone).

pub struct EscrowSet<Context> {
    set: PrivateMutable<EscrowNote, Context>,
}

Create escrow

pub fn create_escrow(
    self,
    controller: AztecAddress,
    seller: AztecAddress,
    buyer: AztecAddress,
    escrow_id: Field,
    amount: u128,
) {
    let note = EscrowNote::new(controller, seller, buyer, escrow_id, amount);
    self.set
        .initialize_or_replace(|_| note)
        .emit(controller, MessageDelivery.UNCONSTRAINED_ONCHAIN);
}

Release escrow (nullify + tombstone)

pub fn release_escrow(
    self,
    escrow_id: Field,
    recipient: AztecAddress,
    controller: AztecAddress,
    amount: u128,
) {
    self.set
        .replace(|escrow_note: EscrowNote| -> EscrowNote {
            assert(escrow_note.get_escrow_id() == escrow_id, "escrow id mismatch");
            assert(
                escrow_note.get_seller().eq(recipient)
                    | escrow_note.get_buyer().eq(recipient),
                "invalid recipient",
            );
            assert(escrow_note.get_value() == amount, "amount mismatch");
            assert(amount > 0, "invalid escrow");

            EscrowNote::new(
                escrow_note.get_owner(),
                escrow_note.get_seller(),
                escrow_note.get_buyer(),
                escrow_id,
                0,
            )
        })
        .emit(controller, MessageDelivery.UNCONSTRAINED_ONCHAIN);
}


3) Token contract integration

Storage binding

escrows: Map<Field, EscrowSet<Context>, Context>,


Escrow open (lock private balance)

#[external("private")]
fn escrow_open(
    escrow_id: Field,
    seller: AztecAddress,
    buyer: AztecAddress,
    controller: AztecAddress,
    amount: u128,
    authwit_nonce: Field,
) {
    assert(amount > 0, "zero amount");
    assert_current_call_valid_authwit::<6>(&mut context, seller);

    storage.private_balances
        .at(seller)
        .sub(seller, amount)
        .emit(seller, MessageDelivery.UNCONSTRAINED_ONCHAIN);

    storage.escrows.at(escrow_id).create_escrow(
        controller,
        seller,
        buyer,
        escrow_id,
        amount,
    );
}


Escrow release (unlock to buyer or seller)

#[external("private")]
fn escrow_release(
    escrow_id: Field,
    to: AztecAddress,
    controller: AztecAddress,
    amount: u128,
) {
    storage.escrows
        .at(escrow_id)
        .release_escrow(escrow_id, to, controller, amount);

    storage.private_balances
        .at(to)
        .add(to, amount)
        .emit(to, MessageDelivery.UNCONSTRAINED_ONCHAIN);
}


4) Orchestration usage (DvP)

Trade creation → open escrow

Token::at(asset_address)
    .escrow_open(
        trade_hash,
        seller,
        buyer,
        payment_oracle,
        amount,
        nonce,
    )
    .call(&mut context);

Payment confirmation → release escrow

Token::at(asset_address)
    .escrow_release(
        trade_hash,
        buyer,
        payment_oracle,
        amount,
    )
    .call(&mut context);


Review focus

  1. Nullifier authority
    Is using owner = controller as the sole nullifier authority the right model for escrow control?

  2. Authorization surface
    escrow_release relies on note nullification rather than msg_sender() checks. Any concerns with this pattern?

  3. Single-slot escrow per escrow_id
    Using PrivateMutable with initialize_or_replace + tombstone: any edge cases experts would flag?

1 Like

Hey @zkDomson, Wonderland’s escrow standard might be useful to implement something like this, although the approach is very different from what’s presented in this post: escrow, release logic and any token used are all different contracts.

To answer some of your questions:

  1. It depends on how much trust can be placed on the controller. Only the controller can nullify the note, so what happens if for whatever reason he doesn’t when he’s supposed to?
  2. Nullifying the note requires the controller’s keys. I don’t see a problem with this.

Hi @ilpepepig thank you for your reply. The controller will always be a trusted actor. Even if his keys should be compromised, there will be a way to recover tokens through mint/burn mechanism. But thats actually a good point. We definitely need some additional functionalities for this case.

1 Like

Interesting use case, what would be the unhappy flow in case of no delivery off chain or the other leg. Is the sender of the on chain leg can reclaim after an expiry time?