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
-
Nullifier authority
Is usingowner = controlleras the sole nullifier authority the right model for escrow control? -
Authorization surface
escrow_releaserelies on note nullification rather thanmsg_sender()checks. Any concerns with this pattern? -
Single-slot escrow per
escrow_id
UsingPrivateMutablewithinitialize_or_replace+ tombstone: any edge cases experts would flag?