Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 45 additions & 35 deletions bun.lock

Large diffs are not rendered by default.

123 changes: 123 additions & 0 deletions docs/commitment-pattern-findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Commitment Pattern Investigation — Findings

**Date:** 2026-03-14
**Branch:** `feat/wonderland-direct-transfer`
**Status:** Solved with custom token contract fork. Blocked on devnet until v4.0.4 upgrade.

## TL;DR

The official Aztec TokenContract v4.0.4 hardcodes `completer = msg_sender()` in `prepare_private_balance_increase`, preventing cross-party commitment flows where the server prepares and the client finalizes.

**Solution:** A one-line fork of the token contract that accepts an explicit `completer` parameter. This is safe — the completer can only send tokens FROM their own balance TO the address bound in the partial note.

**Blocker:** The commitment pattern works on local sandbox (v4.0.4) but not on devnet (`4.0.0-devnet.2-patch.1`) due to a PXE nullifier witness bug fixed in v4.0.4. Devnet upgrade expected in ~2 weeks.

## Background

We need the commitment pattern for **structural recipient verification**: if the server creates a partial note bound to its own address, the client can only complete the transfer TO the server. This closes the "who did the payment go to?" verification gap.

## Investigation Timeline

### Phase 1: Testing standard contracts

| Environment | SDK Version | Contract | Result |
|---|---|---|---|
| Devnet | `4.0.0-devnet.2-patch.1` | Wonderland `@defi-wonderland/aztec-standards` | Nullifier witness not found |
| Devnet | `4.0.0-devnet.2-patch.1` | Official `@aztec/noir-contracts.js` | Nullifier witness not found |

Initial diagnosis: the error occurs because the standard token contract binds `completer = msg_sender()` during prepare. When server (Bob) prepares and client (Alice) finalizes, the nullifier hash mismatches (Bob != Alice).

### Phase 2: Custom token contract fork

We forked the official v4.0.4 token contract with one change:

```diff
- fn _prepare_private_balance_increase(to: AztecAddress) -> PartialUintNote {
- UintNote::partial(to, slot, context, to, self.msg_sender())
+ fn _prepare_private_balance_increase(to: AztecAddress, completer: AztecAddress) -> PartialUintNote {
+ UintNote::partial(to, slot, context, to, completer)
}
```

Compiled with `aztecprotocol/aztec:4.0.4` Docker image (`nargo compile` + `bb aztec_process`).

### Phase 3: Devnet testing

| SDK Version | Node Version | Result |
|---|---|---|
| `4.0.4` | devnet (`4.0.0-devnet.2-patch.1`) | "Incorrect verification keys tree root" — VK tree mismatch |
| `4.0.0-devnet.2-patch.1` | devnet | prepare succeeds, finalize fails: "Nullifier witness not found" |

The devnet has a PXE/simulator bug where `utilityGetNullifierMembershipWitness` cannot find the nullifier created by partial note prepare. This bug was fixed in the v4.0.4 SDK (PRs below), but the devnet node hasn't upgraded.

Using the v4.0.4 SDK with the devnet node fails because the protocol's verification key tree root changed between versions — account deployment is rejected.

**Conclusion:** The commitment pattern cannot work on devnet until both the SDK and node are at v4.0.4+.

## Root Cause — Standard Contract Limitation

From the v4.0.4 token contract source (`main.nr`):

```rust
fn _prepare_private_balance_increase(to: AztecAddress) -> PartialUintNote {
let partial_note = UintNote::partial(
to,
self.storage.balances.get_storage_slot(),
self.context,
to, // owner
self.msg_sender(), // completer = whoever calls prepare
);
partial_note
}
```

The validity commitment hash **includes the completer's identity** (introduced in PR [#14379](https://github.com/AztecProtocol/aztec-packages/pull/14379)). The prepare step stores `completer = msg_sender()`, and finalize recomputes the hash using its own `msg_sender()`. If they differ, the nullifier lookup fails.

### Our x402 flow vs what the standard contract expects

```
Standard contract (broken for x402):
Bob (server) calls prepare(bob) → completer = Bob
Alice (client) calls finalize(...) → checks completer = Alice
→ Bob != Alice → nullifier hash mismatch

Our fork (works):
Bob (server) calls prepare(bob, alice) → completer = Alice
Alice (client) calls finalize(...) → checks completer = Alice
→ Alice == Alice → succeeds
```

## Security Assessment of the Fork

The `completer` parameter only controls who is authorized to call finalize for that specific partial note. The completer:
- Can only send tokens FROM their own balance
- Can only send TO the address specified in `to` (cryptographically bound in the partial note)
- Cannot steal tokens — they can only choose to complete or not
- Cannot change the recipient — it's bound at prepare time

The rest of the token contract (minting, balances, transfers, admin) is untouched.

## Upstream PRs Investigated

