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