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