-
Notifications
You must be signed in to change notification settings - Fork 600
feat: simple Orderbook DEX #14464
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: simple Orderbook DEX #14464
Changes from all commits
0f361bb
b3517f4
6d71ec1
8f3edb8
a9f4573
0060448
1edeb21
ae387df
5eaf2e6
9c3a5b0
01d3dad
5371330
6c5c001
46eb3f3
3d52b57
af2da1d
994a114
3aa3fa5
b801c42
13b203b
88fb65f
294c94f
65f6141
50e2bdf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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** | ||
nventuro marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /// 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<Context> { | ||
| config: PublicImmutable<Config, Context>, | ||
| orders: Map<Field, PublicImmutable<Order, Context>, 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, | ||
nventuro marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ) -> 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); | ||
benesjan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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(); | ||
benesjan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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. | ||
benesjan marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, | ||
nventuro marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ); | ||
|
|
||
| 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); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unsure. The way we do this in private is with the |
||
|
|
||
| (order, is_fulfilled) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.