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