diff --git a/noir-projects/aztec-nr/aztec/src/macros/notes.nr b/noir-projects/aztec-nr/aztec/src/macros/notes.nr index e284c35fb065..a5c57e03f59c 100644 --- a/noir-projects/aztec-nr/aztec/src/macros/notes.nr +++ b/noir-projects/aztec-nr/aztec/src/macros/notes.nr @@ -387,8 +387,8 @@ pub comptime fn custom_note(s: TypeDefinition) -> Quoted { /// Asserts that the note has an 'owner' field. /// -/// We require notes implemented with #[note] macro macro to have an 'owner' field because our -/// auto-generated nullifier functions expect it. This requirement is most likely only temporary. +/// We require notes implemented with #[note] macro to have an 'owner' field because our auto-generated nullifier +/// functions expect it. This requirement is most likely only temporary. comptime fn assert_has_owner(note: TypeDefinition) { let fields = note.fields_as_written(); let mut has_owner = false; diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index a6c5846e1417..93e0fa606168 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -11,6 +11,7 @@ members = [ "contracts/app/auth_contract", "contracts/app/card_game_contract", "contracts/app/claim_contract", + "contracts/app/closed_set_orderbook_contract", "contracts/app/crowdfunding_contract", "contracts/app/easy_private_token_contract", "contracts/app/easy_private_voting_contract", diff --git a/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/Nargo.toml new file mode 100644 index 000000000000..4a3cd5356e41 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "closed_set_orderbook_contract" +authors = [""] +compiler_version = ">=0.25.0" +type = "contract" + +[dependencies] +aztec = { path = "../../../../aztec-nr/aztec" } +token = { path = "../token_contract" } +uint_note = { path = "../../../../aztec-nr/uint-note" } diff --git a/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/config.nr b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/config.nr new file mode 100644 index 000000000000..4b490c1b8d17 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/config.nr @@ -0,0 +1,117 @@ +use dep::aztec::protocol_types::{address::AztecAddress, traits::{Deserialize, Packable, Serialize}}; +use std::meta::derive; + +/// We store the configuration in a struct such that to load it from SharedMutable asserts only a single merkle proof. +#[derive(Deserialize, Eq, Packable, Serialize)] +pub struct Config { + token0: AztecAddress, + token1: AztecAddress, + pub fee_collector: AztecAddress, + pub fee: u16, // The fee is defined in basis points (0-10000) hence u16 is sufficient. +} + +impl Config { + pub fn new( + token0: AztecAddress, + token1: AztecAddress, + fee_collector: AztecAddress, + fee: u16, + ) -> Self { + assert(!token0.eq(token1), "Tokens must be different"); + assert(fee <= 10000, "Fee must be in basis points (0-10000)"); + Self { token0, token1, fee_collector, fee } + } + + pub fn validate_input_tokens_and_get_direction( + self, + bid_token: AztecAddress, + ask_token: AztecAddress, + ) -> bool { + assert((bid_token == self.token0) | (bid_token == self.token1), "BID_TOKEN_IS_INVALID"); + assert((ask_token == self.token0) | (ask_token == self.token1), "ASK_TOKEN_IS_INVALID"); + assert(bid_token != ask_token, "SAME_TOKEN_TRADE"); + + bid_token == self.token0 + } + + /// Returns a tuple of (bid_token, ask_token) based on `bid_token_is_zero` param. + pub fn get_tokens(self, bid_token_is_zero: bool) -> (AztecAddress, AztecAddress) { + if bid_token_is_zero { + (self.token0, self.token1) + } else { + (self.token1, self.token0) + } + } +} + +mod test { + use crate::config::Config; + use aztec::protocol_types::{address::AztecAddress, traits::FromField}; + + global token0: AztecAddress = AztecAddress::from_field(1); + global token1: AztecAddress = AztecAddress::from_field(2); + global token2: AztecAddress = AztecAddress::from_field(3); + global fee_collector: AztecAddress = AztecAddress::from_field(4); + global fee: u16 = 100; // 1% + + #[test] + unconstrained fn new_config_valid_inputs() { + let _ = Config::new(token0, token1, fee_collector, fee); + } + + #[test(should_fail_with = "Tokens must be different")] + unconstrained fn new_config_same_tokens() { + let _ = Config::new(token0, token0, fee_collector, fee); + } + + #[test(should_fail_with = "Fee must be in basis points (0-10000)")] + unconstrained fn new_config_invalid_fee() { + let _ = Config::new(token0, token1, fee_collector, 10001); + } + + #[test] + unconstrained fn validate_input_tokens_valid() { + let config = Config::new(token0, token1, fee_collector, fee); + + // Test token0 to token1 direction + let is_zero = config.validate_input_tokens_and_get_direction(token0, token1); + assert(is_zero); + + // Test token1 to token0 direction + let is_zero = config.validate_input_tokens_and_get_direction(token1, token0); + assert(!is_zero); + } + + #[test(should_fail_with = "BID_TOKEN_IS_INVALID")] + unconstrained fn validate_input_tokens_invalid_bid() { + let config = Config::new(token0, token1, fee_collector, fee); + let _ = config.validate_input_tokens_and_get_direction(token2, token1); + } + + #[test(should_fail_with = "ASK_TOKEN_IS_INVALID")] + unconstrained fn validate_input_tokens_invalid_ask() { + let config = Config::new(token0, token1, fee_collector, fee); + let _ = config.validate_input_tokens_and_get_direction(token0, token2); + } + + #[test(should_fail_with = "SAME_TOKEN_TRADE")] + unconstrained fn validate_input_tokens_same_token() { + let config = Config::new(token0, token1, fee_collector, fee); + let _ = config.validate_input_tokens_and_get_direction(token0, token0); + } + + #[test] + unconstrained fn get_tokens_correct_order() { + let config = Config::new(token0, token1, fee_collector, fee); + + let is_zero = config.validate_input_tokens_and_get_direction(token0, token1); + let (bid, ask) = config.get_tokens(is_zero); + assert(bid == token0); + assert(ask == token1); + + let is_zero = config.validate_input_tokens_and_get_direction(token1, token0); + let (bid, ask) = config.get_tokens(is_zero); + assert(bid == token1); + assert(ask == token0); + } +} diff --git a/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/main.nr b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/main.nr new file mode 100644 index 000000000000..0dd3a448adcd --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/main.nr @@ -0,0 +1,180 @@ +mod config; +mod order_note; + +use aztec::macros::aztec; + +/// ## Overview +/// This contract demonstrates how to implement an **Orderbook** that keeps all of the information in private. Orders +/// are revealed only to a closed set of participants (those that know the Orderbook's secret keys). +/// +/// **Note:** +/// This is purely a demonstration implemented to test various features of Aztec.nr. The **Aztec team** does not +/// consider this the optimal design for building a DEX. +/// +/// ## Reentrancy Guard Considerations +/// +/// ### 1. Private Functions: +/// Reentrancy protection is typically necessary if entering an intermediate state that is only valid when +/// the action completes uninterrupted. This follows the **Checks-Effects-Interactions** pattern. +/// +/// - In this contract, **private functions** do not introduce intermediate states. +/// +/// ### 2. Public Functions: +/// The only public function in the contract is the constructor and there reentrancy is prevented by the +/// `#[initializer]` macro. +#[aztec] +pub contract ClosedSetOrderbook { + use crate::{config::Config, order_note::OrderNote}; + use aztec::{ + macros::{functions::{initializer, private, public, utility}, storage::storage}, + messages::logs::note::encode_and_encrypt_note, + note::{ + lifecycle::destroy_note_unsafe, note_getter::get_note, retrieved_note::RetrievedNote, + }, + oracle::random::random, + protocol_types::address::AztecAddress, + state_vars::{HasStorageSlot, Map, PrivateImmutable, SharedMutable}, + }; + + use token::Token; + + // Note that this value was copied over to e2e_closed_set_orderbook.test.ts. and needs to be kept in sync. + global CHANGE_CONFIG_DELAY: u64 = 60 * 60 * 24; + + #[storage] + struct Storage { + // Using SharedMutable here reveals the whole config which is unfortunate. + // TODO: Implement smt. like `ClosedSharedMutable` as described in the comment below and use it here. + // https://github.com/AztecProtocol/aztec-packages/issues/14917#issuecomment-2960329494 + config: SharedMutable, + orders: Map, Context>, + } + + #[public] + #[initializer] + fn constructor( + token0: AztecAddress, + token1: AztecAddress, + fee_collector: AztecAddress, + fee: u16, + ) { + // TODO: Why don't we have a way to directly initialize SharedMutable when it's created? If this is done + // in the constructor then it's safe as we cannot interact with an undeployed contract that has initializer. + // Waiting for delay to pass here is quite annoying. + storage.config.schedule_value_change(Config::new(token0, token1, fee_collector, fee)); + } + + /// Privately creates a new order in the orderbook + /// The maker specifies the tokens and amounts they want to trade + #[private] + fn create_order( + bid_token: AztecAddress, + ask_token: AztecAddress, + bid_amount: u128, + ask_amount: u128, + authwit_nonce: Field, + ) -> Field { + let config = storage.config.get_current_value(); + let maker = context.msg_sender(); + + // Transfer tokens from maker to the public balance of this contract. + Token::at(bid_token) + .transfer_in_private(maker, context.this_address(), bid_amount, authwit_nonce) + .call(&mut context); + + // This contract is the "nullificator" of the order note meaning that this contract's nullifier key will be + // used to compute the nullifier. + let order_nullificator = context.this_address(); + // The viewer of the order note is also this contract as we use this contract's viewing key to encrypt + // the note. + let order_viewer = context.this_address(); + + // The entity used to compute the tagging secret. We use this contract's address instead of maker address + // such that potential takers don't need to add maker to their PXE when looking for orders. + // + // Note that when fulfilling the order takers will need to add maker to their PXE as they will need to send + // them `ask_token` note. + let order_note_sender = context.this_address(); + + // Safety: If the order ID is not unique then the order will fail to be stored in PrivateImmutable due to + // initialization nullifier collision. + let order_id = unsafe { random() }; + + // Create the order (this validates the input tokens and amounts). + let order = OrderNote::new( + order_id, + config, + order_nullificator, + maker, + bid_amount, + ask_amount, + bid_token, + ask_token, + ); + + // Store the order in private storage and emit an event. + storage.orders.at(order_id).initialize(order).emit(encode_and_encrypt_note( + &mut context, + order_viewer, + order_note_sender, + )); + + // Note that I don't emit an event here because all the possible takers will have access to the viewing key + // (and the nullifier key) and hence will see when the order note is emitted. + + order_id + } + + /// Privately fulfills an existing order in the orderbook + /// The taker provides the order ID they want to fulfill + #[private] + fn fulfill_order(order_id: Field, authwit_nonce: Field) { + let config = storage.config.get_current_value(); + let taker = context.msg_sender(); + + // We get and nullify the order. We are working around PrivateImmutable here because the PrivateImmutable + // getter does not allow us to then destroy the note. This is of course intentional as the note is supposed + // to be immutable. But we want to nullify the note as it's valuable to communicate order fulfillment that way. + // TODO: Rework this. An option is to not use any state variable and instead manually handle the notes. + let order_note_storage_slot = storage.orders.at(order_id).get_storage_slot(); + let (order_retrieved_note, note_hash_for_read_request): (RetrievedNote, Field) = + get_note(&mut context, order_note_storage_slot); + destroy_note_unsafe( + &mut context, + order_retrieved_note, + note_hash_for_read_request, + ); + + let order = order_retrieved_note.note; + + // Determine which tokens are being exchanged based on bid_token_is_zero flag + let (bid_token, ask_token) = config.get_tokens(order.bid_token_is_zero); + + // Calculate fee amount based on bid_amount + let fee_amount = (order.bid_amount * config.fee as u128) / 10000u128; + let taker_amount = order.bid_amount - fee_amount; + + // Transfer the ask_amount from taker directly to the maker + Token::at(ask_token) + .transfer_in_private(taker, order.maker, order.ask_amount, authwit_nonce) + .call(&mut context); + + // Note that we cannot use the hyper-optimized `Token::transfer` function to perform the following 2 transfers + // because we need constrained delivery. + + // Transfer the bid_amount minus fee from this contract's private balance to the taker + Token::at(bid_token) + .transfer_in_private(context.this_address(), taker, taker_amount, 0) + .call(&mut context); + + // Transfer the fee from this contract's private balance to the fee collector + Token::at(bid_token) + .transfer_in_private(context.this_address(), config.fee_collector, fee_amount, 0) + .call(&mut context); + } + + #[utility] + unconstrained fn get_config() -> pub Config { + storage.config.get_current_value() + } +} diff --git a/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/order_note.nr b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/order_note.nr new file mode 100644 index 000000000000..1834904068b1 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/closed_set_orderbook_contract/src/order_note.nr @@ -0,0 +1,128 @@ +use crate::config::Config; +use aztec::{macros::notes::note, protocol_types::address::AztecAddress}; + +// TODO: Current macros would not pack this efficiently. Add manual Packable impl. or optimize the macros. +#[note] +pub struct OrderNote { + // A unique identifier for this order, stored within the note itself as it's needed to fulfill the order. + // This field could potentially be removed if we opt to communicate the order ID through event emission instead. + pub order_id: Field, + // The "nullificator" address whose nullifier key will be used to compute this note's nullifier. + // This field is named "owner" to satisfy the `#[note]` macro's requirement of having an "owner" field. + // The term 'nullificator' more precisely describes this field's role, as it represents an entity that can only + // nullify the note. This differs from an 'owner' which traditionally has both nullification and viewing + // privileges. Hence using 'owner' here is a bit misleading. + pub owner: AztecAddress, + // The order maker (who submitted the order). Used to send the `ask_token` note to the maker when the order is + // fulfilled. + // TODO: With constrained tagging could we drop this field and save DA costs? (I am currently ignorant about + // how constrained tagging is supposed to work as I have not yet managed to catch up on Mike's hackmd.) + pub maker: AztecAddress, + // Amount of bid tokens + pub bid_amount: u128, + // Amount of ask tokens + pub ask_amount: u128, + // Whether the order is from token0 to token1 or from token1 to token0 + pub bid_token_is_zero: bool, +} + +impl OrderNote { + pub fn new( + order_id: Field, + config: Config, + owner: AztecAddress, + maker: AztecAddress, + bid_amount: u128, + ask_amount: u128, + bid_token: AztecAddress, + ask_token: AztecAddress, + ) -> Self { + assert(bid_amount > 0 as u128, "ZERO_BID_AMOUNT"); + assert(ask_amount > 0 as u128, "ZERO_ASK_AMOUNT"); + + let bid_token_is_zero = + config.validate_input_tokens_and_get_direction(bid_token, ask_token); + Self { order_id, owner, maker, bid_amount, ask_amount, bid_token_is_zero } + } +} + +mod test { + use crate::{config::Config, order_note::OrderNote}; + use aztec::protocol_types::{address::AztecAddress, traits::FromField}; + + global token0: AztecAddress = AztecAddress::from_field(1); + global token1: AztecAddress = AztecAddress::from_field(2); + global token2: AztecAddress = AztecAddress::from_field(3); + global fee_collector: AztecAddress = AztecAddress::from_field(4); + global owner: AztecAddress = AztecAddress::from_field(5); + global maker: AztecAddress = AztecAddress::from_field(6); + global fee: u16 = 100; // 1% + + #[test] + unconstrained fn new_order_valid_inputs() { + let config = Config::new(token0, token1, fee_collector, fee); + let order_id = 1; + let bid_amount = 100; + let ask_amount = 200; + + // Test token0 to token1 direction + let order = OrderNote::new( + order_id, + config, + owner, + maker, + bid_amount, + ask_amount, + token0, + token1, + ); + assert(order.order_id == order_id); + assert(order.owner == owner); + assert(order.maker == maker); + assert(order.bid_amount == bid_amount); + assert(order.ask_amount == ask_amount); + assert(order.bid_token_is_zero); + + // Test token1 to token0 direction + let order = OrderNote::new( + order_id, + config, + owner, + maker, + bid_amount, + ask_amount, + token1, + token0, + ); + assert(order.order_id == order_id); + assert(order.owner == owner); + assert(order.maker == maker); + assert(order.bid_amount == bid_amount); + assert(order.ask_amount == ask_amount); + assert(!order.bid_token_is_zero); + } + + #[test(should_fail_with = "ZERO_BID_AMOUNT")] + unconstrained fn new_order_zero_bid_amount() { + let config = Config::new(token0, token1, fee_collector, fee); + let order_id = 1; + + let _ = OrderNote::new(order_id, config, owner, maker, 0, 100, token0, token1); + } + + #[test(should_fail_with = "ZERO_ASK_AMOUNT")] + unconstrained fn new_order_zero_ask_amount() { + let config = Config::new(token0, token1, fee_collector, fee); + let order_id = 1; + + let _ = OrderNote::new(order_id, config, owner, maker, 100, 0, token0, token1); + } + + #[test(should_fail_with = "BID_TOKEN_IS_INVALID")] + unconstrained fn new_order_invalid_tokens() { + let config = Config::new(token0, token1, fee_collector, fee); + let order_id = 1; + + let _ = OrderNote::new(order_id, config, owner, maker, 100, 100, token2, token1); + } +} diff --git a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/reset_output_validator.nr b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/reset_output_validator.nr index ffb3166f512a..48a113ff0e31 100644 --- a/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/reset_output_validator.nr +++ b/noir-projects/noir-protocol-circuits/crates/private-kernel-lib/src/components/reset_output_validator.nr @@ -317,7 +317,7 @@ impl { + jest.setTimeout(TIMEOUT); + + let teardownA: () => Promise; + let teardownB: () => Promise; + let teardownC: () => Promise; + let logger: Logger; + + let makerPxe: PXE; + let takerPxe: PXE; + let feeCollectorPxe: PXE; + let cheatCodes: CheatCodes; + let sequencer: SequencerClient; + let aztecNode: AztecNode; + + let initialFundedAccounts: InitialAccountData[]; + + let adminWallet: AccountWallet; + let maker: AccountWallet; + let taker: AccountWallet; + let feeCollector: AccountWallet; + + let token0: TokenContract; + let token1: TokenContract; + let orderbook: ClosedSetOrderbookContract; + + const bidAmount = 1000n; + const askAmount = 2000n; + const fee = 10n; // 0.1% + const feeAmount = (bidAmount * fee) / 10000n; + const takerAmount = bidAmount - feeAmount; + + beforeAll(async () => { + let maybeSequencer: SequencerClient | undefined = undefined; + ({ + pxe: makerPxe, + teardown: teardownA, + wallets: [adminWallet, maker], + initialFundedAccounts, + logger, + cheatCodes, + sequencer: maybeSequencer, + aztecNode, + } = await setup(2, { numberOfInitialFundedAccounts: 4 })); + + if (!maybeSequencer) { + throw new Error('Sequencer client not found'); + } + sequencer = maybeSequencer; + + // TAKER ACCOUNT SETUP + // We setup a second PXE for the taker account to demonstrate a more realistic scenario. + { + // Setup second PXE for taker + ({ pxe: takerPxe, teardown: teardownB } = await setupPXEService(aztecNode, {}, undefined, true)); + const takerAccount = await deployFundedSchnorrAccount(takerPxe, initialFundedAccounts[2]); + taker = await takerAccount.getWallet(); + } + + // FEE COLLECTOR ACCOUNT SETUP + // We setup a third PXE for the fee collector account + { + // Setup third PXE for fee collector + ({ pxe: feeCollectorPxe, teardown: teardownC } = await setupPXEService(aztecNode, {}, undefined, true)); + const feeCollectorAccount = await deployFundedSchnorrAccount(feeCollectorPxe, initialFundedAccounts[3]); + feeCollector = await feeCollectorAccount.getWallet(); + } + + // Note: I am not sure if the following is needed but it was present in the e2e_2_pxes.test.ts so I better just + // copied it here since we are also dealing with multiple PXEs. + /*TODO(post-honk): We wait 5 seconds for a race condition in setting up two nodes. + What is a more robust solution? */ + await sleep(5000); + + { + // Taker sends the ask token to the maker so we need to register the taker as a sender in maker's PXE. + await makerPxe.registerSender(taker.getAddress()); + // We need to register the maker as a sender in taker's PXE even though none of the notes are sent from maker to + // taker because we need to discover the notes sent from maker to the orderbook contract. + await takerPxe.registerSender(maker.getAddress()); + // We need to register the admin wallet as a sender for taker and fee collector such that their PXEs know that + // they need to sync the minted token notes (admin is set as sender there). + await takerPxe.registerSender(adminWallet.getAddress()); + } + + // TOKEN SETUP + { + token0 = await deployToken(adminWallet, 0n, logger); + token1 = await deployToken(adminWallet, 0n, logger); + + // Register tokens with taker's and fee collector's PXEs + // (we don't need to do so for maker pxe because we deployed the tokens via that) + await takerPxe.registerContract(token0); + await takerPxe.registerContract(token1); + await feeCollectorPxe.registerContract(token0); + await feeCollectorPxe.registerContract(token1); + + // Mint tokens to maker and taker + await mintTokensToPrivate(token0, adminWallet, maker.getAddress(), bidAmount); + await mintTokensToPrivate(token1, adminWallet, taker.getAddress(), askAmount); + } + + // ORDERBOOK SETUP + { + // Generate secret key and public keys for the orderbook contract + const orderbookSecretKey = Fr.random(); + const orderbookPublicKeys = (await deriveKeys(orderbookSecretKey)).publicKeys; + + // Deploy the orderbook contract with public keys such that it can receive and nullify notes. + orderbook = await ClosedSetOrderbookContract.deployWithPublicKeys( + orderbookPublicKeys, + adminWallet, + token0.address, + token1.address, + feeCollector.getAddress(), + fee, + ) + .send() + .deployed(); + + // Register orderbook with all PXEs + await makerPxe.registerAccount(orderbookSecretKey, await orderbook.partialAddress); + await takerPxe.registerAccount(orderbookSecretKey, await orderbook.partialAddress); + await feeCollectorPxe.registerAccount(orderbookSecretKey, await orderbook.partialAddress); + await takerPxe.registerContract(orderbook); + await feeCollectorPxe.registerContract(orderbook); + } + }); + + afterAll(async () => { + await teardownC(); + await teardownB(); + await teardownA(); + }); + + // THESE TESTS HAVE TO BE RUN SEQUENTIALLY AS THEY ARE INTERDEPENDENT. + describe('full flow - happy path', () => { + it('config actives', async () => { + // Warp time to get past the config change delay + await cheatCodes.warpL2TimeAtLeastBy(sequencer, aztecNode, CHANGE_CONFIG_DELAY); + + const config = await orderbook.methods.get_config().simulate(); + expect(config.token0).toEqual(token0.address); + expect(config.token1).toEqual(token1.address); + expect(config.fee_collector).toEqual(feeCollector.getAddress()); + expect(config.fee).toEqual(fee); + }); + + it('creates an order', async () => { + const nonceForAuthwits = Fr.random(); + + // Create authwit for maker to allow orderbook to transfer bidAmount of token0 to itself + const makerAuthwit = await maker.createAuthWit({ + caller: orderbook.address, + action: token0.methods.transfer_in_private(maker.getAddress(), orderbook.address, bidAmount, nonceForAuthwits), + }); + + // Create order + await orderbook + .withWallet(maker) + .methods.create_order(token0.address, token1.address, bidAmount, askAmount, nonceForAuthwits) + .with({ authWitnesses: [makerAuthwit] }) + .send() + .wait(); + + // At this point, bidAmount of token0 should be transferred to the private balance of the orderbook and maker + // should have 0. + await expectTokenBalance(maker, token0, maker.getAddress(), 0n, logger); + await expectTokenBalance(maker, token0, orderbook.address, bidAmount, logger); + }); + + it('fulfills an order', async () => { + // First we check that taker's PXE has managed to successfully sync the token notes. + { + await expectTokenBalance(taker, token0, orderbook.address, bidAmount, logger); + await expectTokenBalance(taker, token1, taker.getAddress(), askAmount, logger); + } + + // Taker fetches the order and decides to fulfill it. + const orderNote = await takerPxe.getNotes({ + contractAddress: orderbook.address, + }); + expect(orderNote.length).toEqual(1); + const orderId = orderNote[0].note.items[0]; + + // Create authwit for taker to allow orderbook to transfer askAmount of token1 from taker to maker + const nonceForAuthwits = Fr.random(); + const takerAuthwit = await taker.createAuthWit({ + caller: orderbook.address, + action: token1.methods.transfer_in_private(taker.getAddress(), maker.getAddress(), askAmount, nonceForAuthwits), + }); + + // Fulfill order using taker's PXE + await orderbook + .withWallet(taker) + .methods.fulfill_order(orderId, nonceForAuthwits) + .with({ authWitnesses: [takerAuthwit] }) + .send() + .wait({ interval: 0.1 }); + + // Verify balances after order fulfillment: + // - maker should have the askAmount of token1 + // - taker should have the bidAmount minus fee of token0 + // - fee collector should have the fee amount of token0 + // - orderbook should have nothing + await expectTokenBalance(maker, token0, maker.getAddress(), 0n, logger); + await expectTokenBalance(maker, token1, maker.getAddress(), askAmount, logger); + await expectTokenBalance(taker, token0, taker.getAddress(), takerAmount, logger); + await expectTokenBalance(taker, token1, taker.getAddress(), 0n, logger); + await expectTokenBalance(feeCollector, token0, feeCollector.getAddress(), feeAmount, logger); + await expectTokenBalance(taker, token0, orderbook.address, 0n, logger); + await expectTokenBalance(taker, token1, orderbook.address, 0n, logger); + }); + }); +});