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 87% 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 6274a64cb05c..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, @@ -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/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/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 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 58% 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 6e2b9803acd2..d3c1b1bef8ce 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 @@ -1,25 +1,18 @@ -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 { 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 { OnchainDeliveryTestContract } from '@aztec/noir-test-contracts.js/OnchainDeliveryTest'; 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'; +import { AUTOMINE_E2E_OPTS } from '../../fixtures/fixtures.js'; +import { ensureHandshakeRegistryPublished, setup } from '../../fixtures/setup.js'; -describe('constrained delivery', () => { +// Delivery-method-specific tests that don't fit the generic (strategy, mode) matrix in `onchain.test.ts` +describe('delivery/constrained', () => { jest.setTimeout(300_000); let teardown: () => Promise; @@ -30,7 +23,7 @@ describe('constrained delivery', () => { let batchRecipient2: AztecAddress; let batchRecipient3: AztecAddress; let batchRecipient4: AztecAddress; - let contract: ConstrainedDeliveryTestContract; + let contract: OnchainDeliveryTestContract; let registry: HandshakeRegistryContract; beforeAll(async () => { @@ -41,7 +34,7 @@ describe('constrained delivery', () => { } = 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); }); @@ -159,109 +152,32 @@ describe('constrained delivery', () => { }); }); -// 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', () => { +// 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. +describe('delivery/constrained: rejects unsound sources', () => { 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, { + it('rejects a constrained send backed by an arbitrary secret', async () => { + const { + teardown, + wallet, + accounts: [sender, recipient], + } = await setup(2, { ...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, - additionallyFundedAccounts[0].signingKey, - ); - 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], + pxeCreationOptions: { + hooks: { + resolveTaggingSecretStrategy: async () => ({ type: 'arbitrary-secret', secret: await Point.random() }), + }, }, - ); - - 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 }); + }); + 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(); } - - 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/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..165d7056e4ed --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/handshake_reuse.test.ts @@ -0,0 +1,41 @@ +import { AppTaggingSecretKind } from '@aztec/stdlib/logs'; + +import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; + +// 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({ + strategy: 'non-interactive handshake', + mode: { events: 'constrained', notes: 'unconstrained' }, + senderHook: ({ deliveryMode }) => { + 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); + }); + }, + }); + + // 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', + mode: { events: 'unconstrained', notes: 'constrained' }, + senderHook: ({ deliveryMode }) => { + 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.test.ts b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts new file mode 100644 index 000000000000..cdab97abcf31 --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/onchain.test.ts @@ -0,0 +1,47 @@ +import { Point } from '@aztec/foundation/curves/grumpkin'; + +import { buildMessageDeliveryTest } from './onchain_delivery_harness.js'; + +describe('onchain delivery', () => { + let arbitrarySecret: Point; + + beforeAll(async () => { + arbitrarySecret = await Point.random(); + }); + + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: 'constrained', + senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), + }); + + buildMessageDeliveryTest({ + strategy: 'non-interactive handshake', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'non-interactive-handshake' }), + }); + + buildMessageDeliveryTest({ + strategy: 'arbitrary secret', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'arbitrary-secret', secret: arbitrarySecret }), + recipientRegistration: async (recipientWallet, recipientAddress) => { + await recipientWallet.registerTaggingSecretSource({ + kind: 'arbitrary-secret', + recipient: recipientAddress, + secret: arbitrarySecret, + }); + }, + }); + + // With the recipient registering the sender, + // the recipient PXE reconstructs the address-derived tag and discovers the delivery. + buildMessageDeliveryTest({ + strategy: 'address-derived', + mode: 'unconstrained', + senderHook: () => Promise.resolve({ type: 'address-derived' }), + recipientRegistration: async (recipientWallet, _recipientAddress, senderAddress) => { + await recipientWallet.registerSender(senderAddress); + }, + }); +}); 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..d10ae9196aea --- /dev/null +++ b/yarn-project/end-to-end/src/automine/delivery/onchain_delivery_harness.ts @@ -0,0 +1,177 @@ +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; + // 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, additionalTests } = 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, + additionallyFundedAccounts[0].signingKey, + ); + 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); + }); + + additionalTests?.(); + }); +} 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 5e3f75f7865a..b50810e8c329 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 @@ -23,7 +23,7 @@ import { Fq, Fr } from '@aztec/foundation/curves/bn254'; 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'; @@ -395,6 +395,15 @@ export class TestWallet extends BaseWallet { return this.pxe.sync(); } + /** + * 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. + */ + registerTaggingSecretSource(source: Exclude): Promise { + return this.pxe.registerTaggingSecretSource(source); + } + stop(): Promise { return this.pxe.stop(); } diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 3a3b1120262d..daf24a114ee6 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -1,13 +1,16 @@ 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 { deriveKeys } from '@aztec/stdlib/keys'; +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'; @@ -25,7 +28,6 @@ describe('LogService', () => { let contractAddress: AztecAddress; let aztecNode: MockProxy; let keyStore: KeyStore; - let recipientTaggingStore: RecipientTaggingStore; let addressStore: AddressStore; let taggingSecretSourcesStore: TaggingSecretSourcesStore; let logService: LogService; @@ -34,32 +36,12 @@ 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, 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 () => { @@ -322,8 +304,97 @@ describe('LogService', () => { }); }); }); + + describe('address-derived discovery requires a registered sender', () => { + let recipientCompleteAddress: CompleteAddress; + let recipient: AztecAddress; + let sender: AztecAddress; + let senderIndex0Tag: SiloedTag; + let senderLog: LogResult; + + beforeEach(async () => { + contractAddress = await AztecAddress.random(); + + const l2TipsProvider = mock(); + const testContext = await createTestLogService(l2TipsProvider); + ({ 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. + recipientCompleteAddress = await keyStore.addAccount(await deriveKeys(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.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. + 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); + }); + }); }); +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,