From a8e71dc8be0af667f138789fc8a8b1530e2b8e10 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 12:24:55 -0400 Subject: [PATCH 01/40] test(end-to-end): onchain message delivery harness (mode x secret-source) Cross-PXE buildMessageDeliveryTest over constrained/unconstrained delivery x handshake/arbitrary-secret sources, plus an it.failing F-770 cell. Renames e2e_constrained_delivery -> e2e_onchain_delivery; adds unconstrained emit fns + TestWallet.registerArbitrarySecret. Also regenerates a stale e2e jest testEnvironment ref flagged by prepare:check. --- .../src/main.nr | 10 + yarn-project/end-to-end/package.json | 2 +- .../src/e2e_constrained_delivery.test.ts | 266 ------------- .../src/e2e_onchain_delivery.test.ts | 350 ++++++++++++++++++ .../end-to-end/src/test-wallet/test_wallet.ts | 10 +- 5 files changed, 370 insertions(+), 268 deletions(-) delete mode 100644 yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts create mode 100644 yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr index 6274a64cb05c..10ff0dd656af 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr @@ -56,6 +56,16 @@ pub contract ConstrainedDeliveryTest { self.emit(DeliveryEvent { value }).deliver_to(recipient, MessageDelivery::onchain_constrained()); } + #[external("private")] + fn emit_note_unconstrained(recipient: AztecAddress, value: Field) { + self.storage.notes.at(recipient).insert(FieldNote { value }).deliver(MessageDelivery::onchain_unconstrained()); + } + + #[external("private")] + fn emit_event_unconstrained(recipient: AztecAddress, value: Field) { + self.emit(DeliveryEvent { value }).deliver_to(recipient, MessageDelivery::onchain_unconstrained()); + } + #[external("private")] fn emit_two_events(recipient: AztecAddress) { self.emit(DeliveryEvent { value: 1 }).deliver_to(recipient, MessageDelivery::onchain_constrained()); diff --git a/yarn-project/end-to-end/package.json b/yarn-project/end-to-end/package.json index 6b21639a228f..6fb93fd46441 100644 --- a/yarn-project/end-to-end/package.json +++ b/yarn-project/end-to-end/package.json @@ -170,6 +170,6 @@ "setupFiles": [ "../../foundation/src/jest/setup.mjs" ], - "testEnvironment": "./shared/timing_env.mjs" + "testEnvironment": "../../foundation/src/jest/env.mjs" } } diff --git a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts b/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts deleted file mode 100644 index 5ee4f32ef33a..000000000000 --- a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; -import { NO_FROM } from '@aztec/aztec.js/account'; -import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import { BatchCall } from '@aztec/aztec.js/contracts'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import type { Wallet } from '@aztec/aztec.js/wallet'; -import { BlockNumber } from '@aztec/foundation/branded-types'; -import { HandshakeRegistryContract } from '@aztec/noir-contracts.js/HandshakeRegistry'; -import { - ConstrainedDeliveryTestContract, - type DeliveryEvent, -} from '@aztec/noir-test-contracts.js/ConstrainedDeliveryTest'; -import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/handshake-registry/constants'; -import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; - -import { jest } from '@jest/globals'; - -import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; -import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from './fixtures/setup.js'; -import { TestWallet } from './test-wallet/test_wallet.js'; - -describe('constrained delivery', () => { - jest.setTimeout(300_000); - - let teardown: () => Promise; - let wallet: Wallet; - let sender: AztecAddress; - let recipient: AztecAddress; - let batchRecipient: AztecAddress; - let batchRecipient2: AztecAddress; - let batchRecipient3: AztecAddress; - let batchRecipient4: AztecAddress; - let contract: ConstrainedDeliveryTestContract; - let registry: HandshakeRegistryContract; - - beforeAll(async () => { - ({ - teardown, - wallet, - accounts: [sender, recipient, batchRecipient, batchRecipient2, batchRecipient3, batchRecipient4], - } = await setup(6, { ...AUTOMINE_E2E_OPTS })); - - await ensureHandshakeRegistryPublished(wallet, sender); - ({ contract } = await ConstrainedDeliveryTestContract.deploy(wallet).send({ from: sender })); - registry = HandshakeRegistryContract.at(STANDARD_HANDSHAKE_REGISTRY_ADDRESS, wallet); - }); - - afterAll(() => teardown()); - - it('reuses an existing standard-registry constrained handshake', async () => { - await contract.methods.emit_note(recipient, 1).send({ from: sender }); - - const { result: secretAfterFirstSend } = await contract.methods - .get_app_siloed_secrets(sender, recipient) - .simulate({ from: sender }); - expect(secretAfterFirstSend).toBeDefined(); - - await contract.methods.emit_event(recipient, 1).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, recipient) - .simulate({ from: sender }); - // The second send reuses the handshake rather than bootstrapping a new one: the secret is unchanged. - expect(secret).toEqual(secretAfterFirstSend); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - - expect(index).toEqual(2n); - }); - - // Constrained sends to one recipient form a strictly ordered sequence, so concurrent and batched sends behave - // differently: parallel txs collide on the shared index nullifier, same-tx batches work only once the handshake is - // committed, and batches that bootstrap a brand-new recipient re-handshake onto separate secrets. Each test uses - // its own recipient. - describe('concurrency and batching', () => { - // Constrained sends to one `(sender, recipient)` pair are strictly ordered: the first send bootstraps the - // handshake and every send emits a nullifier keyed only on `(sender, recipient, secret, index)`. Two sends fired - // in parallel read the same index and collide, so one tx is rejected. Marked `it.failing` because this is a - // protocol limitation, not a bug: it documents the constraint and will start failing (prompting its removal) if - // parallel sends to a single pair ever become supported. The working alternative is the batched test below. - it.failing('cannot fan out constrained sends on the same sequence in parallel', async () => { - await Promise.all([ - contract.methods.emit_note(recipient, 1).send({ from: sender }), - contract.methods.emit_note(recipient, 1).send({ from: sender }), - ]); - }); - - // CAN batch (1): a contract call may emit several constrained messages to one recipient in a single tx; each - // later emit proves the previous nullifier as a same-tx pending nullifier. The handshake must already be - // committed (see the re-handshake test below), so it is established first; a fresh recipient starts at index 0, - // so two emits land indices 0 and 1 and the next index is 2. - it('lands multiple constrained sends from a single contract call on an established handshake', async () => { - await registry.methods.non_interactive_handshake(sender, batchRecipient).send({ from: sender }); - - await contract.methods.emit_two_events(batchRecipient).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(2n); - }); - - // CAN batch (2): client-side BatchCall aggregates separate calls into one tx with the same effect. The two - // emit_note calls that fail as parallel txs (above) succeed batched, given an established handshake. - it('lands the same two sends when aggregated into one tx with BatchCall', async () => { - await registry.methods.non_interactive_handshake(sender, batchRecipient2).send({ from: sender }); - - await new BatchCall(wallet, [ - contract.methods.emit_note(batchRecipient2, 1), - contract.methods.emit_note(batchRecipient2, 1), - ]).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient2) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(2n); - }); - - // CANNOT batch onto a brand-new recipient, even within a single contract call. The registry lookup that decides - // reuse-vs-bootstrap is a utility call reading committed state, so the second emit cannot see the first emit's - // pending bootstrap and re-handshakes onto a fresh secret (each handshake mints a new shared secret). The registry - // keeps the second handshake, which holds a single log, so the next index is 1, not 2. This is why the - // established-handshake tests above seed the handshake first. - it('re-handshakes instead of reusing when sends bootstrap a new recipient in the same tx', async () => { - await contract.methods.emit_two_events(batchRecipient3).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient3) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(1n); - }); - - // The same new-recipient limitation holds via client-side BatchCall: the two aggregated emit_note calls each - // bootstrap and re-handshake onto separate secrets (the utility read can't see the first's pending bootstrap), - // so the next index is 1, not 2. Confirms the constraint is in the utility read, not the batching mechanism. - it('re-handshakes instead of reusing when BatchCall sends bootstrap a new recipient in the same tx', async () => { - await new BatchCall(wallet, [ - contract.methods.emit_note(batchRecipient4, 1), - contract.methods.emit_note(batchRecipient4, 1), - ]).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient4) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(1n); - }); - }); -}); - -// A constrained message sent by an account on one PXE is discovered and read by a recipient whose account lives on a -// separate PXE, with no shared in-memory state: PXE B finds the message purely from on-chain logs plus the -// HandshakeRegistry. The two PXEs share one node, following the e2e_2_pxes pattern. -describe('cross-PXE constrained delivery', () => { - jest.setTimeout(300_000); - - let aztecNode: AztecNode & AztecNodeDebug; - let walletSender: TestWallet; - let walletRecipient: TestWallet; - let sender: AztecAddress; - let recipient: AztecAddress; - let additionallyFundedAccounts: InitialAccountData[]; - let contractSender: ConstrainedDeliveryTestContract; - let teardownSender: () => Promise; - let teardownRecipient: () => Promise; - - beforeAll(async () => { - // PXE A holds the sender. The recipient is funded at genesis here but created and deployed on PXE B below. - ({ - aztecNode, - additionallyFundedAccounts, - wallet: walletSender, - accounts: [sender], - teardown: teardownSender, - } = await setup(1, { - ...AUTOMINE_E2E_OPTS, - additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), - })); - - // PXE B holds the recipient on the same node; the recipient account's keys live only here. - ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( - aztecNode, - aztecNode, - {}, - undefined, - 'pxe-b', - )); - const recipientAccount = await walletRecipient.createSchnorrAccount( - additionallyFundedAccounts[0].secret, - additionallyFundedAccounts[0].salt, - ); - await (await recipientAccount.getDeployMethod()).send({ from: NO_FROM }); - recipient = recipientAccount.address; - - await ensureHandshakeRegistryPublished(walletSender, sender); - const { contract: deployed, instance } = await ConstrainedDeliveryTestContract.deploy(walletSender).send({ - from: sender, - }); - contractSender = deployed; - - await ensureHandshakeRegistryPublished(walletRecipient, recipient); - await walletRecipient.registerContract(instance, ConstrainedDeliveryTestContract.artifact); - }); - - afterAll(async () => { - await teardownRecipient(); - await teardownSender(); - }); - - it('delivers multiple constrained events from PXE A that PXE B discovers', async () => { - // Distinct values prove PXE B decrypts each message's content, not merely that a tagged log arrived; delivering - // several on one sequence proves both the bootstrap send (index 0) and the reused-handshake sends are - // discovered. Constrained sends to one pair are strictly ordered, so they go one tx at a time. - const eventValues = [10n, 20n, 30n]; - const blockNumbers: number[] = []; - for (const value of eventValues) { - const { receipt } = await contractSender.methods.emit_event(recipient, value).send({ from: sender }); - blockNumbers.push(receipt.blockNumber!); - } - - await walletRecipient.sync(); - - const events = await walletRecipient.getPrivateEvents( - ConstrainedDeliveryTestContract.events.DeliveryEvent, - { - contractAddress: contractSender.address, - fromBlock: BlockNumber(Math.min(...blockNumbers)), - toBlock: BlockNumber(Math.max(...blockNumbers) + 1), - scopes: [recipient], - }, - ); - - const discovered = events.map(e => e.event.value); - expect(discovered.length).toBe(eventValues.length); - for (const value of eventValues) { - expect(discovered).toContainEqual(value); - } - }); - - it('delivers multiple constrained notes from PXE A that PXE B reads back', async () => { - // Same recipient and handshake as the events above, so these notes land at the following sequence indices. - const noteValues = [40n, 50n, 60n]; - for (const value of noteValues) { - await contractSender.methods.emit_note(recipient, value).send({ from: sender }); - } - - await walletRecipient.sync(); - - const contractRecipient = ConstrainedDeliveryTestContract.at(contractSender.address, walletRecipient); - // Count proves every delivered note was discovered; the sum of distinct values proves each was decrypted. - const { result } = await contractRecipient.methods.get_note_values(recipient).simulate({ from: recipient }); - const values: bigint[] = result.storage.slice(0, Number(result.len)); - expect(values).toEqual(noteValues); - }); -}); diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts new file mode 100644 index 000000000000..1c5612ccf31e --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -0,0 +1,350 @@ +import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; +import type { FieldLike } from '@aztec/aztec.js/abi'; +import { NO_FROM } from '@aztec/aztec.js/account'; +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { BatchCall } from '@aztec/aztec.js/contracts'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { Point } from '@aztec/foundation/curves/grumpkin'; +import { HandshakeRegistryContract } from '@aztec/noir-contracts.js/HandshakeRegistry'; +import { + ConstrainedDeliveryTestContract, + type DeliveryEvent, +} from '@aztec/noir-test-contracts.js/ConstrainedDeliveryTest'; +import type { PXECreationOptions } from '@aztec/pxe/server'; +import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/handshake-registry/constants'; +import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; + +import { jest } from '@jest/globals'; + +import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from './fixtures/setup.js'; +import { TestWallet } from './test-wallet/test_wallet.js'; + +// The wallet hook that selects a message's tagging-secret source (handshake / address-ECDH / arbitrary). Derived from +// the exported PXE options rather than importing the hook type, which `@aztec/pxe/server` does not re-export. +type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; + +// Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; +// unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's +// `resolveTaggingSecretStrategy` hook selects (a non-interactive handshake, an address-derived ECDH secret, or a raw +// arbitrary secret shared out of band). This harness exercises the valid (mode, source) cells end to end across two +// PXEs that share only a node: PXE A sends, PXE B discovers purely from on-chain logs plus the HandshakeRegistry. +// +// Cross-PXE is the meaningful setup: PXE B holds no sender state, so a cell only "discovers" a message if the source +// truly reached it. That is also what makes the F-770 cell a real red — a defaulted unconstrained send to an external +// recipient currently derives an address-ECDH tag PXE B cannot reconstruct. +function buildMessageDeliveryTest(opts: { + description: string; + mode: 'constrained' | 'unconstrained'; + // Omitted = no hook, so the PXE applies its default strategy for the mode. + senderHook?: SenderHook; + // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. + recipientRegistration?: ( + recipient: TestWallet, + recipientAddress: AztecAddress, + sender: AztecAddress, + ) => Promise; + // When true the discovery assertions are expected to fail (a not-yet-implemented path); see the F-770 cell. + expectRed?: boolean; +}) { + const { description, mode, senderHook, recipientRegistration, expectRed } = opts; + + describe(description, () => { + jest.setTimeout(300_000); + + const eventValues = [10n, 20n, 30n]; + const noteValues = [40n, 50n, 60n]; + + let aztecNode: AztecNode & AztecNodeDebug; + let walletSender: TestWallet; + let walletRecipient: TestWallet; + let sender: AztecAddress; + let recipient: AztecAddress; + let contractSender: ConstrainedDeliveryTestContract; + let teardownSender: () => Promise; + let teardownRecipient: () => Promise; + // Discovery results captured in beforeAll, so the (possibly `it.failing`) assertions below stay pure: an infra + // failure during delivery fails beforeAll loudly instead of being swallowed as an expected red. + let discoveredEvents: FieldLike[]; + let readNotes: bigint[]; + + beforeAll(async () => { + // PXE A holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded at genesis + // here but created and deployed on the isolated PXE B below, so it carries no sender state from other cells. + let additionallyFundedAccounts: InitialAccountData[]; + ({ + aztecNode, + additionallyFundedAccounts, + wallet: walletSender, + accounts: [sender], + teardown: teardownSender, + } = await setup(1, { + ...AUTOMINE_E2E_OPTS, + additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), + pxeCreationOptions: senderHook ? { hooks: { resolveTaggingSecretStrategy: senderHook } } : undefined, + })); + + ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( + aztecNode, + aztecNode, + {}, + undefined, + 'pxe-b', + )); + const recipientAccount = await walletRecipient.createSchnorrAccount( + additionallyFundedAccounts[0].secret, + additionallyFundedAccounts[0].salt, + ); + await (await recipientAccount.getDeployMethod()).send({ from: NO_FROM }); + recipient = recipientAccount.address; + + await ensureHandshakeRegistryPublished(walletSender, sender); + const { contract: deployed, instance } = await ConstrainedDeliveryTestContract.deploy(walletSender).send({ + from: sender, + }); + contractSender = deployed; + + await ensureHandshakeRegistryPublished(walletRecipient, recipient); + await walletRecipient.registerContract(instance, ConstrainedDeliveryTestContract.artifact); + + await recipientRegistration?.(walletRecipient, recipient, sender); + + const sendEvent = (value: bigint) => + mode === 'constrained' + ? contractSender.methods.emit_event(recipient, value) + : contractSender.methods.emit_event_unconstrained(recipient, value); + const sendNote = (value: bigint) => + mode === 'constrained' + ? contractSender.methods.emit_note(recipient, value) + : contractSender.methods.emit_note_unconstrained(recipient, value); + + // Constrained sends to one pair are strictly ordered, so deliver one tx at a time. The first send bootstraps the + // handshake (when the source is a handshake); the rest reuse it. + const blockNumbers: number[] = []; + for (const value of eventValues) { + const { receipt } = await sendEvent(value).send({ from: sender }); + blockNumbers.push(receipt.blockNumber!); + } + for (const value of noteValues) { + await sendNote(value).send({ from: sender }); + } + + await walletRecipient.sync(); + + const events = await walletRecipient.getPrivateEvents( + ConstrainedDeliveryTestContract.events.DeliveryEvent, + { + contractAddress: contractSender.address, + fromBlock: BlockNumber(Math.min(...blockNumbers)), + toBlock: BlockNumber(Math.max(...blockNumbers) + 1), + scopes: [recipient], + }, + ); + discoveredEvents = events.map(e => e.event.value); + + const contractRecipient = ConstrainedDeliveryTestContract.at(contractSender.address, walletRecipient); + const { result } = await contractRecipient.methods.get_note_values(recipient).simulate({ from: recipient }); + readNotes = result.storage.slice(0, Number(result.len)); + }); + + afterAll(async () => { + await teardownRecipient(); + await teardownSender(); + }); + + // `it.failing` passes while the assertion fails and turns into a suite failure once the path works, prompting + // promotion to a plain `it`. Only the assertion runs here; delivery happened in beforeAll, so a real delivery + // failure surfaces rather than masquerading as the expected red. + const test = expectRed ? it.failing : it; + + test('PXE B discovers the events delivered by PXE A', () => { + expect(discoveredEvents.length).toBe(eventValues.length); + for (const value of eventValues) { + expect(discoveredEvents).toContainEqual(value); + } + }); + + test('PXE B reads back the notes delivered by PXE A', () => { + expect(readNotes).toEqual(noteValues); + }); + }); +} + +describe('onchain delivery', () => { + // GREEN: constrained always goes through a handshake (the PXE default for constrained), reused mode-agnostically. + buildMessageDeliveryTest({ + description: 'constrained · handshake', + mode: 'constrained', + }); + + // GREEN: unconstrained delivery whose source the wallet pins to a non-interactive handshake. The first send + // bootstraps the handshake; PXE B discovers it via the registry and reads the (nullifier-free) unconstrained logs. + buildMessageDeliveryTest({ + description: 'unconstrained · handshake', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), + }); + + // GREEN: unconstrained delivery tagged with a raw secret the two parties share out of band. The sender's hook and + // the recipient registration use the same point, generated once in `recipientRegistration` (which runs before any + // send fires the hook). + let arbitrarySecret: Point; + buildMessageDeliveryTest({ + description: 'unconstrained · arbitrary secret', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: arbitrarySecret }), + recipientRegistration: async (recipientWallet, recipientAddress) => { + arbitrarySecret = await Point.random(); + await recipientWallet.registerArbitrarySecret(recipientAddress, arbitrarySecret); + }, + }); + + // RED (F-770): with no hook, unconstrained delivery to an external recipient defaults to an address-derived (ECDH) + // tag. PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing. F-770 will default + // external unconstrained delivery to a non-interactive handshake; this flips to green then. + buildMessageDeliveryTest({ + description: 'unconstrained · default to external recipient (F-770)', + mode: 'unconstrained', + expectRed: true, + }); +}); + +// Constrained sends to one recipient form a strictly ordered sequence, so concurrent and batched sends behave +// differently: parallel txs collide on the shared index nullifier, same-tx batches work only once the handshake is +// committed, and batches that bootstrap a brand-new recipient re-handshake onto separate secrets. Each test uses +// its own recipient. This is single-PXE and constrained-specific, so it stays outside the cross-PXE matrix above. +describe('constrained delivery sequencing', () => { + jest.setTimeout(300_000); + + let teardown: () => Promise; + let wallet: Wallet; + let sender: AztecAddress; + let recipient: AztecAddress; + let batchRecipient: AztecAddress; + let batchRecipient2: AztecAddress; + let batchRecipient3: AztecAddress; + let batchRecipient4: AztecAddress; + let contract: ConstrainedDeliveryTestContract; + let registry: HandshakeRegistryContract; + + beforeAll(async () => { + ({ + teardown, + wallet, + accounts: [sender, recipient, batchRecipient, batchRecipient2, batchRecipient3, batchRecipient4], + } = await setup(6, { ...AUTOMINE_E2E_OPTS })); + + await ensureHandshakeRegistryPublished(wallet, sender); + ({ contract } = await ConstrainedDeliveryTestContract.deploy(wallet).send({ from: sender })); + registry = HandshakeRegistryContract.at(STANDARD_HANDSHAKE_REGISTRY_ADDRESS, wallet); + }); + + afterAll(() => teardown()); + + it('reuses an existing standard-registry constrained handshake', async () => { + await contract.methods.emit_note(recipient, 1).send({ from: sender }); + + const { result: secretAfterFirstSend } = await contract.methods + .get_app_siloed_secrets(sender, recipient) + .simulate({ from: sender }); + expect(secretAfterFirstSend).toBeDefined(); + + await contract.methods.emit_event(recipient, 1).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, recipient) + .simulate({ from: sender }); + // The second send reuses the handshake rather than bootstrapping a new one: the secret is unchanged. + expect(secret).toEqual(secretAfterFirstSend); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + + expect(index).toEqual(2n); + }); + + // Constrained sends to one `(sender, recipient)` pair are strictly ordered: the first send bootstraps the + // handshake and every send emits a nullifier keyed only on `(sender, recipient, secret, index)`. Two sends fired + // in parallel read the same index and collide, so one tx is rejected. Marked `it.failing` because this is a + // protocol limitation, not a bug: it documents the constraint and will start failing (prompting its removal) if + // parallel sends to a single pair ever become supported. The working alternative is the batched test below. + it.failing('cannot fan out constrained sends on the same sequence in parallel', async () => { + await Promise.all([ + contract.methods.emit_note(recipient, 1).send({ from: sender }), + contract.methods.emit_note(recipient, 1).send({ from: sender }), + ]); + }); + + // CAN batch (1): a contract call may emit several constrained messages to one recipient in a single tx; each + // later emit proves the previous nullifier as a same-tx pending nullifier. The handshake must already be + // committed (see the re-handshake test below), so it is established first; a fresh recipient starts at index 0, + // so two emits land indices 0 and 1 and the next index is 2. + it('lands multiple constrained sends from a single contract call on an established handshake', async () => { + await registry.methods.non_interactive_handshake(sender, batchRecipient).send({ from: sender }); + + await contract.methods.emit_two_events(batchRecipient).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(2n); + }); + + // CAN batch (2): client-side BatchCall aggregates separate calls into one tx with the same effect. The two + // emit_note calls that fail as parallel txs (above) succeed batched, given an established handshake. + it('lands the same two sends when aggregated into one tx with BatchCall', async () => { + await registry.methods.non_interactive_handshake(sender, batchRecipient2).send({ from: sender }); + + await new BatchCall(wallet, [ + contract.methods.emit_note(batchRecipient2, 1), + contract.methods.emit_note(batchRecipient2, 1), + ]).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient2) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(2n); + }); + + // CANNOT batch onto a brand-new recipient, even within a single contract call. The registry lookup that decides + // reuse-vs-bootstrap is a utility call reading committed state, so the second emit cannot see the first emit's + // pending bootstrap and re-handshakes onto a fresh secret (each handshake mints a new shared secret). The registry + // keeps the second handshake, which holds a single log, so the next index is 1, not 2. This is why the + // established-handshake tests above seed the handshake first. + it('re-handshakes instead of reusing when sends bootstrap a new recipient in the same tx', async () => { + await contract.methods.emit_two_events(batchRecipient3).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient3) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(1n); + }); + + // The same new-recipient limitation holds via client-side BatchCall: the two aggregated emit_note calls each + // bootstrap and re-handshake onto separate secrets (the utility read can't see the first's pending bootstrap), + // so the next index is 1, not 2. Confirms the constraint is in the utility read, not the batching mechanism. + it('re-handshakes instead of reusing when BatchCall sends bootstrap a new recipient in the same tx', async () => { + await new BatchCall(wallet, [ + contract.methods.emit_note(batchRecipient4, 1), + contract.methods.emit_note(batchRecipient4, 1), + ]).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient4) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(1n); + }); +}); diff --git a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts index f6b4389e34d4..56503b43b04c 100644 --- a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts +++ b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts @@ -20,7 +20,7 @@ import { TxSimulationResultWithAppOffset } from '@aztec/aztec.js/wallet'; import type { DefaultAccountEntrypointOptions } from '@aztec/entrypoints/account'; import { DefaultEntrypoint } from '@aztec/entrypoints/default'; import { Fq, Fr } from '@aztec/foundation/curves/bn254'; -import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; +import { GrumpkinScalar, type Point } from '@aztec/foundation/curves/grumpkin'; import type { NotesFilter } from '@aztec/pxe/client/lazy'; import { type PXEConfig, getPXEConfig } from '@aztec/pxe/config'; import { PXE, type PXECreationOptions, createPXE } from '@aztec/pxe/server'; @@ -398,6 +398,14 @@ export class TestWallet extends BaseWallet { return this.pxe.sync(); } + /** + * Registers a raw out-of-band shared secret so this PXE discovers unconstrained messages tagged with it. Test-only + * surface over {@link PXE.registerTaggingSecretSource}, which the base `Wallet` does not expose. + */ + registerArbitrarySecret(recipient: AztecAddress, secret: Point): Promise { + return this.pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); + } + stop(): Promise { return this.pxe.stop(); } From 082a4ec85796862babb09615b766b48a8ea916a9 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 13:05:09 -0400 Subject: [PATCH 02/40] refactor: rename ConstrainedDeliveryTest contract to OnchainDeliveryTest It now exercises both constrained and unconstrained delivery, matching the e2e_onchain_delivery suite. Also dedupes two redundant harness comments. --- noir-projects/noir-contracts/Nargo.toml | 2 +- .../Nargo.toml | 2 +- .../src/main.nr | 4 +-- .../src/test.nr | 8 +++--- .../src/e2e_onchain_delivery.test.ts | 25 ++++++++----------- 5 files changed, 18 insertions(+), 23 deletions(-) rename noir-projects/noir-contracts/contracts/test/{constrained_delivery_test_contract => onchain_delivery_test_contract}/Nargo.toml (88%) rename noir-projects/noir-contracts/contracts/test/{constrained_delivery_test_contract => onchain_delivery_test_contract}/src/main.nr (97%) rename noir-projects/noir-contracts/contracts/test/{constrained_delivery_test_contract => onchain_delivery_test_contract}/src/test.nr (93%) diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index 0692eebc6bc6..2fa302c83073 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -46,7 +46,7 @@ members = [ "contracts/test/benchmarking_contract", "contracts/test/calldata_limit_test_contract", "contracts/test/child_contract", - "contracts/test/constrained_delivery_test_contract", + "contracts/test/onchain_delivery_test_contract", "contracts/test/counter/counter_contract", "contracts/test/custom_message_contract", "contracts/test/custom_sync_state_contract", diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/Nargo.toml similarity index 88% rename from noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml rename to noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/Nargo.toml index 6c29997b4b4b..ed97e1969c01 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/Nargo.toml +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/Nargo.toml @@ -1,5 +1,5 @@ [package] -name = "constrained_delivery_test_contract" +name = "onchain_delivery_test_contract" authors = [""] compiler_version = ">=0.25.0" type = "contract" diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr similarity index 97% rename from noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr rename to noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr index 10ff0dd656af..b275896d74b2 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/main.nr @@ -1,10 +1,10 @@ -//! Thin wrappers around constrained delivery for TXE tests. +//! Thin wrappers around onchain message delivery for tests. use aztec::macros::aztec; mod test; #[aztec] -pub contract ConstrainedDeliveryTest { +pub contract OnchainDeliveryTest { use aztec::{ macros::{events::event, functions::external, storage::storage}, messages::delivery::MessageDelivery, diff --git a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr similarity index 93% rename from noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr rename to noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr index 45f4279d149e..07aa0879dc79 100644 --- a/noir-projects/noir-contracts/contracts/test/constrained_delivery_test_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/test/onchain_delivery_test_contract/src/test.nr @@ -4,7 +4,7 @@ //! bootstrap the app-siloed handshake secret, reserve the next per-secret index, constrain that index, emit the //! sequence nullifier, then emit the tagged private log. The malformed-log test lands a constrained tag without its //! sequence nullifier so the next real delivery must reject the broken sequence. -use crate::ConstrainedDeliveryTest; +use crate::OnchainDeliveryTest; use aztec::{ protocol::address::AztecAddress, @@ -24,7 +24,7 @@ unconstrained fn setup() -> (TestEnvironment, AztecAddress, AztecAddress, AztecA .without_initializer(); assert_eq(registry_address, STANDARD_HANDSHAKE_REGISTRY_ADDRESS); - let test_address = env.deploy("ConstrainedDeliveryTest").without_initializer(); + let test_address = env.deploy("OnchainDeliveryTest").without_initializer(); (env, registry_address, test_address, sender, recipient) } @@ -38,7 +38,7 @@ unconstrained fn authorizing(registry_address: AztecAddress) -> CallPrivateOptio #[test] unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { let (env, registry_address, test_address, sender, recipient) = setup(); - let test_contract = ConstrainedDeliveryTest::at(test_address); + let test_contract = OnchainDeliveryTest::at(test_address); let registry = HandshakeRegistry::at(registry_address); env.call_private_opts(sender, authorizing(registry_address), test_contract.emit_event(recipient, 1)); @@ -69,7 +69,7 @@ unconstrained fn rehandshake_replaces_registry_secret_for_future_delivery() { #[test(should_fail_with = "reading an unknown nullifier")] unconstrained fn fails_at_index_above_zero_without_prior_nullifier() { let (env, registry_address, test_address, sender, recipient) = setup(); - let test_contract = ConstrainedDeliveryTest::at(test_address); + let test_contract = OnchainDeliveryTest::at(test_address); let registry = HandshakeRegistry::at(registry_address); let _ = env.call_private(sender, registry.non_interactive_handshake(sender, recipient)); diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 1c5612ccf31e..25c775116918 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -8,10 +8,7 @@ import type { Wallet } from '@aztec/aztec.js/wallet'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { Point } from '@aztec/foundation/curves/grumpkin'; import { HandshakeRegistryContract } from '@aztec/noir-contracts.js/HandshakeRegistry'; -import { - ConstrainedDeliveryTestContract, - type DeliveryEvent, -} from '@aztec/noir-test-contracts.js/ConstrainedDeliveryTest'; +import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; import type { PXECreationOptions } from '@aztec/pxe/server'; import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/handshake-registry/constants'; import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; @@ -33,8 +30,7 @@ type SenderHook = NonNullable['resolveT // PXEs that share only a node: PXE A sends, PXE B discovers purely from on-chain logs plus the HandshakeRegistry. // // Cross-PXE is the meaningful setup: PXE B holds no sender state, so a cell only "discovers" a message if the source -// truly reached it. That is also what makes the F-770 cell a real red — a defaulted unconstrained send to an external -// recipient currently derives an address-ECDH tag PXE B cannot reconstruct. +// truly reached it. It is also what makes the F-770 cell below a real red. function buildMessageDeliveryTest(opts: { description: string; mode: 'constrained' | 'unconstrained'; @@ -62,7 +58,7 @@ function buildMessageDeliveryTest(opts: { let walletRecipient: TestWallet; let sender: AztecAddress; let recipient: AztecAddress; - let contractSender: ConstrainedDeliveryTestContract; + let contractSender: OnchainDeliveryTestContract; let teardownSender: () => Promise; let teardownRecipient: () => Promise; // Discovery results captured in beforeAll, so the (possibly `it.failing`) assertions below stay pure: an infra @@ -101,13 +97,13 @@ function buildMessageDeliveryTest(opts: { recipient = recipientAccount.address; await ensureHandshakeRegistryPublished(walletSender, sender); - const { contract: deployed, instance } = await ConstrainedDeliveryTestContract.deploy(walletSender).send({ + const { contract: deployed, instance } = await OnchainDeliveryTestContract.deploy(walletSender).send({ from: sender, }); contractSender = deployed; await ensureHandshakeRegistryPublished(walletRecipient, recipient); - await walletRecipient.registerContract(instance, ConstrainedDeliveryTestContract.artifact); + await walletRecipient.registerContract(instance, OnchainDeliveryTestContract.artifact); await recipientRegistration?.(walletRecipient, recipient, sender); @@ -134,7 +130,7 @@ function buildMessageDeliveryTest(opts: { await walletRecipient.sync(); const events = await walletRecipient.getPrivateEvents( - ConstrainedDeliveryTestContract.events.DeliveryEvent, + OnchainDeliveryTestContract.events.DeliveryEvent, { contractAddress: contractSender.address, fromBlock: BlockNumber(Math.min(...blockNumbers)), @@ -144,7 +140,7 @@ function buildMessageDeliveryTest(opts: { ); discoveredEvents = events.map(e => e.event.value); - const contractRecipient = ConstrainedDeliveryTestContract.at(contractSender.address, walletRecipient); + const contractRecipient = OnchainDeliveryTestContract.at(contractSender.address, walletRecipient); const { result } = await contractRecipient.methods.get_note_values(recipient).simulate({ from: recipient }); readNotes = result.storage.slice(0, Number(result.len)); }); @@ -155,8 +151,7 @@ function buildMessageDeliveryTest(opts: { }); // `it.failing` passes while the assertion fails and turns into a suite failure once the path works, prompting - // promotion to a plain `it`. Only the assertion runs here; delivery happened in beforeAll, so a real delivery - // failure surfaces rather than masquerading as the expected red. + // promotion to a plain `it`. const test = expectRed ? it.failing : it; test('PXE B discovers the events delivered by PXE A', () => { @@ -226,7 +221,7 @@ describe('constrained delivery sequencing', () => { let batchRecipient2: AztecAddress; let batchRecipient3: AztecAddress; let batchRecipient4: AztecAddress; - let contract: ConstrainedDeliveryTestContract; + let contract: OnchainDeliveryTestContract; let registry: HandshakeRegistryContract; beforeAll(async () => { @@ -237,7 +232,7 @@ describe('constrained delivery sequencing', () => { } = await setup(6, { ...AUTOMINE_E2E_OPTS })); await ensureHandshakeRegistryPublished(wallet, sender); - ({ contract } = await ConstrainedDeliveryTestContract.deploy(wallet).send({ from: sender })); + ({ contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender })); registry = HandshakeRegistryContract.at(STANDARD_HANDSHAKE_REGISTRY_ADDRESS, wallet); }); From 458ec6c71e5a49c37f750abf084afef9224ca02d Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 13:12:56 -0400 Subject: [PATCH 03/40] fmt --- .../aztec/src/messages/delivery/handshake.nr | 3 ++- .../aztec-nr/aztec/src/messages/delivery/tag.nr | 8 ++++++-- .../src/messages/delivery/tag_secret_source.nr | 15 +++------------ .../handshake_registry_contract/src/test.nr | 4 ++-- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr index 9fc3f40f0b45..a007d11e20b8 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr @@ -81,7 +81,8 @@ pub(crate) unconstrained fn get_existing_app_siloed_handshake_secrets( /// Establishes a non-interactive handshake for `(sender, recipient)` and returns its app-siloed secrets. /// /// The registry inserts a fresh handshake note and returns the app-siloed secrets. The constrained return value is the -/// source of truth for the secrets, so constrained delivery anchoring a freshly bootstrapped handshake needs no separate +/// source of truth for the secrets, so constrained delivery anchoring a freshly bootstrapped handshake needs no +/// separate /// `validate_handshake`. /// /// Any handshake already registered for the pair is overwritten. diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr index d77bf8420232..b136fade676d 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag.nr @@ -118,7 +118,9 @@ mod test { let index: u32 = 3; env.private_context(|context| { - mock_existing_handshake_secrets(Option::some(AppSiloedHandshakeSecrets { shared: secret, sender_only: 99 })); + mock_existing_handshake_secrets(Option::some( + AppSiloedHandshakeSecrets { shared: secret, sender_only: 99 }, + )); let _ = OracleMock::mock("aztec_prv_getNextTaggingIndex").returns(index); let resolve_oracle = OracleMock::mock("aztec_prv_resolveTaggingStrategy").returns( ResolvedTaggingStrategy::non_interactive_handshake(), @@ -172,7 +174,9 @@ mod test { let index: u32 = 3; env.private_context(|context| { - mock_existing_handshake_secrets(Option::some(AppSiloedHandshakeSecrets { shared: secret, sender_only: 99 })); + mock_existing_handshake_secrets(Option::some( + AppSiloedHandshakeSecrets { shared: secret, sender_only: 99 }, + )); let _ = OracleMock::mock("aztec_prv_getNextTaggingIndex").returns(index); let _ = OracleMock::mock("aztec_prv_isNullifierPending").returns(false); diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr index 91a727309163..1841a2200705 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/tag_secret_source.nr @@ -24,19 +24,13 @@ impl TagSecretSource { /// Establishes a fresh non-interactive handshake, publishing information about the recipient onchain. pub(crate) fn new_non_interactive_handshake() -> Self { - Self { - kind: NEW_NON_INTERACTIVE_HANDSHAKE, - secrets: AppSiloedHandshakeSecrets { shared: 0, sender_only: 0 }, - } + Self { kind: NEW_NON_INTERACTIVE_HANDSHAKE, secrets: AppSiloedHandshakeSecrets { shared: 0, sender_only: 0 } } } /// A ready-to-use unconstrained secret the wallet resolved. Sound only for unconstrained delivery, which derives /// the discovery tag from the shared secret alone and never folds in a sender-only secret. pub(crate) fn unconstrained_secret(secret: Field) -> Self { - Self { - kind: UNCONSTRAINED_SECRET, - secrets: AppSiloedHandshakeSecrets { shared: secret, sender_only: 0 }, - } + Self { kind: UNCONSTRAINED_SECRET, secrets: AppSiloedHandshakeSecrets { shared: secret, sender_only: 0 } } } /// Returns the app-siloed handshake secrets, performing any handshake creation the source requires. @@ -117,10 +111,7 @@ mod test { unconstrained fn unconstrained_secret_obtains_its_secret() { let env = TestEnvironment::new(); env.private_context(|context| { - assert_eq( - TagSecretSource::unconstrained_secret(42).obtain_secrets(context, SENDER, RECIPIENT).shared, - 42, - ); + assert_eq(TagSecretSource::unconstrained_secret(42).obtain_secrets(context, SENDER, RECIPIENT).shared, 42); }); } diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr index da7acc591df7..5a6d1dbbac8b 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/test.nr @@ -4,8 +4,8 @@ use aztec::{ messages::delivery::{ constrained_delivery::VALIDATE_HANDSHAKE_SELECTOR, handshake::{ - AppSiloedHandshakeSecrets, GET_APP_SILOED_SECRETS_SELECTOR, GET_HANDSHAKES_SELECTOR, MAX_HANDSHAKES_PER_PAGE, - NON_INTERACTIVE_HANDSHAKE_SELECTOR, + AppSiloedHandshakeSecrets, GET_APP_SILOED_SECRETS_SELECTOR, GET_HANDSHAKES_SELECTOR, + MAX_HANDSHAKES_PER_PAGE, NON_INTERACTIVE_HANDSHAKE_SELECTOR, }, }, oracle::shared_secret::get_shared_secret, From 6d7d6df8216d98057067411cf6d5e1f42e1edb90 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 13:23:18 -0400 Subject: [PATCH 04/40] chore(end-to-end): restore #24281 timing_env testEnvironment An earlier prepare:check regen rewrote jest.testEnvironment to the package.common.json template value, which disables #24281's per-test TimingEnvironment. Restore the base value (net-zero vs base; the template mismatch is #24281's pre-existing concern). --- yarn-project/end-to-end/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/end-to-end/package.json b/yarn-project/end-to-end/package.json index 6fb93fd46441..6b21639a228f 100644 --- a/yarn-project/end-to-end/package.json +++ b/yarn-project/end-to-end/package.json @@ -170,6 +170,6 @@ "setupFiles": [ "../../foundation/src/jest/setup.mjs" ], - "testEnvironment": "../../foundation/src/jest/env.mjs" + "testEnvironment": "./shared/timing_env.mjs" } } From f6f432e43348bb3ff5e86bd3e7a2f99bcf1e7a1f Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 14:40:50 -0400 Subject: [PATCH 05/40] test cleanup --- .../aztec/src/messages/delivery/handshake.nr | 3 +- yarn-project/end-to-end/package.json | 2 +- .../src/e2e_onchain_delivery.test.ts | 185 +++++++++--------- 3 files changed, 95 insertions(+), 95 deletions(-) diff --git a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr index a007d11e20b8..e885e6c3318d 100644 --- a/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr +++ b/noir-projects/aztec-nr/aztec/src/messages/delivery/handshake.nr @@ -82,8 +82,7 @@ pub(crate) unconstrained fn get_existing_app_siloed_handshake_secrets( /// /// The registry inserts a fresh handshake note and returns the app-siloed secrets. The constrained return value is the /// source of truth for the secrets, so constrained delivery anchoring a freshly bootstrapped handshake needs no -/// separate -/// `validate_handshake`. +/// separate `validate_handshake`. /// /// Any handshake already registered for the pair is overwritten. pub(crate) fn create_non_interactive_handshake( diff --git a/yarn-project/end-to-end/package.json b/yarn-project/end-to-end/package.json index 6b21639a228f..6fb93fd46441 100644 --- a/yarn-project/end-to-end/package.json +++ b/yarn-project/end-to-end/package.json @@ -170,6 +170,6 @@ "setupFiles": [ "../../foundation/src/jest/setup.mjs" ], - "testEnvironment": "./shared/timing_env.mjs" + "testEnvironment": "../../foundation/src/jest/env.mjs" } } diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 25c775116918..e36bdc293239 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -25,12 +25,12 @@ type SenderHook = NonNullable['resolveT // Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; // unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's -// `resolveTaggingSecretStrategy` hook selects (a non-interactive handshake, an address-derived ECDH secret, or a raw -// arbitrary secret shared out of band). This harness exercises the valid (mode, source) cells end to end across two -// PXEs that share only a node: PXE A sends, PXE B discovers purely from on-chain logs plus the HandshakeRegistry. +// `resolveTaggingSecretStrategy` hook selects. This harness exercises the valid (mode, source) cells end to end +// across two PXEs that share only a node: PXE A sends, PXE B discovers purely from onchain logs plus the +// HandshakeRegistry. // // Cross-PXE is the meaningful setup: PXE B holds no sender state, so a cell only "discovers" a message if the source -// truly reached it. It is also what makes the F-770 cell below a real red. +// truly reached it. It is also what makes the expected-red default-source cell meaningful. function buildMessageDeliveryTest(opts: { description: string; mode: 'constrained' | 'unconstrained'; @@ -42,7 +42,7 @@ function buildMessageDeliveryTest(opts: { recipientAddress: AztecAddress, sender: AztecAddress, ) => Promise; - // When true the discovery assertions are expected to fail (a not-yet-implemented path); see the F-770 cell. + // When true the discovery assertions are expected to fail for a not-yet-implemented path. expectRed?: boolean; }) { const { description, mode, senderHook, recipientRegistration, expectRed } = opts; @@ -61,8 +61,8 @@ function buildMessageDeliveryTest(opts: { let contractSender: OnchainDeliveryTestContract; let teardownSender: () => Promise; let teardownRecipient: () => Promise; - // Discovery results captured in beforeAll, so the (possibly `it.failing`) assertions below stay pure: an infra - // failure during delivery fails beforeAll loudly instead of being swallowed as an expected red. + // Discovery results captured in beforeAll, so the assertions in the actual tests below stay pure. + // A failure during delivery fails beforeAll loudly instead of being swallowed as an expected red. let discoveredEvents: FieldLike[]; let readNotes: bigint[]; @@ -168,16 +168,16 @@ function buildMessageDeliveryTest(opts: { } describe('onchain delivery', () => { - // GREEN: constrained always goes through a handshake (the PXE default for constrained), reused mode-agnostically. + // GREEN: constrained always goes through a handshake (the PXE default for constrained), reused across modes. buildMessageDeliveryTest({ - description: 'constrained · handshake', + description: 'constrained x handshake', mode: 'constrained', }); // GREEN: unconstrained delivery whose source the wallet pins to a non-interactive handshake. The first send // bootstraps the handshake; PXE B discovers it via the registry and reads the (nullifier-free) unconstrained logs. buildMessageDeliveryTest({ - description: 'unconstrained · handshake', + description: 'unconstrained x handshake', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), }); @@ -187,7 +187,7 @@ describe('onchain delivery', () => { // send fires the hook). let arbitrarySecret: Point; buildMessageDeliveryTest({ - description: 'unconstrained · arbitrary secret', + description: 'unconstrained x arbitrary secret', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: arbitrarySecret }), recipientRegistration: async (recipientWallet, recipientAddress) => { @@ -196,21 +196,16 @@ describe('onchain delivery', () => { }, }); - // RED (F-770): with no hook, unconstrained delivery to an external recipient defaults to an address-derived (ECDH) - // tag. PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing. F-770 will default - // external unconstrained delivery to a non-interactive handshake; this flips to green then. + // RED: with no hook, unconstrained delivery to an external recipient defaults to an address-derived tag. PXE B holds + // no sender state, so it cannot reconstruct that tag and discovers nothing. buildMessageDeliveryTest({ - description: 'unconstrained · default to external recipient (F-770)', + description: 'unconstrained x default to external recipient', mode: 'unconstrained', expectRed: true, }); }); -// Constrained sends to one recipient form a strictly ordered sequence, so concurrent and batched sends behave -// differently: parallel txs collide on the shared index nullifier, same-tx batches work only once the handshake is -// committed, and batches that bootstrap a brand-new recipient re-handshake onto separate secrets. Each test uses -// its own recipient. This is single-PXE and constrained-specific, so it stays outside the cross-PXE matrix above. -describe('constrained delivery sequencing', () => { +describe('constrained delivery', () => { jest.setTimeout(300_000); let teardown: () => Promise; @@ -259,87 +254,93 @@ describe('constrained delivery sequencing', () => { expect(index).toEqual(2n); }); - // Constrained sends to one `(sender, recipient)` pair are strictly ordered: the first send bootstraps the - // handshake and every send emits a nullifier keyed only on `(sender, recipient, secret, index)`. Two sends fired - // in parallel read the same index and collide, so one tx is rejected. Marked `it.failing` because this is a - // protocol limitation, not a bug: it documents the constraint and will start failing (prompting its removal) if - // parallel sends to a single pair ever become supported. The working alternative is the batched test below. - it.failing('cannot fan out constrained sends on the same sequence in parallel', async () => { - await Promise.all([ - contract.methods.emit_note(recipient, 1).send({ from: sender }), - contract.methods.emit_note(recipient, 1).send({ from: sender }), - ]); - }); - - // CAN batch (1): a contract call may emit several constrained messages to one recipient in a single tx; each - // later emit proves the previous nullifier as a same-tx pending nullifier. The handshake must already be - // committed (see the re-handshake test below), so it is established first; a fresh recipient starts at index 0, - // so two emits land indices 0 and 1 and the next index is 2. - it('lands multiple constrained sends from a single contract call on an established handshake', async () => { - await registry.methods.non_interactive_handshake(sender, batchRecipient).send({ from: sender }); - - await contract.methods.emit_two_events(batchRecipient).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(2n); - }); + // Constrained sends to one recipient form a strictly ordered sequence, so concurrent and batched sends behave + // differently: parallel txs collide on the shared index nullifier, same-tx batches work only once the handshake is + // committed, and batches that bootstrap a brand-new recipient re-handshake onto separate secrets. Each test uses + // its own recipient. + describe('concurrency and batching', () => { + // Constrained sends to one `(sender, recipient)` pair are strictly ordered: the first send bootstraps the + // handshake and every send emits a nullifier keyed only on `(sender, recipient, secret, index)`. Two sends fired + // in parallel read the same index and collide, so one tx is rejected. Marked `it.failing` because this is a + // protocol limitation, not a bug: it documents the constraint and will start failing (prompting its removal) if + // parallel sends to a single pair ever become supported. The working alternative is the batched test below. + it.failing('cannot fan out constrained sends on the same sequence in parallel', async () => { + await Promise.all([ + contract.methods.emit_note(recipient, 1).send({ from: sender }), + contract.methods.emit_note(recipient, 1).send({ from: sender }), + ]); + }); - // CAN batch (2): client-side BatchCall aggregates separate calls into one tx with the same effect. The two - // emit_note calls that fail as parallel txs (above) succeed batched, given an established handshake. - it('lands the same two sends when aggregated into one tx with BatchCall', async () => { - await registry.methods.non_interactive_handshake(sender, batchRecipient2).send({ from: sender }); + // CAN batch (1): a contract call may emit several constrained messages to one recipient in a single tx; each + // later emit proves the previous nullifier as a same-tx pending nullifier. The handshake must already be + // committed (see the re-handshake test below), so it is established first; a fresh recipient starts at index 0, + // so two emits land indices 0 and 1 and the next index is 2. + it('lands multiple constrained sends from a single contract call on an established handshake', async () => { + await registry.methods.non_interactive_handshake(sender, batchRecipient).send({ from: sender }); - await new BatchCall(wallet, [ - contract.methods.emit_note(batchRecipient2, 1), - contract.methods.emit_note(batchRecipient2, 1), - ]).send({ from: sender }); + await contract.methods.emit_two_events(batchRecipient).send({ from: sender }); - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient2) - .simulate({ from: sender }); - expect(secret).toBeDefined(); + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient) + .simulate({ from: sender }); + expect(secret).toBeDefined(); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(2n); - }); + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(2n); + }); - // CANNOT batch onto a brand-new recipient, even within a single contract call. The registry lookup that decides - // reuse-vs-bootstrap is a utility call reading committed state, so the second emit cannot see the first emit's - // pending bootstrap and re-handshakes onto a fresh secret (each handshake mints a new shared secret). The registry - // keeps the second handshake, which holds a single log, so the next index is 1, not 2. This is why the - // established-handshake tests above seed the handshake first. - it('re-handshakes instead of reusing when sends bootstrap a new recipient in the same tx', async () => { - await contract.methods.emit_two_events(batchRecipient3).send({ from: sender }); + // CAN batch (2): client-side BatchCall aggregates separate calls into one tx with the same effect. The two + // emit_note calls that fail as parallel txs (above) succeed batched, given an established handshake. + it('lands the same two sends when aggregated into one tx with BatchCall', async () => { + await registry.methods.non_interactive_handshake(sender, batchRecipient2).send({ from: sender }); - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient3) - .simulate({ from: sender }); - expect(secret).toBeDefined(); + await new BatchCall(wallet, [ + contract.methods.emit_note(batchRecipient2, 1), + contract.methods.emit_note(batchRecipient2, 1), + ]).send({ from: sender }); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(1n); - }); + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient2) + .simulate({ from: sender }); + expect(secret).toBeDefined(); - // The same new-recipient limitation holds via client-side BatchCall: the two aggregated emit_note calls each - // bootstrap and re-handshake onto separate secrets (the utility read can't see the first's pending bootstrap), - // so the next index is 1, not 2. Confirms the constraint is in the utility read, not the batching mechanism. - it('re-handshakes instead of reusing when BatchCall sends bootstrap a new recipient in the same tx', async () => { - await new BatchCall(wallet, [ - contract.methods.emit_note(batchRecipient4, 1), - contract.methods.emit_note(batchRecipient4, 1), - ]).send({ from: sender }); + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(2n); + }); - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient4) - .simulate({ from: sender }); - expect(secret).toBeDefined(); + // CANNOT batch onto a brand-new recipient, even within a single contract call. The registry lookup that decides + // reuse-vs-bootstrap is a utility call reading committed state, so the second emit cannot see the first emit's + // pending bootstrap and re-handshakes onto a fresh secret (each handshake mints a new shared secret). The registry + // keeps the second handshake, which holds a single log, so the next index is 1, not 2. This is why the + // established-handshake tests above seed the handshake first. + it('re-handshakes instead of reusing when sends bootstrap a new recipient in the same tx', async () => { + await contract.methods.emit_two_events(batchRecipient3).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient3) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(1n); + }); - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(1n); + // The same new-recipient limitation holds via client-side BatchCall: the two aggregated emit_note calls each + // bootstrap and re-handshake onto separate secrets (the utility read can't see the first's pending bootstrap), + // so the next index is 1, not 2. Confirms the constraint is in the utility read, not the batching mechanism. + it('re-handshakes instead of reusing when BatchCall sends bootstrap a new recipient in the same tx', async () => { + await new BatchCall(wallet, [ + contract.methods.emit_note(batchRecipient4, 1), + contract.methods.emit_note(batchRecipient4, 1), + ]).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient4) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(1n); + }); }); }); From 7a75fed3d41b730f5eca7d82e6a424f0fdb9957e Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 15:06:55 -0400 Subject: [PATCH 06/40] test(end-to-end): pin rejected-source and cross-mode handshake reuse Extend the onchain-delivery harness with two coverage cells: - constrained x arbitrary secret, asserting the in-circuit rejection (an unconstrained secret cannot back constrained delivery), pinning the PXE->circuit soundness boundary end to end. - a handshake bootstrapped constrained and reused unconstrained, with a tripwire hook so cross-PXE discovery is a durable proof of mode-agnostic reuse even after the unconstrained default changes. Replace the expectRed boolean with a discovered/undiscoverable/rejected outcome and allow per-message-type modes. --- .../src/e2e_onchain_delivery.test.ts | 104 ++++++++++++++---- 1 file changed, 83 insertions(+), 21 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index e36bdc293239..280211c4546a 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -12,6 +12,7 @@ import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-tes import type { PXECreationOptions } from '@aztec/pxe/server'; import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/handshake-registry/constants'; import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { jest } from '@jest/globals'; @@ -23,17 +24,32 @@ import { TestWallet } from './test-wallet/test_wallet.js'; // the exported PXE options rather than importing the hook type, which `@aztec/pxe/server` does not re-export. type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; +type Mode = 'constrained' | 'unconstrained'; + +// What PXE B is expected to observe for a (mode, source) cell: +// - 'discovered': PXE B discovers every message PXE A sent (a working cell; asserted with `it`). +// - 'undiscoverable': delivery lands onchain but PXE B cannot reconstruct the tag and discovers nothing. A +// TEMPORARY limitation of a not-yet-implemented path, asserted with `it.failing` so the cell +// turns red (prompting promotion to `it`) the moment the path starts working. +// - { type: 'rejected' }: PXE A's send is rejected in-circuit because the resolved source cannot back the mode. A +// PERMANENT soundness invariant, positively asserted with `it` against the panic message. +type RejectedOutcome = { type: 'rejected'; message: string }; +type DeliveryOutcome = 'discovered' | 'undiscoverable' | RejectedOutcome; + +const isRejectedOutcome = (outcome: DeliveryOutcome): outcome is RejectedOutcome => typeof outcome === 'object'; + // Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; // unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's -// `resolveTaggingSecretStrategy` hook selects. This harness exercises the valid (mode, source) cells end to end -// across two PXEs that share only a node: PXE A sends, PXE B discovers purely from onchain logs plus the -// HandshakeRegistry. +// `resolveTaggingSecretStrategy` hook selects. This harness exercises the (mode, source) cells end to end across two +// PXEs that share only a node: PXE A sends, PXE B discovers purely from onchain logs plus the HandshakeRegistry. // // Cross-PXE is the meaningful setup: PXE B holds no sender state, so a cell only "discovers" a message if the source -// truly reached it. It is also what makes the expected-red default-source cell meaningful. +// truly reached it, which is also what makes the undiscoverable and rejected cells meaningful. function buildMessageDeliveryTest(opts: { description: string; - mode: 'constrained' | 'unconstrained'; + // A single mode applies to both the event and note sends; `{ events, notes }` sends each in its own mode, which + // exercises cross-mode handshake reuse: bootstrap in one mode, deliver in the other on the same handshake. + mode: Mode | { events: Mode; notes: Mode }; // Omitted = no hook, so the PXE applies its default strategy for the mode. senderHook?: SenderHook; // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. @@ -42,10 +58,10 @@ function buildMessageDeliveryTest(opts: { recipientAddress: AztecAddress, sender: AztecAddress, ) => Promise; - // When true the discovery assertions are expected to fail for a not-yet-implemented path. - expectRed?: boolean; + // Defaults to 'discovered'; see `DeliveryOutcome`. + outcome?: DeliveryOutcome; }) { - const { description, mode, senderHook, recipientRegistration, expectRed } = opts; + const { description, mode, senderHook, recipientRegistration, outcome = 'discovered' } = opts; describe(description, () => { jest.setTimeout(300_000); @@ -66,6 +82,16 @@ function buildMessageDeliveryTest(opts: { let discoveredEvents: FieldLike[]; let readNotes: bigint[]; + const { events: eventMode, notes: noteMode } = typeof mode === 'string' ? { events: mode, notes: mode } : mode; + const sendEvent = (value: bigint) => + eventMode === 'constrained' + ? contractSender.methods.emit_event(recipient, value) + : contractSender.methods.emit_event_unconstrained(recipient, value); + const sendNote = (value: bigint) => + noteMode === 'constrained' + ? contractSender.methods.emit_note(recipient, value) + : contractSender.methods.emit_note_unconstrained(recipient, value); + beforeAll(async () => { // PXE A holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded at genesis // here but created and deployed on the isolated PXE B below, so it carries no sender state from other cells. @@ -107,14 +133,12 @@ function buildMessageDeliveryTest(opts: { await recipientRegistration?.(walletRecipient, recipient, sender); - const sendEvent = (value: bigint) => - mode === 'constrained' - ? contractSender.methods.emit_event(recipient, value) - : contractSender.methods.emit_event_unconstrained(recipient, value); - const sendNote = (value: bigint) => - mode === 'constrained' - ? contractSender.methods.emit_note(recipient, value) - : contractSender.methods.emit_note_unconstrained(recipient, value); + // A rejected cell lands nothing: its send is exercised (and asserted) in the test body, not here, so the + // matcher distinguishes the expected rejection from an unexpected infra error rather than beforeAll swallowing + // it. Both teardowns and the sender/recipient/contract are assigned above, so afterAll stays safe. + if (isRejectedOutcome(outcome)) { + return; + } // Constrained sends to one pair are strictly ordered, so deliver one tx at a time. The first send bootstraps the // handshake (when the source is a handshake); the rest reuse it. @@ -150,9 +174,16 @@ function buildMessageDeliveryTest(opts: { await teardownSender(); }); + if (isRejectedOutcome(outcome)) { + it('PXE A cannot send: the resolved source cannot back the delivery mode', async () => { + await expect(sendEvent(eventValues[0]).send({ from: sender })).rejects.toThrow(outcome.message); + }); + return; + } + // `it.failing` passes while the assertion fails and turns into a suite failure once the path works, prompting // promotion to a plain `it`. - const test = expectRed ? it.failing : it; + const test = outcome === 'undiscoverable' ? it.failing : it; test('PXE B discovers the events delivered by PXE A', () => { expect(discoveredEvents.length).toBe(eventValues.length); @@ -168,7 +199,7 @@ function buildMessageDeliveryTest(opts: { } describe('onchain delivery', () => { - // GREEN: constrained always goes through a handshake (the PXE default for constrained), reused across modes. + // GREEN: constrained always goes through a handshake (the PXE default for constrained). buildMessageDeliveryTest({ description: 'constrained x handshake', mode: 'constrained', @@ -196,12 +227,43 @@ describe('onchain delivery', () => { }, }); - // RED: with no hook, unconstrained delivery to an external recipient defaults to an address-derived tag. PXE B holds - // no sender state, so it cannot reconstruct that tag and discovers nothing. + // UNDISCOVERABLE: with no hook, unconstrained delivery to an external recipient defaults to an address-derived tag. + // PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing. buildMessageDeliveryTest({ description: 'unconstrained x default to external recipient', mode: 'unconstrained', - expectRed: true, + outcome: 'undiscoverable', + }); + + // GREEN: one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes + // reuse it. Reuse bypasses the wallet hook entirely (an existing registry handshake is resolved before the hook is + // consulted), so the hook returns a handshake for the bootstrapping constrained send but throws if it is ever + // consulted for the unconstrained send. That makes discovery a durable proof of mode-agnostic reuse: were reuse to + // regress, the unconstrained note would fall through to the hook and fail loudly instead of being silently + // re-discovered some other way (e.g. if the unconstrained default later becomes a handshake of its own). + buildMessageDeliveryTest({ + description: 'handshake bootstrapped constrained, reused unconstrained (cross-mode)', + mode: { events: 'constrained', notes: 'unconstrained' }, + senderHook: async ({ deliveryMode }) => { + if (deliveryMode !== AppTaggingSecretKind.CONSTRAINED) { + throw new Error( + 'cross-mode reuse regressed: the unconstrained send consulted the strategy hook instead of reusing the bootstrapped handshake', + ); + } + return { type: 'non-interactive-handshake' }; + }, + }); + + // REJECTED: the mirror of the unconstrained x arbitrary-secret cell with constrained mode. An unconstrained secret + // cannot back constrained delivery, so the circuit rejects the send. This pins the PXE -> circuit soundness + // boundary that PXE deliberately delegates to the circuit (it resolves the unsound strategy without rejecting it). + // The secret value is irrelevant to the rejection, so a fresh point per hook call is fine and no recipient + // registration is needed. + buildMessageDeliveryTest({ + description: 'constrained x arbitrary secret (rejected)', + mode: 'constrained', + senderHook: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), + outcome: { type: 'rejected', message: 'an unconstrained tagging secret cannot back constrained delivery' }, }); }); From 648c633dda25378259e7711d3bf3dd94aed404fc Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 15:12:23 -0400 Subject: [PATCH 07/40] package json field --- .../crates/types/src/constants_tests.nr | 16 ++++++++-------- yarn-project/end-to-end/package.json | 2 +- yarn-project/end-to-end/package.local.json | 7 ++++++- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr b/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr index 8683b230dc6f..34a798c977ec 100644 --- a/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr +++ b/noir-projects/noir-protocol-circuits/crates/types/src/constants_tests.nr @@ -15,14 +15,14 @@ use crate::{ DOM_SEP__ECDH_SUBKEY, DOM_SEP__EVENT_COMMITMENT, DOM_SEP__EVENT_LOG_TAG, DOM_SEP__FBSK_M, DOM_SEP__FUNCTION_ARGS, DOM_SEP__INITIALIZATION_NULLIFIER, DOM_SEP__INITIALIZER, DOM_SEP__IVSK_M, DOM_SEP__MERKLE_HASH, DOM_SEP__MESSAGE_NULLIFIER, DOM_SEP__MSSK_M, - DOM_SEP__NHK_M, - DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, DOM_SEP__NOTE_COMPLETION_LOG_TAG, - DOM_SEP__NOTE_HASH, DOM_SEP__NOTE_HASH_NONCE, DOM_SEP__NOTE_NULLIFIER, - DOM_SEP__NULLIFIER_MERKLE, DOM_SEP__OVSK_M, DOM_SEP__PARTIAL_ADDRESS, - DOM_SEP__PARTIAL_NOTE_COMMITMENT, DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, - DOM_SEP__PRIVATE_FUNCTION_LEAF, DOM_SEP__PRIVATE_INITIALIZATION_NULLIFIER, - DOM_SEP__PRIVATE_LOG_FIRST_FIELD, DOM_SEP__PRIVATE_TX_HASH, DOM_SEP__PROTOCOL_CONTRACTS, - DOM_SEP__PUBLIC_BYTECODE, DOM_SEP__PUBLIC_CALLDATA, DOM_SEP__PUBLIC_DATA_MERKLE, + DOM_SEP__NHK_M, DOM_SEP__NON_INTERACTIVE_HANDSHAKE_LOG_TAG, + DOM_SEP__NOTE_COMPLETION_LOG_TAG, DOM_SEP__NOTE_HASH, DOM_SEP__NOTE_HASH_NONCE, + DOM_SEP__NOTE_NULLIFIER, DOM_SEP__NULLIFIER_MERKLE, DOM_SEP__OVSK_M, + DOM_SEP__PARTIAL_ADDRESS, DOM_SEP__PARTIAL_NOTE_COMMITMENT, + DOM_SEP__PARTIAL_NOTE_VALIDITY_COMMITMENT, DOM_SEP__PRIVATE_FUNCTION_LEAF, + DOM_SEP__PRIVATE_INITIALIZATION_NULLIFIER, DOM_SEP__PRIVATE_LOG_FIRST_FIELD, + DOM_SEP__PRIVATE_TX_HASH, DOM_SEP__PROTOCOL_CONTRACTS, DOM_SEP__PUBLIC_BYTECODE, + DOM_SEP__PUBLIC_CALLDATA, DOM_SEP__PUBLIC_DATA_MERKLE, DOM_SEP__PUBLIC_INITIALIZATION_NULLIFIER, DOM_SEP__PUBLIC_KEYS_HASH, DOM_SEP__PUBLIC_LEAF_SLOT, DOM_SEP__PUBLIC_STORAGE_MAP_SLOT, DOM_SEP__PUBLIC_TX_HASH, DOM_SEP__RETRIEVED_BYTECODES_MERKLE, DOM_SEP__SALTED_INITIALIZATION_HASH, diff --git a/yarn-project/end-to-end/package.json b/yarn-project/end-to-end/package.json index 6fb93fd46441..6b21639a228f 100644 --- a/yarn-project/end-to-end/package.json +++ b/yarn-project/end-to-end/package.json @@ -170,6 +170,6 @@ "setupFiles": [ "../../foundation/src/jest/setup.mjs" ], - "testEnvironment": "../../foundation/src/jest/env.mjs" + "testEnvironment": "./shared/timing_env.mjs" } } diff --git a/yarn-project/end-to-end/package.local.json b/yarn-project/end-to-end/package.local.json index d2b8973573d3..0a574d145cb7 100644 --- a/yarn-project/end-to-end/package.local.json +++ b/yarn-project/end-to-end/package.local.json @@ -3,6 +3,11 @@ "test": "NODE_NO_WARNINGS=1 node --experimental-vm-modules ../node_modules/.bin/jest --passWithNoTests src/fixtures" }, "jest": { - "setupFilesAfterEnv": ["../../foundation/src/jest/setupAfterEnv.mjs", "jest-extended/all", "./shared/jest_setup.ts"] + "setupFilesAfterEnv": [ + "../../foundation/src/jest/setupAfterEnv.mjs", + "jest-extended/all", + "./shared/jest_setup.ts" + ], + "testEnvironment": "./shared/timing_env.mjs" } } From c9e93cd4208eb4ab245f8c824067a4e7feee65f1 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 15:21:29 -0400 Subject: [PATCH 08/40] more cleanup --- .../end-to-end/src/e2e_onchain_delivery.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 280211c4546a..255e2b46807d 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -20,8 +20,8 @@ import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from './fixtures/setup.js'; import { TestWallet } from './test-wallet/test_wallet.js'; -// The wallet hook that selects a message's tagging-secret source (handshake / address-ECDH / arbitrary). Derived from -// the exported PXE options rather than importing the hook type, which `@aztec/pxe/server` does not re-export. +// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options +// rather than importing the hook type, which `@aztec/pxe/server` does not re-export. type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; type Mode = 'constrained' | 'unconstrained'; @@ -227,8 +227,8 @@ describe('onchain delivery', () => { }, }); - // UNDISCOVERABLE: with no hook, unconstrained delivery to an external recipient defaults to an address-derived tag. - // PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing. + // TODO(F-770): With no hook, unconstrained delivery to an external recipient defaults to an address-derived + // tag. PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing. buildMessageDeliveryTest({ description: 'unconstrained x default to external recipient', mode: 'unconstrained', @@ -240,7 +240,7 @@ describe('onchain delivery', () => { // consulted), so the hook returns a handshake for the bootstrapping constrained send but throws if it is ever // consulted for the unconstrained send. That makes discovery a durable proof of mode-agnostic reuse: were reuse to // regress, the unconstrained note would fall through to the hook and fail loudly instead of being silently - // re-discovered some other way (e.g. if the unconstrained default later becomes a handshake of its own). + // re-discovered some other way. buildMessageDeliveryTest({ description: 'handshake bootstrapped constrained, reused unconstrained (cross-mode)', mode: { events: 'constrained', notes: 'unconstrained' }, @@ -256,7 +256,7 @@ describe('onchain delivery', () => { // REJECTED: the mirror of the unconstrained x arbitrary-secret cell with constrained mode. An unconstrained secret // cannot back constrained delivery, so the circuit rejects the send. This pins the PXE -> circuit soundness - // boundary that PXE deliberately delegates to the circuit (it resolves the unsound strategy without rejecting it). + // boundary that PXE deliberately delegates to the circuit. // The secret value is irrelevant to the rejection, so a fresh point per hook call is fine and no recipient // registration is needed. buildMessageDeliveryTest({ From e4d7905eb702eb6473aabadfa27e82236137687f Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 15:37:46 -0400 Subject: [PATCH 09/40] lint --- yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 255e2b46807d..ae59086bc940 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -244,13 +244,13 @@ describe('onchain delivery', () => { buildMessageDeliveryTest({ description: 'handshake bootstrapped constrained, reused unconstrained (cross-mode)', mode: { events: 'constrained', notes: 'unconstrained' }, - senderHook: async ({ deliveryMode }) => { + senderHook: ({ deliveryMode }) => { if (deliveryMode !== AppTaggingSecretKind.CONSTRAINED) { throw new Error( 'cross-mode reuse regressed: the unconstrained send consulted the strategy hook instead of reusing the bootstrapped handshake', ); } - return { type: 'non-interactive-handshake' }; + return Promise.resolve({ type: 'non-interactive-handshake' }); }, }); From 47ebeea26c12d02db747fc42e55c9928cd662b20 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 16:01:43 -0400 Subject: [PATCH 10/40] test(end-to-end): pin onchain-delivery discovery boundaries Add a permanent `not-discoverable` outcome (asserts PXE B discovers nothing) alongside the temporary `xfail-discovered` (F-770), plus cells pinning that an arbitrary secret or an address-derived sender the recipient never registered is undiscoverable, while a registered one is discovered. --- .../src/e2e_onchain_delivery.test.ts | 96 +++++++++++++++---- 1 file changed, 75 insertions(+), 21 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index ae59086bc940..9dc21d734a46 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -26,17 +26,20 @@ type SenderHook = NonNullable['resolveT type Mode = 'constrained' | 'unconstrained'; -// What PXE B is expected to observe for a (mode, source) cell: -// - 'discovered': PXE B discovers every message PXE A sent (a working cell; asserted with `it`). -// - 'undiscoverable': delivery lands onchain but PXE B cannot reconstruct the tag and discovers nothing. A -// TEMPORARY limitation of a not-yet-implemented path, asserted with `it.failing` so the cell -// turns red (prompting promotion to `it`) the moment the path starts working. -// - { type: 'rejected' }: PXE A's send is rejected in-circuit because the resolved source cannot back the mode. A -// PERMANENT soundness invariant, positively asserted with `it` against the panic message. -type RejectedOutcome = { type: 'rejected'; message: string }; -type DeliveryOutcome = 'discovered' | 'undiscoverable' | RejectedOutcome; - -const isRejectedOutcome = (outcome: DeliveryOutcome): outcome is RejectedOutcome => typeof outcome === 'object'; +// What PXE B is expected to observe for a (mode, source) cell. The `type` names the asserted condition, so the two +// "PXE B sees nothing" outcomes never read alike: +// - 'discovered': PXE B discovers every message PXE A sent. Asserted with `it`. +// - 'xfail-discovered': asserts the same as 'discovered', but a known not-yet-implemented path fails it today, so it +// runs under `it.failing` and turns the suite red (prompting promotion to 'discovered') once that path works. +// - 'not-discoverable': delivery lands onchain but PXE B was never given the secret, so it discovers nothing. A +// PERMANENT privacy boundary asserted with `it`; a leak that surfaces the delivery to PXE B flips it red. +// - 'rejected': PXE A's send is rejected in-circuit because the resolved source cannot back the mode. Asserted with +// `it` against `message`. +type DeliveryOutcome = + | { type: 'discovered' } + | { type: 'xfail-discovered' } + | { type: 'not-discoverable'; reason: string } + | { type: 'rejected'; message: string }; // Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; // unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's @@ -44,7 +47,7 @@ const isRejectedOutcome = (outcome: DeliveryOutcome): outcome is RejectedOutcome // PXEs that share only a node: PXE A sends, PXE B discovers purely from onchain logs plus the HandshakeRegistry. // // Cross-PXE is the meaningful setup: PXE B holds no sender state, so a cell only "discovers" a message if the source -// truly reached it, which is also what makes the undiscoverable and rejected cells meaningful. +// truly reached it, which is also what makes the not-discoverable and rejected cells meaningful. function buildMessageDeliveryTest(opts: { description: string; // A single mode applies to both the event and note sends; `{ events, notes }` sends each in its own mode, which @@ -61,7 +64,7 @@ function buildMessageDeliveryTest(opts: { // Defaults to 'discovered'; see `DeliveryOutcome`. outcome?: DeliveryOutcome; }) { - const { description, mode, senderHook, recipientRegistration, outcome = 'discovered' } = opts; + const { description, mode, senderHook, recipientRegistration, outcome = { type: 'discovered' } } = opts; describe(description, () => { jest.setTimeout(300_000); @@ -136,7 +139,7 @@ function buildMessageDeliveryTest(opts: { // A rejected cell lands nothing: its send is exercised (and asserted) in the test body, not here, so the // matcher distinguishes the expected rejection from an unexpected infra error rather than beforeAll swallowing // it. Both teardowns and the sender/recipient/contract are assigned above, so afterAll stays safe. - if (isRejectedOutcome(outcome)) { + if (outcome.type === 'rejected') { return; } @@ -174,16 +177,29 @@ function buildMessageDeliveryTest(opts: { await teardownSender(); }); - if (isRejectedOutcome(outcome)) { + if (outcome.type === 'rejected') { it('PXE A cannot send: the resolved source cannot back the delivery mode', async () => { await expect(sendEvent(eventValues[0]).send({ from: sender })).rejects.toThrow(outcome.message); }); return; } - // `it.failing` passes while the assertion fails and turns into a suite failure once the path works, prompting - // promotion to a plain `it`. - const test = outcome === 'undiscoverable' ? it.failing : it; + if (outcome.type === 'not-discoverable') { + // The sends landed onchain (beforeAll mined them), but PXE B was never given the secret, so discovery must come + // up empty. Plain `it` so a future leak that surfaces the delivery to PXE B fails loudly. + it(`PXE B cannot discover the delivered events (${outcome.reason})`, () => { + expect(discoveredEvents).toEqual([]); + }); + it(`PXE B cannot read the delivered notes (${outcome.reason})`, () => { + expect(readNotes).toEqual([]); + }); + return; + } + + // 'discovered' and 'xfail-discovered' assert the same positive condition; 'xfail-discovered' runs under + // `it.failing`, which passes while the assertion fails and turns the suite red once the path works, prompting + // promotion to 'discovered'. + const test = outcome.type === 'xfail-discovered' ? it.failing : it; test('PXE B discovers the events delivered by PXE A', () => { expect(discoveredEvents.length).toBe(eventValues.length); @@ -227,12 +243,50 @@ describe('onchain delivery', () => { }, }); - // TODO(F-770): With no hook, unconstrained delivery to an external recipient defaults to an address-derived - // tag. PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing. + // NOT-DISCOVERABLE (privacy twin of the arbitrary-secret cell above): the sender tags with an arbitrary secret the + // recipient never registers. The cell above discovers this same kind of send; here PXE B cannot, because the secret + // was never shared with it. A leak that surfaced the delivery to PXE B would flip this red. + let unsharedSecret: Point; + buildMessageDeliveryTest({ + description: 'unconstrained x arbitrary secret never shared with the recipient', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: unsharedSecret }), + recipientRegistration: async () => { + // Generate the secret the sender uses, but deliberately do NOT register it on PXE B; that is the point. + unsharedSecret = await Point.random(); + }, + outcome: { type: 'not-discoverable', reason: 'the arbitrary secret was never registered on PXE B' }, + }); + + // TODO(F-770): with no hook, unconstrained delivery to an external recipient defaults to an address-derived tag. + // PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing yet. F-770 will default this + // to a handshake, at which point this cell turns red and graduates to 'discovered'. buildMessageDeliveryTest({ description: 'unconstrained x default to external recipient', mode: 'unconstrained', - outcome: 'undiscoverable', + outcome: { type: 'xfail-discovered' }, + }); + + // DISCOVERED: the address-derived (ECDH) source, which is the unconstrained default exercised above. With the + // recipient registering the sender, PXE B reconstructs the address-derived tag and discovers the delivery. Positive + // control for the cell below (same source, sender unregistered) and for the default cell above. + buildMessageDeliveryTest({ + description: 'unconstrained x address-derived (recipient registers the sender)', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'address-derived' }), + recipientRegistration: async (recipientWallet, _recipientAddress, senderAddress) => { + await recipientWallet.registerSender(senderAddress); + }, + }); + + // NOT-DISCOVERABLE (privacy twin of the cell above): the same address-derived source, but the recipient never + // registers the sender, so PXE B cannot reconstruct the tag. Unlike the F-770 default cell above (a temporary gap), + // an explicitly address-derived send to an unregistered sender is a permanent boundary that survives F-770. + buildMessageDeliveryTest({ + description: 'unconstrained x address-derived without registering the sender', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'address-derived' }), + outcome: { type: 'not-discoverable', reason: 'PXE B never registered the sender' }, }); // GREEN: one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes From 2bcdacb1719b8644fa62d53c5b07a43e5680b845 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 16:47:43 -0400 Subject: [PATCH 11/40] more tests and improved harness --- .../src/e2e_onchain_delivery.test.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 9dc21d734a46..3a13bf899835 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -196,9 +196,8 @@ function buildMessageDeliveryTest(opts: { return; } - // 'discovered' and 'xfail-discovered' assert the same positive condition; 'xfail-discovered' runs under - // `it.failing`, which passes while the assertion fails and turns the suite red once the path works, prompting - // promotion to 'discovered'. + // 'discovered' and 'xfail-discovered' assert the same positive condition. + // 'xfail-discovered' runs under `it.failing`. const test = outcome.type === 'xfail-discovered' ? it.failing : it; test('PXE B discovers the events delivered by PXE A', () => { @@ -243,9 +242,9 @@ describe('onchain delivery', () => { }, }); - // NOT-DISCOVERABLE (privacy twin of the arbitrary-secret cell above): the sender tags with an arbitrary secret the - // recipient never registers. The cell above discovers this same kind of send; here PXE B cannot, because the secret - // was never shared with it. A leak that surfaced the delivery to PXE B would flip this red. + // NOT-DISCOVERABLE: the sender tags with an arbitrary secret the recipient never registers. + // The cell above discovers this same kind of send; here PXE B cannot, because the secret + // was never shared with it. let unsharedSecret: Point; buildMessageDeliveryTest({ description: 'unconstrained x arbitrary secret never shared with the recipient', @@ -259,15 +258,15 @@ describe('onchain delivery', () => { }); // TODO(F-770): with no hook, unconstrained delivery to an external recipient defaults to an address-derived tag. - // PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing yet. F-770 will default this - // to a handshake, at which point this cell turns red and graduates to 'discovered'. + // PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing yet. Switching the default + // to a handshake, will turn this test red and require graduating to 'discovered'. buildMessageDeliveryTest({ description: 'unconstrained x default to external recipient', mode: 'unconstrained', outcome: { type: 'xfail-discovered' }, }); - // DISCOVERED: the address-derived (ECDH) source, which is the unconstrained default exercised above. With the + // DISCOVERED: the address-derived source, which is the unconstrained default exercised above. With the // recipient registering the sender, PXE B reconstructs the address-derived tag and discovers the delivery. Positive // control for the cell below (same source, sender unregistered) and for the default cell above. buildMessageDeliveryTest({ @@ -279,9 +278,8 @@ describe('onchain delivery', () => { }, }); - // NOT-DISCOVERABLE (privacy twin of the cell above): the same address-derived source, but the recipient never - // registers the sender, so PXE B cannot reconstruct the tag. Unlike the F-770 default cell above (a temporary gap), - // an explicitly address-derived send to an unregistered sender is a permanent boundary that survives F-770. + // NOT-DISCOVERABLE: An explicit address-derived source, but the recipient never + // registers the sender, so PXE B cannot reconstruct the tag. buildMessageDeliveryTest({ description: 'unconstrained x address-derived without registering the sender', mode: 'unconstrained', From 27dd6b12621bcfb92e2f2a3dbf1fae3f2dc1748b Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 16:52:18 -0400 Subject: [PATCH 12/40] cleanup --- yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 3a13bf899835..4b2fbf1e483c 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -221,7 +221,7 @@ describe('onchain delivery', () => { }); // GREEN: unconstrained delivery whose source the wallet pins to a non-interactive handshake. The first send - // bootstraps the handshake; PXE B discovers it via the registry and reads the (nullifier-free) unconstrained logs. + // bootstraps the handshake; PXE B discovers it via the registry and reads the unconstrained logs. buildMessageDeliveryTest({ description: 'unconstrained x handshake', mode: 'unconstrained', From 4c8289a8372344c53f262a5b053fa429da8b94df Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 17:14:13 -0400 Subject: [PATCH 13/40] test(end-to-end): pin reverse cross-mode handshake reuse Add the reverse of the existing cross-mode cell: unconstrained events bootstrap the handshake, constrained notes reuse it. This is the stricter reuse direction. A constrained reuse validates the reused secret against the registry at index 0 and chains by sequence nullifier, so a cross-mode tagging index leak would fail in-circuit here, where the tolerant unconstrained scan in the forward cell absorbs it silently. The mode-keyed sender index this relies on is PXE-side (unit tests mock the index oracle), so e2e is the only level that exercises this direction. --- .../src/e2e_onchain_delivery.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 4b2fbf1e483c..57387c6121e9 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -306,6 +306,26 @@ describe('onchain delivery', () => { }, }); + // GREEN: the reverse of the cross-mode cell above. The unconstrained events bootstrap the handshake (no nullifier); + // the constrained notes reuse it, validating the reused secret against the registry at index 0 and then chaining by + // sequence nullifier. This is the stricter reuse direction: were the sender's tagging index to leak across modes, the + // first constrained note would land at a non-zero index and assert a predecessor nullifier the unconstrained sends + // never emitted, failing in-circuit; the tolerant unconstrained scan in the cell above would absorb the same bug + // silently. The hook returns a handshake for the bootstrapping unconstrained send and throws if consulted for the + // constrained reuse, so cross-PXE discovery is a durable proof of mode-agnostic reuse. + buildMessageDeliveryTest({ + description: 'handshake bootstrapped unconstrained, reused constrained (cross-mode)', + mode: { events: 'unconstrained', notes: 'constrained' }, + senderHook: ({ deliveryMode }) => { + if (deliveryMode !== AppTaggingSecretKind.UNCONSTRAINED) { + throw new Error( + 'cross-mode reuse regressed: the constrained send consulted the strategy hook instead of reusing the bootstrapped handshake', + ); + } + return Promise.resolve({ type: 'non-interactive-handshake' }); + }, + }); + // REJECTED: the mirror of the unconstrained x arbitrary-secret cell with constrained mode. An unconstrained secret // cannot back constrained delivery, so the circuit rejects the send. This pins the PXE -> circuit soundness // boundary that PXE deliberately delegates to the circuit. From 27ad174c9cfe5cd348b19135aa302f8907e45607 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 29 Jun 2026 17:29:31 -0400 Subject: [PATCH 14/40] improve comments --- .../end-to-end/src/e2e_onchain_delivery.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 57387c6121e9..1c77af9ffec3 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -306,13 +306,14 @@ describe('onchain delivery', () => { }, }); - // GREEN: the reverse of the cross-mode cell above. The unconstrained events bootstrap the handshake (no nullifier); - // the constrained notes reuse it, validating the reused secret against the registry at index 0 and then chaining by - // sequence nullifier. This is the stricter reuse direction: were the sender's tagging index to leak across modes, the - // first constrained note would land at a non-zero index and assert a predecessor nullifier the unconstrained sends - // never emitted, failing in-circuit; the tolerant unconstrained scan in the cell above would absorb the same bug - // silently. The hook returns a handshake for the bootstrapping unconstrained send and throws if consulted for the - // constrained reuse, so cross-PXE discovery is a durable proof of mode-agnostic reuse. + // GREEN: the stricter cross-mode direction. The unconstrained events bootstrap the handshake; the constrained + // notes reuse it. The tripwire makes reuse a hard guarantee: a constrained send that consulted the hook would throw, + // and the only way to skip the hook is resolving an existing handshake, so a green means the notes reused the + // bootstrapped handshake. It also pins the constrained sequence to a fresh index 0: index 0 validates against the + // registry, higher indices assert a predecessor nullifier, so a sender index leaked from the unconstrained counter + // would make the first note demand a predecessor that was never emitted and fail the actual send. + // The index is never read here; the circuit rejecting a wrong one is the signal. The forward cell can't catch this because + // its reusing side is unconstrained, where the index only feeds the tag and the scan tolerates gaps. buildMessageDeliveryTest({ description: 'handshake bootstrapped unconstrained, reused constrained (cross-mode)', mode: { events: 'unconstrained', notes: 'constrained' }, From 5210aaa49e019f935392676e8d27380fe1216d01 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Tue, 30 Jun 2026 15:55:55 -0400 Subject: [PATCH 15/40] test cleanup --- .../handshake_registry_contract/src/main.nr | 3 +- .../src/e2e_constrained_delivery.test.ts | 186 ++++++++++ .../src/e2e_onchain_delivery.test.ts | 317 +++--------------- .../end-to-end/src/test-wallet/test_wallet.ts | 13 +- yarn-project/pxe/src/logs/log_service.test.ts | 82 ++++- 5 files changed, 321 insertions(+), 280 deletions(-) create mode 100644 yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts diff --git a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr index bfe00801ff1d..e4afea7749a8 100644 --- a/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/standard/handshake_registry_contract/src/main.nr @@ -20,8 +20,7 @@ global NON_INTERACTIVE_HANDSHAKE: u8 = 1; /// [`HandshakeRegistry::validate_handshake`] to check app-siloed secrets against the current stored handshake. The /// private surfaces silo against `msg_sender()`, so a contract can only obtain or validate secrets siloed to itself. /// Re-handshaking does not revoke already-started constrained-delivery sequences; it only replaces the registry note -/// used -/// for [validation][`HandshakeRegistry::validate_handshake`]. +/// used for [validation][`HandshakeRegistry::validate_handshake`]. /// /// Currently only implements the non-interactive flow (see [`HandshakeRegistry::non_interactive_handshake`]). #[aztec(::aztec::macros::AztecConfig::new().custom_sync_state(crate::handshake_registry_sync))] diff --git a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts b/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts new file mode 100644 index 000000000000..ab42c4f0a835 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts @@ -0,0 +1,186 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { BatchCall } from '@aztec/aztec.js/contracts'; +import type { Wallet } from '@aztec/aztec.js/wallet'; +import { Point } from '@aztec/foundation/curves/grumpkin'; +import { HandshakeRegistryContract } from '@aztec/noir-contracts.js/HandshakeRegistry'; +import { OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; +import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/handshake-registry/constants'; + +import { jest } from '@jest/globals'; + +import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup } from './fixtures/setup.js'; + +// Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `e2e_onchain_delivery.test.ts`: +// the strict ordering of a constrained sequence (handshake reuse, concurrency, batching) and the soundness boundary +// that rejects an arbitrary secret backing constrained delivery. +describe('constrained delivery', () => { + jest.setTimeout(300_000); + + let teardown: () => Promise; + let wallet: Wallet; + let sender: AztecAddress; + let recipient: AztecAddress; + let batchRecipient: AztecAddress; + let batchRecipient2: AztecAddress; + let batchRecipient3: AztecAddress; + let batchRecipient4: AztecAddress; + let contract: OnchainDeliveryTestContract; + let registry: HandshakeRegistryContract; + + beforeAll(async () => { + ({ + teardown, + wallet, + accounts: [sender, recipient, batchRecipient, batchRecipient2, batchRecipient3, batchRecipient4], + } = await setup(6, { ...AUTOMINE_E2E_OPTS })); + + await ensureHandshakeRegistryPublished(wallet, sender); + ({ contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender })); + registry = HandshakeRegistryContract.at(STANDARD_HANDSHAKE_REGISTRY_ADDRESS, wallet); + }); + + afterAll(() => teardown()); + + it('reuses an existing standard-registry constrained handshake', async () => { + await contract.methods.emit_note(recipient, 1).send({ from: sender }); + + const { result: secretAfterFirstSend } = await contract.methods + .get_app_siloed_secrets(sender, recipient) + .simulate({ from: sender }); + expect(secretAfterFirstSend).toBeDefined(); + + await contract.methods.emit_event(recipient, 1).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, recipient) + .simulate({ from: sender }); + // The second send reuses the handshake rather than bootstrapping a new one: the secret is unchanged. + expect(secret).toEqual(secretAfterFirstSend); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + + expect(index).toEqual(2n); + }); + + // Constrained sends to one recipient form a strictly ordered sequence, so concurrent and batched sends behave + // differently: parallel txs collide on the shared index nullifier, same-tx batches work only once the handshake is + // committed, and batches that bootstrap a brand-new recipient re-handshake onto separate secrets. Each test uses + // its own recipient. + describe('concurrency and batching', () => { + // Constrained sends to one `(sender, recipient)` pair are strictly ordered: the first send bootstraps the + // handshake and every send emits a nullifier keyed only on `(sender, recipient, secret, index)`. Two sends fired + // in parallel read the same index and collide, so one tx is rejected. Marked `it.failing` because this is a + // protocol limitation, not a bug: it documents the constraint and will start failing (prompting its removal) if + // parallel sends to a single pair ever become supported. The working alternative is the batched test below. + it.failing('cannot fan out constrained sends on the same sequence in parallel', async () => { + await Promise.all([ + contract.methods.emit_note(recipient, 1).send({ from: sender }), + contract.methods.emit_note(recipient, 1).send({ from: sender }), + ]); + }); + + // CAN batch (1): a contract call may emit several constrained messages to one recipient in a single tx; each + // later emit proves the previous nullifier as a same-tx pending nullifier. The handshake must already be + // committed (see the re-handshake test below), so it is established first; a fresh recipient starts at index 0, + // so two emits land indices 0 and 1 and the next index is 2. + it('lands multiple constrained sends from a single contract call on an established handshake', async () => { + await registry.methods.non_interactive_handshake(sender, batchRecipient).send({ from: sender }); + + await contract.methods.emit_two_events(batchRecipient).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(2n); + }); + + // CAN batch (2): client-side BatchCall aggregates separate calls into one tx with the same effect. The two + // emit_note calls that fail as parallel txs (above) succeed batched, given an established handshake. + it('lands the same two sends when aggregated into one tx with BatchCall', async () => { + await registry.methods.non_interactive_handshake(sender, batchRecipient2).send({ from: sender }); + + await new BatchCall(wallet, [ + contract.methods.emit_note(batchRecipient2, 1), + contract.methods.emit_note(batchRecipient2, 1), + ]).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient2) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(2n); + }); + + // CANNOT batch onto a brand-new recipient, even within a single contract call. The registry lookup that decides + // reuse-vs-bootstrap is a utility call reading committed state, so the second emit cannot see the first emit's + // pending bootstrap and re-handshakes onto a fresh secret (each handshake mints a new shared secret). The registry + // keeps the second handshake, which holds a single log, so the next index is 1, not 2. This is why the + // established-handshake tests above seed the handshake first. + it('re-handshakes instead of reusing when sends bootstrap a new recipient in the same tx', async () => { + await contract.methods.emit_two_events(batchRecipient3).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient3) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(1n); + }); + + // The same new-recipient limitation holds via client-side BatchCall: the two aggregated emit_note calls each + // bootstrap and re-handshake onto separate secrets (the utility read can't see the first's pending bootstrap), + // so the next index is 1, not 2. Confirms the constraint is in the utility read, not the batching mechanism. + it('re-handshakes instead of reusing when BatchCall sends bootstrap a new recipient in the same tx', async () => { + await new BatchCall(wallet, [ + contract.methods.emit_note(batchRecipient4, 1), + contract.methods.emit_note(batchRecipient4, 1), + ]).send({ from: sender }); + + const { result: secret } = await contract.methods + .get_app_siloed_secrets(sender, batchRecipient4) + .simulate({ from: sender }); + expect(secret).toBeDefined(); + + const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); + expect(index).toEqual(1n); + }); + }); +}); + +describe('rejects unsound sources', () => { + jest.setTimeout(300_000); + + // An unconstrained (arbitrary) secret cannot back constrained delivery, so the circuit rejects the send. This pins + // the PXE -> circuit soundness boundary that the PXE deliberately delegates to the circuit. The secret value is + // irrelevant to the rejection, so a fresh point per hook call is fine and no recipient registration is needed. + it('rejects a constrained send backed by an arbitrary secret', async () => { + const { + teardown, + wallet, + accounts: [sender, recipient], + } = await setup(2, { + ...AUTOMINE_E2E_OPTS, + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), + }, + }, + }); + try { + await ensureHandshakeRegistryPublished(wallet, sender); + const { contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender }); + await expect(contract.methods.emit_event(recipient, 1).send({ from: sender })).rejects.toThrow( + 'an unconstrained tagging secret cannot back constrained delivery', + ); + } finally { + await teardown(); + } + }); +}); diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts index 1c77af9ffec3..96dc9ea9e9d5 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts @@ -2,15 +2,11 @@ import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/account import type { FieldLike } from '@aztec/aztec.js/abi'; import { NO_FROM } from '@aztec/aztec.js/account'; import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import { BatchCall } from '@aztec/aztec.js/contracts'; import type { AztecNode } from '@aztec/aztec.js/node'; -import type { Wallet } from '@aztec/aztec.js/wallet'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { Point } from '@aztec/foundation/curves/grumpkin'; -import { HandshakeRegistryContract } from '@aztec/noir-contracts.js/HandshakeRegistry'; import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; import type { PXECreationOptions } from '@aztec/pxe/server'; -import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/handshake-registry/constants'; import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; @@ -26,45 +22,36 @@ type SenderHook = NonNullable['resolveT type Mode = 'constrained' | 'unconstrained'; -// What PXE B is expected to observe for a (mode, source) cell. The `type` names the asserted condition, so the two -// "PXE B sees nothing" outcomes never read alike: -// - 'discovered': PXE B discovers every message PXE A sent. Asserted with `it`. -// - 'xfail-discovered': asserts the same as 'discovered', but a known not-yet-implemented path fails it today, so it -// runs under `it.failing` and turns the suite red (prompting promotion to 'discovered') once that path works. -// - 'not-discoverable': delivery lands onchain but PXE B was never given the secret, so it discovers nothing. A -// PERMANENT privacy boundary asserted with `it`; a leak that surfaces the delivery to PXE B flips it red. -// - 'rejected': PXE A's send is rejected in-circuit because the resolved source cannot back the mode. Asserted with -// `it` against `message`. -type DeliveryOutcome = - | { type: 'discovered' } - | { type: 'xfail-discovered' } - | { type: 'not-discoverable'; reason: string } - | { type: 'rejected'; message: string }; +// A single mode applies to both the event and note sends; `{ events, notes }` sends each in its own mode, which +// exercises cross-mode handshake reuse: bootstrap in one mode, deliver in the other on the same handshake. +type DeliveryMode = Mode | { events: Mode; notes: Mode }; + +const formatMode = (mode: DeliveryMode): string => (typeof mode === 'string' ? mode : `${mode.events}->${mode.notes}`); // Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; // unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's -// `resolveTaggingSecretStrategy` hook selects. This harness exercises the (mode, source) cells end to end across two -// PXEs that share only a node: PXE A sends, PXE B discovers purely from onchain logs plus the HandshakeRegistry. +// `resolveTaggingSecretStrategy` hook selects. This harness exercises (strategy, mode) cells end to end across two +// PXEs that share only a node: the sender PXE sends, the recipient PXE discovers purely from onchain logs plus the +// HandshakeRegistry. // -// Cross-PXE is the meaningful setup: PXE B holds no sender state, so a cell only "discovers" a message if the source -// truly reached it, which is also what makes the not-discoverable and rejected cells meaningful. +// Cross-PXE is the meaningful setup: the recipient PXE holds no sender state, so a cell only "discovers" a message if +// the source truly reached it. function buildMessageDeliveryTest(opts: { - description: string; - // A single mode applies to both the event and note sends; `{ events, notes }` sends each in its own mode, which - // exercises cross-mode handshake reuse: bootstrap in one mode, deliver in the other on the same handshake. - mode: Mode | { events: Mode; notes: Mode }; - // Omitted = no hook, so the PXE applies its default strategy for the mode. - senderHook?: SenderHook; + // Names the tagging-secret source, e.g. 'handshake' or 'arbitrary secret'. The describe title is derived as + // `${strategy} x ${mode}`. + strategy: string; + mode: DeliveryMode; + // Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests). + senderHook: SenderHook; // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. recipientRegistration?: ( recipient: TestWallet, recipientAddress: AztecAddress, sender: AztecAddress, ) => Promise; - // Defaults to 'discovered'; see `DeliveryOutcome`. - outcome?: DeliveryOutcome; }) { - const { description, mode, senderHook, recipientRegistration, outcome = { type: 'discovered' } } = opts; + const { strategy, mode, senderHook, recipientRegistration } = opts; + const description = `${strategy} x ${formatMode(mode)}`; describe(description, () => { jest.setTimeout(300_000); @@ -96,8 +83,9 @@ function buildMessageDeliveryTest(opts: { : contractSender.methods.emit_note_unconstrained(recipient, value); beforeAll(async () => { - // PXE A holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded at genesis - // here but created and deployed on the isolated PXE B below, so it carries no sender state from other cells. + // The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded + // at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state + // from other cells. let additionallyFundedAccounts: InitialAccountData[]; ({ aztecNode, @@ -108,7 +96,7 @@ function buildMessageDeliveryTest(opts: { } = await setup(1, { ...AUTOMINE_E2E_OPTS, additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), - pxeCreationOptions: senderHook ? { hooks: { resolveTaggingSecretStrategy: senderHook } } : undefined, + pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } }, })); ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( @@ -116,7 +104,7 @@ function buildMessageDeliveryTest(opts: { aztecNode, {}, undefined, - 'pxe-b', + 'pxe-recipient', )); const recipientAccount = await walletRecipient.createSchnorrAccount( additionallyFundedAccounts[0].secret, @@ -136,13 +124,6 @@ function buildMessageDeliveryTest(opts: { await recipientRegistration?.(walletRecipient, recipient, sender); - // A rejected cell lands nothing: its send is exercised (and asserted) in the test body, not here, so the - // matcher distinguishes the expected rejection from an unexpected infra error rather than beforeAll swallowing - // it. Both teardowns and the sender/recipient/contract are assigned above, so afterAll stays safe. - if (outcome.type === 'rejected') { - return; - } - // Constrained sends to one pair are strictly ordered, so deliver one tx at a time. The first send bootstraps the // handshake (when the source is a handshake); the rest reuse it. const blockNumbers: number[] = []; @@ -177,100 +158,57 @@ function buildMessageDeliveryTest(opts: { await teardownSender(); }); - if (outcome.type === 'rejected') { - it('PXE A cannot send: the resolved source cannot back the delivery mode', async () => { - await expect(sendEvent(eventValues[0]).send({ from: sender })).rejects.toThrow(outcome.message); - }); - return; - } - - if (outcome.type === 'not-discoverable') { - // The sends landed onchain (beforeAll mined them), but PXE B was never given the secret, so discovery must come - // up empty. Plain `it` so a future leak that surfaces the delivery to PXE B fails loudly. - it(`PXE B cannot discover the delivered events (${outcome.reason})`, () => { - expect(discoveredEvents).toEqual([]); - }); - it(`PXE B cannot read the delivered notes (${outcome.reason})`, () => { - expect(readNotes).toEqual([]); - }); - return; - } - - // 'discovered' and 'xfail-discovered' assert the same positive condition. - // 'xfail-discovered' runs under `it.failing`. - const test = outcome.type === 'xfail-discovered' ? it.failing : it; - - test('PXE B discovers the events delivered by PXE A', () => { + it('the recipient PXE discovers the events delivered by the sender PXE', () => { expect(discoveredEvents.length).toBe(eventValues.length); for (const value of eventValues) { expect(discoveredEvents).toContainEqual(value); } }); - test('PXE B reads back the notes delivered by PXE A', () => { + it('the recipient PXE reads back the notes delivered by the sender PXE', () => { expect(readNotes).toEqual(noteValues); }); }); } describe('onchain delivery', () => { - // GREEN: constrained always goes through a handshake (the PXE default for constrained). + // constrained always goes through a handshake. Stated explicitly rather than relying on the PXE default. buildMessageDeliveryTest({ - description: 'constrained x handshake', + strategy: 'handshake', mode: 'constrained', + senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), }); - // GREEN: unconstrained delivery whose source the wallet pins to a non-interactive handshake. The first send - // bootstraps the handshake; PXE B discovers it via the registry and reads the unconstrained logs. + // unconstrained delivery whose source the wallet pins to a non-interactive handshake. The first send + // bootstraps the handshake; the recipient PXE discovers it via the registry and reads the unconstrained logs. buildMessageDeliveryTest({ - description: 'unconstrained x handshake', + strategy: 'handshake', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), }); - // GREEN: unconstrained delivery tagged with a raw secret the two parties share out of band. The sender's hook and + // unconstrained delivery tagged with a raw secret the two parties share out of band. The sender's hook and // the recipient registration use the same point, generated once in `recipientRegistration` (which runs before any // send fires the hook). let arbitrarySecret: Point; buildMessageDeliveryTest({ - description: 'unconstrained x arbitrary secret', + strategy: 'arbitrary secret', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: arbitrarySecret }), recipientRegistration: async (recipientWallet, recipientAddress) => { arbitrarySecret = await Point.random(); - await recipientWallet.registerArbitrarySecret(recipientAddress, arbitrarySecret); - }, - }); - - // NOT-DISCOVERABLE: the sender tags with an arbitrary secret the recipient never registers. - // The cell above discovers this same kind of send; here PXE B cannot, because the secret - // was never shared with it. - let unsharedSecret: Point; - buildMessageDeliveryTest({ - description: 'unconstrained x arbitrary secret never shared with the recipient', - mode: 'unconstrained', - senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: unsharedSecret }), - recipientRegistration: async () => { - // Generate the secret the sender uses, but deliberately do NOT register it on PXE B; that is the point. - unsharedSecret = await Point.random(); + await recipientWallet.registerTaggingSecretSource({ + kind: 'arbitrary-secret', + recipient: recipientAddress, + secret: arbitrarySecret, + }); }, - outcome: { type: 'not-discoverable', reason: 'the arbitrary secret was never registered on PXE B' }, }); - // TODO(F-770): with no hook, unconstrained delivery to an external recipient defaults to an address-derived tag. - // PXE B holds no sender state, so it cannot reconstruct that tag and discovers nothing yet. Switching the default - // to a handshake, will turn this test red and require graduating to 'discovered'. + // the address-derived source, which is the unconstrained default. With the recipient registering the sender, + // the recipient PXE reconstructs the address-derived tag and discovers the delivery. buildMessageDeliveryTest({ - description: 'unconstrained x default to external recipient', - mode: 'unconstrained', - outcome: { type: 'xfail-discovered' }, - }); - - // DISCOVERED: the address-derived source, which is the unconstrained default exercised above. With the - // recipient registering the sender, PXE B reconstructs the address-derived tag and discovers the delivery. Positive - // control for the cell below (same source, sender unregistered) and for the default cell above. - buildMessageDeliveryTest({ - description: 'unconstrained x address-derived (recipient registers the sender)', + strategy: 'address-derived', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'address-derived' }), recipientRegistration: async (recipientWallet, _recipientAddress, senderAddress) => { @@ -278,23 +216,14 @@ describe('onchain delivery', () => { }, }); - // NOT-DISCOVERABLE: An explicit address-derived source, but the recipient never - // registers the sender, so PXE B cannot reconstruct the tag. - buildMessageDeliveryTest({ - description: 'unconstrained x address-derived without registering the sender', - mode: 'unconstrained', - senderHook: () => Promise.resolve({ type: 'address-derived' }), - outcome: { type: 'not-discoverable', reason: 'PXE B never registered the sender' }, - }); - - // GREEN: one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes + // one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes // reuse it. Reuse bypasses the wallet hook entirely (an existing registry handshake is resolved before the hook is // consulted), so the hook returns a handshake for the bootstrapping constrained send but throws if it is ever // consulted for the unconstrained send. That makes discovery a durable proof of mode-agnostic reuse: were reuse to // regress, the unconstrained note would fall through to the hook and fail loudly instead of being silently // re-discovered some other way. buildMessageDeliveryTest({ - description: 'handshake bootstrapped constrained, reused unconstrained (cross-mode)', + strategy: 'handshake', mode: { events: 'constrained', notes: 'unconstrained' }, senderHook: ({ deliveryMode }) => { if (deliveryMode !== AppTaggingSecretKind.CONSTRAINED) { @@ -306,16 +235,16 @@ describe('onchain delivery', () => { }, }); - // GREEN: the stricter cross-mode direction. The unconstrained events bootstrap the handshake; the constrained + // the stricter cross-mode direction. The unconstrained events bootstrap the handshake; the constrained // notes reuse it. The tripwire makes reuse a hard guarantee: a constrained send that consulted the hook would throw, // and the only way to skip the hook is resolving an existing handshake, so a green means the notes reused the // bootstrapped handshake. It also pins the constrained sequence to a fresh index 0: index 0 validates against the // registry, higher indices assert a predecessor nullifier, so a sender index leaked from the unconstrained counter // would make the first note demand a predecessor that was never emitted and fail the actual send. - // The index is never read here; the circuit rejecting a wrong one is the signal. The forward cell can't catch this because - // its reusing side is unconstrained, where the index only feeds the tag and the scan tolerates gaps. + // The index is never read here; the circuit rejecting a wrong one is the signal. The forward cell can't catch + // this because its reusing side is unconstrained, where the index only feeds the tag and the scan tolerates gaps. buildMessageDeliveryTest({ - description: 'handshake bootstrapped unconstrained, reused constrained (cross-mode)', + strategy: 'handshake', mode: { events: 'unconstrained', notes: 'constrained' }, senderHook: ({ deliveryMode }) => { if (deliveryMode !== AppTaggingSecretKind.UNCONSTRAINED) { @@ -326,156 +255,4 @@ describe('onchain delivery', () => { return Promise.resolve({ type: 'non-interactive-handshake' }); }, }); - - // REJECTED: the mirror of the unconstrained x arbitrary-secret cell with constrained mode. An unconstrained secret - // cannot back constrained delivery, so the circuit rejects the send. This pins the PXE -> circuit soundness - // boundary that PXE deliberately delegates to the circuit. - // The secret value is irrelevant to the rejection, so a fresh point per hook call is fine and no recipient - // registration is needed. - buildMessageDeliveryTest({ - description: 'constrained x arbitrary secret (rejected)', - mode: 'constrained', - senderHook: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), - outcome: { type: 'rejected', message: 'an unconstrained tagging secret cannot back constrained delivery' }, - }); -}); - -describe('constrained delivery', () => { - jest.setTimeout(300_000); - - let teardown: () => Promise; - let wallet: Wallet; - let sender: AztecAddress; - let recipient: AztecAddress; - let batchRecipient: AztecAddress; - let batchRecipient2: AztecAddress; - let batchRecipient3: AztecAddress; - let batchRecipient4: AztecAddress; - let contract: OnchainDeliveryTestContract; - let registry: HandshakeRegistryContract; - - beforeAll(async () => { - ({ - teardown, - wallet, - accounts: [sender, recipient, batchRecipient, batchRecipient2, batchRecipient3, batchRecipient4], - } = await setup(6, { ...AUTOMINE_E2E_OPTS })); - - await ensureHandshakeRegistryPublished(wallet, sender); - ({ contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender })); - registry = HandshakeRegistryContract.at(STANDARD_HANDSHAKE_REGISTRY_ADDRESS, wallet); - }); - - afterAll(() => teardown()); - - it('reuses an existing standard-registry constrained handshake', async () => { - await contract.methods.emit_note(recipient, 1).send({ from: sender }); - - const { result: secretAfterFirstSend } = await contract.methods - .get_app_siloed_secrets(sender, recipient) - .simulate({ from: sender }); - expect(secretAfterFirstSend).toBeDefined(); - - await contract.methods.emit_event(recipient, 1).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, recipient) - .simulate({ from: sender }); - // The second send reuses the handshake rather than bootstrapping a new one: the secret is unchanged. - expect(secret).toEqual(secretAfterFirstSend); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - - expect(index).toEqual(2n); - }); - - // Constrained sends to one recipient form a strictly ordered sequence, so concurrent and batched sends behave - // differently: parallel txs collide on the shared index nullifier, same-tx batches work only once the handshake is - // committed, and batches that bootstrap a brand-new recipient re-handshake onto separate secrets. Each test uses - // its own recipient. - describe('concurrency and batching', () => { - // Constrained sends to one `(sender, recipient)` pair are strictly ordered: the first send bootstraps the - // handshake and every send emits a nullifier keyed only on `(sender, recipient, secret, index)`. Two sends fired - // in parallel read the same index and collide, so one tx is rejected. Marked `it.failing` because this is a - // protocol limitation, not a bug: it documents the constraint and will start failing (prompting its removal) if - // parallel sends to a single pair ever become supported. The working alternative is the batched test below. - it.failing('cannot fan out constrained sends on the same sequence in parallel', async () => { - await Promise.all([ - contract.methods.emit_note(recipient, 1).send({ from: sender }), - contract.methods.emit_note(recipient, 1).send({ from: sender }), - ]); - }); - - // CAN batch (1): a contract call may emit several constrained messages to one recipient in a single tx; each - // later emit proves the previous nullifier as a same-tx pending nullifier. The handshake must already be - // committed (see the re-handshake test below), so it is established first; a fresh recipient starts at index 0, - // so two emits land indices 0 and 1 and the next index is 2. - it('lands multiple constrained sends from a single contract call on an established handshake', async () => { - await registry.methods.non_interactive_handshake(sender, batchRecipient).send({ from: sender }); - - await contract.methods.emit_two_events(batchRecipient).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(2n); - }); - - // CAN batch (2): client-side BatchCall aggregates separate calls into one tx with the same effect. The two - // emit_note calls that fail as parallel txs (above) succeed batched, given an established handshake. - it('lands the same two sends when aggregated into one tx with BatchCall', async () => { - await registry.methods.non_interactive_handshake(sender, batchRecipient2).send({ from: sender }); - - await new BatchCall(wallet, [ - contract.methods.emit_note(batchRecipient2, 1), - contract.methods.emit_note(batchRecipient2, 1), - ]).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient2) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(2n); - }); - - // CANNOT batch onto a brand-new recipient, even within a single contract call. The registry lookup that decides - // reuse-vs-bootstrap is a utility call reading committed state, so the second emit cannot see the first emit's - // pending bootstrap and re-handshakes onto a fresh secret (each handshake mints a new shared secret). The registry - // keeps the second handshake, which holds a single log, so the next index is 1, not 2. This is why the - // established-handshake tests above seed the handshake first. - it('re-handshakes instead of reusing when sends bootstrap a new recipient in the same tx', async () => { - await contract.methods.emit_two_events(batchRecipient3).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient3) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(1n); - }); - - // The same new-recipient limitation holds via client-side BatchCall: the two aggregated emit_note calls each - // bootstrap and re-handshake onto separate secrets (the utility read can't see the first's pending bootstrap), - // so the next index is 1, not 2. Confirms the constraint is in the utility read, not the batching mechanism. - it('re-handshakes instead of reusing when BatchCall sends bootstrap a new recipient in the same tx', async () => { - await new BatchCall(wallet, [ - contract.methods.emit_note(batchRecipient4, 1), - contract.methods.emit_note(batchRecipient4, 1), - ]).send({ from: sender }); - - const { result: secret } = await contract.methods - .get_app_siloed_secrets(sender, batchRecipient4) - .simulate({ from: sender }); - expect(secret).toBeDefined(); - - const { result: index } = await contract.methods.next_index_for_secret(secret).simulate({ from: sender }); - expect(index).toEqual(1n); - }); - }); }); diff --git a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts index 56503b43b04c..8062e3f5e815 100644 --- a/yarn-project/end-to-end/src/test-wallet/test_wallet.ts +++ b/yarn-project/end-to-end/src/test-wallet/test_wallet.ts @@ -20,10 +20,10 @@ import { TxSimulationResultWithAppOffset } from '@aztec/aztec.js/wallet'; import type { DefaultAccountEntrypointOptions } from '@aztec/entrypoints/account'; import { DefaultEntrypoint } from '@aztec/entrypoints/default'; import { Fq, Fr } from '@aztec/foundation/curves/bn254'; -import { GrumpkinScalar, type Point } from '@aztec/foundation/curves/grumpkin'; +import { GrumpkinScalar } from '@aztec/foundation/curves/grumpkin'; import type { NotesFilter } from '@aztec/pxe/client/lazy'; import { type PXEConfig, getPXEConfig } from '@aztec/pxe/config'; -import { PXE, type PXECreationOptions, createPXE } from '@aztec/pxe/server'; +import { PXE, type PXECreationOptions, type TaggingSecretSource, createPXE } from '@aztec/pxe/server'; import { AuthWitness } from '@aztec/stdlib/auth-witness'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; @@ -399,11 +399,12 @@ export class TestWallet extends BaseWallet { } /** - * Registers a raw out-of-band shared secret so this PXE discovers unconstrained messages tagged with it. Test-only - * surface over {@link PXE.registerTaggingSecretSource}, which the base `Wallet` does not expose. + * Registers a non-sender tagging-secret source (e.g. a raw out-of-band shared secret) so this PXE discovers messages + * tagged with it. Test-only surface over {@link PXE.registerTaggingSecretSource}, which the base `Wallet` does not + * expose. The `address-derived` (sender) variant is excluded: use {@link Wallet.registerSender} for that. */ - registerArbitrarySecret(recipient: AztecAddress, secret: Point): Promise { - return this.pxe.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient, secret }); + registerTaggingSecretSource(source: Exclude): Promise { + return this.pxe.registerTaggingSecretSource(source); } stop(): Promise { diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 6adcfda9c724..c7e9dda5a7c8 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -1,13 +1,15 @@ import { INITIAL_L2_BLOCK_NUM } from '@aztec/constants'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { randomInt } from '@aztec/foundation/crypto/random'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { KeyStore } from '@aztec/key-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; +import type { CompleteAddress } from '@aztec/stdlib/contract'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; -import { SiloedTag, Tag } from '@aztec/stdlib/logs'; -import { makeBlockHeader, randomPrivateLogResult } from '@aztec/stdlib/testing'; +import { AppTaggingSecret, type LogResult, SiloedTag, Tag, computeSharedTaggingSecret } from '@aztec/stdlib/logs'; +import { makeBlockHeader, makeL2Tips, randomPrivateLogResult } from '@aztec/stdlib/testing'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -304,6 +306,82 @@ describe('LogService', () => { }); }); }); + + // Address-derived discovery is gated on registering the sender: the recipient can only reconstruct a sender's tags + // for senders in `getSenders()` (plus its own accounts). This pins that gate through `fetchTaggedLogs` end to end. + describe('address-derived sender gate', () => { + let recipientCompleteAddress: CompleteAddress; + let recipient: AztecAddress; + let sender: AztecAddress; + let senderIndex0Tag: SiloedTag; + let senderLog: LogResult; + + beforeEach(async () => { + contractAddress = await AztecAddress.random(); + keyStore = new KeyStore(await openTmpStore('test')); + recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); + taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); + addressStore = new AddressStore(await openTmpStore('test')); + + const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); + const l2TipsStore = mock(); + l2TipsStore.getL2Tips.mockResolvedValue(makeL2Tips(anchorBlockHeader.globalVariables.blockNumber)); + + aztecNode = mock(); + + logService = new LogService( + aztecNode, + anchorBlockHeader, + l2TipsStore, + keyStore, + recipientTaggingStore, + taggingSecretSourcesStore, + addressStore, + 'test', + ); + + // A real recipient account, so the ECDH tag derivation has the keys and address preimage it needs. + recipientCompleteAddress = await keyStore.addAccount(new Fr(1), Fr.random()); + recipient = recipientCompleteAddress.address; + await addressStore.addCompleteAddress(recipientCompleteAddress); + + sender = await AztecAddress.random(); + + // Recompute, from the same ECDH inputs the service uses, the tag the recipient would scan for the sender's + // first message. The node returns the sender's log only for this exact tag, so discovery proves the tag was + // scanned, which only happens when the sender is registered. + const recipientIvsk = await keyStore.getMasterIncomingViewingSecretKey(recipient); + const sharedSecret = await computeSharedTaggingSecret(recipientCompleteAddress, recipientIvsk, sender); + const appSecret = await AppTaggingSecret.compute(sharedSecret!, contractAddress, recipient); + senderIndex0Tag = await SiloedTag.compute({ extendedSecret: appSecret, index: 0 }); + + // Past the anchor block, so the log is unfinalized and the scan completes in a single round. + senderLog = randomPrivateLogResult({ blockNumber: INITIAL_L2_BLOCK_NUM + 1, includeEffects: true }); + aztecNode.getPrivateLogsByTags.mockImplementation(({ tags }) => + Promise.resolve( + // A tag query is either a bare tag (first page) or a `{ tag, afterLog }` cursor (paginated follow-ups). + tags.map(query => { + const siloedTag = query instanceof SiloedTag ? query : query.tag; + return siloedTag.equals(senderIndex0Tag) ? [senderLog] : []; + }), + ), + ); + }); + + it('does not discover messages from an unregistered sender', async () => { + const discovered = await logService.fetchTaggedLogs(contractAddress, recipient, []); + expect(discovered).toEqual([]); + }); + + it('discovers the sender messages once the sender is registered', async () => { + await taggingSecretSourcesStore.addSender(sender); + + const discovered = await logService.fetchTaggedLogs(contractAddress, recipient, []); + + expect(discovered).toHaveLength(1); + expect(discovered[0].context.txHash).toEqual(senderLog.txHash); + }); + }); }); function makeLogRetrievalRequest( From f5c8a4e8ea5222e1ed823227233405d39275217a Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Tue, 30 Jun 2026 16:06:15 -0400 Subject: [PATCH 16/40] move tests --- yarn-project/end-to-end/bootstrap.sh | 1 + .../delivery/constrained.test.ts} | 62 +++++++++---------- .../delivery/onchain.test.ts} | 8 +-- 3 files changed, 36 insertions(+), 35 deletions(-) rename yarn-project/end-to-end/src/{e2e_constrained_delivery.test.ts => automine/delivery/constrained.test.ts} (84%) rename yarn-project/end-to-end/src/{e2e_onchain_delivery.test.ts => automine/delivery/onchain.test.ts} (98%) diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index ea42ae6f15f7..8b34522635c3 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -43,6 +43,7 @@ function test_cmds { src/automine/token/*.test.ts src/automine/accounts/*.test.ts src/automine/effects/*.test.ts + src/automine/delivery/*.test.ts src/automine/simulation/!(avm_simulator).test.ts src/single-node/block-building/*.test.ts src/single-node/proving/*.test.ts diff --git a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts similarity index 84% rename from yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts rename to yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index ab42c4f0a835..c7366fd8c602 100644 --- a/yarn-project/end-to-end/src/e2e_constrained_delivery.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -8,13 +8,13 @@ import { STANDARD_HANDSHAKE_REGISTRY_ADDRESS } from '@aztec/standard-contracts/h import { jest } from '@jest/globals'; -import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; -import { ensureHandshakeRegistryPublished, setup } from './fixtures/setup.js'; +import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup } from '../../fixtures/setup.js'; -// Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `e2e_onchain_delivery.test.ts`: +// Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts`: // the strict ordering of a constrained sequence (handshake reuse, concurrency, batching) and the soundness boundary // that rejects an arbitrary secret backing constrained delivery. -describe('constrained delivery', () => { +describe('automine/delivery/constrained', () => { jest.setTimeout(300_000); let teardown: () => Promise; @@ -152,35 +152,35 @@ describe('constrained delivery', () => { expect(index).toEqual(1n); }); }); -}); - -describe('rejects unsound sources', () => { - jest.setTimeout(300_000); - // An unconstrained (arbitrary) secret cannot back constrained delivery, so the circuit rejects the send. This pins - // the PXE -> circuit soundness boundary that the PXE deliberately delegates to the circuit. The secret value is - // irrelevant to the rejection, so a fresh point per hook call is fine and no recipient registration is needed. - it('rejects a constrained send backed by an arbitrary secret', async () => { - const { - teardown, - wallet, - accounts: [sender, recipient], - } = await setup(2, { - ...AUTOMINE_E2E_OPTS, - pxeCreationOptions: { - hooks: { - resolveTaggingSecretStrategy: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), + describe('rejects unsound sources', () => { + jest.setTimeout(300_000); + + // An unconstrained (arbitrary) secret cannot back constrained delivery, so the circuit rejects the send. This pins + // the PXE -> circuit soundness boundary that the PXE deliberately delegates to the circuit. The secret value is + // irrelevant to the rejection, so a fresh point per hook call is fine and no recipient registration is needed. + it('rejects a constrained send backed by an arbitrary secret', async () => { + const { + teardown, + wallet, + accounts: [sender, recipient], + } = await setup(2, { + ...AUTOMINE_E2E_OPTS, + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), + }, }, - }, + }); + try { + await ensureHandshakeRegistryPublished(wallet, sender); + const { contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender }); + await expect(contract.methods.emit_event(recipient, 1).send({ from: sender })).rejects.toThrow( + 'an unconstrained tagging secret cannot back constrained delivery', + ); + } finally { + await teardown(); + } }); - try { - await ensureHandshakeRegistryPublished(wallet, sender); - const { contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender }); - await expect(contract.methods.emit_event(recipient, 1).send({ from: sender })).rejects.toThrow( - 'an unconstrained tagging secret cannot back constrained delivery', - ); - } finally { - await teardown(); - } }); }); diff --git a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts similarity index 98% rename from yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts rename to yarn-project/end-to-end/src/automine/delivery/onchain.test.ts index 96dc9ea9e9d5..c3a6f1957014 100644 --- a/yarn-project/end-to-end/src/e2e_onchain_delivery.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -12,9 +12,9 @@ import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { jest } from '@jest/globals'; -import { AUTOMINE_E2E_OPTS } from './fixtures/fixtures.js'; -import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from './fixtures/setup.js'; -import { TestWallet } from './test-wallet/test_wallet.js'; +import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js'; +import { TestWallet } from '../../test-wallet/test_wallet.js'; // The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options // rather than importing the hook type, which `@aztec/pxe/server` does not re-export. @@ -171,7 +171,7 @@ function buildMessageDeliveryTest(opts: { }); } -describe('onchain delivery', () => { +describe('automine/delivery/onchain', () => { // constrained always goes through a handshake. Stated explicitly rather than relying on the PXE default. buildMessageDeliveryTest({ strategy: 'handshake', From 4e06e3629f232586efa87609645700c9a78b8211 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Tue, 30 Jun 2026 17:01:30 -0400 Subject: [PATCH 17/40] cleanup log test --- yarn-project/pxe/src/logs/log_service.test.ts | 81 +++++++++---------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index c7e9dda5a7c8..c864b59b5d91 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -36,32 +36,13 @@ describe('LogService', () => { const tag = Tag.random(); beforeEach(async () => { - // Set up contract address contractAddress = await AztecAddress.random(); - keyStore = new KeyStore(await openTmpStore('test')); - recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); - taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); - addressStore = new AddressStore(await openTmpStore('test')); - - aztecNode = mock(); + ({ aztecNode, keyStore, recipientTaggingStore, taggingSecretSourcesStore, addressStore, logService } = + await createTestLogService()); aztecNode.getPrivateLogsByTags.mockReset(); aztecNode.getPublicLogsByTags.mockReset(); aztecNode.getTxEffect.mockReset(); - - // Set up anchor block header (required for bulkRetrieveLogs) - const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); - - logService = new LogService( - aztecNode, - anchorBlockHeader, - mock(), - keyStore, - recipientTaggingStore, - taggingSecretSourcesStore, - addressStore, - 'test', - ); }); it('returns empty arrays if no logs are found', async () => { @@ -318,27 +299,12 @@ describe('LogService', () => { beforeEach(async () => { contractAddress = await AztecAddress.random(); - keyStore = new KeyStore(await openTmpStore('test')); - recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); - taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); - addressStore = new AddressStore(await openTmpStore('test')); - - const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); - const l2TipsStore = mock(); - l2TipsStore.getL2Tips.mockResolvedValue(makeL2Tips(anchorBlockHeader.globalVariables.blockNumber)); - - aztecNode = mock(); - - logService = new LogService( - aztecNode, - anchorBlockHeader, - l2TipsStore, - keyStore, - recipientTaggingStore, - taggingSecretSourcesStore, - addressStore, - 'test', - ); + + const l2TipsProvider = mock(); + const testContext = await createTestLogService(l2TipsProvider); + ({ aztecNode, keyStore, recipientTaggingStore, taggingSecretSourcesStore, addressStore, logService } = + testContext); + l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(testContext.anchorBlockHeader.globalVariables.blockNumber)); // A real recipient account, so the ECDH tag derivation has the keys and address preimage it needs. recipientCompleteAddress = await keyStore.addAccount(new Fr(1), Fr.random()); @@ -384,6 +350,37 @@ describe('LogService', () => { }); }); +async function createTestLogService(l2TipsProvider: MockProxy = mock()) { + const keyStore = new KeyStore(await openTmpStore('test')); + const recipientTaggingStore = new RecipientTaggingStore(await openTmpStore('test')); + const taggingSecretSourcesStore = new TaggingSecretSourcesStore(await openTmpStore('test')); + const addressStore = new AddressStore(await openTmpStore('test')); + const aztecNode = mock(); + // Anchor block header is required for bulkRetrieveLogs. + const anchorBlockHeader = makeBlockHeader(randomInt(1000), { blockNumber: BlockNumber(INITIAL_L2_BLOCK_NUM) }); + + const logService = new LogService( + aztecNode, + anchorBlockHeader, + l2TipsProvider, + keyStore, + recipientTaggingStore, + taggingSecretSourcesStore, + addressStore, + 'test', + ); + + return { + aztecNode, + keyStore, + recipientTaggingStore, + taggingSecretSourcesStore, + addressStore, + anchorBlockHeader, + logService, + }; +} + function makeLogRetrievalRequest( contractAddress: AztecAddress, tag: Tag, From 922952a531762be5de5e207f16dec05197e0e70d Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Tue, 30 Jun 2026 17:10:39 -0400 Subject: [PATCH 18/40] fix(pxe): remove unused recipientTaggingStore var in log_service.test.ts --- yarn-project/pxe/src/logs/log_service.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index c864b59b5d91..df5a437074f3 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -27,7 +27,6 @@ describe('LogService', () => { let contractAddress: AztecAddress; let aztecNode: MockProxy; let keyStore: KeyStore; - let recipientTaggingStore: RecipientTaggingStore; let addressStore: AddressStore; let taggingSecretSourcesStore: TaggingSecretSourcesStore; let logService: LogService; @@ -37,8 +36,7 @@ describe('LogService', () => { beforeEach(async () => { contractAddress = await AztecAddress.random(); - ({ aztecNode, keyStore, recipientTaggingStore, taggingSecretSourcesStore, addressStore, logService } = - await createTestLogService()); + ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = await createTestLogService()); aztecNode.getPrivateLogsByTags.mockReset(); aztecNode.getPublicLogsByTags.mockReset(); @@ -302,8 +300,7 @@ describe('LogService', () => { const l2TipsProvider = mock(); const testContext = await createTestLogService(l2TipsProvider); - ({ aztecNode, keyStore, recipientTaggingStore, taggingSecretSourcesStore, addressStore, logService } = - testContext); + ({ aztecNode, keyStore, taggingSecretSourcesStore, addressStore, logService } = testContext); l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(testContext.anchorBlockHeader.globalVariables.blockNumber)); // A real recipient account, so the ECDH tag derivation has the keys and address preimage it needs. From 9e769aa185603ca52c4379abbd82a8d3bc423d97 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Wed, 1 Jul 2026 10:59:56 -0400 Subject: [PATCH 19/40] fix(end-to-end): un-nest the arbitrary-secret rejection test Moving it inside describe('automine/delivery/constrained') delayed the outer suite's teardown (its afterAll only runs after every nested test completes), so this test's own setup() started a second sandbox while the outer one was still holding its anvil port. The second anvil fails to bind and the wrapper script that launches it never propagates that failure, so the test hangs until the 300s jest timeout instead of failing fast. Restoring it as a sibling describe brings back the working order: the outer suite tears down before this test starts its own sandbox. --- .../src/automine/delivery/constrained.test.ts | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index c7366fd8c602..d8bae606a624 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -152,35 +152,35 @@ describe('automine/delivery/constrained', () => { expect(index).toEqual(1n); }); }); +}); + +describe('automine/delivery/constrained: rejects unsound sources', () => { + jest.setTimeout(300_000); - describe('rejects unsound sources', () => { - jest.setTimeout(300_000); - - // An unconstrained (arbitrary) secret cannot back constrained delivery, so the circuit rejects the send. This pins - // the PXE -> circuit soundness boundary that the PXE deliberately delegates to the circuit. The secret value is - // irrelevant to the rejection, so a fresh point per hook call is fine and no recipient registration is needed. - it('rejects a constrained send backed by an arbitrary secret', async () => { - const { - teardown, - wallet, - accounts: [sender, recipient], - } = await setup(2, { - ...AUTOMINE_E2E_OPTS, - pxeCreationOptions: { - hooks: { - resolveTaggingSecretStrategy: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), - }, + // An unconstrained (arbitrary) secret cannot back constrained delivery, so the circuit rejects the send. This pins + // the PXE -> circuit soundness boundary that the PXE deliberately delegates to the circuit. The secret value is + // irrelevant to the rejection, so a fresh point per hook call is fine and no recipient registration is needed. + it('rejects a constrained send backed by an arbitrary secret', async () => { + const { + teardown, + wallet, + accounts: [sender, recipient], + } = await setup(2, { + ...AUTOMINE_E2E_OPTS, + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), }, - }); - try { - await ensureHandshakeRegistryPublished(wallet, sender); - const { contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender }); - await expect(contract.methods.emit_event(recipient, 1).send({ from: sender })).rejects.toThrow( - 'an unconstrained tagging secret cannot back constrained delivery', - ); - } finally { - await teardown(); - } + }, }); + try { + await ensureHandshakeRegistryPublished(wallet, sender); + const { contract } = await OnchainDeliveryTestContract.deploy(wallet).send({ from: sender }); + await expect(contract.methods.emit_event(recipient, 1).send({ from: sender })).rejects.toThrow( + 'an unconstrained tagging secret cannot back constrained delivery', + ); + } finally { + await teardown(); + } }); }); From 481dac649115c3630c6dd7afba503e6518fcec77 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Wed, 1 Jul 2026 11:21:59 -0400 Subject: [PATCH 20/40] docs(end-to-end): explain why the arbitrary-secret test stays top-level Flags the anvil-port-collision hazard from the prior nesting bug so it isn't reintroduced by moving the test back under the sibling describe. --- .../end-to-end/src/automine/delivery/constrained.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index d8bae606a624..8a77ddb51f3c 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -154,6 +154,13 @@ describe('automine/delivery/constrained', () => { }); }); +// This test builds its own PXE via setup() rather than reusing the wallet from the describe block above, because +// it needs a resolveTaggingSecretStrategy hook that only exists as a PXE-creation-time option. It must stay a +// top-level describe, not merged (nested describe, or plain `it`) into 'automine/delivery/constrained' above: that +// describe's own afterAll teardown only fires once every test inside it has finished, so nesting this one in there +// would leave that describe's sandbox (and its anvil) alive while this test starts a second one. The second anvil +// then fails to bind to the same port, and the wrapper script that launches it never surfaces that failure, so the +// test hangs for the full jest timeout instead of failing fast. describe('automine/delivery/constrained: rejects unsound sources', () => { jest.setTimeout(300_000); From d6ae4b186332777d63ea7676ba0789d99ccac620 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Wed, 1 Jul 2026 13:44:49 -0400 Subject: [PATCH 21/40] fix(end-to-end): address onchain-delivery review feedback nchamo's review on #24373 asked for a readable outer describe name, a strategy label that says 'non-interactive handshake' instead of the ambiguous 'handshake' (interactive handshakes are coming), the arbitrary-secret point computed once instead of as a side effect of recipientRegistration, and the cross-mode handshake-reuse cells split out of the basic (strategy, mode) matrix. Extracts buildMessageDeliveryTest into onchain_delivery_harness.ts so onchain.test.ts and the new handshake_reuse.test.ts can both build cells from it. --- .../automine/delivery/handshake_reuse.test.ts | 48 ++++ .../src/automine/delivery/onchain.test.ts | 227 +----------------- .../delivery/onchain_delivery_harness.ts | 170 +++++++++++++ 3 files changed, 228 insertions(+), 217 deletions(-) create mode 100644 yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts create mode 100644 yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts diff --git a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts new file mode 100644 index 000000000000..e33fe755b8ef --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -0,0 +1,48 @@ +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; + +// Regression coverage for mode-agnostic handshake reuse, split out from `onchain.test.ts`. That file only pins that +// each (strategy, mode) cell delivers; this file pins the stronger claim that a handshake bootstrapped under one +// delivery mode is reused, not re-bootstrapped, when the other mode sends next. +describe('automine/delivery/handshake_reuse', () => { + // one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes + // reuse it. Reuse bypasses the wallet hook entirely (an existing registry handshake is resolved before the hook is + // consulted), so the hook returns a handshake for the bootstrapping constrained send but throws if it is ever + // consulted for the unconstrained send. That makes discovery a durable proof of mode-agnostic reuse: were reuse to + // regress, the unconstrained note would fall through to the hook and fail loudly instead of being silently + // re-discovered some other way. + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: { events: 'constrained', notes: 'unconstrained' }, + senderHook: ({ deliveryMode }) => { + if (deliveryMode !== AppTaggingSecretKind.CONSTRAINED) { + throw new Error( + 'cross-mode reuse regressed: the unconstrained send consulted the strategy hook instead of reusing the bootstrapped handshake', + ); + } + return Promise.resolve({ type: 'non-interactive-handshake' }); + }, + }); + + // the stricter cross-mode direction. The unconstrained events bootstrap the handshake; the constrained + // notes reuse it. The tripwire makes reuse a hard guarantee: a constrained send that consulted the hook would throw, + // and the only way to skip the hook is resolving an existing handshake, so a green means the notes reused the + // bootstrapped handshake. It also pins the constrained sequence to a fresh index 0: index 0 validates against the + // registry, higher indices assert a predecessor nullifier, so a sender index leaked from the unconstrained counter + // would make the first note demand a predecessor that was never emitted and fail the actual send. + // The index is never read here; the circuit rejecting a wrong one is the signal. The forward cell can't catch + // this because its reusing side is unconstrained, where the index only feeds the tag and the scan tolerates gaps. + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: { events: 'unconstrained', notes: 'constrained' }, + senderHook: ({ deliveryMode }) => { + if (deliveryMode !== AppTaggingSecretKind.UNCONSTRAINED) { + throw new Error( + 'cross-mode reuse regressed: the constrained send consulted the strategy hook instead of reusing the bootstrapped handshake', + ); + } + return Promise.resolve({ type: 'non-interactive-handshake' }); + }, + }); +}); diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts index c3a6f1957014..a5870d712b37 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -1,180 +1,11 @@ -import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; -import type { FieldLike } from '@aztec/aztec.js/abi'; -import { NO_FROM } from '@aztec/aztec.js/account'; -import type { AztecAddress } from '@aztec/aztec.js/addresses'; -import type { AztecNode } from '@aztec/aztec.js/node'; -import { BlockNumber } from '@aztec/foundation/branded-types'; import { Point } from '@aztec/foundation/curves/grumpkin'; -import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; -import type { PXECreationOptions } from '@aztec/pxe/server'; -import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; -import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; -import { jest } from '@jest/globals'; +import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; -import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; -import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js'; -import { TestWallet } from '../../test-wallet/test_wallet.js'; - -// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options -// rather than importing the hook type, which `@aztec/pxe/server` does not re-export. -type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; - -type Mode = 'constrained' | 'unconstrained'; - -// A single mode applies to both the event and note sends; `{ events, notes }` sends each in its own mode, which -// exercises cross-mode handshake reuse: bootstrap in one mode, deliver in the other on the same handshake. -type DeliveryMode = Mode | { events: Mode; notes: Mode }; - -const formatMode = (mode: DeliveryMode): string => (typeof mode === 'string' ? mode : `${mode.events}->${mode.notes}`); - -// Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; -// unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's -// `resolveTaggingSecretStrategy` hook selects. This harness exercises (strategy, mode) cells end to end across two -// PXEs that share only a node: the sender PXE sends, the recipient PXE discovers purely from onchain logs plus the -// HandshakeRegistry. -// -// Cross-PXE is the meaningful setup: the recipient PXE holds no sender state, so a cell only "discovers" a message if -// the source truly reached it. -function buildMessageDeliveryTest(opts: { - // Names the tagging-secret source, e.g. 'handshake' or 'arbitrary secret'. The describe title is derived as - // `${strategy} x ${mode}`. - strategy: string; - mode: DeliveryMode; - // Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests). - senderHook: SenderHook; - // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. - recipientRegistration?: ( - recipient: TestWallet, - recipientAddress: AztecAddress, - sender: AztecAddress, - ) => Promise; -}) { - const { strategy, mode, senderHook, recipientRegistration } = opts; - const description = `${strategy} x ${formatMode(mode)}`; - - describe(description, () => { - jest.setTimeout(300_000); - - const eventValues = [10n, 20n, 30n]; - const noteValues = [40n, 50n, 60n]; - - let aztecNode: AztecNode & AztecNodeDebug; - let walletSender: TestWallet; - let walletRecipient: TestWallet; - let sender: AztecAddress; - let recipient: AztecAddress; - let contractSender: OnchainDeliveryTestContract; - let teardownSender: () => Promise; - let teardownRecipient: () => Promise; - // Discovery results captured in beforeAll, so the assertions in the actual tests below stay pure. - // A failure during delivery fails beforeAll loudly instead of being swallowed as an expected red. - let discoveredEvents: FieldLike[]; - let readNotes: bigint[]; - - const { events: eventMode, notes: noteMode } = typeof mode === 'string' ? { events: mode, notes: mode } : mode; - const sendEvent = (value: bigint) => - eventMode === 'constrained' - ? contractSender.methods.emit_event(recipient, value) - : contractSender.methods.emit_event_unconstrained(recipient, value); - const sendNote = (value: bigint) => - noteMode === 'constrained' - ? contractSender.methods.emit_note(recipient, value) - : contractSender.methods.emit_note_unconstrained(recipient, value); - - beforeAll(async () => { - // The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded - // at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state - // from other cells. - let additionallyFundedAccounts: InitialAccountData[]; - ({ - aztecNode, - additionallyFundedAccounts, - wallet: walletSender, - accounts: [sender], - teardown: teardownSender, - } = await setup(1, { - ...AUTOMINE_E2E_OPTS, - additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), - pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } }, - })); - - ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( - aztecNode, - aztecNode, - {}, - undefined, - 'pxe-recipient', - )); - const recipientAccount = await walletRecipient.createSchnorrAccount( - additionallyFundedAccounts[0].secret, - additionallyFundedAccounts[0].salt, - ); - await (await recipientAccount.getDeployMethod()).send({ from: NO_FROM }); - recipient = recipientAccount.address; - - await ensureHandshakeRegistryPublished(walletSender, sender); - const { contract: deployed, instance } = await OnchainDeliveryTestContract.deploy(walletSender).send({ - from: sender, - }); - contractSender = deployed; - - await ensureHandshakeRegistryPublished(walletRecipient, recipient); - await walletRecipient.registerContract(instance, OnchainDeliveryTestContract.artifact); - - await recipientRegistration?.(walletRecipient, recipient, sender); - - // Constrained sends to one pair are strictly ordered, so deliver one tx at a time. The first send bootstraps the - // handshake (when the source is a handshake); the rest reuse it. - const blockNumbers: number[] = []; - for (const value of eventValues) { - const { receipt } = await sendEvent(value).send({ from: sender }); - blockNumbers.push(receipt.blockNumber!); - } - for (const value of noteValues) { - await sendNote(value).send({ from: sender }); - } - - await walletRecipient.sync(); - - const events = await walletRecipient.getPrivateEvents( - OnchainDeliveryTestContract.events.DeliveryEvent, - { - contractAddress: contractSender.address, - fromBlock: BlockNumber(Math.min(...blockNumbers)), - toBlock: BlockNumber(Math.max(...blockNumbers) + 1), - scopes: [recipient], - }, - ); - discoveredEvents = events.map(e => e.event.value); - - const contractRecipient = OnchainDeliveryTestContract.at(contractSender.address, walletRecipient); - const { result } = await contractRecipient.methods.get_note_values(recipient).simulate({ from: recipient }); - readNotes = result.storage.slice(0, Number(result.len)); - }); - - afterAll(async () => { - await teardownRecipient(); - await teardownSender(); - }); - - it('the recipient PXE discovers the events delivered by the sender PXE', () => { - expect(discoveredEvents.length).toBe(eventValues.length); - for (const value of eventValues) { - expect(discoveredEvents).toContainEqual(value); - } - }); - - it('the recipient PXE reads back the notes delivered by the sender PXE', () => { - expect(readNotes).toEqual(noteValues); - }); - }); -} - -describe('automine/delivery/onchain', () => { +describe('onchain delivery', () => { // constrained always goes through a handshake. Stated explicitly rather than relying on the PXE default. buildMessageDeliveryTest({ - strategy: 'handshake', + strategy: 'non-interactive handshake', mode: 'constrained', senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), }); @@ -182,21 +13,23 @@ describe('automine/delivery/onchain', () => { // unconstrained delivery whose source the wallet pins to a non-interactive handshake. The first send // bootstraps the handshake; the recipient PXE discovers it via the registry and reads the unconstrained logs. buildMessageDeliveryTest({ - strategy: 'handshake', + strategy: 'non-interactive handshake', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), }); - // unconstrained delivery tagged with a raw secret the two parties share out of band. The sender's hook and - // the recipient registration use the same point, generated once in `recipientRegistration` (which runs before any - // send fires the hook). + // unconstrained delivery tagged with a raw secret the two parties share out of band. Generated once in a + // beforeAll that runs before any send can fire the sender hook, so both the hook and the recipient registration + // read the same point instead of one of them computing it as a side effect of the other. let arbitrarySecret: Point; + beforeAll(async () => { + arbitrarySecret = await Point.random(); + }); buildMessageDeliveryTest({ strategy: 'arbitrary secret', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: arbitrarySecret }), recipientRegistration: async (recipientWallet, recipientAddress) => { - arbitrarySecret = await Point.random(); await recipientWallet.registerTaggingSecretSource({ kind: 'arbitrary-secret', recipient: recipientAddress, @@ -215,44 +48,4 @@ describe('automine/delivery/onchain', () => { await recipientWallet.registerSender(senderAddress); }, }); - - // one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes - // reuse it. Reuse bypasses the wallet hook entirely (an existing registry handshake is resolved before the hook is - // consulted), so the hook returns a handshake for the bootstrapping constrained send but throws if it is ever - // consulted for the unconstrained send. That makes discovery a durable proof of mode-agnostic reuse: were reuse to - // regress, the unconstrained note would fall through to the hook and fail loudly instead of being silently - // re-discovered some other way. - buildMessageDeliveryTest({ - strategy: 'handshake', - mode: { events: 'constrained', notes: 'unconstrained' }, - senderHook: ({ deliveryMode }) => { - if (deliveryMode !== AppTaggingSecretKind.CONSTRAINED) { - throw new Error( - 'cross-mode reuse regressed: the unconstrained send consulted the strategy hook instead of reusing the bootstrapped handshake', - ); - } - return Promise.resolve({ type: 'non-interactive-handshake' }); - }, - }); - - // the stricter cross-mode direction. The unconstrained events bootstrap the handshake; the constrained - // notes reuse it. The tripwire makes reuse a hard guarantee: a constrained send that consulted the hook would throw, - // and the only way to skip the hook is resolving an existing handshake, so a green means the notes reused the - // bootstrapped handshake. It also pins the constrained sequence to a fresh index 0: index 0 validates against the - // registry, higher indices assert a predecessor nullifier, so a sender index leaked from the unconstrained counter - // would make the first note demand a predecessor that was never emitted and fail the actual send. - // The index is never read here; the circuit rejecting a wrong one is the signal. The forward cell can't catch - // this because its reusing side is unconstrained, where the index only feeds the tag and the scan tolerates gaps. - buildMessageDeliveryTest({ - strategy: 'handshake', - mode: { events: 'unconstrained', notes: 'constrained' }, - senderHook: ({ deliveryMode }) => { - if (deliveryMode !== AppTaggingSecretKind.UNCONSTRAINED) { - throw new Error( - 'cross-mode reuse regressed: the constrained send consulted the strategy hook instead of reusing the bootstrapped handshake', - ); - } - return Promise.resolve({ type: 'non-interactive-handshake' }); - }, - }); }); diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts new file mode 100644 index 000000000000..a8d01fea2d9e --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts @@ -0,0 +1,170 @@ +import { type InitialAccountData, generateSchnorrAccounts } from '@aztec/accounts/testing'; +import type { FieldLike } from '@aztec/aztec.js/abi'; +import { NO_FROM } from '@aztec/aztec.js/account'; +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import { BlockNumber } from '@aztec/foundation/branded-types'; +import { type DeliveryEvent, OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; +import type { PXECreationOptions } from '@aztec/pxe/server'; +import type { AztecNodeDebug } from '@aztec/stdlib/interfaces/client'; + +import { jest } from '@jest/globals'; + +import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup, setupPXEAndGetWallet } from '../../fixtures/setup.js'; +import { TestWallet } from '../../test-wallet/test_wallet.js'; + +// The wallet hook that selects a message's tagging-secret source. Derived from the exported PXE options +// rather than importing the hook type, which `@aztec/pxe/server` does not re-export. +export type SenderHook = NonNullable['resolveTaggingSecretStrategy']>; + +export type Mode = 'constrained' | 'unconstrained'; + +// A single mode applies to both the event and note sends; `{ events, notes }` sends each in its own mode, which +// exercises cross-mode handshake reuse: bootstrap in one mode, deliver in the other on the same handshake. +export type DeliveryMode = Mode | { events: Mode; notes: Mode }; + +const formatMode = (mode: DeliveryMode): string => (typeof mode === 'string' ? mode : `${mode.events}->${mode.notes}`); + +// Onchain private delivery has two orthogonal axes: the delivery MODE (constrained = nullifier-chained sequence; +// unconstrained = no nullifier, windowed scan) and the tagging-secret SOURCE, which the wallet's +// `resolveTaggingSecretStrategy` hook selects. This harness exercises (strategy, mode) cells end to end across two +// PXEs that share only a node: the sender PXE sends, the recipient PXE discovers purely from onchain logs plus the +// HandshakeRegistry. +// +// Cross-PXE is the meaningful setup: the recipient PXE holds no sender state, so a cell only "discovers" a message if +// the source truly reached it. +export function buildMessageDeliveryTest(opts: { + // Names the tagging-secret source, e.g. 'non-interactive handshake' or 'arbitrary secret'. The describe title is + // derived as `${strategy} x ${mode}`. + strategy: string; + mode: DeliveryMode; + // Required: every cell states its source explicitly rather than leaning on the PXE default (covered by unit tests). + senderHook: SenderHook; + // Recipient-side setup the source requires (e.g. registering a raw arbitrary secret); runs once after deployment. + recipientRegistration?: ( + recipient: TestWallet, + recipientAddress: AztecAddress, + sender: AztecAddress, + ) => Promise; +}) { + const { strategy, mode, senderHook, recipientRegistration } = opts; + const description = `${strategy} x ${formatMode(mode)}`; + + describe(description, () => { + jest.setTimeout(300_000); + + const eventValues = [10n, 20n, 30n]; + const noteValues = [40n, 50n, 60n]; + + let aztecNode: AztecNode & AztecNodeDebug; + let walletSender: TestWallet; + let walletRecipient: TestWallet; + let sender: AztecAddress; + let recipient: AztecAddress; + let contractSender: OnchainDeliveryTestContract; + let teardownSender: () => Promise; + let teardownRecipient: () => Promise; + // Discovery results captured in beforeAll, so the assertions in the actual tests below stay pure. + // A failure during delivery fails beforeAll loudly instead of being swallowed as an expected red. + let discoveredEvents: FieldLike[]; + let readNotes: bigint[]; + + const { events: eventMode, notes: noteMode } = typeof mode === 'string' ? { events: mode, notes: mode } : mode; + const sendEvent = (value: bigint) => + eventMode === 'constrained' + ? contractSender.methods.emit_event(recipient, value) + : contractSender.methods.emit_event_unconstrained(recipient, value); + const sendNote = (value: bigint) => + noteMode === 'constrained' + ? contractSender.methods.emit_note(recipient, value) + : contractSender.methods.emit_note_unconstrained(recipient, value); + + beforeAll(async () => { + // The sender PXE holds the sender and carries this cell's tagging-secret-strategy hook. The recipient is funded + // at genesis here but created and deployed on the isolated recipient PXE below, so it carries no sender state + // from other cells. + let additionallyFundedAccounts: InitialAccountData[]; + ({ + aztecNode, + additionallyFundedAccounts, + wallet: walletSender, + accounts: [sender], + teardown: teardownSender, + } = await setup(1, { + ...AUTOMINE_E2E_OPTS, + additionallyFundedAccounts: await generateSchnorrAccounts(1, 'schnorr'), + pxeCreationOptions: { hooks: { resolveTaggingSecretStrategy: senderHook } }, + })); + + ({ wallet: walletRecipient, teardown: teardownRecipient } = await setupPXEAndGetWallet( + aztecNode, + aztecNode, + {}, + undefined, + 'pxe-recipient', + )); + const recipientAccount = await walletRecipient.createSchnorrAccount( + additionallyFundedAccounts[0].secret, + additionallyFundedAccounts[0].salt, + ); + await (await recipientAccount.getDeployMethod()).send({ from: NO_FROM }); + recipient = recipientAccount.address; + + await ensureHandshakeRegistryPublished(walletSender, sender); + const { contract: deployed, instance } = await OnchainDeliveryTestContract.deploy(walletSender).send({ + from: sender, + }); + contractSender = deployed; + + await ensureHandshakeRegistryPublished(walletRecipient, recipient); + await walletRecipient.registerContract(instance, OnchainDeliveryTestContract.artifact); + + await recipientRegistration?.(walletRecipient, recipient, sender); + + // Constrained sends to one pair are strictly ordered, so deliver one tx at a time. The first send bootstraps the + // handshake (when the source is a handshake); the rest reuse it. + const blockNumbers: number[] = []; + for (const value of eventValues) { + const { receipt } = await sendEvent(value).send({ from: sender }); + blockNumbers.push(receipt.blockNumber!); + } + for (const value of noteValues) { + await sendNote(value).send({ from: sender }); + } + + await walletRecipient.sync(); + + const events = await walletRecipient.getPrivateEvents( + OnchainDeliveryTestContract.events.DeliveryEvent, + { + contractAddress: contractSender.address, + fromBlock: BlockNumber(Math.min(...blockNumbers)), + toBlock: BlockNumber(Math.max(...blockNumbers) + 1), + scopes: [recipient], + }, + ); + discoveredEvents = events.map(e => e.event.value); + + const contractRecipient = OnchainDeliveryTestContract.at(contractSender.address, walletRecipient); + const { result } = await contractRecipient.methods.get_note_values(recipient).simulate({ from: recipient }); + readNotes = result.storage.slice(0, Number(result.len)); + }); + + afterAll(async () => { + await teardownRecipient(); + await teardownSender(); + }); + + it('the recipient PXE discovers the events delivered by the sender PXE', () => { + expect(discoveredEvents.length).toBe(eventValues.length); + for (const value of eventValues) { + expect(discoveredEvents).toContainEqual(value); + } + }); + + it('the recipient PXE reads back the notes delivered by the sender PXE', () => { + expect(readNotes).toEqual(noteValues); + }); + }); +} From eea90a9e448bb3a06f0c5538b8bdf36de5029b5d Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Wed, 1 Jul 2026 15:02:40 -0400 Subject: [PATCH 22/40] fix(end-to-end): assert handshake-reuse hook calls instead of throwing Replace the throw-inside-the-sender-hook tripwire in handshake_reuse.test.ts with a recorder: the hook now records each call's deliveryMode, and a sibling assertion checks it fired exactly once with the bootstrapping mode. buildMessageDeliveryTest gains an optional additionalTests callback so the assertion runs inside the same describe/beforeAll as the recorder, rather than depending on Jest's cross-describe sibling-ordering. --- .../automine/delivery/handshake_reuse.test.ts | 54 ++++++++++--------- .../delivery/onchain_delivery_harness.ts | 8 ++- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts index e33fe755b8ef..657cb48371e6 100644 --- a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -2,47 +2,49 @@ import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; -// Regression coverage for mode-agnostic handshake reuse, split out from `onchain.test.ts`. That file only pins that +// Regression coverage for mode-agnostic handshake reuse. That file only pins that // each (strategy, mode) cell delivers; this file pins the stronger claim that a handshake bootstrapped under one // delivery mode is reused, not re-bootstrapped, when the other mode sends next. -describe('automine/delivery/handshake_reuse', () => { - // one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes - // reuse it. Reuse bypasses the wallet hook entirely (an existing registry handshake is resolved before the hook is - // consulted), so the hook returns a handshake for the bootstrapping constrained send but throws if it is ever - // consulted for the unconstrained send. That makes discovery a durable proof of mode-agnostic reuse: were reuse to - // regress, the unconstrained note would fall through to the hook and fail loudly instead of being silently - // re-discovered some other way. +describe('handshake_reuse', () => { + // one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes reuse + // it. Reuse resolves an existing registry handshake before the wallet's strategy hook is ever consulted (see + // `tag.nr`'s `reuses_an_existing_handshake_secret`), so the hook should fire exactly once: for the first + // (constrained) send that bootstraps the handshake. The hook records every call it receives, so the assertion below + // pins that count and mode directly instead of tripwiring on an unexpected one. + const forwardHookCalls: AppTaggingSecretKind[] = []; buildMessageDeliveryTest({ strategy: 'non-interactive handshake', mode: { events: 'constrained', notes: 'unconstrained' }, senderHook: ({ deliveryMode }) => { - if (deliveryMode !== AppTaggingSecretKind.CONSTRAINED) { - throw new Error( - 'cross-mode reuse regressed: the unconstrained send consulted the strategy hook instead of reusing the bootstrapped handshake', - ); - } + forwardHookCalls.push(deliveryMode); return Promise.resolve({ type: 'non-interactive-handshake' }); }, + additionalTests: () => { + it('the strategy hook fires exactly once, to bootstrap the handshake on the first (constrained) send', () => { + expect(forwardHookCalls).toHaveLength(1); + expect(forwardHookCalls[0]).toBe(AppTaggingSecretKind.CONSTRAINED); + }); + }, }); - // the stricter cross-mode direction. The unconstrained events bootstrap the handshake; the constrained - // notes reuse it. The tripwire makes reuse a hard guarantee: a constrained send that consulted the hook would throw, - // and the only way to skip the hook is resolving an existing handshake, so a green means the notes reused the - // bootstrapped handshake. It also pins the constrained sequence to a fresh index 0: index 0 validates against the - // registry, higher indices assert a predecessor nullifier, so a sender index leaked from the unconstrained counter - // would make the first note demand a predecessor that was never emitted and fail the actual send. - // The index is never read here; the circuit rejecting a wrong one is the signal. The forward cell can't catch - // this because its reusing side is unconstrained, where the index only feeds the tag and the scan tolerates gaps. + // the stricter cross-mode direction: the unconstrained events bootstrap the handshake; the constrained notes reuse + // it. This also pins the constrained sequence to a fresh index 0: index 0 validates against the registry, higher + // indices assert a predecessor nullifier, so a sender index leaked from the unconstrained counter would make the + // first note demand a predecessor that was never emitted and fail the actual send during delivery, before the + // hook-call assertion below ever runs. + const reverseHookCalls: AppTaggingSecretKind[] = []; buildMessageDeliveryTest({ strategy: 'non-interactive handshake', mode: { events: 'unconstrained', notes: 'constrained' }, senderHook: ({ deliveryMode }) => { - if (deliveryMode !== AppTaggingSecretKind.UNCONSTRAINED) { - throw new Error( - 'cross-mode reuse regressed: the constrained send consulted the strategy hook instead of reusing the bootstrapped handshake', - ); - } + reverseHookCalls.push(deliveryMode); return Promise.resolve({ type: 'non-interactive-handshake' }); }, + additionalTests: () => { + it('the strategy hook fires exactly once, to bootstrap the handshake on the first (unconstrained) send', () => { + expect(reverseHookCalls).toHaveLength(1); + expect(reverseHookCalls[0]).toBe(AppTaggingSecretKind.UNCONSTRAINED); + }); + }, }); }); diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts index a8d01fea2d9e..8cc7d62c22ea 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts @@ -47,8 +47,12 @@ export function buildMessageDeliveryTest(opts: { recipientAddress: AztecAddress, sender: AztecAddress, ) => Promise; + // Extra `it()`s to register in this cell's suite, e.g. assertions against state a custom `senderHook` recorded. + // Called inside the same `describe`, after the two baseline assertions below, so it shares their `beforeAll` + // instead of depending on Jest's cross-`describe` execution order. + additionalTests?: () => void; }) { - const { strategy, mode, senderHook, recipientRegistration } = opts; + const { strategy, mode, senderHook, recipientRegistration, additionalTests } = opts; const description = `${strategy} x ${formatMode(mode)}`; describe(description, () => { @@ -166,5 +170,7 @@ export function buildMessageDeliveryTest(opts: { it('the recipient PXE reads back the notes delivered by the sender PXE', () => { expect(readNotes).toEqual(noteValues); }); + + additionalTests?.(); }); } From b547463eb9c9864920cba10da73d15f8244ced55 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Wed, 1 Jul 2026 15:05:23 -0400 Subject: [PATCH 23/40] fix(pxe): update log_service.test.ts to renamed AppTaggingSecret.computeDirectional Rebasing onto merge-train/fairies-v5 picked up #24402, which renamed AppTaggingSecret.compute to computeDirectional; this call site predates that rename on this branch and needed updating to match. --- yarn-project/pxe/src/logs/log_service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index df5a437074f3..5ddd75ae9708 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -315,7 +315,7 @@ describe('LogService', () => { // scanned, which only happens when the sender is registered. const recipientIvsk = await keyStore.getMasterIncomingViewingSecretKey(recipient); const sharedSecret = await computeSharedTaggingSecret(recipientCompleteAddress, recipientIvsk, sender); - const appSecret = await AppTaggingSecret.compute(sharedSecret!, contractAddress, recipient); + const appSecret = await AppTaggingSecret.computeDirectional(sharedSecret!, contractAddress, recipient); senderIndex0Tag = await SiloedTag.compute({ extendedSecret: appSecret, index: 0 }); // Past the anchor block, so the log is unfinalized and the scan completes in a single round. From 09392c9e647ec0604c7b0c7d1a50111ec11d543f Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 2 Jul 2026 13:23:32 -0400 Subject: [PATCH 24/40] fix(ci): wait for dockerd to finish removing a stale container before reusing its name (#24469) ## Summary Under host load, dockerd can leave a container Dead/mid-removal for several seconds after `docker rm -f` returns in `ci3/docker_isolate`. Reusing the name before removal actually completes races into `Conflict, name already in use`, and its sibling `can not get logs from container which is dead or marked for removal`. Poll `docker container inspect` until the name is actually free (bounded at 30s), and fail loudly if it never clears. ## Context Seen fleet-wide across unrelated `automine/*` tests, not specific to this branch's new tests. Reviewed with Codex, iterated on quoting + `docker container inspect` vs `docker inspect`. --- ci3/docker_isolate | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ci3/docker_isolate b/ci3/docker_isolate index 5e6724c7bb8c..868ea59c8e9b 100755 --- a/ci3/docker_isolate +++ b/ci3/docker_isolate @@ -23,7 +23,18 @@ if [ -n "${NAME:-}" ]; then name="$(echo "${NAME}" | sed 's/^[^a-zA-Z0-9]*//' | tr '/' '_')${NAME_POSTFIX:-}" name_arg="--name $name" # Kill any existing container with the same name. - docker rm -f $name &>/dev/null || true + docker rm -f "$name" &>/dev/null || true + # Under host load, dockerd can leave a container "Dead" or mid-removal for several + # seconds after `rm -f` returns, so wait for it to actually disappear before reusing + # the name below. Otherwise `docker run --name` races into "Conflict, name already in use". + for _ in $(seq 1 30); do + docker container inspect "$name" &>/dev/null || break + sleep 1 + done + if docker container inspect "$name" &>/dev/null; then + echo "docker_isolate: dockerd failed to remove stale container $name within 30s" >&2 + exit 1 + fi fi # For pinning to given CPUs. From 64e43e49b53d208727688ceb4ac4c77810849ac5 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 2 Jul 2026 13:35:15 -0400 Subject: [PATCH 25/40] empty From 23ed7487ff586007a005cffc0734eccfe1fdf092 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 2 Jul 2026 13:41:08 -0400 Subject: [PATCH 26/40] . From 58613559d8892b133dc786649a34e42f4f9bb824 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 2 Jul 2026 13:41:40 -0400 Subject: [PATCH 27/40] . --- .../end-to-end/src/automine/delivery/handshake_reuse.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts index 657cb48371e6..291c3dfcbd07 100644 --- a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -20,7 +20,7 @@ describe('handshake_reuse', () => { return Promise.resolve({ type: 'non-interactive-handshake' }); }, additionalTests: () => { - it('the strategy hook fires exactly once, to bootstrap the handshake on the first (constrained) send', () => { + it('the strategy hook fires exactly once, to bootstrap the handshake on the first constrained send', () => { expect(forwardHookCalls).toHaveLength(1); expect(forwardHookCalls[0]).toBe(AppTaggingSecretKind.CONSTRAINED); }); @@ -41,7 +41,7 @@ describe('handshake_reuse', () => { return Promise.resolve({ type: 'non-interactive-handshake' }); }, additionalTests: () => { - it('the strategy hook fires exactly once, to bootstrap the handshake on the first (unconstrained) send', () => { + it('the strategy hook fires exactly once, to bootstrap the handshake on the first unconstrained send', () => { expect(reverseHookCalls).toHaveLength(1); expect(reverseHookCalls[0]).toBe(AppTaggingSecretKind.UNCONSTRAINED); }); From 0184e786a78ad8647bd12b31ba0e01978141e075 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Thu, 2 Jul 2026 17:24:44 -0400 Subject: [PATCH 28/40] fix(pxe): pass derived account privacy keys to addAccount in log_service test Semantic conflict with #24451 from the fairies-v5 merge: addAccount now takes AccountPrivacyKeys instead of a secret-key Fr. --- yarn-project/pxe/src/logs/log_service.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index badfbfc611ab..79249a0a45f3 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -8,6 +8,7 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { L2TipsProvider } from '@aztec/stdlib/block'; import type { CompleteAddress } from '@aztec/stdlib/contract'; import type { AztecNode } from '@aztec/stdlib/interfaces/server'; +import { deriveKeys } from '@aztec/stdlib/keys'; import { AppTaggingSecret, type LogResult, SiloedTag, Tag, computeSharedTaggingSecret } from '@aztec/stdlib/logs'; import { makeBlockHeader, makeL2Tips, randomPrivateLogResult } from '@aztec/stdlib/testing'; @@ -320,7 +321,7 @@ describe('LogService', () => { l2TipsProvider.getL2Tips.mockResolvedValue(makeL2Tips(testContext.anchorBlockHeader.globalVariables.blockNumber)); // A real recipient account, so the ECDH tag derivation has the keys and address preimage it needs. - recipientCompleteAddress = await keyStore.addAccount(new Fr(1), Fr.random()); + recipientCompleteAddress = await keyStore.addAccount(await deriveKeys(new Fr(1)), Fr.random()); recipient = recipientCompleteAddress.address; await addressStore.addCompleteAddress(recipientCompleteAddress); From 59e55dd099785be1db58718dd92b6a076e0e7db5 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Thu, 2 Jul 2026 21:19:20 +0000 Subject: [PATCH 29/40] fix(wallet-sdk): use derived contract address in registerContract ContractInstancePreimage no longer carries an address field; derive it from the address returned by pxe.registerContract instead of reading the removed instance.address. Fixes the tsgo TS2339 build failure on merge-train/fairies-v5. --- yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index 004232d4bbf1..932d602c0301 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -358,7 +358,7 @@ export abstract class BaseWallet implements Wallet { if (artifact) { await this.pxe.registerContractClass(artifact); } - await this.pxe.registerContract(instance); + const contractAddress = await this.pxe.registerContract(instance); if (secretKeyOrKeys) { // PXE never receives the account seed (from which the message-signing/fallback secret keys could be re-derived): @@ -372,9 +372,9 @@ export abstract class BaseWallet implements Wallet { ? await deriveKeys(secretKeyOrKeys) : await deriveKeysFromMasterSecretKeys(secretKeyOrKeys); const { address } = await this.pxe.registerAccount(derivedKeys, await computePartialAddress(instance)); - if (!address.equals(instance.address)) { + if (!address.equals(contractAddress)) { throw new Error( - `Registered account address ${address.toString()} does not match contract instance address ${instance.address.toString()}: the provided keys do not correspond to this account.`, + `Registered account address ${address.toString()} does not match contract instance address ${contractAddress.toString()}: the provided keys do not correspond to this account.`, ); } } From c524be78d5536eb7c76f8b39e422594ece2c8302 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 6 Jul 2026 17:26:48 +0000 Subject: [PATCH 30/40] fix(e2e): stop emitting every nested automine test cmd twice The tests array listed the automine subdirectories explicitly and also kept the src/automine/!(simulation)/**/*.test.ts catch-all (a merge artifact), so every nested automine test was emitted as two identical cmds. Both copies run under the same docker container name; when they overlap, one docker_isolate force-removes the other's live container, failing that test, and the fresh-named retry then passes. That is the mass automine FLAKED pattern (49 per run) on this stack. The deduplicated unique cmd set is unchanged (145 automine jobs). --- yarn-project/end-to-end/bootstrap.sh | 7 ------- 1 file changed, 7 deletions(-) diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 1e0b5149d626..8ea2bb11b9c7 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -53,13 +53,6 @@ function test_cmds { local tests=( # List all standalone and nested tests, except for the ones listed above. src/automine/*.test.ts - src/automine/contracts/*.test.ts - src/automine/contracts/deploy/*.test.ts - src/automine/contracts/nested/*.test.ts - src/automine/token/*.test.ts - src/automine/accounts/*.test.ts - src/automine/effects/*.test.ts - src/automine/delivery/*.test.ts src/automine/!(simulation)/**/*.test.ts src/automine/simulation/!(avm_simulator).test.ts src/single-node/!(prover)/**/*.test.ts From 007c178d4eb7a37ae02e9dc324ea496a0a906c76 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 6 Jul 2026 18:02:32 +0000 Subject: [PATCH 31/40] revert(ci): drop docker_isolate polling loop; dedup makes it unnecessary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polling loop from #24469 was guarding against dockerd's "container name still bound after inspect returns not-found" race under host load. In practice the exit condition (`docker container inspect` returns not-found) is a strictly weaker invariant than the one docker run --name actually needs, so the poll broke out fast and `docker run` still hit Conflict. The observed 'Conflict, name already in use' failures on this stack turned out to be a duplicate-emission bug in `test_cmds` (fixed on this PR), which produced two sibling docker_isolate invocations racing on the same name. With the tests-array dedup in place, no two invocations claim the same name in a run, so the polling has nothing left to guard. Left the pre-existing `docker rm -f "$name"` line intact — cheap, useful for the ordinary 'leftover container from an earlier phase' case. Added a note in end-to-end/bootstrap.sh next to the tests array so a future reader who accidentally reintroduces a duplicate path sees the symptom-to-cause mapping without having to re-derive it. --- ci3/docker_isolate | 11 ----------- yarn-project/end-to-end/bootstrap.sh | 3 +++ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/ci3/docker_isolate b/ci3/docker_isolate index 868ea59c8e9b..8a13d4d0b59f 100755 --- a/ci3/docker_isolate +++ b/ci3/docker_isolate @@ -24,17 +24,6 @@ if [ -n "${NAME:-}" ]; then name_arg="--name $name" # Kill any existing container with the same name. docker rm -f "$name" &>/dev/null || true - # Under host load, dockerd can leave a container "Dead" or mid-removal for several - # seconds after `rm -f` returns, so wait for it to actually disappear before reusing - # the name below. Otherwise `docker run --name` races into "Conflict, name already in use". - for _ in $(seq 1 30); do - docker container inspect "$name" &>/dev/null || break - sleep 1 - done - if docker container inspect "$name" &>/dev/null; then - echo "docker_isolate: dockerd failed to remove stale container $name within 30s" >&2 - exit 1 - fi fi # For pinning to given CPUs. diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 8ea2bb11b9c7..829d6aac14af 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -52,6 +52,9 @@ function test_cmds { local tests=( # List all standalone and nested tests, except for the ones listed above. + # Keep these globs non-overlapping: docker_isolate derives the container name from the test path, so a + # duplicated path emits two sibling invocations racing on the same name — you'll see docker "Conflict, + # name already in use" errors, or one copy's docker rm -f killing the other's live container mid-run. src/automine/*.test.ts src/automine/!(simulation)/**/*.test.ts src/automine/simulation/!(avm_simulator).test.ts From 65c37db8c0da1e45c9e17f93509b84e5f6a00816 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 14:04:50 -0400 Subject: [PATCH 32/40] Apply suggestion from @vezenovm --- .../end-to-end/src/automine/delivery/constrained.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index 8a77ddb51f3c..f70dc1e66085 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -11,9 +11,7 @@ import { jest } from '@jest/globals'; import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; import { ensureHandshakeRegistryPublished, setup } from '../../fixtures/setup.js'; -// Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts`: -// the strict ordering of a constrained sequence (handshake reuse, concurrency, batching) and the soundness boundary -// that rejects an arbitrary secret backing constrained delivery. +// Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts` describe('automine/delivery/constrained', () => { jest.setTimeout(300_000); From 14a6b7348187687e1e078c59e8b7e43b7ab01ce3 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 14:11:21 -0400 Subject: [PATCH 33/40] Apply suggestions from code review Co-authored-by: Maxim Vezenov --- .../end-to-end/src/automine/delivery/constrained.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index f70dc1e66085..55b2f44098f1 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -12,7 +12,7 @@ import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; import { ensureHandshakeRegistryPublished, setup } from '../../fixtures/setup.js'; // Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts` -describe('automine/delivery/constrained', () => { +describe('delivery/constrained', () => { jest.setTimeout(300_000); let teardown: () => Promise; @@ -154,12 +154,12 @@ describe('automine/delivery/constrained', () => { // This test builds its own PXE via setup() rather than reusing the wallet from the describe block above, because // it needs a resolveTaggingSecretStrategy hook that only exists as a PXE-creation-time option. It must stay a -// top-level describe, not merged (nested describe, or plain `it`) into 'automine/delivery/constrained' above: that +// top-level describe, not merged (nested describe, or plain `it`) into 'delivery/constrained' above: that // describe's own afterAll teardown only fires once every test inside it has finished, so nesting this one in there // would leave that describe's sandbox (and its anvil) alive while this test starts a second one. The second anvil // then fails to bind to the same port, and the wrapper script that launches it never surfaces that failure, so the // test hangs for the full jest timeout instead of failing fast. -describe('automine/delivery/constrained: rejects unsound sources', () => { +describe('delivery/constrained: rejects unsound sources', () => { jest.setTimeout(300_000); // An unconstrained (arbitrary) secret cannot back constrained delivery, so the circuit rejects the send. This pins From 5b73de66d3b6a603122e6f669e542097883db83e Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 17:10:25 -0400 Subject: [PATCH 34/40] Update yarn-project/end-to-end/src/automine/delivery/constrained.test.ts Co-authored-by: Nicolas Chamo --- .../end-to-end/src/automine/delivery/constrained.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index 55b2f44098f1..f6234cebce94 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -162,9 +162,6 @@ describe('delivery/constrained', () => { describe('delivery/constrained: rejects unsound sources', () => { jest.setTimeout(300_000); - // An unconstrained (arbitrary) secret cannot back constrained delivery, so the circuit rejects the send. This pins - // the PXE -> circuit soundness boundary that the PXE deliberately delegates to the circuit. The secret value is - // irrelevant to the rejection, so a fresh point per hook call is fine and no recipient registration is needed. it('rejects a constrained send backed by an arbitrary secret', async () => { const { teardown, From a67c64f6db0ae8bcd9c0be51a673abb1fb9dc912 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 17:10:47 -0400 Subject: [PATCH 35/40] Update yarn-project/end-to-end/src/automine/delivery/constrained.test.ts Co-authored-by: Nicolas Chamo --- .../end-to-end/src/automine/delivery/constrained.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts index f6234cebce94..d3c1b1bef8ce 100644 --- a/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/constrained.test.ts @@ -153,12 +153,7 @@ describe('delivery/constrained', () => { }); // This test builds its own PXE via setup() rather than reusing the wallet from the describe block above, because -// it needs a resolveTaggingSecretStrategy hook that only exists as a PXE-creation-time option. It must stay a -// top-level describe, not merged (nested describe, or plain `it`) into 'delivery/constrained' above: that -// describe's own afterAll teardown only fires once every test inside it has finished, so nesting this one in there -// would leave that describe's sandbox (and its anvil) alive while this test starts a second one. The second anvil -// then fails to bind to the same port, and the wrapper script that launches it never surfaces that failure, so the -// test hangs for the full jest timeout instead of failing fast. +// it needs a resolveTaggingSecretStrategy hook that only exists as a PXE-creation-time option. describe('delivery/constrained: rejects unsound sources', () => { jest.setTimeout(300_000); From 77acbbbeb87b098f5b881c42f4821dfb971b5039 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 17:11:04 -0400 Subject: [PATCH 36/40] Update yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts Co-authored-by: Nicolas Chamo --- .../end-to-end/src/automine/delivery/handshake_reuse.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts index 291c3dfcbd07..c5605df5935b 100644 --- a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -6,11 +6,6 @@ import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; // each (strategy, mode) cell delivers; this file pins the stronger claim that a handshake bootstrapped under one // delivery mode is reused, not re-bootstrapped, when the other mode sends next. describe('handshake_reuse', () => { - // one handshake serves both modes. The constrained events bootstrap the handshake; the unconstrained notes reuse - // it. Reuse resolves an existing registry handshake before the wallet's strategy hook is ever consulted (see - // `tag.nr`'s `reuses_an_existing_handshake_secret`), so the hook should fire exactly once: for the first - // (constrained) send that bootstraps the handshake. The hook records every call it receives, so the assertion below - // pins that count and mode directly instead of tripwiring on an unexpected one. const forwardHookCalls: AppTaggingSecretKind[] = []; buildMessageDeliveryTest({ strategy: 'non-interactive handshake', From dd8a1b88484ef00aab102ee5a879870577d62ba6 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 17:11:19 -0400 Subject: [PATCH 37/40] Update yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts Co-authored-by: Nicolas Chamo --- .../src/automine/delivery/handshake_reuse.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts index c5605df5935b..2464c49846a0 100644 --- a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -21,12 +21,6 @@ describe('handshake_reuse', () => { }); }, }); - - // the stricter cross-mode direction: the unconstrained events bootstrap the handshake; the constrained notes reuse - // it. This also pins the constrained sequence to a fresh index 0: index 0 validates against the registry, higher - // indices assert a predecessor nullifier, so a sender index leaked from the unconstrained counter would make the - // first note demand a predecessor that was never emitted and fail the actual send during delivery, before the - // hook-call assertion below ever runs. const reverseHookCalls: AppTaggingSecretKind[] = []; buildMessageDeliveryTest({ strategy: 'non-interactive handshake', From 8609059f159245461a375bf03e169c7939039c83 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 17:19:32 -0400 Subject: [PATCH 38/40] comments cleanup --- .../automine/delivery/handshake_reuse.test.ts | 8 +++++--- .../src/automine/delivery/onchain.test.ts | 18 +++++++----------- yarn-project/pxe/src/logs/log_service.test.ts | 4 +--- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts index 2464c49846a0..165d7056e4ed 100644 --- a/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -2,9 +2,8 @@ import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; -// Regression coverage for mode-agnostic handshake reuse. That file only pins that -// each (strategy, mode) cell delivers; this file pins the stronger claim that a handshake bootstrapped under one -// delivery mode is reused, not re-bootstrapped, when the other mode sends next. +// Pins mode-agnostic handshake reuse: a handshake bootstrapped under one delivery mode must be reused when the other +// mode sends next. describe('handshake_reuse', () => { const forwardHookCalls: AppTaggingSecretKind[] = []; buildMessageDeliveryTest({ @@ -21,6 +20,9 @@ describe('handshake_reuse', () => { }); }, }); + + // Reverse direction also pins the constrained sequence to a fresh index 0 after an unconstrained bootstrap; a leaked + // unconstrained counter would make the first constrained send require a predecessor nullifier that does not exist. const reverseHookCalls: AppTaggingSecretKind[] = []; buildMessageDeliveryTest({ strategy: 'non-interactive handshake', diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts index a5870d712b37..cdab97abcf31 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -3,28 +3,24 @@ import { Point } from '@aztec/foundation/curves/grumpkin'; import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; describe('onchain delivery', () => { - // constrained always goes through a handshake. Stated explicitly rather than relying on the PXE default. + let arbitrarySecret: Point; + + beforeAll(async () => { + arbitrarySecret = await Point.random(); + }); + buildMessageDeliveryTest({ strategy: 'non-interactive handshake', mode: 'constrained', senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), }); - // unconstrained delivery whose source the wallet pins to a non-interactive handshake. The first send - // bootstraps the handshake; the recipient PXE discovers it via the registry and reads the unconstrained logs. buildMessageDeliveryTest({ strategy: 'non-interactive handshake', mode: 'unconstrained', senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), }); - // unconstrained delivery tagged with a raw secret the two parties share out of band. Generated once in a - // beforeAll that runs before any send can fire the sender hook, so both the hook and the recipient registration - // read the same point instead of one of them computing it as a side effect of the other. - let arbitrarySecret: Point; - beforeAll(async () => { - arbitrarySecret = await Point.random(); - }); buildMessageDeliveryTest({ strategy: 'arbitrary secret', mode: 'unconstrained', @@ -38,7 +34,7 @@ describe('onchain delivery', () => { }, }); - // the address-derived source, which is the unconstrained default. With the recipient registering the sender, + // With the recipient registering the sender, // the recipient PXE reconstructs the address-derived tag and discovers the delivery. buildMessageDeliveryTest({ strategy: 'address-derived', diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 817c5cb3f468..daf24a114ee6 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -305,9 +305,7 @@ describe('LogService', () => { }); }); - // Address-derived discovery is gated on registering the sender: the recipient can only reconstruct a sender's tags - // for senders in `getSenders()` (plus its own accounts). This pins that gate through `fetchTaggedLogs` end to end. - describe('address-derived sender gate', () => { + describe('address-derived discovery requires a registered sender', () => { let recipientCompleteAddress: CompleteAddress; let recipient: AztecAddress; let sender: AztecAddress; From 25dff7252b06cda5c4dc845709f276fc33371469 Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 17:43:54 -0400 Subject: [PATCH 39/40] Pass signing --- .../end-to-end/src/automine/delivery/onchain_delivery_harness.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts index 8cc7d62c22ea..d10ae9196aea 100644 --- a/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts +++ b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts @@ -111,6 +111,7 @@ export function buildMessageDeliveryTest(opts: { const recipientAccount = await walletRecipient.createSchnorrAccount( additionallyFundedAccounts[0].secret, additionallyFundedAccounts[0].salt, + additionallyFundedAccounts[0].signingKey, ); await (await recipientAccount.getDeployMethod()).send({ from: NO_FROM }); recipient = recipientAccount.address; From 06fd3cc9131778de7a0ab60ec21e01d53566c38c Mon Sep 17 00:00:00 2001 From: Maxim Vezenov Date: Mon, 6 Jul 2026 19:17:11 -0400 Subject: [PATCH 40/40] Apply suggestion from @vezenovm --- ci3/docker_isolate | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci3/docker_isolate b/ci3/docker_isolate index 8a13d4d0b59f..5e6724c7bb8c 100755 --- a/ci3/docker_isolate +++ b/ci3/docker_isolate @@ -23,7 +23,7 @@ if [ -n "${NAME:-}" ]; then name="$(echo "${NAME}" | sed 's/^[^a-zA-Z0-9]*//' | tr '/' '_')${NAME_POSTFIX:-}" name_arg="--name $name" # Kill any existing container with the same name. - docker rm -f "$name" &>/dev/null || true + docker rm -f $name &>/dev/null || true fi # For pinning to given CPUs.