diff --git a/.circleci/config.yml b/.circleci/config.yml index 89d875c64f18..c84aa80a44fc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -737,6 +737,17 @@ jobs: name: "Test" command: cond_run_script end-to-end ./scripts/run_tests_local e2e_token_contract.test.ts + e2e-private-airdrop: + machine: + image: ubuntu-2004:202010-01 + resource_class: large + steps: + - *checkout + - *setup_env + - run: + name: "Test" + command: cond_run_script end-to-end ./scripts/run_tests_local e2e_private_airdrop.test.ts + e2e-private-token-contract: machine: image: ubuntu-2004:202010-01 @@ -1459,6 +1470,7 @@ workflows: - e2e-deploy-contract: *e2e_test - e2e-lending-contract: *e2e_test - e2e-token-contract: *e2e_test + - e2e-private-airdrop: *e2e_test - e2e-private-token-contract: *e2e_test - e2e-sandbox-example: *e2e_test - e2e-multi-transfer-contract: *e2e_test @@ -1494,6 +1506,7 @@ workflows: - e2e-deploy-contract - e2e-lending-contract - e2e-token-contract + - e2e-private-airdrop - e2e-private-token-contract - e2e-sandbox-example - e2e-multi-transfer-contract diff --git a/yarn-project/acir-simulator/src/client/simulator.ts b/yarn-project/acir-simulator/src/client/simulator.ts index 3bf33e48aa62..f00be81213ed 100644 --- a/yarn-project/acir-simulator/src/client/simulator.ts +++ b/yarn-project/acir-simulator/src/client/simulator.ts @@ -181,7 +181,7 @@ export class AcirSimulator { let abi: FunctionAbiWithDebugMetadata | undefined = undefined; // Brute force - for (let i = 0; i < MAX_NOTE_FIELDS_LENGTH; i++) { + for (let i = notePreimage.length; i < MAX_NOTE_FIELDS_LENGTH; i++) { const signature = `compute_note_hash_and_nullifier(Field,Field,Field,[Field;${i}])`; const selector = FunctionSelector.fromSignature(signature); try { diff --git a/yarn-project/aztec-rpc/src/aztec_rpc_server/aztec_rpc_server.ts b/yarn-project/aztec-rpc/src/aztec_rpc_server/aztec_rpc_server.ts index b374ea7aafcf..cd51cd006c27 100644 --- a/yarn-project/aztec-rpc/src/aztec_rpc_server/aztec_rpc_server.ts +++ b/yarn-project/aztec-rpc/src/aztec_rpc_server/aztec_rpc_server.ts @@ -1,4 +1,5 @@ import { + AcirSimulator, ExecutionResult, collectEncryptedLogs, collectEnqueuedPublicFunctionCalls, @@ -7,6 +8,7 @@ import { } from '@aztec/acir-simulator'; import { AztecAddress, + CircuitsWasm, CompleteAddress, EthAddress, FunctionData, @@ -16,6 +18,7 @@ import { PartialAddress, PublicCallRequest, } from '@aztec/circuits.js'; +import { computeCommitmentNonce } from '@aztec/circuits.js/abis'; import { encodeArguments } from '@aztec/foundation/abi'; import { padArrayEnd } from '@aztec/foundation/collection'; import { Fr, Point } from '@aztec/foundation/fields'; @@ -36,6 +39,7 @@ import { L2BlockL2Logs, LogType, NodeInfo, + NotePreimage, SimulationError, Tx, TxExecutionRequest, @@ -61,6 +65,8 @@ import { Synchroniser } from '../synchroniser/index.js'; */ export class AztecRPCServer implements AztecRPC { private synchroniser: Synchroniser; + private contractDataOracle: ContractDataOracle; + private simulator: AcirSimulator; private log: DebugLogger; private clientInfo: string; @@ -73,6 +79,8 @@ export class AztecRPCServer implements AztecRPC { ) { this.log = createDebugLogger(logSuffix ? `aztec:rpc_server_${logSuffix}` : `aztec:rpc_server`); this.synchroniser = new Synchroniser(node, db, logSuffix); + this.contractDataOracle = new ContractDataOracle(db, node); + this.simulator = getAcirSimulator(db, node, node, node, keyStore, this.contractDataOracle); const { version, name } = getPackageInfo(); this.clientInfo = `${name.split('/')[name.split('/').length - 1]}@${version}`; @@ -190,6 +198,40 @@ export class AztecRPCServer implements AztecRPC { return ownerNotes.map(n => n.notePreimage); } + public async getNoteNonces( + contractAddress: AztecAddress, + storageSlot: Fr, + preimage: NotePreimage, + txHash: TxHash, + ): Promise { + const tx = await this.node.getTx(txHash); + if (!tx) { + throw new Error(`Unknown tx: ${txHash}`); + } + + const wasm = await CircuitsWasm.get(); + + const nonces: Fr[] = []; + const firstNullifier = tx.newNullifiers[0]; + const commitments = tx.newCommitments; + for (let i = 0; i < commitments.length; ++i) { + const commitment = commitments[i]; + if (commitment.equals(Fr.ZERO)) break; + + const nonce = computeCommitmentNonce(wasm, firstNullifier, i); + const { uniqueSiloedNoteHash } = await this.simulator.computeNoteHashAndNullifier( + contractAddress, + nonce, + storageSlot, + preimage.items, + ); + if (commitment.equals(uniqueSiloedNoteHash)) { + nonces.push(nonce); + } + } + return nonces; + } + public async getBlock(blockNumber: number): Promise { // If a negative block number is provided the current block number is fetched. if (blockNumber < 0) { @@ -314,20 +356,21 @@ export class AztecRPCServer implements AztecRPC { /** * Retrieves the simulation parameters required to run an ACIR simulation. * This includes the contract address, function ABI, portal contract address, and historic tree roots. - * The function uses the given 'contractDataOracle' to fetch the necessary data from the node and user's database. * * @param execRequest - The transaction request object containing details of the contract call. - * @param contractDataOracle - An instance of ContractDataOracle used to fetch the necessary data. * @returns An object containing the contract address, function ABI, portal contract address, and historic tree roots. */ - async #getSimulationParameters( - execRequest: FunctionCall | TxExecutionRequest, - contractDataOracle: ContractDataOracle, - ) { + async #getSimulationParameters(execRequest: FunctionCall | TxExecutionRequest) { const contractAddress = (execRequest as FunctionCall).to ?? (execRequest as TxExecutionRequest).origin; - const functionAbi = await contractDataOracle.getFunctionAbi(contractAddress, execRequest.functionData.selector); - const debug = await contractDataOracle.getFunctionDebugMetadata(contractAddress, execRequest.functionData.selector); - const portalContract = await contractDataOracle.getPortalContractAddress(contractAddress); + const functionAbi = await this.contractDataOracle.getFunctionAbi( + contractAddress, + execRequest.functionData.selector, + ); + const debug = await this.contractDataOracle.getFunctionDebugMetadata( + contractAddress, + execRequest.functionData.selector, + ); + const portalContract = await this.contractDataOracle.getPortalContractAddress(contractAddress); return { contractAddress, @@ -339,22 +382,14 @@ export class AztecRPCServer implements AztecRPC { }; } - async #simulate(txRequest: TxExecutionRequest, contractDataOracle?: ContractDataOracle): Promise { + async #simulate(txRequest: TxExecutionRequest): Promise { // TODO - Pause syncing while simulating. - if (!contractDataOracle) { - contractDataOracle = new ContractDataOracle(this.db, this.node); - } - const { contractAddress, functionAbi, portalContract } = await this.#getSimulationParameters( - txRequest, - contractDataOracle, - ); - - const simulator = getAcirSimulator(this.db, this.node, this.node, this.node, this.keyStore, contractDataOracle); + const { contractAddress, functionAbi, portalContract } = await this.#getSimulationParameters(txRequest); this.log('Executing simulator...'); try { - const result = await simulator.run(txRequest, functionAbi, contractAddress, portalContract); + const result = await this.simulator.run(txRequest, functionAbi, contractAddress, portalContract); this.log('Simulation completed!'); return result; } catch (err) { @@ -375,18 +410,11 @@ export class AztecRPCServer implements AztecRPC { * @returns The simulation result containing the outputs of the unconstrained function. */ async #simulateUnconstrained(execRequest: FunctionCall, from?: AztecAddress) { - const contractDataOracle = new ContractDataOracle(this.db, this.node); - - const { contractAddress, functionAbi, portalContract } = await this.#getSimulationParameters( - execRequest, - contractDataOracle, - ); - - const simulator = getAcirSimulator(this.db, this.node, this.node, this.node, this.keyStore, contractDataOracle); + const { contractAddress, functionAbi, portalContract } = await this.#getSimulationParameters(execRequest); this.log('Executing unconstrained simulator...'); try { - const result = await simulator.runUnconstrained( + const result = await this.simulator.runUnconstrained( execRequest, from ?? AztecAddress.ZERO, functionAbi, @@ -419,8 +447,7 @@ export class AztecRPCServer implements AztecRPC { if (err instanceof SimulationError) { const callStack = err.getCallStack(); const originalFailingFunction = callStack[callStack.length - 1]; - const contractDataOracle = new ContractDataOracle(this.db, this.node); - const debugInfo = await contractDataOracle.getFunctionDebugMetadata( + const debugInfo = await this.contractDataOracle.getFunctionDebugMetadata( originalFailingFunction.contractAddress, originalFailingFunction.functionSelector, ); @@ -451,12 +478,10 @@ export class AztecRPCServer implements AztecRPC { async #simulateAndProve(txExecutionRequest: TxExecutionRequest, newContract: ContractDao | undefined) { // TODO - Pause syncing while simulating. - const contractDataOracle = new ContractDataOracle(this.db, this.node); - const kernelOracle = new KernelOracle(contractDataOracle, this.node); - // Get values that allow us to reconstruct the block hash - const executionResult = await this.#simulate(txExecutionRequest, contractDataOracle); + const executionResult = await this.#simulate(txExecutionRequest); + const kernelOracle = new KernelOracle(this.contractDataOracle, this.node); const kernelProver = new KernelProver(kernelOracle); this.log(`Executing kernel prover...`); const { proof, publicInputs } = await kernelProver.prove(txExecutionRequest.toTxRequest(), executionResult); diff --git a/yarn-project/aztec-rpc/src/note_processor/note_processor.test.ts b/yarn-project/aztec-rpc/src/note_processor/note_processor.test.ts index 71a18cde9f7b..063e7d05ca94 100644 --- a/yarn-project/aztec-rpc/src/note_processor/note_processor.test.ts +++ b/yarn-project/aztec-rpc/src/note_processor/note_processor.test.ts @@ -18,7 +18,7 @@ import { import { jest } from '@jest/globals'; import { MockProxy, mock } from 'jest-mock-extended'; -import { Database, MemoryDB } from '../database/index.js'; +import { Database, MemoryDB, NoteSpendingInfoDao } from '../database/index.js'; import { NoteProcessor } from './note_processor.js'; const TXS_PER_BLOCK = 4; @@ -46,9 +46,11 @@ describe('Note Processor', () => { ); // ownedData: [tx1, tx2, ...], the numbers in each tx represents the indices of the note hashes the account owns. - const createEncryptedLogsAndOwnedNoteSpendingInfo = (newNotes: NoteSpendingInfo[], ownedData: number[][]) => { - const txLogs: TxL2Logs[] = []; + const createEncryptedLogsAndOwnedNoteSpendingInfo = (ownedData: number[][], ownedNotes: NoteSpendingInfo[]) => { + const newNotes: NoteSpendingInfo[] = []; const ownedNoteSpendingInfo: NoteSpendingInfo[] = []; + const txLogs: TxL2Logs[] = []; + let usedOwnedNote = 0; for (let i = 0; i < TXS_PER_BLOCK; ++i) { const ownedDataIndices = ownedData[i] || []; if (ownedDataIndices.some(index => index >= MAX_NEW_COMMITMENTS_PER_TX)) { @@ -56,25 +58,32 @@ describe('Note Processor', () => { } const logs: FunctionL2Logs[] = []; - const notesForTx = newNotes.slice(i * MAX_NEW_COMMITMENTS_PER_TX, (i + 1) * MAX_NEW_COMMITMENTS_PER_TX); - notesForTx.forEach((note, noteIndex) => { + for (let noteIndex = 0; noteIndex < MAX_NEW_COMMITMENTS_PER_TX; ++noteIndex) { const isOwner = ownedDataIndices.includes(noteIndex); const publicKey = isOwner ? owner.getPublicKey() : Point.random(); - const log = note.toEncryptedBuffer(publicKey, grumpkin); - // 1 tx containing 1 function invocation containing 1 log - logs.push(new FunctionL2Logs([log])); + const note = (isOwner && ownedNotes[usedOwnedNote]) || NoteSpendingInfo.random(); + usedOwnedNote += note === ownedNotes[usedOwnedNote] ? 1 : 0; + newNotes.push(note); if (isOwner) { ownedNoteSpendingInfo.push(note); } - }); + const log = note.toEncryptedBuffer(publicKey, grumpkin); + // 1 tx containing 1 function invocation containing 1 log + logs.push(new FunctionL2Logs([log])); + } txLogs.push(new TxL2Logs(logs)); } const encryptedLogs = new L2BlockL2Logs(txLogs); - return { encryptedLogs, ownedNoteSpendingInfo }; + return { newNotes, ownedNoteSpendingInfo, encryptedLogs }; }; - const mockData = (ownedData: number[][], prependedBlocks = 0, appendedBlocks = 0) => { + const mockData = ( + ownedData: number[][], + prependedBlocks = 0, + appendedBlocks = 0, + ownedNotes: NoteSpendingInfo[] = [], + ) => { if (ownedData.length > TXS_PER_BLOCK) { throw new Error(`Tx size should be less than ${TXS_PER_BLOCK}.`); } @@ -87,17 +96,14 @@ describe('Note Processor', () => { const block = L2Block.random(firstBlockNum + i, TXS_PER_BLOCK); block.startPrivateDataTreeSnapshot.nextAvailableLeafIndex = firstBlockDataStartIndex + i * numCommitmentsPerBlock; - const newNotes = Array(numCommitmentsPerBlock).fill(0).map(NoteSpendingInfo.random); - - block.newCommitments = newNotes.map(n => computeMockNoteHash(n.notePreimage.items)); - const isTargetBlock = i === prependedBlocks; - const { encryptedLogs, ownedNoteSpendingInfo } = createEncryptedLogsAndOwnedNoteSpendingInfo( - newNotes, + const { newNotes, encryptedLogs, ownedNoteSpendingInfo } = createEncryptedLogsAndOwnedNoteSpendingInfo( isTargetBlock ? ownedData : [], + isTargetBlock ? ownedNotes : [], ); encryptedLogsArr.push(encryptedLogs); ownedNoteSpendingInfos.push(...ownedNoteSpendingInfo); + block.newCommitments = newNotes.map(n => computeMockNoteHash(n.notePreimage.items)); const randomBlockContext = new L2BlockContext(block); blockContexts.push(randomBlockContext); @@ -149,12 +155,14 @@ describe('Note Processor', () => { }); it('should store multiple notes that belong to us', async () => { - const prependedBlocks = 3; + const prependedBlocks = 2; + const appendedBlocks = 1; const thisBlockDataStartIndex = firstBlockDataStartIndex + prependedBlocks * numCommitmentsPerBlock; const { blockContexts, encryptedLogsArr, ownedNoteSpendingInfos } = mockData( [[], [1], [], [0, 2]], prependedBlocks, + appendedBlocks, ); await noteProcessor.process(blockContexts, encryptedLogsArr); @@ -182,4 +190,31 @@ describe('Note Processor', () => { const { blockContexts, encryptedLogsArr } = mockData([]); await noteProcessor.process(blockContexts, encryptedLogsArr); }); + + it('should be able to recover two notes with the same preimage', async () => { + const note = NoteSpendingInfo.random(); + const note2 = NoteSpendingInfo.random(); + // All notes expect one have the same contract address, storage slot, and preimage. + const notes = [note, note, note, note2, note]; + const { blockContexts, encryptedLogsArr, ownedNoteSpendingInfos } = mockData([[0, 2], [], [0, 1, 3]], 0, 0, notes); + await noteProcessor.process(blockContexts, encryptedLogsArr); + + const addedInfos: NoteSpendingInfoDao[] = addNoteSpendingInfoBatchSpy.mock.calls[0][0]; + expect(addedInfos).toEqual([ + expect.objectContaining({ ...ownedNoteSpendingInfos[0] }), + expect.objectContaining({ ...ownedNoteSpendingInfos[1] }), + expect.objectContaining({ ...ownedNoteSpendingInfos[2] }), + expect.objectContaining({ ...ownedNoteSpendingInfos[3] }), + expect.objectContaining({ ...ownedNoteSpendingInfos[4] }), + ]); + expect(ownedNoteSpendingInfos[0]).toEqual(ownedNoteSpendingInfos[1]); + expect(ownedNoteSpendingInfos[1]).toEqual(ownedNoteSpendingInfos[2]); + expect(ownedNoteSpendingInfos[2]).toEqual(ownedNoteSpendingInfos[4]); + expect(ownedNoteSpendingInfos[3]).not.toEqual(ownedNoteSpendingInfos[4]); + + // Check that every note has a different nonce. + const nonceSet = new Set(); + addedInfos.forEach(info => nonceSet.add(info.nonce.value)); + expect(nonceSet.size).toBe(notes.length); + }); }); diff --git a/yarn-project/aztec-rpc/src/note_processor/note_processor.ts b/yarn-project/aztec-rpc/src/note_processor/note_processor.ts index 5395b32e12d6..1b1f3617cc07 100644 --- a/yarn-project/aztec-rpc/src/note_processor/note_processor.ts +++ b/yarn-project/aztec-rpc/src/note_processor/note_processor.ts @@ -111,18 +111,21 @@ export class NoteProcessor { // Note: Each tx generates a `TxL2Logs` object and for this reason we can rely on its index corresponding // to the index of a tx in a block. const txFunctionLogs = txLogs[indexOfTxInABlock].functionLogs; + const excludedIndices: Set = new Set(); for (const functionLogs of txFunctionLogs) { for (const logs of functionLogs.logs) { const noteSpendingInfo = NoteSpendingInfo.fromEncryptedBuffer(logs, privateKey, curve); if (noteSpendingInfo) { // We have successfully decrypted the data. try { - const { index, nonce, innerNoteHash, siloedNullifier } = await this.findNoteIndexAndNullifier( - dataStartIndexForTx, + const { commitmentIndex, nonce, innerNoteHash, siloedNullifier } = await this.findNoteIndexAndNullifier( newCommitments, newNullifiers[0], noteSpendingInfo, + excludedIndices, ); + const index = BigInt(dataStartIndexForTx + commitmentIndex); + excludedIndices.add(commitmentIndex); noteSpendingInfoDaos.push({ ...noteSpendingInfo, nonce, @@ -158,17 +161,19 @@ export class NoteProcessor { * The nullifier is calculated using the private key of the account, * contract address, and note preimage associated with the noteSpendingInfo. * This method assists in identifying spent commitments in the private state. - * @param dataStartIndex - First index of the commitments in the tx in the private data tree. * @param commitments - Commitments in the tx. One of them should be the note's commitment. * @param firstNullifier - First nullifier in the tx. * @param noteSpendingInfo - An instance of NoteSpendingInfo containing transaction details. - * @returns A Fr instance representing the computed nullifier. + * @param excludedIndices - Indices that have been assigned a note in the same tx. Notes in a tx can have the same + * NoteSpendingInfo. We need to find a different index for each replicate. + * @returns Information for a decrypted note, including the index of its commitment, nonce, inner note + * hash, and the siloed nullifier. Throw if cannot find the nonce for the note. */ private async findNoteIndexAndNullifier( - dataStartIndex: number, commitments: Fr[], firstNullifier: Fr, { contractAddress, storageSlot, notePreimage }: NoteSpendingInfo, + excludedIndices: Set, ) { const wasm = await CircuitsWasm.get(); let commitmentIndex = 0; @@ -178,27 +183,21 @@ export class NoteProcessor { let uniqueSiloedNoteHash: Fr | undefined; let innerNullifier: Fr | undefined; for (; commitmentIndex < commitments.length; ++commitmentIndex) { + if (excludedIndices.has(commitmentIndex)) continue; + const commitment = commitments[commitmentIndex]; if (commitment.equals(Fr.ZERO)) break; const expectedNonce = computeCommitmentNonce(wasm, firstNullifier, commitmentIndex); - const { - innerNoteHash: innerNoteHashTmp, - siloedNoteHash: siloedNoteHashTmp, - uniqueSiloedNoteHash: uniqueSiloedNoteHashTmp, - innerNullifier: innerNullifierTmp, - } = await this.simulator.computeNoteHashAndNullifier( - contractAddress, - expectedNonce, - storageSlot, - notePreimage.items, - ); - siloedNoteHash = siloedNoteHashTmp; - if (commitment.equals(uniqueSiloedNoteHashTmp)) { + ({ innerNoteHash, siloedNoteHash, uniqueSiloedNoteHash, innerNullifier } = + await this.simulator.computeNoteHashAndNullifier( + contractAddress, + expectedNonce, + storageSlot, + notePreimage.items, + )); + if (commitment.equals(uniqueSiloedNoteHash)) { nonce = expectedNonce; - innerNoteHash = innerNoteHashTmp; - uniqueSiloedNoteHash = uniqueSiloedNoteHashTmp; - innerNullifier = innerNullifierTmp; break; } } @@ -226,10 +225,9 @@ https://github.com/AztecProtocol/aztec-packages/issues/1641`; } return { - index: BigInt(dataStartIndex + commitmentIndex), + commitmentIndex, nonce, innerNoteHash: innerNoteHash!, - uniqueSiloedNoteHash: uniqueSiloedNoteHash!, siloedNullifier: siloNullifier(wasm, contractAddress, innerNullifier!), }; } diff --git a/yarn-project/aztec.js/src/wallet/base_wallet.ts b/yarn-project/aztec.js/src/wallet/base_wallet.ts index b9383429e149..dac3fc0c8186 100644 --- a/yarn-project/aztec.js/src/wallet/base_wallet.ts +++ b/yarn-project/aztec.js/src/wallet/base_wallet.ts @@ -70,6 +70,9 @@ export abstract class BaseWallet implements Wallet { getPublicStorageAt(contract: AztecAddress, storageSlot: Fr): Promise { return this.rpc.getPublicStorageAt(contract, storageSlot); } + getNoteNonces(contract: AztecAddress, storageSlot: Fr, preimage: NotePreimage, txHash: TxHash): Promise { + return this.rpc.getNoteNonces(contract, storageSlot, preimage, txHash); + } viewTx(functionName: string, args: any[], to: AztecAddress, from?: AztecAddress | undefined): Promise { return this.rpc.viewTx(functionName, args, to, from); } diff --git a/yarn-project/end-to-end/src/e2e_private_airdrop.test.ts b/yarn-project/end-to-end/src/e2e_private_airdrop.test.ts new file mode 100644 index 000000000000..7e78b06a849d --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_private_airdrop.test.ts @@ -0,0 +1,157 @@ +import { AztecNodeService } from '@aztec/aztec-node'; +import { AztecRPCServer } from '@aztec/aztec-rpc'; +import { AztecRPC, CompleteAddress, TxHash, Wallet } from '@aztec/aztec.js'; +import { Fr, MAX_NEW_COMMITMENTS_PER_CALL } from '@aztec/circuits.js'; +import { DebugLogger } from '@aztec/foundation/log'; +import { PrivateTokenAirdropContract } from '@aztec/noir-contracts/types'; +import { NotePreimage } from '@aztec/types'; + +import { setup } from './fixtures/utils.js'; + +class Claim { + static EMPTY = new Claim(0n, Fr.ZERO); + + constructor(public readonly amount: bigint, public readonly secret: Fr) {} + + get preimage() { + return new NotePreimage([new Fr(this.amount), this.secret]); + } +} + +describe('private airdrop', () => { + const numberOfAccounts = 3; + const initialSupply = 1000n; + const claimsStorageSlot = new Fr(2n); + + let aztecNode: AztecNodeService | undefined; + let aztecRpcServer: AztecRPC; + let wallets: Wallet[]; + let contracts: PrivateTokenAirdropContract[]; + let accounts: CompleteAddress[]; + let claims: Claim[]; + let logger: DebugLogger; + + beforeEach(async () => { + ({ aztecNode, aztecRpcServer, accounts, wallets, logger } = await setup(numberOfAccounts)); + + logger(`Deploying zk token contract...`); + const owner = accounts[0].address; + const contract = await PrivateTokenAirdropContract.deploy(wallets[0], initialSupply, owner).send().deployed(); + logger(`zk token contract deployed at ${contract.address}`); + + contracts = [contract]; + for (let i = 1; i < accounts.length; ++i) { + contracts.push(await PrivateTokenAirdropContract.at(contract.address, wallets[i])); + } + }, 100_000); + + afterEach(async () => { + await aztecNode?.stop(); + if (aztecRpcServer instanceof AztecRPCServer) { + await aztecRpcServer?.stop(); + } + }, 30_000); + + const expectBalance = async (accountIndex: number, expectedBalance: bigint) => { + const account = accounts[accountIndex].address; + const balance = await contracts[accountIndex].methods.getBalance(account).view({ from: account }); + logger(`Account ${accountIndex} balance: ${balance}`); + expect(balance).toBe(expectedBalance); + }; + + const createClaims = (amounts: bigint[]) => { + claims = amounts.map(amount => new Claim(amount, Fr.random())); + claims.push( + ...Array(MAX_NEW_COMMITMENTS_PER_CALL - amounts.length) + .fill(0) + .map(() => Claim.EMPTY), + ); + }; + + const claimToken = async (accountIndex: number, claim: Claim, txHash: TxHash, nonceIndex = 0) => { + const contract = contracts[accountIndex]; + const account = accounts[accountIndex].address; + const wallet = wallets[accountIndex]; + const nonces = await wallet.getNoteNonces(contract.address, claimsStorageSlot, claim.preimage, txHash); + + const preimageBuf = claim.preimage.toBuffer(); + const numNonces = claims.reduce((count, c) => count + (c.preimage.toBuffer().equals(preimageBuf) ? 1 : 0), 0); + expect(nonces.length).toBe(numNonces); + expect(nonces[nonceIndex]).not.toEqual(Fr.ZERO); + + return contract.methods.claim(claim.amount, claim.secret, account, nonces[nonceIndex]).send().wait(); + }; + + it('should create claim notes for any accounts to claim', async () => { + let txHash: TxHash; + + // Transaction 1 + { + logger(`Create claims...`); + const accountIndex = 0; + await expectBalance(accountIndex, initialSupply); + + createClaims([12n, 345n]); + // Create a claim that has the exact same preimage as another claim. + claims[2] = claims[0]; + + const amounts = claims.map(c => c.amount); + const secrets = claims.map(c => c.secret); + ({ txHash } = await contracts[accountIndex].methods.createClaims(amounts, secrets).send().wait()); + + const amountSum = amounts.reduce((sum, a) => sum + a, 0n); + await expectBalance(accountIndex, initialSupply - amountSum); + } + + // Transaction 2 + { + logger(`Account 1 claims note 0...`); + const accountIndex = 1; + const claim = claims[0]; + await expectBalance(accountIndex, 0n); + + await claimToken(accountIndex, claim, txHash); + + await expectBalance(accountIndex, claim.amount); + + logger(`Fails to claim note 0 again...`); + await expect(claimToken(accountIndex, claim, txHash)).rejects.toThrow(); + } + + // Transaction 3 + { + logger(`Account 2 claims note 1...`); + const accountIndex = 2; + const claim = claims[1]; + await expectBalance(accountIndex, 0n); + + await claimToken(accountIndex, claim, txHash); + + await expectBalance(accountIndex, claim.amount); + + logger(`Fails to claim note 1 again...`); + await expect(claimToken(accountIndex, claim, txHash)).rejects.toThrow(); + + logger(`Fails to claim note 0...`); + await expect(claimToken(accountIndex, claims[0], txHash)).rejects.toThrow(); + } + + // Transaction 4 + { + logger(`Account 1 claims note 2...`); + const accountIndex = 1; + const claim0 = claims[0]; + const claim2 = claims[2]; + expect(claim2.preimage).toEqual(claim0.preimage); + + await expectBalance(accountIndex, claim0.amount); + + // Claim 2 has the same preimage as claim 0. + // `getNoteNonces` will return 2 nonces. And we need to use nonce 1 to spend the duplicated claim. + const nonceIndex = 1; + await claimToken(accountIndex, claim2, txHash, nonceIndex); + + await expectBalance(accountIndex, claim0.amount + claim2.amount); + } + }, 100_000); +}); diff --git a/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/interface.nr b/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/interface.nr index ff54645fa31b..9581bbd11cb4 100644 --- a/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/interface.nr +++ b/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/interface.nr @@ -75,16 +75,44 @@ impl PrivateTokenAirdropPrivateContextInterface { fn createClaims( self, context: &mut PrivateContext, - amounts: [Field;2], - secrets: [Field;2] + amounts: [Field;16], + secrets: [Field;16] ) -> [Field; RETURN_VALUES_LENGTH] { - let mut serialised_args = [0; 4]; + let mut serialised_args = [0; 32]; serialised_args[0] = amounts[0]; serialised_args[1] = amounts[1]; - serialised_args[2] = secrets[0]; - serialised_args[3] = secrets[1]; - - context.call_private_function(self.address, 0x720f5cc9, serialised_args) + serialised_args[2] = amounts[2]; + serialised_args[3] = amounts[3]; + serialised_args[4] = amounts[4]; + serialised_args[5] = amounts[5]; + serialised_args[6] = amounts[6]; + serialised_args[7] = amounts[7]; + serialised_args[8] = amounts[8]; + serialised_args[9] = amounts[9]; + serialised_args[10] = amounts[10]; + serialised_args[11] = amounts[11]; + serialised_args[12] = amounts[12]; + serialised_args[13] = amounts[13]; + serialised_args[14] = amounts[14]; + serialised_args[15] = amounts[15]; + serialised_args[16] = secrets[0]; + serialised_args[17] = secrets[1]; + serialised_args[18] = secrets[2]; + serialised_args[19] = secrets[3]; + serialised_args[20] = secrets[4]; + serialised_args[21] = secrets[5]; + serialised_args[22] = secrets[6]; + serialised_args[23] = secrets[7]; + serialised_args[24] = secrets[8]; + serialised_args[25] = secrets[9]; + serialised_args[26] = secrets[10]; + serialised_args[27] = secrets[11]; + serialised_args[28] = secrets[12]; + serialised_args[29] = secrets[13]; + serialised_args[30] = secrets[14]; + serialised_args[31] = secrets[15]; + + context.call_private_function(self.address, 0x2eebe7ab, serialised_args) } diff --git a/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/main.nr b/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/main.nr index c7eac5d9d4b8..d78568dbab56 100644 --- a/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/main.nr +++ b/yarn-project/noir-contracts/src/contracts/private_token_airdrop_contract/src/main.nr @@ -10,6 +10,7 @@ contract PrivateTokenAirdrop { value_note::{VALUE_NOTE_LEN, ValueNote, ValueNoteMethods}, }; use dep::aztec::{ + constants_gen::MAX_NEW_COMMITMENTS_PER_CALL, context::{PrivateContext, PublicContext, Context}, state_vars::{map::Map, set::Set}, note::{ @@ -135,25 +136,26 @@ contract PrivateTokenAirdrop { #[aztec(private)] fn createClaims( - amounts: [Field; 2], - secrets: [Field; 2], + amounts: [Field; MAX_NEW_COMMITMENTS_PER_CALL], + secrets: [Field; MAX_NEW_COMMITMENTS_PER_CALL], ) { let storage = Storage::init(Context::private(&mut context)); let sender = context.msg_sender(); // Pick from the set of sender's notes to spend amount. let sender_balance = storage.balances.at(sender); - let total = amounts[0] + amounts[1]; + let total = amounts.fold(0, |sum, a| sum + a); decrement(sender_balance, total, sender); // Create claim notes. let claims = storage.claims; - let mut note0 = ClaimNote::new(amounts[0], secrets[0]); - let mut note1 = ClaimNote::new(amounts[1], secrets[1]); - - // Insert the new claim notes to the set. - claims.insert(&mut note0); - claims.insert(&mut note1); + for i in 0..amounts.len() { + let amount = amounts[i]; + if amount != 0 { + let mut note = ClaimNote::new(amount, secrets[i]); + claims.insert(&mut note); + } + } } #[aztec(private)] diff --git a/yarn-project/types/src/interfaces/aztec_rpc.ts b/yarn-project/types/src/interfaces/aztec_rpc.ts index 558a9243110d..fbd009689252 100644 --- a/yarn-project/types/src/interfaces/aztec_rpc.ts +++ b/yarn-project/types/src/interfaces/aztec_rpc.ts @@ -191,6 +191,16 @@ export interface AztecRPC { */ getPublicStorageAt(contract: AztecAddress, storageSlot: Fr): Promise; + /** + * Find the nonce(s) for a note in a tx with given preimage at a specified contract address and storage slot. + * @param contract - The contract address of the note. + * @param storageSlot - The storage slot of the note. + * @param preimage - The note preimage. + * @param txHash - The tx hash of the tx containing the note. + * @returns The nonces of the note. It's an array because there might be more than one note with the same preimage. + */ + getNoteNonces(contract: AztecAddress, storageSlot: Fr, preimage: NotePreimage, txHash: TxHash): Promise; + /** * Simulate the execution of a view (read-only) function on a deployed contract without actually modifying state. * This is useful to inspect contract state, for example fetching a variable value or calling a getter function.