diff --git a/contract_/src/OrderBook.cairo b/contract_/src/OrderBook.cairo new file mode 100644 index 0000000..ea89243 --- /dev/null +++ b/contract_/src/OrderBook.cairo @@ -0,0 +1,375 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IOrderBook { + fn place_buy_order( + ref self: TContractState, token_address: ContractAddress, amount: u256, price: u256, + ) -> u64; + fn place_sell_order( + ref self: TContractState, token_address: ContractAddress, amount: u256, price: u256, + ) -> u64; + fn cancel_order(ref self: TContractState, order_id: u64); + fn match_orders_batch( + ref self: TContractState, token_address: ContractAddress, max_matches: u32, + ); + fn get_order(self: @TContractState, order_id: u64) -> Order; + fn get_best_buy_price(self: @TContractState, token_address: ContractAddress) -> u256; + fn get_best_sell_price(self: @TContractState, token_address: ContractAddress) -> u256; + fn get_order_book_depth(self: @TContractState, token_address: ContractAddress) -> (u32, u32); +} + +#[derive(Drop, Copy, Serde, starknet::Store)] +pub struct Order { + pub id: u64, + pub user: ContractAddress, + pub token_address: ContractAddress, + pub amount: u256, + pub price: u256, + pub filled_amount: u256, + pub is_buy: bool, + pub is_active: bool, +} + +#[starknet::contract] +pub mod OrderBook { + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::storage::*; + use starknet::{ContractAddress, get_caller_address}; + use super::{IOrderBook, Order}; + + #[storage] + pub struct Storage { + orders: Map, + next_order_id: u64, + // Price-sorted order queues for gas optimization + buy_price_levels: Map<(ContractAddress, u256), Vec>, + sell_price_levels: Map<(ContractAddress, u256), Vec>, + // Track best prices for quick access + best_buy_prices: Map, + best_sell_prices: Map, + // Order count per token for depth calculation + active_buy_orders: Map, + active_sell_orders: Map, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + OrderPlaced: OrderPlaced, + OrderCancelled: OrderCancelled, + OrderMatched: OrderMatched, + BatchMatchingCompleted: BatchMatchingCompleted, + } + + #[derive(Drop, starknet::Event)] + pub struct OrderPlaced { + pub order_id: u64, + pub user: ContractAddress, + pub token_address: ContractAddress, + pub is_buy: bool, + pub amount: u256, + pub price: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct OrderCancelled { + pub order_id: u64, + pub user: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct OrderMatched { + pub buy_order_id: u64, + pub sell_order_id: u64, + pub buyer: ContractAddress, + pub seller: ContractAddress, + pub token_address: ContractAddress, + pub amount: u256, + pub price: u256, + } + + #[derive(Drop, starknet::Event)] + pub struct BatchMatchingCompleted { + pub token_address: ContractAddress, + pub matches_processed: u32, + } + + #[constructor] + fn constructor(ref self: ContractState) { + self.next_order_id.write(1); + } + + #[abi(embed_v0)] + pub impl OrderBookImpl of IOrderBook { + fn place_buy_order( + ref self: ContractState, token_address: ContractAddress, amount: u256, price: u256, + ) -> u64 { + let caller = get_caller_address(); + let order_id = self.next_order_id.read(); + + let order = Order { + id: order_id, + user: caller, + token_address, + amount, + price, + filled_amount: 0, + is_buy: true, + is_active: true, + }; + + self.orders.entry(order_id).write(order); + self.buy_price_levels.entry((token_address, price)).push(order_id); + + // Update best buy price + let current_best = self.best_buy_prices.entry(token_address).read(); + if price > current_best { + self.best_buy_prices.entry(token_address).write(price); + } + + // Update order count + let current_count = self.active_buy_orders.entry(token_address).read(); + self.active_buy_orders.entry(token_address).write(current_count + 1); + + self.next_order_id.write(order_id + 1); + + self + .emit( + Event::OrderPlaced( + OrderPlaced { + order_id, user: caller, token_address, is_buy: true, amount, price, + }, + ), + ); + + order_id + } + + fn place_sell_order( + ref self: ContractState, token_address: ContractAddress, amount: u256, price: u256, + ) -> u64 { + let caller = get_caller_address(); + let order_id = self.next_order_id.read(); + + // Verify user has enough tokens + let token = IERC20Dispatcher { contract_address: token_address }; + let balance = token.balance_of(caller); + assert(balance >= amount, 'Insufficient token balance'); + + let order = Order { + id: order_id, + user: caller, + token_address, + amount, + price, + filled_amount: 0, + is_buy: false, + is_active: true, + }; + + self.orders.entry(order_id).write(order); + self.sell_price_levels.entry((token_address, price)).push(order_id); + + // Update best sell price + let current_best = self.best_sell_prices.entry(token_address).read(); + if current_best == 0 || price < current_best { + self.best_sell_prices.entry(token_address).write(price); + } + + // Update order count + let current_count = self.active_sell_orders.entry(token_address).read(); + self.active_sell_orders.entry(token_address).write(current_count + 1); + + self.next_order_id.write(order_id + 1); + + self + .emit( + Event::OrderPlaced( + OrderPlaced { + order_id, user: caller, token_address, is_buy: false, amount, price, + }, + ), + ); + + order_id + } + + fn cancel_order(ref self: ContractState, order_id: u64) { + let caller = get_caller_address(); + let mut order = self.orders.entry(order_id).read(); + + assert(order.user == caller, 'Not order owner'); + assert(order.is_active, 'Order not active'); + + order.is_active = false; + self.orders.entry(order_id).write(order); + + // Update order counts + if order.is_buy { + let current_count = self.active_buy_orders.entry(order.token_address).read(); + self.active_buy_orders.entry(order.token_address).write(current_count - 1); + } else { + let current_count = self.active_sell_orders.entry(order.token_address).read(); + self.active_sell_orders.entry(order.token_address).write(current_count - 1); + } + + self.emit(Event::OrderCancelled(OrderCancelled { order_id, user: caller })); + } + + fn match_orders_batch( + ref self: ContractState, token_address: ContractAddress, max_matches: u32, + ) { + let mut matches_processed = 0; + let best_buy_price = self.best_buy_prices.entry(token_address).read(); + let best_sell_price = self.best_sell_prices.entry(token_address).read(); + + if best_buy_price == 0 || best_sell_price == 0 || best_buy_price < best_sell_price { + return; + } + + // Process matches up to max_matches limit for gas optimization + while matches_processed != max_matches { + let current_best_buy = self.best_buy_prices.entry(token_address).read(); + let current_best_sell = self.best_sell_prices.entry(token_address).read(); + + if current_best_buy < current_best_sell { + break; + } + + let buy_orders = self.buy_price_levels.entry((token_address, current_best_buy)); + let sell_orders = self.sell_price_levels.entry((token_address, current_best_sell)); + + if buy_orders.len() == 0 || sell_orders.len() == 0 { + break; + } + + let buy_order_id = buy_orders.at(0).read(); + let sell_order_id = sell_orders.at(0).read(); + + let mut buy_order = self.orders.entry(buy_order_id).read(); + let mut sell_order = self.orders.entry(sell_order_id).read(); + + if !buy_order.is_active || !sell_order.is_active { + // Skip inactive orders - break to avoid infinite loop + break; + } + + let remaining_buy = buy_order.amount - buy_order.filled_amount; + let remaining_sell = sell_order.amount - sell_order.filled_amount; + let trade_amount = if remaining_buy < remaining_sell { + remaining_buy + } else { + remaining_sell + }; + + // Execute trade + self + ._execute_trade( + ref buy_order, + ref sell_order, + trade_amount, + current_best_sell, + token_address, + ); + + self.orders.entry(buy_order_id).write(buy_order); + self.orders.entry(sell_order_id).write(sell_order); + + matches_processed += 1; + + // Update best prices if orders are fully filled + if buy_order.filled_amount >= buy_order.amount { + self._update_best_buy_price(token_address); + } + if sell_order.filled_amount >= sell_order.amount { + self._update_best_sell_price(token_address); + } + } + + self + .emit( + Event::BatchMatchingCompleted( + BatchMatchingCompleted { token_address, matches_processed }, + ), + ); + } + + fn get_order(self: @ContractState, order_id: u64) -> Order { + self.orders.entry(order_id).read() + } + + fn get_best_buy_price(self: @ContractState, token_address: ContractAddress) -> u256 { + self.best_buy_prices.entry(token_address).read() + } + + fn get_best_sell_price(self: @ContractState, token_address: ContractAddress) -> u256 { + self.best_sell_prices.entry(token_address).read() + } + + fn get_order_book_depth( + self: @ContractState, token_address: ContractAddress, + ) -> (u32, u32) { + let buy_count = self.active_buy_orders.entry(token_address).read(); + let sell_count = self.active_sell_orders.entry(token_address).read(); + (buy_count, sell_count) + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn _execute_trade( + ref self: ContractState, + ref buy_order: Order, + ref sell_order: Order, + amount: u256, + price: u256, + token_address: ContractAddress, + ) { + let token = IERC20Dispatcher { contract_address: token_address }; + + let success = token.transfer_from(sell_order.user, buy_order.user, amount); + assert(success, 'Token transfer failed'); + + buy_order.filled_amount += amount; + sell_order.filled_amount += amount; + + if buy_order.filled_amount >= buy_order.amount { + buy_order.is_active = false; + } + if sell_order.filled_amount >= sell_order.amount { + sell_order.is_active = false; + } + + self + .emit( + Event::OrderMatched( + OrderMatched { + buy_order_id: buy_order.id, + sell_order_id: sell_order.id, + buyer: buy_order.user, + seller: sell_order.user, + token_address, + amount, + price, + }, + ), + ); + } + + fn _update_best_buy_price(ref self: ContractState, token_address: ContractAddress) { + // Find the next highest buy price with active orders + // This is a simplified implementation + let current_best = self.best_buy_prices.entry(token_address).read(); + if current_best > 0 { + self.best_buy_prices.entry(token_address).write(current_best - 1); + } + } + + fn _update_best_sell_price(ref self: ContractState, token_address: ContractAddress) { + // Find the next lowest sell price with active orders + // This is a simplified implementation + let current_best = self.best_sell_prices.entry(token_address).read(); + self.best_sell_prices.entry(token_address).write(current_best + 1); + } + } +} diff --git a/contract_/src/lib.cairo b/contract_/src/lib.cairo index 6f4b4fb..83da546 100644 --- a/contract_/src/lib.cairo +++ b/contract_/src/lib.cairo @@ -1,2 +1,3 @@ pub mod BigIncGenesis; pub mod MockERC20; +pub mod OrderBook; diff --git a/contract_/tests/test_order_book.cairo b/contract_/tests/test_order_book.cairo new file mode 100644 index 0000000..4cb33e9 --- /dev/null +++ b/contract_/tests/test_order_book.cairo @@ -0,0 +1,186 @@ +use contract_::OrderBook::{IOrderBookDispatcher, IOrderBookDispatcherTrait}; +use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address, + stop_cheat_caller_address, +}; +use starknet::{ContractAddress, contract_address_const}; + +fn deploy_mock_token(initial_supply: u256, recipient: ContractAddress) -> ContractAddress { + let contract = declare("MockERC20").unwrap().contract_class(); + let constructor_calldata = array![ + initial_supply.low.into(), initial_supply.high.into(), recipient.into(), + ]; + let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap(); + contract_address +} + +fn deploy_order_book() -> ContractAddress { + let contract = declare("OrderBook").unwrap().contract_class(); + let constructor_calldata = array![]; + let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap(); + contract_address +} + +#[test] +fn test_place_buy_order() { + let alice: ContractAddress = contract_address_const::<'alice'>(); + let order_book_address = deploy_order_book(); + let token_address = deploy_mock_token(1000000, alice); + + let order_book = IOrderBookDispatcher { contract_address: order_book_address }; + + start_cheat_caller_address(order_book_address, alice); + + let order_id = order_book.place_buy_order(token_address, 100, 50); + + let order = order_book.get_order(order_id); + assert!(order.id == order_id, "Order ID mismatch"); + assert!(order.user == alice, "Order user mismatch"); + assert!(order.amount == 100, "Order amount mismatch"); + assert!(order.price == 50, "Order price mismatch"); + assert!(order.is_active, "Order should be active"); + + let (buy_count, _) = order_book.get_order_book_depth(token_address); + assert!(buy_count == 1, "Should have one buy order"); + + stop_cheat_caller_address(order_book_address); +} + +#[test] +fn test_place_sell_order() { + let alice: ContractAddress = contract_address_const::<'alice'>(); + let order_book_address = deploy_order_book(); + let token_address = deploy_mock_token(1000000, alice); + + let order_book = IOrderBookDispatcher { contract_address: order_book_address }; + let token = IERC20Dispatcher { contract_address: token_address }; + + start_cheat_caller_address(order_book_address, alice); + start_cheat_caller_address(token_address, alice); + + // Approve order book to spend tokens + token.approve(order_book_address, 1000000); + + let order_id = order_book.place_sell_order(token_address, 100, 60); + + let order = order_book.get_order(order_id); + assert!(order.id == order_id, "Order ID mismatch"); + assert!(order.user == alice, "Order user mismatch"); + assert!(order.amount == 100, "Order amount mismatch"); + assert!(order.price == 60, "Order price mismatch"); + assert!(order.is_active, "Order should be active"); + + let (_, sell_count) = order_book.get_order_book_depth(token_address); + assert!(sell_count == 1, "Should have one sell order"); + + stop_cheat_caller_address(token_address); + stop_cheat_caller_address(order_book_address); +} + +#[test] +fn test_cancel_order() { + let alice: ContractAddress = contract_address_const::<'alice'>(); + let order_book_address = deploy_order_book(); + let token_address = deploy_mock_token(1000000, alice); + + let order_book = IOrderBookDispatcher { contract_address: order_book_address }; + + start_cheat_caller_address(order_book_address, alice); + + let order_id = order_book.place_buy_order(token_address, 100, 50); + + // Verify order is active + let order = order_book.get_order(order_id); + assert!(order.is_active, "Order should be active"); + + // Cancel the order + order_book.cancel_order(order_id); + + // Verify order is cancelled + let cancelled_order = order_book.get_order(order_id); + assert!(!cancelled_order.is_active, "Order should be cancelled"); + + stop_cheat_caller_address(order_book_address); +} + +#[test] +fn test_order_matching() { + let alice: ContractAddress = contract_address_const::<'alice'>(); + let bob: ContractAddress = contract_address_const::<'bob'>(); + let order_book_address = deploy_order_book(); + let token_address = deploy_mock_token(1000000, alice); + + let order_book = IOrderBookDispatcher { contract_address: order_book_address }; + let token = IERC20Dispatcher { contract_address: token_address }; + + // Alice places a sell order + start_cheat_caller_address(token_address, alice); + start_cheat_caller_address(order_book_address, alice); + token.approve(order_book_address, 1000000); + let sell_order_id = order_book.place_sell_order(token_address, 100, 50); + stop_cheat_caller_address(order_book_address); + stop_cheat_caller_address(token_address); + + // Bob places a buy order with higher price + start_cheat_caller_address(order_book_address, bob); + let buy_order_id = order_book.place_buy_order(token_address, 100, 60); + stop_cheat_caller_address(order_book_address); + + // Check initial balances + let bob_balance_before = token.balance_of(bob); + + // Match orders + order_book.match_orders_batch(token_address, 10); + + // Verify orders are filled + let sell_order = order_book.get_order(sell_order_id); + let buy_order = order_book.get_order(buy_order_id); + + assert!(sell_order.filled_amount == 100, "Sell order should be fully filled"); + assert!(buy_order.filled_amount == 100, "Buy order should be fully filled"); + assert!(!sell_order.is_active, "Sell order should be inactive"); + assert!(!buy_order.is_active, "Buy order should be inactive"); + + // Verify token transfer (Bob should have received tokens) + let bob_balance_after = token.balance_of(bob); + assert!(bob_balance_after == bob_balance_before + 100, "Bob should receive tokens"); +} + +#[test] +#[should_panic(expected: 'Insufficient token balance')] +fn test_sell_order_insufficient_balance() { + let alice: ContractAddress = contract_address_const::<'alice'>(); + let order_book_address = deploy_order_book(); + let token_address = deploy_mock_token(50, alice); // Only 50 tokens + + let order_book = IOrderBookDispatcher { contract_address: order_book_address }; + + start_cheat_caller_address(order_book_address, alice); + + // Try to sell 100 tokens when only having 50 + order_book.place_sell_order(token_address, 100, 60); + + stop_cheat_caller_address(order_book_address); +} + +#[test] +#[should_panic(expected: 'Not order owner')] +fn test_cancel_order_not_owner() { + let alice: ContractAddress = contract_address_const::<'alice'>(); + let bob: ContractAddress = contract_address_const::<'bob'>(); + let order_book_address = deploy_order_book(); + let token_address = deploy_mock_token(1000000, alice); + + let order_book = IOrderBookDispatcher { contract_address: order_book_address }; + + // Alice places an order + start_cheat_caller_address(order_book_address, alice); + let order_id = order_book.place_buy_order(token_address, 100, 50); + stop_cheat_caller_address(order_book_address); + + // Bob tries to cancel Alice's order + start_cheat_caller_address(order_book_address, bob); + order_book.cancel_order(order_id); + stop_cheat_caller_address(order_book_address); +}