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

**URL:** <https://forum.aztec.network/t/confidential-token-escrow-pattern-for-dvp-settlement-aztec-devnet-v3-0-0-review-request/8243>\
**Category:** Aztec\
**Created:** [December 22, 2025, 1:08pm UTC](https://forum.aztec.network/t/confidential-token-escrow-pattern-for-dvp-settlement-aztec-devnet-v3-0-0-review-request/8243 "2025-12-22T13:08:20Z")\
**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, 1:08pm UTC](https://forum.aztec.network/t/confidential-token-escrow-pattern-for-dvp-settlement-aztec-devnet-v3-0-0-review-request/8243/1 "2025-12-22T13:08:20Z")

</div>

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

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

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

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

```

### Create escrow

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

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

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

```

* * *

### Escrow open (lock private balance)

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

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

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

```

**Payment confirmation → release escrow**

```rust
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?

---

<div class="post-metadata">

**Author:** ![ilpepepig](https://avatars.discourse-cdn.com/v4/letter/i/db5fbb/32.png) [@ilpepepig](https://forum.aztec.network/u/ilpepepig)\
**Post date:** [January 13, 2026, 10:00am UTC](https://forum.aztec.network/t/confidential-token-escrow-pattern-for-dvp-settlement-aztec-devnet-v3-0-0-review-request/8243/2 "2026-01-13T10:00:51Z")

</div>

Hey @zkDomson, [Wonderland’s escrow standard](https://github.com/defi-wonderland/aztec-standards/blob/v3.0.0-devnet.2/src/escrow_contract/README.md) 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.

---

<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:58am UTC](https://forum.aztec.network/t/confidential-token-escrow-pattern-for-dvp-settlement-aztec-devnet-v3-0-0-review-request/8243/3 "2026-01-21T09:58:47Z")

</div>

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.

---

<div class="post-metadata">

**Author:** ![niceday](https://dub1.discourse-cdn.com/flex013/user_avatar/forum.aztec.network/niceday/32/2697_2.png) [@niceday](https://forum.aztec.network/u/niceday)\
**Post date:** [January 23, 2026, 9:58pm UTC](https://forum.aztec.network/t/confidential-token-escrow-pattern-for-dvp-settlement-aztec-devnet-v3-0-0-review-request/8243/4 "2026-01-23T21:58:47Z")

</div>

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?
