diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/main.nr b/noir-projects/noir-contracts/contracts/token_contract/src/main.nr index d1695732c066..ef9887c29ff2 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/main.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/main.nr @@ -335,13 +335,37 @@ contract Token { let to_ivpk = header.get_ivpk_m(&mut context, to); let amount = U128::from_integer(amount); - storage.balances.sub(from, amount).emit(encode_and_encrypt_note_with_keys_unconstrained(&mut context, from_ovpk, from_ivpk)); + let mut (change, missing) = storage.balances.odd_sub(from, amount, 2); + + // If we are still missing some balance to cover the amount, call accumulate to spend more notes. + if !missing.eq(U128::zero()) { + change = Token::at(context.this_address())._accumulate(from, missing.to_field()).call(&mut context); + } + + storage.balances.add(from, change).emit(encode_and_encrypt_note_with_keys_unconstrained(&mut context, from_ovpk, from_ivpk)); storage.balances.add(to, amount).emit(encode_and_encrypt_note_with_keys_unconstrained(&mut context, from_ovpk, to_ivpk)); Transfer { from: from.to_field(), to: to.to_field(), amount: amount.to_field() }.emit(encode_and_encrypt_event_with_keys_unconstrained(&mut context, from_ovpk, to_ivpk)); } // docs:end:transfer + /** + * Accumulate value by spending notes until we hit the missing amount + * If not enough notes are available, recurse to do it again. + * Will use MORE notes than the transfer directly since there are much smaller usual overhead on this function. + * Will fail with `Cannot return zero notes` if there are not enough notes to cover the amount. + */ + #[aztec(private)] + #[aztec(internal)] + fn _accumulate(owner: AztecAddress, missing_: Field) -> U128 { + // Since we are already spending a lot of doing the call, and we don't do anything else in here, we might as well go over more notes. + let mut (change, missing) = storage.balances.odd_sub(owner, U128::from_integer(missing_), 8); + if !missing.eq(U128::zero()) { + change = Token::at(context.this_address())._accumulate(owner, missing.to_field()).call(&mut context); + } + change + } + /** * Cancel a private authentication witness. * @param inner_hash The inner hash of the authwit to cancel. diff --git a/noir-projects/noir-contracts/contracts/token_contract/src/types/balances_map.nr b/noir-projects/noir-contracts/contracts/token_contract/src/types/balances_map.nr index a13206360c33..d8cbc19fb7f6 100644 --- a/noir-projects/noir-contracts/contracts/token_contract/src/types/balances_map.nr +++ b/noir-projects/noir-contracts/contracts/token_contract/src/types/balances_map.nr @@ -113,6 +113,43 @@ impl BalancesMap { self.add(owner, minuend - subtrahend) } + + pub fn odd_sub( + self: Self, + owner: AztecAddress, + subtrahend: U128, + limit: u32 + ) -> (U128, U128) where T: NoteInterface + OwnedNote { + // docs:start:get_notes + let options = NoteGetterOptions::with_filter(filter_notes_min_sum, subtrahend).set_limit(limit); + let notes = self.map.at(owner).get_notes(options); + // docs:end:get_notes + + assert(notes.len() > 0, "No notes found"); + + let mut minuend: U128 = U128::from_integer(0); + for i in 0..options.limit { + if i < notes.len() { + let note = notes.get_unchecked(i); + + // Removes the note from the owner's set of notes. + // This will call the the `compute_nullifer` function of the `token_note` + // which require knowledge of the secret key (currently the users encryption key). + // The contract logic must ensure that the spending key is used as well. + // docs:start:remove + self.map.at(owner).remove(note); + // docs:end:remove + + minuend = minuend + note.get_amount(); + } + } + + if minuend >= subtrahend { + (minuend - subtrahend, U128::zero()) + } else { + (U128::zero(), subtrahend - minuend) + } + } } pub fn filter_notes_min_sum( diff --git a/yarn-project/circuits.js/package.json b/yarn-project/circuits.js/package.json index 1444de37a2d8..524b4a622104 100644 --- a/yarn-project/circuits.js/package.json +++ b/yarn-project/circuits.js/package.json @@ -97,4 +97,4 @@ ] ] } -} \ No newline at end of file +} diff --git a/yarn-project/end-to-end/src/e2e_token_contract/odd_transfer_private.test.ts b/yarn-project/end-to-end/src/e2e_token_contract/odd_transfer_private.test.ts new file mode 100644 index 000000000000..029a028722b1 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_token_contract/odd_transfer_private.test.ts @@ -0,0 +1,79 @@ +import { BatchCall, EventType, Fr } from '@aztec/aztec.js'; +import { TokenContract } from '@aztec/noir-contracts.js'; + +import { TokenContractTest } from './token_contract_test.js'; + +describe('e2e_token_contract transfer private', () => { + const t = new TokenContractTest('odd_transfer_private'); + let { asset, accounts, tokenSim, wallets } = t; + + beforeAll(async () => { + await t.applyBaseSnapshots(); + await t.applyMintSnapshot(); + await t.setup(); + ({ asset, accounts, tokenSim, wallets } = t); + }); + + afterAll(async () => { + await t.teardown(); + }); + + afterEach(async () => { + await t.tokenSim.check(); + }); + + it('transfer full balance', async () => { + const N = 5; + // Mint 4 * N notes + // Then calls `transfer` to send all the notes to another account + // The transfer will only spend 2 notes itself, and then call `_accumulate` which will recurse + // until it have spent all the notes + + let expectedNullifiers = 2; // 1 from the tx_hash and 1 from the original note in the senders possesion + + for (let i = 0; i < N; i++) { + const actions = [ + asset.methods.privately_mint_private_note(1n).request(), + asset.methods.privately_mint_private_note(1n).request(), + asset.methods.privately_mint_private_note(1n).request(), + asset.methods.privately_mint_private_note(1n).request(), + ]; + await new BatchCall(wallets[0], actions).send().wait(); + + expectedNullifiers += actions.length; + + tokenSim.mintPrivate(BigInt(actions.length)); + tokenSim.redeemShield(accounts[0].address, BigInt(actions.length)); + } + + const amount = await asset.methods.balance_of_private(accounts[0].address).simulate(); + + expect(amount).toBeGreaterThan(0n); + const tx = await asset.methods.transfer(accounts[1].address, amount).send().wait({ debug: true }); + tokenSim.transferPrivate(accounts[0].address, accounts[1].address, amount); + + expect(tx.debugInfo?.nullifiers.length).toBe(expectedNullifiers); + + // We expect there to have been inserted a single new note. + expect(tx.debugInfo?.noteHashes.length).toBe(1); + + const events = await wallets[1].getEvents(EventType.Encrypted, TokenContract.events.Transfer, tx.blockNumber!, 1); + + expect(events[0]).toEqual({ + from: accounts[0].address, + to: accounts[1].address, + amount: new Fr(amount), + }); + }); + + describe('failure cases', () => { + it('transfer more than balance', async () => { + const balance0 = await asset.methods.balance_of_private(accounts[0].address).simulate(); + const amount = balance0 + 1n; + expect(amount).toBeGreaterThan(0n); + await expect(asset.methods.transfer(accounts[1].address, amount).simulate()).rejects.toThrow( + "Assertion failed: Cannot return zero notes 'returned_notes.len() != 0'", + ); + }); + }); +});