| PR | Date Merged | What It Does | In v4.0.4? |
|---|---|---|---|
| [#14379](https://github.com/AztecProtocol/aztec-packages/pull/14379) | 2025-05-21 | Added completer identity to validity commitment hash | Yes |
| [#14432](https://github.com/AztecProtocol/aztec-packages/pull/14432) | 2025-05-27 | Moved validity commitment from public storage to nullifier tree | Yes |
| [#14533](https://github.com/AztecProtocol/aztec-packages/pull/14533) | 2025-06-03 | Implemented private-to-private partial note completion | Yes |
| [#12391](https://github.com/AztecProtocol/aztec-packages/pull/12391) | Earlier | Partial notes system redesign | Yes |

All PRs are in v4.0.4 (tagged 2026-02-27). The nullifier witness bug on devnet is in the PXE/simulator, not in the contract — v4.0.4 SDK has the fix.

## Current Status

- **Custom contract**: compiled, artifact checked in, TypeScript wrapper at `@aztec-x402/contracts/Token`
- **Local sandbox (4.0.4)**: ready for testing the full commitment flow
- **Devnet**: blocked until node upgrades to 4.0.4+ (~2 weeks)
- **Fallback**: `transfer_in_private` (direct transfer) works on devnet but lacks structural recipient verification

## Artifacts

- Forked contract source: `packages/contracts/token/src/main.nr`
- Compiled artifact: `packages/contracts/token/target/token_contract-Token.json`
- TypeScript wrapper: `packages/contracts/src/Token.ts`
- Phase 0 test script: `packages/demo/src/aztec/test-wonderland-commitment.ts`
140 changes: 97 additions & 43 deletions packages/client/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ const SENDER_ADDRESS = "0x" + "aa".repeat(32);
const SERVER_ADDRESS = "0x" + "bb".repeat(32);
const TOKEN_ADDRESS = "0x" + "dd".repeat(32);
const TX_HASH = "0x" + "cc".repeat(32);
const MOCK_COMMITMENT = "0x" + "ff".repeat(32);
const MOCK_NONCE = "01234567-0123-0123-0123-012345678901";

function createMockScheme(): SchemeNetworkClient {
function createMockScheme(): SchemeNetworkClient & { signer: { getAddress: ReturnType<typeof jest.fn> } } {
return {
scheme: "exact",
signer: {
getAddress: jest.fn().mockResolvedValue(SENDER_ADDRESS),
},
createPaymentPayload: jest.fn().mockResolvedValue({
x402Version: 2,
payload: {
Expand All @@ -22,15 +27,15 @@ function createMockScheme(): SchemeNetworkClient {
};
}

function createPaymentRequired() {
function createPaymentRequired(extra: Record<string, unknown> = {}) {
const requirements: PaymentRequirements = {
scheme: "exact",
network: "aztec:sandbox",
asset: TOKEN_ADDRESS,
amount: "100000",
payTo: SERVER_ADDRESS,
maxTimeoutSeconds: 120,
extra: {},
extra,
};

return {
Expand All @@ -39,8 +44,12 @@ function createPaymentRequired() {
};
}

function encode402(extra: Record<string, unknown> = {}): string {
return Buffer.from(JSON.stringify(createPaymentRequired(extra))).toString("base64");
}

describe("wrapFetchWithPayment", () => {
let scheme: SchemeNetworkClient;
let scheme: ReturnType<typeof createMockScheme>;

beforeEach(() => {
scheme = createMockScheme();
Expand All @@ -62,18 +71,41 @@ describe("wrapFetchWithPayment", () => {
expect(scheme.createPaymentPayload).not.toHaveBeenCalled();
});

it("handles 402 by creating payment and retrying", async () => {
const paymentRequired = createPaymentRequired();
const encoded = Buffer.from(JSON.stringify(paymentRequired)).toString("base64");
it("returns 402 if no PAYMENT-REQUIRED header present", async () => {
const mockFetch = jest.fn().mockResolvedValue(
new Response("Payment Required", { status: 402 }),
);

const wrappedFetch = wrapFetchWithPayment(mockFetch, scheme);
const response = await wrappedFetch("https://api.example.com/data");

expect(response.status).toBe(402);
expect(scheme.createPaymentPayload).not.toHaveBeenCalled();
});

it("handles full 3-request flow: initial → prepare → payment", async () => {
const mockFetch = jest
.fn()
// Phase 1: initial request → 402 with nonce
.mockResolvedValueOnce(
new Response("Payment Required", {
status: 402,
headers: { "PAYMENT-REQUIRED": encode402({ nonce: MOCK_NONCE }) },
}),
)
// Phase 2: prepare request → 402 with nonce + commitment
.mockResolvedValueOnce(
new Response("Payment Required", {
status: 402,
headers: { "PAYMENT-REQUIRED": encoded },
headers: {
"PAYMENT-REQUIRED": encode402({
nonce: MOCK_NONCE,
commitment: MOCK_COMMITMENT,
}),
},
}),
)
// Phase 3: payment request → success
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: "paid content" }), {
status: 200,
Expand All @@ -85,38 +117,52 @@ describe("wrapFetchWithPayment", () => {
const response = await wrappedFetch("https://api.example.com/data");

expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(scheme.createPaymentPayload).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledTimes(3);

// Check that the retry includes the PAYMENT-SIGNATURE header
const retryCall = mockFetch.mock.calls[1];
const retryInit: Record<string, unknown> = retryCall[1];
const headers: Record<string, string> = retryInit.headers;
expect(headers["PAYMENT-SIGNATURE"]).toBeTruthy();
});
// Phase 2 should include X-402-PREPARE header
const prepareCall = mockFetch.mock.calls[1];
const prepareInit: Record<string, unknown> = prepareCall[1];
const prepareHeaders: Record<string, string> = prepareInit.headers;
expect(prepareHeaders["X-402-PREPARE"]).toBeTruthy();

it("returns 402 if no PAYMENT-REQUIRED header present", async () => {
const mockFetch = jest.fn().mockResolvedValue(
new Response("Payment Required", { status: 402 }),
// Decode and verify prepare data contains nonce + sender address
const prepareData = JSON.parse(
Buffer.from(prepareHeaders["X-402-PREPARE"], "base64").toString(),
);
expect(prepareData.nonce).toBe(MOCK_NONCE);
expect(prepareData.senderAddress).toBe(SENDER_ADDRESS);

const wrappedFetch = wrapFetchWithPayment(mockFetch, scheme);
const response = await wrappedFetch("https://api.example.com/data");
// Phase 3 should include PAYMENT-SIGNATURE header
const paymentCall = mockFetch.mock.calls[2];
const paymentInit: Record<string, unknown> = paymentCall[1];
const paymentHeaders: Record<string, string> = paymentInit.headers;
expect(paymentHeaders["PAYMENT-SIGNATURE"]).toBeTruthy();

expect(response.status).toBe(402);
expect(scheme.createPaymentPayload).not.toHaveBeenCalled();
// createPaymentPayload should receive requirements with commitment
expect(scheme.createPaymentPayload).toHaveBeenCalledTimes(1);
const payloadCall = (scheme.createPaymentPayload as ReturnType<typeof jest.fn>).mock.calls[0];
const requirements = payloadCall[1] as PaymentRequirements;
expect(requirements.extra?.commitment).toBe(MOCK_COMMITMENT);
});

it("passes through the original request options on retry", async () => {
const paymentRequired = createPaymentRequired();
const encoded = Buffer.from(JSON.stringify(paymentRequired)).toString("base64");

it("passes through original request options on retry", async () => {
const mockFetch = jest
.fn()
.mockResolvedValueOnce(
new Response("Payment Required", {
status: 402,
headers: { "PAYMENT-REQUIRED": encoded },
headers: { "PAYMENT-REQUIRED": encode402({ nonce: MOCK_NONCE }) },
}),
)
.mockResolvedValueOnce(
new Response("Payment Required", {
status: 402,
headers: {
"PAYMENT-REQUIRED": encode402({
nonce: MOCK_NONCE,
commitment: MOCK_COMMITMENT,
}),
},
}),
)
.mockResolvedValueOnce(
Expand All @@ -130,33 +176,41 @@ describe("wrapFetchWithPayment", () => {
body: "test body",
});

const retryCall = mockFetch.mock.calls[1];
// Phase 3 should preserve original headers + add PAYMENT-SIGNATURE
const retryCall = mockFetch.mock.calls[2];
expect(retryCall[0]).toBe("https://api.example.com/data");
const retryInit: Record<string, unknown> = retryCall[1];
expect(retryInit.method).toBe("POST");
expect(retryInit.body).toBe("test body");
// Should merge custom headers with payment header
const headers: Record<string, string> = retryInit.headers;
expect(headers["X-Custom"]).toBe("value");
expect(headers["PAYMENT-SIGNATURE"]).toBeTruthy();
});

it("does not retry more than once", async () => {
const paymentRequired = createPaymentRequired();
const encoded = Buffer.from(JSON.stringify(paymentRequired)).toString("base64");

const mockFetch = jest.fn().mockResolvedValue(
new Response("Payment Required", {
status: 402,
headers: { "PAYMENT-REQUIRED": encoded },
}),
);
it("falls back gracefully when prepare phase returns non-402", async () => {
const mockFetch = jest
.fn()
// Phase 1: initial 402 with nonce
.mockResolvedValueOnce(
new Response("Payment Required", {
status: 402,
headers: { "PAYMENT-REQUIRED": encode402({ nonce: MOCK_NONCE }) },
}),
)
// Phase 2: prepare returns 500 (server error)
.mockResolvedValueOnce(
new Response("Internal Server Error", { status: 500 }),
)
// Phase 3: payment still attempted with original requirements
.mockResolvedValueOnce(
new Response("ok", { status: 200 }),
);

const wrappedFetch = wrapFetchWithPayment(mockFetch, scheme);
const response = await wrappedFetch("https://api.example.com/data");

// First call + one retry = 2 calls total
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(response.status).toBe(402);
// Should still attempt payment with original requirements
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
Loading