Skip to main content

tempo_precompiles/stablecoin_dex/
mod.rs

1//! On-chain CLOB (Central Limit Order Book) for [stablecoin trading].
2//!
3//! Supports limit orders, market swaps, and flip orders across
4//! TIP-20 token pairs with tick-based pricing and price-time priority.
5//!
6//! [stablecoin trading]: <https://docs.tempo.xyz/protocol/exchange>
7
8pub mod dispatch;
9pub mod error;
10pub mod order;
11pub mod orderbook;
12
13pub use order::Order;
14pub use orderbook::{
15    MAX_TICK, MIN_TICK, Orderbook, PRICE_SCALE, RoundingDirection, TickLevel, base_to_quote,
16    quote_to_base, tick_to_price, validate_tick_spacing,
17};
18use tempo_contracts::precompiles::PATH_USD_ADDRESS;
19pub use tempo_contracts::precompiles::{IStablecoinDEX, StablecoinDEXError, StablecoinDEXEvents};
20
21use crate::{
22    STABLECOIN_DEX_ADDRESS,
23    error::{Result, TempoPrecompileError},
24    stablecoin_dex::orderbook::{MAX_PRICE, MIN_PRICE, compute_book_key},
25    storage::{Handler, Mapping},
26    tip20::{ITIP20, TIP20Token, is_tip20_prefix, validate_usd_currency},
27    tip20_factory::TIP20Factory,
28    tip403_registry::{AuthRole, TIP403Registry, is_policy_lookup_error},
29};
30use alloy::primitives::{Address, B256, U256};
31use tempo_precompiles_macros::contract;
32
33/// Minimum order size of $100 USD
34pub const MIN_ORDER_AMOUNT: u128 = 100_000_000;
35
36/// Allowed tick spacing for order placement
37pub const TICK_SPACING: i16 = 10;
38
39/// On-chain CLOB (Central Limit Order Book) for stablecoin trading.
40///
41/// Supports limit orders, market swaps, and flip orders across USD-denominated TIP-20 token pairs.
42/// Orders use tick-based pricing with price-time priority.
43///
44/// The struct fields define the on-chain storage layout; the `#[contract]` macro generates the
45/// storage handlers which provide an ergonomic way to interact with the EVM state.
46#[contract(addr = STABLECOIN_DEX_ADDRESS)]
47pub struct StablecoinDEX {
48    books: Mapping<B256, Orderbook>,
49    orders: Mapping<u128, Order>,
50    balances: Mapping<Address, Mapping<Address, u128>>,
51    next_order_id: u128,
52    book_keys: Vec<B256>,
53}
54
55impl StablecoinDEX {
56    /// Returns the [`StablecoinDEX`] address.
57    pub fn address(&self) -> Address {
58        self.address
59    }
60
61    /// Initializes the stablecoin DEX precompile.
62    pub fn initialize(&mut self) -> Result<()> {
63        // must ensure the account is not empty, by setting some code
64        self.__initialize()
65    }
66
67    /// Read next order ID (always at least 1)
68    fn next_order_id(&self) -> Result<u128> {
69        Ok(self.next_order_id.read()?.max(1))
70    }
71
72    /// Increment next order ID
73    fn increment_next_order_id(&mut self) -> Result<()> {
74        let next_order_id = self.next_order_id()?;
75        self.next_order_id.write(next_order_id + 1)
76    }
77
78    /// Returns the user's DEX balance for `token`.
79    pub fn balance_of(&self, user: Address, token: Address) -> Result<u128> {
80        self.balances[user][token].read()
81    }
82
83    /// Returns the minimum representable scaled price (`MIN_PRICE`).
84    pub fn min_price(&self) -> u32 {
85        MIN_PRICE
86    }
87
88    /// Returns the maximum representable scaled price (`MAX_PRICE`).
89    pub fn max_price(&self) -> u32 {
90        MAX_PRICE
91    }
92
93    /// Validates that a trading pair exists or creates the pair
94    fn validate_or_create_pair(&mut self, book: &Orderbook, token: Address) -> Result<()> {
95        if book.base.is_zero() {
96            self.create_pair(token)?;
97        }
98        Ok(())
99    }
100
101    /// Fetches an active [`Order`] from storage by ID.
102    ///
103    /// # Errors
104    /// - `OrderDoesNotExist` — order has a zero maker (already filled/deleted) or has not yet
105    ///   been assigned (ID ≥ next order ID)
106    pub fn get_order(&self, order_id: u128) -> Result<Order> {
107        let order = self.orders[order_id].read()?;
108
109        // If the order is not filled and currently active
110        if !order.maker().is_zero() && order.order_id() < self.next_order_id()? {
111            Ok(order)
112        } else {
113            Err(StablecoinDEXError::order_does_not_exist().into())
114        }
115    }
116
117    /// Set user's balance for a specific token
118    fn set_balance(&mut self, user: Address, token: Address, amount: u128) -> Result<()> {
119        self.balances[user][token].write(amount)
120    }
121
122    /// Add to user's balance
123    fn increment_balance(&mut self, user: Address, token: Address, amount: u128) -> Result<()> {
124        let current = self.balance_of(user, token)?;
125        self.set_balance(
126            user,
127            token,
128            current
129                .checked_add(amount)
130                .ok_or(TempoPrecompileError::under_overflow())?,
131        )
132    }
133
134    /// Subtract from user's balance.
135    fn sub_balance(&mut self, user: Address, token: Address, amount: u128) -> Result<()> {
136        let current = self.balance_of(user, token)?;
137        self.set_balance(
138            user,
139            token,
140            current
141                .checked_sub(amount)
142                .ok_or(TempoPrecompileError::under_overflow())?,
143        )
144    }
145
146    /// Emit the appropriate OrderFilled event
147    fn emit_order_filled(
148        &mut self,
149        order_id: u128,
150        maker: Address,
151        taker: Address,
152        amount_filled: u128,
153        partial_fill: bool,
154    ) -> Result<()> {
155        self.emit_event(StablecoinDEXEvents::OrderFilled(
156            IStablecoinDEX::OrderFilled {
157                orderId: order_id,
158                maker,
159                taker,
160                amountFilled: amount_filled,
161                partialFill: partial_fill,
162            },
163        ))?;
164
165        Ok(())
166    }
167
168    /// Transfer tokens, accounting for pathUSD
169    fn transfer(&mut self, token: Address, to: Address, amount: u128) -> Result<()> {
170        TIP20Token::from_address(token)?.transfer(
171            self.address,
172            ITIP20::transferCall {
173                to,
174                amount: U256::from(amount),
175            },
176        )?;
177        Ok(())
178    }
179
180    /// Transfer tokens from user, accounting for pathUSD
181    fn transfer_from(&mut self, token: Address, from: Address, amount: u128) -> Result<()> {
182        TIP20Token::from_address(token)?.transfer_from(
183            self.address,
184            ITIP20::transferFromCall {
185                from,
186                to: self.address,
187                amount: U256::from(amount),
188            },
189        )?;
190        Ok(())
191    }
192
193    /// Decrement user's internal balance or transfer from external wallet
194    fn decrement_balance_or_transfer_from(
195        &mut self,
196        user: Address,
197        token: Address,
198        amount: u128,
199    ) -> Result<()> {
200        TIP20Token::from_address(token)?.ensure_transfer_authorized(user, self.address)?;
201
202        let user_balance = self.balance_of(user, token)?;
203        if user_balance >= amount {
204            self.sub_balance(user, token, amount)
205        } else {
206            let remaining = amount
207                .checked_sub(user_balance)
208                .ok_or(TempoPrecompileError::under_overflow())?;
209
210            self.transfer_from(token, user, remaining)?;
211            self.set_balance(user, token, 0)
212        }
213    }
214
215    /// Quotes the input amount required to receive exactly `amount_out` tokens, routing through
216    /// one or more orderbooks without executing trades.
217    ///
218    /// # Errors
219    /// - `IdenticalTokens` — `token_in` and `token_out` are the same address
220    /// - `InvalidToken` — a token address does not have a valid TIP-20 prefix
221    /// - `PairDoesNotExist` — no orderbook exists for one of the hops in the route
222    /// - `InsufficientLiquidity` — not enough resting orders to fill `amount_out`
223    pub fn quote_swap_exact_amount_out(
224        &self,
225        token_in: Address,
226        token_out: Address,
227        amount_out: u128,
228    ) -> Result<u128> {
229        // Find and validate the trade route (book keys + direction for each hop)
230        let route = self.find_trade_path(token_in, token_out)?;
231
232        // Execute quotes backwards from output to input
233        let mut current_amount = amount_out;
234        for (book_key, base_for_quote) in route.iter().rev() {
235            current_amount = self.quote_exact_out(*book_key, current_amount, *base_for_quote)?;
236        }
237
238        Ok(current_amount)
239    }
240
241    /// Quotes the output amount received for exactly `amount_in` input tokens, routing through
242    /// one or more orderbooks without executing trades.
243    ///
244    /// # Errors
245    /// - `IdenticalTokens` — `token_in` and `token_out` are the same address
246    /// - `InvalidToken` — a token address does not have a valid TIP-20 prefix
247    /// - `PairDoesNotExist` — no orderbook exists for one of the hops in the route
248    /// - `InsufficientLiquidity` — not enough resting orders to fill `amount_in`
249    pub fn quote_swap_exact_amount_in(
250        &self,
251        token_in: Address,
252        token_out: Address,
253        amount_in: u128,
254    ) -> Result<u128> {
255        // Find and validate the trade route (book keys + direction for each hop)
256        let route = self.find_trade_path(token_in, token_out)?;
257
258        // Execute quotes for each hop using precomputed book keys and directions
259        let mut current_amount = amount_in;
260        for (book_key, base_for_quote) in route {
261            current_amount = self.quote_exact_in(book_key, current_amount, base_for_quote)?;
262        }
263
264        Ok(current_amount)
265    }
266
267    /// Swaps `amount_in` of `token_in` for `token_out`, routing through
268    /// one or more orderbooks. Deducts input via [`TIP20Token`] transfer
269    /// or DEX balance, then fills orders at best price per hop.
270    ///
271    /// # Errors
272    /// - `InvalidBaseToken` — token address does not have a valid TIP-20 prefix
273    /// - `PairNotFound` — no orderbook exists for the token pair
274    /// - `InsufficientOutput` — final output amount falls below `min_amount_out`
275    /// - `InsufficientBalance` — sender balance lower than required input
276    pub fn swap_exact_amount_in(
277        &mut self,
278        sender: Address,
279        token_in: Address,
280        token_out: Address,
281        amount_in: u128,
282        min_amount_out: u128,
283    ) -> Result<u128> {
284        // Find and validate the trade route (book keys + direction for each hop)
285        let route = self.find_trade_path(token_in, token_out)?;
286
287        // Deduct input tokens from sender (only once, at the start)
288        self.decrement_balance_or_transfer_from(sender, token_in, amount_in)?;
289
290        // Execute swaps for each hop - intermediate balances are transitory
291        let mut amount = amount_in;
292        for (book_key, base_for_quote) in route {
293            // Fill orders for this hop - no min check on intermediate hops
294            amount = self.fill_orders_exact_in(book_key, base_for_quote, amount, sender)?;
295        }
296
297        // Check final output meets minimum requirement
298        if amount < min_amount_out {
299            return Err(StablecoinDEXError::insufficient_output().into());
300        }
301
302        self.transfer(token_out, sender, amount)?;
303
304        Ok(amount)
305    }
306
307    /// Swaps to receive exactly `amount_out` of `token_out`, routing
308    /// through one or more orderbooks. Works backwards from output to
309    /// compute input, then deducts via [`TIP20Token`] or DEX balance.
310    ///
311    /// # Errors
312    /// - `InvalidBaseToken` — token address does not have a valid TIP-20 prefix
313    /// - `PairNotFound` — no orderbook exists for the token pair
314    /// - `MaxInputExceeded` — required input exceeds `max_amount_in`
315    /// - `InsufficientBalance` — sender balance lower than required input
316    pub fn swap_exact_amount_out(
317        &mut self,
318        sender: Address,
319        token_in: Address,
320        token_out: Address,
321        amount_out: u128,
322        max_amount_in: u128,
323    ) -> Result<u128> {
324        // Find and validate the trade route (book keys + direction for each hop)
325        let route = self.find_trade_path(token_in, token_out)?;
326
327        // Work backwards from output to calculate input needed - intermediate amounts are TRANSITORY
328        let mut amount = amount_out;
329        for (book_key, base_for_quote) in route.iter().rev() {
330            amount = self.fill_orders_exact_out(*book_key, *base_for_quote, amount, sender)?;
331        }
332
333        if amount > max_amount_in {
334            return Err(StablecoinDEXError::max_input_exceeded().into());
335        }
336
337        // Deduct input tokens ONCE at end
338        self.decrement_balance_or_transfer_from(sender, token_in, amount)?;
339
340        // Transfer only final output ONCE at end
341        self.transfer(token_out, sender, amount_out)?;
342
343        Ok(amount)
344    }
345
346    /// Returns the [`TickLevel`] for a given `base` token, `tick`, and side. Looks up the
347    /// quote token via [`TIP20Token`] and derives the book key.
348    ///
349    /// # Errors
350    /// - `InvalidBaseToken` — `base` address does not resolve to a valid [`TIP20Token`]
351    pub fn get_price_level(&self, base: Address, tick: i16, is_bid: bool) -> Result<TickLevel> {
352        let quote = TIP20Token::from_address(base)?.quote_token()?;
353        let book_key = compute_book_key(base, quote);
354        if is_bid {
355            self.books[book_key].bids[tick].read()
356        } else {
357            self.books[book_key].asks[tick].read()
358        }
359    }
360
361    /// Returns the [`Orderbook`] for a given pair key.
362    pub fn books(&self, pair_key: B256) -> Result<Orderbook> {
363        self.books[pair_key].read()
364    }
365
366    /// Returns all registered orderbook keys.
367    pub fn get_book_keys(&self) -> Result<Vec<B256>> {
368        self.book_keys.read()
369    }
370
371    /// Converts a relative tick to a scaled price. On T2+ validates [`TICK_SPACING`] alignment.
372    ///
373    /// # Errors
374    /// - `InvalidTick` — tick is not aligned to [`TICK_SPACING`] (T2+ only)
375    pub fn tick_to_price(&self, tick: i16) -> Result<u32> {
376        if self.storage.spec().is_t2() {
377            orderbook::validate_tick_spacing(tick)?;
378        }
379
380        Ok(orderbook::tick_to_price(tick))
381    }
382
383    /// Converts a scaled price to a relative tick. On T2+ validates [`TICK_SPACING`] alignment.
384    ///
385    /// # Errors
386    /// - `TickOutOfBounds` — price is outside the `[MIN_PRICE, MAX_PRICE]` range
387    /// - `InvalidTick` — resulting tick is not aligned to [`TICK_SPACING`] (T2+ only)
388    pub fn price_to_tick(&self, price: u32) -> Result<i16> {
389        let tick = orderbook::price_to_tick(price)?;
390
391        if self.storage.spec().is_t2() {
392            orderbook::validate_tick_spacing(tick)?;
393        }
394
395        Ok(tick)
396    }
397
398    /// Creates a new trading pair between `base` and its quote token.
399    /// Both must be USD-denominated tokens validated via
400    /// [`TIP20Factory`]. Reverts if the pair already exists.
401    ///
402    /// # Errors
403    /// - `InvalidBaseToken` — token address does not have a valid TIP-20 prefix
404    /// - `InvalidCurrency` — both tokens must be USD-denominated (validated via [`TIP20Factory`]).
405    /// - `PairAlreadyExists` — an orderbook for this pair is already initialized
406    pub fn create_pair(&mut self, base: Address) -> Result<B256> {
407        // Validate that base is a TIP20 token
408        if !TIP20Factory::new().is_tip20(base)? {
409            return Err(StablecoinDEXError::invalid_base_token().into());
410        }
411
412        let quote = TIP20Token::from_address(base)?.quote_token()?;
413        validate_usd_currency(base)?;
414        validate_usd_currency(quote)?;
415
416        let book_key = compute_book_key(base, quote);
417
418        if self.books[book_key].read()?.is_initialized() {
419            return Err(StablecoinDEXError::pair_already_exists().into());
420        }
421
422        let book = Orderbook::new(base, quote);
423        self.books[book_key].write(book)?;
424        self.book_keys.push(book_key)?;
425
426        // Emit PairCreated event
427        self.emit_event(StablecoinDEXEvents::PairCreated(
428            IStablecoinDEX::PairCreated {
429                key: book_key,
430                base,
431                quote,
432            },
433        ))?;
434
435        Ok(book_key)
436    }
437
438    /// Places a limit order on the orderbook for `token` against its quote token.
439    /// Escrows the appropriate amount via [`TIP20Token`] transfer or DEX balance and enforces
440    /// compliance via the [`TIP403Registry`]. Auto-creates the trading pair if needed.
441    ///
442    /// # Errors
443    /// - `InvalidBaseToken` — token address does not have a valid TIP-20 prefix
444    /// - `TickOutOfBounds` — tick is outside the allowed `[MIN_TICK, MAX_TICK]` range
445    /// - `InvalidTick` — tick is not aligned to `TICK_SPACING`
446    /// - `BelowMinimumOrderSize` — order amount is below `MIN_ORDER_AMOUNT`
447    /// - `InsufficientBalance` — sender balance lower than required escrow
448    /// - `PolicyForbids` — TIP-403 policy rejects the token transfer
449    ///
450    /// # Returns
451    /// The assigned order ID
452    pub fn place(
453        &mut self,
454        sender: Address,
455        token: Address,
456        amount: u128,
457        is_bid: bool,
458        tick: i16,
459    ) -> Result<u128> {
460        let quote_token = TIP20Token::from_address(token)?.quote_token()?;
461
462        // Compute book_key from token pair
463        let book_key = compute_book_key(token, quote_token);
464
465        let book = self.books[book_key].read()?;
466        self.validate_or_create_pair(&book, token)?;
467
468        // Validate tick is within bounds
469        if !(MIN_TICK..=MAX_TICK).contains(&tick) {
470            return Err(StablecoinDEXError::tick_out_of_bounds(tick).into());
471        }
472
473        // Enforce that the tick adheres to tick spacing
474        if tick % TICK_SPACING != 0 {
475            return Err(StablecoinDEXError::invalid_tick().into());
476        }
477
478        // Validate order amount meets minimum requirement
479        if amount < MIN_ORDER_AMOUNT {
480            return Err(StablecoinDEXError::below_minimum_order_size(amount).into());
481        }
482
483        // Calculate escrow amount and token based on order side
484        let (escrow_token, escrow_amount, non_escrow_token) = if is_bid {
485            // For bids, escrow quote tokens based on price
486            let quote_amount = base_to_quote(amount, tick, RoundingDirection::Up)
487                .ok_or(StablecoinDEXError::insufficient_balance())?;
488            (quote_token, quote_amount, token)
489        } else {
490            // For asks, escrow base tokens
491            (token, amount, quote_token)
492        };
493
494        // Check policy on non-escrow token (escrow token is checked in decrement_balance_or_transfer_from)
495        // Direction: DEX → sender (order placer receives non-escrow token when filled)
496        TIP20Token::from_address(non_escrow_token)?
497            .ensure_transfer_authorized(self.address, sender)?;
498
499        // Debit from user's balance or transfer from wallet
500        self.decrement_balance_or_transfer_from(sender, escrow_token, escrow_amount)?;
501
502        // Create the order
503        let order_id = self.next_order_id()?;
504        self.increment_next_order_id()?;
505        let order = if is_bid {
506            Order::new_bid(order_id, sender, book_key, amount, tick)
507        } else {
508            Order::new_ask(order_id, sender, book_key, amount, tick)
509        };
510        self.commit_order_to_book(order)?;
511
512        // Emit OrderPlaced event
513        self.emit_event(StablecoinDEXEvents::OrderPlaced(
514            IStablecoinDEX::OrderPlaced {
515                orderId: order_id,
516                maker: sender,
517                token,
518                amount,
519                isBid: is_bid,
520                tick,
521                isFlipOrder: false,
522                flipTick: 0,
523            },
524        ))?;
525
526        Ok(order_id)
527    }
528
529    /// Commits an order to the specified orderbook, updating tick bits, best bid/ask, and total liquidity
530    fn commit_order_to_book(&mut self, mut order: Order) -> Result<()> {
531        let orderbook = self.books[order.book_key()].read()?;
532        let mut level = self.books[order.book_key()]
533            .tick_level_handler(order.tick(), order.is_bid())
534            .read()?;
535
536        let prev_tail = level.tail;
537        if prev_tail == 0 {
538            level.head = order.order_id();
539            level.tail = order.order_id();
540
541            self.books[order.book_key()].set_tick_bit(order.tick(), order.is_bid())?;
542
543            if order.is_bid() {
544                if order.tick() > orderbook.best_bid_tick {
545                    self.books[order.book_key()]
546                        .best_bid_tick
547                        .write(order.tick())?;
548                }
549            } else if order.tick() < orderbook.best_ask_tick {
550                self.books[order.book_key()]
551                    .best_ask_tick
552                    .write(order.tick())?;
553            }
554        } else {
555            // Update previous tail's next pointer
556            let mut prev_order = self.orders[prev_tail].read()?;
557            prev_order.next = order.order_id();
558            self.orders[prev_tail].write(prev_order)?;
559
560            // Set current order's prev pointer
561            order.prev = prev_tail;
562            level.tail = order.order_id();
563        }
564
565        let new_liquidity = level
566            .total_liquidity
567            .checked_add(order.remaining())
568            .ok_or(TempoPrecompileError::under_overflow())?;
569        level.total_liquidity = new_liquidity;
570
571        self.books[order.book_key()]
572            .tick_level_handler_mut(order.tick(), order.is_bid())
573            .write(level)?;
574
575        self.orders[order.order_id()].write(order)
576    }
577
578    /// Places a flip order that auto-reverses to the opposite side when
579    /// fully filled, acting as perpetual liquidity. Escrows tokens via
580    /// [`TIP20Token`] and enforces compliance via [`TIP403Registry`].
581    /// For bids `flip_tick` must be > `tick`; for asks, < `tick`.
582    ///
583    /// # Errors
584    /// - `InvalidBaseToken` — token address does not have a valid TIP-20 prefix
585    /// - `TickOutOfBounds` — tick or flip_tick outside `[MIN_TICK, MAX_TICK]`
586    /// - `InvalidTick` — tick is not aligned to `TICK_SPACING`
587    /// - `InvalidFlipTick` — flip_tick on wrong side of tick for order direction
588    /// - `BelowMinimumOrderSize` — order amount is below `MIN_ORDER_AMOUNT`
589    /// - `InsufficientBalance` — sender balance lower than required escrow
590    /// - `PolicyForbids` — TIP-403 policy rejects the token transfer
591    #[allow(clippy::too_many_arguments)]
592    pub fn place_flip(
593        &mut self,
594        sender: Address,
595        token: Address,
596        amount: u128,
597        is_bid: bool,
598        tick: i16,
599        flip_tick: i16,
600        internal_balance_only: bool,
601    ) -> Result<u128> {
602        let quote_token = TIP20Token::from_address(token)?.quote_token()?;
603
604        // Compute book_key from token pair
605        let book_key = compute_book_key(token, quote_token);
606
607        // CHECKPOINT START: `place_flip` performs multiple state mutations that
608        // must succeed or fail as a unit. The guard auto-reverts on drop.
609        let batch = self.storage.checkpoint();
610
611        // Check book existence
612        let book = self.books[book_key].read()?;
613        self.validate_or_create_pair(&book, token)?;
614
615        // Validate tick and flip_tick are within bounds
616        if !(MIN_TICK..=MAX_TICK).contains(&tick) {
617            return Err(StablecoinDEXError::tick_out_of_bounds(tick).into());
618        }
619
620        // Enforce that the tick adheres to tick spacing
621        if tick % TICK_SPACING != 0 {
622            return Err(StablecoinDEXError::invalid_tick().into());
623        }
624
625        if !(MIN_TICK..=MAX_TICK).contains(&flip_tick) {
626            return Err(StablecoinDEXError::tick_out_of_bounds(flip_tick).into());
627        }
628
629        // Enforce that the tick adheres to tick spacing
630        if flip_tick % TICK_SPACING != 0 {
631            return Err(StablecoinDEXError::invalid_flip_tick().into());
632        }
633
634        // Validate flip_tick relationship to tick based on order side
635        if (is_bid && flip_tick <= tick) || (!is_bid && flip_tick >= tick) {
636            return Err(StablecoinDEXError::invalid_flip_tick().into());
637        }
638
639        // Validate order amount meets minimum requirement
640        if amount < MIN_ORDER_AMOUNT {
641            return Err(StablecoinDEXError::below_minimum_order_size(amount).into());
642        }
643
644        // Calculate escrow amount and token based on order side
645        let (escrow_token, escrow_amount, non_escrow_token) = if is_bid {
646            // For bids, escrow quote tokens based on price
647            let quote_amount = base_to_quote(amount, tick, RoundingDirection::Up)
648                .ok_or(StablecoinDEXError::insufficient_balance())?;
649            (quote_token, quote_amount, token)
650        } else {
651            // For asks, escrow base tokens
652            (token, amount, quote_token)
653        };
654
655        // Check policy on non-escrow token (escrow token is checked in decrement_balance_or_transfer_from or below)
656        // Direction: DEX → sender (order placer receives non-escrow token when filled)
657        TIP20Token::from_address(non_escrow_token)?
658            .ensure_transfer_authorized(self.address, sender)?;
659
660        // Debit from user's balance only. This is set to true after a flip order is filled and the
661        // subsequent flip order is being placed.
662        if internal_balance_only {
663            TIP20Token::from_address(escrow_token)?
664                .ensure_transfer_authorized(sender, self.address)?;
665            let user_balance = self.balance_of(sender, escrow_token)?;
666            if user_balance < escrow_amount {
667                return Err(StablecoinDEXError::insufficient_balance().into());
668            }
669            self.sub_balance(sender, escrow_token, escrow_amount)?;
670        } else {
671            self.decrement_balance_or_transfer_from(sender, escrow_token, escrow_amount)?;
672        }
673
674        // Create the flip order
675        let order_id = self.next_order_id()?;
676        let order = Order::new_flip(order_id, sender, book_key, amount, tick, is_bid, flip_tick)
677            .map_err(|_| StablecoinDEXError::invalid_flip_tick())?;
678
679        // Commit the flip order
680        if self.storage.spec().is_t1c() {
681            // PERF: skip 1 redundant SLOAD
682            self.next_order_id.write(order_id + 1)?;
683        } else {
684            self.increment_next_order_id()?;
685        }
686        self.commit_order_to_book(order)?;
687
688        // Emit OrderPlaced event for flip order
689        self.emit_event(StablecoinDEXEvents::OrderPlaced(
690            IStablecoinDEX::OrderPlaced {
691                orderId: order_id,
692                maker: sender,
693                token,
694                amount,
695                isBid: is_bid,
696                tick,
697                isFlipOrder: true,
698                flipTick: flip_tick,
699            },
700        ))?;
701
702        // CHECKPOINT END: commit the state-changing batch
703        batch.commit();
704
705        Ok(order_id)
706    }
707
708    /// Partially fill an order with the specified amount. Fill amount is denominated in base token.
709    fn partial_fill_order(
710        &mut self,
711        order: &mut Order,
712        level: &mut TickLevel,
713        fill_amount: u128,
714        taker: Address,
715    ) -> Result<u128> {
716        let orderbook = self.books[order.book_key()].read()?;
717
718        // Update order remaining amount
719        let new_remaining = order.remaining() - fill_amount;
720        self.orders[order.order_id()]
721            .remaining
722            .write(new_remaining)?;
723
724        // Calculate quote amount for this fill (used by both maker settlement and taker output)
725        let quote_amount = base_to_quote(
726            fill_amount,
727            order.tick(),
728            if order.is_bid() {
729                RoundingDirection::Down // Bid: taker receives quote, round DOWN
730            } else {
731                RoundingDirection::Up // Ask: maker receives quote, round UP to favor maker
732            },
733        )
734        .ok_or(TempoPrecompileError::under_overflow())?;
735
736        if order.is_bid() {
737            // Bid order maker receives base tokens (exact amount)
738            self.increment_balance(order.maker(), orderbook.base, fill_amount)?;
739        } else {
740            // Ask order maker receives quote tokens
741            self.increment_balance(order.maker(), orderbook.quote, quote_amount)?;
742        }
743
744        // Taker output: bid→quote, ask→base (zero-sum with maker)
745        let amount_out = if order.is_bid() {
746            quote_amount
747        } else {
748            fill_amount
749        };
750
751        // Update price level total liquidity
752        let new_liquidity = level
753            .total_liquidity
754            .checked_sub(fill_amount)
755            .ok_or(TempoPrecompileError::under_overflow())?;
756        level.total_liquidity = new_liquidity;
757
758        self.books[order.book_key()]
759            .tick_level_handler_mut(order.tick(), order.is_bid())
760            .write(*level)?;
761
762        // Emit OrderFilled event for partial fill
763        self.emit_order_filled(order.order_id(), order.maker(), taker, fill_amount, true)?;
764
765        Ok(amount_out)
766    }
767
768    /// Fill an order and delete from storage. Returns the next best order and price level.
769    fn fill_order(
770        &mut self,
771        book_key: B256,
772        order: &mut Order,
773        mut level: TickLevel,
774        taker: Address,
775    ) -> Result<(u128, Option<(TickLevel, Order)>)> {
776        debug_assert_eq!(order.book_key(), book_key);
777
778        let orderbook = self.books[book_key].read()?;
779        let fill_amount = order.remaining();
780
781        // Settlement: bid rounds DOWN (taker receives less), ask rounds UP (maker receives more)
782        let amount_out = if order.is_bid() {
783            // Bid maker receives base tokens (exact amount)
784            self.increment_balance(order.maker(), orderbook.base, fill_amount)?;
785            // Taker receives quote tokens - round DOWN
786            base_to_quote(fill_amount, order.tick(), RoundingDirection::Down)
787                .ok_or(TempoPrecompileError::under_overflow())?
788        } else {
789            // Ask maker receives quote tokens - round UP to favor maker
790            let quote_amount = base_to_quote(fill_amount, order.tick(), RoundingDirection::Up)
791                .ok_or(TempoPrecompileError::under_overflow())?;
792
793            self.increment_balance(order.maker(), orderbook.quote, quote_amount)?;
794
795            // Taker receives base tokens (exact amount)
796            fill_amount
797        };
798
799        // Emit OrderFilled event for complete fill
800        self.emit_order_filled(order.order_id(), order.maker(), taker, fill_amount, false)?;
801
802        if order.is_flip() {
803            // Create a new flip order with flipped side and swapped ticks.
804            // Bid becomes Ask, Ask becomes Bid.
805            // The current tick becomes the new flip_tick, and flip_tick becomes the new tick.
806            // Uses internal balance only, does not transfer from wallet.
807            //
808            // Business logic errors are ignored so that flip failure does not block the swap.
809            // System errors (OOG, DB errors, panics) propagate because state may be inconsistent.
810            if let Err(e) = self.place_flip(
811                order.maker(),
812                orderbook.base,
813                order.amount(),
814                !order.is_bid(),
815                order.flip_tick(),
816                order.tick(),
817                true,
818            ) && e.is_system_error()
819                && self.storage.spec().is_t1a()
820            {
821                return Err(e);
822            }
823        }
824
825        // Delete the filled order
826        self.orders[order.order_id()].delete()?;
827
828        // Advance tick if liquidity is exhausted
829        let next_tick_info = if order.next() == 0 {
830            self.books[book_key]
831                .tick_level_handler_mut(order.tick(), order.is_bid())
832                .delete()?;
833            self.books[book_key].delete_tick_bit(order.tick(), order.is_bid())?;
834
835            let (tick, has_liquidity) =
836                self.books[book_key].next_initialized_tick(order.tick(), order.is_bid())?;
837
838            // Update best_tick when tick is exhausted
839            if order.is_bid() {
840                let new_best = if has_liquidity { tick } else { i16::MIN };
841                self.books[book_key].best_bid_tick.write(new_best)?;
842            } else {
843                let new_best = if has_liquidity { tick } else { i16::MAX };
844                self.books[book_key].best_ask_tick.write(new_best)?;
845            }
846
847            if !has_liquidity {
848                // No more liquidity at better prices - return None to signal completion
849                None
850            } else {
851                let new_level = self.books[book_key]
852                    .tick_level_handler(tick, order.is_bid())
853                    .read()?;
854                let new_order = self.orders[new_level.head].read()?;
855
856                Some((new_level, new_order))
857            }
858        } else {
859            // If there are subsequent orders at tick, advance to next order
860            level.head = order.next();
861            self.orders[order.next()].prev.delete()?;
862
863            let new_liquidity = level
864                .total_liquidity
865                .checked_sub(fill_amount)
866                .ok_or(TempoPrecompileError::under_overflow())?;
867            level.total_liquidity = new_liquidity;
868
869            self.books[book_key]
870                .tick_level_handler_mut(order.tick(), order.is_bid())
871                .write(level)?;
872
873            let new_order = self.orders[order.next()].read()?;
874            Some((level, new_order))
875        };
876
877        Ok((amount_out, next_tick_info))
878    }
879
880    /// Fill orders for exact output amount
881    fn fill_orders_exact_out(
882        &mut self,
883        book_key: B256,
884        bid: bool,
885        mut amount_out: u128,
886        taker: Address,
887    ) -> Result<u128> {
888        let mut level = self.get_best_price_level(book_key, bid)?;
889        let mut order = self.orders[level.head].read()?;
890
891        let mut total_amount_in: u128 = 0;
892
893        while amount_out > 0 {
894            let tick = order.tick();
895
896            let (fill_amount, amount_in) = if bid {
897                // For bids: amount_out is quote, amount_in is base
898                // Round UP baseNeeded to ensure we collect enough base to cover exact output
899                let base_needed = quote_to_base(amount_out, tick, RoundingDirection::Up)
900                    .ok_or(TempoPrecompileError::under_overflow())?;
901                let fill_amount = base_needed.min(order.remaining());
902                (fill_amount, fill_amount)
903            } else {
904                // For asks: amount_out is base, amount_in is quote
905                // Taker pays quote, maker receives quote - round UP (zero-sum with maker)
906                let fill_amount = amount_out.min(order.remaining());
907                let amount_in = base_to_quote(fill_amount, tick, RoundingDirection::Up)
908                    .ok_or(TempoPrecompileError::under_overflow())?;
909                (fill_amount, amount_in)
910            };
911
912            if fill_amount < order.remaining() {
913                self.partial_fill_order(&mut order, &mut level, fill_amount, taker)?;
914                total_amount_in = total_amount_in
915                    .checked_add(amount_in)
916                    .ok_or(TempoPrecompileError::under_overflow())?;
917                break;
918            } else {
919                let (amount_out_received, next_order_info) =
920                    self.fill_order(book_key, &mut order, level, taker)?;
921                total_amount_in = total_amount_in
922                    .checked_add(amount_in)
923                    .ok_or(TempoPrecompileError::under_overflow())?;
924
925                // Update remaining amount_out
926                if bid {
927                    // Round UP baseNeeded to match the initial calculation
928                    let base_needed = quote_to_base(amount_out, tick, RoundingDirection::Up)
929                        .ok_or(TempoPrecompileError::under_overflow())?;
930                    if base_needed > order.remaining() {
931                        amount_out = amount_out
932                            .checked_sub(amount_out_received)
933                            .ok_or(TempoPrecompileError::under_overflow())?;
934                    } else {
935                        amount_out = 0;
936                    }
937                } else if amount_out > order.remaining() {
938                    amount_out = amount_out
939                        .checked_sub(amount_out_received)
940                        .ok_or(TempoPrecompileError::under_overflow())?;
941                } else {
942                    amount_out = 0;
943                }
944
945                if let Some((new_level, new_order)) = next_order_info {
946                    level = new_level;
947                    order = new_order;
948                } else {
949                    if amount_out > 0 {
950                        return Err(StablecoinDEXError::insufficient_liquidity().into());
951                    }
952                    break;
953                }
954            }
955        }
956
957        Ok(total_amount_in)
958    }
959
960    /// Fill orders with exact amount in
961    fn fill_orders_exact_in(
962        &mut self,
963        book_key: B256,
964        bid: bool,
965        mut amount_in: u128,
966        taker: Address,
967    ) -> Result<u128> {
968        let mut level = self.get_best_price_level(book_key, bid)?;
969        let mut order = self.orders[level.head].read()?;
970
971        let mut total_amount_out: u128 = 0;
972
973        while amount_in > 0 {
974            let tick = order.tick();
975
976            let fill_amount = if bid {
977                // For bids: amount_in is base, fill in base
978                amount_in.min(order.remaining())
979            } else {
980                // For asks: amount_in is quote, convert to base
981                // Round down base_out (user receives less base, favors protocol)
982                let base_out = quote_to_base(amount_in, tick, RoundingDirection::Down)
983                    .ok_or(TempoPrecompileError::under_overflow())?;
984                base_out.min(order.remaining())
985            };
986
987            if fill_amount < order.remaining() {
988                let amount_out =
989                    self.partial_fill_order(&mut order, &mut level, fill_amount, taker)?;
990                total_amount_out = total_amount_out
991                    .checked_add(amount_out)
992                    .ok_or(TempoPrecompileError::under_overflow())?;
993                break;
994            } else {
995                let (amount_out, next_order_info) =
996                    self.fill_order(book_key, &mut order, level, taker)?;
997                total_amount_out = total_amount_out
998                    .checked_add(amount_out)
999                    .ok_or(TempoPrecompileError::under_overflow())?;
1000
1001                // Set to 0 to avoid rounding errors
1002                if bid {
1003                    if amount_in > order.remaining() {
1004                        amount_in = amount_in
1005                            .checked_sub(order.remaining())
1006                            .ok_or(TempoPrecompileError::under_overflow())?;
1007                    } else {
1008                        amount_in = 0;
1009                    }
1010                } else {
1011                    // For asks: taker pays quote, maker receives quote
1012                    let base_out = quote_to_base(amount_in, tick, RoundingDirection::Down)
1013                        .ok_or(TempoPrecompileError::under_overflow())?;
1014                    if base_out > order.remaining() {
1015                        // Quote consumed = what maker receives - round UP (zero-sum with maker)
1016                        let quote_needed =
1017                            base_to_quote(order.remaining(), tick, RoundingDirection::Up)
1018                                .ok_or(TempoPrecompileError::under_overflow())?;
1019                        amount_in = amount_in
1020                            .checked_sub(quote_needed)
1021                            .ok_or(TempoPrecompileError::under_overflow())?;
1022                    } else {
1023                        amount_in = 0;
1024                    }
1025                }
1026
1027                if let Some((new_level, new_order)) = next_order_info {
1028                    level = new_level;
1029                    order = new_order;
1030                } else {
1031                    if amount_in > 0 {
1032                        return Err(StablecoinDEXError::insufficient_liquidity().into());
1033                    }
1034                    break;
1035                }
1036            }
1037        }
1038
1039        Ok(total_amount_out)
1040    }
1041
1042    /// Helper function to get best tick from orderbook
1043    fn get_best_price_level(&mut self, book_key: B256, is_bid: bool) -> Result<TickLevel> {
1044        let orderbook = self.books[book_key].read()?;
1045
1046        let current_tick = if is_bid {
1047            if orderbook.best_bid_tick == i16::MIN {
1048                return Err(StablecoinDEXError::insufficient_liquidity().into());
1049            }
1050            orderbook.best_bid_tick
1051        } else {
1052            if orderbook.best_ask_tick == i16::MAX {
1053                return Err(StablecoinDEXError::insufficient_liquidity().into());
1054            }
1055            orderbook.best_ask_tick
1056        };
1057
1058        self.books[book_key]
1059            .tick_level_handler(current_tick, is_bid)
1060            .read()
1061    }
1062
1063    /// Cancels an active order and refunds escrowed tokens to the maker.
1064    /// Only the order maker can cancel their own orders.
1065    ///
1066    /// # Errors
1067    /// - `OrderDoesNotExist` — order ID not found or already fully filled
1068    /// - `Unauthorized` — only the order maker can cancel their order
1069    pub fn cancel(&mut self, sender: Address, order_id: u128) -> Result<()> {
1070        let order = self.orders[order_id].read()?;
1071
1072        if order.maker().is_zero() {
1073            return Err(StablecoinDEXError::order_does_not_exist().into());
1074        }
1075
1076        if order.maker() != sender {
1077            return Err(StablecoinDEXError::unauthorized().into());
1078        }
1079
1080        if order.remaining() == 0 {
1081            return Err(StablecoinDEXError::order_does_not_exist().into());
1082        }
1083
1084        self.cancel_active_order(order)
1085    }
1086
1087    /// Cancel an active order (already in the orderbook)
1088    fn cancel_active_order(&mut self, order: Order) -> Result<()> {
1089        let mut level = self.books[order.book_key()]
1090            .tick_level_handler(order.tick(), order.is_bid())
1091            .read()?;
1092
1093        // Update linked list
1094        if order.prev() != 0 {
1095            self.orders[order.prev()].next.write(order.next())?;
1096        } else {
1097            level.head = order.next();
1098        }
1099
1100        if order.next() != 0 {
1101            self.orders[order.next()].prev.write(order.prev())?;
1102        } else {
1103            level.tail = order.prev();
1104        }
1105
1106        // Update level liquidity
1107        let new_liquidity = level
1108            .total_liquidity
1109            .checked_sub(order.remaining())
1110            .ok_or(TempoPrecompileError::under_overflow())?;
1111        level.total_liquidity = new_liquidity;
1112
1113        // If this was the last order at this tick, clear the bitmap bit
1114        if level.head == 0 {
1115            self.books[order.book_key()].delete_tick_bit(order.tick(), order.is_bid())?;
1116
1117            // If this was the best tick, update it
1118            let orderbook = self.books[order.book_key()].read()?;
1119            let best_tick = if order.is_bid() {
1120                orderbook.best_bid_tick
1121            } else {
1122                orderbook.best_ask_tick
1123            };
1124
1125            if best_tick == order.tick() {
1126                let (next_tick, has_liquidity) = self.books[order.book_key()]
1127                    .next_initialized_tick(order.tick(), order.is_bid())?;
1128
1129                if order.is_bid() {
1130                    let new_best = if has_liquidity { next_tick } else { i16::MIN };
1131                    self.books[order.book_key()].best_bid_tick.write(new_best)?;
1132                } else {
1133                    let new_best = if has_liquidity { next_tick } else { i16::MAX };
1134                    self.books[order.book_key()].best_ask_tick.write(new_best)?;
1135                }
1136            }
1137        }
1138
1139        self.books[order.book_key()]
1140            .tick_level_handler_mut(order.tick(), order.is_bid())
1141            .write(level)?;
1142
1143        // Refund tokens to maker - must match the escrow amount
1144        let orderbook = self.books[order.book_key()].read()?;
1145        if order.is_bid() {
1146            // Bid orders escrowed quote tokens using RoundingDirection::Up,
1147            // so refund must also use Up to return the exact escrowed amount
1148            let quote_amount =
1149                base_to_quote(order.remaining(), order.tick(), RoundingDirection::Up)
1150                    .ok_or(TempoPrecompileError::under_overflow())?;
1151
1152            self.increment_balance(order.maker(), orderbook.quote, quote_amount)?;
1153        } else {
1154            // Ask orders are in base token, refund base amount (exact)
1155            self.increment_balance(order.maker(), orderbook.base, order.remaining())?;
1156        }
1157
1158        // Clear the order from storage
1159        self.orders[order.order_id()].delete()?;
1160
1161        // Emit OrderCancelled event
1162        self.emit_event(StablecoinDEXEvents::OrderCancelled(
1163            IStablecoinDEX::OrderCancelled {
1164                orderId: order.order_id(),
1165            },
1166        ))
1167    }
1168
1169    /// Cancels an order whose maker is blocked by [`TIP403Registry`] policy, allowing anyone to
1170    /// clean up stale liquidity.
1171    ///
1172    /// [TIP-1015]: T2+ checks sender authorization (maker must be able to send escrowed tokens)
1173    ///
1174    /// [TIP-1015]: <https://docs.tempo.xyz/protocol/tips/tip-1015>
1175    ///
1176    /// # Errors
1177    /// - `OrderDoesNotExist` — order ID not found or already fully filled
1178    /// - `OrderNotStale` — order maker is still authorized by TIP-403 policy
1179    pub fn cancel_stale_order(&mut self, order_id: u128) -> Result<()> {
1180        let order = self.orders[order_id].read()?;
1181
1182        if order.maker().is_zero() {
1183            return Err(StablecoinDEXError::order_does_not_exist().into());
1184        }
1185
1186        let book = self.books[order.book_key()].read()?;
1187        let token = if order.is_bid() {
1188            book.quote
1189        } else {
1190            book.base
1191        };
1192
1193        let policy_id = TIP20Token::from_address(token)?.transfer_policy_id()?;
1194        // If the policy lookup fails because the policy is invalid or missing, the order is stale.
1195        // Pre-T2 this surfaces as Panic(UnderOverflow); T2+ returns TIP403RegistryError variants.
1196        match TIP403Registry::new().is_authorized_as(policy_id, order.maker(), AuthRole::sender()) {
1197            Ok(true) => Err(StablecoinDEXError::order_not_stale().into()),
1198            Ok(false) => self.cancel_active_order(order),
1199            Err(e) if is_policy_lookup_error(&e) => self.cancel_active_order(order),
1200            Err(e) => Err(e),
1201        }
1202    }
1203
1204    /// Withdraws `amount` from the caller's DEX balance, transferring
1205    /// tokens back via [`TIP20Token`].
1206    ///
1207    /// # Errors
1208    /// - `InsufficientBalance` — DEX balance lower than withdrawal amount
1209    pub fn withdraw(&mut self, user: Address, token: Address, amount: u128) -> Result<()> {
1210        let current_balance = self.balance_of(user, token)?;
1211        if current_balance < amount {
1212            return Err(StablecoinDEXError::insufficient_balance().into());
1213        }
1214        self.sub_balance(user, token, amount)?;
1215        self.transfer(token, user, amount)?;
1216
1217        Ok(())
1218    }
1219
1220    /// Quote exact output amount without executing trades
1221    fn quote_exact_out(&self, book_key: B256, amount_out: u128, is_bid: bool) -> Result<u128> {
1222        let mut remaining_out = amount_out;
1223        let mut amount_in = 0u128;
1224        let orderbook = self.books[book_key].read()?;
1225
1226        let mut current_tick = if is_bid {
1227            orderbook.best_bid_tick
1228        } else {
1229            orderbook.best_ask_tick
1230        };
1231        // Check for no liquidity: i16::MIN means no bids, i16::MAX means no asks
1232        if current_tick == i16::MIN || current_tick == i16::MAX {
1233            return Err(StablecoinDEXError::insufficient_liquidity().into());
1234        }
1235
1236        while remaining_out > 0 {
1237            let level = self.books[book_key]
1238                .tick_level_handler(current_tick, is_bid)
1239                .read()?;
1240
1241            // If no liquidity at this level, move to next tick
1242            if level.total_liquidity == 0 {
1243                let (next_tick, initialized) =
1244                    self.books[book_key].next_initialized_tick(current_tick, is_bid)?;
1245
1246                if !initialized {
1247                    return Err(StablecoinDEXError::insufficient_liquidity().into());
1248                }
1249                current_tick = next_tick;
1250                continue;
1251            }
1252
1253            let (fill_amount, amount_in_tick) = if is_bid {
1254                // For bids: remaining_out is in quote, amount_in is in base
1255                // Round UP to ensure we collect enough base to cover exact output.
1256                // Note: this quote iterates per-tick, but execution iterates per-order.
1257                // If multiple orders exist at a tick, execution may charge slightly more
1258                // due to ceiling accumulation across order boundaries.
1259                let base_needed = quote_to_base(remaining_out, current_tick, RoundingDirection::Up)
1260                    .ok_or(TempoPrecompileError::under_overflow())?;
1261                let fill_amount = if base_needed > level.total_liquidity {
1262                    level.total_liquidity
1263                } else {
1264                    base_needed
1265                };
1266                (fill_amount, fill_amount)
1267            } else {
1268                // For asks: remaining_out is in base, amount_in is in quote
1269                // Taker pays quote, maker receives quote - round UP to favor maker
1270                let fill_amount = if remaining_out > level.total_liquidity {
1271                    level.total_liquidity
1272                } else {
1273                    remaining_out
1274                };
1275                let quote_needed = base_to_quote(fill_amount, current_tick, RoundingDirection::Up)
1276                    .ok_or(TempoPrecompileError::under_overflow())?;
1277                (fill_amount, quote_needed)
1278            };
1279
1280            let amount_out_tick = if is_bid {
1281                // Round down amount_out_tick (user receives less quote).
1282                // Cap at remaining_out to avoid underflow from round-trip rounding:
1283                // when tick > 0, base_to_quote(quote_to_base(x, Up), Down) can exceed x by 1.
1284                base_to_quote(fill_amount, current_tick, RoundingDirection::Down)
1285                    .ok_or(TempoPrecompileError::under_overflow())?
1286                    .min(remaining_out)
1287            } else {
1288                fill_amount
1289            };
1290
1291            remaining_out = remaining_out.saturating_sub(amount_out_tick);
1292            amount_in = amount_in
1293                .checked_add(amount_in_tick)
1294                .ok_or(TempoPrecompileError::under_overflow())?;
1295
1296            // If we exhausted this level or filled our requirement, move to next tick
1297            if fill_amount == level.total_liquidity {
1298                let (next_tick, initialized) =
1299                    self.books[book_key].next_initialized_tick(current_tick, is_bid)?;
1300
1301                if !initialized && remaining_out > 0 {
1302                    return Err(StablecoinDEXError::insufficient_liquidity().into());
1303                }
1304                current_tick = next_tick;
1305            } else {
1306                break;
1307            }
1308        }
1309
1310        Ok(amount_in)
1311    }
1312
1313    /// Find the trade path between two tokens
1314    /// Returns a vector of (book_key, base_for_quote) tuples for each hop
1315    /// Also validates that all pairs exist
1316    fn find_trade_path(&self, token_in: Address, token_out: Address) -> Result<Vec<(B256, bool)>> {
1317        // Cannot trade same token
1318        if token_in == token_out {
1319            return Err(StablecoinDEXError::identical_tokens().into());
1320        }
1321
1322        // Validate that both tokens are TIP20 tokens
1323        if !is_tip20_prefix(token_in) || !is_tip20_prefix(token_out) {
1324            return Err(StablecoinDEXError::invalid_token().into());
1325        }
1326
1327        // Check if direct or reverse pair exists
1328        let in_quote = TIP20Token::from_address(token_in)?.quote_token()?;
1329        let out_quote = TIP20Token::from_address(token_out)?.quote_token()?;
1330
1331        if in_quote == token_out || out_quote == token_in {
1332            return self.validate_and_build_route(&[token_in, token_out]);
1333        }
1334
1335        // Multi-hop: Find LCA and build path
1336        let path_in = self.find_path_to_root(token_in)?;
1337        let path_out = self.find_path_to_root(token_out)?;
1338
1339        // Find the lowest common ancestor (LCA) using O(n+m) algorithm:
1340        // Build a HashSet from path_out for O(1) lookups, then iterate path_in
1341        let path_out_set: std::collections::HashSet<Address> = path_out.iter().copied().collect();
1342        let mut lca = None;
1343        for token_a in &path_in {
1344            if path_out_set.contains(token_a) {
1345                lca = Some(*token_a);
1346                break;
1347            }
1348        }
1349
1350        let lca = lca.ok_or_else(StablecoinDEXError::pair_does_not_exist)?;
1351
1352        // Build the trade path: token_in -> ... -> LCA -> ... -> token_out
1353        let mut trade_path = Vec::new();
1354
1355        // Add path from token_in up to and including LCA
1356        for token in &path_in {
1357            trade_path.push(*token);
1358            if *token == lca {
1359                break;
1360            }
1361        }
1362
1363        // Add path from LCA down to token_out (excluding LCA itself)
1364        let lca_to_out: Vec<Address> = path_out
1365            .iter()
1366            .take_while(|&&t| t != lca)
1367            .copied()
1368            .collect();
1369
1370        // Reverse to get path from LCA to token_out
1371        trade_path.extend(lca_to_out.iter().rev());
1372
1373        self.validate_and_build_route(&trade_path)
1374    }
1375
1376    /// Validates that all pairs in the path exist and returns book keys with direction info
1377    fn validate_and_build_route(&self, path: &[Address]) -> Result<Vec<(B256, bool)>> {
1378        let mut route = Vec::new();
1379
1380        for i in 0..path.len() - 1 {
1381            let token_in = path[i];
1382            let token_out = path[i + 1];
1383
1384            let (base, quote) = {
1385                let token_in_tip20 = TIP20Token::from_address(token_in)?;
1386                if token_in_tip20.quote_token()? == token_out {
1387                    (token_in, token_out)
1388                } else {
1389                    let token_out_tip20 = TIP20Token::from_address(token_out)?;
1390                    if token_out_tip20.quote_token()? == token_in {
1391                        (token_out, token_in)
1392                    } else {
1393                        return Err(StablecoinDEXError::pair_does_not_exist().into());
1394                    }
1395                }
1396            };
1397
1398            let book_key = compute_book_key(base, quote);
1399            let orderbook = self.books[book_key].read()?;
1400
1401            if orderbook.base.is_zero() {
1402                return Err(StablecoinDEXError::pair_does_not_exist().into());
1403            }
1404
1405            let is_base_for_quote = token_in == base;
1406            route.push((book_key, is_base_for_quote));
1407        }
1408
1409        Ok(route)
1410    }
1411
1412    /// Find the path from a token to the root (pathUSD)
1413    /// Returns a vector of addresses starting with the token and ending with pathUSD
1414    fn find_path_to_root(&self, mut token: Address) -> Result<Vec<Address>> {
1415        let mut path = vec![token];
1416
1417        while token != PATH_USD_ADDRESS {
1418            token = TIP20Token::from_address(token)?.quote_token()?;
1419            path.push(token);
1420        }
1421
1422        Ok(path)
1423    }
1424
1425    /// Quote exact input amount without executing trades
1426    fn quote_exact_in(&self, book_key: B256, amount_in: u128, is_bid: bool) -> Result<u128> {
1427        let mut remaining_in = amount_in;
1428        let mut amount_out = 0u128;
1429        let orderbook = self.books[book_key].read()?;
1430
1431        let mut current_tick = if is_bid {
1432            orderbook.best_bid_tick
1433        } else {
1434            orderbook.best_ask_tick
1435        };
1436
1437        // Check for no liquidity: i16::MIN means no bids, i16::MAX means no asks
1438        if current_tick == i16::MIN || current_tick == i16::MAX {
1439            return Err(StablecoinDEXError::insufficient_liquidity().into());
1440        }
1441
1442        while remaining_in > 0 {
1443            let level = self.books[book_key]
1444                .tick_level_handler(current_tick, is_bid)
1445                .read()?;
1446
1447            // If no liquidity at this level, move to next tick
1448            if level.total_liquidity == 0 {
1449                let (next_tick, initialized) =
1450                    self.books[book_key].next_initialized_tick(current_tick, is_bid)?;
1451
1452                if !initialized {
1453                    return Err(StablecoinDEXError::insufficient_liquidity().into());
1454                }
1455                current_tick = next_tick;
1456                continue;
1457            }
1458
1459            // Compute (fill_amount, amount_out_tick, amount_consumed) based on hardfork
1460            let (fill_amount, amount_out_tick, amount_consumed) = if is_bid {
1461                // For bids: remaining_in is base, amount_out is quote
1462                let fill = remaining_in.min(level.total_liquidity);
1463                // Round down quote_out (user receives less quote)
1464                let quote_out = base_to_quote(fill, current_tick, RoundingDirection::Down)
1465                    .ok_or(TempoPrecompileError::under_overflow())?;
1466                (fill, quote_out, fill)
1467            } else {
1468                // For asks: remaining_in is quote, amount_out is base
1469                // Taker pays quote, maker receives quote - round UP (zero-sum with maker)
1470                let base_to_get =
1471                    quote_to_base(remaining_in, current_tick, RoundingDirection::Down)
1472                        .ok_or(TempoPrecompileError::under_overflow())?;
1473                let fill = base_to_get.min(level.total_liquidity);
1474                let quote_consumed = base_to_quote(fill, current_tick, RoundingDirection::Up)
1475                    .ok_or(TempoPrecompileError::under_overflow())?;
1476                (fill, fill, quote_consumed)
1477            };
1478
1479            remaining_in = remaining_in
1480                .checked_sub(amount_consumed)
1481                .ok_or(TempoPrecompileError::under_overflow())?;
1482            amount_out = amount_out
1483                .checked_add(amount_out_tick)
1484                .ok_or(TempoPrecompileError::under_overflow())?;
1485
1486            // If we exhausted this level, move to next tick
1487            if fill_amount == level.total_liquidity {
1488                let (next_tick, initialized) =
1489                    self.books[book_key].next_initialized_tick(current_tick, is_bid)?;
1490
1491                if !initialized && remaining_in > 0 {
1492                    return Err(StablecoinDEXError::insufficient_liquidity().into());
1493                }
1494                current_tick = next_tick;
1495            } else {
1496                break;
1497            }
1498        }
1499
1500        Ok(amount_out)
1501    }
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506    use alloy::{primitives::IntoLogData, sol_types::SolEvent};
1507    use tempo_chainspec::hardfork::TempoHardfork;
1508    use tempo_contracts::precompiles::TIP20Error;
1509
1510    use crate::{
1511        error::TempoPrecompileError,
1512        storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
1513        test_util::TIP20Setup,
1514        tip403_registry::{ITIP403Registry, TIP403Registry},
1515    };
1516
1517    use super::*;
1518    use crate::STABLECOIN_DEX_ADDRESS;
1519
1520    fn setup_test_tokens(
1521        admin: Address,
1522        user: Address,
1523        exchange_address: Address,
1524        amount: u128,
1525    ) -> Result<(Address, Address)> {
1526        // Configure pathUSD
1527        let quote = TIP20Setup::path_usd(admin)
1528            .with_issuer(admin)
1529            .with_mint(user, U256::from(amount))
1530            .with_approval(user, exchange_address, U256::from(amount))
1531            .apply()?;
1532
1533        // Configure base token (uses pathUSD as quote by default)
1534        let base = TIP20Setup::create("BASE", "BASE", admin)
1535            .with_issuer(admin)
1536            .with_mint(user, U256::from(amount))
1537            .with_approval(user, exchange_address, U256::from(amount))
1538            .apply()?;
1539
1540        Ok((base.address(), quote.address()))
1541    }
1542
1543    #[test]
1544    fn test_tick_to_price() {
1545        let test_ticks = [-2000i16, -1000, -100, -1, 0, 1, 100, 1000, 2000];
1546        for tick in test_ticks {
1547            let price = orderbook::tick_to_price(tick);
1548            let expected_price = (orderbook::PRICE_SCALE as i32 + tick as i32) as u32;
1549            assert_eq!(price, expected_price);
1550        }
1551    }
1552
1553    #[test]
1554    fn test_price_to_tick() -> eyre::Result<()> {
1555        let mut storage = HashMapStorageProvider::new(1);
1556        StorageCtx::enter(&mut storage, || {
1557            let exchange = StablecoinDEX::new();
1558
1559            // Valid prices should succeed
1560            assert_eq!(exchange.price_to_tick(orderbook::PRICE_SCALE)?, 0);
1561            assert_eq!(exchange.price_to_tick(orderbook::MIN_PRICE)?, MIN_TICK);
1562            assert_eq!(exchange.price_to_tick(orderbook::MAX_PRICE)?, MAX_TICK);
1563
1564            // Out of bounds prices should fail
1565            let result = exchange.price_to_tick(orderbook::MIN_PRICE - 1);
1566            assert!(result.is_err());
1567            assert!(matches!(
1568                result.unwrap_err(),
1569                TempoPrecompileError::StablecoinDEX(StablecoinDEXError::TickOutOfBounds(_))
1570            ));
1571
1572            let result = exchange.price_to_tick(orderbook::MAX_PRICE + 1);
1573            assert!(result.is_err());
1574            assert!(matches!(
1575                result.unwrap_err(),
1576                TempoPrecompileError::StablecoinDEX(StablecoinDEXError::TickOutOfBounds(_))
1577            ));
1578
1579            Ok(())
1580        })
1581    }
1582
1583    #[test]
1584    fn test_calculate_quote_amount_rounding() -> eyre::Result<()> {
1585        // Floor division rounds DOWN
1586        // amount = 100, tick = 1 means price = 100001
1587        // 100 * 100001 / 100000 = 10000100 / 100000 = 100.001
1588        // Should round down to 100
1589        let amount = 100u128;
1590        let tick = 1i16;
1591        let result_floor = base_to_quote(amount, tick, RoundingDirection::Down).unwrap();
1592        assert_eq!(
1593            result_floor, 100,
1594            "Expected 100 (rounded down from 100.001)"
1595        );
1596
1597        // Ceiling division rounds UP - same inputs should round up to 101
1598        let result_ceil = base_to_quote(amount, tick, RoundingDirection::Up).unwrap();
1599        assert_eq!(result_ceil, 101, "Expected 101 (rounded up from 100.001)");
1600
1601        // Another test case with floor
1602        let amount2 = 999u128;
1603        let tick2 = 5i16; // price = 100005
1604        let result2_floor = base_to_quote(amount2, tick2, RoundingDirection::Down).unwrap();
1605        // 999 * 100005 / 100000 = 99904995 / 100000 = 999.04995 -> should be 999
1606        assert_eq!(
1607            result2_floor, 999,
1608            "Expected 999 (rounded down from 999.04995)"
1609        );
1610
1611        // Same inputs with ceiling should round up to 1000
1612        let result2_ceil = base_to_quote(amount2, tick2, RoundingDirection::Up).unwrap();
1613        assert_eq!(
1614            result2_ceil, 1000,
1615            "Expected 1000 (rounded up from 999.04995)"
1616        );
1617
1618        // Test with no remainder (should work the same for both)
1619        let amount3 = 100000u128;
1620        let tick3 = 0i16; // price = 100000
1621        let result3_floor = base_to_quote(amount3, tick3, RoundingDirection::Down).unwrap();
1622        let result3_ceil = base_to_quote(amount3, tick3, RoundingDirection::Up).unwrap();
1623        // 100000 * 100000 / 100000 = 100000 (exact, no rounding)
1624        assert_eq!(result3_floor, 100000, "Exact division should remain exact");
1625        assert_eq!(result3_ceil, 100000, "Exact division should remain exact");
1626
1627        Ok(())
1628    }
1629
1630    #[test]
1631    fn test_settlement_rounding_favors_protocol() -> eyre::Result<()> {
1632        let mut storage = HashMapStorageProvider::new(1);
1633        StorageCtx::enter(&mut storage, || {
1634            let mut exchange = StablecoinDEX::new();
1635            exchange.initialize()?;
1636
1637            let alice = Address::random();
1638            let bob = Address::random();
1639            let admin = Address::random();
1640
1641            // Use an amount above MIN_ORDER_AMOUNT that causes rounding
1642            let base_amount = 100_000_003u128;
1643            let tick = 100i16;
1644
1645            let price = orderbook::tick_to_price(tick) as u128;
1646            let expected_quote_floor = (base_amount * price) / orderbook::PRICE_SCALE as u128;
1647            let expected_quote_ceil =
1648                (base_amount * price).div_ceil(orderbook::PRICE_SCALE as u128);
1649
1650            let max_escrow = expected_quote_ceil * 2;
1651
1652            let base = TIP20Setup::create("BASE", "BASE", admin)
1653                .with_issuer(admin)
1654                .with_mint(alice, U256::from(base_amount * 2))
1655                .with_mint(bob, U256::from(base_amount * 2))
1656                .with_approval(alice, exchange.address, U256::MAX)
1657                .with_approval(bob, exchange.address, U256::MAX)
1658                .apply()?;
1659            let base_token = base.address();
1660            let quote_token = base.quote_token()?;
1661
1662            TIP20Setup::path_usd(admin)
1663                .with_issuer(admin)
1664                .with_mint(alice, U256::from(max_escrow))
1665                .with_mint(bob, U256::from(max_escrow))
1666                .with_approval(alice, exchange.address, U256::MAX)
1667                .with_approval(bob, exchange.address, U256::MAX)
1668                .apply()?;
1669
1670            exchange.create_pair(base_token)?;
1671
1672            exchange.place(alice, base_token, base_amount, false, tick)?;
1673
1674            let alice_quote_before = exchange.balance_of(alice, quote_token)?;
1675            assert_eq!(alice_quote_before, 0);
1676
1677            exchange.swap_exact_amount_in(bob, quote_token, base_token, expected_quote_ceil, 0)?;
1678
1679            let alice_quote_after = exchange.balance_of(alice, quote_token)?;
1680
1681            // Ask order maker receives quote - round UP to favor maker
1682            assert_eq!(
1683                alice_quote_after, expected_quote_ceil,
1684                "Ask order maker should receive quote rounded UP. Got {alice_quote_after}, expected ceil {expected_quote_ceil}"
1685            );
1686
1687            assert!(
1688                expected_quote_ceil > expected_quote_floor,
1689                "Test setup error: should have a non-zero remainder"
1690            );
1691
1692            Ok(())
1693        })
1694    }
1695
1696    #[test]
1697    fn test_cancellation_refund_equals_escrow_for_bid_orders() -> eyre::Result<()> {
1698        let mut storage = HashMapStorageProvider::new(1);
1699        StorageCtx::enter(&mut storage, || {
1700            let mut exchange = StablecoinDEX::new();
1701            exchange.initialize()?;
1702
1703            let alice = Address::random();
1704            let admin = Address::random();
1705
1706            // Use an amount above MIN_ORDER_AMOUNT that causes rounding (not evenly divisible)
1707            let base_amount = 100_000_003u128;
1708            let tick = 100i16;
1709
1710            let price = orderbook::tick_to_price(tick) as u128;
1711            let escrow_ceil = (base_amount * price).div_ceil(orderbook::PRICE_SCALE as u128);
1712
1713            let base = TIP20Setup::create("BASE", "BASE", admin)
1714                .with_issuer(admin)
1715                .apply()?;
1716            let base_token = base.address();
1717            let quote_token = base.quote_token()?;
1718
1719            TIP20Setup::path_usd(admin)
1720                .with_issuer(admin)
1721                .with_mint(alice, U256::from(escrow_ceil))
1722                .with_approval(alice, exchange.address, U256::MAX)
1723                .apply()?;
1724
1725            exchange.create_pair(base_token)?;
1726
1727            let order_id = exchange.place(alice, base_token, base_amount, true, tick)?;
1728
1729            // Verify escrow was taken
1730            let alice_balance_after_place = exchange.balance_of(alice, quote_token)?;
1731            assert_eq!(
1732                alice_balance_after_place, 0,
1733                "All quote tokens should be escrowed"
1734            );
1735
1736            exchange.cancel(alice, order_id)?;
1737
1738            let alice_refund = exchange.balance_of(alice, quote_token)?;
1739
1740            // The refund should equal the escrow amount - user should not lose tokens
1741            // when placing and immediately canceling an order
1742            assert_eq!(
1743                alice_refund, escrow_ceil,
1744                "Cancellation refund must equal escrow amount. User escrowed {escrow_ceil} but got back {alice_refund}"
1745            );
1746
1747            Ok(())
1748        })
1749    }
1750
1751    #[test]
1752    fn test_place_order_pair_auto_created() -> eyre::Result<()> {
1753        let mut storage = HashMapStorageProvider::new(1);
1754        StorageCtx::enter(&mut storage, || {
1755            let mut exchange = StablecoinDEX::new();
1756            exchange.initialize()?;
1757
1758            let alice = Address::random();
1759            let admin = Address::random();
1760            let min_order_amount = MIN_ORDER_AMOUNT;
1761            let tick = 100i16;
1762
1763            let price = orderbook::tick_to_price(tick);
1764            let expected_escrow =
1765                (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
1766
1767            let (base_token, _quote_token) =
1768                setup_test_tokens(admin, alice, exchange.address, expected_escrow)?;
1769
1770            // Pair is auto-created when placing order
1771            let result = exchange.place(alice, base_token, min_order_amount, true, tick);
1772            assert!(result.is_ok());
1773
1774            Ok(())
1775        })
1776    }
1777
1778    #[test]
1779    fn test_place_order_below_minimum_amount() -> eyre::Result<()> {
1780        let mut storage = HashMapStorageProvider::new(1);
1781        StorageCtx::enter(&mut storage, || {
1782            let mut exchange = StablecoinDEX::new();
1783            exchange.initialize()?;
1784
1785            let alice = Address::random();
1786            let admin = Address::random();
1787            let min_order_amount = MIN_ORDER_AMOUNT;
1788            let below_minimum = min_order_amount - 1;
1789            let tick = 100i16;
1790
1791            let price = orderbook::tick_to_price(tick);
1792            let escrow_amount = (below_minimum * price as u128) / orderbook::PRICE_SCALE as u128;
1793
1794            let (base_token, _quote_token) =
1795                setup_test_tokens(admin, alice, exchange.address, escrow_amount)?;
1796
1797            // Create the pair
1798            exchange
1799                .create_pair(base_token)
1800                .expect("Could not create pair");
1801
1802            // Try to place an order below minimum amount
1803            let result = exchange.place(alice, base_token, below_minimum, true, tick);
1804            assert_eq!(
1805                result,
1806                Err(StablecoinDEXError::below_minimum_order_size(below_minimum).into())
1807            );
1808
1809            Ok(())
1810        })
1811    }
1812
1813    #[test]
1814    fn test_place_bid_order() -> eyre::Result<()> {
1815        let mut storage = HashMapStorageProvider::new(1);
1816        StorageCtx::enter(&mut storage, || {
1817            let mut exchange = StablecoinDEX::new();
1818            exchange.initialize()?;
1819
1820            let alice = Address::random();
1821            let admin = Address::random();
1822            let min_order_amount = MIN_ORDER_AMOUNT;
1823            let tick = 100i16;
1824
1825            let price = orderbook::tick_to_price(tick);
1826            let expected_escrow =
1827                (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
1828
1829            // Setup tokens with enough balance for the escrow
1830            let (base_token, quote_token) =
1831                setup_test_tokens(admin, alice, exchange.address, expected_escrow)?;
1832
1833            // Create the pair before placing orders
1834            exchange
1835                .create_pair(base_token)
1836                .expect("Could not create pair");
1837
1838            // Place the bid order
1839            let order_id = exchange
1840                .place(alice, base_token, min_order_amount, true, tick)
1841                .expect("Place bid order should succeed");
1842
1843            assert_eq!(order_id, 1);
1844            assert_eq!(exchange.next_order_id()?, 2);
1845
1846            // Verify the order was stored correctly
1847            let stored_order = exchange.orders[order_id].read()?;
1848            assert_eq!(stored_order.maker(), alice);
1849            assert_eq!(stored_order.amount(), min_order_amount);
1850            assert_eq!(stored_order.remaining(), min_order_amount);
1851            assert_eq!(stored_order.tick(), tick);
1852            assert!(stored_order.is_bid());
1853            assert!(!stored_order.is_flip());
1854
1855            // Verify the order is in the active orderbook
1856            let book_key = compute_book_key(base_token, quote_token);
1857            let book_handler = &exchange.books[book_key];
1858            let level = book_handler.tick_level_handler(tick, true).read()?;
1859            assert_eq!(level.head, order_id);
1860            assert_eq!(level.tail, order_id);
1861            assert_eq!(level.total_liquidity, min_order_amount);
1862
1863            // Verify balance was reduced by the escrow amount
1864            let quote_tip20 = TIP20Token::from_address(quote_token)?;
1865            let remaining_balance =
1866                quote_tip20.balance_of(ITIP20::balanceOfCall { account: alice })?;
1867            assert_eq!(remaining_balance, U256::ZERO);
1868
1869            // Verify exchange received the tokens
1870            let exchange_balance = quote_tip20.balance_of(ITIP20::balanceOfCall {
1871                account: exchange.address,
1872            })?;
1873            assert_eq!(exchange_balance, U256::from(expected_escrow));
1874
1875            Ok(())
1876        })
1877    }
1878
1879    #[test]
1880    fn test_place_ask_order() -> eyre::Result<()> {
1881        let mut storage = HashMapStorageProvider::new(1);
1882        StorageCtx::enter(&mut storage, || {
1883            let mut exchange = StablecoinDEX::new();
1884            exchange.initialize()?;
1885
1886            let alice = Address::random();
1887            let admin = Address::random();
1888            let min_order_amount = MIN_ORDER_AMOUNT;
1889            let tick = 50i16; // Use positive tick to avoid conversion issues
1890
1891            // Setup tokens with enough base token balance for the order
1892            let (base_token, quote_token) =
1893                setup_test_tokens(admin, alice, exchange.address, min_order_amount)?;
1894            // Create the pair before placing orders
1895            exchange
1896                .create_pair(base_token)
1897                .expect("Could not create pair");
1898
1899            let order_id = exchange
1900                .place(alice, base_token, min_order_amount, false, tick) // is_bid = false for ask
1901                .expect("Place ask order should succeed");
1902
1903            assert_eq!(order_id, 1);
1904            assert_eq!(exchange.next_order_id()?, 2);
1905
1906            // Verify the order was stored correctly
1907            let stored_order = exchange.orders[order_id].read()?;
1908            assert_eq!(stored_order.maker(), alice);
1909            assert_eq!(stored_order.amount(), min_order_amount);
1910            assert_eq!(stored_order.remaining(), min_order_amount);
1911            assert_eq!(stored_order.tick(), tick);
1912            assert!(!stored_order.is_bid());
1913            assert!(!stored_order.is_flip());
1914
1915            // Verify the order is in the active orderbook
1916            let book_key = compute_book_key(base_token, quote_token);
1917            let book_handler = &exchange.books[book_key];
1918            let level = book_handler.tick_level_handler(tick, false).read()?;
1919            assert_eq!(level.head, order_id);
1920            assert_eq!(level.tail, order_id);
1921            assert_eq!(level.total_liquidity, min_order_amount);
1922
1923            // Verify balance was reduced by the escrow amount
1924            let base_tip20 = TIP20Token::from_address(base_token)?;
1925            let remaining_balance =
1926                base_tip20.balance_of(ITIP20::balanceOfCall { account: alice })?;
1927            assert_eq!(remaining_balance, U256::ZERO); // All tokens should be escrowed
1928
1929            // Verify exchange received the base tokens
1930            let exchange_balance = base_tip20.balance_of(ITIP20::balanceOfCall {
1931                account: exchange.address,
1932            })?;
1933            assert_eq!(exchange_balance, U256::from(min_order_amount));
1934
1935            Ok(())
1936        })
1937    }
1938
1939    #[test]
1940    fn test_place_flip_order_below_minimum_amount() -> eyre::Result<()> {
1941        let mut storage = HashMapStorageProvider::new(1);
1942        StorageCtx::enter(&mut storage, || {
1943            let mut exchange = StablecoinDEX::new();
1944            exchange.initialize()?;
1945
1946            let alice = Address::random();
1947            let admin = Address::random();
1948            let min_order_amount = MIN_ORDER_AMOUNT;
1949            let below_minimum = min_order_amount - 1;
1950            let tick = 100i16;
1951            let flip_tick = 200i16;
1952
1953            let price = orderbook::tick_to_price(tick);
1954            let escrow_amount = (below_minimum * price as u128) / orderbook::PRICE_SCALE as u128;
1955
1956            let (base_token, _quote_token) =
1957                setup_test_tokens(admin, alice, exchange.address, escrow_amount)?;
1958
1959            // Create the pair
1960            exchange
1961                .create_pair(base_token)
1962                .expect("Could not create pair");
1963
1964            // Try to place a flip order below minimum amount
1965            let result = exchange.place_flip(
1966                alice,
1967                base_token,
1968                below_minimum,
1969                true,
1970                tick,
1971                flip_tick,
1972                false,
1973            );
1974            assert_eq!(
1975                result,
1976                Err(StablecoinDEXError::below_minimum_order_size(below_minimum).into())
1977            );
1978
1979            Ok(())
1980        })
1981    }
1982
1983    #[test]
1984    fn test_place_flip_auto_creates_pair() -> Result<()> {
1985        let mut storage = HashMapStorageProvider::new(1);
1986        StorageCtx::enter(&mut storage, || {
1987            let mut exchange = StablecoinDEX::new();
1988            exchange.initialize()?;
1989
1990            let admin = Address::random();
1991            let user = Address::random();
1992
1993            // Setup tokens
1994            let (base_token, quote_token) =
1995                setup_test_tokens(admin, user, exchange.address, 100_000_000)?;
1996
1997            // Before placing flip order, verify pair doesn't exist
1998            let book_key = compute_book_key(base_token, quote_token);
1999            let book_before = exchange.books[book_key].read()?;
2000            assert!(book_before.base.is_zero(),);
2001
2002            // Transfer tokens to exchange first
2003            let mut base = TIP20Token::from_address(base_token)?;
2004            base.transfer(
2005                user,
2006                ITIP20::transferCall {
2007                    to: exchange.address,
2008                    amount: U256::from(MIN_ORDER_AMOUNT),
2009                },
2010            )
2011            .expect("Base token transfer failed");
2012
2013            // Place a flip order which should also create the pair
2014            exchange.place_flip(user, base_token, MIN_ORDER_AMOUNT, true, 0, 10, false)?;
2015
2016            let book_after = exchange.books[book_key].read()?;
2017            assert_eq!(book_after.base, base_token);
2018
2019            // Verify PairCreated event was emitted (along with FlipOrderPlaced)
2020            let events = exchange.emitted_events();
2021            assert_eq!(events.len(), 2);
2022            assert_eq!(
2023                events[0],
2024                StablecoinDEXEvents::PairCreated(IStablecoinDEX::PairCreated {
2025                    key: book_key,
2026                    base: base_token,
2027                    quote: quote_token,
2028                })
2029                .into_log_data()
2030            );
2031
2032            Ok(())
2033        })
2034    }
2035
2036    #[test]
2037    fn test_place_flip_order() -> eyre::Result<()> {
2038        let mut storage = HashMapStorageProvider::new(1);
2039        StorageCtx::enter(&mut storage, || {
2040            let mut exchange = StablecoinDEX::new();
2041            exchange.initialize()?;
2042
2043            let alice = Address::random();
2044            let admin = Address::random();
2045            let min_order_amount = MIN_ORDER_AMOUNT;
2046            let tick = 100i16;
2047            let flip_tick = 200i16; // Must be > tick for bid flip orders
2048
2049            // Calculate escrow amount needed for bid
2050            let price = orderbook::tick_to_price(tick);
2051            let expected_escrow =
2052                (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
2053
2054            // Setup tokens with enough balance for the escrow
2055            let (base_token, quote_token) =
2056                setup_test_tokens(admin, alice, exchange.address, expected_escrow)?;
2057            exchange
2058                .create_pair(base_token)
2059                .expect("Could not create pair");
2060
2061            let order_id = exchange
2062                .place_flip(
2063                    alice,
2064                    base_token,
2065                    min_order_amount,
2066                    true,
2067                    tick,
2068                    flip_tick,
2069                    false,
2070                )
2071                .expect("Place flip bid order should succeed");
2072
2073            assert_eq!(order_id, 1);
2074            assert_eq!(exchange.next_order_id()?, 2);
2075
2076            // Verify the order was stored correctly
2077            let stored_order = exchange.orders[order_id].read()?;
2078            assert_eq!(stored_order.maker(), alice);
2079            assert_eq!(stored_order.amount(), min_order_amount);
2080            assert_eq!(stored_order.remaining(), min_order_amount);
2081            assert_eq!(stored_order.tick(), tick);
2082            assert!(stored_order.is_bid());
2083            assert!(stored_order.is_flip());
2084            assert_eq!(stored_order.flip_tick(), flip_tick);
2085
2086            // Verify the order is in the active orderbook
2087            let book_key = compute_book_key(base_token, quote_token);
2088            let book_handler = &exchange.books[book_key];
2089            let level = book_handler.tick_level_handler(tick, true).read()?;
2090            assert_eq!(level.head, order_id);
2091            assert_eq!(level.tail, order_id);
2092            assert_eq!(level.total_liquidity, min_order_amount);
2093
2094            // Verify balance was reduced by the escrow amount
2095            let quote_tip20 = TIP20Token::from_address(quote_token)?;
2096            let remaining_balance =
2097                quote_tip20.balance_of(ITIP20::balanceOfCall { account: alice })?;
2098            assert_eq!(remaining_balance, U256::ZERO);
2099
2100            // Verify exchange received the tokens
2101            let exchange_balance = quote_tip20.balance_of(ITIP20::balanceOfCall {
2102                account: exchange.address,
2103            })?;
2104            assert_eq!(exchange_balance, U256::from(expected_escrow));
2105
2106            Ok(())
2107        })
2108    }
2109
2110    #[test]
2111    fn test_withdraw() -> eyre::Result<()> {
2112        let mut storage = HashMapStorageProvider::new(1);
2113        StorageCtx::enter(&mut storage, || {
2114            let mut exchange = StablecoinDEX::new();
2115            exchange.initialize()?;
2116
2117            let alice = Address::random();
2118            let admin = Address::random();
2119            let min_order_amount = MIN_ORDER_AMOUNT;
2120            let tick = 100i16;
2121            let price = orderbook::tick_to_price(tick);
2122            let expected_escrow =
2123                (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
2124
2125            // Setup tokens
2126            let (base_token, quote_token) =
2127                setup_test_tokens(admin, alice, exchange.address, expected_escrow)?;
2128            exchange
2129                .create_pair(base_token)
2130                .expect("Could not create pair");
2131
2132            // Place the bid order and cancel
2133            let order_id = exchange
2134                .place(alice, base_token, min_order_amount, true, tick)
2135                .expect("Place bid order should succeed");
2136
2137            exchange
2138                .cancel(alice, order_id)
2139                .expect("Cancel pending order should succeed");
2140
2141            assert_eq!(exchange.balance_of(alice, quote_token)?, expected_escrow);
2142
2143            // Get balances before withdrawal
2144            exchange
2145                .withdraw(alice, quote_token, expected_escrow)
2146                .expect("Withdraw should succeed");
2147            assert_eq!(exchange.balance_of(alice, quote_token)?, 0);
2148
2149            // Verify wallet balances changed correctly
2150            let quote_tip20 = TIP20Token::from_address(quote_token)?;
2151            assert_eq!(
2152                quote_tip20.balance_of(ITIP20::balanceOfCall { account: alice })?,
2153                expected_escrow
2154            );
2155            assert_eq!(
2156                quote_tip20.balance_of(ITIP20::balanceOfCall {
2157                    account: exchange.address
2158                })?,
2159                0
2160            );
2161
2162            Ok(())
2163        })
2164    }
2165
2166    #[test]
2167    fn test_withdraw_insufficient_balance() -> eyre::Result<()> {
2168        let mut storage = HashMapStorageProvider::new(1);
2169        StorageCtx::enter(&mut storage, || {
2170            let mut exchange = StablecoinDEX::new();
2171            exchange.initialize()?;
2172
2173            let alice = Address::random();
2174            let admin = Address::random();
2175
2176            let min_order_amount = MIN_ORDER_AMOUNT;
2177            let (_base_token, quote_token) =
2178                setup_test_tokens(admin, alice, exchange.address, min_order_amount)?;
2179
2180            // Alice has 0 balance on the exchange
2181            assert_eq!(exchange.balance_of(alice, quote_token)?, 0);
2182
2183            // Try to withdraw more than balance
2184            let result = exchange.withdraw(alice, quote_token, 100u128);
2185
2186            assert_eq!(
2187                result,
2188                Err(StablecoinDEXError::insufficient_balance().into())
2189            );
2190
2191            Ok(())
2192        })
2193    }
2194
2195    #[test]
2196    fn test_quote_swap_exact_amount_out() -> eyre::Result<()> {
2197        let mut storage = HashMapStorageProvider::new(1);
2198        StorageCtx::enter(&mut storage, || {
2199            let mut exchange = StablecoinDEX::new();
2200            exchange.initialize()?;
2201
2202            let alice = Address::random();
2203            let admin = Address::random();
2204            let min_order_amount = MIN_ORDER_AMOUNT;
2205            let amount_out = 500_000u128;
2206            let tick = 10;
2207
2208            let (base_token, quote_token) =
2209                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
2210            exchange
2211                .create_pair(base_token)
2212                .expect("Could not create pair");
2213
2214            let order_amount = min_order_amount;
2215            exchange
2216                .place(alice, base_token, order_amount, false, tick)
2217                .expect("Order should succeed");
2218
2219            let amount_in = exchange
2220                .quote_swap_exact_amount_out(quote_token, base_token, amount_out)
2221                .expect("Swap should succeed");
2222
2223            let price = orderbook::tick_to_price(tick);
2224            let expected_amount_in = (amount_out * price as u128) / orderbook::PRICE_SCALE as u128;
2225            assert_eq!(amount_in, expected_amount_in);
2226
2227            Ok(())
2228        })
2229    }
2230
2231    #[test]
2232    fn test_quote_swap_exact_amount_in() -> eyre::Result<()> {
2233        let mut storage = HashMapStorageProvider::new(1);
2234        StorageCtx::enter(&mut storage, || {
2235            let mut exchange = StablecoinDEX::new();
2236            exchange.initialize()?;
2237
2238            let alice = Address::random();
2239            let admin = Address::random();
2240            let min_order_amount = MIN_ORDER_AMOUNT;
2241            let amount_in = 500_000u128;
2242            let tick = 10;
2243
2244            let (base_token, quote_token) =
2245                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
2246            exchange
2247                .create_pair(base_token)
2248                .expect("Could not create pair");
2249
2250            let order_amount = min_order_amount;
2251            exchange
2252                .place(alice, base_token, order_amount, true, tick)
2253                .expect("Place bid order should succeed");
2254
2255            let amount_out = exchange
2256                .quote_swap_exact_amount_in(base_token, quote_token, amount_in)
2257                .expect("Swap should succeed");
2258
2259            // Calculate expected amount_out based on tick price
2260            let price = orderbook::tick_to_price(tick);
2261            let expected_amount_out = (amount_in * price as u128) / orderbook::PRICE_SCALE as u128;
2262            assert_eq!(amount_out, expected_amount_out);
2263
2264            Ok(())
2265        })
2266    }
2267
2268    #[test]
2269    fn test_quote_swap_exact_amount_out_base_for_quote() -> eyre::Result<()> {
2270        let mut storage = HashMapStorageProvider::new(1);
2271        StorageCtx::enter(&mut storage, || {
2272            let mut exchange = StablecoinDEX::new();
2273            exchange.initialize()?;
2274
2275            let alice = Address::random();
2276            let admin = Address::random();
2277            let min_order_amount = MIN_ORDER_AMOUNT;
2278            let amount_out = 500_000u128;
2279            let tick = 0;
2280
2281            let (base_token, quote_token) =
2282                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
2283            exchange
2284                .create_pair(base_token)
2285                .expect("Could not create pair");
2286
2287            // Alice places a bid: willing to BUY base using quote
2288            let order_amount = min_order_amount;
2289            exchange
2290                .place(alice, base_token, order_amount, true, tick)
2291                .expect("Place bid order should succeed");
2292
2293            // Quote: sell base to get quote
2294            // Should match against Alice's bid (buyer of base)
2295            let amount_in = exchange
2296                .quote_swap_exact_amount_out(base_token, quote_token, amount_out)
2297                .expect("Quote should succeed");
2298
2299            let price = orderbook::tick_to_price(tick);
2300            // Expected: ceil(amount_out * PRICE_SCALE / price)
2301            let expected_amount_in =
2302                (amount_out * orderbook::PRICE_SCALE as u128).div_ceil(price as u128);
2303            assert_eq!(amount_in, expected_amount_in);
2304
2305            Ok(())
2306        })
2307    }
2308
2309    #[test]
2310    fn test_quote_exact_out_bid_positive_tick_no_underflow() -> eyre::Result<()> {
2311        let mut storage = HashMapStorageProvider::new(1);
2312        StorageCtx::enter(&mut storage, || {
2313            let mut exchange = StablecoinDEX::new();
2314            exchange.initialize()?;
2315
2316            let alice = Address::random();
2317            let admin = Address::random();
2318
2319            let (base_token, quote_token) =
2320                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
2321            exchange.create_pair(base_token)?;
2322
2323            let tick = 10;
2324            let order_amount = MIN_ORDER_AMOUNT;
2325            exchange.place(alice, base_token, order_amount, true, tick)?;
2326
2327            for amount_out in [100_001u128, 100_003, 100_007, 100_009, 100_011] {
2328                let amount_in = exchange
2329                    .quote_swap_exact_amount_out(base_token, quote_token, amount_out)
2330                    .unwrap_or_else(|_| {
2331                        panic!("quote_exact_out should not underflow for amount_out={amount_out}")
2332                    });
2333
2334                let expected =
2335                    orderbook::quote_to_base(amount_out, tick, RoundingDirection::Up).unwrap();
2336                assert_eq!(
2337                    amount_in, expected,
2338                    "amount_in should equal quote_to_base(amount_out, tick, Up) for amount_out={amount_out}"
2339                );
2340            }
2341
2342            Ok(())
2343        })
2344    }
2345
2346    #[test]
2347    fn test_swap_exact_amount_out() -> eyre::Result<()> {
2348        let mut storage = HashMapStorageProvider::new(1);
2349        StorageCtx::enter(&mut storage, || {
2350            let mut exchange = StablecoinDEX::new();
2351            exchange.initialize()?;
2352
2353            let alice = Address::random();
2354            let bob = Address::random();
2355            let admin = Address::random();
2356            let min_order_amount = MIN_ORDER_AMOUNT;
2357            let amount_out = 500_000u128;
2358            let tick = 10;
2359
2360            let (base_token, quote_token) =
2361                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
2362            exchange
2363                .create_pair(base_token)
2364                .expect("Could not create pair");
2365
2366            let order_amount = min_order_amount;
2367            exchange
2368                .place(alice, base_token, order_amount, false, tick)
2369                .expect("Order should succeed");
2370
2371            exchange
2372                .set_balance(bob, quote_token, 200_000_000u128)
2373                .expect("Could not set balance");
2374
2375            let price = orderbook::tick_to_price(tick);
2376            let max_amount_in = (amount_out * price as u128) / orderbook::PRICE_SCALE as u128;
2377
2378            let amount_in = exchange
2379                .swap_exact_amount_out(bob, quote_token, base_token, amount_out, max_amount_in)
2380                .expect("Swap should succeed");
2381
2382            let base_tip20 = TIP20Token::from_address(base_token)?;
2383            let bob_base_balance = base_tip20.balance_of(ITIP20::balanceOfCall { account: bob })?;
2384            assert_eq!(bob_base_balance, U256::from(amount_out));
2385
2386            let alice_quote_exchange_balance = exchange.balance_of(alice, quote_token)?;
2387            assert_eq!(alice_quote_exchange_balance, amount_in);
2388
2389            Ok(())
2390        })
2391    }
2392
2393    #[test]
2394    fn test_swap_exact_amount_in() -> eyre::Result<()> {
2395        let mut storage = HashMapStorageProvider::new(1);
2396        StorageCtx::enter(&mut storage, || {
2397            let mut exchange = StablecoinDEX::new();
2398            exchange.initialize()?;
2399
2400            let alice = Address::random();
2401            let bob = Address::random();
2402            let admin = Address::random();
2403            let min_order_amount = MIN_ORDER_AMOUNT;
2404            let amount_in = 500_000u128;
2405            let tick = 10;
2406
2407            let (base_token, quote_token) =
2408                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
2409            exchange
2410                .create_pair(base_token)
2411                .expect("Could not create pair");
2412
2413            let order_amount = min_order_amount;
2414            exchange
2415                .place(alice, base_token, order_amount, true, tick)
2416                .expect("Order should succeed");
2417
2418            exchange
2419                .set_balance(bob, base_token, 200_000_000u128)
2420                .expect("Could not set balance");
2421
2422            let price = orderbook::tick_to_price(tick);
2423            let min_amount_out = (amount_in * price as u128) / orderbook::PRICE_SCALE as u128;
2424
2425            let amount_out = exchange
2426                .swap_exact_amount_in(bob, base_token, quote_token, amount_in, min_amount_out)
2427                .expect("Swap should succeed");
2428
2429            let quote_tip20 = TIP20Token::from_address(quote_token)?;
2430            let bob_quote_balance =
2431                quote_tip20.balance_of(ITIP20::balanceOfCall { account: bob })?;
2432            assert_eq!(bob_quote_balance, U256::from(amount_out));
2433
2434            let alice_base_exchange_balance = exchange.balance_of(alice, base_token)?;
2435            assert_eq!(alice_base_exchange_balance, amount_in);
2436
2437            Ok(())
2438        })
2439    }
2440
2441    #[test]
2442    fn test_flip_order_execution() -> eyre::Result<()> {
2443        let mut storage = HashMapStorageProvider::new(1);
2444        StorageCtx::enter(&mut storage, || {
2445            let mut exchange = StablecoinDEX::new();
2446            exchange.initialize()?;
2447
2448            let alice = Address::random();
2449            let bob = Address::random();
2450            let admin = Address::random();
2451            let min_order_amount = MIN_ORDER_AMOUNT;
2452            let amount = min_order_amount;
2453            let tick = 100i16;
2454            let flip_tick = 200i16;
2455
2456            let price = orderbook::tick_to_price(tick);
2457            let expected_escrow = (amount * price as u128) / orderbook::PRICE_SCALE as u128;
2458
2459            let (base_token, quote_token) =
2460                setup_test_tokens(admin, alice, exchange.address, expected_escrow * 2)?;
2461            exchange
2462                .create_pair(base_token)
2463                .expect("Could not create pair");
2464
2465            // Place a flip bid order
2466            let flip_order_id = exchange
2467                .place_flip(alice, base_token, amount, true, tick, flip_tick, false)
2468                .expect("Place flip order should succeed");
2469
2470            exchange
2471                .set_balance(bob, base_token, amount)
2472                .expect("Could not set balance");
2473
2474            exchange
2475                .swap_exact_amount_in(bob, base_token, quote_token, amount, 0)
2476                .expect("Swap should succeed");
2477
2478            // Assert that the order has filled (remaining should be 0)
2479            let filled_order = exchange.orders[flip_order_id].read()?;
2480            assert_eq!(filled_order.remaining(), 0);
2481
2482            // The flipped order should be created with id = flip_order_id + 1
2483            let new_order_id = exchange.next_order_id()? - 1;
2484            assert_eq!(new_order_id, flip_order_id + 1);
2485
2486            let new_order = exchange.orders[new_order_id].read()?;
2487            assert_eq!(new_order.maker(), alice);
2488            assert_eq!(new_order.tick(), flip_tick);
2489            assert_eq!(new_order.flip_tick(), tick);
2490            assert!(new_order.is_ask());
2491            assert_eq!(new_order.amount(), amount);
2492            assert_eq!(new_order.remaining(), amount);
2493
2494            Ok(())
2495        })
2496    }
2497
2498    #[test]
2499    fn test_pair_created() -> eyre::Result<()> {
2500        let mut storage = HashMapStorageProvider::new(1);
2501        StorageCtx::enter(&mut storage, || {
2502            let mut exchange = StablecoinDEX::new();
2503            exchange.initialize()?;
2504
2505            let admin = Address::random();
2506            let alice = Address::random();
2507
2508            let min_order_amount = MIN_ORDER_AMOUNT;
2509            // Setup tokens
2510            let (base_token, quote_token) =
2511                setup_test_tokens(admin, alice, exchange.address, min_order_amount)?;
2512
2513            // Create the pair
2514            let key = exchange
2515                .create_pair(base_token)
2516                .expect("Could not create pair");
2517
2518            // Verify PairCreated event was emitted
2519            exchange.assert_emitted_events(vec![StablecoinDEXEvents::PairCreated(
2520                IStablecoinDEX::PairCreated {
2521                    key,
2522                    base: base_token,
2523                    quote: quote_token,
2524                },
2525            )]);
2526
2527            Ok(())
2528        })
2529    }
2530
2531    #[test]
2532    fn test_pair_already_created() -> eyre::Result<()> {
2533        let mut storage = HashMapStorageProvider::new(1);
2534        StorageCtx::enter(&mut storage, || {
2535            let mut exchange = StablecoinDEX::new();
2536            exchange.initialize()?;
2537
2538            let admin = Address::random();
2539            let alice = Address::random();
2540
2541            let min_order_amount = MIN_ORDER_AMOUNT;
2542            // Setup tokens
2543            let (base_token, _) =
2544                setup_test_tokens(admin, alice, exchange.address, min_order_amount)?;
2545
2546            exchange
2547                .create_pair(base_token)
2548                .expect("Could not create pair");
2549
2550            let result = exchange.create_pair(base_token);
2551            assert_eq!(
2552                result,
2553                Err(StablecoinDEXError::pair_already_exists().into())
2554            );
2555
2556            Ok(())
2557        })
2558    }
2559
2560    /// Helper to verify a single hop in a route
2561    fn verify_hop(hop: (B256, bool), token_in: Address) -> eyre::Result<()> {
2562        let (book_key, is_base_for_quote) = hop;
2563
2564        let exchange = StablecoinDEX::new();
2565        let orderbook = exchange.books[book_key].read()?;
2566
2567        let expected_book_key = compute_book_key(orderbook.base, orderbook.quote);
2568        assert_eq!(book_key, expected_book_key, "Book key should match");
2569
2570        let expected_direction = token_in == orderbook.base;
2571        assert_eq!(
2572            is_base_for_quote, expected_direction,
2573            "Direction should be correct: token_in={}, base={}, is_base_for_quote={}",
2574            token_in, orderbook.base, is_base_for_quote
2575        );
2576
2577        Ok(())
2578    }
2579
2580    #[test]
2581    fn test_find_path_to_root() -> eyre::Result<()> {
2582        let mut storage = HashMapStorageProvider::new(1);
2583        StorageCtx::enter(&mut storage, || {
2584            let mut exchange = StablecoinDEX::new();
2585            exchange.initialize()?;
2586
2587            let admin = Address::random();
2588
2589            // Setup: pathUSD <- USDC <- TokenA
2590            let usdc = TIP20Setup::create("USDC", "USDC", admin).apply()?;
2591            let token_a = TIP20Setup::create("TokenA", "TKA", admin)
2592                .quote_token(usdc.address())
2593                .apply()?;
2594
2595            // Find path from TokenA to root
2596            let path = exchange.find_path_to_root(token_a.address())?;
2597
2598            // Expected: [TokenA, USDC, pathUSD]
2599            assert_eq!(path.len(), 3);
2600            assert_eq!(path[0], token_a.address());
2601            assert_eq!(path[1], usdc.address());
2602            assert_eq!(path[2], PATH_USD_ADDRESS);
2603
2604            Ok(())
2605        })
2606    }
2607
2608    #[test]
2609    fn test_find_trade_path_same_token_errors() -> eyre::Result<()> {
2610        let mut storage = HashMapStorageProvider::new(1);
2611        StorageCtx::enter(&mut storage, || {
2612            let mut exchange = StablecoinDEX::new();
2613            exchange.initialize()?;
2614
2615            let admin = Address::random();
2616            let user = Address::random();
2617
2618            let min_order_amount = MIN_ORDER_AMOUNT;
2619            let (token, _) = setup_test_tokens(admin, user, exchange.address, min_order_amount)?;
2620
2621            // Trading same token should error with IdenticalTokens
2622            let result = exchange.find_trade_path(token, token);
2623            assert_eq!(
2624                result,
2625                Err(StablecoinDEXError::identical_tokens().into()),
2626                "Should return IdenticalTokens error when token_in == token_out"
2627            );
2628
2629            Ok(())
2630        })
2631    }
2632
2633    #[test]
2634    fn test_find_trade_path_direct_pair() -> eyre::Result<()> {
2635        let mut storage = HashMapStorageProvider::new(1);
2636        StorageCtx::enter(&mut storage, || {
2637            let mut exchange = StablecoinDEX::new();
2638            exchange.initialize()?;
2639
2640            let admin = Address::random();
2641            let user = Address::random();
2642
2643            let min_order_amount = MIN_ORDER_AMOUNT;
2644            // Setup: pathUSD <- Token (direct pair)
2645            let (token, path_usd) =
2646                setup_test_tokens(admin, user, exchange.address, min_order_amount)?;
2647
2648            // Create the pair first
2649            exchange.create_pair(token).expect("Failed to create pair");
2650
2651            // Trade token -> path_usd (direct pair)
2652            let route = exchange
2653                .find_trade_path(token, path_usd)
2654                .expect("Should find direct pair");
2655
2656            // Expected: 1 hop (token -> path_usd)
2657            assert_eq!(route.len(), 1, "Should have 1 hop for direct pair");
2658            verify_hop(route[0], token)?;
2659
2660            Ok(())
2661        })
2662    }
2663
2664    #[test]
2665    fn test_find_trade_path_reverse_pair() -> eyre::Result<()> {
2666        let mut storage = HashMapStorageProvider::new(1);
2667        StorageCtx::enter(&mut storage, || {
2668            let mut exchange = StablecoinDEX::new();
2669            exchange.initialize()?;
2670
2671            let admin = Address::random();
2672            let user = Address::random();
2673
2674            let min_order_amount = MIN_ORDER_AMOUNT;
2675            // Setup: pathUSD <- Token
2676            let (token, path_usd) =
2677                setup_test_tokens(admin, user, exchange.address, min_order_amount)?;
2678
2679            // Create the pair first
2680            exchange.create_pair(token).expect("Failed to create pair");
2681
2682            // Trade path_usd -> token (reverse direction)
2683            let route = exchange
2684                .find_trade_path(path_usd, token)
2685                .expect("Should find reverse pair");
2686
2687            // Expected: 1 hop (path_usd -> token)
2688            assert_eq!(route.len(), 1, "Should have 1 hop for reverse pair");
2689            verify_hop(route[0], path_usd)?;
2690
2691            Ok(())
2692        })
2693    }
2694
2695    #[test]
2696    fn test_find_trade_path_two_hop_siblings() -> eyre::Result<()> {
2697        let mut storage = HashMapStorageProvider::new(1);
2698        StorageCtx::enter(&mut storage, || {
2699            let mut exchange = StablecoinDEX::new();
2700            exchange.initialize()?;
2701
2702            let admin = Address::random();
2703
2704            // Setup: pathUSD <- USDC
2705            //        pathUSD <- EURC
2706            // (USDC and EURC are siblings, both have pathUSD as quote)
2707            let usdc = TIP20Setup::create("USDC", "USDC", admin).apply()?;
2708            let eurc = TIP20Setup::create("EURC", "EURC", admin).apply()?;
2709
2710            // Create pairs first
2711            exchange.create_pair(usdc.address())?;
2712            exchange.create_pair(eurc.address())?;
2713
2714            // Trade USDC -> EURC should go through pathUSD
2715            let route = exchange.find_trade_path(usdc.address(), eurc.address())?;
2716
2717            // Expected: 2 hops (USDC -> pathUSD, pathUSD -> EURC)
2718            assert_eq!(route.len(), 2, "Should have 2 hops for sibling tokens");
2719            verify_hop(route[0], usdc.address())?;
2720            verify_hop(route[1], PATH_USD_ADDRESS)?;
2721
2722            Ok(())
2723        })
2724    }
2725
2726    #[test]
2727    fn test_quote_exact_in_multi_hop() -> eyre::Result<()> {
2728        let mut storage = HashMapStorageProvider::new(1);
2729        StorageCtx::enter(&mut storage, || {
2730            let mut exchange = StablecoinDEX::new();
2731            exchange.initialize()?;
2732
2733            let admin = Address::random();
2734            let alice = Address::random();
2735            let min_order_amount = MIN_ORDER_AMOUNT;
2736            let min_order_amount_x10 = U256::from(MIN_ORDER_AMOUNT * 10);
2737
2738            // Setup: pathUSD <- USDC
2739            //        pathUSD <- EURC
2740            let _path_usd = TIP20Setup::path_usd(admin)
2741                .with_issuer(admin)
2742                .with_mint(alice, min_order_amount_x10)
2743                .with_approval(alice, exchange.address, min_order_amount_x10)
2744                .apply()?;
2745            let usdc = TIP20Setup::create("USDC", "USDC", admin)
2746                .with_issuer(admin)
2747                .with_mint(alice, min_order_amount_x10)
2748                .with_approval(alice, exchange.address, min_order_amount_x10)
2749                .apply()?;
2750            let eurc = TIP20Setup::create("EURC", "EURC", admin)
2751                .with_issuer(admin)
2752                .with_mint(alice, min_order_amount_x10)
2753                .with_approval(alice, exchange.address, min_order_amount_x10)
2754                .apply()?;
2755
2756            // Place orders to provide liquidity at 1:1 rate (tick 0)
2757            // For trade USDC -> pathUSD -> EURC:
2758            // - First hop needs: bid on USDC (someone buying USDC with pathUSD)
2759            // - Second hop needs: ask on EURC (someone selling EURC for pathUSD)
2760
2761            // USDC bid: buy USDC with pathUSD
2762            exchange.place(alice, usdc.address(), min_order_amount * 5, true, 0)?;
2763
2764            // EURC ask: sell EURC for pathUSD
2765            exchange.place(alice, eurc.address(), min_order_amount * 5, false, 0)?;
2766
2767            // Quote multi-hop: USDC -> pathUSD -> EURC
2768            let amount_in = min_order_amount;
2769            let amount_out =
2770                exchange.quote_swap_exact_amount_in(usdc.address(), eurc.address(), amount_in)?;
2771
2772            // With 1:1 rates at each hop, output should equal input
2773            assert_eq!(
2774                amount_out, amount_in,
2775                "With 1:1 rates, output should equal input"
2776            );
2777
2778            Ok(())
2779        })
2780    }
2781
2782    #[test]
2783    fn test_quote_exact_out_multi_hop() -> eyre::Result<()> {
2784        let mut storage = HashMapStorageProvider::new(1);
2785        StorageCtx::enter(&mut storage, || {
2786            let mut exchange = StablecoinDEX::new();
2787            exchange.initialize()?;
2788
2789            let admin = Address::random();
2790            let alice = Address::random();
2791            let min_order_amount = MIN_ORDER_AMOUNT;
2792            let min_order_amount_x10 = U256::from(MIN_ORDER_AMOUNT * 10);
2793
2794            // Setup: pathUSD <- USDC
2795            //        pathUSD <- EURC
2796            let _path_usd = TIP20Setup::path_usd(admin)
2797                .with_issuer(admin)
2798                .with_mint(alice, min_order_amount_x10)
2799                .with_approval(alice, exchange.address, min_order_amount_x10)
2800                .apply()?;
2801            let usdc = TIP20Setup::create("USDC", "USDC", admin)
2802                .with_issuer(admin)
2803                .with_mint(alice, min_order_amount_x10)
2804                .with_approval(alice, exchange.address, min_order_amount_x10)
2805                .apply()?;
2806            let eurc = TIP20Setup::create("EURC", "EURC", admin)
2807                .with_issuer(admin)
2808                .with_mint(alice, min_order_amount_x10)
2809                .with_approval(alice, exchange.address, min_order_amount_x10)
2810                .apply()?;
2811
2812            // Place orders at 1:1 rate
2813            exchange.place(alice, usdc.address(), min_order_amount * 5, true, 0)?;
2814            exchange.place(alice, eurc.address(), min_order_amount * 5, false, 0)?;
2815
2816            // Quote multi-hop for exact output: USDC -> pathUSD -> EURC
2817            let amount_out = min_order_amount;
2818            let amount_in =
2819                exchange.quote_swap_exact_amount_out(usdc.address(), eurc.address(), amount_out)?;
2820
2821            // With 1:1 rates at each hop and no fractional remainders,
2822            // ceiling division produces exact amounts
2823            assert_eq!(
2824                amount_in, amount_out,
2825                "With 1:1 rates and no rounding, input should equal output"
2826            );
2827
2828            Ok(())
2829        })
2830    }
2831
2832    #[test]
2833    fn test_swap_exact_in_multi_hop_transitory_balances() -> eyre::Result<()> {
2834        let mut storage = HashMapStorageProvider::new(1);
2835        StorageCtx::enter(&mut storage, || {
2836            let mut exchange = StablecoinDEX::new();
2837            exchange.initialize()?;
2838
2839            let admin = Address::random();
2840            let alice = Address::random();
2841            let bob = Address::random();
2842
2843            let min_order_amount = MIN_ORDER_AMOUNT;
2844            let min_order_amount_x10 = U256::from(MIN_ORDER_AMOUNT * 10);
2845
2846            // Setup: pathUSD <- USDC <- EURC
2847            let path_usd = TIP20Setup::path_usd(admin)
2848                .with_issuer(admin)
2849                // Setup alice as a liquidity provider
2850                .with_mint(alice, min_order_amount_x10)
2851                .with_approval(alice, exchange.address, min_order_amount_x10)
2852                .apply()?;
2853
2854            let usdc = TIP20Setup::create("USDC", "USDC", admin)
2855                .with_issuer(admin)
2856                // Setup alice as a liquidity provider
2857                .with_mint(alice, min_order_amount_x10)
2858                .with_approval(alice, exchange.address, min_order_amount_x10)
2859                // Setup bob as a trader
2860                .with_mint(bob, min_order_amount_x10)
2861                .with_approval(bob, exchange.address, min_order_amount_x10)
2862                .apply()?;
2863
2864            let eurc = TIP20Setup::create("EURC", "EURC", admin)
2865                .with_issuer(admin)
2866                // Setup alice as a liquidity provider
2867                .with_mint(alice, min_order_amount_x10)
2868                .with_approval(alice, exchange.address, min_order_amount_x10)
2869                .apply()?;
2870
2871            // Place liquidity orders at 1:1
2872            exchange.place(alice, usdc.address(), min_order_amount * 5, true, 0)?;
2873            exchange.place(alice, eurc.address(), min_order_amount * 5, false, 0)?;
2874
2875            // Check bob's balances before swap
2876            let bob_usdc_before = usdc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2877            let bob_eurc_before = eurc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2878
2879            // Execute multi-hop swap: USDC -> pathUSD -> EURC
2880            let amount_in = min_order_amount;
2881            let amount_out = exchange.swap_exact_amount_in(
2882                bob,
2883                usdc.address(),
2884                eurc.address(),
2885                amount_in,
2886                0, // min_amount_out
2887            )?;
2888
2889            // Check bob's balances after swap
2890            let bob_usdc_after = usdc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2891            let bob_eurc_after = eurc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2892
2893            // Verify bob spent USDC and received EURC
2894            assert_eq!(
2895                bob_usdc_before - bob_usdc_after,
2896                U256::from(amount_in),
2897                "Bob should have spent exact amount_in USDC"
2898            );
2899            assert_eq!(
2900                bob_eurc_after - bob_eurc_before,
2901                U256::from(amount_out),
2902                "Bob should have received amount_out EURC"
2903            );
2904
2905            // Verify bob has ZERO pathUSD (intermediate token should be transitory)
2906            let bob_path_usd_wallet =
2907                path_usd.balance_of(ITIP20::balanceOfCall { account: bob })?;
2908            assert_eq!(
2909                bob_path_usd_wallet,
2910                U256::ZERO,
2911                "Bob should have ZERO pathUSD in wallet (transitory)"
2912            );
2913
2914            let bob_path_usd_exchange = exchange.balance_of(bob, path_usd.address())?;
2915            assert_eq!(
2916                bob_path_usd_exchange, 0,
2917                "Bob should have ZERO pathUSD on exchange (transitory)"
2918            );
2919
2920            Ok(())
2921        })
2922    }
2923
2924    #[test]
2925    fn test_swap_exact_out_multi_hop_transitory_balances() -> eyre::Result<()> {
2926        let mut storage = HashMapStorageProvider::new(1);
2927        StorageCtx::enter(&mut storage, || {
2928            let mut exchange = StablecoinDEX::new();
2929            exchange.initialize()?;
2930
2931            let admin = Address::random();
2932            let alice = Address::random();
2933            let bob = Address::random();
2934
2935            let min_order_amount = MIN_ORDER_AMOUNT;
2936            let min_order_amount_x10 = U256::from(MIN_ORDER_AMOUNT * 10);
2937
2938            // Setup: pathUSD <- USDC <- EURC
2939            let path_usd = TIP20Setup::path_usd(admin)
2940                .with_issuer(admin)
2941                // Setup alice as a liquidity provider
2942                .with_mint(alice, min_order_amount_x10)
2943                .with_approval(alice, exchange.address, min_order_amount_x10)
2944                .apply()?;
2945
2946            let usdc = TIP20Setup::create("USDC", "USDC", admin)
2947                .with_issuer(admin)
2948                // Setup alice as a liquidity provider
2949                .with_mint(alice, min_order_amount_x10)
2950                .with_approval(alice, exchange.address, min_order_amount_x10)
2951                // Setup bob as a trader
2952                .with_mint(bob, min_order_amount_x10)
2953                .with_approval(bob, exchange.address, min_order_amount_x10)
2954                .apply()?;
2955
2956            let eurc = TIP20Setup::create("EURC", "EURC", admin)
2957                .with_issuer(admin)
2958                // Setup alice as a liquidity provider
2959                .with_mint(alice, min_order_amount_x10)
2960                .with_approval(alice, exchange.address, min_order_amount_x10)
2961                .apply()?;
2962
2963            // Place liquidity orders at 1:1
2964            exchange.place(alice, usdc.address(), min_order_amount * 5, true, 0)?;
2965            exchange.place(alice, eurc.address(), min_order_amount * 5, false, 0)?;
2966
2967            // Check bob's balances before swap
2968            let bob_usdc_before = usdc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2969            let bob_eurc_before = eurc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2970
2971            // Execute multi-hop swap: USDC -> pathUSD -> EURC (exact output)
2972            let amount_out = 90u128;
2973            let amount_in = exchange.swap_exact_amount_out(
2974                bob,
2975                usdc.address(),
2976                eurc.address(),
2977                amount_out,
2978                u128::MAX, // max_amount_in
2979            )?;
2980
2981            // Check bob's balances after swap
2982            let bob_usdc_after = usdc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2983            let bob_eurc_after = eurc.balance_of(ITIP20::balanceOfCall { account: bob })?;
2984
2985            // Verify bob spent USDC and received exact EURC
2986            assert_eq!(
2987                bob_usdc_before - bob_usdc_after,
2988                U256::from(amount_in),
2989                "Bob should have spent amount_in USDC"
2990            );
2991            assert_eq!(
2992                bob_eurc_after - bob_eurc_before,
2993                U256::from(amount_out),
2994                "Bob should have received exact amount_out EURC"
2995            );
2996
2997            // Verify bob has ZERO pathUSD (intermediate token should be transitory)
2998            let bob_path_usd_wallet =
2999                path_usd.balance_of(ITIP20::balanceOfCall { account: bob })?;
3000            assert_eq!(
3001                bob_path_usd_wallet,
3002                U256::ZERO,
3003                "Bob should have ZERO pathUSD in wallet (transitory)"
3004            );
3005
3006            let bob_path_usd_exchange = exchange
3007                .balance_of(bob, path_usd.address())
3008                .expect("Failed to get bob's pathUSD exchange balance");
3009            assert_eq!(
3010                bob_path_usd_exchange, 0,
3011                "Bob should have ZERO pathUSD on exchange (transitory)"
3012            );
3013
3014            Ok(())
3015        })
3016    }
3017
3018    #[test]
3019    fn test_create_pair_invalid_currency() -> eyre::Result<()> {
3020        let mut storage = HashMapStorageProvider::new(1);
3021        StorageCtx::enter(&mut storage, || {
3022            let admin = Address::random();
3023
3024            // Create EUR token with PATH USD as quote (valid non-USD token)
3025            let token_0 = TIP20Setup::create("EuroToken", "EURO", admin)
3026                .currency("EUR")
3027                .apply()?;
3028
3029            let mut exchange = StablecoinDEX::new();
3030            exchange.initialize()?;
3031
3032            // Test: create_pair should reject non-USD token (EUR token has EUR currency)
3033            let result = exchange.create_pair(token_0.address());
3034            assert!(matches!(
3035                result,
3036                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidCurrency(_)))
3037            ));
3038
3039            Ok(())
3040        })
3041    }
3042
3043    #[test]
3044    fn test_create_pair_rejects_non_tip20_base() -> eyre::Result<()> {
3045        let mut storage = HashMapStorageProvider::new(1);
3046        StorageCtx::enter(&mut storage, || {
3047            let admin = Address::random();
3048            let _path_usd = TIP20Setup::path_usd(admin).apply()?;
3049
3050            let mut exchange = StablecoinDEX::new();
3051            exchange.initialize()?;
3052
3053            // Test: create_pair should reject non-TIP20 address (random address without TIP20 prefix)
3054            let non_tip20_address = Address::random();
3055            let result = exchange.create_pair(non_tip20_address);
3056            assert!(matches!(
3057                result,
3058                Err(TempoPrecompileError::StablecoinDEX(
3059                    StablecoinDEXError::InvalidBaseToken(_)
3060                ))
3061            ));
3062
3063            Ok(())
3064        })
3065    }
3066
3067    #[test]
3068    fn test_max_in_check() -> eyre::Result<()> {
3069        let mut storage = HashMapStorageProvider::new(1);
3070        StorageCtx::enter(&mut storage, || {
3071            let mut exchange = StablecoinDEX::new();
3072            exchange.initialize()?;
3073
3074            let alice = Address::random();
3075            let bob = Address::random();
3076            let admin = Address::random();
3077
3078            let (base_token, quote_token) =
3079                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
3080            exchange.create_pair(base_token)?;
3081
3082            let tick_50 = 50i16;
3083            let tick_100 = 100i16;
3084            let order_amount = MIN_ORDER_AMOUNT;
3085
3086            exchange.place(alice, base_token, order_amount, false, tick_50)?;
3087            exchange.place(alice, base_token, order_amount, false, tick_100)?;
3088
3089            exchange.set_balance(bob, quote_token, 200_000_000u128)?;
3090
3091            let price_50 = orderbook::tick_to_price(tick_50);
3092            let price_100 = orderbook::tick_to_price(tick_100);
3093            // Taker pays quote with ceiling rounding
3094            let quote_for_first =
3095                (order_amount * price_50 as u128).div_ceil(orderbook::PRICE_SCALE as u128);
3096            let quote_for_partial_second =
3097                (999 * price_100 as u128).div_ceil(orderbook::PRICE_SCALE as u128);
3098            let total_needed = quote_for_first + quote_for_partial_second;
3099
3100            let result = exchange.swap_exact_amount_out(
3101                bob,
3102                quote_token,
3103                base_token,
3104                order_amount + 999,
3105                total_needed,
3106            );
3107            assert!(result.is_ok());
3108
3109            Ok(())
3110        })
3111    }
3112
3113    #[test]
3114    fn test_exact_out_bid_side() -> eyre::Result<()> {
3115        let mut storage = HashMapStorageProvider::new(1);
3116        StorageCtx::enter(&mut storage, || {
3117            let mut exchange = StablecoinDEX::new();
3118            exchange.initialize()?;
3119
3120            let alice = Address::random();
3121            let bob = Address::random();
3122            let admin = Address::random();
3123
3124            let (base_token, quote_token) =
3125                setup_test_tokens(admin, alice, exchange.address, 1_000_000_000u128)?;
3126            exchange.create_pair(base_token)?;
3127
3128            let tick = 1000i16;
3129            let price = tick_to_price(tick);
3130            let order_amount_base = MIN_ORDER_AMOUNT;
3131
3132            exchange.place(alice, base_token, order_amount_base, true, tick)?;
3133
3134            let amount_out_quote = 5_000_000u128;
3135            let base_needed = (amount_out_quote * PRICE_SCALE as u128) / price as u128;
3136            let max_amount_in = base_needed + 10000;
3137
3138            exchange.set_balance(bob, base_token, max_amount_in * 2)?;
3139
3140            let _amount_in = exchange.swap_exact_amount_out(
3141                bob,
3142                base_token,
3143                quote_token,
3144                amount_out_quote,
3145                max_amount_in,
3146            )?;
3147
3148            // Verify Bob got exactly the quote amount requested
3149            let bob_quote_balance = TIP20Token::from_address(quote_token)?
3150                .balance_of(ITIP20::balanceOfCall { account: bob })?;
3151            assert_eq!(bob_quote_balance, U256::from(amount_out_quote));
3152
3153            Ok(())
3154        })
3155    }
3156
3157    #[test]
3158    fn test_exact_in_ask_side() -> eyre::Result<()> {
3159        let mut storage = HashMapStorageProvider::new(1);
3160        StorageCtx::enter(&mut storage, || {
3161            let mut exchange = StablecoinDEX::new();
3162            exchange.initialize()?;
3163
3164            let alice = Address::random();
3165            let bob = Address::random();
3166            let admin = Address::random();
3167
3168            let (base_token, quote_token) =
3169                setup_test_tokens(admin, alice, exchange.address, 1_000_000_000u128)?;
3170            exchange.create_pair(base_token)?;
3171
3172            let tick = 1000i16;
3173            let price = tick_to_price(tick);
3174            let order_amount_base = MIN_ORDER_AMOUNT;
3175
3176            exchange.place(alice, base_token, order_amount_base, false, tick)?;
3177
3178            let amount_in_quote = 5_000_000u128;
3179            let min_amount_out = 0;
3180
3181            exchange.set_balance(bob, quote_token, amount_in_quote * 2)?;
3182
3183            let amount_out = exchange.swap_exact_amount_in(
3184                bob,
3185                quote_token,
3186                base_token,
3187                amount_in_quote,
3188                min_amount_out,
3189            )?;
3190
3191            let expected_base = (amount_in_quote * PRICE_SCALE as u128) / price as u128;
3192            assert_eq!(amount_out, expected_base);
3193
3194            Ok(())
3195        })
3196    }
3197
3198    #[test]
3199    fn test_clear_order() -> eyre::Result<()> {
3200        const AMOUNT: u128 = 1_000_000_000;
3201
3202        // Test that fill_order properly clears the prev pointer when advancing to the next order
3203        let mut storage = HashMapStorageProvider::new(1);
3204        StorageCtx::enter(&mut storage, || {
3205            let mut exchange = StablecoinDEX::new();
3206            exchange.initialize()?;
3207
3208            let alice = Address::random();
3209            let bob = Address::random();
3210            let carol = Address::random();
3211            let admin = Address::random();
3212
3213            let (base_token, quote_token) =
3214                setup_test_tokens(admin, alice, exchange.address, AMOUNT)?;
3215            exchange.create_pair(base_token)?;
3216
3217            // Give bob base tokens and carol quote tokens
3218            TIP20Setup::config(base_token)
3219                .with_mint(bob, U256::from(AMOUNT))
3220                .with_approval(bob, exchange.address, U256::from(AMOUNT))
3221                .apply()?;
3222            TIP20Setup::config(quote_token)
3223                .with_mint(carol, U256::from(AMOUNT))
3224                .with_approval(carol, exchange.address, U256::from(AMOUNT))
3225                .apply()?;
3226
3227            let tick = 100i16;
3228
3229            // Place two ask orders at the same tick: Order 1 (alice), Order 2 (bob)
3230            let order1_amount = MIN_ORDER_AMOUNT;
3231            let order2_amount = MIN_ORDER_AMOUNT;
3232
3233            let order1_id = exchange.place(alice, base_token, order1_amount, false, tick)?;
3234            let order2_id = exchange.place(bob, base_token, order2_amount, false, tick)?;
3235
3236            // Verify linked list is set up correctly
3237            let order1 = exchange.orders[order1_id].read()?;
3238            let order2 = exchange.orders[order2_id].read()?;
3239            assert_eq!(order1.next(), order2_id);
3240            assert_eq!(order2.prev(), order1_id);
3241
3242            // Swap to fill order1 completely
3243            let swap_amount = order1_amount;
3244            exchange.swap_exact_amount_out(
3245                carol,
3246                quote_token,
3247                base_token,
3248                swap_amount,
3249                u128::MAX,
3250            )?;
3251
3252            // After filling order1, order2 should be the new head with prev = 0
3253            let order2_after = exchange.orders[order2_id].read()?;
3254            assert_eq!(
3255                order2_after.prev(),
3256                0,
3257                "New head order should have prev = 0 after previous head was filled"
3258            );
3259
3260            Ok(())
3261        })
3262    }
3263
3264    #[test]
3265    fn test_best_tick_updates_on_fill() -> eyre::Result<()> {
3266        let mut storage = HashMapStorageProvider::new(1);
3267        StorageCtx::enter(&mut storage, || {
3268            let mut exchange = StablecoinDEX::new();
3269            exchange.initialize()?;
3270
3271            let alice = Address::random();
3272            let bob = Address::random();
3273            let admin = Address::random();
3274            let amount = MIN_ORDER_AMOUNT;
3275
3276            // Use different ticks for bids (100, 90) and asks (50, 60)
3277            let (bid_tick_1, bid_tick_2) = (100_i16, 90_i16); // (best, second best)
3278            let (ask_tick_1, ask_tick_2) = (50_i16, 60_i16); // (best, second best)
3279
3280            // Calculate escrow for all orders
3281            let bid_price_1 = orderbook::tick_to_price(bid_tick_1);
3282            let bid_price_2 = orderbook::tick_to_price(bid_tick_2);
3283            let bid_escrow_1 = (amount * bid_price_1 as u128) / orderbook::PRICE_SCALE as u128;
3284            let bid_escrow_2 = (amount * bid_price_2 as u128) / orderbook::PRICE_SCALE as u128;
3285            let total_bid_escrow = bid_escrow_1 + bid_escrow_2;
3286
3287            let (base_token, quote_token) =
3288                setup_test_tokens(admin, alice, exchange.address, total_bid_escrow)?;
3289            exchange.create_pair(base_token)?;
3290            let book_key = compute_book_key(base_token, quote_token);
3291
3292            // Place bid orders at two different ticks
3293            exchange.place(alice, base_token, amount, true, bid_tick_1)?;
3294            exchange.place(alice, base_token, amount, true, bid_tick_2)?;
3295
3296            // Place ask orders at two different ticks
3297            TIP20Setup::config(base_token)
3298                .with_mint(alice, U256::from(amount * 2))
3299                .with_approval(alice, exchange.address, U256::from(amount * 2))
3300                .apply()?;
3301            exchange.place(alice, base_token, amount, false, ask_tick_1)?;
3302            exchange.place(alice, base_token, amount, false, ask_tick_2)?;
3303
3304            // Verify initial best ticks
3305            let orderbook = exchange.books[book_key].read()?;
3306            assert_eq!(orderbook.best_bid_tick, bid_tick_1);
3307            assert_eq!(orderbook.best_ask_tick, ask_tick_1);
3308
3309            // Fill all bids at tick 100 (bob sells base)
3310            exchange.set_balance(bob, base_token, amount)?;
3311            exchange.swap_exact_amount_in(bob, base_token, quote_token, amount, 0)?;
3312            // Verify best_bid_tick moved to tick 90, best_ask_tick unchanged
3313            let orderbook = exchange.books[book_key].read()?;
3314            assert_eq!(orderbook.best_bid_tick, bid_tick_2);
3315            assert_eq!(orderbook.best_ask_tick, ask_tick_1);
3316
3317            // Fill remaining bid at tick 90
3318            exchange.set_balance(bob, base_token, amount)?;
3319            exchange.swap_exact_amount_in(bob, base_token, quote_token, amount, 0)?;
3320            // Verify best_bid_tick is now i16::MIN, best_ask_tick unchanged
3321            let orderbook = exchange.books[book_key].read()?;
3322            assert_eq!(orderbook.best_bid_tick, i16::MIN);
3323            assert_eq!(orderbook.best_ask_tick, ask_tick_1);
3324
3325            // Fill all asks at tick 50 (bob buys base)
3326            let ask_price_1 = orderbook::tick_to_price(ask_tick_1);
3327            let quote_needed = (amount * ask_price_1 as u128) / orderbook::PRICE_SCALE as u128;
3328            exchange.set_balance(bob, quote_token, quote_needed)?;
3329            exchange.swap_exact_amount_in(bob, quote_token, base_token, quote_needed, 0)?;
3330            // Verify best_ask_tick moved to tick 60, best_bid_tick unchanged
3331            let orderbook = exchange.books[book_key].read()?;
3332            assert_eq!(orderbook.best_ask_tick, ask_tick_2);
3333            assert_eq!(orderbook.best_bid_tick, i16::MIN);
3334
3335            Ok(())
3336        })
3337    }
3338
3339    #[test]
3340    fn test_best_tick_updates_on_cancel() -> eyre::Result<()> {
3341        let mut storage = HashMapStorageProvider::new(1);
3342        StorageCtx::enter(&mut storage, || {
3343            let mut exchange = StablecoinDEX::new();
3344            exchange.initialize()?;
3345
3346            let alice = Address::random();
3347            let admin = Address::random();
3348            let amount = MIN_ORDER_AMOUNT;
3349
3350            let (bid_tick_1, bid_tick_2) = (100_i16, 90_i16); // (best, second best)
3351            let (ask_tick_1, ask_tick_2) = (50_i16, 60_i16); // (best, second best)
3352
3353            // Calculate escrow for 3 bid orders (2 at tick 100, 1 at tick 90)
3354            let price_1 = orderbook::tick_to_price(bid_tick_1);
3355            let price_2 = orderbook::tick_to_price(bid_tick_2);
3356            let escrow_1 = (amount * price_1 as u128) / orderbook::PRICE_SCALE as u128;
3357            let escrow_2 = (amount * price_2 as u128) / orderbook::PRICE_SCALE as u128;
3358            let total_escrow = escrow_1 * 2 + escrow_2;
3359
3360            let (base_token, quote_token) =
3361                setup_test_tokens(admin, alice, exchange.address, total_escrow)?;
3362            exchange.create_pair(base_token)?;
3363            let book_key = compute_book_key(base_token, quote_token);
3364
3365            // Place 2 bid orders at tick 100, 1 at tick 90
3366            let bid_order_1 = exchange.place(alice, base_token, amount, true, bid_tick_1)?;
3367            let bid_order_2 = exchange.place(alice, base_token, amount, true, bid_tick_1)?;
3368            let bid_order_3 = exchange.place(alice, base_token, amount, true, bid_tick_2)?;
3369
3370            // Place 2 ask orders at tick 50 and tick 60
3371            TIP20Setup::config(base_token)
3372                .with_mint(alice, U256::from(amount * 2))
3373                .with_approval(alice, exchange.address, U256::from(amount * 2))
3374                .apply()?;
3375            let ask_order_1 = exchange.place(alice, base_token, amount, false, ask_tick_1)?;
3376            let ask_order_2 = exchange.place(alice, base_token, amount, false, ask_tick_2)?;
3377
3378            // Verify initial best ticks
3379            let orderbook = exchange.books[book_key].read()?;
3380            assert_eq!(orderbook.best_bid_tick, bid_tick_1);
3381            assert_eq!(orderbook.best_ask_tick, ask_tick_1);
3382
3383            // Cancel one bid at tick 100
3384            exchange.cancel(alice, bid_order_1)?;
3385            // Verify best_bid_tick remains 100, best_ask_tick unchanged
3386            let orderbook = exchange.books[book_key].read()?;
3387            assert_eq!(orderbook.best_bid_tick, bid_tick_1);
3388            assert_eq!(orderbook.best_ask_tick, ask_tick_1);
3389
3390            // Cancel remaining bid at tick 100
3391            exchange.cancel(alice, bid_order_2)?;
3392            // Verify best_bid_tick moved to 90, best_ask_tick unchanged
3393            let orderbook = exchange.books[book_key].read()?;
3394            assert_eq!(orderbook.best_bid_tick, bid_tick_2);
3395            assert_eq!(orderbook.best_ask_tick, ask_tick_1);
3396
3397            // Cancel ask at tick 50
3398            exchange.cancel(alice, ask_order_1)?;
3399            // Verify best_ask_tick moved to 60, best_bid_tick unchanged
3400            let orderbook = exchange.books[book_key].read()?;
3401            assert_eq!(orderbook.best_bid_tick, bid_tick_2);
3402            assert_eq!(orderbook.best_ask_tick, ask_tick_2);
3403
3404            // Cancel bid at tick 90
3405            exchange.cancel(alice, bid_order_3)?;
3406            // Verify best_bid_tick is now i16::MIN, best_ask_tick unchanged
3407            let orderbook = exchange.books[book_key].read()?;
3408            assert_eq!(orderbook.best_bid_tick, i16::MIN);
3409            assert_eq!(orderbook.best_ask_tick, ask_tick_2);
3410
3411            // Cancel ask at tick 60
3412            exchange.cancel(alice, ask_order_2)?;
3413            // Verify best_ask_tick is now i16::MAX, best_bid_tick unchanged
3414            let orderbook = exchange.books[book_key].read()?;
3415            assert_eq!(orderbook.best_bid_tick, i16::MIN);
3416            assert_eq!(orderbook.best_ask_tick, i16::MAX);
3417
3418            Ok(())
3419        })
3420    }
3421
3422    #[test]
3423    fn test_place() -> eyre::Result<()> {
3424        const AMOUNT: u128 = 1_000_000_000;
3425
3426        let mut storage = HashMapStorageProvider::new(1);
3427        StorageCtx::enter(&mut storage, || {
3428            let mut exchange = StablecoinDEX::new();
3429            exchange.initialize()?;
3430
3431            let alice = Address::random();
3432            let admin = Address::random();
3433
3434            let (base_token, _quote_token) =
3435                setup_test_tokens(admin, alice, exchange.address, AMOUNT)?;
3436            exchange.create_pair(base_token)?;
3437
3438            // Give alice base tokens
3439            TIP20Setup::config(base_token)
3440                .with_mint(alice, U256::from(AMOUNT))
3441                .with_approval(alice, exchange.address, U256::from(AMOUNT))
3442                .apply()?;
3443
3444            // Test invalid tick spacing
3445            let invalid_tick = 15i16;
3446            let result = exchange.place(alice, base_token, MIN_ORDER_AMOUNT, true, invalid_tick);
3447
3448            let error = result.unwrap_err();
3449            assert!(matches!(
3450                error,
3451                TempoPrecompileError::StablecoinDEX(StablecoinDEXError::InvalidTick(_))
3452            ));
3453
3454            // Test valid tick spacing
3455            let valid_tick = -20i16;
3456            let result = exchange.place(alice, base_token, MIN_ORDER_AMOUNT, true, valid_tick);
3457            assert!(result.is_ok());
3458
3459            Ok(())
3460        })
3461    }
3462
3463    #[test]
3464    fn test_place_flip_checks() -> eyre::Result<()> {
3465        const AMOUNT: u128 = 1_000_000_000;
3466
3467        let mut storage = HashMapStorageProvider::new(1);
3468        StorageCtx::enter(&mut storage, || {
3469            let mut exchange = StablecoinDEX::new();
3470            exchange.initialize()?;
3471
3472            let alice = Address::random();
3473            let admin = Address::random();
3474
3475            let (base_token, _quote_token) =
3476                setup_test_tokens(admin, alice, exchange.address, AMOUNT)?;
3477            exchange.create_pair(base_token)?;
3478
3479            // Give alice base tokens
3480            TIP20Setup::config(base_token)
3481                .with_mint(alice, U256::from(AMOUNT))
3482                .with_approval(alice, exchange.address, U256::from(AMOUNT))
3483                .apply()?;
3484
3485            // Test invalid tick spacing
3486            let invalid_tick = 15i16;
3487            let invalid_flip_tick = 25i16;
3488            let result = exchange.place_flip(
3489                alice,
3490                base_token,
3491                MIN_ORDER_AMOUNT,
3492                true,
3493                invalid_tick,
3494                invalid_flip_tick,
3495                false,
3496            );
3497
3498            let error = result.unwrap_err();
3499            assert!(matches!(
3500                error,
3501                TempoPrecompileError::StablecoinDEX(StablecoinDEXError::InvalidTick(_))
3502            ));
3503
3504            // Test valid tick spacing
3505            let valid_tick = 20i16;
3506            let invalid_flip_tick = 25i16;
3507            let result = exchange.place_flip(
3508                alice,
3509                base_token,
3510                MIN_ORDER_AMOUNT,
3511                true,
3512                valid_tick,
3513                invalid_flip_tick,
3514                false,
3515            );
3516
3517            let error = result.unwrap_err();
3518            assert!(matches!(
3519                error,
3520                TempoPrecompileError::StablecoinDEX(StablecoinDEXError::InvalidFlipTick(_))
3521            ));
3522
3523            let valid_flip_tick = 30i16;
3524            let result = exchange.place_flip(
3525                alice,
3526                base_token,
3527                MIN_ORDER_AMOUNT,
3528                true,
3529                valid_tick,
3530                valid_flip_tick,
3531                false,
3532            );
3533            assert!(result.is_ok());
3534
3535            Ok(())
3536        })
3537    }
3538
3539    #[test]
3540    fn test_find_trade_path_rejects_non_tip20() -> eyre::Result<()> {
3541        let mut storage = HashMapStorageProvider::new(1);
3542        StorageCtx::enter(&mut storage, || {
3543            let mut exchange = StablecoinDEX::new();
3544            exchange.initialize()?;
3545
3546            let admin = Address::random();
3547            let user = Address::random();
3548
3549            let (_, quote_token) =
3550                setup_test_tokens(admin, user, exchange.address, MIN_ORDER_AMOUNT)?;
3551
3552            let non_tip20_address = Address::random();
3553            let result = exchange.find_trade_path(non_tip20_address, quote_token);
3554            assert!(
3555                matches!(
3556                    result,
3557                    Err(TempoPrecompileError::StablecoinDEX(
3558                        StablecoinDEXError::InvalidToken(_)
3559                    ))
3560                ),
3561                "Should return InvalidToken error for non-TIP20 token"
3562            );
3563
3564            Ok(())
3565        })
3566    }
3567
3568    #[test]
3569    fn test_quote_exact_in_handles_both_directions() -> eyre::Result<()> {
3570        let mut storage = HashMapStorageProvider::new(1);
3571        StorageCtx::enter(&mut storage, || {
3572            let mut exchange = StablecoinDEX::new();
3573            exchange.initialize()?;
3574
3575            let alice = Address::random();
3576            let admin = Address::random();
3577            let amount = MIN_ORDER_AMOUNT;
3578            let tick = 100_i16;
3579            let price = orderbook::tick_to_price(tick);
3580
3581            // Calculate escrow for bid order (quote needed to buy `amount` base)
3582            let bid_escrow = (amount * price as u128) / orderbook::PRICE_SCALE as u128;
3583
3584            let (base_token, quote_token) =
3585                setup_test_tokens(admin, alice, exchange.address, bid_escrow)?;
3586
3587            TIP20Setup::config(base_token)
3588                .with_mint(alice, U256::from(amount))
3589                .with_approval(alice, exchange.address, U256::from(amount))
3590                .apply()?;
3591
3592            exchange.create_pair(base_token)?;
3593            let book_key = compute_book_key(base_token, quote_token);
3594
3595            // Place a bid order (alice wants to buy base with quote)
3596            exchange.place(alice, base_token, amount, true, tick)?;
3597
3598            // Test is_bid == true: base -> quote
3599            let quoted_out_bid = exchange.quote_exact_in(book_key, amount, true)?;
3600            let expected_quote_out = amount
3601                .checked_mul(price as u128)
3602                .and_then(|v| v.checked_div(orderbook::PRICE_SCALE as u128))
3603                .expect("calculation");
3604            assert_eq!(
3605                quoted_out_bid, expected_quote_out,
3606                "quote_exact_in with is_bid=true should return quote amount"
3607            );
3608
3609            // Place an ask order (alice wants to sell base for quote)
3610            exchange.place(alice, base_token, amount, false, tick)?;
3611
3612            // Test is_bid == false: quote -> base
3613            let quote_in = (amount * price as u128) / orderbook::PRICE_SCALE as u128;
3614            let quoted_out_ask = exchange.quote_exact_in(book_key, quote_in, false)?;
3615            let expected_base_out = quote_in
3616                .checked_mul(orderbook::PRICE_SCALE as u128)
3617                .and_then(|v| v.checked_div(price as u128))
3618                .expect("calculation");
3619            assert_eq!(
3620                quoted_out_ask, expected_base_out,
3621                "quote_exact_in with is_bid=false should return base amount"
3622            );
3623
3624            Ok(())
3625        })
3626    }
3627
3628    #[test]
3629    fn test_place_auto_creates_pair() -> Result<()> {
3630        let mut storage = HashMapStorageProvider::new(1);
3631        StorageCtx::enter(&mut storage, || {
3632            let mut exchange = StablecoinDEX::new();
3633            exchange.initialize()?;
3634            let admin = Address::random();
3635            let user = Address::random();
3636
3637            // Setup tokens
3638            let (base_token, quote_token) =
3639                setup_test_tokens(admin, user, exchange.address, 100_000_000)?;
3640
3641            // Before placing order, verify pair doesn't exist
3642            let book_key = compute_book_key(base_token, quote_token);
3643            let book_before = exchange.books[book_key].read()?;
3644            assert!(book_before.base.is_zero(),);
3645
3646            // Transfer tokens to exchange first
3647            let mut base = TIP20Token::from_address(base_token)?;
3648            base.transfer(
3649                user,
3650                ITIP20::transferCall {
3651                    to: exchange.address,
3652                    amount: U256::from(MIN_ORDER_AMOUNT),
3653                },
3654            )
3655            .expect("Base token transfer failed");
3656
3657            // Place an order which should also create the pair
3658            exchange.place(user, base_token, MIN_ORDER_AMOUNT, true, 0)?;
3659
3660            let book_after = exchange.books[book_key].read()?;
3661            assert_eq!(book_after.base, base_token);
3662
3663            // Verify PairCreated event was emitted (along with OrderPlaced)
3664            let events = exchange.emitted_events();
3665            assert_eq!(events.len(), 2);
3666            assert_eq!(
3667                events[0],
3668                StablecoinDEXEvents::PairCreated(IStablecoinDEX::PairCreated {
3669                    key: book_key,
3670                    base: base_token,
3671                    quote: quote_token,
3672                })
3673                .into_log_data()
3674            );
3675
3676            Ok(())
3677        })
3678    }
3679
3680    #[test]
3681    fn test_decrement_balance_preserves_balance() -> eyre::Result<()> {
3682        let mut storage = HashMapStorageProvider::new(1);
3683        StorageCtx::enter(&mut storage, || {
3684            let mut exchange = StablecoinDEX::new();
3685            exchange.initialize()?;
3686
3687            let admin = Address::random();
3688            let alice = Address::random();
3689
3690            let base = TIP20Setup::create("BASE", "BASE", admin).apply()?;
3691            let base_address = base.address();
3692
3693            exchange.create_pair(base_address)?;
3694
3695            let internal_balance = MIN_ORDER_AMOUNT / 2;
3696            exchange.set_balance(alice, base_address, internal_balance)?;
3697
3698            assert_eq!(exchange.balance_of(alice, base_address)?, internal_balance);
3699
3700            let tick = 0i16;
3701            let result = exchange.place(alice, base_address, MIN_ORDER_AMOUNT * 2, false, tick);
3702
3703            assert!(result.is_err());
3704            assert_eq!(exchange.balance_of(alice, base_address)?, internal_balance);
3705
3706            Ok(())
3707        })
3708    }
3709
3710    #[test]
3711    fn test_place_order_immediately_active() -> eyre::Result<()> {
3712        let mut storage = HashMapStorageProvider::new(1);
3713        StorageCtx::enter(&mut storage, || {
3714            let mut exchange = StablecoinDEX::new();
3715            exchange.initialize()?;
3716
3717            let admin = Address::random();
3718            let alice = Address::random();
3719            let min_order_amount = MIN_ORDER_AMOUNT;
3720            let tick = 100i16;
3721
3722            let price = orderbook::tick_to_price(tick);
3723            let expected_escrow =
3724                (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
3725
3726            TIP20Setup::path_usd(admin)
3727                .with_issuer(admin)
3728                .with_mint(alice, U256::from(expected_escrow))
3729                .with_approval(alice, exchange.address, U256::from(expected_escrow))
3730                .apply()?;
3731
3732            let base = TIP20Setup::create("BASE", "BASE", admin).apply()?;
3733            let base_token = base.address();
3734            let quote_token = base.quote_token()?;
3735
3736            exchange.create_pair(base_token)?;
3737
3738            let order_id = exchange.place(alice, base_token, min_order_amount, true, tick)?;
3739
3740            assert_eq!(order_id, 1);
3741
3742            let book_key = compute_book_key(base_token, quote_token);
3743            let book_handler = &exchange.books[book_key];
3744            let level = book_handler.tick_level_handler(tick, true).read()?;
3745            assert_eq!(level.head, order_id, "Order should be head of tick level");
3746            assert_eq!(level.tail, order_id, "Order should be tail of tick level");
3747            assert_eq!(
3748                level.total_liquidity, min_order_amount,
3749                "Tick level should have order's liquidity"
3750            );
3751
3752            let orderbook = book_handler.read()?;
3753            assert_eq!(
3754                orderbook.best_bid_tick, tick,
3755                "Best bid tick should be updated"
3756            );
3757
3758            Ok(())
3759        })
3760    }
3761
3762    #[test]
3763    fn test_place_flip_order_immediately_active() -> eyre::Result<()> {
3764        let mut storage = HashMapStorageProvider::new(1);
3765        StorageCtx::enter(&mut storage, || {
3766            let mut exchange = StablecoinDEX::new();
3767            exchange.initialize()?;
3768
3769            let admin = Address::random();
3770            let alice = Address::random();
3771            let min_order_amount = MIN_ORDER_AMOUNT;
3772            let tick = 100i16;
3773            let flip_tick = 200i16;
3774
3775            let price = orderbook::tick_to_price(tick);
3776            let expected_escrow =
3777                (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
3778
3779            TIP20Setup::path_usd(admin)
3780                .with_issuer(admin)
3781                .with_mint(alice, U256::from(expected_escrow))
3782                .with_approval(alice, exchange.address, U256::from(expected_escrow))
3783                .apply()?;
3784
3785            let base = TIP20Setup::create("BASE", "BASE", admin).apply()?;
3786            let base_token = base.address();
3787            let quote_token = base.quote_token()?;
3788
3789            exchange.create_pair(base_token)?;
3790
3791            let order_id = exchange.place_flip(
3792                alice,
3793                base_token,
3794                min_order_amount,
3795                true,
3796                tick,
3797                flip_tick,
3798                false,
3799            )?;
3800
3801            assert_eq!(order_id, 1);
3802
3803            let book_key = compute_book_key(base_token, quote_token);
3804            let book_handler = &exchange.books[book_key];
3805            let level = book_handler.tick_level_handler(tick, true).read()?;
3806            assert_eq!(level.head, order_id, "Order should be head of tick level");
3807            assert_eq!(level.tail, order_id, "Order should be tail of tick level");
3808            assert_eq!(
3809                level.total_liquidity, min_order_amount,
3810                "Tick level should have order's liquidity"
3811            );
3812
3813            let orderbook = book_handler.read()?;
3814            assert_eq!(
3815                orderbook.best_bid_tick, tick,
3816                "Best bid tick should be updated"
3817            );
3818
3819            let stored_order = exchange.orders[order_id].read()?;
3820            assert!(stored_order.is_flip(), "Order should be a flip order");
3821            assert_eq!(
3822                stored_order.flip_tick(),
3823                flip_tick,
3824                "Flip tick should match"
3825            );
3826
3827            Ok(())
3828        })
3829    }
3830
3831    #[test]
3832    fn test_place_post() -> eyre::Result<()> {
3833        let mut storage = HashMapStorageProvider::new(1);
3834        StorageCtx::enter(&mut storage, || {
3835            let mut exchange = StablecoinDEX::new();
3836            exchange.initialize()?;
3837
3838            let admin = Address::random();
3839            let alice = Address::random();
3840            let min_order_amount = MIN_ORDER_AMOUNT;
3841            let tick = 100i16;
3842
3843            let price = orderbook::tick_to_price(tick);
3844            let expected_escrow =
3845                (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
3846
3847            TIP20Setup::path_usd(admin)
3848                .with_issuer(admin)
3849                .with_mint(alice, U256::from(expected_escrow))
3850                .with_approval(alice, exchange.address, U256::from(expected_escrow))
3851                .apply()?;
3852
3853            let base = TIP20Setup::create("BASE", "BASE", admin).apply()?;
3854            let base_token = base.address();
3855            let quote_token = base.quote_token()?;
3856
3857            exchange.create_pair(base_token)?;
3858
3859            let order_id = exchange.place(alice, base_token, min_order_amount, true, tick)?;
3860
3861            let stored_order = exchange.orders[order_id].read()?;
3862            assert_eq!(stored_order.maker(), alice);
3863            assert_eq!(stored_order.remaining(), min_order_amount);
3864            assert_eq!(stored_order.tick(), tick);
3865            assert!(stored_order.is_bid());
3866
3867            let book_key = compute_book_key(base_token, quote_token);
3868            let level = exchange.books[book_key]
3869                .tick_level_handler(tick, true)
3870                .read()?;
3871            assert_eq!(level.head, order_id);
3872            assert_eq!(level.tail, order_id);
3873            assert_eq!(level.total_liquidity, min_order_amount);
3874
3875            let book = exchange.books[book_key].read()?;
3876            assert_eq!(book.best_bid_tick, tick);
3877
3878            assert_eq!(exchange.next_order_id()?, 2);
3879
3880            Ok(())
3881        })
3882    }
3883
3884    #[test]
3885    fn test_blacklisted_user_cannot_use_internal_balance() -> eyre::Result<()> {
3886        use crate::tip403_registry::{ITIP403Registry, TIP403Registry};
3887
3888        let mut storage = HashMapStorageProvider::new(1);
3889        StorageCtx::enter(&mut storage, || {
3890            let mut exchange = StablecoinDEX::new();
3891            exchange.initialize()?;
3892
3893            let alice = Address::random();
3894            let admin = Address::random();
3895
3896            // Create a blacklist policy
3897            let mut registry = TIP403Registry::new();
3898            let policy_id = registry.create_policy(
3899                admin,
3900                ITIP403Registry::createPolicyCall {
3901                    admin,
3902                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
3903                },
3904            )?;
3905
3906            // Setup quote token (pathUSD) with the blacklist policy
3907            let mut quote = TIP20Setup::path_usd(admin).with_issuer(admin).apply()?;
3908
3909            quote.change_transfer_policy_id(
3910                admin,
3911                ITIP20::changeTransferPolicyIdCall {
3912                    newPolicyId: policy_id,
3913                },
3914            )?;
3915
3916            // Setup base token with the blacklist policy
3917            let mut base = TIP20Setup::create("BASE", "BASE", admin)
3918                .with_issuer(admin)
3919                .apply()?;
3920            let base_address = base.address();
3921
3922            base.change_transfer_policy_id(
3923                admin,
3924                ITIP20::changeTransferPolicyIdCall {
3925                    newPolicyId: policy_id,
3926                },
3927            )?;
3928
3929            exchange.create_pair(base_address)?;
3930
3931            // Set up internal balance for alice
3932            let internal_balance = MIN_ORDER_AMOUNT * 2;
3933            exchange.set_balance(alice, base_address, internal_balance)?;
3934            assert_eq!(exchange.balance_of(alice, base_address)?, internal_balance);
3935
3936            // Blacklist alice
3937            registry.modify_policy_blacklist(
3938                admin,
3939                ITIP403Registry::modifyPolicyBlacklistCall {
3940                    policyId: policy_id,
3941                    account: alice,
3942                    restricted: true,
3943                },
3944            )?;
3945            assert!(!registry.is_authorized_as(policy_id, alice, AuthRole::sender())?);
3946
3947            // Attempt to place order using internal balance - should fail
3948            let tick = 0i16;
3949            let result = exchange.place(alice, base_address, MIN_ORDER_AMOUNT, false, tick);
3950
3951            assert!(
3952                result.is_err(),
3953                "Blacklisted user should not be able to place orders using internal balance"
3954            );
3955            let err = result.unwrap_err();
3956            assert!(
3957                matches!(
3958                    err,
3959                    TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
3960                ),
3961                "Expected PolicyForbids error, got: {err:?}"
3962            );
3963            assert_eq!(exchange.balance_of(alice, base_address)?, internal_balance);
3964
3965            Ok(())
3966        })
3967    }
3968
3969    #[test]
3970    fn test_cancel_stale_order() -> eyre::Result<()> {
3971        let mut storage = HashMapStorageProvider::new(1);
3972        StorageCtx::enter(&mut storage, || {
3973            let mut exchange = StablecoinDEX::new();
3974            exchange.initialize()?;
3975
3976            let alice = Address::random();
3977            let admin = Address::random();
3978
3979            let mut registry = TIP403Registry::new();
3980            let policy_id = registry.create_policy(
3981                admin,
3982                ITIP403Registry::createPolicyCall {
3983                    admin,
3984                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
3985                },
3986            )?;
3987
3988            let mut base = TIP20Setup::create("USDC", "USDC", admin)
3989                .with_issuer(admin)
3990                .with_mint(alice, U256::from(MIN_ORDER_AMOUNT * 2))
3991                .with_approval(alice, exchange.address, U256::from(MIN_ORDER_AMOUNT * 2))
3992                .apply()?;
3993            base.change_transfer_policy_id(
3994                admin,
3995                ITIP20::changeTransferPolicyIdCall {
3996                    newPolicyId: policy_id,
3997                },
3998            )?;
3999
4000            exchange.create_pair(base.address())?;
4001            let order_id = exchange.place(alice, base.address(), MIN_ORDER_AMOUNT, false, 0)?;
4002
4003            registry.modify_policy_blacklist(
4004                admin,
4005                ITIP403Registry::modifyPolicyBlacklistCall {
4006                    policyId: policy_id,
4007                    account: alice,
4008                    restricted: true,
4009                },
4010            )?;
4011
4012            exchange.cancel_stale_order(order_id)?;
4013
4014            assert_eq!(
4015                exchange.balance_of(alice, base.address())?,
4016                MIN_ORDER_AMOUNT
4017            );
4018
4019            Ok(())
4020        })
4021    }
4022
4023    #[test]
4024    fn test_cancel_stale_not_stale() -> eyre::Result<()> {
4025        let mut storage = HashMapStorageProvider::new(1);
4026        StorageCtx::enter(&mut storage, || {
4027            let mut exchange = StablecoinDEX::new();
4028            exchange.initialize()?;
4029
4030            let alice = Address::random();
4031            let admin = Address::random();
4032
4033            let mut registry = TIP403Registry::new();
4034            let policy_id = registry.create_policy(
4035                admin,
4036                ITIP403Registry::createPolicyCall {
4037                    admin,
4038                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
4039                },
4040            )?;
4041
4042            let mut base = TIP20Setup::create("USDC", "USDC", admin)
4043                .with_issuer(admin)
4044                .with_mint(alice, U256::from(MIN_ORDER_AMOUNT * 2))
4045                .with_approval(alice, exchange.address, U256::from(MIN_ORDER_AMOUNT * 2))
4046                .apply()?;
4047            base.change_transfer_policy_id(
4048                admin,
4049                ITIP20::changeTransferPolicyIdCall {
4050                    newPolicyId: policy_id,
4051                },
4052            )?;
4053
4054            exchange.create_pair(base.address())?;
4055            let order_id = exchange.place(alice, base.address(), MIN_ORDER_AMOUNT, false, 0)?;
4056
4057            let result = exchange.cancel_stale_order(order_id);
4058            assert!(result.is_err());
4059            assert!(matches!(
4060                result.unwrap_err(),
4061                TempoPrecompileError::StablecoinDEX(StablecoinDEXError::OrderNotStale(_))
4062            ));
4063
4064            Ok(())
4065        })
4066    }
4067
4068    #[test]
4069    fn test_cancel_stale_order_with_invalid_policy_type() -> eyre::Result<()> {
4070        // An order whose token references a legacy-invalid policy (e.g. COMPOUND stored pre-T2)
4071        // should be cancellable as stale. The error returned by `policy_type()` changes at T2:
4072        //   - Pre-T2:  Panic(UnderOverflow)
4073        //   - T2+:     TIP403RegistryError::InvalidPolicyType
4074        // Both must be treated as "policy gone → stale".
4075        for spec in [TempoHardfork::T0, TempoHardfork::T1C, TempoHardfork::T2] {
4076            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T0);
4077
4078            let alice = Address::random();
4079            let admin = Address::random();
4080
4081            let (order_id, base_token, invalid_policy_id) =
4082                StorageCtx::enter(&mut storage, || {
4083                    let mut exchange = StablecoinDEX::new();
4084                    exchange.initialize()?;
4085
4086                    let mut base = TIP20Setup::create("USDC", "USDC", admin)
4087                        .with_issuer(admin)
4088                        .with_mint(alice, U256::from(MIN_ORDER_AMOUNT * 2))
4089                        .with_approval(alice, exchange.address, U256::from(MIN_ORDER_AMOUNT * 2))
4090                        .apply()?;
4091
4092                    exchange.create_pair(base.address())?;
4093                    let order_id =
4094                        exchange.place(alice, base.address(), MIN_ORDER_AMOUNT, false, 0)?;
4095
4096                    // Create an invalid policy (COMPOUND on T0 stores as __Invalid = 255)
4097                    // and reassign the token to it, simulating a legacy-broken policy reference.
4098                    let mut registry = TIP403Registry::new();
4099                    let invalid_policy_id = registry.create_policy(
4100                        admin,
4101                        ITIP403Registry::createPolicyCall {
4102                            admin,
4103                            policyType: ITIP403Registry::PolicyType::COMPOUND,
4104                        },
4105                    )?;
4106                    base.change_transfer_policy_id(
4107                        admin,
4108                        ITIP20::changeTransferPolicyIdCall {
4109                            newPolicyId: invalid_policy_id,
4110                        },
4111                    )?;
4112
4113                    Ok::<_, TempoPrecompileError>((order_id, base.address(), invalid_policy_id))
4114                })?;
4115
4116            // Upgrade to the target spec and attempt cancel
4117            let mut storage = storage.with_spec(spec);
4118            StorageCtx::enter(&mut storage, || {
4119                let mut exchange = StablecoinDEX::new();
4120
4121                // Sanity: the policy lookup itself fails
4122                let registry = TIP403Registry::new();
4123                let auth_result =
4124                    registry.is_authorized_as(invalid_policy_id, alice, AuthRole::sender());
4125                assert!(
4126                    auth_result.is_err(),
4127                    "[{spec:?}] is_authorized_as should fail for invalid policy type"
4128                );
4129
4130                // cancel_stale_order must succeed — the domain error means "policy gone → stale"
4131                exchange.cancel_stale_order(order_id)?;
4132
4133                assert_eq!(
4134                    exchange.balance_of(alice, base_token)?,
4135                    MIN_ORDER_AMOUNT,
4136                    "[{spec:?}] alice should get her funds back"
4137                );
4138
4139                Ok::<_, eyre::Report>(())
4140            })?;
4141        }
4142        Ok(())
4143    }
4144
4145    #[test]
4146    fn test_place_when_base_blacklisted() -> eyre::Result<()> {
4147        let mut storage = HashMapStorageProvider::new(1);
4148        StorageCtx::enter(&mut storage, || {
4149            let mut exchange = StablecoinDEX::new();
4150            exchange.initialize()?;
4151
4152            let alice = Address::random();
4153            let admin = Address::random();
4154
4155            // Setup TIP403 registry and create blacklist policy
4156            let mut registry = TIP403Registry::new();
4157            let policy_id = registry.create_policy(
4158                admin,
4159                ITIP403Registry::createPolicyCall {
4160                    admin,
4161                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
4162                },
4163            )?;
4164
4165            // Set up base and quote tokens
4166            let (base_addr, _quote_addr) =
4167                setup_test_tokens(admin, alice, exchange.address, MIN_ORDER_AMOUNT * 4)?;
4168
4169            // Get the base token and apply blacklist policy
4170            let mut base = TIP20Token::from_address(base_addr)?;
4171            base.change_transfer_policy_id(
4172                admin,
4173                ITIP20::changeTransferPolicyIdCall {
4174                    newPolicyId: policy_id,
4175                },
4176            )?;
4177
4178            // Blacklist alice in the base token
4179            registry.modify_policy_blacklist(
4180                admin,
4181                ITIP403Registry::modifyPolicyBlacklistCall {
4182                    policyId: policy_id,
4183                    account: alice,
4184                    restricted: true,
4185                },
4186            )?;
4187
4188            exchange.create_pair(base_addr)?;
4189
4190            // Test place bid order (alice wants to buy base token) - should fail
4191            let result = exchange.place(alice, base_addr, MIN_ORDER_AMOUNT, true, 0);
4192            assert!(result.is_err());
4193            assert!(matches!(
4194                result.unwrap_err(),
4195                TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
4196            ));
4197
4198            // Test placeFlip bid order - should also fail
4199            let result =
4200                exchange.place_flip(alice, base_addr, MIN_ORDER_AMOUNT, true, 0, 100, false);
4201            assert!(result.is_err());
4202            assert!(matches!(
4203                result.unwrap_err(),
4204                TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
4205            ));
4206
4207            Ok(())
4208        })
4209    }
4210
4211    #[test]
4212    fn test_place_when_quote_blacklisted() -> eyre::Result<()> {
4213        let mut storage = HashMapStorageProvider::new(1);
4214        StorageCtx::enter(&mut storage, || {
4215            let mut exchange = StablecoinDEX::new();
4216            exchange.initialize()?;
4217
4218            let alice = Address::random();
4219            let admin = Address::random();
4220
4221            // Setup TIP403 registry and create blacklist policy
4222            let mut registry = TIP403Registry::new();
4223            let policy_id = registry.create_policy(
4224                admin,
4225                ITIP403Registry::createPolicyCall {
4226                    admin,
4227                    policyType: ITIP403Registry::PolicyType::BLACKLIST,
4228                },
4229            )?;
4230
4231            // Set up base and quote tokens
4232            let (base_addr, quote_addr) =
4233                setup_test_tokens(admin, alice, exchange.address, MIN_ORDER_AMOUNT * 4)?;
4234
4235            // Get the quote token and apply blacklist policy
4236            let mut quote = TIP20Token::from_address(quote_addr)?;
4237            quote.change_transfer_policy_id(
4238                admin,
4239                ITIP20::changeTransferPolicyIdCall {
4240                    newPolicyId: policy_id,
4241                },
4242            )?;
4243
4244            // Blacklist alice in the quote token
4245            registry.modify_policy_blacklist(
4246                admin,
4247                ITIP403Registry::modifyPolicyBlacklistCall {
4248                    policyId: policy_id,
4249                    account: alice,
4250                    restricted: true,
4251                },
4252            )?;
4253
4254            exchange.create_pair(base_addr)?;
4255
4256            // Test place ask order (alice wants to sell base for quote) - should fail
4257            let result = exchange.place(alice, base_addr, MIN_ORDER_AMOUNT, false, 0);
4258            assert!(result.is_err());
4259            assert!(matches!(
4260                result.unwrap_err(),
4261                TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
4262            ));
4263
4264            // Test placeFlip ask order - should also fail
4265            let result =
4266                exchange.place_flip(alice, base_addr, MIN_ORDER_AMOUNT, false, 100, 0, false);
4267            assert!(result.is_err());
4268            assert!(matches!(
4269                result.unwrap_err(),
4270                TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
4271            ));
4272
4273            Ok(())
4274        })
4275    }
4276
4277    #[test]
4278    fn test_compound_policy_non_escrow_token_direction() -> eyre::Result<()> {
4279        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2);
4280        StorageCtx::enter(&mut storage, || {
4281            let mut exchange = StablecoinDEX::new();
4282            exchange.initialize()?;
4283
4284            let (alice, admin) = (Address::random(), Address::random());
4285            let mut registry = TIP403Registry::new();
4286
4287            // Create a sender policy that allows anyone (always-allow = policy 1)
4288            // Create a recipient whitelist that does NOT include alice
4289            let recipient_policy = registry.create_policy(
4290                admin,
4291                ITIP403Registry::createPolicyCall {
4292                    admin,
4293                    policyType: ITIP403Registry::PolicyType::WHITELIST,
4294                },
4295            )?;
4296            // Don't add alice to the recipient whitelist - she cannot receive
4297
4298            // Create compound policy: anyone can send, but only whitelisted can receive
4299            let compound_id = registry.create_compound_policy(
4300                admin,
4301                ITIP403Registry::createCompoundPolicyCall {
4302                    senderPolicyId: 1,                   // always-allow: anyone can send
4303                    recipientPolicyId: recipient_policy, // whitelist: alice NOT included
4304                    mintRecipientPolicyId: 1,            // always-allow: anyone can receive mints
4305                },
4306            )?;
4307
4308            // Setup tokens
4309            let (base_addr, quote_addr) =
4310                setup_test_tokens(admin, alice, exchange.address, MIN_ORDER_AMOUNT * 4)?;
4311
4312            // Apply compound policy to quote token (the non-escrow token for asks)
4313            let mut quote = TIP20Token::from_address(quote_addr)?;
4314            quote.change_transfer_policy_id(
4315                admin,
4316                ITIP20::changeTransferPolicyIdCall {
4317                    newPolicyId: compound_id,
4318                },
4319            )?;
4320
4321            exchange.create_pair(base_addr)?;
4322
4323            // Alice places an ask order: sells base token, receives quote token when filled
4324            // Since alice is NOT in the recipient whitelist for quote token,
4325            // and the non-escrow token (quote) flows DEX → alice, this should FAIL.
4326            let res_ask = exchange.place(alice, base_addr, MIN_ORDER_AMOUNT, false, 0);
4327            // Same for flip orders
4328            let res_flip =
4329                exchange.place_flip(alice, base_addr, MIN_ORDER_AMOUNT, false, 100, 0, false);
4330
4331            for res in [res_ask, res_flip] {
4332                assert!(
4333                    matches!(
4334                        res.unwrap_err(),
4335                        TempoPrecompileError::TIP20(TIP20Error::PolicyForbids(_))
4336                    ),
4337                    "Order should fail: alice cannot receive quote token (non-escrow) per compound policy"
4338                );
4339            }
4340            Ok(())
4341        })
4342    }
4343
4344    #[test]
4345    fn test_swap_exact_amount_out_rounding() -> eyre::Result<()> {
4346        let mut storage = HashMapStorageProvider::new(1);
4347        StorageCtx::enter(&mut storage, || {
4348            let mut exchange = StablecoinDEX::new();
4349            exchange.initialize()?;
4350
4351            let alice = Address::random();
4352            let bob = Address::random();
4353            let admin = Address::random();
4354            let tick = 10;
4355
4356            let (base_token, quote_token) =
4357                setup_test_tokens(admin, alice, exchange.address, 200_000_000u128)?;
4358            exchange
4359                .create_pair(base_token)
4360                .expect("Could not create pair");
4361
4362            let order_amount = 100000000u128;
4363
4364            let tip20_quote_token = TIP20Token::from_address(quote_token)?;
4365            let alice_initial_balance =
4366                tip20_quote_token.balance_of(ITIP20::balanceOfCall { account: alice })?;
4367
4368            exchange
4369                .place(alice, base_token, order_amount, true, tick)
4370                .expect("Order should succeed");
4371
4372            let alice_balance_after_place =
4373                tip20_quote_token.balance_of(ITIP20::balanceOfCall { account: alice })?;
4374            let escrowed = alice_initial_balance - alice_balance_after_place;
4375            assert_eq!(escrowed, U256::from(100010000u128));
4376
4377            exchange
4378                .set_balance(bob, base_token, 200_000_000u128)
4379                .expect("Could not set balance");
4380
4381            exchange
4382                .swap_exact_amount_out(bob, base_token, quote_token, 100009999, u128::MAX)
4383                .expect("Swap should succeed");
4384
4385            Ok(())
4386        })
4387    }
4388
4389    #[test]
4390    fn test_stablecoin_dex_address_returns_correct_precompile() -> eyre::Result<()> {
4391        let mut storage = HashMapStorageProvider::new(1);
4392        StorageCtx::enter(&mut storage, || {
4393            let exchange = StablecoinDEX::new();
4394            assert_eq!(exchange.address(), STABLECOIN_DEX_ADDRESS);
4395            Ok(())
4396        })
4397    }
4398
4399    #[test]
4400    fn test_stablecoin_dex_initialize_sets_storage_state() -> eyre::Result<()> {
4401        let mut storage = HashMapStorageProvider::new(1);
4402        StorageCtx::enter(&mut storage, || {
4403            let mut exchange = StablecoinDEX::new();
4404
4405            // Before init, should not be initialized
4406            assert!(!exchange.is_initialized()?);
4407
4408            // Initialize
4409            exchange.initialize()?;
4410
4411            // After init, should be initialized
4412            assert!(exchange.is_initialized()?);
4413
4414            // New handle should still see initialized state
4415            let exchange2 = StablecoinDEX::new();
4416            assert!(exchange2.is_initialized()?);
4417
4418            Ok(())
4419        })
4420    }
4421
4422    #[test]
4423    fn test_get_order_validates_maker_and_order_id() -> eyre::Result<()> {
4424        let mut storage = HashMapStorageProvider::new(1);
4425        StorageCtx::enter(&mut storage, || {
4426            let mut exchange = StablecoinDEX::new();
4427            exchange.initialize()?;
4428
4429            let admin = Address::random();
4430            let alice = Address::random();
4431            let min_order_amount = MIN_ORDER_AMOUNT;
4432            let tick = 100i16;
4433
4434            let price = orderbook::tick_to_price(tick);
4435            let escrow = (min_order_amount * price as u128) / orderbook::PRICE_SCALE as u128;
4436
4437            let (base_token, _quote_token) =
4438                setup_test_tokens(admin, alice, exchange.address, escrow)?;
4439            exchange.create_pair(base_token)?;
4440
4441            let order_id = exchange.place(alice, base_token, min_order_amount, true, tick)?;
4442
4443            // Valid order should be retrievable
4444            let order = exchange.get_order(order_id)?;
4445            assert_eq!(order.maker(), alice);
4446            assert!(!order.maker().is_zero());
4447            assert!(order.order_id() < exchange.next_order_id()?);
4448
4449            // Order with zero maker (non-existent) should fail
4450            let result = exchange.get_order(999);
4451            assert!(result.is_err());
4452            assert_eq!(
4453                result.unwrap_err(),
4454                StablecoinDEXError::order_does_not_exist().into()
4455            );
4456
4457            // Order ID >= next_order_id should fail (tests the < boundary)
4458            let next_id = exchange.next_order_id()?;
4459            let result = exchange.get_order(next_id);
4460            assert!(result.is_err());
4461            assert_eq!(
4462                result.unwrap_err(),
4463                StablecoinDEXError::order_does_not_exist().into()
4464            );
4465
4466            Ok(())
4467        })
4468    }
4469
4470    /// Common state produced by [`setup_flip_order_test`].
4471    struct FlipOrderTestCtx {
4472        exchange: StablecoinDEX,
4473        alice: Address,
4474        bob: Address,
4475        admin: Address,
4476        base_token: Address,
4477        quote_token: Address,
4478        book_key: B256,
4479        amount: u128,
4480        flip_tick: i16,
4481    }
4482
4483    /// Sets up a [`StablecoinDEX`] with a flip bid order ready to be filled.
4484    fn setup_flip_order_test() -> eyre::Result<FlipOrderTestCtx> {
4485        let mut exchange = StablecoinDEX::new();
4486        exchange.initialize()?;
4487
4488        let alice = Address::random();
4489        let bob = Address::random();
4490        let admin = Address::random();
4491        let amount = MIN_ORDER_AMOUNT;
4492        let tick = 100i16;
4493        let flip_tick = 200i16;
4494
4495        let price = orderbook::tick_to_price(tick);
4496        let expected_escrow = (amount * price as u128) / orderbook::PRICE_SCALE as u128;
4497
4498        let (base_token, quote_token) =
4499            setup_test_tokens(admin, alice, exchange.address, expected_escrow * 2)?;
4500        exchange.create_pair(base_token)?;
4501
4502        let book_key = compute_book_key(base_token, quote_token);
4503
4504        // Place a flip bid order: when filled, it should flip to an ask at flip_tick
4505        exchange.place_flip(alice, base_token, amount, true, tick, flip_tick, false)?;
4506
4507        Ok(FlipOrderTestCtx {
4508            exchange,
4509            alice,
4510            bob,
4511            admin,
4512            base_token,
4513            quote_token,
4514            book_key,
4515            amount,
4516            flip_tick,
4517        })
4518    }
4519
4520    #[test]
4521    fn test_flip_order_fill_ignores_business_logic_error() -> eyre::Result<()> {
4522        // Business logic errors during flip are silently ignored (always).
4523        for spec in [TempoHardfork::T1, TempoHardfork::T1A, TempoHardfork::T2] {
4524            let mut storage = HashMapStorageProvider::new_with_spec(1, spec);
4525            StorageCtx::enter(&mut storage, || {
4526                let FlipOrderTestCtx {
4527                    mut exchange,
4528                    alice,
4529                    bob,
4530                    admin,
4531                    base_token,
4532                    quote_token,
4533                    book_key,
4534                    amount,
4535                    flip_tick,
4536                } = setup_flip_order_test()?;
4537
4538                // Blacklist alice on the base token AFTER order placement.
4539                // When the flip (ask) is placed during fill, ensure_transfer_authorized(alice, dex)
4540                // on the base token will fail with PolicyForbids — a business logic error.
4541                let mut registry = TIP403Registry::new();
4542                let policy_id = registry.create_policy(
4543                    admin,
4544                    ITIP403Registry::createPolicyCall {
4545                        admin,
4546                        policyType: ITIP403Registry::PolicyType::BLACKLIST,
4547                    },
4548                )?;
4549
4550                let mut base = TIP20Token::from_address(base_token)?;
4551                base.change_transfer_policy_id(
4552                    admin,
4553                    ITIP20::changeTransferPolicyIdCall {
4554                        newPolicyId: policy_id,
4555                    },
4556                )?;
4557
4558                registry.modify_policy_blacklist(
4559                    admin,
4560                    ITIP403Registry::modifyPolicyBlacklistCall {
4561                        policyId: policy_id,
4562                        account: alice,
4563                        restricted: true,
4564                    },
4565                )?;
4566
4567                // Fund bob to fill the order
4568                exchange.set_balance(bob, base_token, amount)?;
4569
4570                // The swap must succeed — PolicyForbids is not a system error, so it's ignored
4571                let result = exchange.swap_exact_amount_in(bob, base_token, quote_token, amount, 0);
4572                assert!(
4573                    result.is_ok(),
4574                    "[{spec:?}] Swap should succeed when flip hits a business logic error"
4575                );
4576
4577                // Alice keeps the fill proceeds (base tokens credited during fill, not escrowed)
4578                assert_eq!(exchange.balance_of(alice, base_token)?, amount);
4579
4580                // No flipped order exists — the ask tick level at flip_tick is empty
4581                let level = exchange.books[book_key]
4582                    .tick_level_handler(flip_tick, false)
4583                    .read()?;
4584                assert_eq!(
4585                    level.total_liquidity, 0,
4586                    "[{spec:?}] No flipped order should exist"
4587                );
4588
4589                Ok::<_, eyre::Report>(())
4590            })?;
4591        }
4592        Ok(())
4593    }
4594
4595    #[test]
4596    fn test_flip_order_fill_reverts_on_system_error_post_t1a() -> eyre::Result<()> {
4597        // System errors during flip propagate only on T1A+. Pre-T1A all errors are ignored.
4598        for spec in [TempoHardfork::T1, TempoHardfork::T1A, TempoHardfork::T2] {
4599            let mut storage = HashMapStorageProvider::new_with_spec(1, spec);
4600            StorageCtx::enter(&mut storage, || {
4601                let FlipOrderTestCtx {
4602                    mut exchange,
4603                    alice,
4604                    bob,
4605                    base_token,
4606                    quote_token,
4607                    book_key,
4608                    amount,
4609                    flip_tick,
4610                    ..
4611                } = setup_flip_order_test()?;
4612
4613                let alice_quote_before = exchange.balance_of(alice, quote_token)?;
4614
4615                // Poison the flip target tick so commit_order_to_book overflows on checked_add
4616                let poisoned_level = TickLevel::with_values(0, 0, u128::MAX);
4617                exchange.books[book_key]
4618                    .tick_level_handler_mut(flip_tick, false)
4619                    .write(poisoned_level)?;
4620
4621                // Fund bob to fill the order
4622                exchange.set_balance(bob, base_token, amount)?;
4623
4624                let result = exchange.swap_exact_amount_in(bob, base_token, quote_token, amount, 0);
4625
4626                if spec.is_t1a() {
4627                    // T1A+: system errors propagate — swap must revert
4628                    assert!(
4629                        result.is_err(),
4630                        "Swap should revert when flip hits a system error"
4631                    );
4632                    assert!(
4633                        result.unwrap_err().is_system_error(),
4634                        "Error must be classified as a system error",
4635                    );
4636
4637                    // Maker balance must be unchanged — no funds lost
4638                    let alice_quote_after = exchange.balance_of(alice, quote_token)?;
4639                    assert_eq!(alice_quote_before, alice_quote_after);
4640                } else {
4641                    // Pre-T1A: all flip errors are ignored — swap succeeds
4642                    assert!(
4643                        result.is_ok(),
4644                        "[{spec:?}] Swap should succeed when system error is pre-T1A"
4645                    );
4646                }
4647
4648                Ok::<_, eyre::Report>(())
4649            })?;
4650        }
4651        Ok(())
4652    }
4653
4654    #[test]
4655    fn test_orderbook_invariants_after_all_orders_filled() -> eyre::Result<()> {
4656        let mut storage = HashMapStorageProvider::new(1);
4657        StorageCtx::enter(&mut storage, || {
4658            let mut exchange = StablecoinDEX::new();
4659            exchange.initialize()?;
4660
4661            // Verify initial next_order_id is 1
4662            assert_eq!(exchange.next_order_id()?, 1);
4663
4664            let alice = Address::random();
4665            let bob = Address::random();
4666            let admin = Address::random();
4667            let amount = MIN_ORDER_AMOUNT;
4668            let tick = 100i16;
4669
4670            let price = orderbook::tick_to_price(tick) as u128;
4671            let quote_amount = (amount * price).div_ceil(orderbook::PRICE_SCALE as u128);
4672
4673            let base = TIP20Setup::create("BASE", "BASE", admin)
4674                .with_issuer(admin)
4675                .with_mint(alice, U256::from(amount * 4))
4676                .with_mint(bob, U256::from(amount * 4))
4677                .with_approval(alice, exchange.address, U256::MAX)
4678                .with_approval(bob, exchange.address, U256::MAX)
4679                .apply()?;
4680            let base_token = base.address();
4681            let quote_token = base.quote_token()?;
4682
4683            TIP20Setup::path_usd(admin)
4684                .with_issuer(admin)
4685                .with_mint(alice, U256::from(quote_amount * 4))
4686                .with_mint(bob, U256::from(quote_amount * 4))
4687                .with_approval(alice, exchange.address, U256::MAX)
4688                .with_approval(bob, exchange.address, U256::MAX)
4689                .apply()?;
4690
4691            let book_key = compute_book_key(base_token, quote_token);
4692            exchange.create_pair(base_token)?;
4693
4694            // Place a bid and an ask
4695            let bid_id = exchange.place(alice, base_token, amount, true, tick)?;
4696            assert_eq!(bid_id, 1);
4697            let ask_id = exchange.place(bob, base_token, amount, false, tick)?;
4698            assert_eq!(ask_id, 2);
4699
4700            // Verify book has liquidity
4701            let book = exchange.books[book_key].read()?;
4702            assert_eq!(book.best_bid_tick, tick);
4703            assert_eq!(book.best_ask_tick, tick);
4704
4705            // Fill the bid by selling base into it
4706            exchange.swap_exact_amount_in(bob, base_token, quote_token, amount, 0)?;
4707
4708            // Fill the ask by buying base from it
4709            exchange.swap_exact_amount_in(alice, quote_token, base_token, quote_amount, 0)?;
4710
4711            // Verify sentinel values are restored
4712            let book = exchange.books[book_key].read()?;
4713            assert_eq!(
4714                book.best_bid_tick,
4715                i16::MIN,
4716                "best_bid_tick must be sentinel after all bids filled"
4717            );
4718            assert_eq!(
4719                book.best_ask_tick,
4720                i16::MAX,
4721                "best_ask_tick must be sentinel after all asks filled"
4722            );
4723
4724            // Verify tick levels are cleared
4725            let bid_level = exchange.books[book_key]
4726                .tick_level_handler(tick, true)
4727                .read()?;
4728            assert_eq!(bid_level.head, 0, "bid level head must be 0 after drain");
4729            assert_eq!(bid_level.tail, 0, "bid level tail must be 0 after drain");
4730            assert_eq!(
4731                bid_level.total_liquidity, 0,
4732                "bid level liquidity must be 0 after drain"
4733            );
4734
4735            let ask_level = exchange.books[book_key]
4736                .tick_level_handler(tick, false)
4737                .read()?;
4738            assert_eq!(ask_level.head, 0, "ask level head must be 0 after drain");
4739            assert_eq!(ask_level.tail, 0, "ask level tail must be 0 after drain");
4740            assert_eq!(
4741                ask_level.total_liquidity, 0,
4742                "ask level liquidity must be 0 after drain"
4743            );
4744
4745            // Verify next_order_id is monotonic (never resets)
4746            assert_eq!(
4747                exchange.next_order_id()?,
4748                3,
4749                "next_order_id must remain monotonic after drain"
4750            );
4751
4752            // Verify swaps against drained book return insufficient_liquidity
4753            // Sell base into (empty) bids
4754            let result = exchange.swap_exact_amount_in(bob, base_token, quote_token, amount, 0);
4755            assert_eq!(
4756                result,
4757                Err(StablecoinDEXError::insufficient_liquidity().into()),
4758                "swap against drained bid side must fail"
4759            );
4760            // Buy base from (empty) asks
4761            let result =
4762                exchange.swap_exact_amount_in(alice, quote_token, base_token, quote_amount, 0);
4763            assert_eq!(
4764                result,
4765                Err(StablecoinDEXError::insufficient_liquidity().into()),
4766                "swap against drained ask side must fail"
4767            );
4768
4769            Ok(())
4770        })
4771    }
4772
4773    #[test]
4774    fn test_orderbook_invariants_after_all_orders_cancelled() -> eyre::Result<()> {
4775        let mut storage = HashMapStorageProvider::new(1);
4776        StorageCtx::enter(&mut storage, || {
4777            let mut exchange = StablecoinDEX::new();
4778            exchange.initialize()?;
4779
4780            let alice = Address::random();
4781            let admin = Address::random();
4782            let amount = MIN_ORDER_AMOUNT;
4783            let tick = 100i16;
4784
4785            let price = orderbook::tick_to_price(tick) as u128;
4786            let quote_amount = (amount * price).div_ceil(orderbook::PRICE_SCALE as u128);
4787
4788            let base = TIP20Setup::create("BASE", "BASE", admin)
4789                .with_issuer(admin)
4790                .with_mint(alice, U256::from(amount * 2))
4791                .with_approval(alice, exchange.address, U256::MAX)
4792                .apply()?;
4793            let base_token = base.address();
4794            let quote_token = base.quote_token()?;
4795
4796            TIP20Setup::path_usd(admin)
4797                .with_issuer(admin)
4798                .with_mint(alice, U256::from(quote_amount * 2))
4799                .with_approval(alice, exchange.address, U256::MAX)
4800                .apply()?;
4801
4802            let book_key = compute_book_key(base_token, quote_token);
4803            exchange.create_pair(base_token)?;
4804
4805            // Place a bid and an ask
4806            let bid_id = exchange.place(alice, base_token, amount, true, tick)?;
4807            let ask_id = exchange.place(alice, base_token, amount, false, tick)?;
4808
4809            // Cancel both
4810            exchange.cancel(alice, bid_id)?;
4811            exchange.cancel(alice, ask_id)?;
4812
4813            // Verify sentinel values are restored
4814            let book = exchange.books[book_key].read()?;
4815            assert_eq!(
4816                book.best_bid_tick,
4817                i16::MIN,
4818                "best_bid_tick must be sentinel after all bids cancelled"
4819            );
4820            assert_eq!(
4821                book.best_ask_tick,
4822                i16::MAX,
4823                "best_ask_tick must be sentinel after all asks cancelled"
4824            );
4825
4826            // Verify tick levels are cleared
4827            let bid_level = exchange.books[book_key]
4828                .tick_level_handler(tick, true)
4829                .read()?;
4830            assert_eq!(bid_level.head, 0, "bid level head must be 0");
4831            assert_eq!(bid_level.tail, 0, "bid level tail must be 0");
4832            assert_eq!(bid_level.total_liquidity, 0, "bid liquidity must be 0");
4833
4834            let ask_level = exchange.books[book_key]
4835                .tick_level_handler(tick, false)
4836                .read()?;
4837            assert_eq!(ask_level.head, 0, "ask level head must be 0");
4838            assert_eq!(ask_level.tail, 0, "ask level tail must be 0");
4839            assert_eq!(ask_level.total_liquidity, 0, "ask liquidity must be 0");
4840
4841            // Verify swap against drained book fails
4842            let result = exchange.swap_exact_amount_in(alice, base_token, quote_token, amount, 0);
4843            assert_eq!(
4844                result,
4845                Err(StablecoinDEXError::insufficient_liquidity().into()),
4846                "swap against cancelled book must fail"
4847            );
4848
4849            Ok(())
4850        })
4851    }
4852
4853    #[test]
4854    fn test_sub_balance_errors_on_underflow() -> eyre::Result<()> {
4855        let mut storage = HashMapStorageProvider::new(1);
4856        StorageCtx::enter(&mut storage, || {
4857            let mut exchange = StablecoinDEX::new();
4858            exchange.initialize()?;
4859
4860            let user = Address::random();
4861            let admin = Address::random();
4862
4863            let base = TIP20Setup::create("BASE", "BASE", admin)
4864                .with_issuer(admin)
4865                .apply()?;
4866            let token = base.address();
4867
4868            // Set a balance of 100
4869            exchange.set_balance(user, token, 100)?;
4870            assert_eq!(exchange.balance_of(user, token)?, 100);
4871
4872            // Subtracting more than the balance should error, not silently clamp to 0
4873            let result = exchange.sub_balance(user, token, 101);
4874            assert_eq!(
4875                result,
4876                Err(TempoPrecompileError::under_overflow()),
4877                "sub_balance should error on underflow instead of saturating"
4878            );
4879
4880            // Balance should be unchanged
4881            assert_eq!(exchange.balance_of(user, token)?, 100);
4882
4883            Ok(())
4884        })
4885    }
4886
4887    #[test]
4888    fn test_flip_checkpoint_reverts_partial_state_post_t1c() -> eyre::Result<()> {
4889        // When commit_order_to_book fails inside place_flip:
4890        // - T1C+: checkpoint reverts sub_balance + next_order_id
4891        // - Pre-T1C: partial state leaks (balance debited, id bumped)
4892        //
4893        // All specs are T1A+ so system errors propagate and the swap itself fails.
4894        for spec in [TempoHardfork::T1A, TempoHardfork::T1C] {
4895            let mut storage = HashMapStorageProvider::new_with_spec(1, spec);
4896            StorageCtx::enter(&mut storage, || {
4897                let FlipOrderTestCtx {
4898                    mut exchange,
4899                    alice,
4900                    bob,
4901                    base_token,
4902                    quote_token,
4903                    book_key,
4904                    amount,
4905                    flip_tick,
4906                    ..
4907                } = setup_flip_order_test()?;
4908
4909                let next_id_before = exchange.next_order_id()?;
4910
4911                // Poison the flip target tick so commit_order_to_book
4912                // overflows on checked_add — a system error.
4913                let poisoned = TickLevel::with_values(0, 0, u128::MAX);
4914                exchange.books[book_key]
4915                    .tick_level_handler_mut(flip_tick, false)
4916                    .write(poisoned)?;
4917
4918                // Fund bob to fill the order
4919                exchange.set_balance(bob, base_token, amount)?;
4920
4921                let result = exchange.swap_exact_amount_in(bob, base_token, quote_token, amount, 0);
4922                assert!(result.is_err(), "[{spec:?}] swap should fail");
4923
4924                // 1. `fill_order` credited alice `amount` base before `place_flip`
4925                // 2. `sub_balance` debited it back
4926                // 3. `commit_order_to_book` failed
4927                let alice_base = exchange.balance_of(alice, base_token)?;
4928                let next_id_after = exchange.next_order_id()?;
4929
4930                if spec.is_t1c() {
4931                    // Checkpoint reverts both sub_balance and order_id
4932                    assert_eq!(alice_base, amount);
4933                    assert_eq!(next_id_after, next_id_before);
4934                } else {
4935                    // No checkpoint — partial state leaks
4936                    assert_eq!(alice_base, 0);
4937                    assert_eq!(next_id_after, next_id_before + 1);
4938                }
4939
4940                // verify that `OrderPlaced` event was never emitted due to poisoned tick's revert
4941                assert!(
4942                    exchange.emitted_events().last().is_some_and(
4943                        |e| e.topics()[0] != IStablecoinDEX::OrderPlaced::SIGNATURE_HASH
4944                    )
4945                );
4946
4947                Ok::<_, eyre::Report>(())
4948            })?;
4949        }
4950        Ok(())
4951    }
4952}