Skip to main content

solid_pod_rs/
trading.rs

1//! Peer-to-peer trading and AMM constant-product pool.
2//!
3//! Implements the exchange surface documented in Melvin Carvalho's
4//! *Practical Guide to Solid*, parts 8–9:
5//! <https://melvin.me/public/solid/>
6//!
7//! - **Order book** (`/pay/.sell`, `/pay/.offers`, `/pay/.swap`): sellers
8//!   post sell orders specifying amount, currency pair, and price; buyers
9//!   execute atomic swaps against the order book.
10//! - **AMM pool** (`/pay/.pool`): constant-product `x * y = k` liquidity
11//!   pool with configurable fee (default 30 bps / 0.3%).
12//!
13//! All balance mutations delegate to [`WebLedger`] — this module never
14//! owns or persists balances directly. Integer-only arithmetic with
15//! `u128` intermediates prevents overflow on multiply-before-divide.
16
17use std::collections::HashMap;
18
19use serde::{Deserialize, Serialize};
20
21use crate::payments::{PaymentError, WebLedger};
22
23// ---------------------------------------------------------------------------
24// WebLedger multi-currency extensions
25// ---------------------------------------------------------------------------
26
27/// Extension methods on [`WebLedger`] for currency-specific operations.
28///
29/// The base `WebLedger` API (`credit`, `debit`, `get_balance`) operates on
30/// the default satoshi balance. Trading requires per-currency balances, so
31/// these helpers work through the `LedgerAmount::Multi` representation.
32impl WebLedger {
33    /// Get a DID's balance in a specific currency.
34    pub fn get_currency_balance(&self, did: &str, currency: &str) -> u64 {
35        self.entries
36            .iter()
37            .find(|e| e.url == did)
38            .map(|e| e.amount.chain_balance(currency))
39            .unwrap_or(0)
40    }
41
42    /// Credit a DID in a specific currency.
43    pub fn credit_currency(&mut self, did: &str, currency: &str, amount: u64) {
44        use crate::payments::{CurrencyAmount, LedgerAmount, LedgerEntry};
45
46        self.updated = now_secs();
47        if let Some(entry) = self.entries.iter_mut().find(|e| e.url == did) {
48            match &mut entry.amount {
49                LedgerAmount::Simple(s) => {
50                    // Upgrade Simple → Multi, preserving the satoshi balance.
51                    let sat_val = s.parse::<u64>().unwrap_or(0);
52                    let mut currencies = vec![CurrencyAmount {
53                        currency: "satoshi".into(),
54                        value: sat_val.to_string(),
55                    }];
56                    currencies.push(CurrencyAmount {
57                        currency: currency.into(),
58                        value: amount.to_string(),
59                    });
60                    entry.amount = LedgerAmount::Multi(currencies);
61                }
62                LedgerAmount::Multi(v) => {
63                    if let Some(ca) = v.iter_mut().find(|a| a.currency == currency) {
64                        let current: u64 = ca.value.parse().unwrap_or(0);
65                        ca.value = current.saturating_add(amount).to_string();
66                    } else {
67                        v.push(CurrencyAmount {
68                            currency: currency.into(),
69                            value: amount.to_string(),
70                        });
71                    }
72                }
73            }
74        } else {
75            self.entries.push(LedgerEntry {
76                entry_type: "Entry".into(),
77                url: did.into(),
78                amount: LedgerAmount::Multi(vec![CurrencyAmount {
79                    currency: currency.into(),
80                    value: amount.to_string(),
81                }]),
82            });
83        }
84    }
85
86    /// Debit a DID in a specific currency.
87    pub fn debit_currency(
88        &mut self,
89        did: &str,
90        currency: &str,
91        amount: u64,
92    ) -> Result<u64, PaymentError> {
93        let current = self.get_currency_balance(did, currency);
94        if current < amount {
95            return Err(PaymentError::InsufficientBalance {
96                balance: current,
97                cost: amount,
98            });
99        }
100        self.updated = now_secs();
101        // Entry must exist since get_currency_balance returned > 0.
102        let entry = self.entries.iter_mut().find(|e| e.url == did).unwrap();
103        match &mut entry.amount {
104            crate::payments::LedgerAmount::Multi(v) => {
105                if let Some(ca) = v.iter_mut().find(|a| a.currency == currency) {
106                    let cur: u64 = ca.value.parse().unwrap_or(0);
107                    let new_val = cur - amount;
108                    ca.value = new_val.to_string();
109                    Ok(new_val)
110                } else {
111                    Err(PaymentError::InsufficientBalance {
112                        balance: 0,
113                        cost: amount,
114                    })
115                }
116            }
117            crate::payments::LedgerAmount::Simple(_) => {
118                Err(PaymentError::InsufficientBalance {
119                    balance: 0,
120                    cost: amount,
121                })
122            }
123        }
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Swap result
129// ---------------------------------------------------------------------------
130
131/// Result of executing a trade (either order-book or AMM).
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct SwapResult {
134    pub amount_in: u64,
135    pub amount_out: u64,
136    pub fee: u64,
137    pub new_balance_in: u64,
138    pub new_balance_out: u64,
139}
140
141// ---------------------------------------------------------------------------
142// Sell orders + order book
143// ---------------------------------------------------------------------------
144
145/// A sell order on the order book.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct SellOrder {
148    pub id: String,
149    /// Seller identity: `did:nostr:<hex-pubkey>`.
150    pub seller: String,
151    /// Currency the seller is offering (e.g. `"tbtc4"`).
152    pub sell_currency: String,
153    /// Amount of `sell_currency` offered.
154    pub sell_amount: u64,
155    /// Currency the seller wants in return (e.g. `"tbtc3"`).
156    pub buy_currency: String,
157    /// Price: `buy_currency` units per `sell_currency` unit.
158    pub price: u64,
159    /// Unix timestamp when the order was created.
160    pub created_at: u64,
161}
162
163/// Order book for peer-to-peer trades.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct OrderBook {
166    orders: Vec<SellOrder>,
167    next_id: u64,
168}
169
170impl OrderBook {
171    pub fn new() -> Self {
172        Self {
173            orders: Vec::new(),
174            next_id: 1,
175        }
176    }
177
178    /// Create a new sell order and add it to the book.
179    ///
180    /// Returns the created order. The caller must ensure the seller has
181    /// sufficient balance before listing — the order book does not escrow.
182    pub fn create_order(
183        &mut self,
184        seller: &str,
185        sell_currency: &str,
186        sell_amount: u64,
187        buy_currency: &str,
188        price: u64,
189    ) -> SellOrder {
190        let order = SellOrder {
191            id: self.next_id.to_string(),
192            seller: seller.into(),
193            sell_currency: sell_currency.into(),
194            sell_amount,
195            buy_currency: buy_currency.into(),
196            price,
197            created_at: now_secs(),
198        };
199        self.next_id += 1;
200        self.orders.push(order.clone());
201        order
202    }
203
204    /// List all active orders, optionally filtered by currency pair.
205    ///
206    /// When `currency_pair` is `Some((sell, buy))`, only orders matching
207    /// that exact pair are returned.
208    pub fn list_offers(&self, currency_pair: Option<(&str, &str)>) -> Vec<&SellOrder> {
209        match currency_pair {
210            None => self.orders.iter().collect(),
211            Some((sell, buy)) => self
212                .orders
213                .iter()
214                .filter(|o| o.sell_currency == sell && o.buy_currency == buy)
215                .collect(),
216        }
217    }
218
219    /// Cancel an order. Only the original seller can cancel.
220    pub fn cancel_order(
221        &mut self,
222        id: &str,
223        seller: &str,
224    ) -> Result<SellOrder, PaymentError> {
225        let idx = self
226            .orders
227            .iter()
228            .position(|o| o.id == id)
229            .ok_or_else(|| {
230                PaymentError::InvalidTxo(format!("order {id} not found"))
231            })?;
232
233        if self.orders[idx].seller != seller {
234            return Err(PaymentError::InvalidTxo(format!(
235                "order {id} belongs to {}, not {seller}",
236                self.orders[idx].seller
237            )));
238        }
239
240        Ok(self.orders.remove(idx))
241    }
242
243    /// Execute a swap against an existing sell order.
244    ///
245    /// Atomic: debits the buyer in `buy_currency`, credits the seller in
246    /// `buy_currency`, debits the seller in `sell_currency`, credits the
247    /// buyer in `sell_currency`, then removes the order.
248    pub fn execute_swap(
249        &mut self,
250        id: &str,
251        buyer: &str,
252        ledger: &mut WebLedger,
253    ) -> Result<SwapResult, PaymentError> {
254        let idx = self
255            .orders
256            .iter()
257            .position(|o| o.id == id)
258            .ok_or_else(|| {
259                PaymentError::InvalidTxo(format!("order {id} not found"))
260            })?;
261
262        let order = &self.orders[idx];
263
264        // total_cost = sell_amount * price (what the buyer pays)
265        let total_cost = order
266            .sell_amount
267            .checked_mul(order.price)
268            .ok_or_else(|| {
269                PaymentError::InvalidTxo("price overflow".into())
270            })?;
271
272        // Verify buyer can afford it.
273        let buyer_balance = ledger.get_currency_balance(buyer, &order.buy_currency);
274        if buyer_balance < total_cost {
275            return Err(PaymentError::InsufficientBalance {
276                balance: buyer_balance,
277                cost: total_cost,
278            });
279        }
280
281        // Verify seller still has the tokens to sell.
282        let seller_balance =
283            ledger.get_currency_balance(&order.seller, &order.sell_currency);
284        if seller_balance < order.sell_amount {
285            return Err(PaymentError::InsufficientBalance {
286                balance: seller_balance,
287                cost: order.sell_amount,
288            });
289        }
290
291        // Clone what we need before mutating.
292        let sell_amount = order.sell_amount;
293        let sell_currency = order.sell_currency.clone();
294        let buy_currency = order.buy_currency.clone();
295        let seller = order.seller.clone();
296
297        // Atomic settlement:
298        // 1. Debit buyer in buy_currency.
299        ledger.debit_currency(buyer, &buy_currency, total_cost)?;
300        // 2. Credit seller in buy_currency.
301        ledger.credit_currency(&seller, &buy_currency, total_cost);
302        // 3. Debit seller in sell_currency.
303        ledger.debit_currency(&seller, &sell_currency, sell_amount)?;
304        // 4. Credit buyer in sell_currency.
305        ledger.credit_currency(buyer, &sell_currency, sell_amount);
306
307        // Remove the filled order.
308        self.orders.remove(idx);
309
310        let new_balance_in = ledger.get_currency_balance(buyer, &buy_currency);
311        let new_balance_out = ledger.get_currency_balance(buyer, &sell_currency);
312
313        Ok(SwapResult {
314            amount_in: total_cost,
315            amount_out: sell_amount,
316            fee: 0,
317            new_balance_in,
318            new_balance_out,
319        })
320    }
321}
322
323impl Default for OrderBook {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329// ---------------------------------------------------------------------------
330// AMM constant-product liquidity pool
331// ---------------------------------------------------------------------------
332
333/// AMM constant-product liquidity pool (`x * y = k`).
334///
335/// Supports add/remove liquidity and swaps with a configurable fee
336/// (default 30 basis points = 0.3%). All arithmetic uses `u128`
337/// intermediates to prevent overflow.
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct AmmPool {
340    pub currency_a: String,
341    pub currency_b: String,
342    pub reserve_a: u64,
343    pub reserve_b: u64,
344    pub total_shares: u64,
345    /// Fee in basis points (100 bps = 1%). Default 30 (0.3%).
346    pub fee_bps: u64,
347    /// Per-provider share balances.
348    shares: HashMap<String, u64>,
349}
350
351impl AmmPool {
352    /// Default fee: 30 bps (0.3%), matching Uniswap V2.
353    pub const DEFAULT_FEE_BPS: u64 = 30;
354
355    pub fn new(currency_a: &str, currency_b: &str, fee_bps: u64) -> Self {
356        Self {
357            currency_a: currency_a.into(),
358            currency_b: currency_b.into(),
359            reserve_a: 0,
360            reserve_b: 0,
361            total_shares: 0,
362            fee_bps,
363            shares: HashMap::new(),
364        }
365    }
366
367    /// Add liquidity to the pool.
368    ///
369    /// The first provider sets the initial ratio. Subsequent providers
370    /// must provide amounts in the same ratio as the current reserves
371    /// (within integer rounding). Returns the number of LP shares issued.
372    pub fn add_liquidity(
373        &mut self,
374        provider: &str,
375        amount_a: u64,
376        amount_b: u64,
377        ledger: &mut WebLedger,
378    ) -> Result<u64, PaymentError> {
379        if amount_a == 0 || amount_b == 0 {
380            return Err(PaymentError::InvalidTxo(
381                "liquidity amounts must be non-zero".into(),
382            ));
383        }
384
385        // Debit provider.
386        ledger.debit_currency(provider, &self.currency_a, amount_a)?;
387        ledger.debit_currency(provider, &self.currency_b, amount_b)?;
388
389        let shares = if self.total_shares == 0 {
390            // First provider: shares = sqrt(amount_a * amount_b) using
391            // integer square root to stay in u64 land.
392            let product = (amount_a as u128) * (amount_b as u128);
393            isqrt_u128(product) as u64
394        } else {
395            // Proportional to existing reserves: min(a/A, b/B) * total.
396            let share_a = (amount_a as u128) * (self.total_shares as u128)
397                / (self.reserve_a as u128);
398            let share_b = (amount_b as u128) * (self.total_shares as u128)
399                / (self.reserve_b as u128);
400            share_a.min(share_b) as u64
401        };
402
403        if shares == 0 {
404            return Err(PaymentError::InvalidTxo(
405                "liquidity too small to issue shares".into(),
406            ));
407        }
408
409        self.reserve_a = self.reserve_a.saturating_add(amount_a);
410        self.reserve_b = self.reserve_b.saturating_add(amount_b);
411        self.total_shares = self.total_shares.saturating_add(shares);
412        *self.shares.entry(provider.into()).or_insert(0) += shares;
413
414        Ok(shares)
415    }
416
417    /// Remove liquidity from the pool.
418    ///
419    /// Withdraws proportional amounts of both currencies based on the
420    /// share count. Returns `(amount_a, amount_b)` credited back.
421    pub fn remove_liquidity(
422        &mut self,
423        provider: &str,
424        shares: u64,
425        ledger: &mut WebLedger,
426    ) -> Result<(u64, u64), PaymentError> {
427        let provider_shares = self
428            .shares
429            .get(provider)
430            .copied()
431            .unwrap_or(0);
432
433        if provider_shares < shares {
434            return Err(PaymentError::InsufficientBalance {
435                balance: provider_shares,
436                cost: shares,
437            });
438        }
439
440        if self.total_shares == 0 {
441            return Err(PaymentError::InvalidTxo("pool has no shares".into()));
442        }
443
444        // Proportional withdrawal.
445        let amount_a =
446            ((self.reserve_a as u128) * (shares as u128) / (self.total_shares as u128))
447                as u64;
448        let amount_b =
449            ((self.reserve_b as u128) * (shares as u128) / (self.total_shares as u128))
450                as u64;
451
452        self.reserve_a = self.reserve_a.saturating_sub(amount_a);
453        self.reserve_b = self.reserve_b.saturating_sub(amount_b);
454        self.total_shares = self.total_shares.saturating_sub(shares);
455
456        let entry = self.shares.get_mut(provider).unwrap();
457        *entry -= shares;
458        if *entry == 0 {
459            self.shares.remove(provider);
460        }
461
462        // Credit provider.
463        ledger.credit_currency(provider, &self.currency_a, amount_a);
464        ledger.credit_currency(provider, &self.currency_b, amount_b);
465
466        Ok((amount_a, amount_b))
467    }
468
469    /// Execute a constant-product swap.
470    ///
471    /// Formula (with fee):
472    /// ```text
473    /// amount_out = (reserve_out * amount_in * (10000 - fee_bps))
474    ///            / (reserve_in * 10000 + amount_in * (10000 - fee_bps))
475    /// ```
476    ///
477    /// All intermediate arithmetic uses `u128` to prevent overflow.
478    pub fn swap(
479        &mut self,
480        trader: &str,
481        from_currency: &str,
482        amount_in: u64,
483        ledger: &mut WebLedger,
484    ) -> Result<SwapResult, PaymentError> {
485        if amount_in == 0 {
486            return Err(PaymentError::InvalidTxo(
487                "swap amount must be non-zero".into(),
488            ));
489        }
490
491        let (reserve_in, reserve_out, to_currency) =
492            if from_currency == self.currency_a {
493                (self.reserve_a, self.reserve_b, self.currency_b.clone())
494            } else if from_currency == self.currency_b {
495                (self.reserve_b, self.reserve_a, self.currency_a.clone())
496            } else {
497                return Err(PaymentError::InvalidTxo(format!(
498                    "currency {from_currency} not in pool ({}/{})",
499                    self.currency_a, self.currency_b
500                )));
501            };
502
503        if reserve_in == 0 || reserve_out == 0 {
504            return Err(PaymentError::InvalidTxo("pool is empty".into()));
505        }
506
507        // Constant-product with fee, using u128 intermediates.
508        let fee_factor = 10_000u128 - (self.fee_bps as u128);
509        let numerator = (reserve_out as u128) * (amount_in as u128) * fee_factor;
510        let denominator =
511            (reserve_in as u128) * 10_000u128 + (amount_in as u128) * fee_factor;
512
513        let amount_out = (numerator / denominator) as u64;
514
515        if amount_out == 0 {
516            return Err(PaymentError::InvalidTxo(
517                "swap output rounds to zero".into(),
518            ));
519        }
520
521        // Fee retained in pool = amount_in - effective_input.
522        // effective_input = amount_in * fee_factor / 10000
523        let effective_input =
524            ((amount_in as u128) * fee_factor / 10_000u128) as u64;
525        let fee = amount_in - effective_input;
526
527        // Debit trader's input currency, credit output currency.
528        ledger.debit_currency(trader, from_currency, amount_in)?;
529        ledger.credit_currency(trader, &to_currency, amount_out);
530
531        // Update reserves.
532        if from_currency == self.currency_a {
533            self.reserve_a = self.reserve_a.saturating_add(amount_in);
534            self.reserve_b = self.reserve_b.saturating_sub(amount_out);
535        } else {
536            self.reserve_b = self.reserve_b.saturating_add(amount_in);
537            self.reserve_a = self.reserve_a.saturating_sub(amount_out);
538        }
539
540        let new_balance_in = ledger.get_currency_balance(trader, from_currency);
541        let new_balance_out = ledger.get_currency_balance(trader, &to_currency);
542
543        Ok(SwapResult {
544            amount_in,
545            amount_out,
546            fee,
547            new_balance_in,
548            new_balance_out,
549        })
550    }
551
552    /// Pool info as a JSON value (for `/pay/.pool` response).
553    pub fn pool_info(&self) -> serde_json::Value {
554        serde_json::json!({
555            "currency_a": self.currency_a,
556            "currency_b": self.currency_b,
557            "reserve_a": self.reserve_a,
558            "reserve_b": self.reserve_b,
559            "total_shares": self.total_shares,
560            "fee_bps": self.fee_bps,
561            "invariant_k": (self.reserve_a as u128) * (self.reserve_b as u128),
562            "providers": self.shares.len(),
563        })
564    }
565}
566
567// ---------------------------------------------------------------------------
568// Exchange (combined order book + AMM pools)
569// ---------------------------------------------------------------------------
570
571/// Combined exchange state: order book + AMM pool registry.
572#[derive(Debug, Clone, Serialize, Deserialize)]
573pub struct Exchange {
574    pub order_book: OrderBook,
575    pub pools: HashMap<String, AmmPool>,
576}
577
578impl Exchange {
579    pub fn new() -> Self {
580        Self {
581            order_book: OrderBook::new(),
582            pools: HashMap::new(),
583        }
584    }
585
586    /// Get or create a pool for the given currency pair.
587    ///
588    /// Pool keys are canonically sorted so `("tbtc3", "tbtc4")` and
589    /// `("tbtc4", "tbtc3")` resolve to the same pool.
590    pub fn get_or_create_pool(
591        &mut self,
592        currency_a: &str,
593        currency_b: &str,
594        fee_bps: u64,
595    ) -> &mut AmmPool {
596        let key = pool_key(currency_a, currency_b);
597        self.pools
598            .entry(key)
599            .or_insert_with(|| AmmPool::new(currency_a, currency_b, fee_bps))
600    }
601
602    /// Look up an existing pool (immutable).
603    pub fn get_pool(&self, currency_a: &str, currency_b: &str) -> Option<&AmmPool> {
604        let key = pool_key(currency_a, currency_b);
605        self.pools.get(&key)
606    }
607}
608
609impl Default for Exchange {
610    fn default() -> Self {
611        Self::new()
612    }
613}
614
615// ---------------------------------------------------------------------------
616// Helpers
617// ---------------------------------------------------------------------------
618
619/// Canonical pool key: sorted pair joined by `/`.
620fn pool_key(a: &str, b: &str) -> String {
621    if a <= b {
622        format!("{a}/{b}")
623    } else {
624        format!("{b}/{a}")
625    }
626}
627
628/// Integer square root of a `u128` via Newton's method.
629fn isqrt_u128(n: u128) -> u128 {
630    if n == 0 {
631        return 0;
632    }
633    let mut x = n;
634    let mut y = (x + 1) / 2;
635    while y < x {
636        x = y;
637        y = (x + n / x) / 2;
638    }
639    x
640}
641
642/// Platform-aware current time in seconds.
643fn now_secs() -> u64 {
644    #[cfg(target_arch = "wasm32")]
645    {
646        (js_sys::Date::now() / 1000.0) as u64
647    }
648    #[cfg(not(target_arch = "wasm32"))]
649    {
650        std::time::SystemTime::now()
651            .duration_since(std::time::UNIX_EPOCH)
652            .unwrap_or_default()
653            .as_secs()
654    }
655}
656
657// ---------------------------------------------------------------------------
658// Tests
659// ---------------------------------------------------------------------------
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use crate::payments::WebLedger;
665
666    /// Helper: create a ledger and fund two DIDs in two currencies.
667    fn setup_ledger() -> WebLedger {
668        let mut ledger = WebLedger::new("Test Exchange");
669        ledger.credit_currency("did:nostr:alice", "tbtc4", 10_000);
670        ledger.credit_currency("did:nostr:alice", "tbtc3", 5_000);
671        ledger.credit_currency("did:nostr:bob", "tbtc4", 8_000);
672        ledger.credit_currency("did:nostr:bob", "tbtc3", 12_000);
673        ledger
674    }
675
676    // -- Order book tests --------------------------------------------------
677
678    #[test]
679    fn test_order_create_and_list() {
680        let mut book = OrderBook::new();
681        book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
682        book.create_order("did:nostr:bob", "tbtc3", 50, "tbtc4", 1);
683        book.create_order("did:nostr:alice", "tbtc4", 200, "tbtc3", 3);
684
685        // List all.
686        assert_eq!(book.list_offers(None).len(), 3);
687
688        // Filter by pair.
689        let filtered = book.list_offers(Some(("tbtc4", "tbtc3")));
690        assert_eq!(filtered.len(), 2);
691        assert!(filtered.iter().all(|o| o.sell_currency == "tbtc4"));
692
693        // Different pair.
694        let filtered2 = book.list_offers(Some(("tbtc3", "tbtc4")));
695        assert_eq!(filtered2.len(), 1);
696        assert_eq!(filtered2[0].seller, "did:nostr:bob");
697    }
698
699    #[test]
700    fn test_order_cancel_by_seller() {
701        let mut book = OrderBook::new();
702        let order = book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
703
704        // Non-seller cannot cancel.
705        let err = book.cancel_order(&order.id, "did:nostr:bob").unwrap_err();
706        assert!(
707            format!("{err}").contains("belongs to"),
708            "Expected ownership error, got: {err}"
709        );
710
711        // Seller can cancel.
712        let cancelled = book.cancel_order(&order.id, "did:nostr:alice").unwrap();
713        assert_eq!(cancelled.id, order.id);
714        assert_eq!(book.list_offers(None).len(), 0);
715    }
716
717    #[test]
718    fn test_order_execute_swap() {
719        let mut ledger = setup_ledger();
720        let mut book = OrderBook::new();
721
722        // Alice sells 100 tbtc4 at 2 tbtc3 each (buyer pays 200 tbtc3).
723        let order =
724            book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
725
726        // Bob buys (pays 200 tbtc3, receives 100 tbtc4).
727        let result =
728            book.execute_swap(&order.id, "did:nostr:bob", &mut ledger).unwrap();
729
730        assert_eq!(result.amount_in, 200);  // Bob paid 200 tbtc3
731        assert_eq!(result.amount_out, 100); // Bob received 100 tbtc4
732        assert_eq!(result.fee, 0);          // No fee on order book
733
734        // Verify balances.
735        assert_eq!(
736            ledger.get_currency_balance("did:nostr:bob", "tbtc3"),
737            12_000 - 200
738        );
739        assert_eq!(
740            ledger.get_currency_balance("did:nostr:bob", "tbtc4"),
741            8_000 + 100
742        );
743        assert_eq!(
744            ledger.get_currency_balance("did:nostr:alice", "tbtc3"),
745            5_000 + 200
746        );
747        assert_eq!(
748            ledger.get_currency_balance("did:nostr:alice", "tbtc4"),
749            10_000 - 100
750        );
751
752        // Order removed.
753        assert_eq!(book.list_offers(None).len(), 0);
754    }
755
756    #[test]
757    fn test_order_swap_insufficient_balance() {
758        let mut ledger = setup_ledger();
759        let mut book = OrderBook::new();
760
761        // Alice sells 100 tbtc4 at price 200 tbtc3 each — total cost 20_000.
762        let order =
763            book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 200);
764
765        // Bob only has 12_000 tbtc3.
766        let err =
767            book.execute_swap(&order.id, "did:nostr:bob", &mut ledger).unwrap_err();
768        assert!(matches!(err, PaymentError::InsufficientBalance { .. }));
769
770        // Order still present.
771        assert_eq!(book.list_offers(None).len(), 1);
772    }
773
774    // -- AMM pool tests ----------------------------------------------------
775
776    #[test]
777    fn test_amm_add_liquidity_first() {
778        let mut ledger = setup_ledger();
779        let mut pool = AmmPool::new("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
780
781        let shares = pool
782            .add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
783            .unwrap();
784
785        // shares = isqrt(1000 * 2000) = isqrt(2_000_000) = 1414
786        assert_eq!(shares, isqrt_u128(2_000_000) as u64);
787        assert_eq!(pool.reserve_a, 1_000);
788        assert_eq!(pool.reserve_b, 2_000);
789        assert_eq!(pool.total_shares, shares);
790
791        // Provider balance debited.
792        assert_eq!(
793            ledger.get_currency_balance("did:nostr:alice", "tbtc4"),
794            10_000 - 1_000
795        );
796        assert_eq!(
797            ledger.get_currency_balance("did:nostr:alice", "tbtc3"),
798            5_000 - 2_000
799        );
800    }
801
802    #[test]
803    fn test_amm_add_liquidity_subsequent() {
804        let mut ledger = setup_ledger();
805        let mut pool = AmmPool::new("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
806
807        let shares_alice = pool
808            .add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
809            .unwrap();
810
811        // Bob adds proportional liquidity (same 1:2 ratio).
812        let shares_bob = pool
813            .add_liquidity("did:nostr:bob", 500, 1_000, &mut ledger)
814            .unwrap();
815
816        // Bob's shares should be proportional: 500/1000 * alice_shares = alice_shares / 2
817        // (exact within integer rounding).
818        assert_eq!(shares_bob, shares_alice / 2);
819        assert_eq!(pool.reserve_a, 1_500);
820        assert_eq!(pool.reserve_b, 3_000);
821        assert_eq!(pool.total_shares, shares_alice + shares_bob);
822    }
823
824    #[test]
825    fn test_amm_swap_constant_product() {
826        let mut ledger = setup_ledger();
827        let mut pool = AmmPool::new("tbtc4", "tbtc3", 0); // Zero fee for k verification.
828
829        pool.add_liquidity("did:nostr:alice", 5_000, 5_000, &mut ledger)
830            .unwrap();
831
832        let k_before = (pool.reserve_a as u128) * (pool.reserve_b as u128);
833
834        // Bob swaps 1_000 tbtc4 → tbtc3.
835        let result = pool
836            .swap("did:nostr:bob", "tbtc4", 1_000, &mut ledger)
837            .unwrap();
838
839        let k_after = (pool.reserve_a as u128) * (pool.reserve_b as u128);
840
841        // With zero fee, k must not decrease (rounding can only increase it).
842        assert!(k_after >= k_before, "k decreased: {k_before} → {k_after}");
843
844        // Verify output: out = 5000 * 1000 * 10000 / (5000 * 10000 + 1000 * 10000)
845        //                    = 5_000_000_000 / 60_000_000 = 833
846        assert_eq!(result.amount_out, 833);
847        assert_eq!(result.amount_in, 1_000);
848
849        // Reserves updated.
850        assert_eq!(pool.reserve_a, 6_000);
851        assert_eq!(pool.reserve_b, 5_000 - 833);
852    }
853
854    #[test]
855    fn test_amm_swap_fee_collection() {
856        let mut ledger = setup_ledger();
857        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30); // 0.3% fee.
858
859        pool.add_liquidity("did:nostr:alice", 5_000, 5_000, &mut ledger)
860            .unwrap();
861
862        let k_before = (pool.reserve_a as u128) * (pool.reserve_b as u128);
863
864        // Bob swaps 1_000 tbtc4 → tbtc3.
865        let result = pool
866            .swap("did:nostr:bob", "tbtc4", 1_000, &mut ledger)
867            .unwrap();
868
869        let k_after = (pool.reserve_a as u128) * (pool.reserve_b as u128);
870
871        // With fee, k must strictly increase (fee retained in reserves).
872        assert!(k_after > k_before, "k should increase with fee: {k_before} → {k_after}");
873
874        // Fee is 0.3% of 1000 = 3.
875        assert_eq!(result.fee, 3);
876
877        // Output should be slightly less than zero-fee case (833).
878        // out = 5000 * 1000 * 9970 / (5000 * 10000 + 1000 * 9970)
879        //     = 49_850_000_000 / 59_970_000 = 831
880        assert_eq!(result.amount_out, 831);
881    }
882
883    #[test]
884    fn test_amm_remove_liquidity() {
885        let mut ledger = setup_ledger();
886        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
887
888        let shares = pool
889            .add_liquidity("did:nostr:alice", 2_000, 4_000, &mut ledger)
890            .unwrap();
891
892        // Do a swap to generate fees.
893        pool.swap("did:nostr:bob", "tbtc4", 500, &mut ledger).unwrap();
894
895        // Alice removes all her shares.
896        let (got_a, got_b) = pool
897            .remove_liquidity("did:nostr:alice", shares, &mut ledger)
898            .unwrap();
899
900        // Alice gets back more than she put in because fees accrued.
901        // She deposited 2000 tbtc4 and 4000 tbtc3. The swap added
902        // 500 tbtc4 to reserves and removed some tbtc3.
903        assert!(
904            got_a > 2_000 || got_b > 4_000 || (got_a >= 2_000 && got_b >= 3_500),
905            "Expected fee accrual: got ({got_a}, {got_b}) vs deposited (2000, 4000)"
906        );
907
908        // Pool is empty after full withdrawal.
909        assert_eq!(pool.reserve_a, 0);
910        assert_eq!(pool.reserve_b, 0);
911        assert_eq!(pool.total_shares, 0);
912    }
913
914    #[test]
915    fn test_amm_swap_empty_pool() {
916        let mut ledger = setup_ledger();
917        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
918
919        let err = pool
920            .swap("did:nostr:bob", "tbtc4", 100, &mut ledger)
921            .unwrap_err();
922        assert!(
923            format!("{err}").contains("empty"),
924            "Expected empty pool error, got: {err}"
925        );
926    }
927
928    #[test]
929    fn test_exchange_multi_pool() {
930        let mut ledger = setup_ledger();
931        // Add a third currency.
932        ledger.credit_currency("did:nostr:alice", "signet", 10_000);
933
934        let mut exchange = Exchange::new();
935
936        let pool1 =
937            exchange.get_or_create_pool("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
938        pool1
939            .add_liquidity("did:nostr:alice", 1_000, 1_000, &mut ledger)
940            .unwrap();
941
942        let pool2 =
943            exchange.get_or_create_pool("tbtc4", "signet", AmmPool::DEFAULT_FEE_BPS);
944        pool2
945            .add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
946            .unwrap();
947
948        assert_eq!(exchange.pools.len(), 2);
949
950        // Both pools have independent reserves.
951        let p1 = exchange.get_pool("tbtc4", "tbtc3").unwrap();
952        assert_eq!(p1.reserve_a, 1_000);
953
954        let p2 = exchange.get_pool("tbtc4", "signet").unwrap();
955        assert_eq!(p2.reserve_b, 2_000);
956
957        // Canonical key order: get_pool("signet", "tbtc4") also works.
958        let p2_alt = exchange.get_pool("signet", "tbtc4").unwrap();
959        assert_eq!(p2_alt.reserve_b, 2_000);
960    }
961
962    #[test]
963    fn test_integer_overflow_safety() {
964        let mut ledger = WebLedger::new("Overflow Test");
965        let large = u64::MAX / 2;
966        ledger.credit_currency("did:nostr:whale", "tbtc4", large);
967        ledger.credit_currency("did:nostr:whale", "tbtc3", large);
968
969        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
970
971        // Add large liquidity.
972        let shares = pool
973            .add_liquidity("did:nostr:whale", large, large, &mut ledger)
974            .unwrap();
975        assert!(shares > 0);
976        assert_eq!(pool.reserve_a, large);
977        assert_eq!(pool.reserve_b, large);
978
979        // Fund a trader with a smaller amount.
980        ledger.credit_currency("did:nostr:trader", "tbtc4", 1_000_000);
981
982        // Swap should not panic from overflow.
983        let result = pool
984            .swap("did:nostr:trader", "tbtc4", 1_000_000, &mut ledger)
985            .unwrap();
986        assert!(result.amount_out > 0);
987        assert!(result.amount_out < 1_000_000); // Output < input due to large reserves + fee.
988
989        // Verify k didn't decrease.
990        let k = (pool.reserve_a as u128) * (pool.reserve_b as u128);
991        let k_original = (large as u128) * (large as u128);
992        assert!(k >= k_original);
993    }
994
995    // -- Serialization roundtrip -------------------------------------------
996
997    #[test]
998    fn test_exchange_serialization_roundtrip() {
999        let mut exchange = Exchange::new();
1000        exchange
1001            .order_book
1002            .create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
1003        exchange.get_or_create_pool("tbtc4", "tbtc3", 30);
1004
1005        let json = serde_json::to_string(&exchange).unwrap();
1006        let parsed: Exchange = serde_json::from_str(&json).unwrap();
1007
1008        assert_eq!(parsed.order_book.list_offers(None).len(), 1);
1009        assert_eq!(parsed.pools.len(), 1);
1010    }
1011
1012    // -- Pool info ---------------------------------------------------------
1013
1014    #[test]
1015    fn test_pool_info() {
1016        let mut ledger = setup_ledger();
1017        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
1018        pool.add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
1019            .unwrap();
1020
1021        let info = pool.pool_info();
1022        assert_eq!(info["currency_a"], "tbtc4");
1023        assert_eq!(info["reserve_a"], 1_000);
1024        assert_eq!(info["reserve_b"], 2_000);
1025        assert_eq!(info["fee_bps"], 30);
1026        assert_eq!(info["invariant_k"], 2_000_000u64);
1027        assert_eq!(info["providers"], 1);
1028    }
1029
1030    // -- isqrt helper ------------------------------------------------------
1031
1032    #[test]
1033    fn test_isqrt() {
1034        assert_eq!(isqrt_u128(0), 0);
1035        assert_eq!(isqrt_u128(1), 1);
1036        assert_eq!(isqrt_u128(4), 2);
1037        assert_eq!(isqrt_u128(9), 3);
1038        assert_eq!(isqrt_u128(10), 3);
1039        assert_eq!(isqrt_u128(2_000_000), 1414);
1040        // Large value: sqrt(u64::MAX * u64::MAX) = u64::MAX.
1041        let max = u64::MAX as u128;
1042        assert_eq!(isqrt_u128(max * max), max);
1043    }
1044}