-
Notifications
You must be signed in to change notification settings - Fork 592
feat: ClosedSetOrderbook
#14977
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: ClosedSetOrderbook
#14977
Changes from all commits
f816492
87a3392
04555ce
601ee9c
77ba1b6
133bd84
48891cf
a537d01
7cb903c
31a2558
41a4c69
04a886e
7396dff
2be22c8
93738e2
f76fb86
751f6cc
0b0547d
7ade622
d68cfbd
1ebd560
c43f775
31188d7
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 = "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" } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Context> { | ||
| // 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<Config, CHANGE_CONFIG_DELAY, Context>, | ||
| orders: Map<Field, PrivateImmutable<OrderNote, Context>, 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 | ||
|
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. Can rename this to "nelly" if we want to stick to that name instead. |
||
| // 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. | ||
|
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. I think this is quite interesting as here we essentially want Anyway I would probably solve this by expanding WDYT? |
||
| let order_note_storage_slot = storage.orders.at(order_id).get_storage_slot(); | ||
| let (order_retrieved_note, note_hash_for_read_request): (RetrievedNote<OrderNote>, 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() | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious if reviewer thinks we would want to support something like this. When a contract needs to be initialized and the value is scheduled in
#[initializer]then directly activating should be safe.