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