From 4aa7c0171ffb9e2bd830679f5ecbb3ab957daf56 Mon Sep 17 00:00:00 2001 From: benesjan Date: Thu, 26 Mar 2026 03:04:08 +0000 Subject: [PATCH 01/18] chore: drop dead legacy oracle mappings Remove 3 legacy oracle mappings that are not called by any pinned v4 protocol contract: privateIsSideEffectCounterRevertible, privateNotifySetPublicTeardownFunctionCall, and utilityEmitOffchainEffect. Verified by decoding the Brillig bytecode from the pinned protocol contract artifacts on v4-next. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../oracle/legacy_oracle_mappings.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_mappings.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_mappings.ts index e241a3b0f1a7..7f0a32eb57d8 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_mappings.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/legacy_oracle_mappings.ts @@ -84,12 +84,9 @@ export function buildLegacyOracleCallbacks(oracle: Oracle): ACIRCallback { secret: ACVMField[], ): Promise<(ACVMField | ACVMField[])[]> => oracle.aztec_utl_getL1ToL2MembershipWitness(contractAddress, messageHash, secret), - utilityEmitOffchainEffect: (data: ACVMField[]): Promise => oracle.aztec_utl_emitOffchainEffect(data), // Renames (same signature, different oracle name) privateNotifySetMinRevertibleSideEffectCounter: (counter: ACVMField[]): Promise => oracle.aztec_prv_notifyRevertiblePhaseStart(counter), - privateIsSideEffectCounterRevertible: (sideEffectCounter: ACVMField[]): Promise => - oracle.aztec_prv_isExecutionInRevertiblePhase(sideEffectCounter), // Signature changes: old 4-param oracles → new 1-param validatePublicCalldata privateNotifyEnqueuedPublicFunctionCall: ( _contractAddress: ACVMField[], @@ -97,11 +94,5 @@ export function buildLegacyOracleCallbacks(oracle: Oracle): ACIRCallback { _sideEffectCounter: ACVMField[], _isStaticCall: ACVMField[], ): Promise => oracle.aztec_prv_assertValidPublicCalldata(calldataHash), - privateNotifySetPublicTeardownFunctionCall: ( - _contractAddress: ACVMField[], - calldataHash: ACVMField[], - _sideEffectCounter: ACVMField[], - _isStaticCall: ACVMField[], - ): Promise => oracle.aztec_prv_assertValidPublicCalldata(calldataHash), }; } From 37d79a04989dc9ec223bc485060d36fecd7437cd Mon Sep 17 00:00:00 2001 From: PhilWindle <60546371+PhilWindle@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:01:02 +0000 Subject: [PATCH 02/18] cherry-pick PR #21829: fix: sync dateProvider from anvil stdout on every mined block (with conflicts) --- .claude/settings.local.json | 11 ++ noir/noir-repo | 2 +- .../aztec/src/testing/anvil_test_watcher.ts | 2 +- yarn-project/aztec/src/testing/cheat_codes.ts | 2 +- .../end-to-end/src/e2e_cheat_codes.test.ts | 8 +- .../src/e2e_crowdfunding_and_claim.test.ts | 2 +- .../end-to-end/src/e2e_p2p/add_rollup.test.ts | 2 +- .../end-to-end/src/e2e_synching.test.ts | 2 +- yarn-project/end-to-end/src/fixtures/setup.ts | 20 ++-- .../src/simulators/lending_simulator.ts | 6 +- .../ethereum/src/test/eth_cheat_codes.ts | 10 +- .../ethereum/src/test/start_anvil.test.ts | 37 ++++++ yarn-project/ethereum/src/test/start_anvil.ts | 113 ++++++++++++++++++ 13 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000000..fb7160ed27d1 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch:*)", + "Bash(git cherry-pick:*)", + "Bash(echo \"EXIT: $?\")", + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } +} diff --git a/noir/noir-repo b/noir/noir-repo index 190931435915..f7a7e6c16143 160000 --- a/noir/noir-repo +++ b/noir/noir-repo @@ -1 +1 @@ -Subproject commit 190931435915a6ef908d9be090036d98356237b3 +Subproject commit f7a7e6c16143080d09892ee730c0c8a4bb7877d5 diff --git a/yarn-project/aztec/src/testing/anvil_test_watcher.ts b/yarn-project/aztec/src/testing/anvil_test_watcher.ts index 44ef6662a9c8..4eabc6583949 100644 --- a/yarn-project/aztec/src/testing/anvil_test_watcher.ts +++ b/yarn-project/aztec/src/testing/anvil_test_watcher.ts @@ -130,7 +130,7 @@ export class AnvilTestWatcher { return; } - const l1Time = (await this.cheatcodes.timestamp()) * 1000; + const l1Time = (await this.cheatcodes.lastBlockTimestamp()) * 1000; const wallTime = this.dateProvider.now(); if (l1Time > wallTime) { this.logger.warn(`L1 is ahead of wall time. Syncing wall time to L1 time`); diff --git a/yarn-project/aztec/src/testing/cheat_codes.ts b/yarn-project/aztec/src/testing/cheat_codes.ts index 94ebe0b87046..14413fd4ddd3 100644 --- a/yarn-project/aztec/src/testing/cheat_codes.ts +++ b/yarn-project/aztec/src/testing/cheat_codes.ts @@ -72,7 +72,7 @@ export class CheatCodes { * @param duration - The duration to advance time by (in seconds) */ async warpL2TimeAtLeastBy(sequencerClient: SequencerClient, node: AztecNode, duration: bigint | number) { - const currentTimestamp = await this.eth.timestamp(); + const currentTimestamp = await this.eth.lastBlockTimestamp(); const targetTimestamp = BigInt(currentTimestamp) + BigInt(duration); await this.warpL2TimeAtLeastTo(sequencerClient, node, targetTimestamp); } diff --git a/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts b/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts index 7cb9093a28ca..0ed7ee58d828 100644 --- a/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts +++ b/yarn-project/end-to-end/src/e2e_cheat_codes.test.ts @@ -46,19 +46,19 @@ describe('e2e_cheat_codes', () => { it.each([100, 42, 99])(`setNextBlockTimestamp by %i`, async increment => { const blockNumber = await ethCheatCodes.blockNumber(); - const timestamp = await ethCheatCodes.timestamp(); + const timestamp = await ethCheatCodes.lastBlockTimestamp(); await ethCheatCodes.setNextBlockTimestamp(timestamp + increment); - expect(await ethCheatCodes.timestamp()).toBe(timestamp); + expect(await ethCheatCodes.lastBlockTimestamp()).toBe(timestamp); await ethCheatCodes.mine(); expect(await ethCheatCodes.blockNumber()).toBe(blockNumber + 1); - expect(await ethCheatCodes.timestamp()).toBe(timestamp + increment); + expect(await ethCheatCodes.lastBlockTimestamp()).toBe(timestamp + increment); }); it('setNextBlockTimestamp to a past timestamp throws', async () => { - const timestamp = await ethCheatCodes.timestamp(); + const timestamp = await ethCheatCodes.lastBlockTimestamp(); const pastTimestamp = timestamp - 1000; await expect(async () => await ethCheatCodes.setNextBlockTimestamp(pastTimestamp)).rejects.toThrow( 'Timestamp error', diff --git a/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts b/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts index 68711711b23b..5668ba4e3e77 100644 --- a/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts +++ b/yarn-project/end-to-end/src/e2e_crowdfunding_and_claim.test.ts @@ -60,7 +60,7 @@ describe('e2e_crowdfunding_and_claim', () => { } = await setup(3)); // We set the deadline to a week from now - deadline = (await cheatCodes.eth.timestamp()) + 7 * 24 * 60 * 60; + deadline = (await cheatCodes.eth.lastBlockTimestamp()) + 7 * 24 * 60 * 60; ({ contract: donationToken } = await TokenContract.deploy( wallet, diff --git a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts index c885c8a4745c..0db4c4f28055 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts @@ -518,7 +518,7 @@ describe('e2e_p2p_add_rollup', () => { const futureEpoch = EpochNumber.fromBigInt(500n + BigInt(await newRollup.getCurrentEpochNumber())); const futureSlot = SlotNumber.fromBigInt(BigInt(futureEpoch) * BigInt(t.ctx.aztecNodeConfig.aztecEpochDuration)); const time = await newRollup.getTimestampForSlot(futureSlot); - if (time > BigInt(await t.ctx.cheatCodes.eth.timestamp())) { + if (time > BigInt(await t.ctx.cheatCodes.eth.lastBlockTimestamp())) { await t.ctx.cheatCodes.eth.warp(Number(time)); await waitL1Block(); } diff --git a/yarn-project/end-to-end/src/e2e_synching.test.ts b/yarn-project/end-to-end/src/e2e_synching.test.ts index e03737b2669a..eeaa159b9c2e 100644 --- a/yarn-project/end-to-end/src/e2e_synching.test.ts +++ b/yarn-project/end-to-end/src/e2e_synching.test.ts @@ -466,7 +466,7 @@ describe('e2e_synching', () => { for (const checkpoint of checkpoints) { const lastBlock = checkpoint.blocks.at(-1)!; const targetTime = Number(lastBlock.header.globalVariables.timestamp) - ETHEREUM_SLOT_DURATION; - while ((await cheatCodes.eth.timestamp()) < targetTime) { + while ((await cheatCodes.eth.lastBlockTimestamp()) < targetTime) { await cheatCodes.eth.mine(); } // If it breaks here, first place you should look is the pruning. diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 630173060ab8..7f36e94b7a0d 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -298,6 +298,8 @@ export async function setup( config.dataDirectory = directoryToCleanup; } + const dateProvider = new TestDateProvider(); + if (!config.l1RpcUrls?.length) { if (!isAnvilTestChain(chain.id)) { throw new Error(`No ETHEREUM_HOSTS set but non anvil chain requested`); @@ -306,6 +308,11 @@ export async function setup( l1BlockTime: opts.ethereumSlotDuration, accounts: opts.anvilAccounts, port: opts.anvilPort ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : undefined), +<<<<<<< HEAD +======= + slotsInAnEpoch: opts.anvilSlotsInAnEpoch, + dateProvider, +>>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) }); anvil = res.anvil; config.l1RpcUrls = [res.rpcUrl]; @@ -317,8 +324,6 @@ export async function setup( logger.info(`Logging metrics to ${filename}`); setupMetricsLogger(filename); } - - const dateProvider = new TestDateProvider(); const ethCheatCodes = new EthCheatCodesWithState(config.l1RpcUrls, dateProvider); if (opts.stateLoad) { @@ -414,11 +419,12 @@ export async function setup( await ethCheatCodes.setIntervalMining(config.ethereumSlotDuration); } - // Always sync dateProvider to L1 time after deploying L1 contracts, regardless of mining mode. - // In compose mode, L1 time may have drifted ahead of system time due to the local-network watcher - // warping time forward on each filled slot. Without this sync, the sequencer computes the wrong - // slot from its dateProvider and cannot propose blocks. - dateProvider.setTime((await ethCheatCodes.timestamp()) * 1000); + // In compose mode (no local anvil), sync dateProvider to L1 time since it may have drifted + // ahead of system time due to the local-network watcher warping time forward on each filled slot. + // When running with a local anvil, the dateProvider is kept in sync via the stdout listener. + if (!anvil) { + dateProvider.setTime((await ethCheatCodes.lastBlockTimestamp()) * 1000); + } if (opts.l2StartTime) { await ethCheatCodes.warp(opts.l2StartTime, { resetBlockInterval: true }); diff --git a/yarn-project/end-to-end/src/simulators/lending_simulator.ts b/yarn-project/end-to-end/src/simulators/lending_simulator.ts index 404bb3d5ad8d..ae299b31e249 100644 --- a/yarn-project/end-to-end/src/simulators/lending_simulator.ts +++ b/yarn-project/end-to-end/src/simulators/lending_simulator.ts @@ -94,7 +94,9 @@ export class LendingSimulator { async prepare() { this.accumulator = BASE; - const slot = await this.rollup.getSlotAt(BigInt(await this.cc.eth.timestamp()) + BigInt(this.ethereumSlotDuration)); + const slot = await this.rollup.getSlotAt( + BigInt(await this.cc.eth.lastBlockTimestamp()) + BigInt(this.ethereumSlotDuration), + ); this.time = Number(await this.rollup.getTimestampForSlot(slot)); } @@ -103,7 +105,7 @@ export class LendingSimulator { return; } - const slot = await this.rollup.getSlotAt(BigInt(await this.cc.eth.timestamp())); + const slot = await this.rollup.getSlotAt(BigInt(await this.cc.eth.lastBlockTimestamp())); const targetSlot = SlotNumber(slot + diff); const ts = Number(await this.rollup.getTimestampForSlot(targetSlot)); const timeDiff = ts - this.time; diff --git a/yarn-project/ethereum/src/test/eth_cheat_codes.ts b/yarn-project/ethereum/src/test/eth_cheat_codes.ts index d62b194c0241..b1cbdd199d6f 100644 --- a/yarn-project/ethereum/src/test/eth_cheat_codes.ts +++ b/yarn-project/ethereum/src/test/eth_cheat_codes.ts @@ -85,10 +85,12 @@ export class EthCheatCodes { } /** - * Get the current timestamp - * @returns The current timestamp + * Get the timestamp of the latest mined L1 block. + * Note: this is NOT the current time — it's the discrete timestamp of the last block. + * Between blocks, the actual chain time advances but no new block reflects it. + * @returns The latest block timestamp in seconds */ - public async timestamp(): Promise { + public async lastBlockTimestamp(): Promise { const res = await this.doRpcCall('eth_getBlockByNumber', ['latest', true]); return parseInt(res.timestamp, 16); } @@ -552,7 +554,7 @@ export class EthCheatCodes { } public async syncDateProvider() { - const timestamp = await this.timestamp(); + const timestamp = await this.lastBlockTimestamp(); if ('setTime' in this.dateProvider) { this.dateProvider.setTime(timestamp * 1000); } diff --git a/yarn-project/ethereum/src/test/start_anvil.test.ts b/yarn-project/ethereum/src/test/start_anvil.test.ts index d958ac55bbd7..5d01f6a45030 100644 --- a/yarn-project/ethereum/src/test/start_anvil.test.ts +++ b/yarn-project/ethereum/src/test/start_anvil.test.ts @@ -1,5 +1,6 @@ import { type Logger, createLogger } from '@aztec/foundation/log'; import { sleep } from '@aztec/foundation/sleep'; +import { TestDateProvider } from '@aztec/foundation/timer'; import type { Anvil } from '@viem/anvil'; import { createPublicClient, http, parseAbiItem } from 'viem'; @@ -54,4 +55,40 @@ describe('start_anvil', () => { stopWatching(); }, 500); }); + + it('syncs dateProvider to anvil block time on each mined block', async () => { + // Stop the default anvil instance (no dateProvider). + await anvil.stop(); + + const dateProvider = new TestDateProvider(); + const res = await startAnvil({ dateProvider }); + anvil = res.anvil; + rpcUrl = res.rpcUrl; + + const publicClient = createPublicClient({ transport: http(rpcUrl, { batch: false }) }); + + // Mine a block so anvil emits a "Block Time" line. + await publicClient.request({ method: 'evm_mine', params: [] } as any); + // Give the stdout listener time to fire. + await sleep(200); + + const block = await publicClient.getBlock({ blockTag: 'latest' }); + const blockTimeMs = Number(block.timestamp) * 1000; + // The dateProvider should now be within 2 seconds of the anvil block time. + // TestDateProvider.now() = Date.now() + offset, and setTime sets offset = blockTimeMs - Date.now(), + // so subsequent now() calls return blockTimeMs + elapsed. We check the difference is small. + expect(Math.abs(dateProvider.now() - blockTimeMs)).toBeLessThan(2000); + + // Warp anvil forward by 1000 seconds and verify the dateProvider follows. + const futureTimestamp = Number(block.timestamp) + 1000; + await publicClient.request({ + method: 'evm_setNextBlockTimestamp', + params: [futureTimestamp], + } as any); + await publicClient.request({ method: 'evm_mine', params: [] } as any); + await sleep(200); + + const futureTimeMs = futureTimestamp * 1000; + expect(Math.abs(dateProvider.now() - futureTimeMs)).toBeLessThan(2000); + }); }); diff --git a/yarn-project/ethereum/src/test/start_anvil.ts b/yarn-project/ethereum/src/test/start_anvil.ts index 8a70b29fc12f..acfc992f425a 100644 --- a/yarn-project/ethereum/src/test/start_anvil.ts +++ b/yarn-project/ethereum/src/test/start_anvil.ts @@ -1,5 +1,6 @@ import { createLogger } from '@aztec/foundation/log'; import { makeBackoff, retry } from '@aztec/foundation/retry'; +import type { TestDateProvider } from '@aztec/foundation/timer'; import { fileURLToPath } from '@aztec/foundation/url'; import { type Anvil, createAnvil } from '@viem/anvil'; @@ -18,6 +19,22 @@ export async function startAnvil( chainId?: number; /** The hardfork to use - note: @viem/anvil types are out of date but 'cancun' and 'latest' work */ hardfork?: string; +<<<<<<< HEAD +======= + /** + * Number of slots per epoch used by anvil to compute the 'finalized' and 'safe' block tags. + * Anvil reports `finalized = latest - slotsInAnEpoch * 2`. + * Defaults to 1 so the finalized block advances immediately, making tests that check + * L1-finality-based logic work without needing hundreds of mined blocks. + */ + slotsInAnEpoch?: number; + /** + * If provided, the date provider will be synced to anvil's block time on every mined block. + * This keeps the dateProvider in lockstep with anvil's chain time, avoiding drift between + * the wall clock and the L1 chain when computing L1 slot timestamps. + */ + dateProvider?: TestDateProvider; +>>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) } = {}, ): Promise<{ anvil: Anvil; methodCalls?: string[]; rpcUrl: string; stop: () => Promise }> { const anvilBinary = resolve(dirname(fileURLToPath(import.meta.url)), '../../', 'scripts/anvil_kill_wrapper.sh'); @@ -50,9 +67,30 @@ export async function startAnvil( port = parseInt(message.match(/Listening on ([^:]+):(\d+)/)![2]); } }); +<<<<<<< HEAD await anvil.start(); if (!logger && !opts.captureMethodCalls) { removeHandler(); +======= + + // Continue piping for logging, method-call capture, and/or dateProvider sync after startup. + if (logger || opts.captureMethodCalls || opts.dateProvider) { + child.stdout?.on('data', (data: Buffer) => { + const text = data.toString(); + logger?.debug(text.trim()); + methodCalls?.push(...(text.match(/eth_[^\s]+/g) || [])); + if (opts.dateProvider) { + syncDateProviderFromAnvilOutput(text, opts.dateProvider); + } + }); + child.stderr?.on('data', (data: Buffer) => { + logger?.debug(data.toString().trim()); + }); + } else { + // Consume streams so the child process doesn't block on full pipe buffers. + child.stdout?.resume(); + child.stderr?.resume(); +>>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) } return anvil; @@ -65,7 +103,82 @@ export async function startAnvil( throw new Error('Failed to start anvil'); } +<<<<<<< HEAD // Monkeypatch the anvil instance to include the actually assigned port // Object.defineProperty(anvil, 'port', { value: port, writable: false }); return { anvil, methodCalls, stop: () => anvil.stop(), rpcUrl: `http://127.0.0.1:${port}` }; +======= + const port = detectedPort; + let status: 'listening' | 'idle' = 'listening'; + + anvil.once('close', () => { + status = 'idle'; + }); + + const stop = async () => { + if (status === 'idle') { + return; + } + await killChild(anvil); + }; + + const anvilObj: Anvil = { + port, + host: '127.0.0.1', + get status() { + return status; + }, + stop, + }; + + return { anvil: anvilObj, methodCalls, stop, rpcUrl: `http://127.0.0.1:${port}` }; +} + +/** Extracts block time from anvil stdout and syncs the dateProvider. */ +function syncDateProviderFromAnvilOutput(text: string, dateProvider: TestDateProvider): void { + // Anvil logs mined blocks as: + // Block Time: "Fri, 20 Mar 2026 02:10:46 +0000" + const match = text.match(/Block Time:\s*"([^"]+)"/); + if (match) { + const blockTimeMs = new Date(match[1]).getTime(); + if (!isNaN(blockTimeMs)) { + dateProvider.setTime(blockTimeMs); + } + } +} + +/** Send SIGTERM, wait up to 5 s, then SIGKILL. All timers are always cleared. */ +function killChild(child: ChildProcess): Promise { + return new Promise(resolve => { + if (child.exitCode !== null || child.killed) { + child.stdout?.destroy(); + child.stderr?.destroy(); + resolve(); + return; + } + + let killTimer: NodeJS.Timeout | undefined; + + const onClose = () => { + if (killTimer !== undefined) { + clearTimeout(killTimer); + } + // Destroy stdio streams so their PipeWrap handles don't keep the event loop alive. + child.stdout?.destroy(); + child.stderr?.destroy(); + resolve(); + }; + + child.once('close', onClose); + child.kill('SIGTERM'); + + killTimer = setTimeout(() => { + killTimer = undefined; + child.kill('SIGKILL'); + }, 5000); + + // Ensure the timer does not prevent Node from exiting. + killTimer.unref(); + }); +>>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) } From ed394e80659fa01917c33da6772059614b5b6847 Mon Sep 17 00:00:00 2001 From: PhilWindle <60546371+PhilWindle@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:20:19 +0000 Subject: [PATCH 03/18] cherry-pick PR #21853: fix(sequencer): remove l1 block timestamp check (with conflicts) --- .../src/publisher/sequencer-publisher.ts | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index 3ff0e0d87893..a9e5baa3862c 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -457,7 +457,7 @@ export class SequencerPublisher { * @param tipArchive - The archive to check * @returns The slot and block number if it is possible to propose, undefined otherwise */ - public async canProposeAt( + public canProposeAt( tipArchive: Fr, msgSender: EthAddress, opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {}, @@ -465,7 +465,13 @@ export class SequencerPublisher { // TODO: #14291 - should loop through multiple keys to check if any of them can propose const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive']; +<<<<<<< HEAD const nextL1SlotTs = await this.getNextL1SlotTimestampWithL1Floor(); +======= + const pipelined = opts.pipelined ?? this.epochCache.isProposerPipeliningEnabled(); + const slotOffset = pipelined ? this.aztecSlotDuration : 0n; + const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset; +>>>>>>> 6ba3b59f83 (fix(sequencer): remove l1 block timestamp check (#21853)) return this.rollupContract .canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, { @@ -505,7 +511,7 @@ export class SequencerPublisher { flags, ] as const; - const ts = await this.getNextL1SlotTimestampWithL1Floor(); + const ts = this.getNextL1SlotTimestamp(); const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride( opts?.forcePendingCheckpointNumber, ); @@ -1361,20 +1367,9 @@ export class SequencerPublisher { }); } - /** - * Returns the timestamp to use when simulating L1 proposal calls. - * Uses the wall-clock-based next L1 slot boundary, but floors it with the latest L1 block timestamp - * plus one slot duration. This prevents the sequencer from targeting a future L2 slot when the L1 - * chain hasn't caught up to the wall clock yet (e.g., the dateProvider is one L1 slot ahead of the - * latest mined block), which would cause the propose tx to land in an L1 block with block.timestamp - * still in the previous L2 slot. - * TODO(palla): Properly fix by keeping dateProvider synced with anvil's chain time on every block. - */ - private async getNextL1SlotTimestampWithL1Floor(): Promise { + /** Returns the timestamp to use when simulating L1 proposal calls */ + private getNextL1SlotTimestamp(): bigint { const l1Constants = this.epochCache.getL1Constants(); - const fromWallClock = getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants); - const latestBlock = await this.l1TxUtils.client.getBlock(); - const fromL1Block = latestBlock.timestamp + BigInt(l1Constants.ethereumSlotDuration); - return fromWallClock > fromL1Block ? fromWallClock : fromL1Block; + return getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants); } } From 7343882e5b6056d1c0c400207f4ddc5475f933a2 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 26 Mar 2026 06:35:20 -0300 Subject: [PATCH 04/18] cherry-pick PR #21869: fix(e2e): set anvilSlotsInAnEpoch in slashing tests (with conflicts) --- .../e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts | 1 + .../end-to-end/src/e2e_p2p/data_withholding_slash.test.ts | 1 + .../src/e2e_p2p/duplicate_attestation_slash.test.ts | 1 + .../end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts | 1 + yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts | 1 + .../e2e_p2p/multiple_validators_sentinel.parallel.test.ts | 1 + yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts | 1 + .../end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts | 1 + .../end-to-end/src/e2e_p2p/validators_sentinel.test.ts | 1 + .../end-to-end/src/e2e_sequencer/slasher_config.test.ts | 5 +++++ 10 files changed, 14 insertions(+) diff --git a/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts index ed3b62b21c65..96511ea5b511 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts @@ -56,6 +56,7 @@ describe('e2e_p2p_broadcasted_invalid_block_proposal_slash', () => { basePort: BOOT_NODE_UDP_PORT, metricsPort: shouldCollectMetrics(), initialConfig: { + anvilSlotsInAnEpoch: 4, listenAddress: '127.0.0.1', aztecEpochDuration, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, diff --git a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts index 917dba22df30..45cc1d03b11c 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts @@ -58,6 +58,7 @@ describe('e2e_p2p_data_withholding_slash', () => { basePort: BOOT_NODE_UDP_PORT, metricsPort: shouldCollectMetrics(), initialConfig: { + anvilSlotsInAnEpoch: 4, listenAddress: '127.0.0.1', aztecEpochDuration, ethereumSlotDuration: 4, diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts index 3ebef6ac94da..eb8edad01bc1 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts @@ -68,6 +68,7 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { basePort: BOOT_NODE_UDP_PORT, metricsPort: shouldCollectMetrics(), initialConfig: { + anvilSlotsInAnEpoch: 4, listenAddress: '127.0.0.1', aztecEpochDuration, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts index c0b6062acac6..3f0ed6e6164b 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts @@ -60,6 +60,7 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { basePort: BOOT_NODE_UDP_PORT, metricsPort: shouldCollectMetrics(), initialConfig: { + anvilSlotsInAnEpoch: 4, listenAddress: '127.0.0.1', aztecEpochDuration, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, diff --git a/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts b/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts index 7897fb1269ad..614528ea757a 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts @@ -58,6 +58,7 @@ export class P2PInactivityTest { basePort: BOOT_NODE_UDP_PORT, startProverNode: true, initialConfig: { + anvilSlotsInAnEpoch: 4, proverNodeConfig: { proverNodeEpochProvingDelayMs: AZTEC_SLOT_DURATION * 1000 }, aztecTargetCommitteeSize: COMMITTEE_SIZE, aztecSlotDuration: AZTEC_SLOT_DURATION, diff --git a/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts b/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts index 8747f2b7251d..cf778c18196f 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts @@ -44,6 +44,7 @@ describe('e2e_p2p_multiple_validators_sentinel', () => { basePort: BOOT_NODE_UDP_PORT, startProverNode: true, initialConfig: { + anvilSlotsInAnEpoch: 4, aztecTargetCommitteeSize: NUM_VALIDATORS, aztecSlotDuration: AZTEC_SLOT_DURATION, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, diff --git a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts index 532b94e0dd98..a4e352ab0fff 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts @@ -78,6 +78,7 @@ describe('veto slash', () => { basePort: BOOT_NODE_UDP_PORT, startProverNode: true, initialConfig: { + anvilSlotsInAnEpoch: 4, aztecSlotDuration: AZTEC_SLOT_DURATION, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, aztecProofSubmissionEpochs: 1024, // effectively do not reorg diff --git a/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts index 8683138cb02a..6954c9640c3b 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts @@ -52,6 +52,7 @@ describe('e2e_p2p_valid_epoch_pruned_slash', () => { basePort: BOOT_NODE_UDP_PORT, metricsPort: shouldCollectMetrics(), initialConfig: { + anvilSlotsInAnEpoch: 4, enforceTimeTable: true, cancelTxOnTimeout: false, sequencerPublisherAllowInvalidStates: true, diff --git a/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts b/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts index 3a4e47afe387..43ddccff833f 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts @@ -39,6 +39,7 @@ describe('e2e_p2p_validators_sentinel', () => { basePort: BOOT_NODE_UDP_PORT, startProverNode: true, initialConfig: { + anvilSlotsInAnEpoch: 4, aztecTargetCommitteeSize: NUM_VALIDATORS, aztecSlotDuration: AZTEC_SLOT_DURATION, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, diff --git a/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts b/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts index 57c72796020c..2a67caa12c63 100644 --- a/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts +++ b/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts @@ -9,7 +9,12 @@ describe('e2e_slasher_config', () => { let aztecNode: AztecNode; beforeAll(async () => { +<<<<<<< HEAD ({ aztecNodeAdmin, aztecNode } = await setup(0, { +======= + ({ aztecNodeAdmin, aztecNode, teardown } = await setup(0, { + anvilSlotsInAnEpoch: 4, +>>>>>>> 5319cf3e80 (fix(e2e): set anvilSlotsInAnEpoch in slashing tests (#21869)) slashInactivityTargetPercentage: 1, slashInactivityPenalty: 42n, })); From 9820222588684bc1dc6e4d55f1051040feaea45c Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 26 Mar 2026 10:41:06 -0300 Subject: [PATCH 05/18] cherry-pick PR #22023: fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (with conflicts) --- .../e2e_l1_publisher/e2e_l1_publisher.test.ts | 1 - .../src/publisher/sequencer-publisher.test.ts | 11 -- .../src/publisher/sequencer-publisher.ts | 106 ++++++++++++------ .../checkpoint_voter.ha.integration.test.ts | 5 + .../src/sequencer/checkpoint_voter.ts | 13 +-- .../src/sequencer/sequencer.test.ts | 1 - .../stdlib/src/epoch-helpers/index.test.ts | 19 +++- .../stdlib/src/epoch-helpers/index.ts | 8 ++ 8 files changed, 103 insertions(+), 61 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 2dafcdf0e69b..49e40df34348 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -785,7 +785,6 @@ describe('L1Publisher integration', () => { await publisher.enqueueGovernanceCastSignal( l1ContractAddresses.rollupAddress, block.slot, - block.timestamp, EthAddress.random(), (_payload: any) => Promise.resolve(Signature.random().toString()), ); diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index 12592d5a1042..47389972e5e1 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -220,7 +220,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -448,7 +447,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -463,7 +461,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -478,7 +475,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -493,7 +489,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -507,7 +502,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ); @@ -515,7 +509,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(3), - 2n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ); @@ -534,7 +527,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -549,7 +541,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -565,7 +556,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(2), - 1n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), @@ -576,7 +566,6 @@ describe('SequencerPublisher', () => { await publisher.enqueueGovernanceCastSignal( govPayload, SlotNumber(3), - 2n, EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index a9e5baa3862c..2c8c031820e6 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -41,7 +41,7 @@ import { EmpireBaseAbi, ErrorsAbi, RollupAbi } from '@aztec/l1-artifacts'; import { type ProposerSlashAction, encodeSlashConsensusVotes } from '@aztec/slasher'; import { CommitteeAttestationsAndSigners, type ValidateCheckpointResult } from '@aztec/stdlib/block'; import type { Checkpoint } from '@aztec/stdlib/checkpoint'; -import { getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers'; +import { getLastL1SlotTimestampForL2Slot, getNextL1SlotTimestamp } from '@aztec/stdlib/epoch-helpers'; import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts'; import type { CheckpointHeader } from '@aztec/stdlib/rollup'; import type { L1PublishCheckpointStats } from '@aztec/stdlib/stats'; @@ -511,7 +511,7 @@ export class SequencerPublisher { flags, ] as const; - const ts = this.getNextL1SlotTimestamp(); + const ts = this.getSimulationTimestamp(header.slotNumber); const stateOverrides = await this.rollupContract.makePendingCheckpointNumberOverride( opts?.forcePendingCheckpointNumber, ); @@ -534,7 +534,7 @@ export class SequencerPublisher { data: encodeFunctionData({ abi: RollupAbi, functionName: 'validateHeaderWithAttestations', args }), from: MULTI_CALL_3_ADDRESS, }, - { time: ts + 1n }, + { time: ts }, stateOverrides, ); this.log.debug(`Simulated validateHeader`); @@ -661,8 +661,12 @@ export class SequencerPublisher { attestationsAndSigners: CommitteeAttestationsAndSigners, attestationsAndSignersSignature: Signature, options: { forcePendingCheckpointNumber?: CheckpointNumber }, +<<<<<<< HEAD ): Promise { const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration); +======= + ): Promise { +>>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) const blobFields = checkpoint.toBlobFields(); const blobs = await getBlobsPerL1Block(blobFields); const blobInput = getPrefixedEthBlobCommitments(blobs); @@ -681,13 +685,11 @@ export class SequencerPublisher { blobInput, ] as const; - await this.simulateProposeTx(args, ts, options); - return ts; + await this.simulateProposeTx(args, options); } private async enqueueCastSignalHelper( slotNumber: SlotNumber, - timestamp: bigint, signalType: GovernanceSignalAction, payload: EthAddress, base: IEmpireBase, @@ -765,11 +767,37 @@ export class SequencerPublisher { lastValidL2Slot: slotNumber, }); +<<<<<<< HEAD +======= + const l1BlockNumber = await this.l1TxUtils.getBlockNumber(); + const timestamp = this.getSimulationTimestamp(slotNumber); + +>>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) try { await this.l1TxUtils.simulate(request, { time: timestamp }, [], mergeAbis([request.abi ?? [], ErrorsAbi])); this.log.debug(`Simulation for ${action} at slot ${slotNumber} succeeded`, { request }); } catch (err) { +<<<<<<< HEAD this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, err); +======= + const viemError = formatViemError(err); + this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, viemError, { + simulationTimestamp: timestamp, + l1BlockNumber, + }); + this.backupFailedTx({ + id: keccak256(request.data!), + failureType: 'simulation', + request: { to: request.to!, data: request.data!, value: request.value?.toString() }, + l1BlockNumber: l1BlockNumber.toString(), + error: { message: viemError.message, name: viemError.name }, + context: { + actions: [action], + slot: slotNumber, + sender: this.getSenderAddress().toString(), + }, + }); +>>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) // Yes, we enqueue the request anyway, in case there was a bug with the simulation itself } @@ -820,19 +848,16 @@ export class SequencerPublisher { /** * Enqueues a governance castSignal transaction to cast a signal for a given slot number. * @param slotNumber - The slot number to cast a signal for. - * @param timestamp - The timestamp of the slot to cast a signal for. * @returns True if the signal was successfully enqueued, false otherwise. */ public enqueueGovernanceCastSignal( governancePayload: EthAddress, slotNumber: SlotNumber, - timestamp: bigint, signerAddress: EthAddress, signer: (msg: TypedDataDefinition) => Promise<`0x${string}`>, ): Promise { return this.enqueueCastSignalHelper( slotNumber, - timestamp, 'governance-signal', governancePayload, this.govProposerContract, @@ -845,7 +870,6 @@ export class SequencerPublisher { public async enqueueSlashingActions( actions: ProposerSlashAction[], slotNumber: SlotNumber, - timestamp: bigint, signerAddress: EthAddress, signer: (msg: TypedDataDefinition) => Promise<`0x${string}`>, ): Promise { @@ -866,7 +890,6 @@ export class SequencerPublisher { }); await this.enqueueCastSignalHelper( slotNumber, - timestamp, 'empire-slashing-signal', action.payload, this.slashingProposerContract, @@ -885,7 +908,6 @@ export class SequencerPublisher { (receipt: TransactionReceipt) => !!this.slashFactoryContract.tryExtractSlashPayloadCreatedEvent(receipt.logs), slotNumber, - timestamp, ); break; } @@ -903,7 +925,6 @@ export class SequencerPublisher { request, (receipt: TransactionReceipt) => !!empireSlashingProposer.tryExtractPayloadSubmittedEvent(receipt.logs), slotNumber, - timestamp, ); break; } @@ -927,7 +948,6 @@ export class SequencerPublisher { request, (receipt: TransactionReceipt) => !!tallySlashingProposer.tryExtractVoteCastEvent(receipt.logs), slotNumber, - timestamp, ); break; } @@ -949,7 +969,6 @@ export class SequencerPublisher { request, (receipt: TransactionReceipt) => !!tallySlashingProposer.tryExtractRoundExecutedEvent(receipt.logs), slotNumber, - timestamp, ); break; } @@ -985,15 +1004,13 @@ export class SequencerPublisher { feeAssetPriceModifier: checkpoint.feeAssetPriceModifier, }; - let ts: bigint; - try { // @note This will make sure that we are passing the checks for our header ASSUMING that the data is also made available // This means that we can avoid the simulation issues in later checks. // By simulation issue, I mean the fact that the block.timestamp is equal to the last block, not the next, which // make time consistency checks break. // TODO(palla): Check whether we're validating twice, once here and once within addProposeTx, since we call simulateProposeTx in both places. - ts = await this.validateCheckpointForSubmission( + await this.validateCheckpointForSubmission( checkpoint, attestationsAndSigners, attestationsAndSignersSignature, @@ -1009,7 +1026,7 @@ export class SequencerPublisher { } this.log.verbose(`Enqueuing checkpoint propose transaction`, { ...checkpoint.toCheckpointInfo(), ...opts }); - await this.addProposeTx(checkpoint, proposeTxArgs, opts, ts); + await this.addProposeTx(checkpoint, proposeTxArgs, opts); } public enqueueInvalidateCheckpoint( @@ -1052,8 +1069,8 @@ export class SequencerPublisher { request: L1TxRequest, checkSuccess: (receipt: TransactionReceipt) => boolean | undefined, slotNumber: SlotNumber, - timestamp: bigint, ) { + const timestamp = this.getSimulationTimestamp(slotNumber); const logData = { slotNumber, timestamp, gasLimit: undefined as bigint | undefined }; if (this.lastActions[action] && this.lastActions[action] === slotNumber) { this.log.debug(`Skipping duplicate action ${action} for slot ${slotNumber}`); @@ -1067,8 +1084,9 @@ export class SequencerPublisher { let gasUsed: bigint; const simulateAbi = mergeAbis([request.abi ?? [], ErrorsAbi]); + try { - ({ gasUsed } = await this.l1TxUtils.simulate(request, { time: timestamp }, [], simulateAbi)); // TODO(palla/slash): Check the timestamp logic + ({ gasUsed } = await this.l1TxUtils.simulate(request, { time: timestamp }, [], simulateAbi)); this.log.verbose(`Simulation for ${action} succeeded`, { ...logData, request, gasUsed }); } catch (err) { const viemError = formatViemError(err, simulateAbi); @@ -1124,7 +1142,6 @@ export class SequencerPublisher { private async prepareProposeTx( encodedData: L1ProcessArgs, - timestamp: bigint, options: { forcePendingCheckpointNumber?: CheckpointNumber }, ) { const kzg = Blob.getViemKzgInstance(); @@ -1179,7 +1196,7 @@ export class SequencerPublisher { blobInput, ] as const; - const { rollupData, simulationResult } = await this.simulateProposeTx(args, timestamp, options); + const { rollupData, simulationResult } = await this.simulateProposeTx(args, options); return { args, blobEvaluationGas, rollupData, simulationResult }; } @@ -1187,7 +1204,6 @@ export class SequencerPublisher { /** * Simulates the propose tx with eth_simulateV1 * @param args - The propose tx args - * @param timestamp - The timestamp to simulate proposal at * @returns The simulation result */ private async simulateProposeTx( @@ -1204,7 +1220,6 @@ export class SequencerPublisher { ViemSignature, `0x${string}`, ], - timestamp: bigint, options: { forcePendingCheckpointNumber?: CheckpointNumber }, ) { const rollupData = encodeFunctionData({ @@ -1238,6 +1253,12 @@ export class SequencerPublisher { }); } +<<<<<<< HEAD +======= + const l1BlockNumber = await this.l1TxUtils.getBlockNumber(); + const simTs = this.getSimulationTimestamp(SlotNumber.fromBigInt(args[0].header.slotNumber)); + +>>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) const simulationResult = await this.l1TxUtils .simulate( { @@ -1247,8 +1268,7 @@ export class SequencerPublisher { ...(this.proposerAddressForSimulation && { from: this.proposerAddressForSimulation.toString() }), }, { - // @note we add 1n to the timestamp because geth implementation doesn't like simulation timestamp to be equal to the current block timestamp - time: timestamp + 1n, + time: simTs, // @note reth should have a 30m gas limit per block but throws errors that this tx is beyond limit so we increase here gasLimit: MAX_L1_TX_LIMIT * 2n, }, @@ -1270,7 +1290,23 @@ export class SequencerPublisher { logs: [], }; } +<<<<<<< HEAD this.log.error(`Failed to simulate propose tx`, viemError); +======= + this.log.error(`Failed to simulate propose tx`, viemError, { simulationTimestamp: simTs }); + this.backupFailedTx({ + id: keccak256(rollupData), + failureType: 'simulation', + request: { to: this.rollupContract.address, data: rollupData }, + l1BlockNumber: l1BlockNumber.toString(), + error: { message: viemError.message, name: viemError.name }, + context: { + actions: ['propose'], + slot: Number(args[0].header.slotNumber), + sender: this.getSenderAddress().toString(), + }, + }); +>>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) throw err; }); @@ -1281,16 +1317,11 @@ export class SequencerPublisher { checkpoint: Checkpoint, encodedData: L1ProcessArgs, opts: { txTimeoutAt?: Date; forcePendingCheckpointNumber?: CheckpointNumber } = {}, - timestamp: bigint, ): Promise { const slot = checkpoint.header.slotNumber; const timer = new Timer(); const kzg = Blob.getViemKzgInstance(); - const { rollupData, simulationResult, blobEvaluationGas } = await this.prepareProposeTx( - encodedData, - timestamp, - opts, - ); + const { rollupData, simulationResult, blobEvaluationGas } = await this.prepareProposeTx(encodedData, opts); const startBlock = await this.l1TxUtils.getBlockNumber(); const gasLimit = this.l1TxUtils.bumpGasLimit( BigInt(Math.ceil((Number(simulationResult.gasUsed) * 64) / 63)) + @@ -1367,7 +1398,14 @@ export class SequencerPublisher { }); } - /** Returns the timestamp to use when simulating L1 proposal calls */ + /** Returns the timestamp of the last L1 slot within a given L2 slot. Used as the simulation timestamp + * for eth_simulateV1 calls, since it's guaranteed to be greater than any L1 block produced during the slot. */ + private getSimulationTimestamp(slot: SlotNumber): bigint { + const l1Constants = this.epochCache.getL1Constants(); + return getLastL1SlotTimestampForL2Slot(slot, l1Constants); + } + + /** Returns the timestamp of the next L1 slot boundary after now. */ private getNextL1SlotTimestamp(): bigint { const l1Constants = this.epochCache.getL1Constants(); return getNextL1SlotTimestamp(this.dateProvider.nowInSeconds(), l1Constants); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts index e5c0c52dc7c1..ce1ac2e76d7b 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts @@ -291,6 +291,11 @@ describe('CheckpointVoter HA Integration', () => { ts: BigInt(Math.floor(Date.now() / 1000)), nowMs: BigInt(Date.now()), }); +<<<<<<< HEAD +======= + epochCache.getSlotNow.mockReturnValue(slot); + epochCache.getL1Constants.mockReturnValue(TEST_L1_CONSTANTS as any); +>>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) const slashFactoryContract = mock(); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ts index 5a6ff07c03ee..0bf72881179d 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ts @@ -2,7 +2,6 @@ import type { SlotNumber } from '@aztec/foundation/branded-types'; import type { EthAddress } from '@aztec/foundation/eth-address'; import type { Logger } from '@aztec/foundation/log'; import type { SlasherClientInterface } from '@aztec/slasher'; -import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import type { ResolvedSequencerConfig } from '@aztec/stdlib/interfaces/server'; import type { ValidatorClient } from '@aztec/validator-client'; import { DutyAlreadySignedError } from '@aztec/validator-ha-signer/errors'; @@ -18,7 +17,6 @@ import type { SequencerRollupConstants } from './types.js'; * Handles governance and slashing voting for a given slot. */ export class CheckpointVoter { - private slotTimestamp: bigint; private governanceSigner: (msg: TypedDataDefinition) => Promise<`0x${string}`>; private slashingSigner: (msg: TypedDataDefinition) => Promise<`0x${string}`>; @@ -33,8 +31,6 @@ export class CheckpointVoter { private readonly metrics: SequencerMetrics, private readonly log: Logger, ) { - this.slotTimestamp = getTimestampForSlot(this.slot, this.l1Constants); - // Create separate signers with appropriate duty contexts for governance and slashing votes // These use HA protection to ensure only one node signs per slot/duty const governanceContext: SigningContext = { slot: this.slot, dutyType: DutyType.GOVERNANCE_VOTE }; @@ -77,7 +73,6 @@ export class CheckpointVoter { return await this.publisher.enqueueGovernanceCastSignal( governanceProposerPayload, this.slot, - this.slotTimestamp, this.attestorAddress, this.governanceSigner, ); @@ -108,13 +103,7 @@ export class CheckpointVoter { this.metrics.recordSlashingAttempt(actions.length); - return await this.publisher.enqueueSlashingActions( - actions, - this.slot, - this.slotTimestamp, - this.attestorAddress, - this.slashingSigner, - ); + return await this.publisher.enqueueSlashingActions(actions, this.slot, this.attestorAddress, this.slashingSigner); } catch (err) { if (err instanceof DutyAlreadySignedError) { this.log.info(`Slashing vote already signed by another node`, { diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index d6d6fcc80dce..0ec5e38f5439 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -630,7 +630,6 @@ describe('sequencer', () => { expect(publisher.enqueueGovernanceCastSignal).toHaveBeenCalledWith( governancePayload, SlotNumber(1), - expect.any(BigInt), expect.any(EthAddress), expect.any(Function), ); diff --git a/yarn-project/stdlib/src/epoch-helpers/index.test.ts b/yarn-project/stdlib/src/epoch-helpers/index.test.ts index 150d8216714d..4e89e3b127da 100644 --- a/yarn-project/stdlib/src/epoch-helpers/index.test.ts +++ b/yarn-project/stdlib/src/epoch-helpers/index.test.ts @@ -1,6 +1,11 @@ -import { EpochNumber } from '@aztec/foundation/branded-types'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { type L1RollupConstants, getProofSubmissionDeadlineTimestamp, getTimestampRangeForEpoch } from './index.js'; +import { + type L1RollupConstants, + getLastL1SlotTimestampForL2Slot, + getProofSubmissionDeadlineTimestamp, + getTimestampRangeForEpoch, +} from './index.js'; describe('EpochHelpers', () => { let constants: Omit; @@ -34,4 +39,14 @@ describe('EpochHelpers', () => { const deadline = getProofSubmissionDeadlineTimestamp(EpochNumber.fromBigInt(3n), constants); expect(deadline).toEqual(l1GenesisTime + BigInt(24 * 4 * 3) + BigInt(24 * 8)); }); + + it('returns last L1 slot timestamp for L2 slot', () => { + // L2 slot 0 starts at l1GenesisTime, lasts 24s with 12s L1 slots, so last L1 slot is at +12 + const ts = getLastL1SlotTimestampForL2Slot(SlotNumber(0), constants); + expect(ts).toEqual(l1GenesisTime + BigInt(24 - 12)); + + // L2 slot 5 starts at l1GenesisTime + 5*24 = +120, last L1 slot at +120+12 = +132 + const ts2 = getLastL1SlotTimestampForL2Slot(SlotNumber(5), constants); + expect(ts2).toEqual(l1GenesisTime + BigInt(5 * 24 + 24 - 12)); + }); }); diff --git a/yarn-project/stdlib/src/epoch-helpers/index.ts b/yarn-project/stdlib/src/epoch-helpers/index.ts index 0ae35f5461f4..3f4d8ccf4476 100644 --- a/yarn-project/stdlib/src/epoch-helpers/index.ts +++ b/yarn-project/stdlib/src/epoch-helpers/index.ts @@ -68,6 +68,14 @@ export function getNextL1SlotTimestamp( return constants.l1GenesisTime + (currentL1Slot + 1n) * BigInt(constants.ethereumSlotDuration); } +/** Returns the timestamp of the last L1 slot within a given L2 slot. */ +export function getLastL1SlotTimestampForL2Slot( + slot: SlotNumber, + constants: Pick, +): bigint { + return getTimestampForSlot(slot, constants) + BigInt(constants.slotDuration - constants.ethereumSlotDuration); +} + /** Returns the L2 slot number at the next L1 block based on the current timestamp. */ export function getSlotAtNextL1Block( currentL1Timestamp: bigint, From 22fbcfa462175e467ac8f5d069ab4f6406d61b7e Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Mar 2026 11:05:28 -0300 Subject: [PATCH 06/18] fix: resolve all cherry-pick conflicts Resolves conflicts from cherry-picking PRs #21829, #21853, #21869, #22023. Key resolutions: - start_anvil.ts: take full rewrite from PR #21829 (spawn-based instead of @viem/anvil) - sequencer-publisher.ts: use getNextL1SlotTimestamp with pipelining (PR #21853), getSimulationTimestamp for simulation timestamps (PR #22023), add aztecSlotDuration property - setup.ts: add slotsInAnEpoch and dateProvider options - slasher_config.test.ts: add anvilSlotsInAnEpoch option - checkpoint_voter test: add epochCache mock methods - Drop backupFailedTx calls (infrastructure not on this branch) --- .claude/settings.local.json | 3 +- .../src/e2e_sequencer/slasher_config.test.ts | 4 - yarn-project/end-to-end/src/fixtures/setup.ts | 3 - .../ethereum/src/test/start_anvil.test.ts | 3 +- yarn-project/ethereum/src/test/start_anvil.ts | 111 ++++++++++++------ .../src/publisher/sequencer-publisher.ts | 56 +-------- .../checkpoint_voter.ha.integration.test.ts | 3 - 7 files changed, 80 insertions(+), 103 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fb7160ed27d1..ed9c5d293678 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,8 @@ "Bash(git cherry-pick:*)", "Bash(echo \"EXIT: $?\")", "Bash(git add:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "Bash(git rm:*)" ] } } diff --git a/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts b/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts index 2a67caa12c63..4a2ff9bdfab2 100644 --- a/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts +++ b/yarn-project/end-to-end/src/e2e_sequencer/slasher_config.test.ts @@ -9,12 +9,8 @@ describe('e2e_slasher_config', () => { let aztecNode: AztecNode; beforeAll(async () => { -<<<<<<< HEAD ({ aztecNodeAdmin, aztecNode } = await setup(0, { -======= - ({ aztecNodeAdmin, aztecNode, teardown } = await setup(0, { anvilSlotsInAnEpoch: 4, ->>>>>>> 5319cf3e80 (fix(e2e): set anvilSlotsInAnEpoch in slashing tests (#21869)) slashInactivityTargetPercentage: 1, slashInactivityPenalty: 42n, })); diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index 7f36e94b7a0d..6f6e7b1ef819 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -308,11 +308,8 @@ export async function setup( l1BlockTime: opts.ethereumSlotDuration, accounts: opts.anvilAccounts, port: opts.anvilPort ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : undefined), -<<<<<<< HEAD -======= slotsInAnEpoch: opts.anvilSlotsInAnEpoch, dateProvider, ->>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) }); anvil = res.anvil; config.l1RpcUrls = [res.rpcUrl]; diff --git a/yarn-project/ethereum/src/test/start_anvil.test.ts b/yarn-project/ethereum/src/test/start_anvil.test.ts index 5d01f6a45030..cb0d70f61dbb 100644 --- a/yarn-project/ethereum/src/test/start_anvil.test.ts +++ b/yarn-project/ethereum/src/test/start_anvil.test.ts @@ -2,10 +2,9 @@ import { type Logger, createLogger } from '@aztec/foundation/log'; import { sleep } from '@aztec/foundation/sleep'; import { TestDateProvider } from '@aztec/foundation/timer'; -import type { Anvil } from '@viem/anvil'; import { createPublicClient, http, parseAbiItem } from 'viem'; -import { startAnvil } from './start_anvil.js'; +import { type Anvil, startAnvil } from './start_anvil.js'; describe('start_anvil', () => { let logger: Logger; diff --git a/yarn-project/ethereum/src/test/start_anvil.ts b/yarn-project/ethereum/src/test/start_anvil.ts index acfc992f425a..f5ba8609e15f 100644 --- a/yarn-project/ethereum/src/test/start_anvil.ts +++ b/yarn-project/ethereum/src/test/start_anvil.ts @@ -3,9 +3,17 @@ import { makeBackoff, retry } from '@aztec/foundation/retry'; import type { TestDateProvider } from '@aztec/foundation/timer'; import { fileURLToPath } from '@aztec/foundation/url'; -import { type Anvil, createAnvil } from '@viem/anvil'; +import { type ChildProcess, spawn } from 'child_process'; import { dirname, resolve } from 'path'; +/** Minimal interface matching the @viem/anvil Anvil shape used by callers. */ +export interface Anvil { + readonly port: number; + readonly host: string; + readonly status: 'listening' | 'idle'; + stop(): Promise; +} + /** * Ensures there's a running Anvil instance and returns the RPC URL. */ @@ -17,10 +25,8 @@ export async function startAnvil( captureMethodCalls?: boolean; accounts?: number; chainId?: number; - /** The hardfork to use - note: @viem/anvil types are out of date but 'cancun' and 'latest' work */ + /** The hardfork to use (e.g. 'cancun', 'latest'). */ hardfork?: string; -<<<<<<< HEAD -======= /** * Number of slots per epoch used by anvil to compute the 'finalized' and 'safe' block tags. * Anvil reports `finalized = latest - slotsInAnEpoch * 2`. @@ -34,44 +40,80 @@ export async function startAnvil( * the wall clock and the L1 chain when computing L1 slot timestamps. */ dateProvider?: TestDateProvider; ->>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) } = {}, ): Promise<{ anvil: Anvil; methodCalls?: string[]; rpcUrl: string; stop: () => Promise }> { const anvilBinary = resolve(dirname(fileURLToPath(import.meta.url)), '../../', 'scripts/anvil_kill_wrapper.sh'); const logger = opts.log ? createLogger('ethereum:anvil') : undefined; const methodCalls = opts.captureMethodCalls ? ([] as string[]) : undefined; - let port: number | undefined; + let detectedPort: number | undefined; - // Start anvil. - // We go via a wrapper script to ensure if the parent dies, anvil dies. const anvil = await retry( async () => { - const anvil = createAnvil({ - anvilBinary, - host: '127.0.0.1', - port: opts.port ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : 8545), - blockTime: opts.l1BlockTime, - stopTimeout: 1000, - accounts: opts.accounts ?? 20, - gasLimit: 45_000_000n, - chainId: opts.chainId ?? 31337, + const port = opts.port ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : 8545); + const args: string[] = [ + '--host', + '127.0.0.1', + '--port', + String(port), + '--accounts', + String(opts.accounts ?? 20), + '--gas-limit', + String(45_000_000), + '--chain-id', + String(opts.chainId ?? 31337), + ]; + if (opts.l1BlockTime !== undefined) { + args.push('--block-time', String(opts.l1BlockTime)); + } + if (opts.hardfork !== undefined) { + args.push('--hardfork', opts.hardfork); + } + args.push('--slots-in-an-epoch', String(opts.slotsInAnEpoch ?? 1)); + + const child = spawn(anvilBinary, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, RAYON_NUM_THREADS: '1' }, }); - // Listen to the anvil output to get the port. - const removeHandler = anvil.on('message', (message: string) => { - logger?.debug(message.trim()); + // Wait for "Listening on" or an early exit. + await new Promise((resolve, reject) => { + let stderr = ''; + + const onStdout = (data: Buffer) => { + const text = data.toString(); + logger?.debug(text.trim()); + methodCalls?.push(...(text.match(/eth_[^\s]+/g) || [])); + + if (detectedPort === undefined && text.includes('Listening on')) { + const match = text.match(/Listening on ([^:]+):(\d+)/); + if (match) { + detectedPort = parseInt(match[2]); + } + } + if (detectedPort !== undefined) { + child.stdout?.removeListener('data', onStdout); + child.stderr?.removeListener('data', onStderr); + child.removeListener('close', onClose); + resolve(); + } + }; + + const onStderr = (data: Buffer) => { + stderr += data.toString(); + logger?.debug(data.toString().trim()); + }; + + const onClose = (code: number | null) => { + child.stdout?.removeListener('data', onStdout); + child.stderr?.removeListener('data', onStderr); + reject(new Error(`Anvil exited with code ${code} before listening. stderr: ${stderr}`)); + }; - methodCalls?.push(...(message.match(/eth_[^\s]+/g) || [])); - if (port === undefined && message.includes('Listening on')) { - port = parseInt(message.match(/Listening on ([^:]+):(\d+)/)![2]); - } + child.stdout?.on('data', onStdout); + child.stderr?.on('data', onStderr); + child.once('close', onClose); }); -<<<<<<< HEAD - await anvil.start(); - if (!logger && !opts.captureMethodCalls) { - removeHandler(); -======= // Continue piping for logging, method-call capture, and/or dateProvider sync after startup. if (logger || opts.captureMethodCalls || opts.dateProvider) { @@ -90,24 +132,18 @@ export async function startAnvil( // Consume streams so the child process doesn't block on full pipe buffers. child.stdout?.resume(); child.stderr?.resume(); ->>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) } - return anvil; + return child; }, 'Start anvil', makeBackoff([5, 5, 5]), ); - if (!port) { + if (!detectedPort) { throw new Error('Failed to start anvil'); } -<<<<<<< HEAD - // Monkeypatch the anvil instance to include the actually assigned port - // Object.defineProperty(anvil, 'port', { value: port, writable: false }); - return { anvil, methodCalls, stop: () => anvil.stop(), rpcUrl: `http://127.0.0.1:${port}` }; -======= const port = detectedPort; let status: 'listening' | 'idle' = 'listening'; @@ -180,5 +216,4 @@ function killChild(child: ChildProcess): Promise { // Ensure the timer does not prevent Node from exiting. killTimer.unref(); }); ->>>>>>> 0539c7b722 (fix: sync dateProvider from anvil stdout on every mined block (#21829)) } diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index 2c8c031820e6..f9f8dd9c1009 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -122,6 +122,7 @@ export class SequencerPublisher { protected log: Logger; protected ethereumSlotDuration: bigint; + protected aztecSlotDuration: bigint; private dateProvider: DateProvider; private blobClient: BlobClientInterface; @@ -153,7 +154,7 @@ export class SequencerPublisher { constructor( private config: Pick & - Pick & { l1ChainId: number }, + Pick & { l1ChainId: number }, deps: { telemetry?: TelemetryClient; blobClient: BlobClientInterface; @@ -171,6 +172,7 @@ export class SequencerPublisher { ) { this.log = deps.log ?? createLogger('sequencer:publisher'); this.ethereumSlotDuration = BigInt(config.ethereumSlotDuration); + this.aztecSlotDuration = BigInt(config.aztecSlotDuration); this.dateProvider = deps.dateProvider; this.epochCache = deps.epochCache; this.lastActions = deps.lastActions; @@ -460,18 +462,14 @@ export class SequencerPublisher { public canProposeAt( tipArchive: Fr, msgSender: EthAddress, - opts: { forcePendingCheckpointNumber?: CheckpointNumber } = {}, + opts: { forcePendingCheckpointNumber?: CheckpointNumber; pipelined?: boolean } = {}, ) { // TODO: #14291 - should loop through multiple keys to check if any of them can propose const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive']; -<<<<<<< HEAD - const nextL1SlotTs = await this.getNextL1SlotTimestampWithL1Floor(); -======= const pipelined = opts.pipelined ?? this.epochCache.isProposerPipeliningEnabled(); const slotOffset = pipelined ? this.aztecSlotDuration : 0n; const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset; ->>>>>>> 6ba3b59f83 (fix(sequencer): remove l1 block timestamp check (#21853)) return this.rollupContract .canProposeAt(tipArchive.toBuffer(), msgSender.toString(), nextL1SlotTs, { @@ -661,12 +659,7 @@ export class SequencerPublisher { attestationsAndSigners: CommitteeAttestationsAndSigners, attestationsAndSignersSignature: Signature, options: { forcePendingCheckpointNumber?: CheckpointNumber }, -<<<<<<< HEAD - ): Promise { - const ts = BigInt((await this.l1TxUtils.getBlock()).timestamp + this.ethereumSlotDuration); -======= ): Promise { ->>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) const blobFields = checkpoint.toBlobFields(); const blobs = await getBlobsPerL1Block(blobFields); const blobInput = getPrefixedEthBlobCommitments(blobs); @@ -767,37 +760,16 @@ export class SequencerPublisher { lastValidL2Slot: slotNumber, }); -<<<<<<< HEAD -======= - const l1BlockNumber = await this.l1TxUtils.getBlockNumber(); const timestamp = this.getSimulationTimestamp(slotNumber); ->>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) try { await this.l1TxUtils.simulate(request, { time: timestamp }, [], mergeAbis([request.abi ?? [], ErrorsAbi])); this.log.debug(`Simulation for ${action} at slot ${slotNumber} succeeded`, { request }); } catch (err) { -<<<<<<< HEAD - this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, err); -======= const viemError = formatViemError(err); this.log.error(`Failed simulation for ${action} at slot ${slotNumber} (enqueuing the action anyway)`, viemError, { simulationTimestamp: timestamp, - l1BlockNumber, }); - this.backupFailedTx({ - id: keccak256(request.data!), - failureType: 'simulation', - request: { to: request.to!, data: request.data!, value: request.value?.toString() }, - l1BlockNumber: l1BlockNumber.toString(), - error: { message: viemError.message, name: viemError.name }, - context: { - actions: [action], - slot: slotNumber, - sender: this.getSenderAddress().toString(), - }, - }); ->>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) // Yes, we enqueue the request anyway, in case there was a bug with the simulation itself } @@ -1253,12 +1225,8 @@ export class SequencerPublisher { }); } -<<<<<<< HEAD -======= - const l1BlockNumber = await this.l1TxUtils.getBlockNumber(); const simTs = this.getSimulationTimestamp(SlotNumber.fromBigInt(args[0].header.slotNumber)); ->>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) const simulationResult = await this.l1TxUtils .simulate( { @@ -1290,23 +1258,7 @@ export class SequencerPublisher { logs: [], }; } -<<<<<<< HEAD - this.log.error(`Failed to simulate propose tx`, viemError); -======= this.log.error(`Failed to simulate propose tx`, viemError, { simulationTimestamp: simTs }); - this.backupFailedTx({ - id: keccak256(rollupData), - failureType: 'simulation', - request: { to: this.rollupContract.address, data: rollupData }, - l1BlockNumber: l1BlockNumber.toString(), - error: { message: viemError.message, name: viemError.name }, - context: { - actions: ['propose'], - slot: Number(args[0].header.slotNumber), - sender: this.getSenderAddress().toString(), - }, - }); ->>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) throw err; }); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts index ce1ac2e76d7b..2f55710b5e5e 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts @@ -291,11 +291,8 @@ describe('CheckpointVoter HA Integration', () => { ts: BigInt(Math.floor(Date.now() / 1000)), nowMs: BigInt(Date.now()), }); -<<<<<<< HEAD -======= epochCache.getSlotNow.mockReturnValue(slot); epochCache.getL1Constants.mockReturnValue(TEST_L1_CONSTANTS as any); ->>>>>>> e505b7dcb9 (fix(sequencer): use last L1 slot of L2 slot as eth_simulateV1 timestamp (#22023)) const slashFactoryContract = mock(); From ef51060118ef4cbcf20230b6365c16a7d4567ee0 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Mar 2026 13:33:56 -0300 Subject: [PATCH 07/18] fix: resolve merge conflicts with base branch All conflicts were about dateProvider support from PR #21829 - take our version. --- yarn-project/end-to-end/src/fixtures/setup.ts | 3 --- yarn-project/ethereum/src/test/start_anvil.ts | 14 -------------- 2 files changed, 17 deletions(-) diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index a5bf03beb638..ffef7e66db10 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -313,10 +313,7 @@ export async function setup( accounts: opts.anvilAccounts, port: opts.anvilPort ?? (process.env.ANVIL_PORT ? parseInt(process.env.ANVIL_PORT) : undefined), slotsInAnEpoch: opts.anvilSlotsInAnEpoch, -<<<<<<< HEAD dateProvider, -======= ->>>>>>> origin/backport-to-v4-next-staging }); anvil = res.anvil; config.l1RpcUrls = [res.rpcUrl]; diff --git a/yarn-project/ethereum/src/test/start_anvil.ts b/yarn-project/ethereum/src/test/start_anvil.ts index 798a276c3177..f5ba8609e15f 100644 --- a/yarn-project/ethereum/src/test/start_anvil.ts +++ b/yarn-project/ethereum/src/test/start_anvil.ts @@ -34,15 +34,12 @@ export async function startAnvil( * L1-finality-based logic work without needing hundreds of mined blocks. */ slotsInAnEpoch?: number; -<<<<<<< HEAD /** * If provided, the date provider will be synced to anvil's block time on every mined block. * This keeps the dateProvider in lockstep with anvil's chain time, avoiding drift between * the wall clock and the L1 chain when computing L1 slot timestamps. */ dateProvider?: TestDateProvider; -======= ->>>>>>> origin/backport-to-v4-next-staging } = {}, ): Promise<{ anvil: Anvil; methodCalls?: string[]; rpcUrl: string; stop: () => Promise }> { const anvilBinary = resolve(dirname(fileURLToPath(import.meta.url)), '../../', 'scripts/anvil_kill_wrapper.sh'); @@ -118,23 +115,15 @@ export async function startAnvil( child.once('close', onClose); }); -<<<<<<< HEAD // Continue piping for logging, method-call capture, and/or dateProvider sync after startup. if (logger || opts.captureMethodCalls || opts.dateProvider) { -======= - // Continue piping for logging / method-call capture after startup. - if (logger || opts.captureMethodCalls) { ->>>>>>> origin/backport-to-v4-next-staging child.stdout?.on('data', (data: Buffer) => { const text = data.toString(); logger?.debug(text.trim()); methodCalls?.push(...(text.match(/eth_[^\s]+/g) || [])); -<<<<<<< HEAD if (opts.dateProvider) { syncDateProviderFromAnvilOutput(text, opts.dateProvider); } -======= ->>>>>>> origin/backport-to-v4-next-staging }); child.stderr?.on('data', (data: Buffer) => { logger?.debug(data.toString().trim()); @@ -181,7 +170,6 @@ export async function startAnvil( return { anvil: anvilObj, methodCalls, stop, rpcUrl: `http://127.0.0.1:${port}` }; } -<<<<<<< HEAD /** Extracts block time from anvil stdout and syncs the dateProvider. */ function syncDateProviderFromAnvilOutput(text: string, dateProvider: TestDateProvider): void { // Anvil logs mined blocks as: @@ -195,8 +183,6 @@ function syncDateProviderFromAnvilOutput(text: string, dateProvider: TestDatePro } } -======= ->>>>>>> origin/backport-to-v4-next-staging /** Send SIGTERM, wait up to 5 s, then SIGKILL. All timers are always cleared. */ function killChild(child: ChildProcess): Promise { return new Promise(resolve => { From f818201b0df440951f1926691b899167db4c3369 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Mar 2026 19:32:28 -0300 Subject: [PATCH 08/18] fix: build errors from cherry-pick backports - Add aztecSlotDuration to SequencerPublisher constructor call sites - Replace isProposerPipeliningEnabled (not on this branch) with false default - Remove getSlotNow mock (not on this branch's EpochCache) - Fix eth.timestamp() -> eth.lastBlockTimestamp() in e2e test - Revert update to noir-repo submodule - Delete claude settings.local.json --- .claude/settings.local.json | 15 --------------- noir/noir-repo | 2 +- .../src/e2e_epochs/epochs_missed_l1_slot.test.ts | 2 +- .../src/e2e_l1_publisher/e2e_l1_publisher.test.ts | 1 + yarn-project/end-to-end/src/e2e_synching.test.ts | 1 + .../src/publisher/sequencer-publisher.test.ts | 2 +- .../src/publisher/sequencer-publisher.ts | 2 +- .../checkpoint_voter.ha.integration.test.ts | 1 - 8 files changed, 6 insertions(+), 20 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 8c16d32ce556..000000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git fetch:*)", - "Bash(git cherry-pick:*)", - "Bash(echo \"EXIT: $?\")", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git rm:*)", - "Bash(./bootstrap.sh --help)", - "Bash(gh pr:*)", - "Bash(git merge:*)" - ] - } -} diff --git a/noir/noir-repo b/noir/noir-repo index f7a7e6c16143..190931435915 160000 --- a/noir/noir-repo +++ b/noir/noir-repo @@ -1 +1 @@ -Subproject commit f7a7e6c16143080d09892ee730c0c8a4bb7877d5 +Subproject commit 190931435915a6ef908d9be090036d98356237b3 diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts index 1fe3b1a9a47f..3e863302f2c3 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts @@ -101,7 +101,7 @@ describe('e2e_epochs/epochs_missed_l1_slot', () => { await eth.setAutomine(false); await eth.setIntervalMining(0, { silent: true }); - const frozenL1Timestamp = await eth.timestamp(); + const frozenL1Timestamp = await eth.lastBlockTimestamp(); logger.info(`L1 mining paused at L1 timestamp ${frozenL1Timestamp}`); // Step 3: Wait until the sequencer reaches PUBLISHING_CHECKPOINT during the mining pause. diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 8b698f890e85..7291a7ce8b78 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -274,6 +274,7 @@ describe('L1Publisher integration', () => { { l1ChainId: chainId, ethereumSlotDuration: config.ethereumSlotDuration, + aztecSlotDuration: config.aztecSlotDuration, }, { blobClient, diff --git a/yarn-project/end-to-end/src/e2e_synching.test.ts b/yarn-project/end-to-end/src/e2e_synching.test.ts index eeaa159b9c2e..5f57afed4fd8 100644 --- a/yarn-project/end-to-end/src/e2e_synching.test.ts +++ b/yarn-project/end-to-end/src/e2e_synching.test.ts @@ -443,6 +443,7 @@ describe('e2e_synching', () => { { l1ChainId: 31337, ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: getL1ContractsConfigEnvVars().aztecSlotDuration, }, { blobClient, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index 47389972e5e1..beb8841aea95 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -122,7 +122,7 @@ describe('SequencerPublisher', () => { ...defaultL1TxUtilsConfig, } as unknown as TxSenderConfig & PublisherConfig & - Pick & + Pick & L1TxUtilsConfig; rollup = mock(); diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index f9f8dd9c1009..e93c1f1a145f 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -467,7 +467,7 @@ export class SequencerPublisher { // TODO: #14291 - should loop through multiple keys to check if any of them can propose const ignoredErrors = ['SlotAlreadyInChain', 'InvalidProposer', 'InvalidArchive']; - const pipelined = opts.pipelined ?? this.epochCache.isProposerPipeliningEnabled(); + const pipelined = opts.pipelined ?? false; const slotOffset = pipelined ? this.aztecSlotDuration : 0n; const nextL1SlotTs = this.getNextL1SlotTimestamp() + slotOffset; diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts index 2f55710b5e5e..a99d522e477d 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts @@ -291,7 +291,6 @@ describe('CheckpointVoter HA Integration', () => { ts: BigInt(Math.floor(Date.now() / 1000)), nowMs: BigInt(Date.now()), }); - epochCache.getSlotNow.mockReturnValue(slot); epochCache.getL1Constants.mockReturnValue(TEST_L1_CONSTANTS as any); const slashFactoryContract = mock(); From d7ceefa22e04ae25d55287489f99e09c41d6eb61 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 27 Mar 2026 20:21:45 -0300 Subject: [PATCH 09/18] fix: add aztecSlotDuration to test configs Tests were failing with 'Cannot convert undefined to a BigInt' because mock configs didn't include aztecSlotDuration. --- .../src/publisher/sequencer-publisher-factory.test.ts | 1 + .../sequencer-client/src/publisher/sequencer-publisher.test.ts | 2 ++ .../src/sequencer/checkpoint_voter.ha.integration.test.ts | 1 + 3 files changed, 4 insertions(+) diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts index a06a0b62e2e7..4dff76d54deb 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher-factory.test.ts @@ -34,6 +34,7 @@ describe('SequencerPublisherFactory', () => { beforeEach(() => { mockConfig = { ethereumSlotDuration: 12, + aztecSlotDuration: 24, } as SequencerClientConfig; mockPublisherManager = mock>(); mockBlobClient = mock(); diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index beb8841aea95..3b8f3e4404c4 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -118,6 +118,8 @@ describe('SequencerPublisher', () => { rollupAddress: EthAddress.ZERO.toString(), governanceProposerAddress: mockGovernanceProposerAddress, }, + ethereumSlotDuration: 12, + aztecSlotDuration: 24, ...defaultL1TxUtilsConfig, } as unknown as TxSenderConfig & diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts index a99d522e477d..6d75678c3fb0 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts @@ -277,6 +277,7 @@ describe('CheckpointVoter HA Integration', () => { requiredConfirmations: 1, maxL1TxInclusionWaitPulseSeconds: 60, ethereumSlotDuration: DefaultL1ContractsConfig.ethereumSlotDuration, + aztecSlotDuration: DefaultL1ContractsConfig.aztecSlotDuration, fishermanMode: false, l1ChainId: 1, }; From af8c132e51fe6a92afea41a08bb117616550716c Mon Sep 17 00:00:00 2001 From: Esau Date: Mon, 30 Mar 2026 14:22:21 +0200 Subject: [PATCH 10/18] init --- .../docs/aztec-js/how_to_pay_fees.md | 57 ++++--------------- .../docs/foundational-topics/fees.md | 7 +-- .../docs/resources/migration_notes.md | 22 +++++++ docs/docs-participate/basics/fees.md | 5 +- 4 files changed, 37 insertions(+), 54 deletions(-) diff --git a/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md b/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md index 7209f9b2d98f..f90f3a9e52b3 100644 --- a/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md +++ b/docs/docs-developers/docs/aztec-js/how_to_pay_fees.md @@ -23,13 +23,11 @@ This guide walks you through paying transaction fees on Aztec using various paym | Method | Use Case | Privacy | Requirements | | ------------------- | ----------------------------- | ------- | -------------------------- | | Fee Juice (default) | Account already has Fee Juice | Public | Funded account | -#if(testnet) -| Sponsored FPC | Testing, free transactions | Public | None (not on testnet) | -#else +#if(devnet) | Sponsored FPC | Testing, free transactions | Public | None | +#else +| Sponsored FPC | Testing, free transactions | Public | None (devnet and local only) | #endif -| Private FPC | Pay with tokens privately | Private | Token balance, FPC address | -| Public FPC | Pay with tokens publicly | Public | Token balance, FPC address | | Bridge + Claim | Bootstrap from L1 | Public | L1 ETH for gas | ## Mana and Fee Juice @@ -118,20 +116,22 @@ console.log("Transaction fee:", receipt.transactionFee); ## Use Fee Payment Contracts -Fee Payment Contracts (FPC) pay fees on your behalf, typically accepting a different token than Fee Juice. Since Fee Juice is non-transferable on L2, FPCs are the most common fee payment method. +Fee Payment Contracts (FPCs) pay Fee Juice on your behalf. FPCs must use Fee Juice exclusively on L2 during the setup phase; custom token contract functions cannot be called during setup on public networks. An FPC that accepts other tokens on L1 and bridges Fee Juice works on any network. ### Sponsored Fee Payment Contracts #if(testnet) -:::warning -The Sponsored FPC is **not** deployed on testnet. To pay fees, you must either [bridge Fee Juice from L1](#bridge-fee-juice-from-l1) or deploy your own fee-paying contract. +:::note +The Sponsored FPC is not available on testnet or mainnet. It is only available on devnet and local network. +::: +#elif(mainnet) +:::note +The Sponsored FPC is not available on mainnet. It is only available on devnet and local network. ::: - -The Sponsored FPC pays for fees unconditionally without requiring payment in return. It is available on the local network and devnet (deployed by Aztec Labs), but **not on testnet**. -#else -The Sponsored FPC pays for fees unconditionally without requiring payment in return. It is available on both the local network and devnet (deployed by Aztec Labs). #endif +The Sponsored FPC pays fees unconditionally. It is only available on devnet and local network. + You can derive the Sponsored FPC address from its deployment parameters, register it with your wallet, and use it to pay for transactions: #include_code deploy_sponsored_fpc_contract /docs/examples/ts/aztecjs_advanced/index.ts typescript @@ -140,39 +140,6 @@ Here's a simpler example from the test suite: #include_code sponsored_fpc_simple yarn-project/end-to-end/src/e2e_fees/sponsored_payments.test.ts typescript -### Use other Fee Paying Contracts - -Third-party FPCs can pay for your fees using custom logic, such as accepting different tokens instead of Fee Juice. - -#### Set gas settings - -```typescript -import { GasSettings } from "@aztec/stdlib/gas"; - -// node is from createAztecNodeClient() in the connection guide (see prerequisites) -const maxFeesPerGas = (await node.getCurrentMinFees()).mul(1.5); //adjust this to your needs -const gasSettings = GasSettings.default({ maxFeesPerGas }); -``` - -Private FPCs enable fee payments without revealing the payer's identity onchain: - -#include_code private_fpc_payment yarn-project/end-to-end/src/composed/e2e_local_network_example.test.ts typescript - -Public FPCs can be used in the same way: - -```typescript -import { PublicFeePaymentMethod } from "@aztec/aztec.js/fee"; - -// wallet is from the connection guide; fpcAddress is the FPC contract address -// senderAddress is the account paying; gasSettings is from the step above -const paymentMethod = new PublicFeePaymentMethod( - fpcAddress, - senderAddress, - wallet, - gasSettings, -); -``` - ## Bridge Fee Juice from L1 Fee Juice is non-transferable on L2, but you can bridge it from L1, claim it on L2, and use it. This involves a few components that are part of a running network's infrastructure: diff --git a/docs/docs-developers/docs/foundational-topics/fees.md b/docs/docs-developers/docs/foundational-topics/fees.md index 74992f1b03ba..884b211d6397 100644 --- a/docs/docs-developers/docs/foundational-topics/fees.md +++ b/docs/docs-developers/docs/foundational-topics/fees.md @@ -83,12 +83,9 @@ Fee Juice uses an enshrined `FeeJuicePortal` contract on Ethereum for bridging, An account with Fee Juice can pay for its transactions directly. A new account can even pay for its own deployment transaction, provided Fee Juice was bridged to its address before deployment. -Alternatively, accounts can use [fee-paying contracts (FPCs)](../aztec-js/how_to_pay_fees.md#use-fee-payment-contracts) to pay for transactions. FPCs accept tokens and pay fees in Fee Juice on behalf of users. Common patterns include: +Alternatively, accounts can use [fee-paying contracts (FPCs)](../aztec-js/how_to_pay_fees.md#use-fee-payment-contracts) to pay for transactions. FPCs must use Fee Juice exclusively on L2 during the setup phase, but can accept other tokens on L1 and bridge Fee Juice. -- **Sponsored FPCs**: Pay fees unconditionally, enabling free transactions for users -- **Token-accepting FPCs**: Accept a specific token in exchange for paying fees - -FPCs can contain arbitrary logic to authorize fee payments and can operate privately or publicly. +The **Sponsored FPC** pays fees unconditionally, enabling free transactions. It is only available on devnet and local network. ### Teardown phase diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 40df1255046e..7290fc0c706f 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,26 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### Custom token FPCs removed from default public setup allowlist + +Token contract functions (like `transfer_in_public` and `_increase_public_balance`) have been removed from the default public setup allowlist. FPCs that accept custom tokens (like the reference `FPC` contract) will not work on public networks, because their setup-phase calls to these functions will be rejected. Token class IDs change with each aztec-nr release, making it impractical to maintain them in the allowlist. + +FPCs that use only Fee Juice still work on all networks, since FeeJuice is a protocol contract with a fixed address in the allowlist. Custom FPCs should only call protocol contract functions (AuthRegistry, FeeJuice) during setup. + +`PublicFeePaymentMethod` and `PrivateFeePaymentMethod` in aztec.js are affected, since they use the reference `FPC` contract which calls Token functions during setup. Switch to `FeeJuicePaymentMethodWithClaim` (after [bridging Fee Juice from L1](../aztec-js/how_to_pay_fees.md#bridge-fee-juice-from-l1)) or write an FPC that uses Fee Juice natively. + +**Migration:** + +```diff +- import { PublicFeePaymentMethod } from '@aztec/aztec.js/fee'; +- const paymentMethod = new PublicFeePaymentMethod(fpcAddress, senderAddress, wallet, gasSettings); ++ import { FeeJuicePaymentMethodWithClaim } from '@aztec/aztec.js/fee'; ++ const paymentMethod = new FeeJuicePaymentMethodWithClaim(senderAddress, claim); +``` + +Similarly, the `fpc-public` and `fpc-private` CLI wallet payment methods use the reference Token-based FPC and will not work on public networks. Use `fee_juice` for direct Fee Juice payment, or `fpc-sponsored` on devnet and local network. + + ### [Aztec.nr] Domain-separated tags on log emission All logs emitted through the Aztec.nr framework now include a domain-separated tag at `fields[0]`. Each log category uses its own domain separator via `compute_log_tag(raw_tag, dom_sep)`: @@ -118,6 +138,8 @@ The `DeployTxReceipt` and `DeployWaitOptions` types have been removed. + from: address, + }); ``` + + ### [aztec.js] `isContractInitialized` is now `initializationStatus` tri-state enum `ContractMetadata.isContractInitialized` has been renamed to `ContractMetadata.initializationStatus` and changed from `boolean | undefined` to a `ContractInitializationStatus` enum with values `INITIALIZED`, `UNINITIALIZED`, and `UNKNOWN`. diff --git a/docs/docs-participate/basics/fees.md b/docs/docs-participate/basics/fees.md index 4b67f66c6209..82d9b51de0f0 100644 --- a/docs/docs-participate/basics/fees.md +++ b/docs/docs-participate/basics/fees.md @@ -62,10 +62,7 @@ Aztec offers flexible fee payment: If you have $AZTEC, pay for your own transactions directly from your account. ### Sponsored Transactions -Some applications pay fees on behalf of their users, enabling "free" transactions. The application covers the cost, not you. - -### Fee-Paying Contracts -Specialized contracts can accept other tokens and pay fees in $AZTEC for you. This is useful if you only hold other tokens. +Fee-paying contracts can pay fees on your behalf. For example, on devnet and local network, a sponsored fee-paying contract covers transaction costs for free. FPCs can also accept other tokens on L1 and bridge $AZTEC to pay fees. ## Understanding Your Fee From 9caf4c0bd753669e22054f5d216aa2f724b722fe Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 09:36:48 -0300 Subject: [PATCH 11/18] fix: reorder spread to avoid duplicate property error Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sequencer-client/src/publisher/sequencer-publisher.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index 3b8f3e4404c4..27a0e8f4cbe5 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -118,10 +118,9 @@ describe('SequencerPublisher', () => { rollupAddress: EthAddress.ZERO.toString(), governanceProposerAddress: mockGovernanceProposerAddress, }, + ...defaultL1TxUtilsConfig, ethereumSlotDuration: 12, aztecSlotDuration: 24, - - ...defaultL1TxUtilsConfig, } as unknown as TxSenderConfig & PublisherConfig & Pick & From 37243f3843e89631279df1ce993cefe132a022f6 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 10:35:56 -0300 Subject: [PATCH 12/18] fix(e2e): deflake epochs_mbps deploy-then-call test Backport deflaking fixes from next: send deploy tx before call tx with a 1s propagation delay instead of sending both simultaneously, and add retryUntil poll in assertMultipleBlocksPerSlot to handle archiver sync race. Originally fixed in #21003 and #21026. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../e2e_epochs/epochs_mbps.parallel.test.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts index c4261f46420b..0f293e34e4aa 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts @@ -15,6 +15,7 @@ import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { times, timesAsync } from '@aztec/foundation/collection'; import { SecretValue } from '@aztec/foundation/config'; import { retryUntil } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; import { bufferToHex } from '@aztec/foundation/string'; import { executeTimeout } from '@aztec/foundation/timer'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; @@ -149,6 +150,20 @@ describe('e2e_epochs/epochs_mbps', () => { /** Retrieves all checkpoints from the archiver, checks that one has the target block count, and returns its number. */ async function assertMultipleBlocksPerSlot(targetBlockCount: number, logger: Logger): Promise { + // Wait for the first validator's archiver to index a checkpoint with the target block count. + // waitForTx polls the initial setup node, but this archiver belongs to nodes[0] (the first + // validator). They sync L1 independently, so there's a race window of ~200-400ms. + const waitTimeout = test.L2_SLOT_DURATION_IN_S * 3; + await retryUntil( + async () => { + const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + return checkpoints.some(pc => pc.checkpoint.blocks.length >= targetBlockCount) || undefined; + }, + `checkpoint with at least ${targetBlockCount} blocks`, + waitTimeout, + 0.5, + ); + const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), @@ -538,11 +553,20 @@ describe('e2e_epochs/epochs_mbps', () => { }); await waitUntilL1Timestamp(test.l1Client, targetTimestamp, undefined, test.L2_SLOT_DURATION_IN_S * 3); - // Send both pre-proved txs simultaneously, waiting for them to be checkpointed. + // Send the deploy tx first and give it time to propagate to all validators, + // then send the call tx. Priority fees are a safety net, but arrival ordering + // ensures the deploy tx is in the pool before the call tx regardless of gossip timing. const timeout = test.L2_SLOT_DURATION_IN_S * 5; - logger.warn(`Sending both txs and waiting for checkpointed receipts`); + logger.warn(`Sending deploy tx first, then call tx`); + const deployTxHash = await deployTx.send({ wait: NO_WAIT }); + await sleep(1000); + const callTxHash = await callTx.send({ wait: NO_WAIT }); const [deployReceipt, callReceipt] = await executeTimeout( - () => Promise.all([deployTx.send({ wait: { timeout } }), callTx.send({ wait: { timeout } })]), + () => + Promise.all([ + waitForTx(context.aztecNode, deployTxHash, { timeout }), + waitForTx(context.aztecNode, callTxHash, { timeout }), + ]), timeout * 1000, ); logger.warn(`Both txs checkpointed`, { From a0d9a82d092a789ac5c48e5a3fb6041da3943e43 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 30 Mar 2026 13:29:23 -0300 Subject: [PATCH 13/18] fix(archiver): swallow error when rollup contract not yet finalized on L1 (#22156) ## Motivation Right after deployment, the archiver's L1 sync queries `getProvenCheckpointNumber` at the finalized L1 block tag. But if the rollup contract didn't exist yet at that L1 block, the call returns no data and logs a noisy warning on every sync iteration. ## Approach Swallow the "returned no data" error in `updateFinalizedCheckpoint` since it's an expected transient condition. Other errors still log a warning. ## Changes - **archiver**: Silence the `ContractFunctionExecutionError` with "returned no data" in `updateFinalizedCheckpoint`, which occurs when the rollup contract is too new to exist at the finalized L1 block Co-authored-by: Claude Opus 4.6 (1M context) --- yarn-project/archiver/src/modules/l1_synchronizer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 802576d726b8..1861c8a67c8c 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -256,8 +256,11 @@ export class ArchiverL1Synchronizer implements Traceable { }, ); } - } catch (err) { - this.log.warn(`Failed to update finalized checkpoint: ${err}`); + } catch (err: any) { + // The rollup contract may not exist at the finalized L1 block right after deployment. + if (!err?.message?.includes('returned no data')) { + this.log.warn(`Failed to update finalized checkpoint: ${err}`); + } } } From 5a99963b9ae7813d4d06e1f6b83694612cad406a Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Mon, 30 Mar 2026 11:55:21 +0200 Subject: [PATCH 14/18] cherry-pick: fix: restrict access to scoped capsules (#22113) (with conflicts) --- .../docs/resources/migration_notes.md | 17 ++ docs/netlify.toml | 13 ++ .../contract_function_simulator.ts | 5 +- .../oracle/oracle_version_is_checked.test.ts | 3 +- .../oracle/private_execution_oracle.ts | 2 +- .../oracle/utility_execution.test.ts | 7 +- .../oracle/utility_execution_oracle.ts | 66 ++++---- yarn-project/pxe/src/logs/log_service.test.ts | 3 +- yarn-project/pxe/src/logs/log_service.ts | 6 +- .../capsule_store/capsule_service.test.ts | 148 ++++++++++++++++++ .../storage/capsule_store/capsule_service.ts | 91 +++++++++++ .../pxe/src/storage/capsule_store/index.ts | 1 + .../oracle/txe_oracle_top_level_context.ts | 5 +- yarn-project/txe/src/txe_session.ts | 7 +- 14 files changed, 329 insertions(+), 45 deletions(-) create mode 100644 yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts create mode 100644 yarn-project/pxe/src/storage/capsule_store/capsule_service.ts diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 7290fc0c706f..e83bb7a2f5bd 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,23 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +<<<<<<< HEAD +======= +### [PXE] Capsule operations are now scope-enforced at the PXE level + +The PXE now enforces that capsule operations can only access scopes that were authorized for the current execution. If a contract attempts to access a capsule scope that is not in its allowed scopes list, the PXE will throw an error: + +``` +Scope 0x1234... is not in the allowed scopes list: [0xabcd...]. +``` + +The zero address (`AztecAddress::zero()`) is always allowed regardless of the scopes list, preserving backwards compatibility for contracts using the global scope. + +**Impact**: Contracts that access capsules scoped to addresses not included in the transaction's authorized scopes will now fail at runtime. Ensure the correct scopes are passed when executing transactions. + +## 4.2.0-aztecnr-rc.2 + +>>>>>>> 57d3bd09cf (fix: restrict access to scoped capsules (#22113)) ### Custom token FPCs removed from default public setup allowlist Token contract functions (like `transfer_in_public` and `_increase_public_balance`) have been removed from the default public setup allowlist. FPCs that accept custom tokens (like the reference `FPC` contract) will not work on public networks, because their setup-phase calls to these functions will be rejected. Token class IDs change with each aztec-nr release, making it impractical to maintain them in the allowlist. diff --git a/docs/netlify.toml b/docs/netlify.toml index 9d3d41270b6e..aaeec84ee7e9 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -805,3 +805,16 @@ # PXE: incompatible oracle version between contract and PXE from = "/errors/8" to = "/developers/docs/foundational-topics/pxe" +<<<<<<< HEAD +======= + +[[redirects]] + # CLI: aztec dep version in Nargo.toml does not match the CLI version + from = "/errors/9" + to = "/developers/docs/aztec-nr/framework-description/dependencies#updating-your-aztec-dependencies" + +[[redirects]] + # PXE: capsule operation attempted with a scope not in the allowed scopes list + from = "/errors/10" + to = "/developers/docs/aztec-nr/framework-description/advanced/how_to_use_capsules" +>>>>>>> 57d3bd09cf (fix: restrict access to scoped capsules (#22113)) diff --git a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts index 5c64b0be5b27..6ab6eec6020f 100644 --- a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts +++ b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts @@ -93,6 +93,7 @@ import type { AccessScopes } from '../access_scopes.js'; import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; import type { MessageContextService } from '../messages/message_context_service.js'; import type { AddressStore } from '../storage/address_store/address_store.js'; +import { CapsuleService } from '../storage/capsule_store/capsule_service.js'; import type { CapsuleStore } from '../storage/capsule_store/capsule_store.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; @@ -245,7 +246,7 @@ export class ContractFunctionSimulator { senderTaggingStore: this.senderTaggingStore, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: new CapsuleService(this.capsuleStore, scopes), privateEventStore: this.privateEventStore, messageContextService: this.messageContextService, contractSyncService: this.contractSyncService, @@ -340,7 +341,7 @@ export class ContractFunctionSimulator { aztecNode: this.aztecNode, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: new CapsuleService(this.capsuleStore, scopes), privateEventStore: this.privateEventStore, messageContextService: this.messageContextService, contractSyncService: this.contractSyncService, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts index 12534a67a729..7815f5ae9663 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts @@ -16,6 +16,7 @@ import type { ContractSyncService } from '../../contract_sync/contract_sync_serv import type { MessageContextService } from '../../messages/message_context_service.js'; import { ORACLE_VERSION } from '../../oracle_version.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; +import { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; import type { ContractStore } from '../../storage/contract_store/contract_store.js'; import type { NoteStore } from '../../storage/note_store/note_store.js'; @@ -200,7 +201,7 @@ describe('Oracle Version Check test suite', () => { aztecNode, recipientTaggingStore, senderAddressBookStore, - capsuleStore, + capsuleService: new CapsuleService(capsuleStore, []), privateEventStore, messageContextService, contractSyncService, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts index 54e5af2f7eb7..1ccdae11d8b0 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts @@ -555,7 +555,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP senderTaggingStore: this.senderTaggingStore, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: this.capsuleService, privateEventStore: this.privateEventStore, messageContextService: this.messageContextService, contractSyncService: this.contractSyncService, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 541327fae27a..7c22717d2bbe 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -26,6 +26,7 @@ import type { _MockProxy } from 'jest-mock-extended/lib/Mock.js'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; import { MessageContextService } from '../../messages/message_context_service.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; +import { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; import type { ContractStore } from '../../storage/contract_store/contract_store.js'; import type { NoteStore } from '../../storage/note_store/note_store.js'; @@ -249,7 +250,7 @@ describe('Utility Execution test suite', () => { aztecNode, recipientTaggingStore, senderAddressBookStore, - capsuleStore, + capsuleService: new CapsuleService(capsuleStore, 'ALL_SCOPES'), privateEventStore, messageContextService, contractSyncService, @@ -316,7 +317,7 @@ describe('Utility Execution test suite', () => { aztecNode, recipientTaggingStore, senderAddressBookStore, - capsuleStore, + capsuleService: new CapsuleService(capsuleStore, 'ALL_SCOPES'), privateEventStore, messageContextService, contractSyncService, @@ -497,7 +498,7 @@ describe('Utility Execution test suite', () => { aztecNode, recipientTaggingStore, senderAddressBookStore, - capsuleStore, + capsuleService: new CapsuleService(capsuleStore, 'ALL_SCOPES'), privateEventStore, messageContextService, contractSyncService, diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index aac6d2d05675..6b7370186f54 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -30,7 +30,7 @@ import { MessageContextService } from '../../messages/message_context_service.js import { NoteService } from '../../notes/note_service.js'; import { ORACLE_VERSION } from '../../oracle_version.js'; import type { AddressStore } from '../../storage/address_store/address_store.js'; -import type { CapsuleStore } from '../../storage/capsule_store/capsule_store.js'; +import type { CapsuleService } from '../../storage/capsule_store/capsule_service.js'; import type { ContractStore } from '../../storage/contract_store/contract_store.js'; import type { NoteStore } from '../../storage/note_store/note_store.js'; import type { PrivateEventStore } from '../../storage/private_event_store/private_event_store.js'; @@ -59,7 +59,7 @@ export type UtilityExecutionOracleArgs = { aztecNode: AztecNode; recipientTaggingStore: RecipientTaggingStore; senderAddressBookStore: SenderAddressBookStore; - capsuleStore: CapsuleStore; + capsuleService: CapsuleService; privateEventStore: PrivateEventStore; messageContextService: MessageContextService; contractSyncService: ContractSyncService; @@ -90,7 +90,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra protected readonly aztecNode: AztecNode; protected readonly recipientTaggingStore: RecipientTaggingStore; protected readonly senderAddressBookStore: SenderAddressBookStore; - protected readonly capsuleStore: CapsuleStore; + protected readonly capsuleService: CapsuleService; protected readonly privateEventStore: PrivateEventStore; protected readonly messageContextService: MessageContextService; protected readonly contractSyncService: ContractSyncService; @@ -110,7 +110,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.aztecNode = args.aztecNode; this.recipientTaggingStore = args.recipientTaggingStore; this.senderAddressBookStore = args.senderAddressBookStore; - this.capsuleStore = args.capsuleStore; + this.capsuleService = args.capsuleService; this.privateEventStore = args.privateEventStore; this.messageContextService = args.messageContextService; this.contractSyncService = args.contractSyncService; @@ -502,7 +502,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra this.aztecNode, this.anchorBlockHeader, this.keyStore, - this.capsuleStore, + this.capsuleService, this.recipientTaggingStore, this.senderAddressBookStore, this.addressStore, @@ -539,11 +539,21 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // We read all note and event validation requests and process them all concurrently. This makes the process much // faster as we don't need to wait for the network round-trip. const noteValidationRequests = ( - await this.capsuleStore.readCapsuleArray(contractAddress, noteValidationRequestsArrayBaseSlot, this.jobId, scope) + await this.capsuleService.readCapsuleArray( + contractAddress, + noteValidationRequestsArrayBaseSlot, + this.jobId, + scope, + ) ).map(fields => NoteValidationRequest.fromFields(fields, maxNotePackedLen)); const eventValidationRequests = ( - await this.capsuleStore.readCapsuleArray(contractAddress, eventValidationRequestsArrayBaseSlot, this.jobId, scope) + await this.capsuleService.readCapsuleArray( + contractAddress, + eventValidationRequestsArrayBaseSlot, + this.jobId, + scope, + ) ).map(fields => EventValidationRequest.fromFields(fields, maxEventSerializedLen)); const noteService = new NoteService(this.noteStore, this.aztecNode, this.anchorBlockHeader, this.jobId); @@ -578,14 +588,14 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra await Promise.all([...noteStorePromises, ...eventStorePromises]); // Requests are cleared once we're done. - await this.capsuleStore.setCapsuleArray( + await this.capsuleService.setCapsuleArray( contractAddress, noteValidationRequestsArrayBaseSlot, [], this.jobId, scope, ); - await this.capsuleStore.setCapsuleArray( + await this.capsuleService.setCapsuleArray( contractAddress, eventValidationRequestsArrayBaseSlot, [], @@ -608,14 +618,14 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // We read all log retrieval requests and process them all concurrently. This makes the process much faster as we // don't need to wait for the network round-trip. const logRetrievalRequests = ( - await this.capsuleStore.readCapsuleArray(contractAddress, logRetrievalRequestsArrayBaseSlot, this.jobId, scope) + await this.capsuleService.readCapsuleArray(contractAddress, logRetrievalRequestsArrayBaseSlot, this.jobId, scope) ).map(LogRetrievalRequest.fromFields); const logService = new LogService( this.aztecNode, this.anchorBlockHeader, this.keyStore, - this.capsuleStore, + this.capsuleService, this.recipientTaggingStore, this.senderAddressBookStore, this.addressStore, @@ -626,10 +636,16 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra const maybeLogRetrievalResponses = await logService.fetchLogsByTag(contractAddress, logRetrievalRequests); // Requests are cleared once we're done. - await this.capsuleStore.setCapsuleArray(contractAddress, logRetrievalRequestsArrayBaseSlot, [], this.jobId, scope); + await this.capsuleService.setCapsuleArray( + contractAddress, + logRetrievalRequestsArrayBaseSlot, + [], + this.jobId, + scope, + ); // The responses are stored as Option in a second CapsuleArray. - await this.capsuleStore.setCapsuleArray( + await this.capsuleService.setCapsuleArray( contractAddress, logRetrievalResponsesArrayBaseSlot, maybeLogRetrievalResponses.map(LogRetrievalResponse.toSerializedOption), @@ -653,7 +669,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // need scopes here, we just need a bit of shared memory to cross boundaries between Noir and TS. // At the same time, we don't want to allow any global scope access other than where backwards compatibility // forces us to. Hence we need the scope here to be artificial. - const requestCapsules = await this.capsuleStore.readCapsuleArray( + const requestCapsules = await this.capsuleService.readCapsuleArray( contractAddress, messageContextRequestsArrayBaseSlot, this.jobId, @@ -675,7 +691,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra ); // Leave response in response capsule array. - await this.capsuleStore.setCapsuleArray( + await this.capsuleService.setCapsuleArray( contractAddress, messageContextResponsesArrayBaseSlot, maybeMessageContexts.map(MessageContext.toSerializedOption), @@ -683,7 +699,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra scope, ); } finally { - await this.capsuleStore.setCapsuleArray( + await this.capsuleService.setCapsuleArray( contractAddress, messageContextRequestsArrayBaseSlot, [], @@ -698,23 +714,15 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // TODO(#10727): instead of this check that this.contractAddress is allowed to access the external DB throw new Error(`Contract ${contractAddress} is not allowed to access ${this.contractAddress}'s PXE DB`); } - this.capsuleStore.setCapsule(contractAddress, slot, capsule, this.jobId, scope); + this.capsuleService.setCapsule(contractAddress, slot, capsule, this.jobId, scope); } - public async getCapsule(contractAddress: AztecAddress, slot: Fr, scope: AztecAddress): Promise { + public getCapsule(contractAddress: AztecAddress, slot: Fr, scope: AztecAddress): Promise { if (!contractAddress.equals(this.contractAddress)) { // TODO(#10727): instead of this check that this.contractAddress is allowed to access the external DB throw new Error(`Contract ${contractAddress} is not allowed to access ${this.contractAddress}'s PXE DB`); } - const maybeTransientCapsule = this.capsules.find( - c => - c.contractAddress.equals(contractAddress) && - c.storageSlot.equals(slot) && - (c.scope ?? AztecAddress.ZERO).equals(scope), - )?.data; - - // TODO(#12425): On the following line, the pertinent capsule gets overshadowed by the transient one. Tackle this. - return maybeTransientCapsule ?? (await this.capsuleStore.getCapsule(contractAddress, slot, this.jobId, scope)); + return this.capsuleService.getCapsule(contractAddress, slot, this.jobId, scope, this.capsules); } public deleteCapsule(contractAddress: AztecAddress, slot: Fr, scope: AztecAddress): void { @@ -722,7 +730,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // TODO(#10727): instead of this check that this.contractAddress is allowed to access the external DB throw new Error(`Contract ${contractAddress} is not allowed to access ${this.contractAddress}'s PXE DB`); } - this.capsuleStore.deleteCapsule(contractAddress, slot, this.jobId, scope); + this.capsuleService.deleteCapsule(contractAddress, slot, this.jobId, scope); } public copyCapsule( @@ -736,7 +744,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra // TODO(#10727): instead of this check that this.contractAddress is allowed to access the external DB throw new Error(`Contract ${contractAddress} is not allowed to access ${this.contractAddress}'s PXE DB`); } - return this.capsuleStore.copyCapsule(contractAddress, srcSlot, dstSlot, numEntries, this.jobId, scope); + return this.capsuleService.copyCapsule(contractAddress, srcSlot, dstSlot, numEntries, this.jobId, scope); } /** diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index 01b1a2244195..a801f48c948a 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -13,6 +13,7 @@ import { type MockProxy, mock } from 'jest-mock-extended'; import { LogRetrievalRequest } from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; import { AddressStore } from '../storage/address_store/address_store.js'; +import { CapsuleService } from '../storage/capsule_store/capsule_service.js'; import { CapsuleStore } from '../storage/capsule_store/capsule_store.js'; import { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import { SenderAddressBookStore } from '../storage/tagging_store/sender_address_book_store.js'; @@ -53,7 +54,7 @@ describe('LogService', () => { aztecNode, anchorBlockHeader, keyStore, - capsuleStore, + new CapsuleService(capsuleStore, 'ALL_SCOPES'), recipientTaggingStore, senderAddressBookStore, addressStore, diff --git a/yarn-project/pxe/src/logs/log_service.ts b/yarn-project/pxe/src/logs/log_service.ts index df4645b37be5..0f830e018b3e 100644 --- a/yarn-project/pxe/src/logs/log_service.ts +++ b/yarn-project/pxe/src/logs/log_service.ts @@ -15,7 +15,7 @@ import type { BlockHeader } from '@aztec/stdlib/tx'; import type { LogRetrievalRequest } from '../contract_function_simulator/noir-structs/log_retrieval_request.js'; import { LogRetrievalResponse } from '../contract_function_simulator/noir-structs/log_retrieval_response.js'; import { AddressStore } from '../storage/address_store/address_store.js'; -import { CapsuleStore } from '../storage/capsule_store/capsule_store.js'; +import type { CapsuleService } from '../storage/capsule_store/capsule_service.js'; import type { RecipientTaggingStore } from '../storage/tagging_store/recipient_tagging_store.js'; import type { SenderAddressBookStore } from '../storage/tagging_store/sender_address_book_store.js'; import { @@ -31,7 +31,7 @@ export class LogService { private readonly aztecNode: AztecNode, private readonly anchorBlockHeader: BlockHeader, private readonly keyStore: KeyStore, - private readonly capsuleStore: CapsuleStore, + private readonly capsuleService: CapsuleService, private readonly recipientTaggingStore: RecipientTaggingStore, private readonly senderAddressBookStore: SenderAddressBookStore, private readonly addressStore: AddressStore, @@ -207,7 +207,7 @@ export class LogService { }); // TODO: This looks like it could belong more at the oracle interface level - return this.capsuleStore.appendToCapsuleArray( + return this.capsuleService.appendToCapsuleArray( contractAddress, capsuleArrayBaseSlot, pendingTaggedLogs, diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts new file mode 100644 index 000000000000..6cbe7a68b46c --- /dev/null +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts @@ -0,0 +1,148 @@ +import { Fr } from '@aztec/foundation/curves/bn254'; +import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; + +import { CapsuleService } from './capsule_service.js'; +import { CapsuleStore } from './capsule_store.js'; + +describe('CapsuleService', () => { + let contract: AztecAddress; + let allowedScope: AztecAddress; + let disallowedScope: AztecAddress; + let capsuleStore: CapsuleStore; + let capsuleService: CapsuleService; + + const jobId = 'test'; + + beforeEach(async () => { + contract = await AztecAddress.random(); + allowedScope = await AztecAddress.random(); + disallowedScope = await AztecAddress.random(); + capsuleStore = new CapsuleStore(await openTmpStore('capsule_service_test')); + capsuleService = new CapsuleService(capsuleStore, [allowedScope]); + }); + + describe('scope enforcement', () => { + const slot = new Fr(1); + const capsule = [new Fr(42)]; + + it('setCapsule rejects a disallowed scope', () => { + expect(() => capsuleService.setCapsule(contract, slot, capsule, jobId, disallowedScope)).toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('getCapsule rejects a disallowed scope', async () => { + await expect(capsuleService.getCapsule(contract, slot, jobId, disallowedScope)).rejects.toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('deleteCapsule rejects a disallowed scope', () => { + expect(() => capsuleService.deleteCapsule(contract, slot, jobId, disallowedScope)).toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('copyCapsule rejects a disallowed scope', () => { + expect(() => capsuleService.copyCapsule(contract, slot, new Fr(5), 1, jobId, disallowedScope)).toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('appendToCapsuleArray rejects a disallowed scope', () => { + expect(() => capsuleService.appendToCapsuleArray(contract, slot, [capsule], jobId, disallowedScope)).toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('readCapsuleArray rejects a disallowed scope', () => { + expect(() => capsuleService.readCapsuleArray(contract, slot, jobId, disallowedScope)).toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('setCapsuleArray rejects a disallowed scope', () => { + expect(() => capsuleService.setCapsuleArray(contract, slot, [capsule], jobId, disallowedScope)).toThrow( + 'is not in the allowed scopes list', + ); + }); + + it('allows operations with an allowed scope', async () => { + const scope = allowedScope; + + // setCapsule + getCapsule + capsuleService.setCapsule(contract, slot, capsule, jobId, scope); + expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toEqual(capsule); + + // deleteCapsule + capsuleService.deleteCapsule(contract, slot, jobId, scope); + expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toBeNull(); + + // copyCapsule + capsuleService.setCapsule(contract, slot, capsule, jobId, scope); + await capsuleService.copyCapsule(contract, slot, new Fr(5), 1, jobId, scope); + expect(await capsuleService.getCapsule(contract, new Fr(5), jobId, scope)).toEqual(capsule); + + // appendToCapsuleArray + readCapsuleArray + const baseSlot = new Fr(10); + await capsuleService.appendToCapsuleArray(contract, baseSlot, [capsule], jobId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual([capsule]); + + // setCapsuleArray + const newArray = [capsule, capsule]; + await capsuleService.setCapsuleArray(contract, baseSlot, newArray, jobId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual(newArray); + }); + + it('ALL_SCOPES allows any scope', async () => { + const allScopesService = new CapsuleService(capsuleStore, 'ALL_SCOPES'); + const randomScope = await AztecAddress.random(); + + allScopesService.setCapsule(contract, slot, capsule, jobId, randomScope); + const result = await allScopesService.getCapsule(contract, slot, jobId, randomScope); + expect(result).toEqual(capsule); + }); + + it('address zero is always allowed even if not in the scopes list', async () => { + const scope = AztecAddress.ZERO; + + // setCapsule + getCapsule + capsuleService.setCapsule(contract, slot, capsule, jobId, scope); + expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toEqual(capsule); + + // deleteCapsule + capsuleService.deleteCapsule(contract, slot, jobId, scope); + expect(await capsuleService.getCapsule(contract, slot, jobId, scope)).toBeNull(); + + // copyCapsule + capsuleService.setCapsule(contract, slot, capsule, jobId, scope); + await capsuleService.copyCapsule(contract, slot, new Fr(5), 1, jobId, scope); + expect(await capsuleService.getCapsule(contract, new Fr(5), jobId, scope)).toEqual(capsule); + + // appendToCapsuleArray + readCapsuleArray + const baseSlot = new Fr(10); + await capsuleService.appendToCapsuleArray(contract, baseSlot, [capsule], jobId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual([capsule]); + + // setCapsuleArray + const newArray = [capsule, capsule]; + await capsuleService.setCapsuleArray(contract, baseSlot, newArray, jobId, scope); + expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual(newArray); + }); + + it('empty allowed scopes rejects requests', async () => { + const noScopesService = new CapsuleService(capsuleStore, []); + const scope = allowedScope; + const err = 'is not in the allowed scopes list'; + + expect(() => noScopesService.setCapsule(contract, slot, capsule, jobId, scope)).toThrow(err); + await expect(noScopesService.getCapsule(contract, slot, jobId, scope)).rejects.toThrow(err); + expect(() => noScopesService.deleteCapsule(contract, slot, jobId, scope)).toThrow(err); + expect(() => noScopesService.copyCapsule(contract, slot, new Fr(5), 1, jobId, scope)).toThrow(err); + expect(() => noScopesService.appendToCapsuleArray(contract, slot, [capsule], jobId, scope)).toThrow(err); + expect(() => noScopesService.readCapsuleArray(contract, slot, jobId, scope)).toThrow(err); + expect(() => noScopesService.setCapsuleArray(contract, slot, [capsule], jobId, scope)).toThrow(err); + }); + }); +}); diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts new file mode 100644 index 000000000000..4ba3c2084b95 --- /dev/null +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts @@ -0,0 +1,91 @@ +import type { Fr } from '@aztec/foundation/curves/bn254'; +import { AztecAddress } from '@aztec/stdlib/aztec-address'; +import type { Capsule } from '@aztec/stdlib/tx'; + +import type { AccessScopes } from '../../access_scopes.js'; +import type { CapsuleStore } from './capsule_store.js'; + +/** + * Wraps a CapsuleStore with scope-based access control. Each operation asserts that the requested scope is in the + * allowed scopes list before delegating to the underlying store. + */ +export class CapsuleService { + constructor( + private readonly capsuleStore: CapsuleStore, + private readonly allowedScopes: AccessScopes, + ) {} + + setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], jobId: string, scope: AztecAddress) { + assertAllowedScope(scope, this.allowedScopes); + this.capsuleStore.setCapsule(contractAddress, slot, capsule, jobId, scope); + } + + async getCapsule( + contractAddress: AztecAddress, + slot: Fr, + jobId: string, + scope: AztecAddress, + transientCapsules?: Capsule[], + ): Promise { + assertAllowedScope(scope, this.allowedScopes); + + // TODO(#12425): On the following line, the pertinent capsule gets overshadowed by the transient one. Tackle this. + const maybeTransientCapsule = transientCapsules?.find( + c => + c.contractAddress.equals(contractAddress) && + c.storageSlot.equals(slot) && + (c.scope ?? AztecAddress.ZERO).equals(scope), + )?.data; + + return maybeTransientCapsule ?? (await this.capsuleStore.getCapsule(contractAddress, slot, jobId, scope)); + } + + deleteCapsule(contractAddress: AztecAddress, slot: Fr, jobId: string, scope: AztecAddress) { + assertAllowedScope(scope, this.allowedScopes); + this.capsuleStore.deleteCapsule(contractAddress, slot, jobId, scope); + } + + copyCapsule( + contractAddress: AztecAddress, + srcSlot: Fr, + dstSlot: Fr, + numEntries: number, + jobId: string, + scope: AztecAddress, + ): Promise { + assertAllowedScope(scope, this.allowedScopes); + return this.capsuleStore.copyCapsule(contractAddress, srcSlot, dstSlot, numEntries, jobId, scope); + } + + appendToCapsuleArray( + contractAddress: AztecAddress, + baseSlot: Fr, + content: Fr[][], + jobId: string, + scope: AztecAddress, + ): Promise { + assertAllowedScope(scope, this.allowedScopes); + return this.capsuleStore.appendToCapsuleArray(contractAddress, baseSlot, content, jobId, scope); + } + + readCapsuleArray(contractAddress: AztecAddress, baseSlot: Fr, jobId: string, scope: AztecAddress): Promise { + assertAllowedScope(scope, this.allowedScopes); + return this.capsuleStore.readCapsuleArray(contractAddress, baseSlot, jobId, scope); + } + + setCapsuleArray(contractAddress: AztecAddress, baseSlot: Fr, content: Fr[][], jobId: string, scope: AztecAddress) { + assertAllowedScope(scope, this.allowedScopes); + return this.capsuleStore.setCapsuleArray(contractAddress, baseSlot, content, jobId, scope); + } +} + +function assertAllowedScope(scope: AztecAddress, allowedScopes: AccessScopes) { + if (allowedScopes === 'ALL_SCOPES' || scope.equals(AztecAddress.ZERO)) { + return; + } + if (!allowedScopes.some(allowed => allowed.equals(scope))) { + throw new Error( + `Scope ${scope.toString()} is not in the allowed scopes list: [${allowedScopes.map(s => s.toString()).join(', ')}]. See https://docs.aztec.network/errors/10`, + ); + } +} diff --git a/yarn-project/pxe/src/storage/capsule_store/index.ts b/yarn-project/pxe/src/storage/capsule_store/index.ts index accfb15afdc9..ab6c6222a925 100644 --- a/yarn-project/pxe/src/storage/capsule_store/index.ts +++ b/yarn-project/pxe/src/storage/capsule_store/index.ts @@ -1 +1,2 @@ +export { CapsuleService } from './capsule_service.js'; export { CapsuleStore } from './capsule_store.js'; diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index a03b02069353..fb40853bb609 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -15,6 +15,7 @@ import type { KeyStore } from '@aztec/key-store'; import type { AccessScopes } from '@aztec/pxe/client/lazy'; import { AddressStore, + CapsuleService, CapsuleStore, type ContractStore, type ContractSyncService, @@ -382,7 +383,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl senderTaggingStore: this.senderTaggingStore, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: new CapsuleService(this.capsuleStore, effectiveScopes), privateEventStore: this.privateEventStore, contractSyncService: this.stateMachine.contractSyncService, jobId, @@ -748,7 +749,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl aztecNode: this.stateMachine.node, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: new CapsuleService(this.capsuleStore, scopes), privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, contractSyncService: this.contractSyncService, diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 71958ef8740b..6da13ce61304 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -7,6 +7,7 @@ import type { AccessScopes } from '@aztec/pxe/client/lazy'; import { AddressStore, AnchorBlockStore, + CapsuleService, CapsuleStore, ContractStore, ContractSyncService, @@ -378,7 +379,7 @@ export class TXESession implements TXESessionStateHandler { senderTaggingStore: this.senderTaggingStore, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: new CapsuleService(this.capsuleStore, 'ALL_SCOPES'), privateEventStore: this.privateEventStore, contractSyncService: this.stateMachine.contractSyncService, jobId: this.currentJobId, @@ -450,7 +451,7 @@ export class TXESession implements TXESessionStateHandler { aztecNode: this.stateMachine.node, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: new CapsuleService(this.capsuleStore, 'ALL_SCOPES'), privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, contractSyncService: this.contractSyncService, @@ -543,7 +544,7 @@ export class TXESession implements TXESessionStateHandler { aztecNode: this.stateMachine.node, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleStore: this.capsuleStore, + capsuleService: new CapsuleService(this.capsuleStore, scopes), privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, contractSyncService: this.contractSyncService, From 314078eb48e6980169d205385ef2cb8c00cb51b8 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 30 Mar 2026 17:33:57 +0000 Subject: [PATCH 15/18] fix: resolve cherry-pick conflicts --- docs/docs-developers/docs/resources/migration_notes.md | 3 --- docs/netlify.toml | 3 --- 2 files changed, 6 deletions(-) diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index e83bb7a2f5bd..eeb6b727aee8 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,8 +9,6 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD -<<<<<<< HEAD -======= ### [PXE] Capsule operations are now scope-enforced at the PXE level The PXE now enforces that capsule operations can only access scopes that were authorized for the current execution. If a contract attempts to access a capsule scope that is not in its allowed scopes list, the PXE will throw an error: @@ -25,7 +23,6 @@ The zero address (`AztecAddress::zero()`) is always allowed regardless of the sc ## 4.2.0-aztecnr-rc.2 ->>>>>>> 57d3bd09cf (fix: restrict access to scoped capsules (#22113)) ### Custom token FPCs removed from default public setup allowlist Token contract functions (like `transfer_in_public` and `_increase_public_balance`) have been removed from the default public setup allowlist. FPCs that accept custom tokens (like the reference `FPC` contract) will not work on public networks, because their setup-phase calls to these functions will be rejected. Token class IDs change with each aztec-nr release, making it impractical to maintain them in the allowlist. diff --git a/docs/netlify.toml b/docs/netlify.toml index aaeec84ee7e9..a2908957123d 100644 --- a/docs/netlify.toml +++ b/docs/netlify.toml @@ -805,8 +805,6 @@ # PXE: incompatible oracle version between contract and PXE from = "/errors/8" to = "/developers/docs/foundational-topics/pxe" -<<<<<<< HEAD -======= [[redirects]] # CLI: aztec dep version in Nargo.toml does not match the CLI version @@ -817,4 +815,3 @@ # PXE: capsule operation attempted with a scope not in the allowed scopes list from = "/errors/10" to = "/developers/docs/aztec-nr/framework-description/advanced/how_to_use_capsules" ->>>>>>> 57d3bd09cf (fix: restrict access to scoped capsules (#22113)) From 19daacc314044a836bbe4200399e449f848179f9 Mon Sep 17 00:00:00 2001 From: Martin Verzilli Date: Mon, 30 Mar 2026 17:32:27 +0200 Subject: [PATCH 16/18] feat!: remove ALL_SCOPES (#22136) This is a breaking change in the interface between PXE and wallets. ALL_SCOPES allowed wallets to request PXE to run operations "for all its known accounts". This PR removes that option and forces all layers of the system to explicitly state which addresses to use as scope by providing an array of addresses. An empty array now means "no account in scope" (it used to default to "ALL_SCOPES" particularly when executing utilities). --- .../docs/resources/migration_notes.md | 31 +++++ yarn-project/cli-wallet/src/cmds/check_tx.ts | 5 +- yarn-project/cli-wallet/src/utils/wallet.ts | 7 +- yarn-project/pxe/src/access_scopes.ts | 9 -- .../contract_function_simulator.ts | 5 +- .../oracle/oracle_version_is_checked.test.ts | 4 +- .../oracle/private_execution.test.ts | 18 ++- .../oracle/private_execution_oracle.ts | 5 +- .../oracle/utility_execution.test.ts | 20 ++-- .../oracle/utility_execution_oracle.ts | 22 ++-- .../contract_sync_service.test.ts | 110 +++++++----------- .../contract_sync/contract_sync_service.ts | 52 +++------ yarn-project/pxe/src/contract_sync/helpers.ts | 5 +- yarn-project/pxe/src/debug/pxe_debug_utils.ts | 6 +- .../src/entrypoints/client/bundle/index.ts | 1 - .../pxe/src/entrypoints/client/lazy/index.ts | 1 - .../pxe/src/entrypoints/server/index.ts | 1 - yarn-project/pxe/src/logs/log_service.test.ts | 2 +- .../pxe/src/notes/note_service.test.ts | 16 +-- yarn-project/pxe/src/notes/note_service.ts | 5 +- yarn-project/pxe/src/notes_filter.ts | 4 +- yarn-project/pxe/src/pxe.ts | 18 ++- .../capsule_store/capsule_service.test.ts | 9 -- .../storage/capsule_store/capsule_service.ts | 11 +- .../src/storage/note_store/note_store.test.ts | 109 +++++++++++------ .../pxe/src/storage/note_store/note_store.ts | 7 +- .../oracle/txe_oracle_top_level_context.ts | 9 +- yarn-project/txe/src/state_machine/index.ts | 3 - yarn-project/txe/src/txe_session.ts | 25 ++-- .../wallet-sdk/src/base-wallet/base_wallet.ts | 4 +- 30 files changed, 253 insertions(+), 271 deletions(-) delete mode 100644 yarn-project/pxe/src/access_scopes.ts diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index eeb6b727aee8..0017a6806288 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -9,6 +9,37 @@ Aztec is in active development. Each version may introduce breaking changes that ## TBD +### [PXE] `simulateTx`, `executeUtility`, `profileTx`, and `proveTx` no longer accept `scopes: 'ALL_SCOPES'` + +The `AccessScopes` type (`'ALL_SCOPES' | AztecAddress[]`) has been removed. The `scopes` field in `SimulateTxOpts`, +`ExecuteUtilityOpts`, and `ProfileTxOpts` now requires an explicit `AztecAddress[]`. Callers that previously passed +`'ALL_SCOPES'` must now specify which addresses will be in scope for the call. + +**Migration:** + +```diff ++ const accounts = await pxe.getRegisteredAccounts(); ++ const scopes = accounts.map(a => a.address); + + // simulateTx +- await pxe.simulateTx(txRequest, { simulatePublic: true, scopes: 'ALL_SCOPES' }); ++ await pxe.simulateTx(txRequest, { simulatePublic: true, scopes }); + + // executeUtility +- await pxe.executeUtility(call, { scopes: 'ALL_SCOPES' }); ++ await pxe.executeUtility(call, { scopes }); + + // profileTx +- await pxe.profileTx(txRequest, { profileMode: 'full', scopes: 'ALL_SCOPES' }); ++ await pxe.profileTx(txRequest, { profileMode: 'full', scopes }); + + // proveTx +- await pxe.proveTx(txRequest, 'ALL_SCOPES'); ++ await pxe.proveTx(txRequest, scopes); +``` + +**Impact**: Any code passing `'ALL_SCOPES'` to `simulateTx`, `executeUtility`, `profileTx`, or `proveTx` will fail to compile. Replace with an explicit array of account addresses. + ### [PXE] Capsule operations are now scope-enforced at the PXE level The PXE now enforces that capsule operations can only access scopes that were authorized for the current execution. If a contract attempts to access a capsule scope that is not in its allowed scopes list, the PXE will throw an error: diff --git a/yarn-project/cli-wallet/src/cmds/check_tx.ts b/yarn-project/cli-wallet/src/cmds/check_tx.ts index 1ff5ef54dbfe..749633c9422b 100644 --- a/yarn-project/cli-wallet/src/cmds/check_tx.ts +++ b/yarn-project/cli-wallet/src/cmds/check_tx.ts @@ -1,5 +1,5 @@ import type { ContractArtifact } from '@aztec/aztec.js/abi'; -import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { AztecAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import type { AztecNode } from '@aztec/aztec.js/node'; import { ProtocolContractAddress } from '@aztec/aztec.js/protocol'; @@ -87,12 +87,13 @@ async function inspectTx(wallet: CLIWallet, aztecNode: AztecNode, txHash: TxHash // Nullifiers const nullifierCount = effects.nullifiers.length; const { deployNullifiers, initNullifiers, classNullifiers } = await getKnownNullifiers(wallet, artifactMap); + const accounts = (await wallet.getAccounts()).map(a => a.item); if (nullifierCount > 0) { log(' Nullifiers:'); for (const nullifier of effects.nullifiers) { const deployed = deployNullifiers[nullifier.toString()]; const note = deployed - ? (await wallet.getNotes({ siloedNullifier: nullifier, contractAddress: deployed, scopes: 'ALL_SCOPES' }))[0] + ? (await wallet.getNotes({ siloedNullifier: nullifier, contractAddress: deployed, scopes: accounts }))[0] : undefined; const initialized = initNullifiers[nullifier.toString()]; const registered = classNullifiers[nullifier.toString()]; diff --git a/yarn-project/cli-wallet/src/utils/wallet.ts b/yarn-project/cli-wallet/src/utils/wallet.ts index 3768dc272267..d339a4d82026 100644 --- a/yarn-project/cli-wallet/src/utils/wallet.ts +++ b/yarn-project/cli-wallet/src/utils/wallet.ts @@ -56,7 +56,12 @@ export class CLIWallet extends BaseWallet { override async getAccounts(): Promise[]> { const accounts = (await this.db?.listAliases('accounts')) ?? []; - return Promise.resolve(accounts.map(({ key, value }) => ({ alias: value, item: AztecAddress.fromString(key) }))); + return Promise.resolve( + accounts.map(({ key, value }) => { + const alias = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key; + return { alias, item: AztecAddress.fromString(value) }; + }), + ); } private async createCancellationTxExecutionRequest( diff --git a/yarn-project/pxe/src/access_scopes.ts b/yarn-project/pxe/src/access_scopes.ts deleted file mode 100644 index 9ea570de8e13..000000000000 --- a/yarn-project/pxe/src/access_scopes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AztecAddress } from '@aztec/stdlib/aztec-address'; - -/** - * Controls which accounts' private state and keys are accessible during execution. - * - `'ALL_SCOPES'`: All registered accounts' private state and keys are accessible. - * - `AztecAddress[]` with entries: Only the specified accounts' private state and keys are accessible. - * - `[]` (empty array): Deny-all. No private state is visible and no keys are accessible. - */ -export type AccessScopes = 'ALL_SCOPES' | AztecAddress[]; diff --git a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts index 6ab6eec6020f..5e3975e36d1e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts +++ b/yarn-project/pxe/src/contract_function_simulator/contract_function_simulator.ts @@ -89,7 +89,6 @@ import { getFinalMinRevertibleSideEffectCounter, } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../access_scopes.js'; import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; import type { MessageContextService } from '../messages/message_context_service.js'; import type { AddressStore } from '../storage/address_store/address_store.js'; @@ -123,7 +122,7 @@ export type ContractSimulatorRunOpts = { /** The address used as a tagging sender when emitting private logs. */ senderForTags?: AztecAddress; /** The accounts whose notes we can access in this call. */ - scopes: AccessScopes; + scopes: AztecAddress[]; /** The job ID for staged writes. */ jobId: string; }; @@ -320,7 +319,7 @@ export class ContractFunctionSimulator { call: FunctionCall, authwits: AuthWitness[], anchorBlockHeader: BlockHeader, - scopes: AccessScopes, + scopes: AztecAddress[], jobId: string, ): Promise<{ result: Fr[]; offchainEffects: OffchainEffect[] }> { const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata(call.to, call.selector); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts index 7815f5ae9663..983ad39a26cf 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/oracle_version_is_checked.test.ts @@ -149,7 +149,7 @@ describe('Oracle Version Check test suite', () => { anchorBlockHeader, senderForTags, jobId: 'test', - scopes: 'ALL_SCOPES', + scopes: [], }); expect(assertCompatibleOracleVersionSpy).toHaveBeenCalledTimes(1); @@ -206,7 +206,7 @@ describe('Oracle Version Check test suite', () => { messageContextService, contractSyncService, jobId: 'test', - scopes: 'ALL_SCOPES', + scopes: [], }); }); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts index 5650a7f13cde..9df391d1a020 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution.test.ts @@ -219,7 +219,7 @@ describe('Private Execution test suite', () => { anchorBlockHeader, senderForTags, jobId: TEST_JOB_ID, - scopes: 'ALL_SCOPES', + scopes: [owner], }); }; @@ -324,8 +324,7 @@ describe('Private Execution test suite', () => { // Configure mock to actually perform sync_state calls (needed for nested call tests) contractSyncService.ensureContractSynced.mockImplementation( async (contractAddress, functionToInvokeAfterSync, utilityExecutor, anchorBlockHeader, jobId, scopes) => { - const scopeAddresses = scopes === 'ALL_SCOPES' ? [owner] : scopes; - for (const scope of scopeAddresses) { + for (const scope of scopes) { await syncState( contractAddress, contractStore, @@ -384,6 +383,19 @@ describe('Private Execution test suite', () => { keyStore.getAccounts.mockResolvedValue([owner, recipient, senderForTags]); + keyStore.accountHasKey.mockImplementation(async (account: AztecAddress, pkMHash: Fr) => { + if (account.equals(owner)) { + return pkMHash.equals(await ownerCompleteAddress.publicKeys.masterNullifierPublicKey.hash()); + } + if (account.equals(recipient)) { + return pkMHash.equals(await recipientCompleteAddress.publicKeys.masterNullifierPublicKey.hash()); + } + if (account.equals(senderForTags)) { + return pkMHash.equals(await senderForTagsCompleteAddress.publicKeys.masterNullifierPublicKey.hash()); + } + return false; + }); + keyStore.getKeyValidationRequest.mockImplementation(async (pkMHash: Fr, contractAddress: AztecAddress) => { if (pkMHash.equals(await ownerCompleteAddress.publicKeys.masterNullifierPublicKey.hash())) { return Promise.resolve( diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts index 1ccdae11d8b0..dcadd6ca3366 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/private_execution_oracle.ts @@ -25,7 +25,6 @@ import { type TxContext, } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../../access_scopes.js'; import { NoteService } from '../../notes/note_service.js'; import type { SenderTaggingStore } from '../../storage/tagging_store/sender_tagging_store.js'; import { syncSenderTaggingIndexes } from '../../tagging/index.js'; @@ -43,7 +42,7 @@ export type PrivateExecutionOracleArgs = Omit Promise; + utilityExecutor: (call: FunctionCall, scopes: AztecAddress[]) => Promise; executionCache: HashedValuesCache; noteCache: ExecutionNoteCache; taggingIndexCache: ExecutionTaggingIndexCache; @@ -76,7 +75,7 @@ export class PrivateExecutionOracle extends UtilityExecutionOracle implements IP private readonly argsHash: Fr; private readonly txContext: TxContext; private readonly callContext: CallContext; - private readonly utilityExecutor: (call: FunctionCall, scopes: AccessScopes) => Promise; + private readonly utilityExecutor: (call: FunctionCall, scopes: AztecAddress[]) => Promise; private readonly executionCache: HashedValuesCache; private readonly noteCache: ExecutionNoteCache; private readonly taggingIndexCache: ExecutionTaggingIndexCache; diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts index 7c22717d2bbe..c68a606f5c5e 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution.test.ts @@ -232,12 +232,16 @@ describe('Utility Execution test suite', () => { let utilityExecutionOracle: UtilityExecutionOracle; const syncedBlockNumber = 100; + let scope: AztecAddress; + beforeEach(async () => { contractAddress = await AztecAddress.random(); anchorBlockHeader = BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(syncedBlockNumber) }), }); + scope = await AztecAddress.random(); + utilityExecutionOracle = new UtilityExecutionOracle({ contractAddress, authWitnesses: [], @@ -250,12 +254,12 @@ describe('Utility Execution test suite', () => { aztecNode, recipientTaggingStore, senderAddressBookStore, - capsuleService: new CapsuleService(capsuleStore, 'ALL_SCOPES'), + capsuleService: new CapsuleService(capsuleStore, [scope]), privateEventStore, messageContextService, contractSyncService, jobId: 'test-job-id', - scopes: 'ALL_SCOPES', + scopes: [scope], }); }); @@ -268,8 +272,7 @@ describe('Utility Execution test suite', () => { }); describe('capsules', () => { - it('forwards scope to the capsule store', async () => { - const scope = await AztecAddress.random(); + it('forwards scope to the capsule service', async () => { const slot = Fr.random(); const srcSlot = Fr.random(); const dstSlot = Fr.random(); @@ -317,12 +320,12 @@ describe('Utility Execution test suite', () => { aztecNode, recipientTaggingStore, senderAddressBookStore, - capsuleService: new CapsuleService(capsuleStore, 'ALL_SCOPES'), + capsuleService: new CapsuleService(capsuleStore, [scope]), privateEventStore, messageContextService, contractSyncService, jobId: 'test-job-id', - scopes: 'ALL_SCOPES', + scopes: [scope], }); capsuleStore.getCapsule.mockResolvedValueOnce(persisted); @@ -358,7 +361,6 @@ describe('Utility Execution test suite', () => { describe('resolveMessageContexts', () => { const requestSlot = Fr.random(); const responseSlot = Fr.random(); - const scope = AztecAddress.fromBigInt(42n); it('throws when contractAddress does not match', async () => { const wrongAddress = await AztecAddress.random(); @@ -498,12 +500,12 @@ describe('Utility Execution test suite', () => { aztecNode, recipientTaggingStore, senderAddressBookStore, - capsuleService: new CapsuleService(capsuleStore, 'ALL_SCOPES'), + capsuleService: new CapsuleService(capsuleStore, []), privateEventStore, messageContextService, contractSyncService, jobId: 'test-job-id', - scopes: 'ALL_SCOPES', + scopes: [], }); const oracleA = makeOracle(contractAddressA); diff --git a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts index 6b7370186f54..512ca45ab9ec 100644 --- a/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts +++ b/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts @@ -21,7 +21,6 @@ import type { NoteStatus } from '@aztec/stdlib/note'; import { MerkleTreeId, type NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; import type { BlockHeader, Capsule, OffchainEffect } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../../access_scopes.js'; import { createContractLogger, logContractMessage, stripAztecnrLogPrefix } from '../../contract_logging.js'; import type { ContractSyncService } from '../../contract_sync/contract_sync_service.js'; import { EventService } from '../../events/event_service.js'; @@ -65,7 +64,7 @@ export type UtilityExecutionOracleArgs = { contractSyncService: ContractSyncService; jobId: string; log?: ReturnType; - scopes: AccessScopes; + scopes: AztecAddress[]; }; /** @@ -96,7 +95,7 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra protected readonly contractSyncService: ContractSyncService; protected readonly jobId: string; protected logger: ReturnType; - protected readonly scopes: AccessScopes; + protected readonly scopes: AztecAddress[]; constructor(args: UtilityExecutionOracleArgs) { this.contractAddress = args.contractAddress; @@ -166,18 +165,15 @@ export class UtilityExecutionOracle implements IMiscOracle, IUtilityExecutionOra * @throws If scopes are defined and the account is not in the scopes. */ public async getKeyValidationRequest(pkMHash: Fr): Promise { - // If scopes are defined, check that the key belongs to an account in the scopes. - if (this.scopes !== 'ALL_SCOPES' && this.scopes.length > 0) { - let hasAccess = false; - for (let i = 0; i < this.scopes.length && !hasAccess; i++) { - if (await this.keyStore.accountHasKey(this.scopes[i], pkMHash)) { - hasAccess = true; - } - } - if (!hasAccess) { - throw new Error(`Key validation request denied: no scoped account has a key with hash ${pkMHash.toString()}.`); + let hasAccess = false; + for (let i = 0; i < this.scopes.length && !hasAccess; i++) { + if (await this.keyStore.accountHasKey(this.scopes[i], pkMHash)) { + hasAccess = true; } } + if (!hasAccess) { + throw new Error(`Key validation request denied: no scoped account has a key with hash ${pkMHash.toString()}.`); + } return this.keyStore.getKeyValidationRequest(pkMHash, this.contractAddress); } diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts index 0ed395978b75..6e70a2b12896 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.test.ts @@ -9,7 +9,6 @@ import { makeBlockHeader } from '@aztec/stdlib/testing'; import { jest } from '@jest/globals'; import { mock } from 'jest-mock-extended'; -import type { AccessScopes } from '../access_scopes.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; import { ContractSyncService } from './contract_sync_service.js'; @@ -19,7 +18,7 @@ describe('ContractSyncService', () => { let contractStore: ReturnType>; let noteStore: ReturnType>; let service: ContractSyncService; - let utilityExecutor: jest.Mock<(call: FunctionCall, scopes: AccessScopes) => Promise>; + let utilityExecutor: jest.Mock<(call: FunctionCall, scopes: AztecAddress[]) => Promise>; const contractAddress = AztecAddress.fromBigInt(100n); const scopeA = AztecAddress.fromBigInt(200n); @@ -30,7 +29,7 @@ describe('ContractSyncService', () => { beforeEach(() => { utilityExecutor = jest - .fn<(call: FunctionCall, scopes: AccessScopes) => Promise>() + .fn<(call: FunctionCall, scopes: AztecAddress[]) => Promise>() .mockResolvedValue(undefined); contractStore = mock(); @@ -62,13 +61,7 @@ describe('ContractSyncService', () => { // syncNoteNullifiers returns early when no notes noteStore.getNotes.mockResolvedValue([]); - service = new ContractSyncService( - aztecNode, - contractStore, - noteStore, - () => Promise.resolve([scopeA, scopeB]), - createLogger('test:contract-sync'), - ); + service = new ContractSyncService(aztecNode, contractStore, noteStore, createLogger('test:contract-sync')); }); describe('ensureContractSynced', () => { @@ -85,15 +78,11 @@ describe('ContractSyncService', () => { }); it('skips scope-specific syncs after syncing with all scopes', async () => { - await service.ensureContractSynced( - contractAddress, - null, - utilityExecutor, - anchorBlockHeader, - jobId, - 'ALL_SCOPES', - ); - // ALL_SCOPES resolves to [scopeA, scopeB] via getRegisteredAccounts, so syncState is called once per account + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); + // [scopeA, scopeB] syncs each scope individually expectSyncedScopes([scopeA], [scopeB]); // After syncing all scopes, scope-specific calls should be skipped @@ -102,18 +91,14 @@ describe('ContractSyncService', () => { expectSyncedScopes([scopeA], [scopeB]); }); - it('still syncs all scopes even after scope-specific sync', async () => { + it('only syncs unsynced scopes when requesting multiple', async () => { await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); - await service.ensureContractSynced( - contractAddress, - null, - utilityExecutor, - anchorBlockHeader, - jobId, - 'ALL_SCOPES', - ); - // ALL_SCOPES resolves to [scopeA, scopeB]; both are re-synced since ALL_SCOPES bypasses per-scope cache - expectSyncedScopes([scopeA], [scopeA], [scopeB]); + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); + // scopeA is already cached, so only scopeB is synced + expectSyncedScopes([scopeA], [scopeB]); }); it('empty scopes array skips sync entirely', async () => { @@ -277,63 +262,46 @@ describe('ContractSyncService', () => { expectSyncedScopes([scopeA], [scopeB], [scopeA], [scopeB]); }); - it('also invalidates the ALL_SCOPES entry', async () => { - // Sync ALL_SCOPES -- covers every account. Resolves to [scopeA, scopeB] via getRegisteredAccounts. - await service.ensureContractSynced( - contractAddress, - null, - utilityExecutor, - anchorBlockHeader, - jobId, - 'ALL_SCOPES', - ); + it('invalidating one scope does not affect the other', async () => { + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); expectSyncedScopes([scopeA], [scopeB]); - // Syncing scopeA is a no-op because ALL_SCOPES already covers it. + // Syncing scopeA is a no-op because it's already cached. await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); expectSyncedScopes([scopeA], [scopeB]); - // Invalidate scopeA -- this should also clear the ALL_SCOPES entry. + // Invalidate scopeA only. service.invalidateContractForScopes(contractAddress, [scopeA]); // Now syncing scopeA triggers a re-sync. await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [scopeA]); expectSyncedScopes([scopeA], [scopeB], [scopeA]); - // And syncing ALL_SCOPES also triggers a re-sync since it was invalidated too. - await service.ensureContractSynced( - contractAddress, - null, - utilityExecutor, - anchorBlockHeader, - jobId, - 'ALL_SCOPES', - ); - expectSyncedScopes([scopeA], [scopeB], [scopeA], [scopeA], [scopeB]); + // Syncing both scopes only re-syncs scopeA (already re-synced above is cached), scopeB is still cached. + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); + expectSyncedScopes([scopeA], [scopeB], [scopeA]); }); it('empty scopes is a no-op', async () => { - await service.ensureContractSynced( - contractAddress, - null, - utilityExecutor, - anchorBlockHeader, - jobId, - 'ALL_SCOPES', - ); + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); expectSyncedScopes([scopeA], [scopeB]); service.invalidateContractForScopes(contractAddress, []); - // ALL_SCOPES should still be cached since no scopes were invalidated. - await service.ensureContractSynced( - contractAddress, - null, - utilityExecutor, - anchorBlockHeader, - jobId, - 'ALL_SCOPES', - ); + // Both scopes should still be cached since no scopes were invalidated. + await service.ensureContractSynced(contractAddress, null, utilityExecutor, anchorBlockHeader, jobId, [ + scopeA, + scopeB, + ]); expectSyncedScopes([scopeA], [scopeB]); }); @@ -351,7 +319,7 @@ describe('ContractSyncService', () => { }); /** Asserts the utility executor was called exactly with the given sequence of scope arrays. */ - const expectSyncedScopes = (...expectedScopes: AccessScopes[]) => { + const expectSyncedScopes = (...expectedScopes: AztecAddress[][]) => { expect(utilityExecutor).toHaveBeenCalledTimes(expectedScopes.length); for (let i = 0; i < expectedScopes.length; i++) { const [, actualScopes] = utilityExecutor.mock.calls[i]; @@ -360,7 +328,7 @@ describe('ContractSyncService', () => { }; /** Asserts the utility executor was called exactly with the given sequence of [contractAddress, scopes] pairs. */ - const expectSyncedContracts = (...expected: [AztecAddress, AccessScopes][]) => { + const expectSyncedContracts = (...expected: [AztecAddress, AztecAddress[]][]) => { expect(utilityExecutor).toHaveBeenCalledTimes(expected.length); for (let i = 0; i < expected.length; i++) { const [call, actualScopes] = utilityExecutor.mock.calls[i]; diff --git a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts index 8708010c62e2..147bd1eb65df 100644 --- a/yarn-project/pxe/src/contract_sync/contract_sync_service.ts +++ b/yarn-project/pxe/src/contract_sync/contract_sync_service.ts @@ -4,7 +4,6 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { BlockHeader } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../access_scopes.js'; import type { StagedStore } from '../job_coordinator/job_coordinator.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; @@ -31,7 +30,6 @@ export class ContractSyncService implements StagedStore { private aztecNode: AztecNode, private contractStore: ContractStore, private noteStore: NoteStore, - private getRegisteredAccounts: () => Promise, private log: Logger, ) {} @@ -52,10 +50,10 @@ export class ContractSyncService implements StagedStore { async ensureContractSynced( contractAddress: AztecAddress, functionToInvokeAfterSync: FunctionSelector | null, - utilityExecutor: (call: FunctionCall, scopes: AccessScopes) => Promise, + utilityExecutor: (call: FunctionCall, scopes: AztecAddress[]) => Promise, anchorBlockHeader: BlockHeader, jobId: string, - scopes: AccessScopes, + scopes: AztecAddress[], ): Promise { if (this.#shouldSkipSync(jobId, contractAddress)) { return; @@ -75,33 +73,29 @@ export class ContractSyncService implements StagedStore { await this.#awaitSync(contractAddress, scopes); } - /** Clears sync cache entries for the given scopes of a contract. Also clears the ALL_SCOPES entry. */ + /** Clears sync cache entries for the given scopes of a contract. */ invalidateContractForScopes(contractAddress: AztecAddress, scopes: AztecAddress[]): void { if (scopes.length === 0) { return; } scopes.forEach(scope => this.syncedContracts.delete(toKey(contractAddress, scope))); - this.syncedContracts.delete(toKey(contractAddress, 'ALL_SCOPES')); } async #syncContract( contractAddress: AztecAddress, functionToInvokeAfterSync: FunctionSelector | null, - utilityExecutor: (call: FunctionCall, scopes: AccessScopes) => Promise, + utilityExecutor: (call: FunctionCall, scopes: AztecAddress[]) => Promise, anchorBlockHeader: BlockHeader, jobId: string, - scopes: AccessScopes, + scopes: AztecAddress[], ): Promise { this.log.debug(`Syncing contract ${contractAddress}`); - // Resolve ALL_SCOPES to actual registered accounts, since sync_state must be called once per account. - const scopeAddresses = scopes === 'ALL_SCOPES' ? await this.getRegisteredAccounts() : scopes; - await Promise.all([ // Call sync_state sequentially for each scope address — each invocation synchronizes one account's private // state using scoped capsule arrays. (async () => { - for (const scope of scopeAddresses) { + for (const scope of scopes) { await syncState( contractAddress, this.contractStore, @@ -147,11 +141,11 @@ export class ContractSyncService implements StagedStore { /** If there are unsynced scopes, starts sync and stores the promise in cache with error cleanup. */ #startSyncIfNeeded( contractAddress: AztecAddress, - scopes: AccessScopes, - syncFn: (scopesToSync: AccessScopes) => Promise, + scopes: AztecAddress[], + syncFn: (scopesToSync: AztecAddress[]) => Promise, ): void { - const scopesToSync = this.#getScopesToSync(contractAddress, scopes); - const keys = toKeys(contractAddress, scopesToSync); + const scopesToSync = scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope))); + const keys = scopesToSync.map(scope => toKey(contractAddress, scope)); if (keys.length === 0) { return; } @@ -162,31 +156,15 @@ export class ContractSyncService implements StagedStore { keys.forEach(key => this.syncedContracts.set(key, promise)); } - /** Filters out scopes that are already cached, returning only those that still need syncing. */ - #getScopesToSync(contractAddress: AztecAddress, scopes: AccessScopes): AccessScopes { - if (this.syncedContracts.has(toKey(contractAddress, 'ALL_SCOPES'))) { - // If we are already syncing all scopes, then return an empty list - return []; - } - if (scopes === 'ALL_SCOPES') { - return 'ALL_SCOPES'; - } - return scopes.filter(scope => !this.syncedContracts.has(toKey(contractAddress, scope))); - } - /** Collects all relevant scope promises (including in-flight ones from concurrent calls) and awaits them. */ - async #awaitSync(contractAddress: AztecAddress, scopes: AccessScopes): Promise { - const promises = toKeys(contractAddress, scopes) - .map(key => this.syncedContracts.get(key)) + async #awaitSync(contractAddress: AztecAddress, scopes: AztecAddress[]): Promise { + const promises = scopes + .map(scope => this.syncedContracts.get(toKey(contractAddress, scope))) .filter(p => p !== undefined); await Promise.all(promises); } } -function toKeys(contract: AztecAddress, scopes: AccessScopes) { - return scopes === 'ALL_SCOPES' ? [toKey(contract, scopes)] : scopes.map(scope => toKey(contract, scope)); -} - -function toKey(contract: AztecAddress, scope: AztecAddress | 'ALL_SCOPES') { - return scope === 'ALL_SCOPES' ? `${contract.toString()}:*` : `${contract.toString()}:${scope.toString()}`; +function toKey(contract: AztecAddress, scope: AztecAddress) { + return `${contract.toString()}:${scope.toString()}`; } diff --git a/yarn-project/pxe/src/contract_sync/helpers.ts b/yarn-project/pxe/src/contract_sync/helpers.ts index 8f437d10dd7f..f794527a32f9 100644 --- a/yarn-project/pxe/src/contract_sync/helpers.ts +++ b/yarn-project/pxe/src/contract_sync/helpers.ts @@ -6,7 +6,6 @@ import { DelayedPublicMutableValues, DelayedPublicMutableValuesWithHash } from ' import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import type { BlockHeader } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../access_scopes.js'; import { NoteService } from '../notes/note_service.js'; import type { ContractStore } from '../storage/contract_store/contract_store.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; @@ -43,7 +42,7 @@ export async function syncState( contractAddress: AztecAddress, contractStore: ContractStore, functionToInvokeAfterSync: FunctionSelector | null, - utilityExecutor: (privateSyncCall: FunctionCall, scopes: AccessScopes) => Promise, + utilityExecutor: (privateSyncCall: FunctionCall, scopes: AztecAddress[]) => Promise, noteStore: NoteStore, aztecNode: AztecNode, anchorBlockHeader: BlockHeader, @@ -60,7 +59,7 @@ export async function syncState( } const noteService = new NoteService(noteStore, aztecNode, anchorBlockHeader, jobId); - const scopes: AccessScopes = [scope]; + const scopes: AztecAddress[] = [scope]; // Both sync_state and syncNoteNullifiers interact with the note store, but running them in parallel is safe // because note store is designed to handle concurrent operations. diff --git a/yarn-project/pxe/src/debug/pxe_debug_utils.ts b/yarn-project/pxe/src/debug/pxe_debug_utils.ts index e5504328a611..592074cd37fe 100644 --- a/yarn-project/pxe/src/debug/pxe_debug_utils.ts +++ b/yarn-project/pxe/src/debug/pxe_debug_utils.ts @@ -1,9 +1,9 @@ import type { FunctionCall } from '@aztec/stdlib/abi'; import type { AuthWitness } from '@aztec/stdlib/auth-witness'; +import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { NoteDao } from '@aztec/stdlib/note'; import type { ContractOverrides } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../access_scopes.js'; import type { BlockSynchronizer } from '../block_synchronizer/block_synchronizer.js'; import type { ContractFunctionSimulator } from '../contract_function_simulator/contract_function_simulator.js'; import type { ContractSyncService } from '../contract_sync/contract_sync_service.js'; @@ -22,7 +22,7 @@ export class PXEDebugUtils { contractFunctionSimulator: ContractFunctionSimulator, call: FunctionCall, authWitnesses: AuthWitness[] | undefined, - scopes: AccessScopes, + scopes: AztecAddress[], jobId: string, ) => Promise; @@ -41,7 +41,7 @@ export class PXEDebugUtils { contractFunctionSimulator: ContractFunctionSimulator, call: FunctionCall, authWitnesses: AuthWitness[] | undefined, - scopes: AccessScopes, + scopes: AztecAddress[], jobId: string, ) => Promise, ) { diff --git a/yarn-project/pxe/src/entrypoints/client/bundle/index.ts b/yarn-project/pxe/src/entrypoints/client/bundle/index.ts index d854f0abf873..437bf2a74da3 100644 --- a/yarn-project/pxe/src/entrypoints/client/bundle/index.ts +++ b/yarn-project/pxe/src/entrypoints/client/bundle/index.ts @@ -1,4 +1,3 @@ -export * from '../../../access_scopes.js'; export * from '../../../notes_filter.js'; export * from '../../../pxe.js'; export * from '../../../config/index.js'; diff --git a/yarn-project/pxe/src/entrypoints/client/lazy/index.ts b/yarn-project/pxe/src/entrypoints/client/lazy/index.ts index 17b4025cbf74..3f417a8b5209 100644 --- a/yarn-project/pxe/src/entrypoints/client/lazy/index.ts +++ b/yarn-project/pxe/src/entrypoints/client/lazy/index.ts @@ -1,4 +1,3 @@ -export * from '../../../access_scopes.js'; export * from '../../../notes_filter.js'; export * from '../../../pxe.js'; export * from '../../../config/index.js'; diff --git a/yarn-project/pxe/src/entrypoints/server/index.ts b/yarn-project/pxe/src/entrypoints/server/index.ts index ffc0a3248e21..49a02ca88a4d 100644 --- a/yarn-project/pxe/src/entrypoints/server/index.ts +++ b/yarn-project/pxe/src/entrypoints/server/index.ts @@ -1,4 +1,3 @@ -export * from '../../access_scopes.js'; export * from '../../notes_filter.js'; export * from '../../pxe.js'; export * from '../../config/index.js'; diff --git a/yarn-project/pxe/src/logs/log_service.test.ts b/yarn-project/pxe/src/logs/log_service.test.ts index a801f48c948a..68678dc94cfc 100644 --- a/yarn-project/pxe/src/logs/log_service.test.ts +++ b/yarn-project/pxe/src/logs/log_service.test.ts @@ -54,7 +54,7 @@ describe('LogService', () => { aztecNode, anchorBlockHeader, keyStore, - new CapsuleService(capsuleStore, 'ALL_SCOPES'), + new CapsuleService(capsuleStore, []), recipientTaggingStore, senderAddressBookStore, addressStore, diff --git a/yarn-project/pxe/src/notes/note_service.test.ts b/yarn-project/pxe/src/notes/note_service.test.ts index d29529735c65..667fc26f2cea 100644 --- a/yarn-project/pxe/src/notes/note_service.test.ts +++ b/yarn-project/pxe/src/notes/note_service.test.ts @@ -41,13 +41,13 @@ describe('NoteService', () => { contractAddress = await AztecAddress.random(); - const notes = await noteStore.getNotes({ contractAddress, scopes: 'ALL_SCOPES' }, 'test'); + recipient = await keyStore.addAccount(new Fr(69), Fr.random()); + + const notes = await noteStore.getNotes({ contractAddress, scopes: [recipient.address] }, 'test'); expect(notes).toHaveLength(0); const accounts = await keyStore.getAccounts(); - expect(accounts).toHaveLength(0); - - recipient = await keyStore.addAccount(new Fr(69), Fr.random()); + expect(accounts).toHaveLength(1); setSyncedBlockNumber(BlockNumber(syncedBlockNumber)); }); @@ -65,7 +65,7 @@ describe('NoteService', () => { const nullifierIndex = randomDataInBlock(123n); aztecNode.findLeavesIndexes.mockResolvedValue([nullifierIndex]); - await noteService.syncNoteNullifiers(contractAddress, 'ALL_SCOPES'); + await noteService.syncNoteNullifiers(contractAddress, [recipient.address]); const remainingNotes = await noteStore.getNotes( { @@ -103,7 +103,7 @@ describe('NoteService', () => { // No nullifier found in merkle tree aztecNode.findLeavesIndexes.mockResolvedValue([undefined]); - await noteService.syncNoteNullifiers(contractAddress, 'ALL_SCOPES'); + await noteService.syncNoteNullifiers(contractAddress, [recipient.address]); const remainingNotes = await noteStore.getNotes( { @@ -148,7 +148,7 @@ describe('NoteService', () => { return Promise.resolve([undefined]); }); - await noteService.syncNoteNullifiers(contractAddress, 'ALL_SCOPES'); + await noteService.syncNoteNullifiers(contractAddress, [recipient.address]); // Verify note still exists const remainingNotes = await noteStore.getNotes( @@ -186,7 +186,7 @@ describe('NoteService', () => { const getNotesSpy = jest.spyOn(noteStore, 'getNotes'); - await noteService.syncNoteNullifiers(contractAddress, 'ALL_SCOPES'); + await noteService.syncNoteNullifiers(contractAddress, [recipient.address]); // Verify applyNullifiers was called once for all accounts expect(getNotesSpy).toHaveBeenCalledTimes(1); diff --git a/yarn-project/pxe/src/notes/note_service.ts b/yarn-project/pxe/src/notes/note_service.ts index dd50499c3ffd..bf839db370b8 100644 --- a/yarn-project/pxe/src/notes/note_service.ts +++ b/yarn-project/pxe/src/notes/note_service.ts @@ -7,7 +7,6 @@ import { Note, NoteDao, NoteStatus } from '@aztec/stdlib/note'; import { MerkleTreeId } from '@aztec/stdlib/trees'; import type { BlockHeader, TxHash } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../access_scopes.js'; import type { NoteStore } from '../storage/note_store/note_store.js'; export class NoteService { @@ -32,7 +31,7 @@ export class NoteService { owner: AztecAddress | undefined, storageSlot: Fr, status: NoteStatus, - scopes: AccessScopes, + scopes: AztecAddress[], ) { const noteDaos = await this.noteStore.getNotes( { @@ -71,7 +70,7 @@ export class NoteService { * * @param contractAddress - The contract whose notes should be checked and nullified. */ - public async syncNoteNullifiers(contractAddress: AztecAddress, scopes: AccessScopes): Promise { + public async syncNoteNullifiers(contractAddress: AztecAddress, scopes: AztecAddress[]): Promise { const anchorBlockHash = await this.anchorBlockHeader.hash(); const contractNotes = await this.noteStore.getNotes({ contractAddress, scopes }, this.jobId); diff --git a/yarn-project/pxe/src/notes_filter.ts b/yarn-project/pxe/src/notes_filter.ts index cdf6c3dc2bc1..18e2ab2ab9f5 100644 --- a/yarn-project/pxe/src/notes_filter.ts +++ b/yarn-project/pxe/src/notes_filter.ts @@ -2,8 +2,6 @@ import type { Fr } from '@aztec/foundation/curves/bn254'; import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { NoteStatus } from '@aztec/stdlib/note'; -import type { AccessScopes } from './access_scopes.js'; - /** * A filter used to fetch notes. * @remarks This filter is applied as an intersection of all its params. @@ -22,5 +20,5 @@ export type NotesFilter = { status?: NoteStatus; /** The siloed nullifier for the note. */ siloedNullifier?: Fr; - scopes: AccessScopes; + scopes: AztecAddress[]; }; diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 6d7b05ab88aa..39072be6823d 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -52,7 +52,6 @@ import { import { inspect } from 'util'; -import type { AccessScopes } from './access_scopes.js'; import { BlockSynchronizer } from './block_synchronizer/index.js'; import type { PXEConfig } from './config/index.js'; import { BenchmarkedNodeFactory } from './contract_function_simulator/benchmarked_node.js'; @@ -96,7 +95,7 @@ export type ProfileTxOpts = { /** If true, proof generation is skipped during profiling. Defaults to true. */ skipProofGeneration?: boolean; /** Addresses whose private state and keys are accessible during private execution. */ - scopes: AccessScopes; + scopes: AztecAddress[]; }; /** Options for PXE.simulateTx. */ @@ -112,7 +111,7 @@ export type SimulateTxOpts = { /** State overrides for the simulation, such as contract instances and artifacts. Requires skipKernels: true */ overrides?: SimulationOverrides; /** Addresses whose private state and keys are accessible during private execution */ - scopes: AccessScopes; + scopes: AztecAddress[]; }; /** Options for PXE.executeUtility. */ @@ -120,7 +119,7 @@ export type ExecuteUtilityOpts = { /** The authentication witnesses required for the function call. */ authwits?: AuthWitness[]; /** The accounts whose notes we can access in this call */ - scopes: AccessScopes; + scopes: AztecAddress[]; }; /** Args for PXE.create. */ @@ -214,7 +213,6 @@ export class PXE { node, contractStore, noteStore, - () => keyStore.getAccounts(), createLogger('pxe:contract_sync', bindings), ); const messageContextService = new MessageContextService(node); @@ -367,7 +365,7 @@ export class PXE { async #executePrivate( contractFunctionSimulator: ContractFunctionSimulator, txRequest: TxExecutionRequest, - scopes: AccessScopes, + scopes: AztecAddress[], jobId: string, ): Promise { const { origin: contractAddress, functionSelector } = txRequest; @@ -416,7 +414,7 @@ export class PXE { contractFunctionSimulator: ContractFunctionSimulator, call: FunctionCall, authWitnesses: AuthWitness[] | undefined, - scopes: AccessScopes, + scopes: AztecAddress[], jobId: string, ) { try { @@ -1041,7 +1039,7 @@ export class PXE { inspect(txRequest), `simulatePublic=${simulatePublic}`, `skipTxValidation=${skipTxValidation}`, - `scopes=${scopes === 'ALL_SCOPES' ? scopes : scopes.map(s => s.toString()).join(', ')}`, + `scopes=${scopes.map(s => s.toString()).join(', ')}`, ); } }); @@ -1053,7 +1051,7 @@ export class PXE { */ public executeUtility( call: FunctionCall, - { authwits, scopes }: ExecuteUtilityOpts = { scopes: 'ALL_SCOPES' }, + { authwits, scopes }: ExecuteUtilityOpts = { scopes: [] }, ): Promise { // We disable concurrent executions since those might execute oracles which read and write to the PXE stores (e.g. // to the capsules), and we need to prevent concurrent runs from interfering with one another (e.g. attempting to @@ -1111,7 +1109,7 @@ export class PXE { throw this.#contextualizeError( err, `executeUtility ${to}:${name}(${stringifiedArgs})`, - `scopes=${scopes === 'ALL_SCOPES' ? scopes : scopes.map(s => s.toString()).join(', ')}`, + `scopes=${scopes.map(s => s.toString()).join(', ')}`, ); } }); diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts index 6cbe7a68b46c..616c907de454 100644 --- a/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_service.test.ts @@ -95,15 +95,6 @@ describe('CapsuleService', () => { expect(await capsuleService.readCapsuleArray(contract, baseSlot, jobId, scope)).toEqual(newArray); }); - it('ALL_SCOPES allows any scope', async () => { - const allScopesService = new CapsuleService(capsuleStore, 'ALL_SCOPES'); - const randomScope = await AztecAddress.random(); - - allScopesService.setCapsule(contract, slot, capsule, jobId, randomScope); - const result = await allScopesService.getCapsule(contract, slot, jobId, randomScope); - expect(result).toEqual(capsule); - }); - it('address zero is always allowed even if not in the scopes list', async () => { const scope = AztecAddress.ZERO; diff --git a/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts b/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts index 4ba3c2084b95..cdb61859cb13 100644 --- a/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts +++ b/yarn-project/pxe/src/storage/capsule_store/capsule_service.ts @@ -2,7 +2,6 @@ import type { Fr } from '@aztec/foundation/curves/bn254'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { Capsule } from '@aztec/stdlib/tx'; -import type { AccessScopes } from '../../access_scopes.js'; import type { CapsuleStore } from './capsule_store.js'; /** @@ -12,7 +11,7 @@ import type { CapsuleStore } from './capsule_store.js'; export class CapsuleService { constructor( private readonly capsuleStore: CapsuleStore, - private readonly allowedScopes: AccessScopes, + private readonly allowedScopes: AztecAddress[], ) {} setCapsule(contractAddress: AztecAddress, slot: Fr, capsule: Fr[], jobId: string, scope: AztecAddress) { @@ -79,13 +78,13 @@ export class CapsuleService { } } -function assertAllowedScope(scope: AztecAddress, allowedScopes: AccessScopes) { - if (allowedScopes === 'ALL_SCOPES' || scope.equals(AztecAddress.ZERO)) { +function assertAllowedScope(scope: AztecAddress, allowedScopes: AztecAddress[]) { + if (scope.equals(AztecAddress.ZERO)) { return; } - if (!allowedScopes.some(allowed => allowed.equals(scope))) { + if (!allowedScopes.some((allowed: AztecAddress) => allowed.equals(scope))) { throw new Error( - `Scope ${scope.toString()} is not in the allowed scopes list: [${allowedScopes.map(s => s.toString()).join(', ')}]. See https://docs.aztec.network/errors/10`, + `Scope ${scope.toString()} is not in the allowed scopes list: [${allowedScopes.map((s: AztecAddress) => s.toString()).join(', ')}]. See https://docs.aztec.network/errors/10`, ); } } diff --git a/yarn-project/pxe/src/storage/note_store/note_store.test.ts b/yarn-project/pxe/src/storage/note_store/note_store.test.ts index 8fc1a287e518..01e38dee0a71 100644 --- a/yarn-project/pxe/src/storage/note_store/note_store.test.ts +++ b/yarn-project/pxe/src/storage/note_store/note_store.test.ts @@ -99,7 +99,7 @@ describe('NoteStore', () => { const noteStore = new NoteStore(store); await verifyAndCommitForEachJob(['pre-commit', 'post-commit'], noteStore, async (jobId: string) => { - const notes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, jobId); + const notes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, jobId); expect(Array.isArray(notes)).toBe(true); expect(notes).toHaveLength(0); }); @@ -123,8 +123,8 @@ describe('NoteStore', () => { const noteStore2 = new NoteStore(store); await verifyAndCommitForEachJob(['second-store', 'fresh-job'], noteStore2, async (jobId: string) => { - const notesA = await noteStore2.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, jobId); - const notesB = await noteStore2.getNotes({ contractAddress: CONTRACT_B, scopes: 'ALL_SCOPES' }, jobId); + const notesA = await noteStore2.getNotes({ contractAddress: CONTRACT_A, scopes: [FAKE_ADDRESS] }, jobId); + const notesB = await noteStore2.getNotes({ contractAddress: CONTRACT_B, scopes: [FAKE_ADDRESS] }, jobId); expect(nullifierSet(notesA)).toEqual(nullifierSet([SILOED_NULLIFIER_1])); expect(nullifierSet(notesB)).toEqual(nullifierSet([SILOED_NULLIFIER_2])); @@ -150,14 +150,14 @@ describe('NoteStore', () => { }); it('filters notes matching only the contractAddress', async () => { - const notes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const notes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, 'test'); // note1 and note2 match CONTRACT_A expect(nullifierSet(notes)).toEqual(nullifierSet([note1, note2])); }); it('filters notes matching contractAddress and storageSlot', async () => { const notes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, storageSlot: SLOT_Y, scopes: 'ALL_SCOPES' }, + { contractAddress: CONTRACT_A, storageSlot: SLOT_Y, scopes: [SCOPE_1, SCOPE_2] }, 'test', ); expect(nullifierSet(notes)).toEqual(nullifierSet([note2])); @@ -210,14 +210,14 @@ describe('NoteStore', () => { const nullifiers = [mkNullifier(note2)]; await expect(noteStore.applyNullifiers(nullifiers, 'test')).resolves.toEqual([note2]); - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, 'test'); expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note1])); const allNotes = await noteStore.getNotes( { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, - scopes: 'ALL_SCOPES', + scopes: [SCOPE_1, SCOPE_2], }, 'test', ); @@ -270,7 +270,7 @@ describe('NoteStore', () => { const filter = { contractAddress: CONTRACT_A, siloedNullifier: note1.siloedNullifier, - scopes: 'ALL_SCOPES' as const, + scopes: [SCOPE_1, SCOPE_2], }; const notes = await noteStore.getNotes(filter, 'test'); @@ -281,7 +281,7 @@ describe('NoteStore', () => { { contractAddress: CONTRACT_A, siloedNullifier: note2.siloedNullifier, - scopes: 'ALL_SCOPES', + scopes: [SCOPE_1, SCOPE_2], }, 'test', ); @@ -303,13 +303,13 @@ describe('NoteStore', () => { }); it('returns no notes when filtering by non-existing contractAddress', async () => { - const notes = await noteStore.getNotes({ contractAddress: FAKE_ADDRESS, scopes: 'ALL_SCOPES' }, 'test'); + const notes = await noteStore.getNotes({ contractAddress: FAKE_ADDRESS, scopes: [SCOPE_1, SCOPE_2] }, 'test'); expect(notes).toHaveLength(0); }); it('returns no notes when filtering by non-existing storageSlot', async () => { const notes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, storageSlot: NON_EXISTING_SLOT, scopes: 'ALL_SCOPES' }, + { contractAddress: CONTRACT_A, storageSlot: NON_EXISTING_SLOT, scopes: [SCOPE_1, SCOPE_2] }, 'test', ); expect(notes).toHaveLength(0); @@ -329,7 +329,7 @@ describe('NoteStore', () => { const filter = { contractAddress: CONTRACT_A, siloedNullifier: NON_EXISTING_SLOT, - scopes: 'ALL_SCOPES' as const, + scopes: [SCOPE_1, SCOPE_2], }; const notes = await noteStore.getNotes(filter, 'test'); @@ -340,7 +340,7 @@ describe('NoteStore', () => { const filter = { contractAddress: CONTRACT_B, siloedNullifier: note2.siloedNullifier, - scopes: 'ALL_SCOPES' as const, + scopes: [SCOPE_1, SCOPE_2], }; const notes = await noteStore.getNotes(filter, 'test'); @@ -372,12 +372,12 @@ describe('NoteStore', () => { const result = await noteStore.applyNullifiers([mkNullifier(note1)], 'test'); expect(result).toEqual([note1]); - const active = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const active = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, 'test'); const all = await noteStore.getNotes( { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, - scopes: 'ALL_SCOPES', + scopes: [SCOPE_1, SCOPE_2], }, 'test', ); @@ -390,8 +390,8 @@ describe('NoteStore', () => { const nullifiers = [mkNullifier(note1), mkNullifier(note3)]; const result = await noteStore.applyNullifiers(nullifiers, 'test'); - const activeA = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); - const activeB = await noteStore.getNotes({ contractAddress: CONTRACT_B, scopes: 'ALL_SCOPES' }, 'test'); + const activeA = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, 'test'); + const activeB = await noteStore.getNotes({ contractAddress: CONTRACT_B, scopes: [SCOPE_1, SCOPE_2] }, 'test'); expect(result).toEqual([note1, note3]); // returned nullified notes expect(nullifierSet(activeA)).toEqual(nullifierSet([note2])); // note2 remains active @@ -405,7 +405,7 @@ describe('NoteStore', () => { contractAddress: CONTRACT_A, siloedNullifier: note2.siloedNullifier, status: NoteStatus.ACTIVE_OR_NULLIFIED, - scopes: 'ALL_SCOPES' as const, + scopes: [SCOPE_1, SCOPE_2], }; const notes = await noteStore.getNotes(filter, 'test'); @@ -479,7 +479,10 @@ describe('NoteStore', () => { // Verify notes are still active (transaction rolled back) await verifyAndCommitForEachJob(['test', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, jobId); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + jobId, + ); expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note1, note2])); }); }); @@ -495,7 +498,10 @@ describe('NoteStore', () => { expect(result).toEqual([]); await verifyAndCommitForEachJob(['test', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, jobId); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + jobId, + ); expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note2])); }); }); @@ -519,11 +525,14 @@ describe('NoteStore', () => { // Verify note is now in nullified state await verifyAndCommitForEachJob(['fresh-job', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, jobId); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + jobId, + ); expect(nullifierSet(activeNotes)).not.toContain(freshNullifier.toBigInt()); const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: 'ALL_SCOPES' }, + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, jobId, ); expect(nullifierSet(allNotes)).toContain(freshNullifier.toBigInt()); @@ -553,14 +562,17 @@ describe('NoteStore', () => { // Verify all notes are nullified await verifyAndCommitForEachJob(['concurrent-job', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, jobId); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + jobId, + ); const activeNullifiers = nullifierSet(activeNotes); for (const nullifier of noteNullifiers) { expect(activeNullifiers).not.toContain(nullifier.toBigInt()); } const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: 'ALL_SCOPES' }, + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, jobId, ); expect(nullifierSet(allNotes)).toEqual(nullifierSet([note1, note2, ...noteNullifiers])); @@ -578,11 +590,14 @@ describe('NoteStore', () => { // Verify the note is in nullified state await verifyAndCommitForEachJob(['new-job', 'after-job-commit'], noteStore, async (jobId: string) => { - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, jobId); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + jobId, + ); expect(nullifierSet(activeNotes)).not.toContain(note1.siloedNullifier.toBigInt()); const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: 'ALL_SCOPES' }, + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, jobId, ); expect(nullifierSet(allNotes)).toContain(note1.siloedNullifier.toBigInt()); @@ -622,7 +637,7 @@ describe('NoteStore', () => { // Verify the note is nullified and has both scopes await verifyAndCommitForEachJob(['duplicate-job', 'after-job-commit'], noteStore, async (jobId: string) => { const allNotes = await noteStore.getNotes( - { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: 'ALL_SCOPES' }, + { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, scopes: [SCOPE_1, SCOPE_2] }, jobId, ); expect(nullifierSet(allNotes)).toContain(duplicateNullifier.toBigInt()); @@ -678,7 +693,10 @@ describe('NoteStore', () => { it('restores notes that were nullified after the rollback block', async () => { // noteBlock2 remains active, noteBlock3 was nullified at block 4 should be restored - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + 'test', + ); expect(nullifierSet(activeNotes)).toEqual(nullifierSet([noteBlock2, noteBlock3])); }); @@ -687,7 +705,7 @@ describe('NoteStore', () => { { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, - scopes: 'ALL_SCOPES', + scopes: [SCOPE_1, SCOPE_2], }, 'test', ); @@ -696,13 +714,19 @@ describe('NoteStore', () => { expect(nullifierSet(allNotes)).toEqual(nullifierSet([noteBlock1, noteBlock2, noteBlock3])); // Verify noteBlock1 is not in active notes - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + 'test', + ); expect(nullifierSet(activeNotes)).not.toContain(noteBlock1.siloedNullifier.toBigInt()); }); it('preserves active notes created before the rollback block that were never nullified', async () => { // noteBlock2 was created at block 2 (before rollback block 3) and never nullified - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + 'test', + ); expect(nullifierSet(activeNotes)).toEqual(nullifierSet([noteBlock2, noteBlock3])); }); @@ -711,7 +735,7 @@ describe('NoteStore', () => { { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, - scopes: 'ALL_SCOPES', + scopes: [SCOPE_1, SCOPE_2], }, 'test', ); @@ -742,14 +766,17 @@ describe('NoteStore', () => { await noteStore.commit('test'); await noteStore.rollback(5, 5); - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + 'test', + ); expect(activeNotes).toHaveLength(0); const allNotes = await noteStore.getNotes( { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, - scopes: 'ALL_SCOPES', + scopes: [SCOPE_1, SCOPE_2], }, 'test', ); @@ -774,14 +801,17 @@ describe('NoteStore', () => { await noteStore.commit('test'); await noteStore.rollback(6, 4); - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + 'test', + ); expect(activeNotes).toHaveLength(0); const allNotes = await noteStore.getNotes( { contractAddress: CONTRACT_A, status: NoteStatus.ACTIVE_OR_NULLIFIED, - scopes: 'ALL_SCOPES', + scopes: [SCOPE_1, SCOPE_2], }, 'test', ); @@ -808,13 +838,16 @@ describe('NoteStore', () => { // note1 should be restored (nullified at block 7 > rollback block 5) // note2 should be deleted (created at block 10 > rollback block 5) - const activeNotes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const activeNotes = await noteStore.getNotes( + { contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, + 'test', + ); expect(nullifierSet(activeNotes)).toEqual(nullifierSet([note1Nullifier])); }); it('handles rollback on empty PXE database gracefully', async () => { await expect(noteStore.rollback(10, 20)).resolves.not.toThrow(); - const notes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: 'ALL_SCOPES' }, 'test'); + const notes = await noteStore.getNotes({ contractAddress: CONTRACT_A, scopes: [SCOPE_1, SCOPE_2] }, 'test'); expect(notes).toHaveLength(0); }); diff --git a/yarn-project/pxe/src/storage/note_store/note_store.ts b/yarn-project/pxe/src/storage/note_store/note_store.ts index 7d93c1e24a40..ae43b85e5b09 100644 --- a/yarn-project/pxe/src/storage/note_store/note_store.ts +++ b/yarn-project/pxe/src/storage/note_store/note_store.ts @@ -106,7 +106,7 @@ export class NoteStore implements StagedStore { * returned once if this is the case) */ getNotes(filter: NotesFilter, jobId: string): Promise { - if (filter.scopes !== 'ALL_SCOPES' && filter.scopes.length === 0) { + if (filter.scopes.length === 0) { return Promise.resolve([]); } @@ -180,10 +180,7 @@ export class NoteStore implements StagedStore { continue; } - if ( - filter.scopes !== 'ALL_SCOPES' && - note.scopes.intersection(new Set(filter.scopes.map(s => s.toString()))).size === 0 - ) { + if (note.scopes.intersection(new Set(filter.scopes.map(s => s.toString()))).size === 0) { continue; } diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index fb40853bb609..d5b01754c8ec 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -12,7 +12,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { LogLevels, type Logger, applyStringFormatting, createLogger } from '@aztec/foundation/log'; import { TestDateProvider } from '@aztec/foundation/timer'; import type { KeyStore } from '@aztec/key-store'; -import type { AccessScopes } from '@aztec/pxe/client/lazy'; import { AddressStore, CapsuleService, @@ -329,7 +328,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl const effectiveScopes = from.isZero() ? [] : [from]; // Sync notes before executing private function to discover notes from previous transactions - const utilityExecutor = async (call: FunctionCall, execScopes: AccessScopes) => { + const utilityExecutor = async (call: FunctionCall, execScopes: AztecAddress[]) => { await this.executeUtilityCall(call, execScopes, jobId); }; @@ -707,7 +706,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl }, blockHeader, jobId, - 'ALL_SCOPES', + await this.keyStore.getAccounts(), ); const call = FunctionCall.from({ @@ -721,10 +720,10 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl returnTypes: [], }); - return this.executeUtilityCall(call, 'ALL_SCOPES', jobId); + return this.executeUtilityCall(call, await this.keyStore.getAccounts(), jobId); } - private async executeUtilityCall(call: FunctionCall, scopes: AccessScopes, jobId: string): Promise { + private async executeUtilityCall(call: FunctionCall, scopes: AztecAddress[], jobId: string): Promise { const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata(call.to, call.selector); if (entryPointArtifact.functionType !== FunctionType.UTILITY) { throw new Error(`Cannot run ${entryPointArtifact.functionType} function as utility`); diff --git a/yarn-project/txe/src/state_machine/index.ts b/yarn-project/txe/src/state_machine/index.ts index db91399fb541..5976e9f346a6 100644 --- a/yarn-project/txe/src/state_machine/index.ts +++ b/yarn-project/txe/src/state_machine/index.ts @@ -3,7 +3,6 @@ import { TestCircuitVerifier } from '@aztec/bb-prover/test'; import { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { createLogger } from '@aztec/foundation/log'; -import type { KeyStore } from '@aztec/key-store'; import { type AnchorBlockStore, type ContractStore, ContractSyncService, type NoteStore } from '@aztec/pxe/server'; import { MessageContextService } from '@aztec/pxe/simulator'; import { L2Block } from '@aztec/stdlib/block'; @@ -36,7 +35,6 @@ export class TXEStateMachine { anchorBlockStore: AnchorBlockStore, contractStore: ContractStore, noteStore: NoteStore, - keyStore: KeyStore, ) { const synchronizer = await TXESynchronizer.create(); const aztecNodeConfig = {} as AztecNodeConfig; @@ -69,7 +67,6 @@ export class TXEStateMachine { node, contractStore, noteStore, - () => keyStore.getAccounts(), createLogger('txe:contract_sync'), ); diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 6da13ce61304..ffb8574597a4 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -3,7 +3,6 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { KeyStore } from '@aztec/key-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; -import type { AccessScopes } from '@aztec/pxe/client/lazy'; import { AddressStore, AnchorBlockStore, @@ -180,7 +179,7 @@ export class TXESession implements TXESessionStateHandler { const archiver = new TXEArchiver(store); const anchorBlockStore = new AnchorBlockStore(store); - const stateMachine = await TXEStateMachine.create(archiver, anchorBlockStore, contractStore, noteStore, keyStore); + const stateMachine = await TXEStateMachine.create(archiver, anchorBlockStore, contractStore, noteStore); const nextBlockTimestamp = BigInt(Math.floor(new Date().getTime() / 1000)); const version = new Fr(await stateMachine.node.getVersion()); @@ -189,13 +188,7 @@ export class TXESession implements TXESessionStateHandler { const initialJobId = jobCoordinator.beginJob(); const logger = createLogger('txe:session'); - const contractSyncService = new ContractSyncService( - stateMachine.node, - contractStore, - noteStore, - () => keyStore.getAccounts(), - logger, - ); + const contractSyncService = new ContractSyncService(stateMachine.node, contractStore, noteStore, logger); const topLevelOracleHandler = new TXEOracleTopLevelContext( stateMachine, @@ -343,7 +336,7 @@ export class TXESession implements TXESessionStateHandler { await new NoteService(this.noteStore, this.stateMachine.node, anchorBlock!, this.currentJobId).syncNoteNullifiers( contractAddress, - 'ALL_SCOPES', + await this.keyStore.getAccounts(), ); const latestBlock = await this.stateMachine.node.getBlockHeader('latest'); @@ -379,11 +372,11 @@ export class TXESession implements TXESessionStateHandler { senderTaggingStore: this.senderTaggingStore, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleService: new CapsuleService(this.capsuleStore, 'ALL_SCOPES'), + capsuleService: new CapsuleService(this.capsuleStore, await this.keyStore.getAccounts()), privateEventStore: this.privateEventStore, contractSyncService: this.stateMachine.contractSyncService, jobId: this.currentJobId, - scopes: 'ALL_SCOPES', + scopes: await this.keyStore.getAccounts(), messageContextService: this.stateMachine.messageContextService, }); @@ -437,7 +430,7 @@ export class TXESession implements TXESessionStateHandler { this.stateMachine.node, anchorBlockHeader, this.currentJobId, - ).syncNoteNullifiers(contractAddress, 'ALL_SCOPES'); + ).syncNoteNullifiers(contractAddress, await this.keyStore.getAccounts()); this.oracleHandler = new UtilityExecutionOracle({ contractAddress, @@ -451,12 +444,12 @@ export class TXESession implements TXESessionStateHandler { aztecNode: this.stateMachine.node, recipientTaggingStore: this.recipientTaggingStore, senderAddressBookStore: this.senderAddressBookStore, - capsuleService: new CapsuleService(this.capsuleStore, 'ALL_SCOPES'), + capsuleService: new CapsuleService(this.capsuleStore, await this.keyStore.getAccounts()), privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, contractSyncService: this.contractSyncService, jobId: this.currentJobId, - scopes: 'ALL_SCOPES', + scopes: await this.keyStore.getAccounts(), }); this.state = { name: 'UTILITY' }; @@ -525,7 +518,7 @@ export class TXESession implements TXESessionStateHandler { } private utilityExecutorForContractSync(anchorBlock: any) { - return async (call: FunctionCall, scopes: AccessScopes) => { + return async (call: FunctionCall, scopes: AztecAddress[]) => { const entryPointArtifact = await this.contractStore.getFunctionArtifactWithDebugMetadata(call.to, call.selector); if (entryPointArtifact.functionType !== FunctionType.UTILITY) { throw new Error(`Cannot run ${entryPointArtifact.functionType} function as utility`); 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 dc21fe15ac84..9b11ae2e88a4 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -36,7 +36,7 @@ import type { ChainInfo } from '@aztec/entrypoints/interfaces'; import { Fr } from '@aztec/foundation/curves/bn254'; import { createLogger } from '@aztec/foundation/log'; import type { FieldsOf } from '@aztec/foundation/types'; -import { type AccessScopes, displayDebugLogs } from '@aztec/pxe/client/lazy'; +import { displayDebugLogs } from '@aztec/pxe/client/lazy'; import type { PXE, PackedPrivateEvent } from '@aztec/pxe/server'; import { type ContractArtifact, @@ -94,7 +94,7 @@ export type SimulateViaEntrypointOptions = Pick< /** Fee options for the entrypoint */ feeOptions: FeeOptions; /** Scopes to use for the simulation */ - scopes: AccessScopes; + scopes: AztecAddress[]; }; /** * A base class for Wallet implementations From 459e92988b2851d73163564f980cc5164ee15988 Mon Sep 17 00:00:00 2001 From: critesjosh <18372439+critesjosh@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:41:14 +0000 Subject: [PATCH 17/18] cherry-pick: 49e2563fc3 fix(docs): update CLI commands, ABI fields, and tutorial fixes (with conflicts) --- .../contract_tutorials/counter_contract.md | 200 ++++ .../recursive_verification.md | 987 +++++++++++++++ .../contract_tutorials/token_contract.md | 707 +++++++++++ .../tutorials/js_tutorials/token_bridge.md | 1059 +++++++++++++++++ .../contract_tutorials/counter_contract.md | 6 + .../recursive_verification.md | 5 +- .../contract_tutorials/token_contract.md | 4 +- docs/examples/ts/example_swap/index.ts | 593 +++++++++ .../scripts/generate_data.ts | 6 +- docs/examples/ts/token_bridge/index.ts | 2 +- 10 files changed, 3562 insertions(+), 7 deletions(-) create mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md create mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md create mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md create mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md create mode 100644 docs/examples/ts/example_swap/index.ts diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md new file mode 100644 index 000000000000..b52beb725a64 --- /dev/null +++ b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md @@ -0,0 +1,200 @@ +--- +title: Counter Contract +description: Code-along tutorial for creating a simple counter contract on Aztec. +sidebar_position: 0 +references: ["docs/examples/contracts/counter_contract/src/main.nr"] +--- + +import Image from "@theme/IdealImage"; + +In this guide, we will create our first Aztec.nr smart contract. We will build a simple private counter, where you can keep your own private counter - so no one knows what ID you are at or when you increment! This contract will get you started with the basic setup and syntax of Aztec.nr, but doesn't showcase all of the awesome stuff Aztec is capable of. + +This tutorial is compatible with the Aztec version `4.2.0-aztecnr-rc.2`. Install the correct version with `VERSION=4.2.0-aztecnr-rc.2 bash -i <(curl -sL https://install.aztec.network/4.2.0-aztecnr-rc.2)`. Or if you'd like to use a different version, you can find the relevant tutorial by clicking the version dropdown at the top of the page. + +## Prerequisites + +- You have followed the [quickstart](../../../getting_started_on_local_network.md) +- Running Aztec local network +- Installed [Noir LSP](../../aztec-nr/installation.md) (optional) + +## Set up a project + +Run this to create a new contract project: + +```bash +aztec new counter +``` + +Your structure should look like this: + +```tree +. +|-counter +| |-src +| | |-main.nr +| |-Nargo.toml +``` + +The file `main.nr` will soon turn into our smart contract! + +Add the following dependencies to `Nargo.toml` under the autogenerated content: + +```toml +[dependencies] +aztec = { git="https://github.com/AztecProtocol/aztec-nr/", tag="v4.2.0-aztecnr-rc.2", directory="aztec" } +balance_set = { git="https://github.com/AztecProtocol/aztec-nr/", tag="v4.2.0-aztecnr-rc.2", directory="balance-set" } +``` + +## Define the functions + +Go to `main.nr`, and replace the boilerplate code with this contract initialization: + +```rust +use aztec::macros::aztec; + +#[aztec] +pub contract Counter { +} +``` + +This defines a contract called `Counter`. + +## Imports + +We need to define some imports. + +Write this inside your contract, ie inside these brackets: + +```rust +pub contract Counter { + // imports go here! +} +``` + +```rust title="imports" showLineNumbers +use aztec::{ + macros::{functions::{external, initializer}, storage::storage}, + messages::message_delivery::MessageDelivery, + oracle::logging::debug_log_format, + protocol::{address::AztecAddress, traits::ToField}, + state_vars::Owned, +}; +use balance_set::BalanceSet; +``` +> Source code: docs/examples/contracts/counter_contract/src/main.nr#L7-L16 + + +- `macros::{functions::{external, initializer}, storage::storage}` + Imports the macros needed to define function types (`external`, `initializer`) and the `storage` macro for declaring contract storage structures. + +- `messages::message_delivery::MessageDelivery` + Imports `MessageDelivery` for specifying how note delivery should be handled (e.g., constrained onchain delivery). + +- `oracle::debug_log::debug_log_format` + Imports a debug logging utility for printing formatted messages during contract execution. + +- `protocol::{address::AztecAddress, traits::ToField}` + Brings in `AztecAddress` (used to identify accounts/contracts) and traits for converting values to field elements, necessary for serialization and formatting inside Aztec. + +- `state_vars::Owned` + Brings in `Owned`, a wrapper for state variables that have a single owner. + +- `use balance_set::BalanceSet` + Imports `BalanceSet` from the `balance_set` dependency, which provides functionality for managing private balances (used for our counter). + +## Declare storage + +Add this below the imports. It declares the storage variables for our contract. We use an `Owned` state variable wrapping a `BalanceSet` to manage private balances for each owner. + +```rust title="storage_struct" showLineNumbers +#[storage] +struct Storage { + counters: Owned, Context>, +} +``` +> Source code: docs/examples/contracts/counter_contract/src/main.nr#L18-L23 + + +## Keep the counter private + +Now we’ve got a mechanism for storing our private state, we can start using it to ensure the privacy of balances. + +Let’s create a constructor method to run on deployment that assigns an initial count to a specified owner. This function is called `initialize`, but behaves like a constructor. It is the `#[initializer]` decorator that specifies that this function behaves like a constructor. Write this: + +```rust title="constructor" showLineNumbers +#[initializer] +#[external("private")] +// We can name our initializer anything we want as long as it's marked as aztec(initializer) +fn initialize(headstart: u64, owner: AztecAddress) { + self.storage.counters.at(owner).add(headstart as u128).deliver( + MessageDelivery.ONCHAIN_CONSTRAINED, + ); +} +``` +> Source code: docs/examples/contracts/counter_contract/src/main.nr#L25-L34 + + +This function accesses the counters from storage. It adds the `headstart` value to the `owner`'s counter using `at().add()`, then calls `.deliver(MessageDelivery.ONCHAIN_CONSTRAINED)` to ensure the note is delivered onchain. + +We have annotated this and other functions with `#[external("private")]` which are ABI macros so the compiler understands it will handle private inputs. + +## Incrementing our counter + +Now let's implement an `increment` function to increase the counter. + +```rust title="increment" showLineNumbers +#[external("private")] +fn increment(owner: AztecAddress) { + debug_log_format("Incrementing counter for owner {0}", [owner.to_field()]); + self.storage.counters.at(owner).add(1).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); +} +``` +> Source code: docs/examples/contracts/counter_contract/src/main.nr#L36-L42 + + +The `increment` function works similarly to the `initialize` function. It logs a debug message, then adds 1 to the owner's counter and delivers the note onchain. + +## Getting a counter + +The last thing we need to implement is a function to retrieve a counter value. + +```rust title="get_counter" showLineNumbers +#[external("utility")] +unconstrained fn get_counter(owner: AztecAddress) -> pub u128 { + self.storage.counters.at(owner).balance_of() +} +``` +> Source code: docs/examples/contracts/counter_contract/src/main.nr#L44-L49 + + +This is a `utility` function used to obtain the counter value outside of a transaction. We access the `owner`'s balance from the `counters` storage variable using `at(owner)`, then call `balance_of()` to retrieve the current count. This yields a private counter that only the owner can decrypt. + +## Compile + +Now we've written a simple Aztec.nr smart contract, we can compile it. + +### Compile the smart contract + +In the `./counter/` directory, run: + +```bash +aztec compile +``` + +This command compiles your Noir contract and creates a `target` folder with a `.json` artifact inside. + +After compiling, you can generate a TypeScript class using the `aztec codegen` command. + +In the same directory, run this: + +```bash +aztec codegen -o src/artifacts target +``` + +You can now use the artifact and/or the TS class in your Aztec.js! + +## Next Steps + +### Optional: Learn more about concepts mentioned here + +- [Functions and annotations like `#[external("private")]`](../../aztec-nr/framework-description/functions/function_transforms.md#private-functions) diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md new file mode 100644 index 000000000000..8af0ff3bdba5 --- /dev/null +++ b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md @@ -0,0 +1,987 @@ +--- +title: "Verify Noir Proofs in Aztec Contracts" +description: "Learn to generate offchain ZK proofs with Noir and verify them onchain in Aztec private smart contracts" +--- + +## Overview + +In this tutorial, you will build a system that generates zero-knowledge proofs offchain using a Noir circuit and verifies them onchain within an Aztec Protocol smart contract. You will create a simple circuit that proves two values are not equal, generate an UltraHonk proof, deploy an Aztec contract that stores a verification key hash, and submit the proof for onchain verification. This pattern enables trustless computation where anyone can verify that a computation was performed correctly without revealing the private inputs. + +:::note Why "Recursive" Verification? +This is called "recursive" verification because the proof is verified inside an Aztec private function, which itself gets compiled into a ZK circuit. The result is a proof being verified inside another proof. The Noir circuit you write is not recursive; the recursion happens at the Aztec protocol level when the private function execution (including the `verify_honk_proof` call) is proven. +::: + +:::tip Full Working Example +The complete code for this tutorial is available in the [docs/examples](https://github.com/AztecProtocol/aztec-packages/tree/v4.2.0-aztecnr-rc.2/docs/examples) directory. Clone it to follow along or use it as a reference. +::: + +## Prerequisites + +Before starting, ensure you have the following installed and configured: + +- Node.js (v22 or later) +- yarn package manager +- Aztec CLI (version 4.2.0-aztecnr-rc.2) +- Nargo +- Familiarity with [Noir syntax](https://noir-lang.org/docs) and [Aztec contract basics](../../aztec-nr/index.md) + +Install the required tools: + +```bash +# Install Aztec CLI +VERSION=4.2.0-aztecnr-rc.2 bash -i <(curl -sL https://install.aztec.network/4.2.0-aztecnr-rc.2) +``` + +## Part 1: Understanding the Architecture + +### The Core Problem + +Aztec contracts have inherent [limits on function inputs and transaction complexity](../../resources/considerations/limitations.md#circuit-limitations). These constraints stem from the circuit-based nature of private execution. When your computation requires more inputs than these limits allow, or when the computation itself is too complex to fit in a single function, **recursive proof verification** provides an escape hatch. + +For example, consider a machine learning inference that needs 10,000 input features, or a Merkle tree verification with 1,000 leaves. These cannot fit within a single Aztec function's input constraints. Instead, you can: + +1. Perform the computation offchain in a vanilla Noir circuit with no input limits +2. Generate a proof of correct execution +3. Verify only the proof onchain (115 fields for VK + ~500 fields for proof + N public inputs) + +This pattern transforms arbitrarily large computations into fixed-size proof verification. + +### Data Flow + +The recursive verification pattern follows this data flow: + +1. **Circuit Definition**: Write a Noir circuit that defines the computation you want to prove +2. **Compilation**: Compile the circuit with `nargo compile` to produce bytecode +3. **Proof Generation**: Execute the circuit offchain and generate an UltraHonk proof using [Barretenberg](https://github.com/AztecProtocol/barretenberg) +4. **Onchain Verification**: Submit the proof to an Aztec contract that verifies it using the stored [verification key](../../resources/glossary.md#verification-key) hash + +```mermaid +flowchart LR + A["Noir Circuit
(main.nr)"] --> B["Compiled
Bytecode"] + B --> C["Proof + VK +
Public Inputs"] + C --> D["Aztec Contract
verify_honk_proof"] +``` + +**Why this separation matters**: The circuit defines _what_ you're proving. The proof is _evidence_ that you executed the circuit correctly with valid inputs. The onchain verifier checks the evidence without re-running the computation. This is what makes ZK proofs powerful: verification is orders of magnitude cheaper than computation. + +### Why Verify Proofs in Aztec Contracts? + +Proof verification enables several patterns: + +- **Bypassing Input Limits**: Aztec private functions have strict input constraints. A proof verification call uses ~616 fields (115 VK + ~500 proof + 1 public input), but can attest to computations with arbitrarily many inputs. For example, proving membership in a set of 10,000 elements becomes a fixed-size verification. + +- **Cross-System Verification**: Verify proofs generated by external Noir circuits within your Aztec application. This enables composability: your contract can trust computations performed by other systems without those systems needing to be Aztec-native. + +- **Batching Operations**: Aggregate multiple operations into a single proof. Instead of making N separate contract calls, prove all N operations were done correctly and verify once. + +### Why Use Aztec for Proof Verification? + +Aztec provides a unique advantage: **private function execution**. When you verify a proof in an Aztec private function: + +1. The proof verification happens inside a zero-knowledge circuit +2. The inputs to verification (the proof itself) can remain private +3. You can compose proof verification with other private operations + +This enables patterns impossible on transparent blockchains, like proving you have a valid credential without revealing which credential or when you obtained it. + +### UX Considerations: Multiple Proof Generation + +When using [recursive verification](https://noir-lang.org/docs/noir/standard_library/recursion) in Aztec, users experience **two distinct proof generation phases**: + +1. **Noir Proof Generation** (application-specific): + - Happens before interacting with the Aztec contract + - Proves the computation (e.g., "I know values x and y where x ≠ y") + - Time depends on circuit complexity (seconds to minutes) + - Produces the proof and verification key that will be verified + +2. **Aztec Transaction Proof** (protocol-level): + - Generated by the [PXE](../../foundational-topics/pxe/index.md) when calling the private function + - Proves correct execution of the Aztec contract (including the `verify_honk_proof` call) + +With this foundation in mind, let's build a complete example. You'll create a Noir circuit, generate a proof, and verify it inside an Aztec contract. + +## Part 2: Writing the Noir Circuit + +Start by writing a simple circuit that proves two field values are not equal. This minimal example demonstrates the core pattern—you can extend it for more complex computations like Merkle proofs, credential verification, or something else entirely. + +### Create the Circuit Project + +Use `nargo new` to generate the project structure: + +```bash +nargo new circuit +``` + +This creates the following structure: + +```tree +circuit/ +├── src/ +│ └── main.nr # Circuit code +└── Nargo.toml # Circuit configuration +``` + +### Circuit Code + +Replace the contents of `circuit/src/main.nr` with: + +```rust title="circuit" showLineNumbers +fn main(x: u64, y: pub u64) { + assert(x != y); +} + +#[test] +fn test_main() { + main(1, 2); +} +``` +> Source code: docs/examples/circuits/hello_circuit/src/main.nr#L1-L10 + + +This is intentionally minimal to focus on the verification pattern. In production, you would replace `assert(x != y)` with meaningful computations like: + +- Merkle tree membership proofs +- Hash preimage verification +- Range proofs (proving a value is within bounds) +- Credential verification +- Email verification (proving you received an email from a domain without revealing its contents, like [zkEmail](https://www.prove.email/)) + +### Understanding Private vs Public Inputs + +The circuit has two inputs with different visibility: + +- `x: Field` - A **private input** known only to the prover. This value is never revealed onchain or included in the proof data. The verifier cannot determine what value was used—only that _some_ valid value exists. + +- `y: pub Field` - A **public input** that is visible to the verifier. This value is included in the proof data, but since proof verification happens within a private function, it isn't exposed onchain unless you explicitly reveal it. + +**Why this distinction matters**: The circuit asserts that `x != y`. The prover demonstrates they know a secret value `x` that differs from the public value `y`. + +Public inputs don't have to come from the caller. During verification, the Aztec contract can read values from its own storage and use them as public inputs. This pattern ties the proof to contract state—the prover must generate a proof against the _current_ stored value and cannot substitute a different public input. + +To make the "public input" truly public, the contract developer can enqueue a public function call from the private function that verifies the proof, passing the public input to a public function to be logged or verified against public state. + +For example, you could create a zkpassport proof demonstrating that you are over a certain age. The proof is verified in a private function, then the age (the public input) is passed to a public function where it's compared against a mutable threshold in public storage. + +### Circuit Configuration + +Update `circuit/Nargo.toml` (see [Noir crates and packages](https://noir-lang.org/docs/noir/modules_packages_crates/crates_and_packages) for more details): + +```toml title="circuit_nargo_toml" showLineNumbers +[package] +name = "hello_circuit" +type = "bin" +authors = [""] + +[dependencies] +``` +> Source code: docs/examples/circuits/hello_circuit/Nargo.toml#L1-L8 + + +**Note**: This is a vanilla Noir circuit, not an Aztec contract. It has `type = "bin"` (binary) and no Aztec dependencies. The circuit is compiled with `nargo`, not `aztec compile`. This distinction is important—you can verify proofs from _any_ Noir circuit inside Aztec contracts. + +### Compile the Circuit + +```bash +cd circuit +nargo compile +``` + +This generates `target/hello_circuit.json` containing: + +- **Bytecode**: The compiled circuit representation +- **ABI (Application Binary Interface)**: Describes the circuit's inputs and outputs, including which are public + +The TypeScript code uses the ABI to correctly format inputs during witness generation. + +### Test the Circuit + +```bash +nargo test +``` + +Expected output: + +```text +[hello_circuit] Running 1 test functions +[hello_circuit] Testing test_main... ok +[hello_circuit] All tests passed +``` + +**Tip**: Circuit tests run without generating proofs, making them fast for development. Use them to verify your circuit logic before the more expensive proof generation step. + +## Part 3: Writing the Aztec Contract + +The Aztec contract stores the verification key hash and verifies proofs submitted by users. When a valid proof is submitted, it increments a counter for the caller. + +### Why This Contract Design? + +The contract demonstrates several important patterns: + +1. **VK Hash Storage**: Instead of storing the full 115-field verification key onchain (expensive), we store only its hash (1 field). The prover submits the full VK with each proof, and the contract verifies it matches the stored hash. + +2. **Private-to-Public Flow**: Proof verification happens in a [private function](../../aztec-nr/framework-description/functions/visibility.md) (generating a ZK proof of the verification), but the counter update happens in a public function (visible state change). This separation is fundamental to Aztec's architecture. + +3. **Self-Only Public Functions**: The `_increment_public` function can only be called by the contract itself, not external accounts (similar to `internal` functions in Solidity). This ensures the counter can only be modified after successful proof verification. + +### Create the Contract Project + +Use `aztec new` to generate the contract project structure: + +```bash +aztec new contract --name ValueNotEqual +``` + +This creates: + +```tree +contract/ +├── src/ +│ └── main.nr # Contract code +└── Nargo.toml # Contract configuration +``` + +### Contract Configuration + +Update `contract/Nargo.toml` with the required dependencies: + +```toml +[package] +name = "ValueNotEqual" +type = "contract" +authors = ["[YOUR_NAME]"] + +[dependencies] +aztec = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } +bb_proof_verification = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "barretenberg/noir/bb_proof_verification" } +``` + +**Key differences from the circuit's Nargo.toml**: + +- `type = "contract"` (not `"bin"`) +- Depends on `aztec` for Aztec-specific features +- Depends on `bb_proof_verification` for `verify_honk_proof` + +### Contract Structure + +Replace the contents of `contract/src/main.nr` with: + +```rust title="full_contract" showLineNumbers +use aztec::macros::aztec; + +#[aztec] +pub contract ValueNotEqual { + use aztec::{ + macros::{functions::{external, initializer, only_self, view}, storage::storage}, + oracle::logging::debug_log_format, + protocol::{address::AztecAddress, traits::ToField}, + state_vars::{Map, PublicImmutable, PublicMutable}, + }; + use bb_proof_verification::{UltraHonkVerificationKey, UltraHonkZKProof, verify_honk_proof}; + + #[storage] + struct Storage { + counters: Map, Context>, + vk_hash: PublicImmutable, + } + + #[initializer] + #[external("public")] + fn constructor(headstart: Field, owner: AztecAddress, vk_hash: Field) { + self.storage.counters.at(owner).write(headstart); + self.storage.vk_hash.initialize(vk_hash); + } + + #[external("private")] + fn increment( + owner: AztecAddress, + verification_key: UltraHonkVerificationKey, + proof: UltraHonkZKProof, + public_inputs: [Field; 1], + ) { + debug_log_format("Incrementing counter for owner {0}", [owner.to_field()]); + + // Read the stored VK hash - this is readable from private context + // because PublicImmutable values are committed at deployment + let vk_hash = self.storage.vk_hash.read(); + + // Verify the proof - this is the core operation + // The function checks: + // 1. The VK hashes to the stored vk_hash + // 2. The proof is valid for the given VK and public inputs + verify_honk_proof(verification_key, proof, public_inputs, vk_hash); + + // If we reach here, the proof is valid + // Enqueue a public function call to update state + self.enqueue_self._increment_public(owner); + } + + #[only_self] + #[external("public")] + fn _increment_public(owner: AztecAddress) { + let current = self.storage.counters.at(owner).read(); + self.storage.counters.at(owner).write(current + 1); + } + + #[view] + #[external("public")] + fn get_counter(owner: AztecAddress) -> Field { + self.storage.counters.at(owner).read() + } +} +``` +> Source code: docs/examples/contracts/recursive_verification_contract/src/main.nr#L1-L78 + + +### Storage Variables Explained + +The contract uses two [storage types](../../aztec-nr/framework-description/state_variables.md) with different characteristics: + +**`vk_hash: PublicImmutable`** + +`PublicImmutable` is perfect for values that: + +- Are set once during contract initialization +- Never change after deployment +- Need to be readable from both public and private contexts + +The VK hash fits all these criteria. Once you deploy a contract to verify proofs from a specific circuit, the circuit (and thus its VK) shouldn't change. + +**Why store the hash instead of the full VK?** + +- Storage costs: 1 field vs 115 fields +- The prover already has the full VK (needed to generate the proof) +- Hash verification is cheap compared to storing/loading 115 fields + +**`counters: Map>`** + +`PublicMutable` is used for values that: + +- Change over time +- Are updated by public functions +- Need to be visible onchain + +The counter must be `PublicMutable` because it's modified by `_increment_public`, a public function. Private functions cannot directly write to public state; they can only enqueue public function calls. + +### Function Breakdown + +**1. `constructor` (public initializer)** + +```rust +#[initializer] +#[external("public")] +fn constructor(headstart: Field, owner: AztecAddress, vk_hash: Field) { + self.storage.counters.at(owner).write(headstart); + self.storage.vk_hash.initialize(vk_hash); +} +``` + +- `#[initializer]`: Marks this as the constructor, called once during deployment +- `#[external("public")]`: Executes publicly (visible onchain) +- Sets the initial counter value for the owner +- Stores the VK hash using `initialize()` (required for `PublicImmutable`) + +**2. `increment` (private function)** + +```rust +#[external("private")] +fn increment( + owner: AztecAddress, + verification_key: UltraHonkVerificationKey, + proof: UltraHonkZKProof, + public_inputs: [Field; 1], +) { + let vk_hash = self.storage.vk_hash.read(); + verify_honk_proof(verification_key, proof, public_inputs, vk_hash); + self.enqueue_self._increment_public(owner); +} +``` + +- `#[external("private")]`: Executes privately (generates a ZK proof of execution in the PXE) +- Reads VK hash from storage (allowed because `PublicImmutable` is readable in private context) +- Calls `verify_honk_proof()` which: + - Computes the hash of the provided verification key + - Checks it matches the stored `vk_hash` + - Verifies the proof against the VK and public inputs + - Fails (reverts) if any check fails +- Uses `enqueue_self._increment_public(owner)` to schedule a public function call + +**Why `enqueue_self` instead of a direct call?** + +In Aztec, private functions cannot directly modify public state. Instead, they enqueue public function calls that execute after the private phase completes. This ensures: + +- Private execution remains private (no public state reads during private execution) +- State updates are atomic (all enqueued calls execute or none do) +- The execution order is deterministic + +**3. `_increment_public` (public, self-only)** + +```rust +#[only_self] +#[external("public")] +fn _increment_public(owner: AztecAddress) { + let current = self.storage.counters.at(owner).read(); + self.storage.counters.at(owner).write(current + 1); +} +``` + +- `#[only_self]`: Only callable by the contract itself (via `enqueue_self`) +- `#[external("public")]`: Executes publicly +- Reads the current counter and increments it + +**Why `#[only_self]`?** + +Without this modifier, anyone could call `_increment_public` directly, bypassing proof verification. The `#[only_self]` modifier ensures the function is only reachable through the private `increment` function, which requires a valid proof. + +**4. `get_counter` (public view)** + +```rust +#[view] +#[external("public")] +fn get_counter(owner: AztecAddress) -> Field { + self.storage.counters.at(owner).read() +} +``` + +- `#[view]`: Read-only function, doesn't modify state +- Returns the counter value for any address + +## Part 4: TypeScript Setup and Proof Generation + +Before compiling the contract or running any TypeScript scripts, set up the project with the necessary configuration files and dependencies. + +### Project Setup + +Create the following files in your project root directory. + +**Create `package.json`:** + +```json +{ + "name": "recursive-verification-tutorial", + "type": "module", + "scripts": { + "ccc": "cd contract && aztec compile && aztec codegen target -o ../artifacts", + "data": "tsx scripts/generate_data.ts", + "recursion": "tsx index.ts" + }, + "dependencies": { + "@aztec/accounts": "4.2.0-aztecnr-rc.2", + "@aztec/aztec.js": "4.2.0-aztecnr-rc.2", + "@aztec/bb.js": "4.2.0-aztecnr-rc.2", + "@aztec/kv-store": "4.2.0-aztecnr-rc.2", + "@aztec/noir-contracts.js": "4.2.0-aztecnr-rc.2", + "@aztec/noir-noir_js": "4.2.0-aztecnr-rc.2", + "@aztec/pxe": "4.2.0-aztecnr-rc.2", + "@aztec/wallets": "4.2.0-aztecnr-rc.2", + "tsx": "^4.20.6" + }, + "devDependencies": { + "@types/node": "^22.0.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } +} +``` + +**Create `tsconfig.json`:** + +```json +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + } +} +``` + +**Install dependencies:** + +```bash +yarn install +``` + +This installs all the Aztec packages needed for proof generation and contract interaction. The installation may take a few minutes due to the size of the cryptographic libraries. + +### Compile the Contract + +Now compile the Aztec contract and generate TypeScript bindings: + +```bash +yarn ccc +``` + +**What this command does** (see [How to Compile a Contract](../../aztec-nr/compiling_contracts.md) for details): + +1. `aztec compile`: Compiles the Noir contract and post-processes it for Aztec (different from `nargo compile`) +2. `aztec codegen`: Generates TypeScript bindings from the contract artifact, enabling type-safe contract interaction + +This generates: + +- `contract/target/ValueNotEqual.json` - Contract artifact (bytecode, ABI, etc.) +- `artifacts/ValueNotEqual.ts` - TypeScript class for deploying and interacting with the contract + +### Proof Generation Script + +The proof generation script executes the circuit offchain and produces the proof data needed for onchain verification. + +Create `scripts/generate_data.ts`: + +```typescript title="generate_data" showLineNumbers +import { Noir } from "@aztec/noir-noir_js"; +import circuitJson from "../circuit/target/hello_circuit.json" with { type: "json" }; +import { Barretenberg, UltraHonkBackend, deflattenFields } from "@aztec/bb.js"; +import fs from "fs"; +import { exit } from "process"; + +// Step 1: Initialize Barretenberg API (the proving system backend) +// Barretenberg is the C++ library that implements UltraHonk +// threads: 1 uses single-threaded mode (increase for faster proofs on multi-core machines) +const barretenbergAPI = await Barretenberg.new({ threads: 1 }); + +// Step 2: Create Noir circuit instance from compiled bytecode +// This loads the circuit definition so we can execute it +const helloWorld = new Noir(circuitJson as any); + +// Step 3: Execute circuit with inputs to generate witness +// The witness is all intermediate values computed during circuit execution +// x=1 (private), y=2 (public) - proves that 1 != 2 +const { witness: mainWitness } = await helloWorld.execute({ x: 1, y: 2 }); + +// Step 4: Create UltraHonk backend with circuit bytecode +// The backend handles proof generation and verification +const mainBackend = new UltraHonkBackend(circuitJson.bytecode, barretenbergAPI); + +// Step 5: Generate proof targeting the noir-recursive verifier +// verifierTarget: 'noir-recursive' creates a proof format suitable for +// verification inside another Noir circuit (which is what Aztec contracts are) +const mainProofData = await mainBackend.generateProof(mainWitness, { + verifierTarget: "noir-recursive", +}); + +// Step 6: Verify proof locally before saving +// This catches errors early - if verification fails here, it will fail onchain too +const isValid = await mainBackend.verifyProof(mainProofData, { + verifierTarget: "noir-recursive", +}); +console.log(`Proof verification: ${isValid ? "SUCCESS" : "FAILED"}`); + +// Step 7: Generate recursive artifacts for onchain use +// This converts the proof and VK into field element arrays that can be +// passed to the Aztec contract +const recursiveArtifacts = await mainBackend.generateRecursiveProofArtifacts( + mainProofData.proof, + mainProofData.publicInputs.length, +); + +// Step 8: Convert proof to field elements if needed +// Some versions return empty proofAsFields, requiring manual conversion +let proofAsFields = recursiveArtifacts.proofAsFields; +if (proofAsFields.length === 0) { + console.log("Using deflattenFields to convert proof..."); + proofAsFields = deflattenFields(mainProofData.proof).map((f) => f.toString()); +} + +const vkAsFields = recursiveArtifacts.vkAsFields; + +console.log(`VK size: ${vkAsFields.length}`); // Should be 115 +console.log(`Proof size: ${proofAsFields.length}`); // Should be ~500 +console.log(`Public inputs: ${mainProofData.publicInputs.length}`); // Should be 1 + +// Step 9: Save all data to JSON for contract interaction +const data = { + vkAsFields: vkAsFields, // 115 field elements - the verification key + vkHash: recursiveArtifacts.vkHash, // Hash of VK - stored in contract + proofAsFields: proofAsFields, // ~500 field elements - the proof + publicInputs: mainProofData.publicInputs.map((p: string) => p.toString()), +}; + +fs.writeFileSync("data.json", JSON.stringify(data, null, 2)); +await barretenbergAPI.destroy(); +console.log("Done"); +exit(); +``` +> Source code: docs/examples/ts/recursive_verification/scripts/generate_data.ts#L1-L74 + + +### Understanding the Proof Generation Pipeline + +#### Setup + +- Initialize Barretenberg (the cryptographic backend) +- Load the compiled circuit + +#### Witness Generation + +```typescript +const { witness: mainWitness } = await helloWorld.execute({ x: 1, y: 2 }); +``` + +The witness contains all values computed during circuit execution, not just inputs and outputs, but every intermediate value. The prover needs the witness to construct the proof. The verifier never sees the witness (that's the point of ZK proofs). + +#### Proof Generation + +```typescript +const mainProofData = await mainBackend.generateProof(mainWitness, { + verifierTarget: "noir-recursive", +}); +``` + +**Why `verifierTarget: 'noir-recursive'`?** There are different proof formats optimized for different verifiers: + +- Native verifiers (standalone programs) +- Smart contract verifiers (Solidity) +- Recursive verifiers (inside other ZK circuits) + +Aztec contracts are compiled to ZK circuits, so `verify_honk_proof` runs inside a circuit. We need the recursive-friendly proof format. + +#### Local Verification + +```typescript +const isValid = await mainBackend.verifyProof(mainProofData, { + verifierTarget: "noir-recursive", +}); +``` + +Always verify locally before submitting onchain. Onchain verification costs gas/fees and takes time. Local verification is free and instant. + +#### Field Element Conversion + +ZK proofs are arrays of bytes, but Aztec contracts work with field elements. We convert the proof and VK to arrays of 115 and ~500 field elements respectively. + +```typescript +let proofAsFields = recursiveArtifacts.proofAsFields; +if (proofAsFields.length === 0) { + console.log("Using deflattenFields to convert proof..."); + proofAsFields = deflattenFields(mainProofData.proof).map((f) => f.toString()); +} + +const vkAsFields = recursiveArtifacts.vkAsFields; +``` + +Some versions of the library return an empty `proofAsFields` array, requiring manual conversion via `deflattenFields`. + +#### Saving Data for Contract Interaction + +```typescript +const data = { + vkAsFields: vkAsFields, + vkHash: recursiveArtifacts.vkHash, + proofAsFields: proofAsFields, + publicInputs: mainProofData.publicInputs.map((p: string) => p.toString()), +}; + +fs.writeFileSync("data.json", JSON.stringify(data, null, 2)); +await barretenbergAPI.destroy(); +``` + +The data is saved as JSON so the deployment script can load it. We call `barretenbergAPI.destroy()` to clean up the WebAssembly resources used by Barretenberg. This is important because Barretenberg allocates significant memory for cryptographic operations, and not destroying it can cause memory leaks in long-running processes. + +### Run Proof Generation + +```bash +yarn data +``` + +Expected output: + +```text +Proof verification: SUCCESS +Using deflattenFields to convert proof... +VK size: 115 +Proof size: 500 +Public inputs: 1 +Done +``` + +### Output Format + +The generated `data.json` contains: + +```json +{ + "vkAsFields": ["0x...", "0x...", ...], // 115 field elements + "vkHash": "0x...", // Single field element + "proofAsFields": ["0x...", "0x...", ...], // ~500 field elements + "publicInputs": ["2"] // The public input y=2 +} +``` + +**What each field is used for**: + +- `vkHash`: Passed to the contract constructor, stored permanently +- `vkAsFields`: Passed to `increment()`, verified against stored hash +- `proofAsFields`: Passed to `increment()`, verified by `verify_honk_proof` +- `publicInputs`: Passed to `increment()`, must match what was used during proof generation + +## Part 5: Deploying and Verifying + +The deployment script connects to the Aztec network, creates an account, deploys the contract, and submits a proof for verification. + +### Deployment Script + +Create `index.ts`: + +```typescript title="run_recursion" showLineNumbers +import { SponsoredFeePaymentMethod } from "@aztec/aztec.js/fee"; +import type { FieldLike } from "@aztec/aztec.js/abi"; +import { getSponsoredFPCInstance } from "./scripts/sponsored_fpc.js"; +import { SponsoredFPCContract } from "@aztec/noir-contracts.js/SponsoredFPC"; +import { ValueNotEqualContract } from "./artifacts/ValueNotEqual.js"; +import { EmbeddedWallet } from "@aztec/wallets/embedded"; +import { NO_FROM } from "@aztec/aztec.js/account"; +import { Fr } from "@aztec/aztec.js/fields"; +import fs from "node:fs"; +import assert from "node:assert"; + +if (!fs.existsSync("data.json")) { + console.error( + "data.json not found. Run 'yarn data' first to generate proof data.", + ); + process.exit(1); +} +const data = JSON.parse(fs.readFileSync("data.json", "utf-8")); + +export const NODE_URL = process.env.AZTEC_NODE_URL ?? "http://localhost:8080"; + +// Setup sponsored fee payment - the FPC pays transaction fees for us +const sponsoredFPC = await getSponsoredFPCInstance(); +const sponsoredPaymentMethod = new SponsoredFeePaymentMethod( + sponsoredFPC.address, +); + +// Initialize wallet and connect to local network +// The wallet manages accounts and sends transactions through the PXE +export const setupWallet = async (): Promise => { + try { + // Create wallet with embedded PXE + // The wallet manages accounts and connects to the node + let wallet = await EmbeddedWallet.create(NODE_URL); + + // Register the sponsored FPC so the wallet knows about it + await wallet.registerContract(sponsoredFPC, SponsoredFPCContract.artifact); + return wallet; + } catch (error) { + console.error("Failed to setup local network:", error); + throw error; + } +}; + +async function main() { + // Step 1: Setup wallet and create account + // Accounts in Aztec are smart contracts (account abstraction) + const wallet = await setupWallet(); + const manager = await wallet.createSchnorrAccount(Fr.random(), Fr.random()); + + // Deploy the account contract + const deployMethod = await manager.getDeployMethod(); + await deployMethod.send({ + from: NO_FROM, + fee: { paymentMethod: sponsoredPaymentMethod }, + }); + + const accounts = await wallet.getAccounts(); + + // Step 2: Deploy ValueNotEqual contract + // Constructor args: initial counter (10), owner, VK hash + const { contract: valueNotEqual } = await ValueNotEqualContract.deploy( + wallet, + 10, // Initial counter value + accounts[0].item, // Owner address + data.vkHash as unknown as FieldLike, // VK hash for verification + ).send({ + from: accounts[0].item, + fee: { paymentMethod: sponsoredPaymentMethod }, + }); + + console.log(`Contract deployed at: ${valueNotEqual.address}`); + + const opts = { + from: accounts[0].item, + fee: { paymentMethod: sponsoredPaymentMethod }, + }; + + // Step 3: Read initial counter value + // simulate() executes without submitting a transaction + let counterValue = ( + await valueNotEqual.methods + .get_counter(accounts[0].item) + .simulate({ from: accounts[0].item }) + ).result; + console.log(`Counter value: ${counterValue}`); // Should be 10 + + // Step 4: Call increment() with proof data + // This creates a transaction that: + // 1. Executes the private increment() function (client-side) + // 2. Generates a ZK proof of correct execution + // 3. Submits the proof to the network + // 4. Network verifies the proof + // 5. Executes enqueued _increment_public() + const interaction = await valueNotEqual.methods.increment( + accounts[0].item, + data.vkAsFields as unknown as FieldLike[], // 115 field VK + data.proofAsFields as unknown as FieldLike[], // ~500 field proof + data.publicInputs as unknown as FieldLike[], // Public inputs + ); + + // Step 5: Send transaction and wait for inclusion + await interaction.send(opts); + + // Step 6: Read updated counter + counterValue = ( + await valueNotEqual.methods + .get_counter(accounts[0].item) + .simulate({ from: accounts[0].item }) + ).result; + console.log(`Counter value: ${counterValue}`); // Should be 11 + + assert(counterValue === 11n, "Counter should be 11 after verification"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` +> Source code: docs/examples/ts/recursive_verification/index.ts#L1-L129 + + +### Understanding the Deployment Script + +#### Sponsored Fee Payment + +Aztec transactions require fees. For testing, we use a Sponsored Fee Payment Contract (FPC) that pays fees on behalf of users: + +```typescript +const sponsoredFPC = await getSponsoredFPCInstance(); +const sponsoredPaymentMethod = new SponsoredFeePaymentMethod( + sponsoredFPC.address, +); +``` + +In production, you would use real [fee payment methods](../../aztec-js/how_to_pay_fees.md) (native tokens, ERC20, etc.). + +#### What Happens During `increment().send().wait()` + +This single line triggers a complex flow: + +1. **Private Execution** (client-side, in PXE): + - Execute `increment()` with provided arguments + - Read `vk_hash` from contract storage + - Execute `verify_honk_proof()` inside the private function + - Generate the `enqueue_self._increment_public(owner)` call + +2. **Proof Generation** (client-side, in PXE): + - Generate a ZK proof that the private execution was correct + - This proof doesn't reveal inputs (including the ~500-field proof!) + +3. **Transaction Submission**: + - Send the proof + encrypted logs + public function calls to the network + +4. **Verification & Public Execution** (onchain): + - Network verifies the private execution proof + - Execute `_increment_public(owner)` publicly + - Update the counter in storage + +### Supporting Utility + +Create `scripts/sponsored_fpc.ts`: + +```typescript title="sponsored_fpc" showLineNumbers +import { getContractInstanceFromInstantiationParams } from "@aztec/aztec.js/contracts"; +import { Fr } from "@aztec/aztec.js/fields"; +import { SponsoredFPCContract } from "@aztec/noir-contracts.js/SponsoredFPC"; + +const SPONSORED_FPC_SALT = new Fr(BigInt(0)); + +export async function getSponsoredFPCInstance() { + return await getContractInstanceFromInstantiationParams( + SponsoredFPCContract.artifact, + { + salt: SPONSORED_FPC_SALT, + }, + ); +} +``` +> Source code: docs/examples/ts/recursive_verification/scripts/sponsored_fpc.ts#L1-L16 + + +This utility computes the address of the pre-deployed sponsored FPC contract. The salt ensures we get the same address every time. For more information about fee payment options, see [Paying Fees](../../aztec-js/how_to_pay_fees.md). + +### Start the Local Network + +In a separate terminal, start the [Aztec local network](../../../getting_started_on_local_network.md): + +```bash +aztec start --local-network +``` + +**What this starts**: + +- **Anvil**: A local Ethereum node (L1) +- **Aztec Node**: The L2 rollup node +- **PXE**: Private eXecution Environment (embedded in node for local development) + +Wait for the network to fully initialize. You should see logs indicating readiness. The PXE will be available at `http://localhost:8080`. + +### Deploy and Verify + +Run the deployment script: + +```bash +yarn recursion +``` + +Expected output: + +```text +Contract deployed at: 0x... +Counter value: 10 +Counter value: 11 +``` + +The counter starts at 10 (set during deployment), and after successful proof verification, it increments to 11. This confirms that the Noir proof was verified inside the Aztec contract. + +## Quick Reference + +If you want to run all commands at once, or if you're starting fresh, here's the complete workflow. You can also reference the [full working example](https://github.com/AztecProtocol/aztec-packages/tree/v4.2.0-aztecnr-rc.2/docs/examples) in the main repository. + +```bash +# Install dependencies (after creating package.json and tsconfig.json) +yarn install + +# Compile the Noir circuit +cd circuit && nargo compile && cd .. + +# Compile the Aztec contract and generate TypeScript bindings +yarn ccc + +# Generate proof data +yarn data + +# Start the local network (in a separate terminal) +aztec start --local-network + +# Deploy and verify +yarn recursion +``` + +## Next Steps + +Now that you understand the basics of proof verification in Aztec contracts, explore these topics: + +- **Simpler Contract Examples**: If you're new to Aztec contracts, the [Counter Tutorial](./counter_contract.md) provides a gentler introduction to contract development patterns. +- **Multiple Public Inputs**: Extend the circuit to have multiple public inputs. Update `public_inputs: [Field; 1]` in the contract to match. +- **Noir Language Reference**: Explore advanced Noir features like loops, arrays, and standard library functions at [noir-lang.org](https://noir-lang.org/docs). diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md new file mode 100644 index 000000000000..9176794905b8 --- /dev/null +++ b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md @@ -0,0 +1,707 @@ +--- +title: Private Token Contract +sidebar_position: 1 +tags: [privacy, tokens, intermediate] +description: Build a privacy-preserving token for employee mental health benefits that keeps spending habits confidential. +references: ["docs/examples/contracts/bob_token_contract/src/main.nr", "docs/examples/ts/bob_token_contract/index.ts"] +--- + +## The Privacy Challenge: Mental Health Benefits at Giggle + +Giggle (a fictional tech company) wants to support their employees' mental health by providing BOB tokens that can be spent at Bob's Psychology Clinic. However, employees have a crucial requirement: **complete privacy**. They don't want Giggle to know: + +- How many BOB tokens they've actually used +- When they're using mental health services +- Their therapy patterns or frequency + +In this tutorial, we'll build a token contract that allows Giggle to mint BOB tokens for employees while ensuring complete privacy in how those tokens are spent. + +## Prerequisites + +This is an intermediate tutorial that assumes you have: + +- Completed the [Counter Contract tutorial](./counter_contract.md) +- A Running Aztec local network (see the Counter tutorial for setup) +- Basic understanding of Aztec.nr syntax and structure +- Aztec toolchain installed (`VERSION=4.2.0-aztecnr-rc.2 bash -i <(curl -sL https://install.aztec.network/4.2.0-aztecnr-rc.2)`) + +If you haven't completed the Counter Contract tutorial, please do so first as we'll skip the basic setup steps covered there. + +## What We're Building + +We'll create BOB tokens with: + +- **Public and Private minting**: Giggle can mint tokens in private or public +- **Public and Private transfers**: Employees can spend tokens at Bob's clinic with full privacy + +### Project Setup + +Let's create a simple yarn + aztec.nr project: + +```bash +mkdir bob_token +cd bob_token +yarn init +# This is to ensure yarn uses node_modules instead of pnp for dependency installation +yarn config set nodeLinker node-modules +yarn add @aztec/aztec.js@4.2.0-aztecnr-rc.2 @aztec/accounts@4.2.0-aztecnr-rc.2 @aztec/kv-store@4.2.0-aztecnr-rc.2 +aztec init +``` + +## Contract structure + +The `aztec init` command created a contract project with `Nargo.toml` and `src/main.nr`. Let's replace the boilerplate in `src/main.nr` with a simple starting point: + +```rust +use aztec::macros::aztec; + +#[aztec] +pub contract BobToken { + // We'll build the mental health token here +} +``` + +The `#[aztec]` macro transforms our contract code to work with Aztec's privacy protocol. + +Let's make sure the Aztec.nr library is listed in our dependencies in `bob_token_contract/Nargo.toml`: + +```toml +[package] +name = "bob_token_contract" +type = "contract" + +[dependencies] +aztec = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } +``` + +Since we're here, let's import more specific stuff from this library: + +```rust +#[aztec] +pub contract BobToken { + use aztec::{ + macros::{functions::{external, initializer, only_self}, storage::storage}, + messages::message_delivery::MessageDelivery, + protocol::address::AztecAddress, + state_vars::{Map, Owned, PublicMutable}, + }; +} +``` + +These are the different macros we need to define the visibility of functions, and some handy types and functions. + +## Building the Mental Health Token System + +### The Privacy Architecture + +Before we start coding, let's understand how privacy works in our mental health token system: + +1. **Public Layer**: Giggle mints tokens publicly - transparent and auditable +2. **Private Layer**: Employees transfer and spend tokens privately - completely confidential +3. **Cross-layer Transfer**: Employees can move tokens between public and private domains as needed + +This architecture ensures that while the initial allocation is transparent (important for corporate governance), the actual usage remains completely private. + +:::info Privacy Note +In Aztec, private state uses a UTXO model with "notes" - think of them as encrypted receipts that only the owner can decrypt and spend. When an employee receives BOB tokens privately, they get encrypted notes that only they can see and use. +::: + +Let's start building! Remember to import types as needed - your IDE's Noir extension can help with auto-imports. + +## Part 1: Public Minting for Transparency + +Let's start with the public components that Giggle will use to mint and track initial token allocations. + +### Setting Up Storage + +First, define the storage for our BOB tokens: + +```rust +#[storage] +struct Storage { + // Giggle's admin address + owner: PublicMutable, + // Public balances - visible for transparency + public_balances: Map, Context>, +} +``` + +This storage structure allows: + +- `owner`: Stores Giggle's admin address (who can mint tokens) +- `public_balances`: Tracks public token balances (employees can verify their allocations) + +:::tip Why Public Balances? +While employees want privacy when spending, having public balances during minting allows: + +1. Employees to verify they received their mental health benefits +2. Auditors to confirm fair distribution +3. Transparency in the allocation process + +::: + +### Initializing Giggle as Owner + +When deploying the contract, we need to set Giggle as the owner: + +```rust title="setup" showLineNumbers +#[initializer] +#[external("public")] +fn setup() { + // Giggle becomes the owner who can mint mental health tokens + self.storage.owner.write(self.msg_sender()); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L32-L39 + + +The `#[initializer]` decorator ensures this runs once during deployment. Only Giggle's address will have the power to mint new BOB tokens for employees. + +### Minting BOB Tokens for Employees + +Giggle needs a way to allocate mental health tokens to employees: + +```rust title="mint_public" showLineNumbers +#[external("public")] +fn mint_public(employee: AztecAddress, amount: u64) { + // Only Giggle can mint tokens + assert_eq(self.msg_sender(), self.storage.owner.read(), "Only Giggle can mint BOB tokens"); + + // Add tokens to employee's public balance + let current_balance = self.storage.public_balances.at(employee).read(); + self.storage.public_balances.at(employee).write(current_balance + amount); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L41-L51 + + +This public minting function: + +1. Verifies that only Giggle (the owner) is calling +2. Transparently adds tokens to the employee's public balance +3. Creates an auditable record of the allocation + +:::info Real-World Scenario +Imagine Giggle allocating 100 BOB tokens to each employee at the start of the year. This public minting ensures employees can verify they received their benefits, while their actual usage remains private. +::: + +### Public Transfers (Optional Transparency) + +While most transfers will be private, we'll add public transfers for cases where transparency is desired: + +```rust title="transfer_public" showLineNumbers +#[external("public")] +fn transfer_public(to: AztecAddress, amount: u64) { + let sender = self.msg_sender(); + let sender_balance = self.storage.public_balances.at(sender).read(); + assert(sender_balance >= amount, "Insufficient BOB tokens"); + + // Deduct from sender + self.storage.public_balances.at(sender).write(sender_balance - amount); + + // Add to recipient + let recipient_balance = self.storage.public_balances.at(to).read(); + self.storage.public_balances.at(to).write(recipient_balance + amount); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L53-L67 + + +This might be used when: + +- An employee transfers tokens to a colleague who's comfortable with transparency +- Bob's clinic makes a public refund +- Any scenario where privacy isn't required + +### Admin Transfer (Future-Proofing) + +In case Giggle's mental health program administration changes: + +```rust title="transfer_ownership" showLineNumbers +#[external("public")] +fn transfer_ownership(new_owner: AztecAddress) { + assert_eq( + self.msg_sender(), + self.storage.owner.read(), + "Only current admin can transfer ownership", + ); + self.storage.owner.write(new_owner); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L69-L79 + + +## Your First Deployment - Let's See It Work + +### Compile Your Contract + +You've written enough code to have a working token! Let's compile and test it: + +```bash +aztec compile +``` + +### Generate TypeScript Interface + +```bash +aztec codegen target --outdir artifacts +``` + +You should now have a nice typescript interface in a new `artifacts` folder. Pretty useful! + +### Deploy and Test + +Create `index.ts`. We will connect to our running local network and its wallet, then deploy the test accounts and get three wallets out of it. Ensure that your local network is running: + +```bash +aztec start --local-network +``` + +Then we will use the `giggleWallet` to deploy our contract, mint 100 BOB to Alice, then transfer 10 of those to Bob's Clinic publicly... for now. Let's go: + +```typescript +import { BobTokenContract } from "./artifacts/BobToken.js"; +import { AztecAddress } from "@aztec/aztec.js/addresses"; +import { createAztecNodeClient } from "@aztec/aztec.js/node"; +import { getInitialTestAccountsData } from "@aztec/accounts/testing"; +import { EmbeddedWallet } from "@aztec/wallets/embedded"; + +async function main() { + // Connect to local network + const node = createAztecNodeClient("http://localhost:8080"); + + const wallet = await EmbeddedWallet.create(node); + + const [giggleWalletData, aliceWalletData, bobClinicWalletData] = + await getInitialTestAccountsData(); + const giggleAccountManager = await wallet.createSchnorrAccount( + giggleWalletData.secret, + giggleWalletData.salt, + ); + const aliceAccountManager = await wallet.createSchnorrAccount( + aliceWalletData.secret, + aliceWalletData.salt, + ); + const bobClinicAccountManager = await wallet.createSchnorrAccount( + bobClinicWalletData.secret, + bobClinicWalletData.salt, + ); + + const giggleAddress = giggleAccountManager.address; + const aliceAddress = aliceAccountManager.address; + const bobClinicAddress = bobClinicAccountManager.address; + + const { contract: bobToken } = await BobTokenContract.deploy(wallet).send({ + from: giggleAddress, + }); + + await bobToken.methods + .mint_public(aliceAddress, 100n) + .send({ from: giggleAddress }); + + await bobToken.methods + .transfer_public(bobClinicAddress, 10n) + .send({ from: aliceAddress }); +} + +main().catch(console.error); +``` + +Run your test: + +```bash +npx tsx index.ts +``` + +:::tip + +What's this `tsx` dark magic? Well, it just compiles and runs typescript using reasonable defaults. Pretty cool for small snippets like this! + +::: + +### 🎉 Celebrate + +Congratulations! You've just deployed a working token contract on Aztec! You can: + +- ✅ Mint BOB tokens as Giggle +- ✅ Transfer tokens between employees +- ✅ Track balances publicly + +But there's a problem... **Giggle can see everything!** They know: + +- Who's transferring tokens +- How much is being spent +- When mental health services are being used + +This defeats the whole purpose of our mental health privacy initiative. Let's fix this by adding private functionality! + +## Part 2: Adding Privacy - The Real Magic Begins + +Now let's add the privacy features that make our mental health benefits truly confidential. + +### Understanding Private Notes + +Here's where Aztec's privacy magic happens. Unlike public balances (a single number), private balances are collections of encrypted "notes". Think of it this way: + +- **Public balance**: "Alice has 100 BOB tokens" (visible to everyone) +- **Private balance**: Alice has encrypted notes [Note1: 30 BOB, Note2: 50 BOB, Note3: 20 BOB] that only she can decrypt + +When Alice spends 40 BOB tokens at Bob's clinic: + +1. She consumes Note1 (30 BOB) and Note2 (50 BOB) = 80 BOB total +2. She creates a new note for Bob's clinic (40 BOB) +3. She creates a "change" note for herself (40 BOB) +4. The consumed notes are nullified (marked as spent) + +In this case, all that the network sees (including Giggle) is just "something happening to some state in some contract". How cool is that? + +### Updating Storage for Privacy + +For something like balances, you can use a simple library called `easy_private_state` which abstracts away a custom private Note. A Note is at the core of how private state works in Aztec and you can read about it [here](../../foundational-topics/state_management.md). For now, let's just import the library in `bob_token_contract/Nargo.toml`: + +```toml +[dependencies] +aztec = { git="https://github.com/AztecProtocol/aztec-nr", tag="v4.2.0-aztecnr-rc.2", directory="aztec" } +balance_set = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v4.2.0-aztecnr-rc.2", directory = "balance-set" } +``` + +Then import `BalanceSet` in our contract: + +```rust +use aztec::macros::aztec; + +#[aztec] +pub contract BobToken { + // ... other imports + use balance_set::BalanceSet; + // ... +} +``` + +We need to update the contract storage to have private balances as well: + +```rust title="storage" showLineNumbers +#[storage] +struct Storage { + // Giggle's admin address + owner: PublicMutable, + // Public balances - visible for transparency + public_balances: Map, Context>, + // Private balances - only the owner can see these + private_balances: Owned, Context>, +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L19-L30 + + +The `private_balances` use `BalanceSet` which manages encrypted notes automatically. + +### Moving Tokens to Privateland + +Great, now our contract knows about private balances. Let's implement a method to allow users to move their publicly minted tokens there: + +```rust title="public_to_private" showLineNumbers +#[external("private")] +fn public_to_private(amount: u64) { + let sender = self.msg_sender(); + // This will enqueue a public function to deduct from public balance + self.enqueue_self._deduct_public_balance(sender, amount); + // Add to private balance + self.storage.private_balances.at(sender).add(amount as u128).deliver( + MessageDelivery.ONCHAIN_CONSTRAINED, + ); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L81-L92 + + +And the helper function: + +```rust title="_deduct_public_balance" showLineNumbers +#[external("public")] +#[only_self] +fn _deduct_public_balance(owner: AztecAddress, amount: u64) { + let balance = self.storage.public_balances.at(owner).read(); + assert(balance >= amount, "Insufficient public BOB tokens"); + self.storage.public_balances.at(owner).write(balance - amount); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L94-L102 + + +By calling `public_to_private` we're telling the network "deduct this amount from my balance" while simultaneously creating a Note with that balance in privateland. + +### Private Transfers + +Now for the crucial privacy feature - transferring BOB tokens in privacy. This is actually pretty simple: + +```rust title="transfer_private" showLineNumbers +#[external("private")] +fn transfer_private(to: AztecAddress, amount: u64) { + let sender = self.msg_sender(); + // Spend sender's notes (consumes existing notes) + self.storage.private_balances.at(sender).sub(amount as u128).deliver( + MessageDelivery.ONCHAIN_CONSTRAINED, + ); + // Create new notes for recipient + self.storage.private_balances.at(to).add(amount as u128).deliver( + MessageDelivery.ONCHAIN_CONSTRAINED, + ); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L104-L117 + + +This function simply nullifies the sender's notes, while adding them to the recipient. + +:::info Real-World Impact + +When an employee uses 50 BOB tokens at Bob's clinic, this private transfer ensures Giggle has no visibility into: + +- The fact that the employee is seeking mental health services +- The frequency of visits +- The amount spent on treatment + +::: + +### Checking Balances + +Employees can check their BOB token balances without hitting the network by using utility unconstrained functions: + +```rust title="check_balances" showLineNumbers +#[external("utility")] +unconstrained fn private_balance_of(owner: AztecAddress) -> pub u128 { + self.storage.private_balances.at(owner).balance_of() +} + +#[external("utility")] +unconstrained fn public_balance_of(owner: AztecAddress) -> pub u64 { + self.storage.public_balances.at(owner).read() +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L119-L129 + + +## Part 3: Securing Private Minting + +Let's make this a little bit harder, and more interesting. Let's say Giggle doesn't want to mint the tokens in public. Can we have private minting on Aztec? + +Sure we can. Let's see. + +### Understanding Execution Domains + +Our BOB token system operates in two domains: + +1. **Public Domain**: Where Giggle mints tokens transparently +2. **Private Domain**: Where employees spend tokens confidentially + +The key challenge: How do we ensure only Giggle can mint tokens when the minting happens in a private function? + +:::warning Privacy Trade-off + +Private functions can't directly read current public state (like who the owner is). They can only read historical public state or enqueue public function calls for validation. + +::: + +### The Access Control Challenge + +We want Giggle to mint BOB tokens directly to employees' private balances (for maximum privacy), but we need to ensure only Giggle can do this. The challenge: ownership is stored publicly, but private functions can't read current public state. + +Let's use a clever pattern where private functions enqueue public validation checks. First we make a little helper function in public. Remember, public functions always run _after_ private functions, since private functions run client-side. + +```rust title="_assert_is_owner" showLineNumbers +#[external("public")] +#[only_self] +fn _assert_is_owner(address: AztecAddress) { + assert_eq(address, self.storage.owner.read(), "Only Giggle can mint BOB tokens"); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L131-L137 + + +Now we can add a secure private minting function. It looks pretty easy, and it is, since the whole thing will revert if the public function fails: + +```rust title="mint_private" showLineNumbers +#[external("private")] +fn mint_private(employee: AztecAddress, amount: u64) { + // Enqueue ownership check (will revert if not Giggle) + self.enqueue_self._assert_is_owner(self.msg_sender()); + + // If check passes, mint tokens privately + self.storage.private_balances.at(employee).add(amount as u128).deliver( + MessageDelivery.ONCHAIN_CONSTRAINED, + ); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L139-L150 + + +This pattern ensures: + +1. The private minting executes first (creating the proof) +2. The public ownership check executes after +3. If the check fails, the entire transaction (including the private part) reverts +4. Only Giggle can successfully mint BOB tokens + +## Part 4: Converting Back to Public + +For the sake of completeness, let's also have a function that brings the tokens back to publicland: + +```rust title="private_to_public" showLineNumbers +#[external("private")] +fn private_to_public(amount: u64) { + let sender = self.msg_sender(); + // Remove from private balance + self.storage.private_balances.at(sender).sub(amount as u128).deliver( + MessageDelivery.ONCHAIN_CONSTRAINED, + ); + // Enqueue public credit + self.enqueue_self._credit_public_balance(sender, amount); +} + +#[external("public")] +#[only_self] +fn _credit_public_balance(owner: AztecAddress, amount: u64) { + let balance = self.storage.public_balances.at(owner).read(); + self.storage.public_balances.at(owner).write(balance + amount); +} +``` +> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L152-L170 + + +Now you've made changes to your contract, you need to recompile your contract. + +Here are the steps from above, for reference: + +```bash +aztec compile +aztec codegen target --outdir artifacts +``` + +## Testing the Complete Privacy System + +Now that you've implemented all the privacy features, let's update our test script to showcase the full privacy flow: + +### Update Your Test Script + +Let's stop being lazy and add a nice little "log" function that just spits out everyone's balances to the console, for example: + +```typescript +// at the top of your file +async function getBalances( + contract: BobTokenContract, + aliceAddress: AztecAddress, + bobAddress: AztecAddress, +) { + await Promise.all([ + contract.methods + .public_balance_of(aliceAddress) + .simulate({ from: aliceAddress }) + .then(({ result }) => result), + contract.methods + .private_balance_of(aliceAddress) + .simulate({ from: aliceAddress }) + .then(({ result }) => result), + contract.methods + .public_balance_of(bobAddress) + .simulate({ from: bobAddress }) + .then(({ result }) => result), + contract.methods + .private_balance_of(bobAddress) + .simulate({ from: bobAddress }) + .then(({ result }) => result), + ]).then( + ([ + alicePublicBalance, + alicePrivateBalance, + bobPublicBalance, + bobPrivateBalance, + ]) => { + console.log( + `📊 Alice has ${alicePublicBalance} public BOB tokens and ${alicePrivateBalance} private BOB tokens`, + ); + console.log( + `📊 Bob's Clinic has ${bobPublicBalance} public BOB tokens and ${bobPrivateBalance} private BOB tokens`, + ); + }, + ); +} +``` + +Looks ugly but it does what it says: prints Alice's and Bob's balances. This will make it easier to see our contract working. + +Now let's add some more stuff to our `index.ts`: + +```typescript +async function main() { + // ...etc + await bobToken.methods + .mint_public(aliceAddress, 100n) + .send({ from: giggleAddress }); + await getBalances(bobToken, aliceAddress, bobClinicAddress); + + await bobToken.methods + .transfer_public(bobClinicAddress, 10n) + .send({ from: aliceAddress }); + await getBalances(bobToken, aliceAddress, bobClinicAddress); + + await bobToken.methods.public_to_private(90n).send({ from: aliceAddress }); + await getBalances(bobToken, aliceAddress, bobClinicAddress); + + await bobToken.methods + .transfer_private(bobClinicAddress, 50n) + .send({ from: aliceAddress }); + await getBalances(bobToken, aliceAddress, bobClinicAddress); + + await bobToken.methods.private_to_public(10n).send({ from: aliceAddress }); + await getBalances(bobToken, aliceAddress, bobClinicAddress); + + await bobToken.methods + .mint_private(aliceAddress, 100n) + .send({ from: giggleAddress }); + await getBalances(bobToken, aliceAddress, bobClinicAddress); +} + +main().catch(console.error); +``` + +The flow is something like: + +- Giggle mints Alice 100 BOB in public +- Alice transfers 10 BOB to Bob in public +- Alice makes the remaining 90 BOB private +- Alice transfers 50 of those to Bob, in private +- Of the remaining 40 BOB, she makes 10 public again +- Giggle mints 100 BOB tokens for Alice, in private + +Let's give it a try: + +```bash +npx tsx index.ts +``` + +You should see the complete privacy journey from transparent allocation to confidential usage! + +## Summary + +You've built a privacy-preserving token system that solves a real-world problem: enabling corporate mental health benefits while protecting employee privacy. This demonstrates Aztec's unique ability to provide both transparency and privacy where each is most needed. + +The BOB token shows how blockchain can enable new models of corporate benefits that weren't possible before - where verification and privacy coexist, empowering employees to seek help without fear of judgment or career impact. + +### What You Learned + +- How to create tokens with both public and private states +- How to bridge between public and private domains +- How to implement access control across execution contexts +- How to build real-world privacy solutions on Aztec + +## Going Further: The AIP-20 Token Standard + +The BOB token you built in this tutorial implements a simplified version of the patterns formalized in **AIP-20**, Aztec's fungible token standard. AIP-20 extends these patterns with commitment-based transfers for DeFi composability, recursive note consumption for large balances, and tokenized vault support (AIP-4626). + +Read the full [AIP-20 standard reference](../../aztec-nr/standards/aip-20.md) for details, or explore all [Aztec Contract Standards](../../aztec-nr/standards/index.md). + +### Continue Your Journey + +- Explore [cross-chain communication](../../foundational-topics/ethereum-aztec-messaging/index.md) to integrate with existing health systems +- Learn about [account abstraction](../../foundational-topics/accounts/index.md) for recovery mechanisms diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md new file mode 100644 index 000000000000..c220e58099ef --- /dev/null +++ b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md @@ -0,0 +1,1059 @@ +--- +title: "Bridge Your NFT to Aztec" +sidebar_position: 1 +description: "Build a private NFT bridge between Ethereum and Aztec using custom notes, PrivateSet, and cross-chain messaging portals." +references: ["docs/examples/tutorials/token_bridge_contract/*"] +--- + +## Why Bridge an NFT? + +Imagine you own a CryptoPunk NFT on Ethereum. You want to use it in games, social apps, or DeFi protocols, but gas fees on Ethereum make every interaction expensive. What if you could move your Punk to Aztec (L2), use it **privately** in dozens of applications, and then bring it back to Ethereum when you're ready to sell? + +In this tutorial, you'll build a **private NFT bridge**. By the end, you'll understand how **portals** work and how **cross-chain messages** flow between L1 and L2. + +Before starting, make sure you have the Aztec local network running at version 4.2.0-aztecnr-rc.2. Check out [the local network guide](../../../getting_started_on_local_network.md) for setup instructions. + +## What You'll Build + +You'll create two contracts with **privacy at the core**: + +- **NFTPunk (L2)** - An NFT contract with encrypted ownership using `PrivateSet` +- **NFTBridge (L2)** - A bridge that mints NFTs privately when claiming L1 messages + +This tutorial focuses on the L2 side to keep things manageable. You'll learn the essential privacy patterns that apply to any asset bridge on Aztec. + +## Project Setup + +Let's start simple. Since this is an Ethereum project, it's easier to just start with Hardhat: + +```bash +git clone https://github.com/critesjosh/hardhat-aztec-example +``` + +You're cloning a repo here to make it easier for Aztec's `l1-contracts` to be mapped correctly. You should now have a `hardhat-aztec-example` folder with Hardhat's default starter, with a few changes in `package.json`. + +We want to add a few more dependencies now before we start: + +```bash +cd hardhat-aztec-example +yarn add @aztec/aztec.js@4.2.0-aztecnr-rc.2 @aztec/accounts@4.2.0-aztecnr-rc.2 @aztec/stdlib@4.2.0-aztecnr-rc.2 @aztec/wallets@4.2.0-aztecnr-rc.2 tsx +``` + +Now start the local network in another terminal: + +```bash +aztec start --local-network +``` + +This should start two important services on ports 8080 and 8545, respectively: Aztec and Anvil (an Ethereum development node). + +## Part 1: Building the NFT Contract + +Let's start with a basic NFT contract on Aztec. That's the representation of the NFT locked on the L2 side: + +```mermaid +graph LR + subgraph Ethereum["Ethereum (L1)"] + L1NFT["🎨 L1 NFT
(CryptoPunk)"] + L1Portal["🌉 L1 Portal
(TokenPortal)"] + end + + subgraph Aztec["Aztec (L2)"] + L2Bridge["🔗 L2 Bridge
(NFTBridge)"] + L2NFT["🎭 L2 NFT
(NFTPunk)"] + end + + L1NFT -->|"Lock NFT"| L1Portal + L1Portal -->|"L1→L2 Message"| L2Bridge + L2Bridge -->|"Mint Private"| L2NFT + + L2NFT -.->|"Burn"| L2Bridge + L2Bridge -.->|"L2→L1 Message"| L1Portal + L1Portal -.->|"Unlock NFT"| L1NFT + + style L2NFT fill:#4ade80,stroke:#22c55e,stroke-width:3px + style L2Bridge fill:#f0f0f0,stroke:#999,stroke-width:2px + style L1Portal fill:#f0f0f0,stroke:#999,stroke-width:2px + style L1NFT fill:#f0f0f0,stroke:#999,stroke-width:2px + + classDef highlight fill:#4ade80,stroke:#22c55e,stroke-width:3px +``` + +Let's create that crate in the `contracts` folder so it looks tidy: + +```bash +aztec new contracts/aztec/nft +cd contracts/aztec/nft +``` + +:::tip Noir Language Server + +If you're using VS Code, install the [Noir Language Support extension](https://marketplace.visualstudio.com/items?itemName=noir-lang.vscode-noir) for syntax highlighting, error checking, and code completion while writing Noir contracts. + +::: + +Open `Nargo.toml` and make sure `aztec` is a dependency: + +```toml +[dependencies] +aztec = { git = "https://github.com/AztecProtocol/aztec-nr", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } +``` + +### Create the NFT Note + +First, let's create a custom note type for private NFT ownership. In the `src/` directory, create a new file called `nft.nr`: + +```bash +touch src/nft.nr +``` + +In this file, you're going to create a **private note** that represents NFT ownership. This is a struct with macros that indicate it is a note that can be compared and packed: + +```rust title="nft_note_struct" showLineNumbers +use aztec::{macros::notes::note, protocol::traits::Packable}; + +#[derive(Eq, Packable)] +#[note] +pub struct NFTNote { + pub token_id: Field, +} +``` +> Source code: docs/examples/contracts/nft/src/nft.nr#L1-L9 + + +You now have a note that represents the owner of a particular NFT. Next, move on to the contract itself. + +:::tip Custom Notes + +Notes are powerful concepts. Learn more about how to use them in the [state management guide](../../foundational-topics/state_management.md). + +::: + +### Define Storage + +Back in `main.nr`, you can now build the contract storage. You need: + +- **admin**: Who controls the contract (set once, never changes) +- **minter**: The bridge address (set once by admin) +- **nfts**: Track which NFTs exist (public, needed for bridging) +- **owners**: Private ownership using the NFTNote + +One interesting aspect of this storage configuration is the use of `DelayedPublicMutable`, which allows private functions to read and use public state. You're using it to publicly track which NFTs are already minted while keeping their owners private. Read more about `DelayedPublicMutable` in [the storage guide](../../aztec-nr/framework-description/state_variables.md). + +Write the storage struct and a simple [initializer](../../foundational-topics/contract_creation.md#initialization) to set the admin in the `main.nr` file: + + + +```rust +use aztec::macros::aztec; +pub mod nft; + +#[aztec] +pub contract NFTPunk { + use crate::nft::NFTNote; + use aztec::{ + macros::{functions::{external, initializer, only_self}, storage::storage}, + protocol::address::AztecAddress, + state_vars::{DelayedPublicMutable, Map, Owned, PrivateSet, PublicImmutable}, + }; + use aztec::messages::message_delivery::MessageDelivery; + use aztec::note::{ + note_getter_options::NoteGetterOptions, note_interface::NoteProperties, + note_viewer_options::NoteViewerOptions, + }; + use aztec::utils::comparison::Comparator; + + #[storage] + struct Storage { + admin: PublicImmutable, + minter: PublicImmutable, + nfts: Map, Context>, + owners: Owned, Context>, + } + #[external("public")] + #[initializer] + fn constructor(admin: AztecAddress) { + self.storage.admin.initialize(admin); + } +} +``` + +### Utility Functions + +Add an internal function to handle the `DelayedPublicMutable` value change. Mark the function as public and `#[only_self]` so only the contract can call it: + +```rust title="mark_nft_exists" showLineNumbers +#[external("public")] +#[only_self] +fn _mark_nft_exists(token_id: Field, exists: bool) { + self.storage.nfts.at(token_id).schedule_value_change(exists); +} +``` +> Source code: docs/examples/contracts/nft/src/main.nr#L42-L48 + + +This function is marked with `#[only_self]`, meaning only the contract itself can call it. It uses `schedule_value_change` to update the `nfts` storage, preventing the same NFT from being minted twice or burned when it doesn't exist. You'll call this public function from a private function later using `enqueue_self`. + +Another useful function checks how many notes a caller has. You can use this later to verify the claim and exit from L2: + +```rust title="notes_of" showLineNumbers +#[external("utility")] +unconstrained fn notes_of(from: AztecAddress) -> Field { + let notes = self.storage.owners.at(from).view_notes(NoteViewerOptions::new()); + notes.len() as Field +} +``` +> Source code: docs/examples/contracts/nft/src/main.nr#L67-L73 + + +### Add Minting and Burning + +Before anything else, you need to set the minter. This will be the bridge contract, so only the bridge contract can mint NFTs. This value doesn't need to change after initialization. Here's how to initialize the `PublicImmutable`: + +```rust title="set_minter" showLineNumbers +#[external("public")] +fn set_minter(minter: AztecAddress) { + assert(self.storage.admin.read().eq(self.msg_sender()), "caller is not admin"); + self.storage.minter.initialize(minter); +} +``` +> Source code: docs/examples/contracts/nft/src/main.nr#L34-L40 + + +Now for the magic - minting NFTs **privately**. The bridge will call this to mint to a user, deliver the note using [constrained message delivery](../../aztec-nr/framework-description/events_and_logs.md) (best practice when "sending someone a +note") and then [enqueue a public call](../../aztec-nr/framework-description/calling_contracts.md) to the `_mark_nft_exists` function: + +```rust title="mint" showLineNumbers +#[external("private")] +fn mint(to: AztecAddress, token_id: Field) { + assert( + self.storage.minter.read().eq(self.msg_sender()), + "caller is not the authorized minter", + ); + + // we create an NFT note and insert it to the PrivateSet - a collection of notes meant to be read in private + let new_nft = NFTNote { token_id }; + self.storage.owners.at(to).insert(new_nft).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); + + // calling the internal public function above to indicate that the NFT is taken + self.enqueue_self._mark_nft_exists(token_id, true); +} +``` +> Source code: docs/examples/contracts/nft/src/main.nr#L50-L65 + + +The bridge will also need to burn NFTs when users withdraw back to L1: + +```rust title="burn" showLineNumbers +#[external("private")] +fn burn(from: AztecAddress, token_id: Field) { + assert( + self.storage.minter.read().eq(self.msg_sender()), + "caller is not the authorized minter", + ); + + // from the NFTNote properties, selects token_id and compares it against the token_id to be burned + let options = NoteGetterOptions::new() + .select(NFTNote::properties().token_id, Comparator.EQ, token_id) + .set_limit(1); + let notes = self.storage.owners.at(from).pop_notes(options); + assert(notes.len() == 1, "NFT not found"); + + self.enqueue_self._mark_nft_exists(token_id, false); +} +``` +> Source code: docs/examples/contracts/nft/src/main.nr#L75-L92 + + +### Compiling! + +Let's verify it compiles: + +```bash +aztec compile +``` + +🎉 You should see "Compiled successfully!" This means our private NFT contract is ready. Now let's build the bridge. + +## Part 2: Building the Bridge + +We have built the L2 NFT contract. This is the L2 representation of an NFT that is locked on the L1 bridge. + +The L2 bridge is the contract that talks to the L1 bridge through cross-chain messaging. You can read more about this protocol [here](../../../docs/foundational-topics/ethereum-aztec-messaging/index.md). + +```mermaid +graph LR + subgraph Ethereum["Ethereum (L1)"] + L1NFT["🎨 L1 NFT
(CryptoPunk)"] + L1Portal["🌉 L1 Portal
(TokenPortal)"] + end + + subgraph Aztec["Aztec (L2)"] + L2Bridge["🔗 L2 Bridge
(NFTBridge)"] + L2NFT["🎭 L2 NFT
(NFTPunk)"] + end + + L1NFT -->|"Lock NFT"| L1Portal + L1Portal -->|"L1→L2 Message"| L2Bridge + L2Bridge -->|"Mint Private"| L2NFT + + L2NFT -.->|"Burn"| L2Bridge + L2Bridge -.->|"L2→L1 Message"| L1Portal + L1Portal -.->|"Unlock NFT"| L1NFT + + style L2Bridge fill:#4ade80,stroke:#22c55e,stroke-width:3px + style L2NFT fill:#f0f0f0,stroke:#999,stroke-width:2px + style L1Portal fill:#f0f0f0,stroke:#999,stroke-width:2px + style L1NFT fill:#f0f0f0,stroke:#999,stroke-width:2px + + classDef highlight fill:#4ade80,stroke:#22c55e,stroke-width:3px +``` + +Let's create a new contract in the same tidy `contracts/aztec` folder: + +```bash +cd .. +aztec new nft_bridge +cd nft_bridge +``` + +And again, add the `aztec-nr` dependency to `Nargo.toml`. We also need to add the `NFTPunk` contract we just wrote above: + +```toml +[dependencies] +aztec = { git="https://github.com/AztecProtocol/aztec-nr", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } +NFTPunk = { path = "../nft" } +``` + +### Understanding Bridges + +A bridge has two jobs: + +1. **Claim**: When someone deposits an NFT on L1, mint it on L2 +2. **Exit**: When someone wants to withdraw, burn on L2 and unlock on L1 + +This means having knowledge about the L2 NFT contract, and the bridge on the L1 side. That's what goes into our bridge's storage. + +### Bridge Storage + +Clean up `main.nr` which is just a placeholder, and let's write the storage struct and the constructor. We'll use `PublicImmutable` since these values never change: + + + +```rust +use aztec::macros::aztec; + +#[aztec] +pub contract NFTBridge { + use aztec::{ + macros::{functions::{external, initializer}, storage::storage}, + protocol::{address::{AztecAddress, EthAddress}, hash::sha256_to_field}, + state_vars::PublicImmutable, + }; + use NFTPunk::NFTPunk; + + #[storage] + struct Storage { + nft: PublicImmutable, + portal: PublicImmutable, + } + + #[external("public")] + #[initializer] + fn constructor(nft: AztecAddress) { + self.storage.nft.initialize(nft); + } + + #[external("public")] + fn set_portal(portal: EthAddress) { + self.storage.portal.initialize(portal); + } +} +``` + +You can't initialize the `portal` value in the constructor because the L1 portal hasn't been deployed yet. You'll need another function to set it up after the L1 portal is deployed. + +### Adding the Bridge Functions + +The Aztec network provides a way to consume messages from L1 to L2 called `consume_l1_to_l2_message`. + +You need to define how to encode messages. Here's a simple approach: when an NFT is being bridged, the L1 portal sends a hash of its `token_id` through the bridge, signaling which `token_id` was locked and can be minted on L2. This approach is simple but sufficient for this tutorial. + +Build the `claim` function, which consumes the message and mints the NFT on the L2 side: + +```rust title="claim" showLineNumbers +#[external("private")] +fn claim(to: AztecAddress, token_id: Field, secret: Field, message_leaf_index: Field) { + // Compute the message hash that was sent from L1 + let token_id_bytes: [u8; 32] = (token_id as Field).to_be_bytes(); + let content_hash = sha256_to_field(token_id_bytes); + + // Consume the L1 -> L2 message + self.context.consume_l1_to_l2_message( + content_hash, + secret, + self.storage.portal.read(), + message_leaf_index, + ); + + // Mint the NFT on L2 + let nft: AztecAddress = self.storage.nft.read(); + self.call(NFTPunk::at(nft).mint(to, token_id)); +} +``` +> Source code: docs/examples/contracts/nft_bridge/src/main.nr#L31-L50 + + +:::tip Secret + +The secret prevents front-running. Certainly you don't want anyone to claim your NFT on the L2 side by just being faster. Adding a secret acts like a "password": you can only claim it if you know it. + +::: + +Similarly, exiting to L1 means burning the NFT on the L2 side and pushing a message through the protocol. To ensure only the L1 recipient can claim it, hash the `token_id` together with the `recipient`: + +```rust title="exit" showLineNumbers +#[external("private")] +fn exit(token_id: Field, recipient: EthAddress) { + // Create L2->L1 message to unlock NFT on L1 + let token_id_bytes: [u8; 32] = token_id.to_be_bytes(); + let recipient_bytes: [u8; 20] = recipient.to_be_bytes(); + let content = sha256_to_field(token_id_bytes.concat(recipient_bytes)); + self.context.message_portal(self.storage.portal.read(), content); + + // Burn the NFT on L2 + let nft: AztecAddress = self.storage.nft.read(); + self.call(NFTPunk::at(nft).burn(self.msg_sender(), token_id)); +} +``` +> Source code: docs/examples/contracts/nft_bridge/src/main.nr#L52-L65 + + +Cross-chain messaging on Aztec is powerful because it doesn't conform to any specific format—you can structure messages however you want. + +:::tip Private Functions + +Both `claim` and `exit` are `#[external("private")]`, which means the bridging process is private—nobody can see who's bridging which NFT by watching the chain. + +::: + +### Compile the Bridge + +```bash +aztec compile +``` + +Bridge compiled successfully! Now process both contracts and generate TypeScript bindings: + +```bash +cd ../nft +aztec codegen target --outdir ../artifacts + +cd ../nft_bridge +aztec codegen target --outdir ../artifacts +``` + +An `artifacts` folder should appear with TypeScript bindings for each contract. You'll use these when deploying the contracts. + +## Part 3: The Ethereum Side + +Now build the L1 contracts. You need: + +- A simple ERC721 NFT contract (the "CryptoPunk") +- A portal contract that locks/unlocks NFTs and communicates with Aztec + +### Install Dependencies + +Aztec's contracts are already in your `package.json`. You just need to add the OpenZeppelin contracts that provide the default ERC721 implementation: + +```bash +cd ../../.. +yarn add @openzeppelin/contracts +``` + +### Create a Simple NFT + +Delete the "Counter" contracts that show up by default in `contracts` and create `contracts/SimpleNFT.sol`: + +```bash +touch contracts/SimpleNFT.sol +``` + +Create a minimal NFT contract sufficient for demonstrating bridging: + +```solidity title="simple_nft" showLineNumbers +pragma solidity >=0.8.27; + +import {ERC721} from "@oz/token/ERC721/ERC721.sol"; + +contract SimpleNFT is ERC721 { + uint256 private _currentTokenId; + + constructor() ERC721("SimplePunk", "SPUNK") {} + + function mint(address to) external returns (uint256) { + uint256 tokenId = _currentTokenId++; + _mint(to, tokenId); + return tokenId; + } +} +``` +> Source code: docs/examples/solidity/nft_bridge/SimpleNFT.sol#L2-L18 + + +### Create the NFT Portal + +The NFT Portal has more code, so build it step-by-step. Create `contracts/NFTPortal.sol`: + +```bash +touch contracts/NFTPortal.sol +``` + +Initialize it with Aztec's registry, which holds the canonical contracts for Aztec-related contracts, including the Inbox and Outbox. These are the message-passing contracts—Aztec sequencers read any messages on these contracts. + +```solidity +import {IERC721} from "@oz/token/ERC721/IERC721.sol"; +import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; +import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; +import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol"; +import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; +import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; +import {Hash} from "@aztec/core/libraries/crypto/Hash.sol"; +import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; + +contract NFTPortal { + IRegistry public registry; + IERC721 public nftContract; + bytes32 public l2Bridge; + + IRollup public rollup; + IOutbox public outbox; + IInbox public inbox; + uint256 public rollupVersion; + + function initialize(address _registry, address _nftContract, bytes32 _l2Bridge) external { + registry = IRegistry(_registry); + nftContract = IERC721(_nftContract); + l2Bridge = _l2Bridge; + + rollup = IRollup(address(registry.getCanonicalRollup())); + outbox = rollup.getOutbox(); + inbox = rollup.getInbox(); + rollupVersion = rollup.getVersion(); + } +} +``` + +The core logic is similar to the L2 logic. `depositToAztec` calls the `Inbox` canonical contract to send a message to Aztec, and `withdraw` calls the `Outbox` contract. + +Add these two functions with explanatory comments: + +```solidity title="portal_deposit_and_withdraw" showLineNumbers +// Lock NFT and send message to L2 +function depositToAztec(uint256 tokenId, bytes32 secretHash) external returns (bytes32, uint256) { + // Lock the NFT + nftContract.transferFrom(msg.sender, address(this), tokenId); + + // Prepare L2 message - just a naive hash of our tokenId + DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); + bytes32 contentHash = Hash.sha256ToField(abi.encode(tokenId)); + + // Send message to Aztec + (bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, secretHash); + return (key, index); +} + +// Unlock NFT after L2 burn +function withdraw( + uint256 tokenId, + Epoch epoch, + uint256 leafIndex, + bytes32[] calldata path +) external { + // Verify message from L2 + DataStructures.L2ToL1Msg memory message = DataStructures.L2ToL1Msg({ + sender: DataStructures.L2Actor(l2Bridge, rollupVersion), + recipient: DataStructures.L1Actor(address(this), block.chainid), + content: Hash.sha256ToField(abi.encodePacked(tokenId, msg.sender)) + }); + + outbox.consume(message, epoch, leafIndex, path); + + // Unlock NFT + nftContract.transferFrom(address(this), msg.sender, tokenId); +} +``` +> Source code: docs/examples/solidity/nft_bridge/NFTPortal.sol#L36-L70 + + +The portal handles two flows: + +- **depositToAztec**: Locks NFT on L1, sends message to L2 +- **withdraw**: Verifies L2 message, unlocks NFT on L1 + +### Compile + +Let's make sure everything compiles: + +```bash +npx hardhat compile +``` + +You should see successful compilation of both contracts! + +## Part 4: Compiling, Deploying, and Testing + +Now deploy everything and test the full flow. This will help you understand how everything fits together. + +Delete the placeholders in `scripts` and create `index.ts`: + +```bash +touch scripts/index.ts +``` + +This script will implement the user flow. + +:::warning Testnet + +This section assumes you're working locally using the local network. For the testnet, you need to account for some things: + +- Your clients need to point to some Sepolia Node and to the public Aztec Full Node +- You need to [deploy your own Aztec accounts](../../aztec-js/how_to_create_account.md) +- You need to pay fees in some other way. Learn how in the [fees guide](../../aztec-js/how_to_pay_fees.md) + +::: + +### Deploying and Initializing + +First, initialize the clients: `aztec.js` for Aztec and `viem` for Ethereum: + +```typescript title="setup" showLineNumbers +import { getInitialTestAccountsData } from "@aztec/accounts/testing"; +import { AztecAddress, EthAddress } from "@aztec/aztec.js/addresses"; +import { Fr } from "@aztec/aztec.js/fields"; +import { createAztecNodeClient } from "@aztec/aztec.js/node"; +import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; +import { sha256ToField } from "@aztec/foundation/crypto/sha256"; +import { + computeL2ToL1MessageHash, + computeSecretHash, +} from "@aztec/stdlib/hash"; +import { computeL2ToL1MembershipWitness } from "@aztec/stdlib/messaging"; +import { EmbeddedWallet } from "@aztec/wallets/embedded"; +import { decodeEventLog, pad } from "@aztec/viem"; +import { foundry } from "@aztec/viem/chains"; +import NFTPortal from "../../../target/solidity/nft_bridge/NFTPortal.sol/NFTPortal.json" with { type: "json" }; +import SimpleNFT from "../../../target/solidity/nft_bridge/SimpleNFT.sol/SimpleNFT.json" with { type: "json" }; +import { NFTBridgeContract } from "./artifacts/NFTBridge.js"; +import { NFTPunkContract } from "./artifacts/NFTPunk.js"; + +// Setup L1 client using anvil's default mnemonic (same as e2e tests) +const MNEMONIC = "test test test test test test test test test test test junk"; +const l1Client = createExtendedL1Client(["http://localhost:8545"], MNEMONIC); +const ownerEthAddress = l1Client.account.address; + +// Setup L2 using Aztec's local network and one of its initial accounts +console.log("Setting up L2...\n"); +const node = createAztecNodeClient("http://localhost:8080"); +const aztecWallet = await EmbeddedWallet.create(node); +const [accData] = await getInitialTestAccountsData(); +const account = await aztecWallet.createSchnorrAccount( + accData.secret, + accData.salt, +); +console.log(`Account: ${account.address.toString()}\n`); + +// Get node info +const nodeInfo = await node.getNodeInfo(); +const registryAddress = nodeInfo.l1ContractAddresses.registryAddress.toString(); +const inboxAddress = nodeInfo.l1ContractAddresses.inboxAddress.toString(); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L1-L42 + + +You now have wallets for both chains, correctly connected to their respective chains. Next, deploy the L1 contracts: + +```typescript title="deploy_l1_contracts" showLineNumbers +console.log("Deploying L1 contracts...\n"); + +const { address: nftAddress } = await deployL1Contract( + l1Client, + SimpleNFT.abi, + SimpleNFT.bytecode.object as `0x${string}`, +); + +const { address: portalAddress } = await deployL1Contract( + l1Client, + NFTPortal.abi, + NFTPortal.bytecode.object as `0x${string}`, +); + +console.log(`SimpleNFT: ${nftAddress}`); +console.log(`NFTPortal: ${portalAddress}\n`); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L44-L61 + + +Now deploy the L2 contracts. Thanks to the TypeScript bindings generated with `aztec codegen`, deployment is straightforward: + +```typescript title="deploy_l2_contracts" showLineNumbers +console.log("Deploying L2 contracts...\n"); + +const { contract: l2Nft } = await NFTPunkContract.deploy(aztecWallet, account.address).send({ + from: account.address, +}); + +const { contract: l2Bridge } = await NFTBridgeContract.deploy( + aztecWallet, + l2Nft.address, +).send({ from: account.address }); + +console.log(`L2 NFT: ${l2Nft.address.toString()}`); +console.log(`L2 Bridge: ${l2Bridge.address.toString()}\n`); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L63-L77 + + +Now that you have the L2 bridge's contract address, initialize the L1 bridge: + +```typescript title="initialize_portal" showLineNumbers +console.log("Initializing portal..."); + +// Initialize the portal contract +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const initHash = await l1Client.writeContract({ + address: portalAddress.toString() as `0x${string}`, + abi: NFTPortal.abi, + functionName: "initialize", + args: [registryAddress, nftAddress.toString(), l2Bridge.address.toString()], +}); +await l1Client.waitForTransactionReceipt({ hash: initHash }); + +console.log("Portal initialized\n"); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L79-L93 + + +The L2 contracts were already initialized when you deployed them, but you still need to: + +- Tell the L2 bridge about Ethereum's portal address (by calling `set_portal` on the bridge) +- Tell the L2 NFT contract who the minter is (by calling `set_minter` on the L2 NFT contract) + +Complete these initialization steps: + +```typescript title="initialize_l2_bridge" showLineNumbers +console.log("Setting up L2 bridge..."); + +await l2Bridge.methods + .set_portal(EthAddress.fromString(portalAddress.toString())) + .send({ from: account.address }); + +await l2Nft.methods + .set_minter(l2Bridge.address) + .send({ from: account.address }); + +console.log("Bridge configured\n"); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L95-L107 + + +This completes the setup. It's a lot of configuration, but you're dealing with four contracts across two chains. + +### L1 → L2 Flow + +Now for the main flow. Mint a CryptoPunk on L1, deposit it to Aztec, and claim it on Aztec. Put everything in the same script. To mint, call the L1 contract with `mint`, which will mint `tokenId = 0`: + +```typescript title="mint_nft_l1" showLineNumbers +console.log("Minting NFT on L1..."); + +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const mintHash = await l1Client.writeContract({ + address: nftAddress.toString() as `0x${string}`, + abi: SimpleNFT.abi, + functionName: "mint", + args: [ownerEthAddress], +}); +await l1Client.waitForTransactionReceipt({ hash: mintHash }); + +// no need to parse logs, this will be tokenId 0 since it's a fresh contract +const tokenId = 0n; + +console.log(`Minted tokenId: ${tokenId}\n`); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L109-L125 + + +To bridge, first approve the portal address to transfer the NFT, then transfer it by calling `depositToAztec`: + +```typescript title="deposit_to_aztec" showLineNumbers +console.log("Depositing NFT to Aztec..."); + +const secret = Fr.random(); +const secretHash = await computeSecretHash(secret); + +// Approve portal to transfer the NFT +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const approveHash = await l1Client.writeContract({ + address: nftAddress.toString() as `0x${string}`, + abi: SimpleNFT.abi, + functionName: "approve", + args: [portalAddress.toString(), tokenId], +}); +await l1Client.waitForTransactionReceipt({ hash: approveHash }); + +// Deposit to Aztec +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const depositHash = await l1Client.writeContract({ + address: portalAddress.toString() as `0x${string}`, + abi: NFTPortal.abi, + functionName: "depositToAztec", + args: [ + tokenId, + pad(secretHash.toString() as `0x${string}`, { dir: "left", size: 32 }), + ], +}); +const depositReceipt = await l1Client.waitForTransactionReceipt({ + hash: depositHash, +}); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L127-L157 + + +The `Inbox` contract will emit an important log: `MessageSent(inProgress, index, leaf, updatedRollingHash);`. This log provides the **leaf index** of the message in the [L1-L2 Message Tree](../../foundational-topics/ethereum-aztec-messaging/index.md)—the location of the message in the tree that will appear on L2. You need this index, plus the secret, to correctly claim and decrypt the message. + +Use viem to extract this information: + +```typescript title="get_message_leaf_index" showLineNumbers +const INBOX_ABI = [ + { + type: "event", + name: "MessageSent", + inputs: [ + { name: "checkpointNumber", type: "uint256", indexed: true }, + { name: "index", type: "uint256", indexed: false }, + { name: "hash", type: "bytes32", indexed: true }, + { name: "rollingHash", type: "bytes16", indexed: false }, + ], + }, +] as const; + +// Find and decode the MessageSent event from the Inbox contract +const messageSentLogs = depositReceipt.logs + .filter((log) => log.address.toLowerCase() === inboxAddress.toLowerCase()) + .map((log: any) => { + try { + const decoded = decodeEventLog({ + abi: INBOX_ABI, + data: log.data, + topics: log.topics, + }); + return { log, decoded }; + } catch { + // Not a decodable event from this ABI + return null; + } + }) + .filter( + (item): item is { log: any; decoded: any } => + item !== null && (item.decoded as any).eventName === "MessageSent", + ); + +const messageLeafIndex = new Fr(messageSentLogs[0].decoded.args.index); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L159-L195 + + +This extracts the logs from the deposit and retrieves the leaf index. You can now claim it on L2. However, for security reasons, at least 2 blocks must pass before a message can be claimed on L2. If you called `claim` on the L2 contract immediately, it would return "no message available". + +Add a utility function to mine two blocks (it deploys a contract with a random salt): + +```typescript title="mine_blocks" showLineNumbers +async function mine2Blocks( + aztecWallet: EmbeddedWallet, + accountAddress: AztecAddress, +) { + await NFTPunkContract.deploy(aztecWallet, accountAddress).send({ + from: accountAddress, + contractAddressSalt: Fr.random(), + }); + await NFTPunkContract.deploy(aztecWallet, accountAddress).send({ + from: accountAddress, + contractAddressSalt: Fr.random(), + }); +} +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L197-L211 + + +Now claim the message on L2: + +```typescript title="claim_on_l2" showLineNumbers +// Mine blocks +await mine2Blocks(aztecWallet, account.address); + +// Check notes before claiming (should be 0) +console.log("Checking notes before claim..."); +const { result: notesBefore } = await l2Nft.methods + .notes_of(account.address) + .simulate({ from: account.address }); +console.log(` Notes count: ${notesBefore}`); + +console.log("Claiming NFT on L2..."); +await l2Bridge.methods + .claim(account.address, new Fr(Number(tokenId)), secret, messageLeafIndex) + .send({ from: account.address }); +console.log("NFT claimed on L2\n"); + +// Check notes after claiming (should be 1) +console.log("Checking notes after claim..."); +const { result: notesAfterClaim } = await l2Nft.methods + .notes_of(account.address) + .simulate({ from: account.address }); +console.log(` Notes count: ${notesAfterClaim}\n`); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L213-L236 + + +### L2 → L1 Flow + +Great! You can expand the L2 contract to add features like NFT transfers. For now, exit the NFT on L2 and redeem it on L1. Mine two blocks because of `DelayedMutable`: + +```typescript title="exit_from_l2" showLineNumbers +// L2 -> L1 flow +console.log("Exiting NFT from L2..."); +// Mine blocks, not necessary on devnet, but must wait for 2 blocks +await mine2Blocks(aztecWallet, account.address); + +const recipientEthAddress = EthAddress.fromString(ownerEthAddress); + +const { receipt: exitReceipt } = await l2Bridge.methods + .exit(new Fr(Number(tokenId)), recipientEthAddress) + .send({ from: account.address }); + +console.log(`Exit message sent (block: ${exitReceipt.blockNumber})\n`); + +// Check notes after burning (should be 0 again) +console.log("Checking notes after burn..."); +const { result: notesAfterBurn } = await l2Nft.methods + .notes_of(account.address) + .simulate({ from: account.address }); +console.log(` Notes count: ${notesAfterBurn}\n`); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L238-L258 + + +Just like in the L1 → L2 flow, you need to know what to claim on L1. Where in the message tree is the message you want to claim? Use the utility `computeL2ToL1MembershipWitness`, which provides the leaf and the sibling path of the message: + +```typescript title="get_withdrawal_witness" showLineNumbers +// Compute the message hash directly from known parameters +// This matches what the portal contract expects: Hash.sha256ToField(abi.encodePacked(tokenId, recipient)) +const tokenIdBuffer = new Fr(Number(tokenId)).toBuffer(); +const recipientBuffer = Buffer.from( + recipientEthAddress.toString().slice(2), + "hex", +); +const content = sha256ToField([tokenIdBuffer, recipientBuffer]); + +// Get rollup version from the portal contract (it stores it during initialize) +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const version = (await l1Client.readContract({ + address: portalAddress.toString() as `0x${string}`, + abi: NFTPortal.abi, + functionName: "rollupVersion", +})) as bigint; + +// Compute the L2->L1 message hash +const msgLeaf = computeL2ToL1MessageHash({ + l2Sender: l2Bridge.address, + l1Recipient: EthAddress.fromString(portalAddress.toString()), + content, + rollupVersion: new Fr(version), + chainId: new Fr(foundry.id), +}); + +// Wait for the block to be proven before withdrawing +// Waiting for the block to be proven is not necessary on the local network, but it is necessary on devnet +console.log("Waiting for block to be proven..."); +console.log(` Exit block number: ${exitReceipt.blockNumber}`); + +let provenBlockNumber = await node.getProvenBlockNumber(); +console.log(` Current proven block: ${provenBlockNumber}`); + +while (provenBlockNumber < exitReceipt.blockNumber!) { + console.log( + ` Waiting... (proven: ${provenBlockNumber}, needed: ${exitReceipt.blockNumber})`, + ); + await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds + provenBlockNumber = await node.getProvenBlockNumber(); +} + +console.log("Block proven!\n"); + +// Compute the membership witness using the message hash and the L2 tx hash +const witness = await computeL2ToL1MembershipWitness(node, msgLeaf, exitReceipt.txHash); +const epoch = witness!.epochNumber; +console.log(` Epoch for block ${exitReceipt.blockNumber}: ${epoch}`); + +const siblingPathHex = witness!.siblingPath + .toBufferArray() + .map((buf: Buffer) => `0x${buf.toString("hex")}` as `0x${string}`); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L260-L313 + + +With this information, call the L1 contract and use the index and the sibling path to claim the L1 NFT: + +```typescript title="withdraw_on_l1" showLineNumbers +console.log("Withdrawing NFT on L1..."); +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const withdrawHash = await l1Client.writeContract({ + address: portalAddress.toString() as `0x${string}`, + abi: NFTPortal.abi, + functionName: "withdraw", + args: [tokenId, BigInt(epoch), BigInt(witness!.leafIndex), siblingPathHex], +}); +await l1Client.waitForTransactionReceipt({ hash: withdrawHash }); +console.log("NFT withdrawn to L1\n"); +``` +> Source code: docs/examples/ts/token_bridge/index.ts#L315-L326 + + +You can now try the whole flow with: + +```typescript +npx hardhat run scripts/index.ts --network localhost +``` + +## What You Built + +A complete private NFT bridge with: + +1. **L1 Contracts** (Solidity) + - `SimpleNFT`: Basic ERC721 for testing + - `NFTPortal`: Locks/unlocks NFTs and handles L1↔L2 messaging + +2. **L2 Contracts** (Noir) + - `NFTPunk`: Private NFT with encrypted ownership using `PrivateSet` + - `NFTBridge`: Claims L1 messages and mints NFTs privately + +3. **Full Flow** + - Mint NFT on L1 + - Deploy portal and bridge + - Lock NFT on L1 → message sent to L2 + - Claim on L2 → private NFT minted + - Later: Burn on L2 → message to L1 → unlock + +## Next Steps + +- Add a web frontend for easy bridging +- Implement batch bridging for multiple NFTs +- Add metadata bridging +- Write comprehensive tests +- Add proper access controls + +:::tip Learn More + +- [State management page](../../foundational-topics/state_management.md) +- [Cross-chain messaging](../../foundational-topics/ethereum-aztec-messaging/index.md) + ::: diff --git a/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md b/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md index 0f8a7f672b99..8f0e98194fb0 100644 --- a/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md +++ b/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md @@ -35,9 +35,15 @@ Your structure should look like this: | |-Nargo.toml ``` +<<<<<<< HEAD The file `main.nr` will soon turn into our smart contract! Add the following dependencies to `Nargo.toml` under the autogenerated content: +======= +The `aztec new` command creates a contract project with `Nargo.toml` and `src/main.nr`. The file `src/main.nr` will soon turn into our smart contract! + +Add the following dependency to `Nargo.toml` under the existing `aztec` dependency: +>>>>>>> 49e2563fc3 (fix(docs): update CLI commands, ABI fields, and tutorial fixes) ```toml [dependencies] diff --git a/docs/docs-developers/docs/tutorials/contract_tutorials/recursive_verification.md b/docs/docs-developers/docs/tutorials/contract_tutorials/recursive_verification.md index 250b8fb90e1e..4d9be3222241 100644 --- a/docs/docs-developers/docs/tutorials/contract_tutorials/recursive_verification.md +++ b/docs/docs-developers/docs/tutorials/contract_tutorials/recursive_verification.md @@ -454,7 +454,10 @@ The proof generation script executes the circuit offchain and produces the proof Create `scripts/generate_data.ts`: -#include_code generate_data /docs/examples/ts/recursive_verification/scripts/generate_data.ts typescript +```js +import circuitJson from "../circuit/target/hello_circuit.json" with { type: "json" }; +#include_code generate_data /docs/examples/ts/recursive_verification/scripts/generate_data.ts raw +``` ### Understanding the Proof Generation Pipeline diff --git a/docs/docs-developers/docs/tutorials/contract_tutorials/token_contract.md b/docs/docs-developers/docs/tutorials/contract_tutorials/token_contract.md index 5e120d1c1f49..f13ced10715e 100644 --- a/docs/docs-developers/docs/tutorials/contract_tutorials/token_contract.md +++ b/docs/docs-developers/docs/tutorials/contract_tutorials/token_contract.md @@ -44,13 +44,13 @@ cd bob_token yarn init # This is to ensure yarn uses node_modules instead of pnp for dependency installation yarn config set nodeLinker node-modules -yarn add @aztec/aztec.js@#include_aztec_version @aztec/accounts@#include_aztec_version @aztec/test-wallet@#include_aztec_version @aztec/kv-store@#include_aztec_version +yarn add @aztec/aztec.js@#include_aztec_version @aztec/accounts@#include_aztec_version @aztec/kv-store@#include_aztec_version aztec init ``` ## Contract structure -The `aztec init` command created a workspace with two crates: a `bob_token_contract` crate for your smart contract code and a `bob_token_test` crate for Noir tests. In `bob_token_contract/src/main.nr` we even have a proto-contract. Let's replace it with a simple starting point: +The `aztec init` command created a contract project with `Nargo.toml` and `src/main.nr`. Let's replace the boilerplate in `src/main.nr` with a simple starting point: ```rust #include_code start /docs/examples/contracts/bob_token_contract/src/main.nr raw diff --git a/docs/examples/ts/example_swap/index.ts b/docs/examples/ts/example_swap/index.ts new file mode 100644 index 000000000000..f8e30c7c9a1b --- /dev/null +++ b/docs/examples/ts/example_swap/index.ts @@ -0,0 +1,593 @@ +// docs:start:setup +import { getInitialTestAccountsData } from "@aztec/accounts/testing"; +import { AztecAddress, EthAddress } from "@aztec/aztec.js/addresses"; +import { SetPublicAuthwitContractInteraction } from "@aztec/aztec.js/authorization"; +import { Fr } from "@aztec/aztec.js/fields"; +import { createAztecNodeClient, waitForNode } from "@aztec/aztec.js/node"; +import { createExtendedL1Client } from "@aztec/ethereum/client"; +import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; +import { sha256ToField } from "@aztec/foundation/crypto/sha256"; +import { TokenContract } from "@aztec/noir-contracts.js/Token"; +import { TokenBridgeContract } from "@aztec/noir-contracts.js/TokenBridge"; +import { + computeL2ToL1MessageHash, + computeSecretHash, +} from "@aztec/stdlib/hash"; +import { computeL2ToL1MembershipWitness } from "@aztec/stdlib/messaging"; +import { decodeEventLog, encodeFunctionData, pad } from "@aztec/viem"; +import { EmbeddedWallet } from "@aztec/wallets/embedded"; +import { foundry } from "@aztec/viem/chains"; +import ExampleERC20 from "../../../target/solidity/example_swap/ExampleERC20.sol/ExampleERC20.json" with { type: "json" }; +import ExampleTokenPortal from "../../../target/solidity/example_swap/ExampleTokenPortal.sol/ExampleTokenPortal.json" with { type: "json" }; +import ExampleUniswapPortal from "../../../target/solidity/example_swap/ExampleUniswapPortal.sol/ExampleUniswapPortal.json" with { type: "json" }; +import { ExampleUniswapContract } from "./artifacts/ExampleUniswap.js"; + +// Setup L1 client +const MNEMONIC = "test test test test test test test test test test test junk"; +const l1RpcUrl = process.env.ETHEREUM_HOST ?? "http://localhost:8545"; +const l1Client = createExtendedL1Client([l1RpcUrl], MNEMONIC); +const ownerEthAddress = l1Client.account.address; + +// Setup L2 client +console.log("Setting up L2...\n"); +const nodeUrl = process.env.AZTEC_NODE_URL ?? "http://localhost:8080"; +const node = createAztecNodeClient(nodeUrl); +await waitForNode(node); +const wallet = await EmbeddedWallet.create(node, { ephemeral: true }); +const [accData] = await getInitialTestAccountsData(); +const account = await wallet.createSchnorrAccount( + accData.secret, + accData.salt, + accData.signingKey, +); +console.log(`Account: ${account.address.toString()}\n`); + +const nodeInfo = await node.getNodeInfo(); +const registryAddress = + nodeInfo.l1ContractAddresses.registryAddress.toString(); +const inboxAddress = nodeInfo.l1ContractAddresses.inboxAddress.toString(); +// docs:end:setup + +// docs:start:deploy_l1 +console.log("Deploying L1 contracts...\n"); + +// Deploy two ERC20 tokens: WETH (input) and DAI (output) +const { address: wethAddress } = await deployL1Contract( + l1Client, + ExampleERC20.abi, + ExampleERC20.bytecode.object as `0x${string}`, + ["Wrapped Ether", "WETH"], +); + +const { address: daiAddress } = await deployL1Contract( + l1Client, + ExampleERC20.abi, + ExampleERC20.bytecode.object as `0x${string}`, + ["Dai Stablecoin", "DAI"], +); + +// Deploy two token portals (one per token) +const { address: wethPortalAddress } = await deployL1Contract( + l1Client, + ExampleTokenPortal.abi, + ExampleTokenPortal.bytecode.object as `0x${string}`, +); + +const { address: daiPortalAddress } = await deployL1Contract( + l1Client, + ExampleTokenPortal.abi, + ExampleTokenPortal.bytecode.object as `0x${string}`, +); + +// Deploy the uniswap portal +const { address: uniswapPortalAddress } = await deployL1Contract( + l1Client, + ExampleUniswapPortal.abi, + ExampleUniswapPortal.bytecode.object as `0x${string}`, +); + +console.log(`WETH: ${wethAddress}`); +console.log(`DAI: ${daiAddress}`); +console.log(`WETH Portal: ${wethPortalAddress}`); +console.log(`DAI Portal: ${daiPortalAddress}`); +console.log(`Uniswap Portal: ${uniswapPortalAddress}\n`); +// docs:end:deploy_l1 + +// docs:start:deploy_l2 +console.log("Deploying L2 contracts...\n"); + +// Deploy L2 tokens (using the standard TokenContract from @aztec/noir-contracts.js) +const { contract: l2Weth } = await TokenContract.deploy( + wallet, + account.address, + "Wrapped Ether", + "WETH", + 18, +).send({ from: account.address }); + +const { contract: l2Dai } = await TokenContract.deploy( + wallet, + account.address, + "Dai Stablecoin", + "DAI", + 18, +).send({ from: account.address }); + +// Deploy L2 token bridges +const { contract: l2WethBridge } = await TokenBridgeContract.deploy( + wallet, + l2Weth.address, + wethPortalAddress, +).send({ from: account.address }); + +const { contract: l2DaiBridge } = await TokenBridgeContract.deploy( + wallet, + l2Dai.address, + daiPortalAddress, +).send({ from: account.address }); + +// Deploy L2 uniswap contract +const { contract: l2Uniswap } = await ExampleUniswapContract.deploy( + wallet, + EthAddress.fromString(uniswapPortalAddress.toString()), +).send({ from: account.address }); + +console.log(`L2 WETH: ${l2Weth.address}`); +console.log(`L2 DAI: ${l2Dai.address}`); +console.log(`L2 WETH Bridge: ${l2WethBridge.address}`); +console.log(`L2 DAI Bridge: ${l2DaiBridge.address}`); +console.log(`L2 Uniswap: ${l2Uniswap.address}\n`); +// docs:end:deploy_l2 + +// docs:start:initialize +console.log("Initializing contracts...\n"); + +// Make bridges minters on their respective tokens +await l2Weth.methods + .set_minter(l2WethBridge.address, true) + .send({ from: account.address }); +await l2Dai.methods + .set_minter(l2DaiBridge.address, true) + .send({ from: account.address }); + +// Initialize L1 portals with registry, underlying token, and L2 bridge addresses +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const initWethPortal = await l1Client.writeContract({ + address: wethPortalAddress.toString() as `0x${string}`, + abi: ExampleTokenPortal.abi, + functionName: "initialize", + args: [ + registryAddress, + wethAddress.toString(), + l2WethBridge.address.toString(), + ], +}); +await l1Client.waitForTransactionReceipt({ hash: initWethPortal }); + +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const initDaiPortal = await l1Client.writeContract({ + address: daiPortalAddress.toString() as `0x${string}`, + abi: ExampleTokenPortal.abi, + functionName: "initialize", + args: [ + registryAddress, + daiAddress.toString(), + l2DaiBridge.address.toString(), + ], +}); +await l1Client.waitForTransactionReceipt({ hash: initDaiPortal }); + +// Initialize uniswap portal +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const initUniswapPortal = await l1Client.writeContract({ + address: uniswapPortalAddress.toString() as `0x${string}`, + abi: ExampleUniswapPortal.abi, + functionName: "initialize", + args: [registryAddress, l2Uniswap.address.toString()], +}); +await l1Client.waitForTransactionReceipt({ hash: initUniswapPortal }); + +console.log("All contracts initialized\n"); +// docs:end:initialize + +// docs:start:fund +console.log("Funding accounts...\n"); + +const SWAP_AMOUNT = 100n * 10n ** 18n; // 100 tokens + +// Mint WETH on L1 for the user +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const mintWethHash = await l1Client.writeContract({ + address: wethAddress.toString() as `0x${string}`, + abi: ExampleERC20.abi, + functionName: "mint", + args: [ownerEthAddress, SWAP_AMOUNT], +}); +await l1Client.waitForTransactionReceipt({ hash: mintWethHash }); + +// Pre-fund the uniswap portal with DAI (for the mock 1:1 swap) +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const mintDaiHash = await l1Client.writeContract({ + address: daiAddress.toString() as `0x${string}`, + abi: ExampleERC20.abi, + functionName: "mint", + args: [uniswapPortalAddress.toString(), SWAP_AMOUNT * 2n], +}); +await l1Client.waitForTransactionReceipt({ hash: mintDaiHash }); + +console.log(`Minted ${SWAP_AMOUNT} WETH to user`); +console.log(`Pre-funded uniswap portal with ${SWAP_AMOUNT * 2n} DAI\n`); +// docs:end:fund + +// docs:start:deposit_to_l2 +console.log("Depositing WETH to Aztec (L1 -> L2)...\n"); + +const depositSecret = Fr.random(); +const depositSecretHash = await computeSecretHash(depositSecret); + +// Approve WETH portal to take tokens +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const approveHash = await l1Client.writeContract({ + address: wethAddress.toString() as `0x${string}`, + abi: ExampleERC20.abi, + functionName: "approve", + args: [wethPortalAddress.toString(), SWAP_AMOUNT], +}); +await l1Client.waitForTransactionReceipt({ hash: approveHash }); + +// Deposit to Aztec publicly +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const depositHash = await l1Client.writeContract({ + address: wethPortalAddress.toString() as `0x${string}`, + abi: ExampleTokenPortal.abi, + functionName: "depositToAztecPublic", + args: [ + account.address.toString(), + SWAP_AMOUNT, + pad(depositSecretHash.toString() as `0x${string}`, { + dir: "left", + size: 32, + }), + ], +}); +const depositReceipt = await l1Client.waitForTransactionReceipt({ + hash: depositHash, +}); + +// Extract message leaf index from Inbox event +const INBOX_ABI = [ + { + type: "event", + name: "MessageSent", + inputs: [ + { name: "checkpointNumber", type: "uint256", indexed: true }, + { name: "index", type: "uint256", indexed: false }, + { name: "hash", type: "bytes32", indexed: true }, + { name: "rollingHash", type: "bytes16", indexed: false }, + ], + }, +] as const; + +const messageSentLogs = depositReceipt.logs + .filter((log) => log.address.toLowerCase() === inboxAddress.toLowerCase()) + .map((log: any) => { + try { + const decoded = decodeEventLog({ + abi: INBOX_ABI, + data: log.data, + topics: log.topics, + }); + return { log, decoded }; + } catch { + return null; + } + }) + .filter( + (item): item is { log: any; decoded: any } => + item !== null && (item.decoded as any).eventName === "MessageSent", + ); + +if (messageSentLogs.length === 0) { + throw new Error("No MessageSent events found in deposit transaction"); +} +const depositLeafIndex = new Fr(messageSentLogs[0].decoded.args.index); +console.log(`Deposit message leaf index: ${depositLeafIndex}\n`); +// docs:end:deposit_to_l2 + +// docs:start:mine_blocks +// Utility: mine 2 blocks (required before L1->L2 messages can be consumed) +async function mine2Blocks( + wallet: EmbeddedWallet, + accountAddress: AztecAddress, +) { + await TokenContract.deploy(wallet, accountAddress, "T", "T", 18).send({ + from: accountAddress, + contractAddressSalt: Fr.random(), + }); + await TokenContract.deploy(wallet, accountAddress, "T", "T", 18).send({ + from: accountAddress, + contractAddressSalt: Fr.random(), + }); +} +// docs:end:mine_blocks + +// docs:start:claim_on_l2 +console.log("Claiming WETH on L2...\n"); + +await mine2Blocks(wallet, account.address); + +await l2WethBridge.methods + .claim_public(account.address, SWAP_AMOUNT, depositSecret, depositLeafIndex) + .send({ from: account.address }); + +const { result: wethBalanceBefore } = await l2Weth.methods + .balance_of_public(account.address) + .simulate({ from: account.address }); +console.log(`L2 WETH balance after claim: ${wethBalanceBefore}\n`); +if (wethBalanceBefore !== SWAP_AMOUNT) { + throw new Error(`Expected WETH balance ${SWAP_AMOUNT}, got ${wethBalanceBefore}`); +} +console.log("✓ WETH claimed successfully on L2\n"); +// docs:end:claim_on_l2 + +// docs:start:public_swap +console.log("=== PUBLIC SWAP FLOW ===\n"); +console.log("Initiating public swap on L2 (WETH -> DAI)...\n"); + +// Force L2 block production so the claim message is included in a block before the swap +await mine2Blocks(wallet, account.address); + +const swapSecret = Fr.random(); +const swapSecretHash = await computeSecretHash(swapSecret); + +// Create authwit for the uniswap contract to transfer WETH on our behalf +const transferAction = l2Weth.methods.transfer_in_public( + account.address, + l2Uniswap.address, + SWAP_AMOUNT, + 0xdeadbeefn, +); +const authwit = await SetPublicAuthwitContractInteraction.create( + wallet, + account.address, + { caller: l2Uniswap.address, action: transferAction }, + true, +); +await authwit.send(); + +// Call swap_public on the L2 uniswap contract +const { receipt: swapReceipt } = await l2Uniswap.methods + .swap_public( + account.address, + l2WethBridge.address, + SWAP_AMOUNT, + l2DaiBridge.address, + 3000n, // fee tier + 0n, // minimum output + account.address, // recipient + swapSecretHash, + ) + .send({ from: account.address }); + +console.log(`Swap tx sent (block: ${swapReceipt.blockNumber})\n`); + +// Verify WETH was spent (balance should be 0 after swap) +const { result: wethAfterSwap } = await l2Weth.methods + .balance_of_public(account.address) + .simulate({ from: account.address }); +if (wethAfterSwap !== 0n) { + throw new Error(`Expected WETH balance 0 after swap, got ${wethAfterSwap}`); +} +console.log("✓ WETH transferred to bridge for swap\n"); +// docs:end:public_swap + +// docs:start:wait_for_proof +console.log("Waiting for block to be proven...\n"); + +let provenBlockNumber = await node.getProvenBlockNumber(); +while (provenBlockNumber < swapReceipt.blockNumber!) { + console.log( + ` Waiting... (proven: ${provenBlockNumber}, needed: ${swapReceipt.blockNumber})`, + ); + await new Promise((resolve) => setTimeout(resolve, 10000)); + provenBlockNumber = await node.getProvenBlockNumber(); +} + +console.log("Block proven!\n"); +// docs:end:wait_for_proof + +// docs:start:consume_l1_messages_setup +console.log("Consuming L2->L1 messages on L1...\n"); + +// The swap generates 2 L2->L1 messages: +// 1. Token bridge exit (withdraw WETH to uniswap portal) +// 2. Uniswap swap intent + +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const portalRollupVersion = (await l1Client.readContract({ + address: wethPortalAddress.toString() as `0x${string}`, + abi: ExampleTokenPortal.abi, + functionName: "rollupVersion", +})) as bigint; + +// Compute message 1: token bridge exit +// Encode using the same approach as Solidity's abi.encodeWithSignature("withdraw(address,uint256,address)", ...) +const withdrawContentEncoded = encodeFunctionData({ + abi: [ + { + name: "withdraw", + type: "function", + inputs: [ + { name: "", type: "address" }, + { name: "", type: "uint256" }, + { name: "", type: "address" }, + ], + outputs: [], + }, + ], + args: [ + uniswapPortalAddress.toString() as `0x${string}`, + SWAP_AMOUNT, + uniswapPortalAddress.toString() as `0x${string}`, + ], +}); +const withdrawContentHash = sha256ToField([ + Buffer.from(withdrawContentEncoded.slice(2), "hex"), +]); + +// Message 1: Token bridge exit message +const exitMsgLeaf = computeL2ToL1MessageHash({ + l2Sender: l2WethBridge.address, + l1Recipient: wethPortalAddress, + content: withdrawContentHash, + rollupVersion: new Fr(portalRollupVersion), + chainId: new Fr(foundry.id), +}); +// docs:end:consume_l1_messages_setup + +// docs:start:consume_l1_messages_witnesses +const exitWitness = await computeL2ToL1MembershipWitness( + node, + exitMsgLeaf, + swapReceipt.txHash, +); +const exitSiblingPath = exitWitness!.siblingPath + .toBufferArray() + .map((buf: Buffer) => `0x${buf.toString("hex")}` as `0x${string}`); + +// Message 2: Uniswap swap intent message +// Compute using the same encoding as ExampleUniswapPortal.sol +const swapContentEncoded = encodeFunctionData({ + abi: [ + { + name: "swap_public", + type: "function", + inputs: [ + { name: "", type: "address" }, + { name: "", type: "uint256" }, + { name: "", type: "uint24" }, + { name: "", type: "address" }, + { name: "", type: "uint256" }, + { name: "", type: "bytes32" }, + { name: "", type: "bytes32" }, + ], + outputs: [], + }, + ], + args: [ + wethPortalAddress.toString() as `0x${string}`, + SWAP_AMOUNT, + 3000, + daiPortalAddress.toString() as `0x${string}`, + 0n, + account.address.toString() as `0x${string}`, + pad(swapSecretHash.toString() as `0x${string}`, { + dir: "left", + size: 32, + }), + ], +}); + +const swapContentHash = sha256ToField([Buffer.from(swapContentEncoded.slice(2), "hex")]); + +const swapMsgLeaf = computeL2ToL1MessageHash({ + l2Sender: l2Uniswap.address, + l1Recipient: uniswapPortalAddress, + content: swapContentHash, + rollupVersion: new Fr(portalRollupVersion), + chainId: new Fr(foundry.id), +}); + +const swapWitness = await computeL2ToL1MembershipWitness( + node, + swapMsgLeaf, + swapReceipt.txHash, +); +const swapSiblingPath = swapWitness!.siblingPath + .toBufferArray() + .map((buf: Buffer) => `0x${buf.toString("hex")}` as `0x${string}`); +// docs:end:consume_l1_messages_witnesses + +// docs:start:consume_l1_messages_execute +// Execute the swap on L1 (consumes both messages) +// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs +const l1SwapHash = await l1Client.writeContract({ + address: uniswapPortalAddress.toString() as `0x${string}`, + abi: ExampleUniswapPortal.abi, + functionName: "swapPublic", + args: [ + wethPortalAddress.toString(), + SWAP_AMOUNT, + 3000, + daiPortalAddress.toString(), + 0n, + account.address.toString(), + pad(swapSecretHash.toString() as `0x${string}`, { + dir: "left", + size: 32, + }), + [BigInt(exitWitness!.epochNumber), BigInt(swapWitness!.epochNumber)], + [BigInt(exitWitness!.leafIndex), BigInt(swapWitness!.leafIndex)], + [exitSiblingPath, swapSiblingPath], + ], +}); +const l1SwapReceipt = await l1Client.waitForTransactionReceipt({ + hash: l1SwapHash, +}); +console.log(`L1 swap executed! Tx: ${l1SwapHash}\n`); +// docs:end:consume_l1_messages_execute + +// docs:start:claim_output +console.log("Claiming DAI output on L2...\n"); + +// Extract the deposit message leaf index from the L1 swap receipt +const daiDepositLogs = l1SwapReceipt.logs + .filter((log) => log.address.toLowerCase() === inboxAddress.toLowerCase()) + .map((log: any) => { + try { + const decoded = decodeEventLog({ + abi: INBOX_ABI, + data: log.data, + topics: log.topics, + }); + return { log, decoded }; + } catch { + return null; + } + }) + .filter( + (item): item is { log: any; decoded: any } => + item !== null && (item.decoded as any).eventName === "MessageSent", + ); + +if (daiDepositLogs.length === 0) { + throw new Error("No MessageSent events found in L1 swap transaction"); +} +const daiDepositLeafIndex = new Fr(daiDepositLogs[0].decoded.args.index); + +// Mine blocks and claim +await mine2Blocks(wallet, account.address); + +await l2DaiBridge.methods + .claim_public(account.address, SWAP_AMOUNT, swapSecret, daiDepositLeafIndex) + .send({ from: account.address }); + +const { result: daiBalance } = await l2Dai.methods + .balance_of_public(account.address) + .simulate({ from: account.address }); + +const { result: wethBalanceAfter } = await l2Weth.methods + .balance_of_public(account.address) + .simulate({ from: account.address }); + +console.log(`L2 WETH balance: ${wethBalanceAfter}`); +console.log(`L2 DAI balance: ${daiBalance}`); + +if (wethBalanceAfter !== 0n) { + throw new Error(`Expected final WETH balance 0, got ${wethBalanceAfter}`); +} +if (daiBalance !== SWAP_AMOUNT) { + throw new Error(`Expected DAI balance ${SWAP_AMOUNT}, got ${daiBalance}`); +} +console.log("\n✓ All checks passed — public swap complete!\n"); +// docs:end:claim_output diff --git a/docs/examples/ts/recursive_verification/scripts/generate_data.ts b/docs/examples/ts/recursive_verification/scripts/generate_data.ts index 46038617dd70..e948a761d2f0 100644 --- a/docs/examples/ts/recursive_verification/scripts/generate_data.ts +++ b/docs/examples/ts/recursive_verification/scripts/generate_data.ts @@ -1,6 +1,6 @@ +import circuitJson from "../../../../target/hello_circuit.json" with { type: "json" }; // docs:start:generate_data import { Noir } from "@aztec/noir-noir_js"; -import circuitJson from "../../../../target/hello_circuit.json" with { type: "json" }; import { Barretenberg, UltraHonkBackend, deflattenFields } from "@aztec/bb.js"; import fs from "fs"; import { exit } from "process"; @@ -56,14 +56,14 @@ if (proofAsFields.length === 0) { const vkAsFields = recursiveArtifacts.vkAsFields; console.log(`VK size: ${vkAsFields.length}`); // Should be 115 -console.log(`Proof size: ${proofAsFields.length}`); // Should be 508 +console.log(`Proof size: ${proofAsFields.length}`); // Should be ~500 console.log(`Public inputs: ${mainProofData.publicInputs.length}`); // Should be 1 // Step 9: Save all data to JSON for contract interaction const data = { vkAsFields: vkAsFields, // 115 field elements - the verification key vkHash: recursiveArtifacts.vkHash, // Hash of VK - stored in contract - proofAsFields: proofAsFields, // 508 field elements - the proof + proofAsFields: proofAsFields, // ~500 field elements - the proof publicInputs: mainProofData.publicInputs.map((p: string) => p.toString()), }; diff --git a/docs/examples/ts/token_bridge/index.ts b/docs/examples/ts/token_bridge/index.ts index 436a4be41dde..728f285164bc 100644 --- a/docs/examples/ts/token_bridge/index.ts +++ b/docs/examples/ts/token_bridge/index.ts @@ -162,7 +162,7 @@ const INBOX_ABI = [ type: "event", name: "MessageSent", inputs: [ - { name: "l2BlockNumber", type: "uint256", indexed: true }, + { name: "checkpointNumber", type: "uint256", indexed: true }, { name: "index", type: "uint256", indexed: false }, { name: "hash", type: "bytes32", indexed: true }, { name: "rollingHash", type: "bytes16", indexed: false }, From 1e2e745c3d4d4feff1c5284aeb3e244363b421a8 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Mon, 30 Mar 2026 20:19:29 +0000 Subject: [PATCH 18/18] fix: resolve cherry-pick conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleted files (version-v4.2.0-aztecnr-rc.2 versioned docs and example_swap) don't exist on v4-next branch — accept deletion. Resolved content conflict in counter_contract.md by taking the incoming (PR) version which better describes the current aztec new behavior. --- .../contract_tutorials/counter_contract.md | 200 ---- .../recursive_verification.md | 987 --------------- .../contract_tutorials/token_contract.md | 707 ----------- .../tutorials/js_tutorials/token_bridge.md | 1059 ----------------- .../contract_tutorials/counter_contract.md | 6 - docs/examples/ts/example_swap/index.ts | 593 --------- 6 files changed, 3552 deletions(-) delete mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md delete mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md delete mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md delete mode 100644 docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md delete mode 100644 docs/examples/ts/example_swap/index.ts diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md deleted file mode 100644 index b52beb725a64..000000000000 --- a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/counter_contract.md +++ /dev/null @@ -1,200 +0,0 @@ ---- -title: Counter Contract -description: Code-along tutorial for creating a simple counter contract on Aztec. -sidebar_position: 0 -references: ["docs/examples/contracts/counter_contract/src/main.nr"] ---- - -import Image from "@theme/IdealImage"; - -In this guide, we will create our first Aztec.nr smart contract. We will build a simple private counter, where you can keep your own private counter - so no one knows what ID you are at or when you increment! This contract will get you started with the basic setup and syntax of Aztec.nr, but doesn't showcase all of the awesome stuff Aztec is capable of. - -This tutorial is compatible with the Aztec version `4.2.0-aztecnr-rc.2`. Install the correct version with `VERSION=4.2.0-aztecnr-rc.2 bash -i <(curl -sL https://install.aztec.network/4.2.0-aztecnr-rc.2)`. Or if you'd like to use a different version, you can find the relevant tutorial by clicking the version dropdown at the top of the page. - -## Prerequisites - -- You have followed the [quickstart](../../../getting_started_on_local_network.md) -- Running Aztec local network -- Installed [Noir LSP](../../aztec-nr/installation.md) (optional) - -## Set up a project - -Run this to create a new contract project: - -```bash -aztec new counter -``` - -Your structure should look like this: - -```tree -. -|-counter -| |-src -| | |-main.nr -| |-Nargo.toml -``` - -The file `main.nr` will soon turn into our smart contract! - -Add the following dependencies to `Nargo.toml` under the autogenerated content: - -```toml -[dependencies] -aztec = { git="https://github.com/AztecProtocol/aztec-nr/", tag="v4.2.0-aztecnr-rc.2", directory="aztec" } -balance_set = { git="https://github.com/AztecProtocol/aztec-nr/", tag="v4.2.0-aztecnr-rc.2", directory="balance-set" } -``` - -## Define the functions - -Go to `main.nr`, and replace the boilerplate code with this contract initialization: - -```rust -use aztec::macros::aztec; - -#[aztec] -pub contract Counter { -} -``` - -This defines a contract called `Counter`. - -## Imports - -We need to define some imports. - -Write this inside your contract, ie inside these brackets: - -```rust -pub contract Counter { - // imports go here! -} -``` - -```rust title="imports" showLineNumbers -use aztec::{ - macros::{functions::{external, initializer}, storage::storage}, - messages::message_delivery::MessageDelivery, - oracle::logging::debug_log_format, - protocol::{address::AztecAddress, traits::ToField}, - state_vars::Owned, -}; -use balance_set::BalanceSet; -``` -> Source code: docs/examples/contracts/counter_contract/src/main.nr#L7-L16 - - -- `macros::{functions::{external, initializer}, storage::storage}` - Imports the macros needed to define function types (`external`, `initializer`) and the `storage` macro for declaring contract storage structures. - -- `messages::message_delivery::MessageDelivery` - Imports `MessageDelivery` for specifying how note delivery should be handled (e.g., constrained onchain delivery). - -- `oracle::debug_log::debug_log_format` - Imports a debug logging utility for printing formatted messages during contract execution. - -- `protocol::{address::AztecAddress, traits::ToField}` - Brings in `AztecAddress` (used to identify accounts/contracts) and traits for converting values to field elements, necessary for serialization and formatting inside Aztec. - -- `state_vars::Owned` - Brings in `Owned`, a wrapper for state variables that have a single owner. - -- `use balance_set::BalanceSet` - Imports `BalanceSet` from the `balance_set` dependency, which provides functionality for managing private balances (used for our counter). - -## Declare storage - -Add this below the imports. It declares the storage variables for our contract. We use an `Owned` state variable wrapping a `BalanceSet` to manage private balances for each owner. - -```rust title="storage_struct" showLineNumbers -#[storage] -struct Storage { - counters: Owned, Context>, -} -``` -> Source code: docs/examples/contracts/counter_contract/src/main.nr#L18-L23 - - -## Keep the counter private - -Now we’ve got a mechanism for storing our private state, we can start using it to ensure the privacy of balances. - -Let’s create a constructor method to run on deployment that assigns an initial count to a specified owner. This function is called `initialize`, but behaves like a constructor. It is the `#[initializer]` decorator that specifies that this function behaves like a constructor. Write this: - -```rust title="constructor" showLineNumbers -#[initializer] -#[external("private")] -// We can name our initializer anything we want as long as it's marked as aztec(initializer) -fn initialize(headstart: u64, owner: AztecAddress) { - self.storage.counters.at(owner).add(headstart as u128).deliver( - MessageDelivery.ONCHAIN_CONSTRAINED, - ); -} -``` -> Source code: docs/examples/contracts/counter_contract/src/main.nr#L25-L34 - - -This function accesses the counters from storage. It adds the `headstart` value to the `owner`'s counter using `at().add()`, then calls `.deliver(MessageDelivery.ONCHAIN_CONSTRAINED)` to ensure the note is delivered onchain. - -We have annotated this and other functions with `#[external("private")]` which are ABI macros so the compiler understands it will handle private inputs. - -## Incrementing our counter - -Now let's implement an `increment` function to increase the counter. - -```rust title="increment" showLineNumbers -#[external("private")] -fn increment(owner: AztecAddress) { - debug_log_format("Incrementing counter for owner {0}", [owner.to_field()]); - self.storage.counters.at(owner).add(1).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); -} -``` -> Source code: docs/examples/contracts/counter_contract/src/main.nr#L36-L42 - - -The `increment` function works similarly to the `initialize` function. It logs a debug message, then adds 1 to the owner's counter and delivers the note onchain. - -## Getting a counter - -The last thing we need to implement is a function to retrieve a counter value. - -```rust title="get_counter" showLineNumbers -#[external("utility")] -unconstrained fn get_counter(owner: AztecAddress) -> pub u128 { - self.storage.counters.at(owner).balance_of() -} -``` -> Source code: docs/examples/contracts/counter_contract/src/main.nr#L44-L49 - - -This is a `utility` function used to obtain the counter value outside of a transaction. We access the `owner`'s balance from the `counters` storage variable using `at(owner)`, then call `balance_of()` to retrieve the current count. This yields a private counter that only the owner can decrypt. - -## Compile - -Now we've written a simple Aztec.nr smart contract, we can compile it. - -### Compile the smart contract - -In the `./counter/` directory, run: - -```bash -aztec compile -``` - -This command compiles your Noir contract and creates a `target` folder with a `.json` artifact inside. - -After compiling, you can generate a TypeScript class using the `aztec codegen` command. - -In the same directory, run this: - -```bash -aztec codegen -o src/artifacts target -``` - -You can now use the artifact and/or the TS class in your Aztec.js! - -## Next Steps - -### Optional: Learn more about concepts mentioned here - -- [Functions and annotations like `#[external("private")]`](../../aztec-nr/framework-description/functions/function_transforms.md#private-functions) diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md deleted file mode 100644 index 8af0ff3bdba5..000000000000 --- a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/recursive_verification.md +++ /dev/null @@ -1,987 +0,0 @@ ---- -title: "Verify Noir Proofs in Aztec Contracts" -description: "Learn to generate offchain ZK proofs with Noir and verify them onchain in Aztec private smart contracts" ---- - -## Overview - -In this tutorial, you will build a system that generates zero-knowledge proofs offchain using a Noir circuit and verifies them onchain within an Aztec Protocol smart contract. You will create a simple circuit that proves two values are not equal, generate an UltraHonk proof, deploy an Aztec contract that stores a verification key hash, and submit the proof for onchain verification. This pattern enables trustless computation where anyone can verify that a computation was performed correctly without revealing the private inputs. - -:::note Why "Recursive" Verification? -This is called "recursive" verification because the proof is verified inside an Aztec private function, which itself gets compiled into a ZK circuit. The result is a proof being verified inside another proof. The Noir circuit you write is not recursive; the recursion happens at the Aztec protocol level when the private function execution (including the `verify_honk_proof` call) is proven. -::: - -:::tip Full Working Example -The complete code for this tutorial is available in the [docs/examples](https://github.com/AztecProtocol/aztec-packages/tree/v4.2.0-aztecnr-rc.2/docs/examples) directory. Clone it to follow along or use it as a reference. -::: - -## Prerequisites - -Before starting, ensure you have the following installed and configured: - -- Node.js (v22 or later) -- yarn package manager -- Aztec CLI (version 4.2.0-aztecnr-rc.2) -- Nargo -- Familiarity with [Noir syntax](https://noir-lang.org/docs) and [Aztec contract basics](../../aztec-nr/index.md) - -Install the required tools: - -```bash -# Install Aztec CLI -VERSION=4.2.0-aztecnr-rc.2 bash -i <(curl -sL https://install.aztec.network/4.2.0-aztecnr-rc.2) -``` - -## Part 1: Understanding the Architecture - -### The Core Problem - -Aztec contracts have inherent [limits on function inputs and transaction complexity](../../resources/considerations/limitations.md#circuit-limitations). These constraints stem from the circuit-based nature of private execution. When your computation requires more inputs than these limits allow, or when the computation itself is too complex to fit in a single function, **recursive proof verification** provides an escape hatch. - -For example, consider a machine learning inference that needs 10,000 input features, or a Merkle tree verification with 1,000 leaves. These cannot fit within a single Aztec function's input constraints. Instead, you can: - -1. Perform the computation offchain in a vanilla Noir circuit with no input limits -2. Generate a proof of correct execution -3. Verify only the proof onchain (115 fields for VK + ~500 fields for proof + N public inputs) - -This pattern transforms arbitrarily large computations into fixed-size proof verification. - -### Data Flow - -The recursive verification pattern follows this data flow: - -1. **Circuit Definition**: Write a Noir circuit that defines the computation you want to prove -2. **Compilation**: Compile the circuit with `nargo compile` to produce bytecode -3. **Proof Generation**: Execute the circuit offchain and generate an UltraHonk proof using [Barretenberg](https://github.com/AztecProtocol/barretenberg) -4. **Onchain Verification**: Submit the proof to an Aztec contract that verifies it using the stored [verification key](../../resources/glossary.md#verification-key) hash - -```mermaid -flowchart LR - A["Noir Circuit
(main.nr)"] --> B["Compiled
Bytecode"] - B --> C["Proof + VK +
Public Inputs"] - C --> D["Aztec Contract
verify_honk_proof"] -``` - -**Why this separation matters**: The circuit defines _what_ you're proving. The proof is _evidence_ that you executed the circuit correctly with valid inputs. The onchain verifier checks the evidence without re-running the computation. This is what makes ZK proofs powerful: verification is orders of magnitude cheaper than computation. - -### Why Verify Proofs in Aztec Contracts? - -Proof verification enables several patterns: - -- **Bypassing Input Limits**: Aztec private functions have strict input constraints. A proof verification call uses ~616 fields (115 VK + ~500 proof + 1 public input), but can attest to computations with arbitrarily many inputs. For example, proving membership in a set of 10,000 elements becomes a fixed-size verification. - -- **Cross-System Verification**: Verify proofs generated by external Noir circuits within your Aztec application. This enables composability: your contract can trust computations performed by other systems without those systems needing to be Aztec-native. - -- **Batching Operations**: Aggregate multiple operations into a single proof. Instead of making N separate contract calls, prove all N operations were done correctly and verify once. - -### Why Use Aztec for Proof Verification? - -Aztec provides a unique advantage: **private function execution**. When you verify a proof in an Aztec private function: - -1. The proof verification happens inside a zero-knowledge circuit -2. The inputs to verification (the proof itself) can remain private -3. You can compose proof verification with other private operations - -This enables patterns impossible on transparent blockchains, like proving you have a valid credential without revealing which credential or when you obtained it. - -### UX Considerations: Multiple Proof Generation - -When using [recursive verification](https://noir-lang.org/docs/noir/standard_library/recursion) in Aztec, users experience **two distinct proof generation phases**: - -1. **Noir Proof Generation** (application-specific): - - Happens before interacting with the Aztec contract - - Proves the computation (e.g., "I know values x and y where x ≠ y") - - Time depends on circuit complexity (seconds to minutes) - - Produces the proof and verification key that will be verified - -2. **Aztec Transaction Proof** (protocol-level): - - Generated by the [PXE](../../foundational-topics/pxe/index.md) when calling the private function - - Proves correct execution of the Aztec contract (including the `verify_honk_proof` call) - -With this foundation in mind, let's build a complete example. You'll create a Noir circuit, generate a proof, and verify it inside an Aztec contract. - -## Part 2: Writing the Noir Circuit - -Start by writing a simple circuit that proves two field values are not equal. This minimal example demonstrates the core pattern—you can extend it for more complex computations like Merkle proofs, credential verification, or something else entirely. - -### Create the Circuit Project - -Use `nargo new` to generate the project structure: - -```bash -nargo new circuit -``` - -This creates the following structure: - -```tree -circuit/ -├── src/ -│ └── main.nr # Circuit code -└── Nargo.toml # Circuit configuration -``` - -### Circuit Code - -Replace the contents of `circuit/src/main.nr` with: - -```rust title="circuit" showLineNumbers -fn main(x: u64, y: pub u64) { - assert(x != y); -} - -#[test] -fn test_main() { - main(1, 2); -} -``` -> Source code: docs/examples/circuits/hello_circuit/src/main.nr#L1-L10 - - -This is intentionally minimal to focus on the verification pattern. In production, you would replace `assert(x != y)` with meaningful computations like: - -- Merkle tree membership proofs -- Hash preimage verification -- Range proofs (proving a value is within bounds) -- Credential verification -- Email verification (proving you received an email from a domain without revealing its contents, like [zkEmail](https://www.prove.email/)) - -### Understanding Private vs Public Inputs - -The circuit has two inputs with different visibility: - -- `x: Field` - A **private input** known only to the prover. This value is never revealed onchain or included in the proof data. The verifier cannot determine what value was used—only that _some_ valid value exists. - -- `y: pub Field` - A **public input** that is visible to the verifier. This value is included in the proof data, but since proof verification happens within a private function, it isn't exposed onchain unless you explicitly reveal it. - -**Why this distinction matters**: The circuit asserts that `x != y`. The prover demonstrates they know a secret value `x` that differs from the public value `y`. - -Public inputs don't have to come from the caller. During verification, the Aztec contract can read values from its own storage and use them as public inputs. This pattern ties the proof to contract state—the prover must generate a proof against the _current_ stored value and cannot substitute a different public input. - -To make the "public input" truly public, the contract developer can enqueue a public function call from the private function that verifies the proof, passing the public input to a public function to be logged or verified against public state. - -For example, you could create a zkpassport proof demonstrating that you are over a certain age. The proof is verified in a private function, then the age (the public input) is passed to a public function where it's compared against a mutable threshold in public storage. - -### Circuit Configuration - -Update `circuit/Nargo.toml` (see [Noir crates and packages](https://noir-lang.org/docs/noir/modules_packages_crates/crates_and_packages) for more details): - -```toml title="circuit_nargo_toml" showLineNumbers -[package] -name = "hello_circuit" -type = "bin" -authors = [""] - -[dependencies] -``` -> Source code: docs/examples/circuits/hello_circuit/Nargo.toml#L1-L8 - - -**Note**: This is a vanilla Noir circuit, not an Aztec contract. It has `type = "bin"` (binary) and no Aztec dependencies. The circuit is compiled with `nargo`, not `aztec compile`. This distinction is important—you can verify proofs from _any_ Noir circuit inside Aztec contracts. - -### Compile the Circuit - -```bash -cd circuit -nargo compile -``` - -This generates `target/hello_circuit.json` containing: - -- **Bytecode**: The compiled circuit representation -- **ABI (Application Binary Interface)**: Describes the circuit's inputs and outputs, including which are public - -The TypeScript code uses the ABI to correctly format inputs during witness generation. - -### Test the Circuit - -```bash -nargo test -``` - -Expected output: - -```text -[hello_circuit] Running 1 test functions -[hello_circuit] Testing test_main... ok -[hello_circuit] All tests passed -``` - -**Tip**: Circuit tests run without generating proofs, making them fast for development. Use them to verify your circuit logic before the more expensive proof generation step. - -## Part 3: Writing the Aztec Contract - -The Aztec contract stores the verification key hash and verifies proofs submitted by users. When a valid proof is submitted, it increments a counter for the caller. - -### Why This Contract Design? - -The contract demonstrates several important patterns: - -1. **VK Hash Storage**: Instead of storing the full 115-field verification key onchain (expensive), we store only its hash (1 field). The prover submits the full VK with each proof, and the contract verifies it matches the stored hash. - -2. **Private-to-Public Flow**: Proof verification happens in a [private function](../../aztec-nr/framework-description/functions/visibility.md) (generating a ZK proof of the verification), but the counter update happens in a public function (visible state change). This separation is fundamental to Aztec's architecture. - -3. **Self-Only Public Functions**: The `_increment_public` function can only be called by the contract itself, not external accounts (similar to `internal` functions in Solidity). This ensures the counter can only be modified after successful proof verification. - -### Create the Contract Project - -Use `aztec new` to generate the contract project structure: - -```bash -aztec new contract --name ValueNotEqual -``` - -This creates: - -```tree -contract/ -├── src/ -│ └── main.nr # Contract code -└── Nargo.toml # Contract configuration -``` - -### Contract Configuration - -Update `contract/Nargo.toml` with the required dependencies: - -```toml -[package] -name = "ValueNotEqual" -type = "contract" -authors = ["[YOUR_NAME]"] - -[dependencies] -aztec = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } -bb_proof_verification = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v4.2.0-aztecnr-rc.2", directory = "barretenberg/noir/bb_proof_verification" } -``` - -**Key differences from the circuit's Nargo.toml**: - -- `type = "contract"` (not `"bin"`) -- Depends on `aztec` for Aztec-specific features -- Depends on `bb_proof_verification` for `verify_honk_proof` - -### Contract Structure - -Replace the contents of `contract/src/main.nr` with: - -```rust title="full_contract" showLineNumbers -use aztec::macros::aztec; - -#[aztec] -pub contract ValueNotEqual { - use aztec::{ - macros::{functions::{external, initializer, only_self, view}, storage::storage}, - oracle::logging::debug_log_format, - protocol::{address::AztecAddress, traits::ToField}, - state_vars::{Map, PublicImmutable, PublicMutable}, - }; - use bb_proof_verification::{UltraHonkVerificationKey, UltraHonkZKProof, verify_honk_proof}; - - #[storage] - struct Storage { - counters: Map, Context>, - vk_hash: PublicImmutable, - } - - #[initializer] - #[external("public")] - fn constructor(headstart: Field, owner: AztecAddress, vk_hash: Field) { - self.storage.counters.at(owner).write(headstart); - self.storage.vk_hash.initialize(vk_hash); - } - - #[external("private")] - fn increment( - owner: AztecAddress, - verification_key: UltraHonkVerificationKey, - proof: UltraHonkZKProof, - public_inputs: [Field; 1], - ) { - debug_log_format("Incrementing counter for owner {0}", [owner.to_field()]); - - // Read the stored VK hash - this is readable from private context - // because PublicImmutable values are committed at deployment - let vk_hash = self.storage.vk_hash.read(); - - // Verify the proof - this is the core operation - // The function checks: - // 1. The VK hashes to the stored vk_hash - // 2. The proof is valid for the given VK and public inputs - verify_honk_proof(verification_key, proof, public_inputs, vk_hash); - - // If we reach here, the proof is valid - // Enqueue a public function call to update state - self.enqueue_self._increment_public(owner); - } - - #[only_self] - #[external("public")] - fn _increment_public(owner: AztecAddress) { - let current = self.storage.counters.at(owner).read(); - self.storage.counters.at(owner).write(current + 1); - } - - #[view] - #[external("public")] - fn get_counter(owner: AztecAddress) -> Field { - self.storage.counters.at(owner).read() - } -} -``` -> Source code: docs/examples/contracts/recursive_verification_contract/src/main.nr#L1-L78 - - -### Storage Variables Explained - -The contract uses two [storage types](../../aztec-nr/framework-description/state_variables.md) with different characteristics: - -**`vk_hash: PublicImmutable`** - -`PublicImmutable` is perfect for values that: - -- Are set once during contract initialization -- Never change after deployment -- Need to be readable from both public and private contexts - -The VK hash fits all these criteria. Once you deploy a contract to verify proofs from a specific circuit, the circuit (and thus its VK) shouldn't change. - -**Why store the hash instead of the full VK?** - -- Storage costs: 1 field vs 115 fields -- The prover already has the full VK (needed to generate the proof) -- Hash verification is cheap compared to storing/loading 115 fields - -**`counters: Map>`** - -`PublicMutable` is used for values that: - -- Change over time -- Are updated by public functions -- Need to be visible onchain - -The counter must be `PublicMutable` because it's modified by `_increment_public`, a public function. Private functions cannot directly write to public state; they can only enqueue public function calls. - -### Function Breakdown - -**1. `constructor` (public initializer)** - -```rust -#[initializer] -#[external("public")] -fn constructor(headstart: Field, owner: AztecAddress, vk_hash: Field) { - self.storage.counters.at(owner).write(headstart); - self.storage.vk_hash.initialize(vk_hash); -} -``` - -- `#[initializer]`: Marks this as the constructor, called once during deployment -- `#[external("public")]`: Executes publicly (visible onchain) -- Sets the initial counter value for the owner -- Stores the VK hash using `initialize()` (required for `PublicImmutable`) - -**2. `increment` (private function)** - -```rust -#[external("private")] -fn increment( - owner: AztecAddress, - verification_key: UltraHonkVerificationKey, - proof: UltraHonkZKProof, - public_inputs: [Field; 1], -) { - let vk_hash = self.storage.vk_hash.read(); - verify_honk_proof(verification_key, proof, public_inputs, vk_hash); - self.enqueue_self._increment_public(owner); -} -``` - -- `#[external("private")]`: Executes privately (generates a ZK proof of execution in the PXE) -- Reads VK hash from storage (allowed because `PublicImmutable` is readable in private context) -- Calls `verify_honk_proof()` which: - - Computes the hash of the provided verification key - - Checks it matches the stored `vk_hash` - - Verifies the proof against the VK and public inputs - - Fails (reverts) if any check fails -- Uses `enqueue_self._increment_public(owner)` to schedule a public function call - -**Why `enqueue_self` instead of a direct call?** - -In Aztec, private functions cannot directly modify public state. Instead, they enqueue public function calls that execute after the private phase completes. This ensures: - -- Private execution remains private (no public state reads during private execution) -- State updates are atomic (all enqueued calls execute or none do) -- The execution order is deterministic - -**3. `_increment_public` (public, self-only)** - -```rust -#[only_self] -#[external("public")] -fn _increment_public(owner: AztecAddress) { - let current = self.storage.counters.at(owner).read(); - self.storage.counters.at(owner).write(current + 1); -} -``` - -- `#[only_self]`: Only callable by the contract itself (via `enqueue_self`) -- `#[external("public")]`: Executes publicly -- Reads the current counter and increments it - -**Why `#[only_self]`?** - -Without this modifier, anyone could call `_increment_public` directly, bypassing proof verification. The `#[only_self]` modifier ensures the function is only reachable through the private `increment` function, which requires a valid proof. - -**4. `get_counter` (public view)** - -```rust -#[view] -#[external("public")] -fn get_counter(owner: AztecAddress) -> Field { - self.storage.counters.at(owner).read() -} -``` - -- `#[view]`: Read-only function, doesn't modify state -- Returns the counter value for any address - -## Part 4: TypeScript Setup and Proof Generation - -Before compiling the contract or running any TypeScript scripts, set up the project with the necessary configuration files and dependencies. - -### Project Setup - -Create the following files in your project root directory. - -**Create `package.json`:** - -```json -{ - "name": "recursive-verification-tutorial", - "type": "module", - "scripts": { - "ccc": "cd contract && aztec compile && aztec codegen target -o ../artifacts", - "data": "tsx scripts/generate_data.ts", - "recursion": "tsx index.ts" - }, - "dependencies": { - "@aztec/accounts": "4.2.0-aztecnr-rc.2", - "@aztec/aztec.js": "4.2.0-aztecnr-rc.2", - "@aztec/bb.js": "4.2.0-aztecnr-rc.2", - "@aztec/kv-store": "4.2.0-aztecnr-rc.2", - "@aztec/noir-contracts.js": "4.2.0-aztecnr-rc.2", - "@aztec/noir-noir_js": "4.2.0-aztecnr-rc.2", - "@aztec/pxe": "4.2.0-aztecnr-rc.2", - "@aztec/wallets": "4.2.0-aztecnr-rc.2", - "tsx": "^4.20.6" - }, - "devDependencies": { - "@types/node": "^22.0.0" - }, - "peerDependencies": { - "typescript": "^5.0.0" - } -} -``` - -**Create `tsconfig.json`:** - -```json -{ - "compilerOptions": { - "lib": ["ESNext"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true - } -} -``` - -**Install dependencies:** - -```bash -yarn install -``` - -This installs all the Aztec packages needed for proof generation and contract interaction. The installation may take a few minutes due to the size of the cryptographic libraries. - -### Compile the Contract - -Now compile the Aztec contract and generate TypeScript bindings: - -```bash -yarn ccc -``` - -**What this command does** (see [How to Compile a Contract](../../aztec-nr/compiling_contracts.md) for details): - -1. `aztec compile`: Compiles the Noir contract and post-processes it for Aztec (different from `nargo compile`) -2. `aztec codegen`: Generates TypeScript bindings from the contract artifact, enabling type-safe contract interaction - -This generates: - -- `contract/target/ValueNotEqual.json` - Contract artifact (bytecode, ABI, etc.) -- `artifacts/ValueNotEqual.ts` - TypeScript class for deploying and interacting with the contract - -### Proof Generation Script - -The proof generation script executes the circuit offchain and produces the proof data needed for onchain verification. - -Create `scripts/generate_data.ts`: - -```typescript title="generate_data" showLineNumbers -import { Noir } from "@aztec/noir-noir_js"; -import circuitJson from "../circuit/target/hello_circuit.json" with { type: "json" }; -import { Barretenberg, UltraHonkBackend, deflattenFields } from "@aztec/bb.js"; -import fs from "fs"; -import { exit } from "process"; - -// Step 1: Initialize Barretenberg API (the proving system backend) -// Barretenberg is the C++ library that implements UltraHonk -// threads: 1 uses single-threaded mode (increase for faster proofs on multi-core machines) -const barretenbergAPI = await Barretenberg.new({ threads: 1 }); - -// Step 2: Create Noir circuit instance from compiled bytecode -// This loads the circuit definition so we can execute it -const helloWorld = new Noir(circuitJson as any); - -// Step 3: Execute circuit with inputs to generate witness -// The witness is all intermediate values computed during circuit execution -// x=1 (private), y=2 (public) - proves that 1 != 2 -const { witness: mainWitness } = await helloWorld.execute({ x: 1, y: 2 }); - -// Step 4: Create UltraHonk backend with circuit bytecode -// The backend handles proof generation and verification -const mainBackend = new UltraHonkBackend(circuitJson.bytecode, barretenbergAPI); - -// Step 5: Generate proof targeting the noir-recursive verifier -// verifierTarget: 'noir-recursive' creates a proof format suitable for -// verification inside another Noir circuit (which is what Aztec contracts are) -const mainProofData = await mainBackend.generateProof(mainWitness, { - verifierTarget: "noir-recursive", -}); - -// Step 6: Verify proof locally before saving -// This catches errors early - if verification fails here, it will fail onchain too -const isValid = await mainBackend.verifyProof(mainProofData, { - verifierTarget: "noir-recursive", -}); -console.log(`Proof verification: ${isValid ? "SUCCESS" : "FAILED"}`); - -// Step 7: Generate recursive artifacts for onchain use -// This converts the proof and VK into field element arrays that can be -// passed to the Aztec contract -const recursiveArtifacts = await mainBackend.generateRecursiveProofArtifacts( - mainProofData.proof, - mainProofData.publicInputs.length, -); - -// Step 8: Convert proof to field elements if needed -// Some versions return empty proofAsFields, requiring manual conversion -let proofAsFields = recursiveArtifacts.proofAsFields; -if (proofAsFields.length === 0) { - console.log("Using deflattenFields to convert proof..."); - proofAsFields = deflattenFields(mainProofData.proof).map((f) => f.toString()); -} - -const vkAsFields = recursiveArtifacts.vkAsFields; - -console.log(`VK size: ${vkAsFields.length}`); // Should be 115 -console.log(`Proof size: ${proofAsFields.length}`); // Should be ~500 -console.log(`Public inputs: ${mainProofData.publicInputs.length}`); // Should be 1 - -// Step 9: Save all data to JSON for contract interaction -const data = { - vkAsFields: vkAsFields, // 115 field elements - the verification key - vkHash: recursiveArtifacts.vkHash, // Hash of VK - stored in contract - proofAsFields: proofAsFields, // ~500 field elements - the proof - publicInputs: mainProofData.publicInputs.map((p: string) => p.toString()), -}; - -fs.writeFileSync("data.json", JSON.stringify(data, null, 2)); -await barretenbergAPI.destroy(); -console.log("Done"); -exit(); -``` -> Source code: docs/examples/ts/recursive_verification/scripts/generate_data.ts#L1-L74 - - -### Understanding the Proof Generation Pipeline - -#### Setup - -- Initialize Barretenberg (the cryptographic backend) -- Load the compiled circuit - -#### Witness Generation - -```typescript -const { witness: mainWitness } = await helloWorld.execute({ x: 1, y: 2 }); -``` - -The witness contains all values computed during circuit execution, not just inputs and outputs, but every intermediate value. The prover needs the witness to construct the proof. The verifier never sees the witness (that's the point of ZK proofs). - -#### Proof Generation - -```typescript -const mainProofData = await mainBackend.generateProof(mainWitness, { - verifierTarget: "noir-recursive", -}); -``` - -**Why `verifierTarget: 'noir-recursive'`?** There are different proof formats optimized for different verifiers: - -- Native verifiers (standalone programs) -- Smart contract verifiers (Solidity) -- Recursive verifiers (inside other ZK circuits) - -Aztec contracts are compiled to ZK circuits, so `verify_honk_proof` runs inside a circuit. We need the recursive-friendly proof format. - -#### Local Verification - -```typescript -const isValid = await mainBackend.verifyProof(mainProofData, { - verifierTarget: "noir-recursive", -}); -``` - -Always verify locally before submitting onchain. Onchain verification costs gas/fees and takes time. Local verification is free and instant. - -#### Field Element Conversion - -ZK proofs are arrays of bytes, but Aztec contracts work with field elements. We convert the proof and VK to arrays of 115 and ~500 field elements respectively. - -```typescript -let proofAsFields = recursiveArtifacts.proofAsFields; -if (proofAsFields.length === 0) { - console.log("Using deflattenFields to convert proof..."); - proofAsFields = deflattenFields(mainProofData.proof).map((f) => f.toString()); -} - -const vkAsFields = recursiveArtifacts.vkAsFields; -``` - -Some versions of the library return an empty `proofAsFields` array, requiring manual conversion via `deflattenFields`. - -#### Saving Data for Contract Interaction - -```typescript -const data = { - vkAsFields: vkAsFields, - vkHash: recursiveArtifacts.vkHash, - proofAsFields: proofAsFields, - publicInputs: mainProofData.publicInputs.map((p: string) => p.toString()), -}; - -fs.writeFileSync("data.json", JSON.stringify(data, null, 2)); -await barretenbergAPI.destroy(); -``` - -The data is saved as JSON so the deployment script can load it. We call `barretenbergAPI.destroy()` to clean up the WebAssembly resources used by Barretenberg. This is important because Barretenberg allocates significant memory for cryptographic operations, and not destroying it can cause memory leaks in long-running processes. - -### Run Proof Generation - -```bash -yarn data -``` - -Expected output: - -```text -Proof verification: SUCCESS -Using deflattenFields to convert proof... -VK size: 115 -Proof size: 500 -Public inputs: 1 -Done -``` - -### Output Format - -The generated `data.json` contains: - -```json -{ - "vkAsFields": ["0x...", "0x...", ...], // 115 field elements - "vkHash": "0x...", // Single field element - "proofAsFields": ["0x...", "0x...", ...], // ~500 field elements - "publicInputs": ["2"] // The public input y=2 -} -``` - -**What each field is used for**: - -- `vkHash`: Passed to the contract constructor, stored permanently -- `vkAsFields`: Passed to `increment()`, verified against stored hash -- `proofAsFields`: Passed to `increment()`, verified by `verify_honk_proof` -- `publicInputs`: Passed to `increment()`, must match what was used during proof generation - -## Part 5: Deploying and Verifying - -The deployment script connects to the Aztec network, creates an account, deploys the contract, and submits a proof for verification. - -### Deployment Script - -Create `index.ts`: - -```typescript title="run_recursion" showLineNumbers -import { SponsoredFeePaymentMethod } from "@aztec/aztec.js/fee"; -import type { FieldLike } from "@aztec/aztec.js/abi"; -import { getSponsoredFPCInstance } from "./scripts/sponsored_fpc.js"; -import { SponsoredFPCContract } from "@aztec/noir-contracts.js/SponsoredFPC"; -import { ValueNotEqualContract } from "./artifacts/ValueNotEqual.js"; -import { EmbeddedWallet } from "@aztec/wallets/embedded"; -import { NO_FROM } from "@aztec/aztec.js/account"; -import { Fr } from "@aztec/aztec.js/fields"; -import fs from "node:fs"; -import assert from "node:assert"; - -if (!fs.existsSync("data.json")) { - console.error( - "data.json not found. Run 'yarn data' first to generate proof data.", - ); - process.exit(1); -} -const data = JSON.parse(fs.readFileSync("data.json", "utf-8")); - -export const NODE_URL = process.env.AZTEC_NODE_URL ?? "http://localhost:8080"; - -// Setup sponsored fee payment - the FPC pays transaction fees for us -const sponsoredFPC = await getSponsoredFPCInstance(); -const sponsoredPaymentMethod = new SponsoredFeePaymentMethod( - sponsoredFPC.address, -); - -// Initialize wallet and connect to local network -// The wallet manages accounts and sends transactions through the PXE -export const setupWallet = async (): Promise => { - try { - // Create wallet with embedded PXE - // The wallet manages accounts and connects to the node - let wallet = await EmbeddedWallet.create(NODE_URL); - - // Register the sponsored FPC so the wallet knows about it - await wallet.registerContract(sponsoredFPC, SponsoredFPCContract.artifact); - return wallet; - } catch (error) { - console.error("Failed to setup local network:", error); - throw error; - } -}; - -async function main() { - // Step 1: Setup wallet and create account - // Accounts in Aztec are smart contracts (account abstraction) - const wallet = await setupWallet(); - const manager = await wallet.createSchnorrAccount(Fr.random(), Fr.random()); - - // Deploy the account contract - const deployMethod = await manager.getDeployMethod(); - await deployMethod.send({ - from: NO_FROM, - fee: { paymentMethod: sponsoredPaymentMethod }, - }); - - const accounts = await wallet.getAccounts(); - - // Step 2: Deploy ValueNotEqual contract - // Constructor args: initial counter (10), owner, VK hash - const { contract: valueNotEqual } = await ValueNotEqualContract.deploy( - wallet, - 10, // Initial counter value - accounts[0].item, // Owner address - data.vkHash as unknown as FieldLike, // VK hash for verification - ).send({ - from: accounts[0].item, - fee: { paymentMethod: sponsoredPaymentMethod }, - }); - - console.log(`Contract deployed at: ${valueNotEqual.address}`); - - const opts = { - from: accounts[0].item, - fee: { paymentMethod: sponsoredPaymentMethod }, - }; - - // Step 3: Read initial counter value - // simulate() executes without submitting a transaction - let counterValue = ( - await valueNotEqual.methods - .get_counter(accounts[0].item) - .simulate({ from: accounts[0].item }) - ).result; - console.log(`Counter value: ${counterValue}`); // Should be 10 - - // Step 4: Call increment() with proof data - // This creates a transaction that: - // 1. Executes the private increment() function (client-side) - // 2. Generates a ZK proof of correct execution - // 3. Submits the proof to the network - // 4. Network verifies the proof - // 5. Executes enqueued _increment_public() - const interaction = await valueNotEqual.methods.increment( - accounts[0].item, - data.vkAsFields as unknown as FieldLike[], // 115 field VK - data.proofAsFields as unknown as FieldLike[], // ~500 field proof - data.publicInputs as unknown as FieldLike[], // Public inputs - ); - - // Step 5: Send transaction and wait for inclusion - await interaction.send(opts); - - // Step 6: Read updated counter - counterValue = ( - await valueNotEqual.methods - .get_counter(accounts[0].item) - .simulate({ from: accounts[0].item }) - ).result; - console.log(`Counter value: ${counterValue}`); // Should be 11 - - assert(counterValue === 11n, "Counter should be 11 after verification"); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); -``` -> Source code: docs/examples/ts/recursive_verification/index.ts#L1-L129 - - -### Understanding the Deployment Script - -#### Sponsored Fee Payment - -Aztec transactions require fees. For testing, we use a Sponsored Fee Payment Contract (FPC) that pays fees on behalf of users: - -```typescript -const sponsoredFPC = await getSponsoredFPCInstance(); -const sponsoredPaymentMethod = new SponsoredFeePaymentMethod( - sponsoredFPC.address, -); -``` - -In production, you would use real [fee payment methods](../../aztec-js/how_to_pay_fees.md) (native tokens, ERC20, etc.). - -#### What Happens During `increment().send().wait()` - -This single line triggers a complex flow: - -1. **Private Execution** (client-side, in PXE): - - Execute `increment()` with provided arguments - - Read `vk_hash` from contract storage - - Execute `verify_honk_proof()` inside the private function - - Generate the `enqueue_self._increment_public(owner)` call - -2. **Proof Generation** (client-side, in PXE): - - Generate a ZK proof that the private execution was correct - - This proof doesn't reveal inputs (including the ~500-field proof!) - -3. **Transaction Submission**: - - Send the proof + encrypted logs + public function calls to the network - -4. **Verification & Public Execution** (onchain): - - Network verifies the private execution proof - - Execute `_increment_public(owner)` publicly - - Update the counter in storage - -### Supporting Utility - -Create `scripts/sponsored_fpc.ts`: - -```typescript title="sponsored_fpc" showLineNumbers -import { getContractInstanceFromInstantiationParams } from "@aztec/aztec.js/contracts"; -import { Fr } from "@aztec/aztec.js/fields"; -import { SponsoredFPCContract } from "@aztec/noir-contracts.js/SponsoredFPC"; - -const SPONSORED_FPC_SALT = new Fr(BigInt(0)); - -export async function getSponsoredFPCInstance() { - return await getContractInstanceFromInstantiationParams( - SponsoredFPCContract.artifact, - { - salt: SPONSORED_FPC_SALT, - }, - ); -} -``` -> Source code: docs/examples/ts/recursive_verification/scripts/sponsored_fpc.ts#L1-L16 - - -This utility computes the address of the pre-deployed sponsored FPC contract. The salt ensures we get the same address every time. For more information about fee payment options, see [Paying Fees](../../aztec-js/how_to_pay_fees.md). - -### Start the Local Network - -In a separate terminal, start the [Aztec local network](../../../getting_started_on_local_network.md): - -```bash -aztec start --local-network -``` - -**What this starts**: - -- **Anvil**: A local Ethereum node (L1) -- **Aztec Node**: The L2 rollup node -- **PXE**: Private eXecution Environment (embedded in node for local development) - -Wait for the network to fully initialize. You should see logs indicating readiness. The PXE will be available at `http://localhost:8080`. - -### Deploy and Verify - -Run the deployment script: - -```bash -yarn recursion -``` - -Expected output: - -```text -Contract deployed at: 0x... -Counter value: 10 -Counter value: 11 -``` - -The counter starts at 10 (set during deployment), and after successful proof verification, it increments to 11. This confirms that the Noir proof was verified inside the Aztec contract. - -## Quick Reference - -If you want to run all commands at once, or if you're starting fresh, here's the complete workflow. You can also reference the [full working example](https://github.com/AztecProtocol/aztec-packages/tree/v4.2.0-aztecnr-rc.2/docs/examples) in the main repository. - -```bash -# Install dependencies (after creating package.json and tsconfig.json) -yarn install - -# Compile the Noir circuit -cd circuit && nargo compile && cd .. - -# Compile the Aztec contract and generate TypeScript bindings -yarn ccc - -# Generate proof data -yarn data - -# Start the local network (in a separate terminal) -aztec start --local-network - -# Deploy and verify -yarn recursion -``` - -## Next Steps - -Now that you understand the basics of proof verification in Aztec contracts, explore these topics: - -- **Simpler Contract Examples**: If you're new to Aztec contracts, the [Counter Tutorial](./counter_contract.md) provides a gentler introduction to contract development patterns. -- **Multiple Public Inputs**: Extend the circuit to have multiple public inputs. Update `public_inputs: [Field; 1]` in the contract to match. -- **Noir Language Reference**: Explore advanced Noir features like loops, arrays, and standard library functions at [noir-lang.org](https://noir-lang.org/docs). diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md deleted file mode 100644 index 9176794905b8..000000000000 --- a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/contract_tutorials/token_contract.md +++ /dev/null @@ -1,707 +0,0 @@ ---- -title: Private Token Contract -sidebar_position: 1 -tags: [privacy, tokens, intermediate] -description: Build a privacy-preserving token for employee mental health benefits that keeps spending habits confidential. -references: ["docs/examples/contracts/bob_token_contract/src/main.nr", "docs/examples/ts/bob_token_contract/index.ts"] ---- - -## The Privacy Challenge: Mental Health Benefits at Giggle - -Giggle (a fictional tech company) wants to support their employees' mental health by providing BOB tokens that can be spent at Bob's Psychology Clinic. However, employees have a crucial requirement: **complete privacy**. They don't want Giggle to know: - -- How many BOB tokens they've actually used -- When they're using mental health services -- Their therapy patterns or frequency - -In this tutorial, we'll build a token contract that allows Giggle to mint BOB tokens for employees while ensuring complete privacy in how those tokens are spent. - -## Prerequisites - -This is an intermediate tutorial that assumes you have: - -- Completed the [Counter Contract tutorial](./counter_contract.md) -- A Running Aztec local network (see the Counter tutorial for setup) -- Basic understanding of Aztec.nr syntax and structure -- Aztec toolchain installed (`VERSION=4.2.0-aztecnr-rc.2 bash -i <(curl -sL https://install.aztec.network/4.2.0-aztecnr-rc.2)`) - -If you haven't completed the Counter Contract tutorial, please do so first as we'll skip the basic setup steps covered there. - -## What We're Building - -We'll create BOB tokens with: - -- **Public and Private minting**: Giggle can mint tokens in private or public -- **Public and Private transfers**: Employees can spend tokens at Bob's clinic with full privacy - -### Project Setup - -Let's create a simple yarn + aztec.nr project: - -```bash -mkdir bob_token -cd bob_token -yarn init -# This is to ensure yarn uses node_modules instead of pnp for dependency installation -yarn config set nodeLinker node-modules -yarn add @aztec/aztec.js@4.2.0-aztecnr-rc.2 @aztec/accounts@4.2.0-aztecnr-rc.2 @aztec/kv-store@4.2.0-aztecnr-rc.2 -aztec init -``` - -## Contract structure - -The `aztec init` command created a contract project with `Nargo.toml` and `src/main.nr`. Let's replace the boilerplate in `src/main.nr` with a simple starting point: - -```rust -use aztec::macros::aztec; - -#[aztec] -pub contract BobToken { - // We'll build the mental health token here -} -``` - -The `#[aztec]` macro transforms our contract code to work with Aztec's privacy protocol. - -Let's make sure the Aztec.nr library is listed in our dependencies in `bob_token_contract/Nargo.toml`: - -```toml -[package] -name = "bob_token_contract" -type = "contract" - -[dependencies] -aztec = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } -``` - -Since we're here, let's import more specific stuff from this library: - -```rust -#[aztec] -pub contract BobToken { - use aztec::{ - macros::{functions::{external, initializer, only_self}, storage::storage}, - messages::message_delivery::MessageDelivery, - protocol::address::AztecAddress, - state_vars::{Map, Owned, PublicMutable}, - }; -} -``` - -These are the different macros we need to define the visibility of functions, and some handy types and functions. - -## Building the Mental Health Token System - -### The Privacy Architecture - -Before we start coding, let's understand how privacy works in our mental health token system: - -1. **Public Layer**: Giggle mints tokens publicly - transparent and auditable -2. **Private Layer**: Employees transfer and spend tokens privately - completely confidential -3. **Cross-layer Transfer**: Employees can move tokens between public and private domains as needed - -This architecture ensures that while the initial allocation is transparent (important for corporate governance), the actual usage remains completely private. - -:::info Privacy Note -In Aztec, private state uses a UTXO model with "notes" - think of them as encrypted receipts that only the owner can decrypt and spend. When an employee receives BOB tokens privately, they get encrypted notes that only they can see and use. -::: - -Let's start building! Remember to import types as needed - your IDE's Noir extension can help with auto-imports. - -## Part 1: Public Minting for Transparency - -Let's start with the public components that Giggle will use to mint and track initial token allocations. - -### Setting Up Storage - -First, define the storage for our BOB tokens: - -```rust -#[storage] -struct Storage { - // Giggle's admin address - owner: PublicMutable, - // Public balances - visible for transparency - public_balances: Map, Context>, -} -``` - -This storage structure allows: - -- `owner`: Stores Giggle's admin address (who can mint tokens) -- `public_balances`: Tracks public token balances (employees can verify their allocations) - -:::tip Why Public Balances? -While employees want privacy when spending, having public balances during minting allows: - -1. Employees to verify they received their mental health benefits -2. Auditors to confirm fair distribution -3. Transparency in the allocation process - -::: - -### Initializing Giggle as Owner - -When deploying the contract, we need to set Giggle as the owner: - -```rust title="setup" showLineNumbers -#[initializer] -#[external("public")] -fn setup() { - // Giggle becomes the owner who can mint mental health tokens - self.storage.owner.write(self.msg_sender()); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L32-L39 - - -The `#[initializer]` decorator ensures this runs once during deployment. Only Giggle's address will have the power to mint new BOB tokens for employees. - -### Minting BOB Tokens for Employees - -Giggle needs a way to allocate mental health tokens to employees: - -```rust title="mint_public" showLineNumbers -#[external("public")] -fn mint_public(employee: AztecAddress, amount: u64) { - // Only Giggle can mint tokens - assert_eq(self.msg_sender(), self.storage.owner.read(), "Only Giggle can mint BOB tokens"); - - // Add tokens to employee's public balance - let current_balance = self.storage.public_balances.at(employee).read(); - self.storage.public_balances.at(employee).write(current_balance + amount); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L41-L51 - - -This public minting function: - -1. Verifies that only Giggle (the owner) is calling -2. Transparently adds tokens to the employee's public balance -3. Creates an auditable record of the allocation - -:::info Real-World Scenario -Imagine Giggle allocating 100 BOB tokens to each employee at the start of the year. This public minting ensures employees can verify they received their benefits, while their actual usage remains private. -::: - -### Public Transfers (Optional Transparency) - -While most transfers will be private, we'll add public transfers for cases where transparency is desired: - -```rust title="transfer_public" showLineNumbers -#[external("public")] -fn transfer_public(to: AztecAddress, amount: u64) { - let sender = self.msg_sender(); - let sender_balance = self.storage.public_balances.at(sender).read(); - assert(sender_balance >= amount, "Insufficient BOB tokens"); - - // Deduct from sender - self.storage.public_balances.at(sender).write(sender_balance - amount); - - // Add to recipient - let recipient_balance = self.storage.public_balances.at(to).read(); - self.storage.public_balances.at(to).write(recipient_balance + amount); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L53-L67 - - -This might be used when: - -- An employee transfers tokens to a colleague who's comfortable with transparency -- Bob's clinic makes a public refund -- Any scenario where privacy isn't required - -### Admin Transfer (Future-Proofing) - -In case Giggle's mental health program administration changes: - -```rust title="transfer_ownership" showLineNumbers -#[external("public")] -fn transfer_ownership(new_owner: AztecAddress) { - assert_eq( - self.msg_sender(), - self.storage.owner.read(), - "Only current admin can transfer ownership", - ); - self.storage.owner.write(new_owner); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L69-L79 - - -## Your First Deployment - Let's See It Work - -### Compile Your Contract - -You've written enough code to have a working token! Let's compile and test it: - -```bash -aztec compile -``` - -### Generate TypeScript Interface - -```bash -aztec codegen target --outdir artifacts -``` - -You should now have a nice typescript interface in a new `artifacts` folder. Pretty useful! - -### Deploy and Test - -Create `index.ts`. We will connect to our running local network and its wallet, then deploy the test accounts and get three wallets out of it. Ensure that your local network is running: - -```bash -aztec start --local-network -``` - -Then we will use the `giggleWallet` to deploy our contract, mint 100 BOB to Alice, then transfer 10 of those to Bob's Clinic publicly... for now. Let's go: - -```typescript -import { BobTokenContract } from "./artifacts/BobToken.js"; -import { AztecAddress } from "@aztec/aztec.js/addresses"; -import { createAztecNodeClient } from "@aztec/aztec.js/node"; -import { getInitialTestAccountsData } from "@aztec/accounts/testing"; -import { EmbeddedWallet } from "@aztec/wallets/embedded"; - -async function main() { - // Connect to local network - const node = createAztecNodeClient("http://localhost:8080"); - - const wallet = await EmbeddedWallet.create(node); - - const [giggleWalletData, aliceWalletData, bobClinicWalletData] = - await getInitialTestAccountsData(); - const giggleAccountManager = await wallet.createSchnorrAccount( - giggleWalletData.secret, - giggleWalletData.salt, - ); - const aliceAccountManager = await wallet.createSchnorrAccount( - aliceWalletData.secret, - aliceWalletData.salt, - ); - const bobClinicAccountManager = await wallet.createSchnorrAccount( - bobClinicWalletData.secret, - bobClinicWalletData.salt, - ); - - const giggleAddress = giggleAccountManager.address; - const aliceAddress = aliceAccountManager.address; - const bobClinicAddress = bobClinicAccountManager.address; - - const { contract: bobToken } = await BobTokenContract.deploy(wallet).send({ - from: giggleAddress, - }); - - await bobToken.methods - .mint_public(aliceAddress, 100n) - .send({ from: giggleAddress }); - - await bobToken.methods - .transfer_public(bobClinicAddress, 10n) - .send({ from: aliceAddress }); -} - -main().catch(console.error); -``` - -Run your test: - -```bash -npx tsx index.ts -``` - -:::tip - -What's this `tsx` dark magic? Well, it just compiles and runs typescript using reasonable defaults. Pretty cool for small snippets like this! - -::: - -### 🎉 Celebrate - -Congratulations! You've just deployed a working token contract on Aztec! You can: - -- ✅ Mint BOB tokens as Giggle -- ✅ Transfer tokens between employees -- ✅ Track balances publicly - -But there's a problem... **Giggle can see everything!** They know: - -- Who's transferring tokens -- How much is being spent -- When mental health services are being used - -This defeats the whole purpose of our mental health privacy initiative. Let's fix this by adding private functionality! - -## Part 2: Adding Privacy - The Real Magic Begins - -Now let's add the privacy features that make our mental health benefits truly confidential. - -### Understanding Private Notes - -Here's where Aztec's privacy magic happens. Unlike public balances (a single number), private balances are collections of encrypted "notes". Think of it this way: - -- **Public balance**: "Alice has 100 BOB tokens" (visible to everyone) -- **Private balance**: Alice has encrypted notes [Note1: 30 BOB, Note2: 50 BOB, Note3: 20 BOB] that only she can decrypt - -When Alice spends 40 BOB tokens at Bob's clinic: - -1. She consumes Note1 (30 BOB) and Note2 (50 BOB) = 80 BOB total -2. She creates a new note for Bob's clinic (40 BOB) -3. She creates a "change" note for herself (40 BOB) -4. The consumed notes are nullified (marked as spent) - -In this case, all that the network sees (including Giggle) is just "something happening to some state in some contract". How cool is that? - -### Updating Storage for Privacy - -For something like balances, you can use a simple library called `easy_private_state` which abstracts away a custom private Note. A Note is at the core of how private state works in Aztec and you can read about it [here](../../foundational-topics/state_management.md). For now, let's just import the library in `bob_token_contract/Nargo.toml`: - -```toml -[dependencies] -aztec = { git="https://github.com/AztecProtocol/aztec-nr", tag="v4.2.0-aztecnr-rc.2", directory="aztec" } -balance_set = { git = "https://github.com/AztecProtocol/aztec-nr/", tag = "v4.2.0-aztecnr-rc.2", directory = "balance-set" } -``` - -Then import `BalanceSet` in our contract: - -```rust -use aztec::macros::aztec; - -#[aztec] -pub contract BobToken { - // ... other imports - use balance_set::BalanceSet; - // ... -} -``` - -We need to update the contract storage to have private balances as well: - -```rust title="storage" showLineNumbers -#[storage] -struct Storage { - // Giggle's admin address - owner: PublicMutable, - // Public balances - visible for transparency - public_balances: Map, Context>, - // Private balances - only the owner can see these - private_balances: Owned, Context>, -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L19-L30 - - -The `private_balances` use `BalanceSet` which manages encrypted notes automatically. - -### Moving Tokens to Privateland - -Great, now our contract knows about private balances. Let's implement a method to allow users to move their publicly minted tokens there: - -```rust title="public_to_private" showLineNumbers -#[external("private")] -fn public_to_private(amount: u64) { - let sender = self.msg_sender(); - // This will enqueue a public function to deduct from public balance - self.enqueue_self._deduct_public_balance(sender, amount); - // Add to private balance - self.storage.private_balances.at(sender).add(amount as u128).deliver( - MessageDelivery.ONCHAIN_CONSTRAINED, - ); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L81-L92 - - -And the helper function: - -```rust title="_deduct_public_balance" showLineNumbers -#[external("public")] -#[only_self] -fn _deduct_public_balance(owner: AztecAddress, amount: u64) { - let balance = self.storage.public_balances.at(owner).read(); - assert(balance >= amount, "Insufficient public BOB tokens"); - self.storage.public_balances.at(owner).write(balance - amount); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L94-L102 - - -By calling `public_to_private` we're telling the network "deduct this amount from my balance" while simultaneously creating a Note with that balance in privateland. - -### Private Transfers - -Now for the crucial privacy feature - transferring BOB tokens in privacy. This is actually pretty simple: - -```rust title="transfer_private" showLineNumbers -#[external("private")] -fn transfer_private(to: AztecAddress, amount: u64) { - let sender = self.msg_sender(); - // Spend sender's notes (consumes existing notes) - self.storage.private_balances.at(sender).sub(amount as u128).deliver( - MessageDelivery.ONCHAIN_CONSTRAINED, - ); - // Create new notes for recipient - self.storage.private_balances.at(to).add(amount as u128).deliver( - MessageDelivery.ONCHAIN_CONSTRAINED, - ); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L104-L117 - - -This function simply nullifies the sender's notes, while adding them to the recipient. - -:::info Real-World Impact - -When an employee uses 50 BOB tokens at Bob's clinic, this private transfer ensures Giggle has no visibility into: - -- The fact that the employee is seeking mental health services -- The frequency of visits -- The amount spent on treatment - -::: - -### Checking Balances - -Employees can check their BOB token balances without hitting the network by using utility unconstrained functions: - -```rust title="check_balances" showLineNumbers -#[external("utility")] -unconstrained fn private_balance_of(owner: AztecAddress) -> pub u128 { - self.storage.private_balances.at(owner).balance_of() -} - -#[external("utility")] -unconstrained fn public_balance_of(owner: AztecAddress) -> pub u64 { - self.storage.public_balances.at(owner).read() -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L119-L129 - - -## Part 3: Securing Private Minting - -Let's make this a little bit harder, and more interesting. Let's say Giggle doesn't want to mint the tokens in public. Can we have private minting on Aztec? - -Sure we can. Let's see. - -### Understanding Execution Domains - -Our BOB token system operates in two domains: - -1. **Public Domain**: Where Giggle mints tokens transparently -2. **Private Domain**: Where employees spend tokens confidentially - -The key challenge: How do we ensure only Giggle can mint tokens when the minting happens in a private function? - -:::warning Privacy Trade-off - -Private functions can't directly read current public state (like who the owner is). They can only read historical public state or enqueue public function calls for validation. - -::: - -### The Access Control Challenge - -We want Giggle to mint BOB tokens directly to employees' private balances (for maximum privacy), but we need to ensure only Giggle can do this. The challenge: ownership is stored publicly, but private functions can't read current public state. - -Let's use a clever pattern where private functions enqueue public validation checks. First we make a little helper function in public. Remember, public functions always run _after_ private functions, since private functions run client-side. - -```rust title="_assert_is_owner" showLineNumbers -#[external("public")] -#[only_self] -fn _assert_is_owner(address: AztecAddress) { - assert_eq(address, self.storage.owner.read(), "Only Giggle can mint BOB tokens"); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L131-L137 - - -Now we can add a secure private minting function. It looks pretty easy, and it is, since the whole thing will revert if the public function fails: - -```rust title="mint_private" showLineNumbers -#[external("private")] -fn mint_private(employee: AztecAddress, amount: u64) { - // Enqueue ownership check (will revert if not Giggle) - self.enqueue_self._assert_is_owner(self.msg_sender()); - - // If check passes, mint tokens privately - self.storage.private_balances.at(employee).add(amount as u128).deliver( - MessageDelivery.ONCHAIN_CONSTRAINED, - ); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L139-L150 - - -This pattern ensures: - -1. The private minting executes first (creating the proof) -2. The public ownership check executes after -3. If the check fails, the entire transaction (including the private part) reverts -4. Only Giggle can successfully mint BOB tokens - -## Part 4: Converting Back to Public - -For the sake of completeness, let's also have a function that brings the tokens back to publicland: - -```rust title="private_to_public" showLineNumbers -#[external("private")] -fn private_to_public(amount: u64) { - let sender = self.msg_sender(); - // Remove from private balance - self.storage.private_balances.at(sender).sub(amount as u128).deliver( - MessageDelivery.ONCHAIN_CONSTRAINED, - ); - // Enqueue public credit - self.enqueue_self._credit_public_balance(sender, amount); -} - -#[external("public")] -#[only_self] -fn _credit_public_balance(owner: AztecAddress, amount: u64) { - let balance = self.storage.public_balances.at(owner).read(); - self.storage.public_balances.at(owner).write(balance + amount); -} -``` -> Source code: docs/examples/contracts/bob_token_contract/src/main.nr#L152-L170 - - -Now you've made changes to your contract, you need to recompile your contract. - -Here are the steps from above, for reference: - -```bash -aztec compile -aztec codegen target --outdir artifacts -``` - -## Testing the Complete Privacy System - -Now that you've implemented all the privacy features, let's update our test script to showcase the full privacy flow: - -### Update Your Test Script - -Let's stop being lazy and add a nice little "log" function that just spits out everyone's balances to the console, for example: - -```typescript -// at the top of your file -async function getBalances( - contract: BobTokenContract, - aliceAddress: AztecAddress, - bobAddress: AztecAddress, -) { - await Promise.all([ - contract.methods - .public_balance_of(aliceAddress) - .simulate({ from: aliceAddress }) - .then(({ result }) => result), - contract.methods - .private_balance_of(aliceAddress) - .simulate({ from: aliceAddress }) - .then(({ result }) => result), - contract.methods - .public_balance_of(bobAddress) - .simulate({ from: bobAddress }) - .then(({ result }) => result), - contract.methods - .private_balance_of(bobAddress) - .simulate({ from: bobAddress }) - .then(({ result }) => result), - ]).then( - ([ - alicePublicBalance, - alicePrivateBalance, - bobPublicBalance, - bobPrivateBalance, - ]) => { - console.log( - `📊 Alice has ${alicePublicBalance} public BOB tokens and ${alicePrivateBalance} private BOB tokens`, - ); - console.log( - `📊 Bob's Clinic has ${bobPublicBalance} public BOB tokens and ${bobPrivateBalance} private BOB tokens`, - ); - }, - ); -} -``` - -Looks ugly but it does what it says: prints Alice's and Bob's balances. This will make it easier to see our contract working. - -Now let's add some more stuff to our `index.ts`: - -```typescript -async function main() { - // ...etc - await bobToken.methods - .mint_public(aliceAddress, 100n) - .send({ from: giggleAddress }); - await getBalances(bobToken, aliceAddress, bobClinicAddress); - - await bobToken.methods - .transfer_public(bobClinicAddress, 10n) - .send({ from: aliceAddress }); - await getBalances(bobToken, aliceAddress, bobClinicAddress); - - await bobToken.methods.public_to_private(90n).send({ from: aliceAddress }); - await getBalances(bobToken, aliceAddress, bobClinicAddress); - - await bobToken.methods - .transfer_private(bobClinicAddress, 50n) - .send({ from: aliceAddress }); - await getBalances(bobToken, aliceAddress, bobClinicAddress); - - await bobToken.methods.private_to_public(10n).send({ from: aliceAddress }); - await getBalances(bobToken, aliceAddress, bobClinicAddress); - - await bobToken.methods - .mint_private(aliceAddress, 100n) - .send({ from: giggleAddress }); - await getBalances(bobToken, aliceAddress, bobClinicAddress); -} - -main().catch(console.error); -``` - -The flow is something like: - -- Giggle mints Alice 100 BOB in public -- Alice transfers 10 BOB to Bob in public -- Alice makes the remaining 90 BOB private -- Alice transfers 50 of those to Bob, in private -- Of the remaining 40 BOB, she makes 10 public again -- Giggle mints 100 BOB tokens for Alice, in private - -Let's give it a try: - -```bash -npx tsx index.ts -``` - -You should see the complete privacy journey from transparent allocation to confidential usage! - -## Summary - -You've built a privacy-preserving token system that solves a real-world problem: enabling corporate mental health benefits while protecting employee privacy. This demonstrates Aztec's unique ability to provide both transparency and privacy where each is most needed. - -The BOB token shows how blockchain can enable new models of corporate benefits that weren't possible before - where verification and privacy coexist, empowering employees to seek help without fear of judgment or career impact. - -### What You Learned - -- How to create tokens with both public and private states -- How to bridge between public and private domains -- How to implement access control across execution contexts -- How to build real-world privacy solutions on Aztec - -## Going Further: The AIP-20 Token Standard - -The BOB token you built in this tutorial implements a simplified version of the patterns formalized in **AIP-20**, Aztec's fungible token standard. AIP-20 extends these patterns with commitment-based transfers for DeFi composability, recursive note consumption for large balances, and tokenized vault support (AIP-4626). - -Read the full [AIP-20 standard reference](../../aztec-nr/standards/aip-20.md) for details, or explore all [Aztec Contract Standards](../../aztec-nr/standards/index.md). - -### Continue Your Journey - -- Explore [cross-chain communication](../../foundational-topics/ethereum-aztec-messaging/index.md) to integrate with existing health systems -- Learn about [account abstraction](../../foundational-topics/accounts/index.md) for recovery mechanisms diff --git a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md b/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md deleted file mode 100644 index c220e58099ef..000000000000 --- a/docs/developer_versioned_docs/version-v4.2.0-aztecnr-rc.2/docs/tutorials/js_tutorials/token_bridge.md +++ /dev/null @@ -1,1059 +0,0 @@ ---- -title: "Bridge Your NFT to Aztec" -sidebar_position: 1 -description: "Build a private NFT bridge between Ethereum and Aztec using custom notes, PrivateSet, and cross-chain messaging portals." -references: ["docs/examples/tutorials/token_bridge_contract/*"] ---- - -## Why Bridge an NFT? - -Imagine you own a CryptoPunk NFT on Ethereum. You want to use it in games, social apps, or DeFi protocols, but gas fees on Ethereum make every interaction expensive. What if you could move your Punk to Aztec (L2), use it **privately** in dozens of applications, and then bring it back to Ethereum when you're ready to sell? - -In this tutorial, you'll build a **private NFT bridge**. By the end, you'll understand how **portals** work and how **cross-chain messages** flow between L1 and L2. - -Before starting, make sure you have the Aztec local network running at version 4.2.0-aztecnr-rc.2. Check out [the local network guide](../../../getting_started_on_local_network.md) for setup instructions. - -## What You'll Build - -You'll create two contracts with **privacy at the core**: - -- **NFTPunk (L2)** - An NFT contract with encrypted ownership using `PrivateSet` -- **NFTBridge (L2)** - A bridge that mints NFTs privately when claiming L1 messages - -This tutorial focuses on the L2 side to keep things manageable. You'll learn the essential privacy patterns that apply to any asset bridge on Aztec. - -## Project Setup - -Let's start simple. Since this is an Ethereum project, it's easier to just start with Hardhat: - -```bash -git clone https://github.com/critesjosh/hardhat-aztec-example -``` - -You're cloning a repo here to make it easier for Aztec's `l1-contracts` to be mapped correctly. You should now have a `hardhat-aztec-example` folder with Hardhat's default starter, with a few changes in `package.json`. - -We want to add a few more dependencies now before we start: - -```bash -cd hardhat-aztec-example -yarn add @aztec/aztec.js@4.2.0-aztecnr-rc.2 @aztec/accounts@4.2.0-aztecnr-rc.2 @aztec/stdlib@4.2.0-aztecnr-rc.2 @aztec/wallets@4.2.0-aztecnr-rc.2 tsx -``` - -Now start the local network in another terminal: - -```bash -aztec start --local-network -``` - -This should start two important services on ports 8080 and 8545, respectively: Aztec and Anvil (an Ethereum development node). - -## Part 1: Building the NFT Contract - -Let's start with a basic NFT contract on Aztec. That's the representation of the NFT locked on the L2 side: - -```mermaid -graph LR - subgraph Ethereum["Ethereum (L1)"] - L1NFT["🎨 L1 NFT
(CryptoPunk)"] - L1Portal["🌉 L1 Portal
(TokenPortal)"] - end - - subgraph Aztec["Aztec (L2)"] - L2Bridge["🔗 L2 Bridge
(NFTBridge)"] - L2NFT["🎭 L2 NFT
(NFTPunk)"] - end - - L1NFT -->|"Lock NFT"| L1Portal - L1Portal -->|"L1→L2 Message"| L2Bridge - L2Bridge -->|"Mint Private"| L2NFT - - L2NFT -.->|"Burn"| L2Bridge - L2Bridge -.->|"L2→L1 Message"| L1Portal - L1Portal -.->|"Unlock NFT"| L1NFT - - style L2NFT fill:#4ade80,stroke:#22c55e,stroke-width:3px - style L2Bridge fill:#f0f0f0,stroke:#999,stroke-width:2px - style L1Portal fill:#f0f0f0,stroke:#999,stroke-width:2px - style L1NFT fill:#f0f0f0,stroke:#999,stroke-width:2px - - classDef highlight fill:#4ade80,stroke:#22c55e,stroke-width:3px -``` - -Let's create that crate in the `contracts` folder so it looks tidy: - -```bash -aztec new contracts/aztec/nft -cd contracts/aztec/nft -``` - -:::tip Noir Language Server - -If you're using VS Code, install the [Noir Language Support extension](https://marketplace.visualstudio.com/items?itemName=noir-lang.vscode-noir) for syntax highlighting, error checking, and code completion while writing Noir contracts. - -::: - -Open `Nargo.toml` and make sure `aztec` is a dependency: - -```toml -[dependencies] -aztec = { git = "https://github.com/AztecProtocol/aztec-nr", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } -``` - -### Create the NFT Note - -First, let's create a custom note type for private NFT ownership. In the `src/` directory, create a new file called `nft.nr`: - -```bash -touch src/nft.nr -``` - -In this file, you're going to create a **private note** that represents NFT ownership. This is a struct with macros that indicate it is a note that can be compared and packed: - -```rust title="nft_note_struct" showLineNumbers -use aztec::{macros::notes::note, protocol::traits::Packable}; - -#[derive(Eq, Packable)] -#[note] -pub struct NFTNote { - pub token_id: Field, -} -``` -> Source code: docs/examples/contracts/nft/src/nft.nr#L1-L9 - - -You now have a note that represents the owner of a particular NFT. Next, move on to the contract itself. - -:::tip Custom Notes - -Notes are powerful concepts. Learn more about how to use them in the [state management guide](../../foundational-topics/state_management.md). - -::: - -### Define Storage - -Back in `main.nr`, you can now build the contract storage. You need: - -- **admin**: Who controls the contract (set once, never changes) -- **minter**: The bridge address (set once by admin) -- **nfts**: Track which NFTs exist (public, needed for bridging) -- **owners**: Private ownership using the NFTNote - -One interesting aspect of this storage configuration is the use of `DelayedPublicMutable`, which allows private functions to read and use public state. You're using it to publicly track which NFTs are already minted while keeping their owners private. Read more about `DelayedPublicMutable` in [the storage guide](../../aztec-nr/framework-description/state_variables.md). - -Write the storage struct and a simple [initializer](../../foundational-topics/contract_creation.md#initialization) to set the admin in the `main.nr` file: - - - -```rust -use aztec::macros::aztec; -pub mod nft; - -#[aztec] -pub contract NFTPunk { - use crate::nft::NFTNote; - use aztec::{ - macros::{functions::{external, initializer, only_self}, storage::storage}, - protocol::address::AztecAddress, - state_vars::{DelayedPublicMutable, Map, Owned, PrivateSet, PublicImmutable}, - }; - use aztec::messages::message_delivery::MessageDelivery; - use aztec::note::{ - note_getter_options::NoteGetterOptions, note_interface::NoteProperties, - note_viewer_options::NoteViewerOptions, - }; - use aztec::utils::comparison::Comparator; - - #[storage] - struct Storage { - admin: PublicImmutable, - minter: PublicImmutable, - nfts: Map, Context>, - owners: Owned, Context>, - } - #[external("public")] - #[initializer] - fn constructor(admin: AztecAddress) { - self.storage.admin.initialize(admin); - } -} -``` - -### Utility Functions - -Add an internal function to handle the `DelayedPublicMutable` value change. Mark the function as public and `#[only_self]` so only the contract can call it: - -```rust title="mark_nft_exists" showLineNumbers -#[external("public")] -#[only_self] -fn _mark_nft_exists(token_id: Field, exists: bool) { - self.storage.nfts.at(token_id).schedule_value_change(exists); -} -``` -> Source code: docs/examples/contracts/nft/src/main.nr#L42-L48 - - -This function is marked with `#[only_self]`, meaning only the contract itself can call it. It uses `schedule_value_change` to update the `nfts` storage, preventing the same NFT from being minted twice or burned when it doesn't exist. You'll call this public function from a private function later using `enqueue_self`. - -Another useful function checks how many notes a caller has. You can use this later to verify the claim and exit from L2: - -```rust title="notes_of" showLineNumbers -#[external("utility")] -unconstrained fn notes_of(from: AztecAddress) -> Field { - let notes = self.storage.owners.at(from).view_notes(NoteViewerOptions::new()); - notes.len() as Field -} -``` -> Source code: docs/examples/contracts/nft/src/main.nr#L67-L73 - - -### Add Minting and Burning - -Before anything else, you need to set the minter. This will be the bridge contract, so only the bridge contract can mint NFTs. This value doesn't need to change after initialization. Here's how to initialize the `PublicImmutable`: - -```rust title="set_minter" showLineNumbers -#[external("public")] -fn set_minter(minter: AztecAddress) { - assert(self.storage.admin.read().eq(self.msg_sender()), "caller is not admin"); - self.storage.minter.initialize(minter); -} -``` -> Source code: docs/examples/contracts/nft/src/main.nr#L34-L40 - - -Now for the magic - minting NFTs **privately**. The bridge will call this to mint to a user, deliver the note using [constrained message delivery](../../aztec-nr/framework-description/events_and_logs.md) (best practice when "sending someone a -note") and then [enqueue a public call](../../aztec-nr/framework-description/calling_contracts.md) to the `_mark_nft_exists` function: - -```rust title="mint" showLineNumbers -#[external("private")] -fn mint(to: AztecAddress, token_id: Field) { - assert( - self.storage.minter.read().eq(self.msg_sender()), - "caller is not the authorized minter", - ); - - // we create an NFT note and insert it to the PrivateSet - a collection of notes meant to be read in private - let new_nft = NFTNote { token_id }; - self.storage.owners.at(to).insert(new_nft).deliver(MessageDelivery.ONCHAIN_CONSTRAINED); - - // calling the internal public function above to indicate that the NFT is taken - self.enqueue_self._mark_nft_exists(token_id, true); -} -``` -> Source code: docs/examples/contracts/nft/src/main.nr#L50-L65 - - -The bridge will also need to burn NFTs when users withdraw back to L1: - -```rust title="burn" showLineNumbers -#[external("private")] -fn burn(from: AztecAddress, token_id: Field) { - assert( - self.storage.minter.read().eq(self.msg_sender()), - "caller is not the authorized minter", - ); - - // from the NFTNote properties, selects token_id and compares it against the token_id to be burned - let options = NoteGetterOptions::new() - .select(NFTNote::properties().token_id, Comparator.EQ, token_id) - .set_limit(1); - let notes = self.storage.owners.at(from).pop_notes(options); - assert(notes.len() == 1, "NFT not found"); - - self.enqueue_self._mark_nft_exists(token_id, false); -} -``` -> Source code: docs/examples/contracts/nft/src/main.nr#L75-L92 - - -### Compiling! - -Let's verify it compiles: - -```bash -aztec compile -``` - -🎉 You should see "Compiled successfully!" This means our private NFT contract is ready. Now let's build the bridge. - -## Part 2: Building the Bridge - -We have built the L2 NFT contract. This is the L2 representation of an NFT that is locked on the L1 bridge. - -The L2 bridge is the contract that talks to the L1 bridge through cross-chain messaging. You can read more about this protocol [here](../../../docs/foundational-topics/ethereum-aztec-messaging/index.md). - -```mermaid -graph LR - subgraph Ethereum["Ethereum (L1)"] - L1NFT["🎨 L1 NFT
(CryptoPunk)"] - L1Portal["🌉 L1 Portal
(TokenPortal)"] - end - - subgraph Aztec["Aztec (L2)"] - L2Bridge["🔗 L2 Bridge
(NFTBridge)"] - L2NFT["🎭 L2 NFT
(NFTPunk)"] - end - - L1NFT -->|"Lock NFT"| L1Portal - L1Portal -->|"L1→L2 Message"| L2Bridge - L2Bridge -->|"Mint Private"| L2NFT - - L2NFT -.->|"Burn"| L2Bridge - L2Bridge -.->|"L2→L1 Message"| L1Portal - L1Portal -.->|"Unlock NFT"| L1NFT - - style L2Bridge fill:#4ade80,stroke:#22c55e,stroke-width:3px - style L2NFT fill:#f0f0f0,stroke:#999,stroke-width:2px - style L1Portal fill:#f0f0f0,stroke:#999,stroke-width:2px - style L1NFT fill:#f0f0f0,stroke:#999,stroke-width:2px - - classDef highlight fill:#4ade80,stroke:#22c55e,stroke-width:3px -``` - -Let's create a new contract in the same tidy `contracts/aztec` folder: - -```bash -cd .. -aztec new nft_bridge -cd nft_bridge -``` - -And again, add the `aztec-nr` dependency to `Nargo.toml`. We also need to add the `NFTPunk` contract we just wrote above: - -```toml -[dependencies] -aztec = { git="https://github.com/AztecProtocol/aztec-nr", tag = "v4.2.0-aztecnr-rc.2", directory = "aztec" } -NFTPunk = { path = "../nft" } -``` - -### Understanding Bridges - -A bridge has two jobs: - -1. **Claim**: When someone deposits an NFT on L1, mint it on L2 -2. **Exit**: When someone wants to withdraw, burn on L2 and unlock on L1 - -This means having knowledge about the L2 NFT contract, and the bridge on the L1 side. That's what goes into our bridge's storage. - -### Bridge Storage - -Clean up `main.nr` which is just a placeholder, and let's write the storage struct and the constructor. We'll use `PublicImmutable` since these values never change: - - - -```rust -use aztec::macros::aztec; - -#[aztec] -pub contract NFTBridge { - use aztec::{ - macros::{functions::{external, initializer}, storage::storage}, - protocol::{address::{AztecAddress, EthAddress}, hash::sha256_to_field}, - state_vars::PublicImmutable, - }; - use NFTPunk::NFTPunk; - - #[storage] - struct Storage { - nft: PublicImmutable, - portal: PublicImmutable, - } - - #[external("public")] - #[initializer] - fn constructor(nft: AztecAddress) { - self.storage.nft.initialize(nft); - } - - #[external("public")] - fn set_portal(portal: EthAddress) { - self.storage.portal.initialize(portal); - } -} -``` - -You can't initialize the `portal` value in the constructor because the L1 portal hasn't been deployed yet. You'll need another function to set it up after the L1 portal is deployed. - -### Adding the Bridge Functions - -The Aztec network provides a way to consume messages from L1 to L2 called `consume_l1_to_l2_message`. - -You need to define how to encode messages. Here's a simple approach: when an NFT is being bridged, the L1 portal sends a hash of its `token_id` through the bridge, signaling which `token_id` was locked and can be minted on L2. This approach is simple but sufficient for this tutorial. - -Build the `claim` function, which consumes the message and mints the NFT on the L2 side: - -```rust title="claim" showLineNumbers -#[external("private")] -fn claim(to: AztecAddress, token_id: Field, secret: Field, message_leaf_index: Field) { - // Compute the message hash that was sent from L1 - let token_id_bytes: [u8; 32] = (token_id as Field).to_be_bytes(); - let content_hash = sha256_to_field(token_id_bytes); - - // Consume the L1 -> L2 message - self.context.consume_l1_to_l2_message( - content_hash, - secret, - self.storage.portal.read(), - message_leaf_index, - ); - - // Mint the NFT on L2 - let nft: AztecAddress = self.storage.nft.read(); - self.call(NFTPunk::at(nft).mint(to, token_id)); -} -``` -> Source code: docs/examples/contracts/nft_bridge/src/main.nr#L31-L50 - - -:::tip Secret - -The secret prevents front-running. Certainly you don't want anyone to claim your NFT on the L2 side by just being faster. Adding a secret acts like a "password": you can only claim it if you know it. - -::: - -Similarly, exiting to L1 means burning the NFT on the L2 side and pushing a message through the protocol. To ensure only the L1 recipient can claim it, hash the `token_id` together with the `recipient`: - -```rust title="exit" showLineNumbers -#[external("private")] -fn exit(token_id: Field, recipient: EthAddress) { - // Create L2->L1 message to unlock NFT on L1 - let token_id_bytes: [u8; 32] = token_id.to_be_bytes(); - let recipient_bytes: [u8; 20] = recipient.to_be_bytes(); - let content = sha256_to_field(token_id_bytes.concat(recipient_bytes)); - self.context.message_portal(self.storage.portal.read(), content); - - // Burn the NFT on L2 - let nft: AztecAddress = self.storage.nft.read(); - self.call(NFTPunk::at(nft).burn(self.msg_sender(), token_id)); -} -``` -> Source code: docs/examples/contracts/nft_bridge/src/main.nr#L52-L65 - - -Cross-chain messaging on Aztec is powerful because it doesn't conform to any specific format—you can structure messages however you want. - -:::tip Private Functions - -Both `claim` and `exit` are `#[external("private")]`, which means the bridging process is private—nobody can see who's bridging which NFT by watching the chain. - -::: - -### Compile the Bridge - -```bash -aztec compile -``` - -Bridge compiled successfully! Now process both contracts and generate TypeScript bindings: - -```bash -cd ../nft -aztec codegen target --outdir ../artifacts - -cd ../nft_bridge -aztec codegen target --outdir ../artifacts -``` - -An `artifacts` folder should appear with TypeScript bindings for each contract. You'll use these when deploying the contracts. - -## Part 3: The Ethereum Side - -Now build the L1 contracts. You need: - -- A simple ERC721 NFT contract (the "CryptoPunk") -- A portal contract that locks/unlocks NFTs and communicates with Aztec - -### Install Dependencies - -Aztec's contracts are already in your `package.json`. You just need to add the OpenZeppelin contracts that provide the default ERC721 implementation: - -```bash -cd ../../.. -yarn add @openzeppelin/contracts -``` - -### Create a Simple NFT - -Delete the "Counter" contracts that show up by default in `contracts` and create `contracts/SimpleNFT.sol`: - -```bash -touch contracts/SimpleNFT.sol -``` - -Create a minimal NFT contract sufficient for demonstrating bridging: - -```solidity title="simple_nft" showLineNumbers -pragma solidity >=0.8.27; - -import {ERC721} from "@oz/token/ERC721/ERC721.sol"; - -contract SimpleNFT is ERC721 { - uint256 private _currentTokenId; - - constructor() ERC721("SimplePunk", "SPUNK") {} - - function mint(address to) external returns (uint256) { - uint256 tokenId = _currentTokenId++; - _mint(to, tokenId); - return tokenId; - } -} -``` -> Source code: docs/examples/solidity/nft_bridge/SimpleNFT.sol#L2-L18 - - -### Create the NFT Portal - -The NFT Portal has more code, so build it step-by-step. Create `contracts/NFTPortal.sol`: - -```bash -touch contracts/NFTPortal.sol -``` - -Initialize it with Aztec's registry, which holds the canonical contracts for Aztec-related contracts, including the Inbox and Outbox. These are the message-passing contracts—Aztec sequencers read any messages on these contracts. - -```solidity -import {IERC721} from "@oz/token/ERC721/IERC721.sol"; -import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; -import {IInbox} from "@aztec/core/interfaces/messagebridge/IInbox.sol"; -import {IOutbox} from "@aztec/core/interfaces/messagebridge/IOutbox.sol"; -import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; -import {DataStructures} from "@aztec/core/libraries/DataStructures.sol"; -import {Hash} from "@aztec/core/libraries/crypto/Hash.sol"; -import {Epoch} from "@aztec/core/libraries/TimeLib.sol"; - -contract NFTPortal { - IRegistry public registry; - IERC721 public nftContract; - bytes32 public l2Bridge; - - IRollup public rollup; - IOutbox public outbox; - IInbox public inbox; - uint256 public rollupVersion; - - function initialize(address _registry, address _nftContract, bytes32 _l2Bridge) external { - registry = IRegistry(_registry); - nftContract = IERC721(_nftContract); - l2Bridge = _l2Bridge; - - rollup = IRollup(address(registry.getCanonicalRollup())); - outbox = rollup.getOutbox(); - inbox = rollup.getInbox(); - rollupVersion = rollup.getVersion(); - } -} -``` - -The core logic is similar to the L2 logic. `depositToAztec` calls the `Inbox` canonical contract to send a message to Aztec, and `withdraw` calls the `Outbox` contract. - -Add these two functions with explanatory comments: - -```solidity title="portal_deposit_and_withdraw" showLineNumbers -// Lock NFT and send message to L2 -function depositToAztec(uint256 tokenId, bytes32 secretHash) external returns (bytes32, uint256) { - // Lock the NFT - nftContract.transferFrom(msg.sender, address(this), tokenId); - - // Prepare L2 message - just a naive hash of our tokenId - DataStructures.L2Actor memory actor = DataStructures.L2Actor(l2Bridge, rollupVersion); - bytes32 contentHash = Hash.sha256ToField(abi.encode(tokenId)); - - // Send message to Aztec - (bytes32 key, uint256 index) = inbox.sendL2Message(actor, contentHash, secretHash); - return (key, index); -} - -// Unlock NFT after L2 burn -function withdraw( - uint256 tokenId, - Epoch epoch, - uint256 leafIndex, - bytes32[] calldata path -) external { - // Verify message from L2 - DataStructures.L2ToL1Msg memory message = DataStructures.L2ToL1Msg({ - sender: DataStructures.L2Actor(l2Bridge, rollupVersion), - recipient: DataStructures.L1Actor(address(this), block.chainid), - content: Hash.sha256ToField(abi.encodePacked(tokenId, msg.sender)) - }); - - outbox.consume(message, epoch, leafIndex, path); - - // Unlock NFT - nftContract.transferFrom(address(this), msg.sender, tokenId); -} -``` -> Source code: docs/examples/solidity/nft_bridge/NFTPortal.sol#L36-L70 - - -The portal handles two flows: - -- **depositToAztec**: Locks NFT on L1, sends message to L2 -- **withdraw**: Verifies L2 message, unlocks NFT on L1 - -### Compile - -Let's make sure everything compiles: - -```bash -npx hardhat compile -``` - -You should see successful compilation of both contracts! - -## Part 4: Compiling, Deploying, and Testing - -Now deploy everything and test the full flow. This will help you understand how everything fits together. - -Delete the placeholders in `scripts` and create `index.ts`: - -```bash -touch scripts/index.ts -``` - -This script will implement the user flow. - -:::warning Testnet - -This section assumes you're working locally using the local network. For the testnet, you need to account for some things: - -- Your clients need to point to some Sepolia Node and to the public Aztec Full Node -- You need to [deploy your own Aztec accounts](../../aztec-js/how_to_create_account.md) -- You need to pay fees in some other way. Learn how in the [fees guide](../../aztec-js/how_to_pay_fees.md) - -::: - -### Deploying and Initializing - -First, initialize the clients: `aztec.js` for Aztec and `viem` for Ethereum: - -```typescript title="setup" showLineNumbers -import { getInitialTestAccountsData } from "@aztec/accounts/testing"; -import { AztecAddress, EthAddress } from "@aztec/aztec.js/addresses"; -import { Fr } from "@aztec/aztec.js/fields"; -import { createAztecNodeClient } from "@aztec/aztec.js/node"; -import { createExtendedL1Client } from "@aztec/ethereum/client"; -import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; -import { sha256ToField } from "@aztec/foundation/crypto/sha256"; -import { - computeL2ToL1MessageHash, - computeSecretHash, -} from "@aztec/stdlib/hash"; -import { computeL2ToL1MembershipWitness } from "@aztec/stdlib/messaging"; -import { EmbeddedWallet } from "@aztec/wallets/embedded"; -import { decodeEventLog, pad } from "@aztec/viem"; -import { foundry } from "@aztec/viem/chains"; -import NFTPortal from "../../../target/solidity/nft_bridge/NFTPortal.sol/NFTPortal.json" with { type: "json" }; -import SimpleNFT from "../../../target/solidity/nft_bridge/SimpleNFT.sol/SimpleNFT.json" with { type: "json" }; -import { NFTBridgeContract } from "./artifacts/NFTBridge.js"; -import { NFTPunkContract } from "./artifacts/NFTPunk.js"; - -// Setup L1 client using anvil's default mnemonic (same as e2e tests) -const MNEMONIC = "test test test test test test test test test test test junk"; -const l1Client = createExtendedL1Client(["http://localhost:8545"], MNEMONIC); -const ownerEthAddress = l1Client.account.address; - -// Setup L2 using Aztec's local network and one of its initial accounts -console.log("Setting up L2...\n"); -const node = createAztecNodeClient("http://localhost:8080"); -const aztecWallet = await EmbeddedWallet.create(node); -const [accData] = await getInitialTestAccountsData(); -const account = await aztecWallet.createSchnorrAccount( - accData.secret, - accData.salt, -); -console.log(`Account: ${account.address.toString()}\n`); - -// Get node info -const nodeInfo = await node.getNodeInfo(); -const registryAddress = nodeInfo.l1ContractAddresses.registryAddress.toString(); -const inboxAddress = nodeInfo.l1ContractAddresses.inboxAddress.toString(); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L1-L42 - - -You now have wallets for both chains, correctly connected to their respective chains. Next, deploy the L1 contracts: - -```typescript title="deploy_l1_contracts" showLineNumbers -console.log("Deploying L1 contracts...\n"); - -const { address: nftAddress } = await deployL1Contract( - l1Client, - SimpleNFT.abi, - SimpleNFT.bytecode.object as `0x${string}`, -); - -const { address: portalAddress } = await deployL1Contract( - l1Client, - NFTPortal.abi, - NFTPortal.bytecode.object as `0x${string}`, -); - -console.log(`SimpleNFT: ${nftAddress}`); -console.log(`NFTPortal: ${portalAddress}\n`); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L44-L61 - - -Now deploy the L2 contracts. Thanks to the TypeScript bindings generated with `aztec codegen`, deployment is straightforward: - -```typescript title="deploy_l2_contracts" showLineNumbers -console.log("Deploying L2 contracts...\n"); - -const { contract: l2Nft } = await NFTPunkContract.deploy(aztecWallet, account.address).send({ - from: account.address, -}); - -const { contract: l2Bridge } = await NFTBridgeContract.deploy( - aztecWallet, - l2Nft.address, -).send({ from: account.address }); - -console.log(`L2 NFT: ${l2Nft.address.toString()}`); -console.log(`L2 Bridge: ${l2Bridge.address.toString()}\n`); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L63-L77 - - -Now that you have the L2 bridge's contract address, initialize the L1 bridge: - -```typescript title="initialize_portal" showLineNumbers -console.log("Initializing portal..."); - -// Initialize the portal contract -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const initHash = await l1Client.writeContract({ - address: portalAddress.toString() as `0x${string}`, - abi: NFTPortal.abi, - functionName: "initialize", - args: [registryAddress, nftAddress.toString(), l2Bridge.address.toString()], -}); -await l1Client.waitForTransactionReceipt({ hash: initHash }); - -console.log("Portal initialized\n"); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L79-L93 - - -The L2 contracts were already initialized when you deployed them, but you still need to: - -- Tell the L2 bridge about Ethereum's portal address (by calling `set_portal` on the bridge) -- Tell the L2 NFT contract who the minter is (by calling `set_minter` on the L2 NFT contract) - -Complete these initialization steps: - -```typescript title="initialize_l2_bridge" showLineNumbers -console.log("Setting up L2 bridge..."); - -await l2Bridge.methods - .set_portal(EthAddress.fromString(portalAddress.toString())) - .send({ from: account.address }); - -await l2Nft.methods - .set_minter(l2Bridge.address) - .send({ from: account.address }); - -console.log("Bridge configured\n"); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L95-L107 - - -This completes the setup. It's a lot of configuration, but you're dealing with four contracts across two chains. - -### L1 → L2 Flow - -Now for the main flow. Mint a CryptoPunk on L1, deposit it to Aztec, and claim it on Aztec. Put everything in the same script. To mint, call the L1 contract with `mint`, which will mint `tokenId = 0`: - -```typescript title="mint_nft_l1" showLineNumbers -console.log("Minting NFT on L1..."); - -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const mintHash = await l1Client.writeContract({ - address: nftAddress.toString() as `0x${string}`, - abi: SimpleNFT.abi, - functionName: "mint", - args: [ownerEthAddress], -}); -await l1Client.waitForTransactionReceipt({ hash: mintHash }); - -// no need to parse logs, this will be tokenId 0 since it's a fresh contract -const tokenId = 0n; - -console.log(`Minted tokenId: ${tokenId}\n`); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L109-L125 - - -To bridge, first approve the portal address to transfer the NFT, then transfer it by calling `depositToAztec`: - -```typescript title="deposit_to_aztec" showLineNumbers -console.log("Depositing NFT to Aztec..."); - -const secret = Fr.random(); -const secretHash = await computeSecretHash(secret); - -// Approve portal to transfer the NFT -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const approveHash = await l1Client.writeContract({ - address: nftAddress.toString() as `0x${string}`, - abi: SimpleNFT.abi, - functionName: "approve", - args: [portalAddress.toString(), tokenId], -}); -await l1Client.waitForTransactionReceipt({ hash: approveHash }); - -// Deposit to Aztec -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const depositHash = await l1Client.writeContract({ - address: portalAddress.toString() as `0x${string}`, - abi: NFTPortal.abi, - functionName: "depositToAztec", - args: [ - tokenId, - pad(secretHash.toString() as `0x${string}`, { dir: "left", size: 32 }), - ], -}); -const depositReceipt = await l1Client.waitForTransactionReceipt({ - hash: depositHash, -}); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L127-L157 - - -The `Inbox` contract will emit an important log: `MessageSent(inProgress, index, leaf, updatedRollingHash);`. This log provides the **leaf index** of the message in the [L1-L2 Message Tree](../../foundational-topics/ethereum-aztec-messaging/index.md)—the location of the message in the tree that will appear on L2. You need this index, plus the secret, to correctly claim and decrypt the message. - -Use viem to extract this information: - -```typescript title="get_message_leaf_index" showLineNumbers -const INBOX_ABI = [ - { - type: "event", - name: "MessageSent", - inputs: [ - { name: "checkpointNumber", type: "uint256", indexed: true }, - { name: "index", type: "uint256", indexed: false }, - { name: "hash", type: "bytes32", indexed: true }, - { name: "rollingHash", type: "bytes16", indexed: false }, - ], - }, -] as const; - -// Find and decode the MessageSent event from the Inbox contract -const messageSentLogs = depositReceipt.logs - .filter((log) => log.address.toLowerCase() === inboxAddress.toLowerCase()) - .map((log: any) => { - try { - const decoded = decodeEventLog({ - abi: INBOX_ABI, - data: log.data, - topics: log.topics, - }); - return { log, decoded }; - } catch { - // Not a decodable event from this ABI - return null; - } - }) - .filter( - (item): item is { log: any; decoded: any } => - item !== null && (item.decoded as any).eventName === "MessageSent", - ); - -const messageLeafIndex = new Fr(messageSentLogs[0].decoded.args.index); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L159-L195 - - -This extracts the logs from the deposit and retrieves the leaf index. You can now claim it on L2. However, for security reasons, at least 2 blocks must pass before a message can be claimed on L2. If you called `claim` on the L2 contract immediately, it would return "no message available". - -Add a utility function to mine two blocks (it deploys a contract with a random salt): - -```typescript title="mine_blocks" showLineNumbers -async function mine2Blocks( - aztecWallet: EmbeddedWallet, - accountAddress: AztecAddress, -) { - await NFTPunkContract.deploy(aztecWallet, accountAddress).send({ - from: accountAddress, - contractAddressSalt: Fr.random(), - }); - await NFTPunkContract.deploy(aztecWallet, accountAddress).send({ - from: accountAddress, - contractAddressSalt: Fr.random(), - }); -} -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L197-L211 - - -Now claim the message on L2: - -```typescript title="claim_on_l2" showLineNumbers -// Mine blocks -await mine2Blocks(aztecWallet, account.address); - -// Check notes before claiming (should be 0) -console.log("Checking notes before claim..."); -const { result: notesBefore } = await l2Nft.methods - .notes_of(account.address) - .simulate({ from: account.address }); -console.log(` Notes count: ${notesBefore}`); - -console.log("Claiming NFT on L2..."); -await l2Bridge.methods - .claim(account.address, new Fr(Number(tokenId)), secret, messageLeafIndex) - .send({ from: account.address }); -console.log("NFT claimed on L2\n"); - -// Check notes after claiming (should be 1) -console.log("Checking notes after claim..."); -const { result: notesAfterClaim } = await l2Nft.methods - .notes_of(account.address) - .simulate({ from: account.address }); -console.log(` Notes count: ${notesAfterClaim}\n`); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L213-L236 - - -### L2 → L1 Flow - -Great! You can expand the L2 contract to add features like NFT transfers. For now, exit the NFT on L2 and redeem it on L1. Mine two blocks because of `DelayedMutable`: - -```typescript title="exit_from_l2" showLineNumbers -// L2 -> L1 flow -console.log("Exiting NFT from L2..."); -// Mine blocks, not necessary on devnet, but must wait for 2 blocks -await mine2Blocks(aztecWallet, account.address); - -const recipientEthAddress = EthAddress.fromString(ownerEthAddress); - -const { receipt: exitReceipt } = await l2Bridge.methods - .exit(new Fr(Number(tokenId)), recipientEthAddress) - .send({ from: account.address }); - -console.log(`Exit message sent (block: ${exitReceipt.blockNumber})\n`); - -// Check notes after burning (should be 0 again) -console.log("Checking notes after burn..."); -const { result: notesAfterBurn } = await l2Nft.methods - .notes_of(account.address) - .simulate({ from: account.address }); -console.log(` Notes count: ${notesAfterBurn}\n`); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L238-L258 - - -Just like in the L1 → L2 flow, you need to know what to claim on L1. Where in the message tree is the message you want to claim? Use the utility `computeL2ToL1MembershipWitness`, which provides the leaf and the sibling path of the message: - -```typescript title="get_withdrawal_witness" showLineNumbers -// Compute the message hash directly from known parameters -// This matches what the portal contract expects: Hash.sha256ToField(abi.encodePacked(tokenId, recipient)) -const tokenIdBuffer = new Fr(Number(tokenId)).toBuffer(); -const recipientBuffer = Buffer.from( - recipientEthAddress.toString().slice(2), - "hex", -); -const content = sha256ToField([tokenIdBuffer, recipientBuffer]); - -// Get rollup version from the portal contract (it stores it during initialize) -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const version = (await l1Client.readContract({ - address: portalAddress.toString() as `0x${string}`, - abi: NFTPortal.abi, - functionName: "rollupVersion", -})) as bigint; - -// Compute the L2->L1 message hash -const msgLeaf = computeL2ToL1MessageHash({ - l2Sender: l2Bridge.address, - l1Recipient: EthAddress.fromString(portalAddress.toString()), - content, - rollupVersion: new Fr(version), - chainId: new Fr(foundry.id), -}); - -// Wait for the block to be proven before withdrawing -// Waiting for the block to be proven is not necessary on the local network, but it is necessary on devnet -console.log("Waiting for block to be proven..."); -console.log(` Exit block number: ${exitReceipt.blockNumber}`); - -let provenBlockNumber = await node.getProvenBlockNumber(); -console.log(` Current proven block: ${provenBlockNumber}`); - -while (provenBlockNumber < exitReceipt.blockNumber!) { - console.log( - ` Waiting... (proven: ${provenBlockNumber}, needed: ${exitReceipt.blockNumber})`, - ); - await new Promise((resolve) => setTimeout(resolve, 10000)); // Wait 10 seconds - provenBlockNumber = await node.getProvenBlockNumber(); -} - -console.log("Block proven!\n"); - -// Compute the membership witness using the message hash and the L2 tx hash -const witness = await computeL2ToL1MembershipWitness(node, msgLeaf, exitReceipt.txHash); -const epoch = witness!.epochNumber; -console.log(` Epoch for block ${exitReceipt.blockNumber}: ${epoch}`); - -const siblingPathHex = witness!.siblingPath - .toBufferArray() - .map((buf: Buffer) => `0x${buf.toString("hex")}` as `0x${string}`); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L260-L313 - - -With this information, call the L1 contract and use the index and the sibling path to claim the L1 NFT: - -```typescript title="withdraw_on_l1" showLineNumbers -console.log("Withdrawing NFT on L1..."); -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const withdrawHash = await l1Client.writeContract({ - address: portalAddress.toString() as `0x${string}`, - abi: NFTPortal.abi, - functionName: "withdraw", - args: [tokenId, BigInt(epoch), BigInt(witness!.leafIndex), siblingPathHex], -}); -await l1Client.waitForTransactionReceipt({ hash: withdrawHash }); -console.log("NFT withdrawn to L1\n"); -``` -> Source code: docs/examples/ts/token_bridge/index.ts#L315-L326 - - -You can now try the whole flow with: - -```typescript -npx hardhat run scripts/index.ts --network localhost -``` - -## What You Built - -A complete private NFT bridge with: - -1. **L1 Contracts** (Solidity) - - `SimpleNFT`: Basic ERC721 for testing - - `NFTPortal`: Locks/unlocks NFTs and handles L1↔L2 messaging - -2. **L2 Contracts** (Noir) - - `NFTPunk`: Private NFT with encrypted ownership using `PrivateSet` - - `NFTBridge`: Claims L1 messages and mints NFTs privately - -3. **Full Flow** - - Mint NFT on L1 - - Deploy portal and bridge - - Lock NFT on L1 → message sent to L2 - - Claim on L2 → private NFT minted - - Later: Burn on L2 → message to L1 → unlock - -## Next Steps - -- Add a web frontend for easy bridging -- Implement batch bridging for multiple NFTs -- Add metadata bridging -- Write comprehensive tests -- Add proper access controls - -:::tip Learn More - -- [State management page](../../foundational-topics/state_management.md) -- [Cross-chain messaging](../../foundational-topics/ethereum-aztec-messaging/index.md) - ::: diff --git a/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md b/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md index 8f0e98194fb0..77e26bf0afa9 100644 --- a/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md +++ b/docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md @@ -35,15 +35,9 @@ Your structure should look like this: | |-Nargo.toml ``` -<<<<<<< HEAD -The file `main.nr` will soon turn into our smart contract! - -Add the following dependencies to `Nargo.toml` under the autogenerated content: -======= The `aztec new` command creates a contract project with `Nargo.toml` and `src/main.nr`. The file `src/main.nr` will soon turn into our smart contract! Add the following dependency to `Nargo.toml` under the existing `aztec` dependency: ->>>>>>> 49e2563fc3 (fix(docs): update CLI commands, ABI fields, and tutorial fixes) ```toml [dependencies] diff --git a/docs/examples/ts/example_swap/index.ts b/docs/examples/ts/example_swap/index.ts deleted file mode 100644 index f8e30c7c9a1b..000000000000 --- a/docs/examples/ts/example_swap/index.ts +++ /dev/null @@ -1,593 +0,0 @@ -// docs:start:setup -import { getInitialTestAccountsData } from "@aztec/accounts/testing"; -import { AztecAddress, EthAddress } from "@aztec/aztec.js/addresses"; -import { SetPublicAuthwitContractInteraction } from "@aztec/aztec.js/authorization"; -import { Fr } from "@aztec/aztec.js/fields"; -import { createAztecNodeClient, waitForNode } from "@aztec/aztec.js/node"; -import { createExtendedL1Client } from "@aztec/ethereum/client"; -import { deployL1Contract } from "@aztec/ethereum/deploy-l1-contract"; -import { sha256ToField } from "@aztec/foundation/crypto/sha256"; -import { TokenContract } from "@aztec/noir-contracts.js/Token"; -import { TokenBridgeContract } from "@aztec/noir-contracts.js/TokenBridge"; -import { - computeL2ToL1MessageHash, - computeSecretHash, -} from "@aztec/stdlib/hash"; -import { computeL2ToL1MembershipWitness } from "@aztec/stdlib/messaging"; -import { decodeEventLog, encodeFunctionData, pad } from "@aztec/viem"; -import { EmbeddedWallet } from "@aztec/wallets/embedded"; -import { foundry } from "@aztec/viem/chains"; -import ExampleERC20 from "../../../target/solidity/example_swap/ExampleERC20.sol/ExampleERC20.json" with { type: "json" }; -import ExampleTokenPortal from "../../../target/solidity/example_swap/ExampleTokenPortal.sol/ExampleTokenPortal.json" with { type: "json" }; -import ExampleUniswapPortal from "../../../target/solidity/example_swap/ExampleUniswapPortal.sol/ExampleUniswapPortal.json" with { type: "json" }; -import { ExampleUniswapContract } from "./artifacts/ExampleUniswap.js"; - -// Setup L1 client -const MNEMONIC = "test test test test test test test test test test test junk"; -const l1RpcUrl = process.env.ETHEREUM_HOST ?? "http://localhost:8545"; -const l1Client = createExtendedL1Client([l1RpcUrl], MNEMONIC); -const ownerEthAddress = l1Client.account.address; - -// Setup L2 client -console.log("Setting up L2...\n"); -const nodeUrl = process.env.AZTEC_NODE_URL ?? "http://localhost:8080"; -const node = createAztecNodeClient(nodeUrl); -await waitForNode(node); -const wallet = await EmbeddedWallet.create(node, { ephemeral: true }); -const [accData] = await getInitialTestAccountsData(); -const account = await wallet.createSchnorrAccount( - accData.secret, - accData.salt, - accData.signingKey, -); -console.log(`Account: ${account.address.toString()}\n`); - -const nodeInfo = await node.getNodeInfo(); -const registryAddress = - nodeInfo.l1ContractAddresses.registryAddress.toString(); -const inboxAddress = nodeInfo.l1ContractAddresses.inboxAddress.toString(); -// docs:end:setup - -// docs:start:deploy_l1 -console.log("Deploying L1 contracts...\n"); - -// Deploy two ERC20 tokens: WETH (input) and DAI (output) -const { address: wethAddress } = await deployL1Contract( - l1Client, - ExampleERC20.abi, - ExampleERC20.bytecode.object as `0x${string}`, - ["Wrapped Ether", "WETH"], -); - -const { address: daiAddress } = await deployL1Contract( - l1Client, - ExampleERC20.abi, - ExampleERC20.bytecode.object as `0x${string}`, - ["Dai Stablecoin", "DAI"], -); - -// Deploy two token portals (one per token) -const { address: wethPortalAddress } = await deployL1Contract( - l1Client, - ExampleTokenPortal.abi, - ExampleTokenPortal.bytecode.object as `0x${string}`, -); - -const { address: daiPortalAddress } = await deployL1Contract( - l1Client, - ExampleTokenPortal.abi, - ExampleTokenPortal.bytecode.object as `0x${string}`, -); - -// Deploy the uniswap portal -const { address: uniswapPortalAddress } = await deployL1Contract( - l1Client, - ExampleUniswapPortal.abi, - ExampleUniswapPortal.bytecode.object as `0x${string}`, -); - -console.log(`WETH: ${wethAddress}`); -console.log(`DAI: ${daiAddress}`); -console.log(`WETH Portal: ${wethPortalAddress}`); -console.log(`DAI Portal: ${daiPortalAddress}`); -console.log(`Uniswap Portal: ${uniswapPortalAddress}\n`); -// docs:end:deploy_l1 - -// docs:start:deploy_l2 -console.log("Deploying L2 contracts...\n"); - -// Deploy L2 tokens (using the standard TokenContract from @aztec/noir-contracts.js) -const { contract: l2Weth } = await TokenContract.deploy( - wallet, - account.address, - "Wrapped Ether", - "WETH", - 18, -).send({ from: account.address }); - -const { contract: l2Dai } = await TokenContract.deploy( - wallet, - account.address, - "Dai Stablecoin", - "DAI", - 18, -).send({ from: account.address }); - -// Deploy L2 token bridges -const { contract: l2WethBridge } = await TokenBridgeContract.deploy( - wallet, - l2Weth.address, - wethPortalAddress, -).send({ from: account.address }); - -const { contract: l2DaiBridge } = await TokenBridgeContract.deploy( - wallet, - l2Dai.address, - daiPortalAddress, -).send({ from: account.address }); - -// Deploy L2 uniswap contract -const { contract: l2Uniswap } = await ExampleUniswapContract.deploy( - wallet, - EthAddress.fromString(uniswapPortalAddress.toString()), -).send({ from: account.address }); - -console.log(`L2 WETH: ${l2Weth.address}`); -console.log(`L2 DAI: ${l2Dai.address}`); -console.log(`L2 WETH Bridge: ${l2WethBridge.address}`); -console.log(`L2 DAI Bridge: ${l2DaiBridge.address}`); -console.log(`L2 Uniswap: ${l2Uniswap.address}\n`); -// docs:end:deploy_l2 - -// docs:start:initialize -console.log("Initializing contracts...\n"); - -// Make bridges minters on their respective tokens -await l2Weth.methods - .set_minter(l2WethBridge.address, true) - .send({ from: account.address }); -await l2Dai.methods - .set_minter(l2DaiBridge.address, true) - .send({ from: account.address }); - -// Initialize L1 portals with registry, underlying token, and L2 bridge addresses -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const initWethPortal = await l1Client.writeContract({ - address: wethPortalAddress.toString() as `0x${string}`, - abi: ExampleTokenPortal.abi, - functionName: "initialize", - args: [ - registryAddress, - wethAddress.toString(), - l2WethBridge.address.toString(), - ], -}); -await l1Client.waitForTransactionReceipt({ hash: initWethPortal }); - -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const initDaiPortal = await l1Client.writeContract({ - address: daiPortalAddress.toString() as `0x${string}`, - abi: ExampleTokenPortal.abi, - functionName: "initialize", - args: [ - registryAddress, - daiAddress.toString(), - l2DaiBridge.address.toString(), - ], -}); -await l1Client.waitForTransactionReceipt({ hash: initDaiPortal }); - -// Initialize uniswap portal -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const initUniswapPortal = await l1Client.writeContract({ - address: uniswapPortalAddress.toString() as `0x${string}`, - abi: ExampleUniswapPortal.abi, - functionName: "initialize", - args: [registryAddress, l2Uniswap.address.toString()], -}); -await l1Client.waitForTransactionReceipt({ hash: initUniswapPortal }); - -console.log("All contracts initialized\n"); -// docs:end:initialize - -// docs:start:fund -console.log("Funding accounts...\n"); - -const SWAP_AMOUNT = 100n * 10n ** 18n; // 100 tokens - -// Mint WETH on L1 for the user -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const mintWethHash = await l1Client.writeContract({ - address: wethAddress.toString() as `0x${string}`, - abi: ExampleERC20.abi, - functionName: "mint", - args: [ownerEthAddress, SWAP_AMOUNT], -}); -await l1Client.waitForTransactionReceipt({ hash: mintWethHash }); - -// Pre-fund the uniswap portal with DAI (for the mock 1:1 swap) -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const mintDaiHash = await l1Client.writeContract({ - address: daiAddress.toString() as `0x${string}`, - abi: ExampleERC20.abi, - functionName: "mint", - args: [uniswapPortalAddress.toString(), SWAP_AMOUNT * 2n], -}); -await l1Client.waitForTransactionReceipt({ hash: mintDaiHash }); - -console.log(`Minted ${SWAP_AMOUNT} WETH to user`); -console.log(`Pre-funded uniswap portal with ${SWAP_AMOUNT * 2n} DAI\n`); -// docs:end:fund - -// docs:start:deposit_to_l2 -console.log("Depositing WETH to Aztec (L1 -> L2)...\n"); - -const depositSecret = Fr.random(); -const depositSecretHash = await computeSecretHash(depositSecret); - -// Approve WETH portal to take tokens -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const approveHash = await l1Client.writeContract({ - address: wethAddress.toString() as `0x${string}`, - abi: ExampleERC20.abi, - functionName: "approve", - args: [wethPortalAddress.toString(), SWAP_AMOUNT], -}); -await l1Client.waitForTransactionReceipt({ hash: approveHash }); - -// Deposit to Aztec publicly -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const depositHash = await l1Client.writeContract({ - address: wethPortalAddress.toString() as `0x${string}`, - abi: ExampleTokenPortal.abi, - functionName: "depositToAztecPublic", - args: [ - account.address.toString(), - SWAP_AMOUNT, - pad(depositSecretHash.toString() as `0x${string}`, { - dir: "left", - size: 32, - }), - ], -}); -const depositReceipt = await l1Client.waitForTransactionReceipt({ - hash: depositHash, -}); - -// Extract message leaf index from Inbox event -const INBOX_ABI = [ - { - type: "event", - name: "MessageSent", - inputs: [ - { name: "checkpointNumber", type: "uint256", indexed: true }, - { name: "index", type: "uint256", indexed: false }, - { name: "hash", type: "bytes32", indexed: true }, - { name: "rollingHash", type: "bytes16", indexed: false }, - ], - }, -] as const; - -const messageSentLogs = depositReceipt.logs - .filter((log) => log.address.toLowerCase() === inboxAddress.toLowerCase()) - .map((log: any) => { - try { - const decoded = decodeEventLog({ - abi: INBOX_ABI, - data: log.data, - topics: log.topics, - }); - return { log, decoded }; - } catch { - return null; - } - }) - .filter( - (item): item is { log: any; decoded: any } => - item !== null && (item.decoded as any).eventName === "MessageSent", - ); - -if (messageSentLogs.length === 0) { - throw new Error("No MessageSent events found in deposit transaction"); -} -const depositLeafIndex = new Fr(messageSentLogs[0].decoded.args.index); -console.log(`Deposit message leaf index: ${depositLeafIndex}\n`); -// docs:end:deposit_to_l2 - -// docs:start:mine_blocks -// Utility: mine 2 blocks (required before L1->L2 messages can be consumed) -async function mine2Blocks( - wallet: EmbeddedWallet, - accountAddress: AztecAddress, -) { - await TokenContract.deploy(wallet, accountAddress, "T", "T", 18).send({ - from: accountAddress, - contractAddressSalt: Fr.random(), - }); - await TokenContract.deploy(wallet, accountAddress, "T", "T", 18).send({ - from: accountAddress, - contractAddressSalt: Fr.random(), - }); -} -// docs:end:mine_blocks - -// docs:start:claim_on_l2 -console.log("Claiming WETH on L2...\n"); - -await mine2Blocks(wallet, account.address); - -await l2WethBridge.methods - .claim_public(account.address, SWAP_AMOUNT, depositSecret, depositLeafIndex) - .send({ from: account.address }); - -const { result: wethBalanceBefore } = await l2Weth.methods - .balance_of_public(account.address) - .simulate({ from: account.address }); -console.log(`L2 WETH balance after claim: ${wethBalanceBefore}\n`); -if (wethBalanceBefore !== SWAP_AMOUNT) { - throw new Error(`Expected WETH balance ${SWAP_AMOUNT}, got ${wethBalanceBefore}`); -} -console.log("✓ WETH claimed successfully on L2\n"); -// docs:end:claim_on_l2 - -// docs:start:public_swap -console.log("=== PUBLIC SWAP FLOW ===\n"); -console.log("Initiating public swap on L2 (WETH -> DAI)...\n"); - -// Force L2 block production so the claim message is included in a block before the swap -await mine2Blocks(wallet, account.address); - -const swapSecret = Fr.random(); -const swapSecretHash = await computeSecretHash(swapSecret); - -// Create authwit for the uniswap contract to transfer WETH on our behalf -const transferAction = l2Weth.methods.transfer_in_public( - account.address, - l2Uniswap.address, - SWAP_AMOUNT, - 0xdeadbeefn, -); -const authwit = await SetPublicAuthwitContractInteraction.create( - wallet, - account.address, - { caller: l2Uniswap.address, action: transferAction }, - true, -); -await authwit.send(); - -// Call swap_public on the L2 uniswap contract -const { receipt: swapReceipt } = await l2Uniswap.methods - .swap_public( - account.address, - l2WethBridge.address, - SWAP_AMOUNT, - l2DaiBridge.address, - 3000n, // fee tier - 0n, // minimum output - account.address, // recipient - swapSecretHash, - ) - .send({ from: account.address }); - -console.log(`Swap tx sent (block: ${swapReceipt.blockNumber})\n`); - -// Verify WETH was spent (balance should be 0 after swap) -const { result: wethAfterSwap } = await l2Weth.methods - .balance_of_public(account.address) - .simulate({ from: account.address }); -if (wethAfterSwap !== 0n) { - throw new Error(`Expected WETH balance 0 after swap, got ${wethAfterSwap}`); -} -console.log("✓ WETH transferred to bridge for swap\n"); -// docs:end:public_swap - -// docs:start:wait_for_proof -console.log("Waiting for block to be proven...\n"); - -let provenBlockNumber = await node.getProvenBlockNumber(); -while (provenBlockNumber < swapReceipt.blockNumber!) { - console.log( - ` Waiting... (proven: ${provenBlockNumber}, needed: ${swapReceipt.blockNumber})`, - ); - await new Promise((resolve) => setTimeout(resolve, 10000)); - provenBlockNumber = await node.getProvenBlockNumber(); -} - -console.log("Block proven!\n"); -// docs:end:wait_for_proof - -// docs:start:consume_l1_messages_setup -console.log("Consuming L2->L1 messages on L1...\n"); - -// The swap generates 2 L2->L1 messages: -// 1. Token bridge exit (withdraw WETH to uniswap portal) -// 2. Uniswap swap intent - -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const portalRollupVersion = (await l1Client.readContract({ - address: wethPortalAddress.toString() as `0x${string}`, - abi: ExampleTokenPortal.abi, - functionName: "rollupVersion", -})) as bigint; - -// Compute message 1: token bridge exit -// Encode using the same approach as Solidity's abi.encodeWithSignature("withdraw(address,uint256,address)", ...) -const withdrawContentEncoded = encodeFunctionData({ - abi: [ - { - name: "withdraw", - type: "function", - inputs: [ - { name: "", type: "address" }, - { name: "", type: "uint256" }, - { name: "", type: "address" }, - ], - outputs: [], - }, - ], - args: [ - uniswapPortalAddress.toString() as `0x${string}`, - SWAP_AMOUNT, - uniswapPortalAddress.toString() as `0x${string}`, - ], -}); -const withdrawContentHash = sha256ToField([ - Buffer.from(withdrawContentEncoded.slice(2), "hex"), -]); - -// Message 1: Token bridge exit message -const exitMsgLeaf = computeL2ToL1MessageHash({ - l2Sender: l2WethBridge.address, - l1Recipient: wethPortalAddress, - content: withdrawContentHash, - rollupVersion: new Fr(portalRollupVersion), - chainId: new Fr(foundry.id), -}); -// docs:end:consume_l1_messages_setup - -// docs:start:consume_l1_messages_witnesses -const exitWitness = await computeL2ToL1MembershipWitness( - node, - exitMsgLeaf, - swapReceipt.txHash, -); -const exitSiblingPath = exitWitness!.siblingPath - .toBufferArray() - .map((buf: Buffer) => `0x${buf.toString("hex")}` as `0x${string}`); - -// Message 2: Uniswap swap intent message -// Compute using the same encoding as ExampleUniswapPortal.sol -const swapContentEncoded = encodeFunctionData({ - abi: [ - { - name: "swap_public", - type: "function", - inputs: [ - { name: "", type: "address" }, - { name: "", type: "uint256" }, - { name: "", type: "uint24" }, - { name: "", type: "address" }, - { name: "", type: "uint256" }, - { name: "", type: "bytes32" }, - { name: "", type: "bytes32" }, - ], - outputs: [], - }, - ], - args: [ - wethPortalAddress.toString() as `0x${string}`, - SWAP_AMOUNT, - 3000, - daiPortalAddress.toString() as `0x${string}`, - 0n, - account.address.toString() as `0x${string}`, - pad(swapSecretHash.toString() as `0x${string}`, { - dir: "left", - size: 32, - }), - ], -}); - -const swapContentHash = sha256ToField([Buffer.from(swapContentEncoded.slice(2), "hex")]); - -const swapMsgLeaf = computeL2ToL1MessageHash({ - l2Sender: l2Uniswap.address, - l1Recipient: uniswapPortalAddress, - content: swapContentHash, - rollupVersion: new Fr(portalRollupVersion), - chainId: new Fr(foundry.id), -}); - -const swapWitness = await computeL2ToL1MembershipWitness( - node, - swapMsgLeaf, - swapReceipt.txHash, -); -const swapSiblingPath = swapWitness!.siblingPath - .toBufferArray() - .map((buf: Buffer) => `0x${buf.toString("hex")}` as `0x${string}`); -// docs:end:consume_l1_messages_witnesses - -// docs:start:consume_l1_messages_execute -// Execute the swap on L1 (consumes both messages) -// @ts-expect-error - viem type inference doesn't work with JSON-imported ABIs -const l1SwapHash = await l1Client.writeContract({ - address: uniswapPortalAddress.toString() as `0x${string}`, - abi: ExampleUniswapPortal.abi, - functionName: "swapPublic", - args: [ - wethPortalAddress.toString(), - SWAP_AMOUNT, - 3000, - daiPortalAddress.toString(), - 0n, - account.address.toString(), - pad(swapSecretHash.toString() as `0x${string}`, { - dir: "left", - size: 32, - }), - [BigInt(exitWitness!.epochNumber), BigInt(swapWitness!.epochNumber)], - [BigInt(exitWitness!.leafIndex), BigInt(swapWitness!.leafIndex)], - [exitSiblingPath, swapSiblingPath], - ], -}); -const l1SwapReceipt = await l1Client.waitForTransactionReceipt({ - hash: l1SwapHash, -}); -console.log(`L1 swap executed! Tx: ${l1SwapHash}\n`); -// docs:end:consume_l1_messages_execute - -// docs:start:claim_output -console.log("Claiming DAI output on L2...\n"); - -// Extract the deposit message leaf index from the L1 swap receipt -const daiDepositLogs = l1SwapReceipt.logs - .filter((log) => log.address.toLowerCase() === inboxAddress.toLowerCase()) - .map((log: any) => { - try { - const decoded = decodeEventLog({ - abi: INBOX_ABI, - data: log.data, - topics: log.topics, - }); - return { log, decoded }; - } catch { - return null; - } - }) - .filter( - (item): item is { log: any; decoded: any } => - item !== null && (item.decoded as any).eventName === "MessageSent", - ); - -if (daiDepositLogs.length === 0) { - throw new Error("No MessageSent events found in L1 swap transaction"); -} -const daiDepositLeafIndex = new Fr(daiDepositLogs[0].decoded.args.index); - -// Mine blocks and claim -await mine2Blocks(wallet, account.address); - -await l2DaiBridge.methods - .claim_public(account.address, SWAP_AMOUNT, swapSecret, daiDepositLeafIndex) - .send({ from: account.address }); - -const { result: daiBalance } = await l2Dai.methods - .balance_of_public(account.address) - .simulate({ from: account.address }); - -const { result: wethBalanceAfter } = await l2Weth.methods - .balance_of_public(account.address) - .simulate({ from: account.address }); - -console.log(`L2 WETH balance: ${wethBalanceAfter}`); -console.log(`L2 DAI balance: ${daiBalance}`); - -if (wethBalanceAfter !== 0n) { - throw new Error(`Expected final WETH balance 0, got ${wethBalanceAfter}`); -} -if (daiBalance !== SWAP_AMOUNT) { - throw new Error(`Expected DAI balance ${SWAP_AMOUNT}, got ${daiBalance}`); -} -console.log("\n✓ All checks passed — public swap complete!\n"); -// docs:end:claim_output