diff --git a/noir-projects/aztec-nr/uint-note/src/uint_note.nr b/noir-projects/aztec-nr/uint-note/src/uint_note.nr index 0919b7b0caca..8cfb9a73d10b 100644 --- a/noir-projects/aztec-nr/uint-note/src/uint_note.nr +++ b/noir-projects/aztec-nr/uint-note/src/uint_note.nr @@ -12,7 +12,7 @@ use dep::aztec::{ GENERATOR_INDEX__PARTIAL_NOTE_VALIDITY_COMMITMENT, }, hash::poseidon2_hash_with_separator, - traits::{Deserialize, Hash, Packable, Serialize, ToField}, + traits::{Deserialize, FromField, Hash, Packable, Serialize, ToField}, utils::arrays::array_concat, }, }; @@ -207,7 +207,7 @@ impl NoteType for PrivateUintPartialNotePrivateLogContent { /// slot, but the value field has not yet been set. A partial note can be completed in public with the `complete` /// function (revealing the value to the public), resulting in a UintNote that can be used like any other one (except /// of course that its value is known). -#[derive(Packable, Serialize, Deserialize)] +#[derive(Packable, Serialize, Deserialize, Eq)] pub struct PartialUintNote { commitment: Field, } @@ -268,6 +268,18 @@ impl PartialUintNote { } } +impl ToField for PartialUintNote { + fn to_field(self) -> Field { + self.commitment + } +} + +impl FromField for PartialUintNote { + fn from_field(field: Field) -> Self { + Self { commitment: field } + } +} + mod test { use super::{ PartialUintNote, PrivateUintPartialNotePrivateLogContent, UintNote, diff --git a/noir-projects/noir-contracts/Nargo.toml b/noir-projects/noir-contracts/Nargo.toml index 9e6c33825e5a..2a42a1c76410 100644 --- a/noir-projects/noir-contracts/Nargo.toml +++ b/noir-projects/noir-contracts/Nargo.toml @@ -16,6 +16,7 @@ members = [ "contracts/app/escrow_contract", "contracts/app/lending_contract", "contracts/app/nft_contract", + "contracts/app/orderbook_contract", "contracts/app/price_feed_contract", "contracts/app/token_blacklist_contract", "contracts/app/token_bridge_contract", diff --git a/noir-projects/noir-contracts/contracts/app/orderbook_contract/Nargo.toml b/noir-projects/noir-contracts/contracts/app/orderbook_contract/Nargo.toml new file mode 100644 index 000000000000..baa2fa172a00 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/orderbook_contract/Nargo.toml @@ -0,0 +1,10 @@ +[package] +name = "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/orderbook_contract/src/config.nr b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/config.nr new file mode 100644 index 000000000000..5ce897d434b8 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/config.nr @@ -0,0 +1,103 @@ +use dep::aztec::protocol_types::{address::AztecAddress, traits::{Deserialize, Packable, Serialize}}; +use std::meta::derive; + +/// We store the tokens of the DEX in a struct such that to load it from PublicImmutable asserts only a single +/// merkle proof. +#[derive(Deserialize, Eq, Packable, Serialize)] +pub struct Config { + token0: AztecAddress, + token1: AztecAddress, +} + +impl Config { + pub fn new(token0: AztecAddress, token1: AztecAddress) -> Self { + assert(!token0.eq(token1), "Tokens must be different"); + Self { token0, token1 } + } + + 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::{prelude::AztecAddress, protocol_types::traits::FromField}; + + global token0: AztecAddress = AztecAddress::from_field(1); + global token1: AztecAddress = AztecAddress::from_field(2); + global token2: AztecAddress = AztecAddress::from_field(3); + + #[test] + unconstrained fn new_config_valid_inputs() { + let _ = Config::new(token0, token1); + } + + #[test(should_fail_with = "Tokens must be different")] + unconstrained fn new_config_same_tokens() { + let _ = Config::new(token0, token0); + } + + #[test] + unconstrained fn validate_input_tokens_valid() { + let config = Config::new(token0, token1); + + // 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); + 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); + 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); + let _ = config.validate_input_tokens_and_get_direction(token0, token0); + } + + #[test] + unconstrained fn get_tokens_correct_order() { + let config = Config::new(token0, token1); + + 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/orderbook_contract/src/main.nr b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/main.nr new file mode 100644 index 000000000000..6616f855e7ec --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/main.nr @@ -0,0 +1,200 @@ +mod config; +mod order; + +use aztec::macros::aztec; + +/// ## Overview +/// This contract demonstrates how to implement an **Orderbook** that maintains **public state** +/// while still achieving **identity privacy**. However, it does **not provide function privacy**: +/// - Anyone can observe **what actions** were performed. +/// - All amounts involved are visible, but **who** performed the action remains private. +/// +/// **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. +/// - All operations will be fully executed in **public** without needing intermediate checks. +/// +/// ### 2. Public Functions: +/// No **reentrancy guard** is required for public functions because: +/// - All public functions are marked as **internal** with a **single callsite** - from a private function. +/// - Public functions **cannot call private functions**, eliminating the risk of reentering into them from private. +/// - Since public functions are internal-only, **external contracts cannot access them**, ensuring no external +/// contract can trigger a reentrant call. This eliminates the following attack vector: +/// `Orderbook.private_fn --> Orderbook.public_fn --> ExternalContract.fn --> Orderbook.public_fn`. +#[aztec] +pub contract Orderbook { + use crate::{config::Config, order::Order}; + use aztec::{ + event::event_interface::EventInterface, + macros::{ + events::event, + functions::{initializer, internal, private, public, utility}, + storage::storage, + }, + oracle::notes::check_nullifier_exists, + prelude::{AztecAddress, Map, PublicImmutable}, + protocol_types::traits::{FromField, Serialize, ToField}, + unencrypted_logs::unencrypted_event_emission::encode_event, + }; + + use token::Token; + use uint_note::uint_note::PartialUintNote; + + // The event contains only the `order_id` as the order itself can be retrieved via the `get_order` function. + #[derive(Serialize)] + #[event] + struct OrderCreated { + order_id: Field, + } + + #[derive(Serialize)] + #[event] + struct OrderFulfilled { + order_id: Field, + } + + #[storage] + struct Storage { + config: PublicImmutable, + orders: Map, Context>, + } + + #[public] + #[initializer] + fn constructor(token0: AztecAddress, token1: AztecAddress) { + storage.config.initialize(Config::new(token0, token1)); + } + + /// 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, + nonce: Field, + ) -> Field { + let config = storage.config.read(); + + // Create the order (this validates the input tokens and amounts). + let order = Order::new(config, bid_amount, ask_amount, bid_token, ask_token); + + let maker = context.msg_sender(); + + // Transfer tokens from maker to the public balance of this contract. + Token::at(bid_token) + .transfer_to_public(maker, context.this_address(), bid_amount, nonce) + .call(&mut context); + + // Prepare a partial note that will get completed once the order is fulfilled. Note that only the Orderbook + // contract can complete the partial note. + let maker_partial_note = + Token::at(ask_token).prepare_private_balance_increase(maker, maker).call(&mut context); + + // We use the partial note's as the order ID. Because partial notes emit a nullifier when created they are + // unique, and so this guarantees that our order IDs are also unique without having to keep track of past + // ones. + let order_id = maker_partial_note.to_field(); + + // Store the order in public storage and emit an event. + Orderbook::at(context.this_address())._create_order(order_id, order).enqueue(&mut context); + + order_id + } + + #[public] + #[internal] + fn _create_order(order_id: Field, order: Order) { + // Note that PublicImmutable can be initialized only once so this is a secondary check that the order is + // unique. + storage.orders.at(order_id).initialize(order); + + OrderCreated { order_id }.emit(encode_event(&mut context)); + } + + /// 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, nonce: Field) { + let config = storage.config.read(); + let order = storage.orders.at(order_id).read(); + let taker = context.msg_sender(); + + // 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); + + // TODO(#14362): Once we allow partial note completion in private we can skip the transfer to public here and + // just finalize the maker's partial note straight away here. + // Transfer the ask_amount from taker to the contract + Token::at(ask_token) + .transfer_to_public(taker, context.this_address(), order.ask_amount, nonce) + .call(&mut context); + + // Prepare partial note for taker to receive bid_token + let taker_partial_note = + Token::at(bid_token).prepare_private_balance_increase(taker, taker).call(&mut context); + + // Nullify the order such that it cannot be fulfilled again. We emit a nullifier instead of deleting the order + // from public storage because we get no refund for resetting public storage to zero and just emitting + // a nullifier is cheaper (1 Field in DA instead of multiple Fields for the order). We use the `order_id` + // itself as the nullifier because this contract does not work with notes and hence there is no risk of + // colliding with a real note nullifier. + context.push_nullifier(order_id); + + // Enqueue the fulfillment to finalize both partial notes + Orderbook::at(context.this_address()) + ._fulfill_order( + order_id, + taker_partial_note, + bid_token, + ask_token, + order.bid_amount, + order.ask_amount, + ) + .enqueue(&mut context); + } + + #[public] + #[internal] + fn _fulfill_order( + order_id: Field, + taker_partial_note: PartialUintNote, + bid_token: AztecAddress, + ask_token: AztecAddress, + bid_amount: u128, + ask_amount: u128, + ) { + // The `order_id` is a serialized form of the maker's partial note. + let maker_partial_note = PartialUintNote::from_field(order_id); + + // Finalize transfer of ask_amount of ask_token to maker + Token::at(ask_token).finalize_transfer_to_private(ask_amount, maker_partial_note).call( + &mut context, + ); + + // Finalize transfer of bid_amount of bid_token to taker + Token::at(bid_token).finalize_transfer_to_private(bid_amount, taker_partial_note).call( + &mut context, + ); + + OrderFulfilled { order_id }.emit(encode_event(&mut context)); + } + + /// Returns the order and whether it has been fulfilled. + #[utility] + unconstrained fn get_order(order_id: Field) -> pub (Order, bool) { + let order = storage.orders.at(order_id).read(); + let is_fulfilled = check_nullifier_exists(order_id); + + (order, is_fulfilled) + } +} diff --git a/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/order.nr b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/order.nr new file mode 100644 index 000000000000..2ba36043d428 --- /dev/null +++ b/noir-projects/noir-contracts/contracts/app/orderbook_contract/src/order.nr @@ -0,0 +1,87 @@ +use crate::config::Config; +use aztec::{prelude::AztecAddress, protocol_types::traits::{Deserialize, Packable, Serialize}}; + +// TODO: We do not necessarily need full 128 bits for the amounts so we could try to pack the whole order into 1 Field +// and save on public storage costs. +#[derive(Deserialize, Eq, Packable, Serialize)] +pub struct Order { + // 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 Order { + pub fn new( + config: Config, + 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 { bid_amount, ask_amount, bid_token_is_zero } + } +} + +mod test { + use crate::{config::Config, order::Order}; + use aztec::{prelude::AztecAddress, protocol_types::traits::FromField}; + + #[test] + unconstrained fn new_order_valid_inputs() { + let token0 = AztecAddress::from_field(1); + let token1 = AztecAddress::from_field(2); + let config = Config::new(token0, token1); + + let bid_amount = 100; + let ask_amount = 200; + + // Test token0 to token1 direction + let order = Order::new(config, bid_amount, ask_amount, token0, token1); + assert(order.bid_amount == bid_amount); + assert(order.ask_amount == ask_amount); + assert(order.bid_token_is_zero == true); + + // Test token1 to token0 direction + let order = Order::new(config, bid_amount, ask_amount, token1, token0); + assert(order.bid_amount == bid_amount); + assert(order.ask_amount == ask_amount); + assert(order.bid_token_is_zero == false); + } + + #[test(should_fail_with = "ZERO_BID_AMOUNT")] + unconstrained fn new_order_zero_bid_amount() { + let token0 = AztecAddress::from_field(1); + let token1 = AztecAddress::from_field(2); + let config = Config::new(token0, token1); + + let _ = Order::new(config, 0, 100, token0, token1); + } + + #[test(should_fail_with = "ZERO_ASK_AMOUNT")] + unconstrained fn new_order_zero_ask_amount() { + let token0 = AztecAddress::from_field(1); + let token1 = AztecAddress::from_field(2); + let config = Config::new(token0, token1); + + let _ = Order::new(config, 100, 0, token0, token1); + } + + #[test(should_fail_with = "BID_TOKEN_IS_INVALID")] + unconstrained fn new_order_invalid_tokens() { + let token0 = AztecAddress::from_field(1); + let token1 = AztecAddress::from_field(2); + let token2 = AztecAddress::from_field(3); + let config = Config::new(token0, token1); + + let _ = Order::new(config, 100, 100, token2, token1); + } +} diff --git a/yarn-project/end-to-end/src/e2e_orderbook.test.ts b/yarn-project/end-to-end/src/e2e_orderbook.test.ts new file mode 100644 index 000000000000..8c717d1f1257 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_orderbook.test.ts @@ -0,0 +1,145 @@ +import { type AccountWallet, type FieldLike, Fr, type Logger, type PXE } from '@aztec/aztec.js'; +import { type OrderCreated, type OrderFulfilled, OrderbookContract } from '@aztec/noir-contracts.js/Orderbook'; +import type { TokenContract } from '@aztec/noir-contracts.js/Token'; + +import { jest } from '@jest/globals'; + +import { deployToken, mintTokensToPrivate } from './fixtures/token_utils.js'; +import { setup } from './fixtures/utils.js'; + +const TIMEOUT = 120_000; + +// TODO(#14525): Write thorough Orderbook tests. Currently we test only a happy path here because we will migrate these +// tests to TXE once TXE 2.0 is ready. Didn't write it in TXE now as there is no way to obtain public events and all of +// TXE will be rewritten soon. +describe('Orderbook', () => { + jest.setTimeout(TIMEOUT); + + let teardown: () => Promise; + let logger: Logger; + + let pxe: PXE; + + let adminWallet: AccountWallet; + let maker: AccountWallet; + let taker: AccountWallet; + + let token0: TokenContract; + let token1: TokenContract; + let orderbook: OrderbookContract; + + const bidAmount = 1000n; + const askAmount = 2000n; + + beforeAll(async () => { + ({ + pxe, + teardown, + wallets: [adminWallet, maker, taker], + logger, + } = await setup(3)); + + token0 = await deployToken(adminWallet, 0n, logger); + token1 = await deployToken(adminWallet, 0n, logger); + + orderbook = await OrderbookContract.deploy(adminWallet, token0.address, token1.address).send().deployed(); + + // Mint tokens to maker and taker + await mintTokensToPrivate(token0, adminWallet, maker.getAddress(), bidAmount); + await mintTokensToPrivate(token1, adminWallet, taker.getAddress(), askAmount); + }); + + afterAll(() => teardown()); + + describe('full flow - happy path', () => { + let orderId: FieldLike; + + 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_to_public(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(); + + const orderCreatedEvents = await pxe.getPublicEvents(OrderbookContract.events.OrderCreated, 0, 100); + expect(orderCreatedEvents.length).toBe(1); + + // TODO: Check that the order ID returned from create_order matches the one in the event. It's currently not + // supported by Aztec.js to get a return value from a sent transaction. + + // Get order ID from emitted event + orderId = orderCreatedEvents[0].order_id; + + // Get order from orderbook and verify details + const [order, isFulfilled] = await orderbook.methods.get_order(orderId).simulate(); + expect(order.bid_amount).toEqual(bidAmount); + expect(order.ask_amount).toEqual(askAmount); + expect(order.bid_token_is_zero).toBeTrue(); + expect(isFulfilled).toBeFalse(); + + // At this point, bidAmount of token0 should be transferred to the public balance of the orderbook and maker + // should have 0. + const orderbookBalances0 = await token0.withWallet(maker).methods.balance_of_public(orderbook.address).simulate(); + const makerBalances0 = await token0.withWallet(maker).methods.balance_of_private(maker.getAddress()).simulate(); + expect(orderbookBalances0).toEqual(bidAmount); + expect(makerBalances0).toEqual(0n); + }); + + // Note that this test case depends on the previous one. + it('fulfills an order', async () => { + const nonceForAuthwits = Fr.random(); + + // Create authwit for taker to allow orderbook to transfer askAmount of token1 to itself + const takerAuthwit = await taker.createAuthWit({ + caller: orderbook.address, + action: token1.methods.transfer_to_public(taker.getAddress(), orderbook.address, askAmount, nonceForAuthwits), + }); + + // Fulfill order + await orderbook + .withWallet(taker) + .methods.fulfill_order(orderId, nonceForAuthwits) + .with({ authWitnesses: [takerAuthwit] }) + .send() + .wait(); + + // Verify order was fulfilled by checking events + const orderFulfilledEvents = await pxe.getPublicEvents( + OrderbookContract.events.OrderFulfilled, + 0, + 100, + ); + expect(orderFulfilledEvents.length).toBe(1); + expect(orderFulfilledEvents[0].order_id).toEqual(orderId); + + // Verify balances after order fulfillment + const makerBalances0 = await token0.withWallet(maker).methods.balance_of_private(maker.getAddress()).simulate(); + const makerBalances1 = await token1.withWallet(maker).methods.balance_of_private(maker.getAddress()).simulate(); + const takerBalances0 = await token0.withWallet(taker).methods.balance_of_private(taker.getAddress()).simulate(); + const takerBalances1 = await token1.withWallet(taker).methods.balance_of_private(taker.getAddress()).simulate(); + + // Full maker token 0 balance should be transferred to taker and hence maker should have 0 + expect(makerBalances0).toEqual(0n); + // askAmount of token1 should be transferred to maker + expect(makerBalances1).toEqual(askAmount); + // bidAmount of token0 should be transferred to taker + expect(takerBalances0).toEqual(bidAmount); + // Full taker token 1 balance should be transferred to maker and hence taker should have 0 + expect(takerBalances1).toEqual(0n); + + // Verify that the order is fulfilled + const [_, isFulfilled] = await orderbook.methods.get_order(orderId).simulate(); + expect(isFulfilled).toBeTrue(); + }); + }); +});