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 self.fee_bps >= 10_000 {
370            return Err(PaymentError::InvalidTxo(
371                "pool fee must be below 10000 bps".into(),
372            ));
373        }
374        if amount_a == 0 || amount_b == 0 {
375            return Err(PaymentError::InvalidTxo(
376                "liquidity amounts must be non-zero".into(),
377            ));
378        }
379
380        // Debit provider.
381        ledger.debit_currency(provider, &self.currency_a, amount_a)?;
382        ledger.debit_currency(provider, &self.currency_b, amount_b)?;
383
384        let shares = if self.total_shares == 0 {
385            // First provider: shares = sqrt(amount_a * amount_b) using
386            // integer square root to stay in u64 land.
387            let product = (amount_a as u128) * (amount_b as u128);
388            isqrt_u128(product) as u64
389        } else {
390            // Proportional to existing reserves: min(a/A, b/B) * total.
391            let share_a =
392                (amount_a as u128) * (self.total_shares as u128) / (self.reserve_a as u128);
393            let share_b =
394                (amount_b as u128) * (self.total_shares as u128) / (self.reserve_b as u128);
395            share_a.min(share_b) as u64
396        };
397
398        if shares == 0 {
399            return Err(PaymentError::InvalidTxo(
400                "liquidity too small to issue shares".into(),
401            ));
402        }
403
404        self.reserve_a = self.reserve_a.saturating_add(amount_a);
405        self.reserve_b = self.reserve_b.saturating_add(amount_b);
406        self.total_shares = self.total_shares.saturating_add(shares);
407        *self.shares.entry(provider.into()).or_insert(0) += shares;
408
409        Ok(shares)
410    }
411
412    /// Remove liquidity from the pool.
413    ///
414    /// Withdraws proportional amounts of both currencies based on the
415    /// share count. Returns `(amount_a, amount_b)` credited back.
416    pub fn remove_liquidity(
417        &mut self,
418        provider: &str,
419        shares: u64,
420        ledger: &mut WebLedger,
421    ) -> Result<(u64, u64), PaymentError> {
422        let provider_shares = self.shares.get(provider).copied().unwrap_or(0);
423
424        if provider_shares < shares {
425            return Err(PaymentError::InsufficientBalance {
426                balance: provider_shares,
427                cost: shares,
428            });
429        }
430
431        if self.total_shares == 0 {
432            return Err(PaymentError::InvalidTxo("pool has no shares".into()));
433        }
434
435        // Proportional withdrawal.
436        let amount_a =
437            ((self.reserve_a as u128) * (shares as u128) / (self.total_shares as u128)) as u64;
438        let amount_b =
439            ((self.reserve_b as u128) * (shares as u128) / (self.total_shares as u128)) as u64;
440
441        self.reserve_a = self.reserve_a.saturating_sub(amount_a);
442        self.reserve_b = self.reserve_b.saturating_sub(amount_b);
443        self.total_shares = self.total_shares.saturating_sub(shares);
444
445        let entry = self.shares.get_mut(provider).unwrap();
446        *entry -= shares;
447        if *entry == 0 {
448            self.shares.remove(provider);
449        }
450
451        // Credit provider.
452        ledger.credit_currency(provider, &self.currency_a, amount_a);
453        ledger.credit_currency(provider, &self.currency_b, amount_b);
454
455        Ok((amount_a, amount_b))
456    }
457
458    /// Execute a constant-product swap.
459    ///
460    /// Formula (with fee):
461    /// ```text
462    /// amount_out = (reserve_out * amount_in * (10000 - fee_bps))
463    ///            / (reserve_in * 10000 + amount_in * (10000 - fee_bps))
464    /// ```
465    ///
466    /// All intermediate arithmetic uses `u128` to prevent overflow.
467    pub fn swap(
468        &mut self,
469        trader: &str,
470        from_currency: &str,
471        amount_in: u64,
472        ledger: &mut WebLedger,
473    ) -> Result<SwapResult, PaymentError> {
474        if amount_in == 0 {
475            return Err(PaymentError::InvalidTxo(
476                "swap amount must be non-zero".into(),
477            ));
478        }
479
480        let (reserve_in, reserve_out, to_currency) = if from_currency == self.currency_a {
481            (self.reserve_a, self.reserve_b, self.currency_b.clone())
482        } else if from_currency == self.currency_b {
483            (self.reserve_b, self.reserve_a, self.currency_a.clone())
484        } else {
485            return Err(PaymentError::InvalidTxo(format!(
486                "currency {from_currency} not in pool ({}/{})",
487                self.currency_a, self.currency_b
488            )));
489        };
490
491        if reserve_in == 0 || reserve_out == 0 {
492            return Err(PaymentError::InvalidTxo("pool is empty".into()));
493        }
494
495        // Constant-product with fee, using u128 intermediates.
496        let fee_factor = 10_000u128
497            .checked_sub(self.fee_bps as u128)
498            .filter(|factor| *factor > 0)
499            .ok_or_else(|| PaymentError::InvalidTxo("pool fee must be below 10000 bps".into()))?;
500        let numerator = (reserve_out as u128)
501            .checked_mul(amount_in as u128)
502            .and_then(|product| product.checked_mul(fee_factor))
503            .ok_or_else(|| PaymentError::InvalidTxo("swap arithmetic overflow".into()))?;
504        let denominator = (reserve_in as u128) * 10_000u128 + (amount_in as u128) * fee_factor;
505
506        let amount_out = (numerator / denominator) as u64;
507
508        if amount_out == 0 {
509            return Err(PaymentError::InvalidTxo(
510                "swap output rounds to zero".into(),
511            ));
512        }
513
514        // Fee retained in pool = amount_in - effective_input.
515        // effective_input = amount_in * fee_factor / 10000
516        let effective_input = ((amount_in as u128) * fee_factor / 10_000u128) as u64;
517        let fee = amount_in - effective_input;
518
519        // Debit trader's input currency, credit output currency.
520        ledger.debit_currency(trader, from_currency, amount_in)?;
521        ledger.credit_currency(trader, &to_currency, amount_out);
522
523        // Update reserves.
524        if from_currency == self.currency_a {
525            self.reserve_a = self.reserve_a.saturating_add(amount_in);
526            self.reserve_b = self.reserve_b.saturating_sub(amount_out);
527        } else {
528            self.reserve_b = self.reserve_b.saturating_add(amount_in);
529            self.reserve_a = self.reserve_a.saturating_sub(amount_out);
530        }
531
532        let new_balance_in = ledger.get_currency_balance(trader, from_currency);
533        let new_balance_out = ledger.get_currency_balance(trader, &to_currency);
534
535        Ok(SwapResult {
536            amount_in,
537            amount_out,
538            fee,
539            new_balance_in,
540            new_balance_out,
541        })
542    }
543
544    /// Pool info as a JSON value (for `/pay/.pool` response).
545    pub fn pool_info(&self) -> serde_json::Value {
546        serde_json::json!({
547            "currency_a": self.currency_a,
548            "currency_b": self.currency_b,
549            "reserve_a": self.reserve_a,
550            "reserve_b": self.reserve_b,
551            "total_shares": self.total_shares,
552            "fee_bps": self.fee_bps,
553            "invariant_k": (self.reserve_a as u128) * (self.reserve_b as u128),
554            "providers": self.shares.len(),
555        })
556    }
557}
558
559// ---------------------------------------------------------------------------
560// Exchange (combined order book + AMM pools)
561// ---------------------------------------------------------------------------
562
563/// Combined exchange state: order book + AMM pool registry.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct Exchange {
566    pub order_book: OrderBook,
567    pub pools: HashMap<String, AmmPool>,
568}
569
570impl Exchange {
571    pub fn new() -> Self {
572        Self {
573            order_book: OrderBook::new(),
574            pools: HashMap::new(),
575        }
576    }
577
578    /// Get or create a pool for the given currency pair.
579    ///
580    /// Pool keys are canonically sorted so `("tbtc3", "tbtc4")` and
581    /// `("tbtc4", "tbtc3")` resolve to the same pool.
582    pub fn get_or_create_pool(
583        &mut self,
584        currency_a: &str,
585        currency_b: &str,
586        fee_bps: u64,
587    ) -> &mut AmmPool {
588        let key = pool_key(currency_a, currency_b);
589        self.pools
590            .entry(key)
591            .or_insert_with(|| AmmPool::new(currency_a, currency_b, fee_bps))
592    }
593
594    /// Look up an existing pool (immutable).
595    pub fn get_pool(&self, currency_a: &str, currency_b: &str) -> Option<&AmmPool> {
596        let key = pool_key(currency_a, currency_b);
597        self.pools.get(&key)
598    }
599}
600
601impl Default for Exchange {
602    fn default() -> Self {
603        Self::new()
604    }
605}
606
607// ---------------------------------------------------------------------------
608// Helpers
609// ---------------------------------------------------------------------------
610
611/// Canonical pool key: sorted pair joined by `/`.
612fn pool_key(a: &str, b: &str) -> String {
613    if a <= b {
614        format!("{a}/{b}")
615    } else {
616        format!("{b}/{a}")
617    }
618}
619
620/// Integer square root of a `u128` via Newton's method.
621fn isqrt_u128(n: u128) -> u128 {
622    if n == 0 {
623        return 0;
624    }
625    let mut x = n;
626    let mut y = x.div_ceil(2);
627    while y < x {
628        x = y;
629        y = (x + n / x) / 2;
630    }
631    x
632}
633
634/// Platform-aware current time in seconds.
635fn now_secs() -> u64 {
636    #[cfg(target_arch = "wasm32")]
637    {
638        (js_sys::Date::now() / 1000.0) as u64
639    }
640    #[cfg(not(target_arch = "wasm32"))]
641    {
642        std::time::SystemTime::now()
643            .duration_since(std::time::UNIX_EPOCH)
644            .unwrap_or_default()
645            .as_secs()
646    }
647}
648
649// ---------------------------------------------------------------------------
650// Tests
651// ---------------------------------------------------------------------------
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::payments::WebLedger;
657
658    /// Helper: create a ledger and fund two DIDs in two currencies.
659    fn setup_ledger() -> WebLedger {
660        let mut ledger = WebLedger::new("Test Exchange");
661        ledger.credit_currency("did:nostr:alice", "tbtc4", 10_000);
662        ledger.credit_currency("did:nostr:alice", "tbtc3", 5_000);
663        ledger.credit_currency("did:nostr:bob", "tbtc4", 8_000);
664        ledger.credit_currency("did:nostr:bob", "tbtc3", 12_000);
665        ledger
666    }
667
668    // -- Order book tests --------------------------------------------------
669
670    #[test]
671    fn test_order_create_and_list() {
672        let mut book = OrderBook::new();
673        book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
674        book.create_order("did:nostr:bob", "tbtc3", 50, "tbtc4", 1);
675        book.create_order("did:nostr:alice", "tbtc4", 200, "tbtc3", 3);
676
677        // List all.
678        assert_eq!(book.list_offers(None).len(), 3);
679
680        // Filter by pair.
681        let filtered = book.list_offers(Some(("tbtc4", "tbtc3")));
682        assert_eq!(filtered.len(), 2);
683        assert!(filtered.iter().all(|o| o.sell_currency == "tbtc4"));
684
685        // Different pair.
686        let filtered2 = book.list_offers(Some(("tbtc3", "tbtc4")));
687        assert_eq!(filtered2.len(), 1);
688        assert_eq!(filtered2[0].seller, "did:nostr:bob");
689    }
690
691    #[test]
692    fn test_order_cancel_by_seller() {
693        let mut book = OrderBook::new();
694        let order = book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
695
696        // Non-seller cannot cancel.
697        let err = book.cancel_order(&order.id, "did:nostr:bob").unwrap_err();
698        assert!(
699            format!("{err}").contains("belongs to"),
700            "Expected ownership error, got: {err}"
701        );
702
703        // Seller can cancel.
704        let cancelled = book.cancel_order(&order.id, "did:nostr:alice").unwrap();
705        assert_eq!(cancelled.id, order.id);
706        assert_eq!(book.list_offers(None).len(), 0);
707    }
708
709    #[test]
710    fn test_order_execute_swap() {
711        let mut ledger = setup_ledger();
712        let mut book = OrderBook::new();
713
714        // Alice sells 100 tbtc4 at 2 tbtc3 each (buyer pays 200 tbtc3).
715        let order = book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
716
717        // Bob buys (pays 200 tbtc3, receives 100 tbtc4).
718        let result = book
719            .execute_swap(&order.id, "did:nostr:bob", &mut ledger)
720            .unwrap();
721
722        assert_eq!(result.amount_in, 200); // Bob paid 200 tbtc3
723        assert_eq!(result.amount_out, 100); // Bob received 100 tbtc4
724        assert_eq!(result.fee, 0); // No fee on order book
725
726        // Verify balances.
727        assert_eq!(
728            ledger.get_currency_balance("did:nostr:bob", "tbtc3"),
729            12_000 - 200
730        );
731        assert_eq!(
732            ledger.get_currency_balance("did:nostr:bob", "tbtc4"),
733            8_000 + 100
734        );
735        assert_eq!(
736            ledger.get_currency_balance("did:nostr:alice", "tbtc3"),
737            5_000 + 200
738        );
739        assert_eq!(
740            ledger.get_currency_balance("did:nostr:alice", "tbtc4"),
741            10_000 - 100
742        );
743
744        // Order removed.
745        assert_eq!(book.list_offers(None).len(), 0);
746    }
747
748    #[test]
749    fn test_order_swap_insufficient_balance() {
750        let mut ledger = setup_ledger();
751        let mut book = OrderBook::new();
752
753        // Alice sells 100 tbtc4 at price 200 tbtc3 each — total cost 20_000.
754        let order = book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 200);
755
756        // Bob only has 12_000 tbtc3.
757        let err = book
758            .execute_swap(&order.id, "did:nostr:bob", &mut ledger)
759            .unwrap_err();
760        assert!(matches!(err, PaymentError::InsufficientBalance { .. }));
761
762        // Order still present.
763        assert_eq!(book.list_offers(None).len(), 1);
764    }
765
766    // -- AMM pool tests ----------------------------------------------------
767
768    #[test]
769    fn test_amm_add_liquidity_first() {
770        let mut ledger = setup_ledger();
771        let mut pool = AmmPool::new("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
772
773        let shares = pool
774            .add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
775            .unwrap();
776
777        // shares = isqrt(1000 * 2000) = isqrt(2_000_000) = 1414
778        assert_eq!(shares, isqrt_u128(2_000_000) as u64);
779        assert_eq!(pool.reserve_a, 1_000);
780        assert_eq!(pool.reserve_b, 2_000);
781        assert_eq!(pool.total_shares, shares);
782
783        // Provider balance debited.
784        assert_eq!(
785            ledger.get_currency_balance("did:nostr:alice", "tbtc4"),
786            10_000 - 1_000
787        );
788        assert_eq!(
789            ledger.get_currency_balance("did:nostr:alice", "tbtc3"),
790            5_000 - 2_000
791        );
792    }
793
794    #[test]
795    fn test_amm_add_liquidity_subsequent() {
796        let mut ledger = setup_ledger();
797        let mut pool = AmmPool::new("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
798
799        let shares_alice = pool
800            .add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
801            .unwrap();
802
803        // Bob adds proportional liquidity (same 1:2 ratio).
804        let shares_bob = pool
805            .add_liquidity("did:nostr:bob", 500, 1_000, &mut ledger)
806            .unwrap();
807
808        // Bob's shares should be proportional: 500/1000 * alice_shares = alice_shares / 2
809        // (exact within integer rounding).
810        assert_eq!(shares_bob, shares_alice / 2);
811        assert_eq!(pool.reserve_a, 1_500);
812        assert_eq!(pool.reserve_b, 3_000);
813        assert_eq!(pool.total_shares, shares_alice + shares_bob);
814    }
815
816    #[test]
817    fn test_amm_swap_constant_product() {
818        let mut ledger = setup_ledger();
819        let mut pool = AmmPool::new("tbtc4", "tbtc3", 0); // Zero fee for k verification.
820
821        pool.add_liquidity("did:nostr:alice", 5_000, 5_000, &mut ledger)
822            .unwrap();
823
824        let k_before = (pool.reserve_a as u128) * (pool.reserve_b as u128);
825
826        // Bob swaps 1_000 tbtc4 → tbtc3.
827        let result = pool
828            .swap("did:nostr:bob", "tbtc4", 1_000, &mut ledger)
829            .unwrap();
830
831        let k_after = (pool.reserve_a as u128) * (pool.reserve_b as u128);
832
833        // With zero fee, k must not decrease (rounding can only increase it).
834        assert!(k_after >= k_before, "k decreased: {k_before} → {k_after}");
835
836        // Verify output: out = 5000 * 1000 * 10000 / (5000 * 10000 + 1000 * 10000)
837        //                    = 5_000_000_000 / 60_000_000 = 833
838        assert_eq!(result.amount_out, 833);
839        assert_eq!(result.amount_in, 1_000);
840
841        // Reserves updated.
842        assert_eq!(pool.reserve_a, 6_000);
843        assert_eq!(pool.reserve_b, 5_000 - 833);
844    }
845
846    #[test]
847    fn test_amm_swap_fee_collection() {
848        let mut ledger = setup_ledger();
849        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30); // 0.3% fee.
850
851        pool.add_liquidity("did:nostr:alice", 5_000, 5_000, &mut ledger)
852            .unwrap();
853
854        let k_before = (pool.reserve_a as u128) * (pool.reserve_b as u128);
855
856        // Bob swaps 1_000 tbtc4 → tbtc3.
857        let result = pool
858            .swap("did:nostr:bob", "tbtc4", 1_000, &mut ledger)
859            .unwrap();
860
861        let k_after = (pool.reserve_a as u128) * (pool.reserve_b as u128);
862
863        // With fee, k must strictly increase (fee retained in reserves).
864        assert!(
865            k_after > k_before,
866            "k should increase with fee: {k_before} → {k_after}"
867        );
868
869        // Fee is 0.3% of 1000 = 3.
870        assert_eq!(result.fee, 3);
871
872        // Output should be slightly less than zero-fee case (833).
873        // out = 5000 * 1000 * 9970 / (5000 * 10000 + 1000 * 9970)
874        //     = 49_850_000_000 / 59_970_000 = 831
875        assert_eq!(result.amount_out, 831);
876    }
877
878    #[test]
879    fn test_amm_remove_liquidity() {
880        let mut ledger = setup_ledger();
881        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
882
883        let shares = pool
884            .add_liquidity("did:nostr:alice", 2_000, 4_000, &mut ledger)
885            .unwrap();
886
887        // Do a swap to generate fees.
888        pool.swap("did:nostr:bob", "tbtc4", 500, &mut ledger)
889            .unwrap();
890
891        // Alice removes all her shares.
892        let (got_a, got_b) = pool
893            .remove_liquidity("did:nostr:alice", shares, &mut ledger)
894            .unwrap();
895
896        // Alice gets back more than she put in because fees accrued.
897        // She deposited 2000 tbtc4 and 4000 tbtc3. The swap added
898        // 500 tbtc4 to reserves and removed some tbtc3.
899        assert!(
900            got_a > 2_000 || got_b > 4_000 || (got_a >= 2_000 && got_b >= 3_500),
901            "Expected fee accrual: got ({got_a}, {got_b}) vs deposited (2000, 4000)"
902        );
903
904        // Pool is empty after full withdrawal.
905        assert_eq!(pool.reserve_a, 0);
906        assert_eq!(pool.reserve_b, 0);
907        assert_eq!(pool.total_shares, 0);
908    }
909
910    #[test]
911    fn test_amm_swap_empty_pool() {
912        let mut ledger = setup_ledger();
913        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
914
915        let err = pool
916            .swap("did:nostr:bob", "tbtc4", 100, &mut ledger)
917            .unwrap_err();
918        assert!(
919            format!("{err}").contains("empty"),
920            "Expected empty pool error, got: {err}"
921        );
922    }
923
924    #[test]
925    fn test_exchange_multi_pool() {
926        let mut ledger = setup_ledger();
927        // Add a third currency.
928        ledger.credit_currency("did:nostr:alice", "signet", 10_000);
929
930        let mut exchange = Exchange::new();
931
932        let pool1 = exchange.get_or_create_pool("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
933        pool1
934            .add_liquidity("did:nostr:alice", 1_000, 1_000, &mut ledger)
935            .unwrap();
936
937        let pool2 = exchange.get_or_create_pool("tbtc4", "signet", AmmPool::DEFAULT_FEE_BPS);
938        pool2
939            .add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
940            .unwrap();
941
942        assert_eq!(exchange.pools.len(), 2);
943
944        // Both pools have independent reserves.
945        let p1 = exchange.get_pool("tbtc4", "tbtc3").unwrap();
946        assert_eq!(p1.reserve_a, 1_000);
947
948        let p2 = exchange.get_pool("tbtc4", "signet").unwrap();
949        assert_eq!(p2.reserve_b, 2_000);
950
951        // Canonical key order: get_pool("signet", "tbtc4") also works.
952        let p2_alt = exchange.get_pool("signet", "tbtc4").unwrap();
953        assert_eq!(p2_alt.reserve_b, 2_000);
954    }
955
956    #[test]
957    fn test_integer_overflow_safety() {
958        let mut ledger = WebLedger::new("Overflow Test");
959        let large = u64::MAX / 2;
960        ledger.credit_currency("did:nostr:whale", "tbtc4", large);
961        ledger.credit_currency("did:nostr:whale", "tbtc3", large);
962
963        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
964
965        // Add large liquidity.
966        let shares = pool
967            .add_liquidity("did:nostr:whale", large, large, &mut ledger)
968            .unwrap();
969        assert!(shares > 0);
970        assert_eq!(pool.reserve_a, large);
971        assert_eq!(pool.reserve_b, large);
972
973        // Fund a trader with a smaller amount.
974        ledger.credit_currency("did:nostr:trader", "tbtc4", 1_000_000);
975
976        // Swap should not panic from overflow.
977        let result = pool
978            .swap("did:nostr:trader", "tbtc4", 1_000_000, &mut ledger)
979            .unwrap();
980        assert!(result.amount_out > 0);
981        assert!(result.amount_out < 1_000_000); // Output < input due to large reserves + fee.
982
983        // Verify k didn't decrease.
984        let k = (pool.reserve_a as u128) * (pool.reserve_b as u128);
985        let k_original = (large as u128) * (large as u128);
986        assert!(k >= k_original);
987    }
988
989    // -- Serialization roundtrip -------------------------------------------
990
991    #[test]
992    fn test_exchange_serialization_roundtrip() {
993        let mut exchange = Exchange::new();
994        exchange
995            .order_book
996            .create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
997        exchange.get_or_create_pool("tbtc4", "tbtc3", 30);
998
999        let json = serde_json::to_string(&exchange).unwrap();
1000        let parsed: Exchange = serde_json::from_str(&json).unwrap();
1001
1002        assert_eq!(parsed.order_book.list_offers(None).len(), 1);
1003        assert_eq!(parsed.pools.len(), 1);
1004    }
1005
1006    // -- Pool info ---------------------------------------------------------
1007
1008    #[test]
1009    fn test_pool_info() {
1010        let mut ledger = setup_ledger();
1011        let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
1012        pool.add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
1013            .unwrap();
1014
1015        let info = pool.pool_info();
1016        assert_eq!(info["currency_a"], "tbtc4");
1017        assert_eq!(info["reserve_a"], 1_000);
1018        assert_eq!(info["reserve_b"], 2_000);
1019        assert_eq!(info["fee_bps"], 30);
1020        assert_eq!(info["invariant_k"], 2_000_000u64);
1021        assert_eq!(info["providers"], 1);
1022    }
1023
1024    #[test]
1025    fn untrusted_pool_fees_and_large_arithmetic_fail_without_mutation() {
1026        let mut ledger = setup_ledger();
1027        for fee in [10_000, 10_001, u64::MAX] {
1028            let mut pool = AmmPool::new("tbtc4", "tbtc3", fee);
1029            assert!(pool
1030                .add_liquidity("did:nostr:alice", 1, 1, &mut ledger)
1031                .is_err());
1032            pool.reserve_a = 100;
1033            pool.reserve_b = 100;
1034            assert!(pool
1035                .swap("did:nostr:alice", "tbtc4", 1, &mut ledger)
1036                .is_err());
1037            assert_eq!((pool.reserve_a, pool.reserve_b), (100, 100));
1038        }
1039        let mut pool = AmmPool::new("tbtc4", "tbtc3", 0);
1040        pool.reserve_a = u64::MAX;
1041        pool.reserve_b = u64::MAX;
1042        assert!(pool
1043            .swap("did:nostr:alice", "tbtc4", u64::MAX, &mut ledger)
1044            .is_err());
1045    }
1046
1047    // -- isqrt helper ------------------------------------------------------
1048
1049    #[test]
1050    fn test_isqrt() {
1051        assert_eq!(isqrt_u128(0), 0);
1052        assert_eq!(isqrt_u128(1), 1);
1053        assert_eq!(isqrt_u128(4), 2);
1054        assert_eq!(isqrt_u128(9), 3);
1055        assert_eq!(isqrt_u128(10), 3);
1056        assert_eq!(isqrt_u128(2_000_000), 1414);
1057        // Large value: sqrt(u64::MAX * u64::MAX) = u64::MAX.
1058        let max = u64::MAX as u128;
1059        assert_eq!(isqrt_u128(max * max), max);
1060    }
1061}