use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::payments::{PaymentError, WebLedger};
impl WebLedger {
pub fn get_currency_balance(&self, did: &str, currency: &str) -> u64 {
self.entries
.iter()
.find(|e| e.url == did)
.map(|e| e.amount.chain_balance(currency))
.unwrap_or(0)
}
pub fn credit_currency(&mut self, did: &str, currency: &str, amount: u64) {
use crate::payments::{CurrencyAmount, LedgerAmount, LedgerEntry};
self.updated = now_secs();
if let Some(entry) = self.entries.iter_mut().find(|e| e.url == did) {
match &mut entry.amount {
LedgerAmount::Simple(s) => {
let sat_val = s.parse::<u64>().unwrap_or(0);
let mut currencies = vec![CurrencyAmount {
currency: "satoshi".into(),
value: sat_val.to_string(),
}];
currencies.push(CurrencyAmount {
currency: currency.into(),
value: amount.to_string(),
});
entry.amount = LedgerAmount::Multi(currencies);
}
LedgerAmount::Multi(v) => {
if let Some(ca) = v.iter_mut().find(|a| a.currency == currency) {
let current: u64 = ca.value.parse().unwrap_or(0);
ca.value = current.saturating_add(amount).to_string();
} else {
v.push(CurrencyAmount {
currency: currency.into(),
value: amount.to_string(),
});
}
}
}
} else {
self.entries.push(LedgerEntry {
entry_type: "Entry".into(),
url: did.into(),
amount: LedgerAmount::Multi(vec![CurrencyAmount {
currency: currency.into(),
value: amount.to_string(),
}]),
});
}
}
pub fn debit_currency(
&mut self,
did: &str,
currency: &str,
amount: u64,
) -> Result<u64, PaymentError> {
let current = self.get_currency_balance(did, currency);
if current < amount {
return Err(PaymentError::InsufficientBalance {
balance: current,
cost: amount,
});
}
self.updated = now_secs();
let entry = self.entries.iter_mut().find(|e| e.url == did).unwrap();
match &mut entry.amount {
crate::payments::LedgerAmount::Multi(v) => {
if let Some(ca) = v.iter_mut().find(|a| a.currency == currency) {
let cur: u64 = ca.value.parse().unwrap_or(0);
let new_val = cur - amount;
ca.value = new_val.to_string();
Ok(new_val)
} else {
Err(PaymentError::InsufficientBalance {
balance: 0,
cost: amount,
})
}
}
crate::payments::LedgerAmount::Simple(_) => {
Err(PaymentError::InsufficientBalance {
balance: 0,
cost: amount,
})
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwapResult {
pub amount_in: u64,
pub amount_out: u64,
pub fee: u64,
pub new_balance_in: u64,
pub new_balance_out: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SellOrder {
pub id: String,
pub seller: String,
pub sell_currency: String,
pub sell_amount: u64,
pub buy_currency: String,
pub price: u64,
pub created_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderBook {
orders: Vec<SellOrder>,
next_id: u64,
}
impl OrderBook {
pub fn new() -> Self {
Self {
orders: Vec::new(),
next_id: 1,
}
}
pub fn create_order(
&mut self,
seller: &str,
sell_currency: &str,
sell_amount: u64,
buy_currency: &str,
price: u64,
) -> SellOrder {
let order = SellOrder {
id: self.next_id.to_string(),
seller: seller.into(),
sell_currency: sell_currency.into(),
sell_amount,
buy_currency: buy_currency.into(),
price,
created_at: now_secs(),
};
self.next_id += 1;
self.orders.push(order.clone());
order
}
pub fn list_offers(&self, currency_pair: Option<(&str, &str)>) -> Vec<&SellOrder> {
match currency_pair {
None => self.orders.iter().collect(),
Some((sell, buy)) => self
.orders
.iter()
.filter(|o| o.sell_currency == sell && o.buy_currency == buy)
.collect(),
}
}
pub fn cancel_order(
&mut self,
id: &str,
seller: &str,
) -> Result<SellOrder, PaymentError> {
let idx = self
.orders
.iter()
.position(|o| o.id == id)
.ok_or_else(|| {
PaymentError::InvalidTxo(format!("order {id} not found"))
})?;
if self.orders[idx].seller != seller {
return Err(PaymentError::InvalidTxo(format!(
"order {id} belongs to {}, not {seller}",
self.orders[idx].seller
)));
}
Ok(self.orders.remove(idx))
}
pub fn execute_swap(
&mut self,
id: &str,
buyer: &str,
ledger: &mut WebLedger,
) -> Result<SwapResult, PaymentError> {
let idx = self
.orders
.iter()
.position(|o| o.id == id)
.ok_or_else(|| {
PaymentError::InvalidTxo(format!("order {id} not found"))
})?;
let order = &self.orders[idx];
let total_cost = order
.sell_amount
.checked_mul(order.price)
.ok_or_else(|| {
PaymentError::InvalidTxo("price overflow".into())
})?;
let buyer_balance = ledger.get_currency_balance(buyer, &order.buy_currency);
if buyer_balance < total_cost {
return Err(PaymentError::InsufficientBalance {
balance: buyer_balance,
cost: total_cost,
});
}
let seller_balance =
ledger.get_currency_balance(&order.seller, &order.sell_currency);
if seller_balance < order.sell_amount {
return Err(PaymentError::InsufficientBalance {
balance: seller_balance,
cost: order.sell_amount,
});
}
let sell_amount = order.sell_amount;
let sell_currency = order.sell_currency.clone();
let buy_currency = order.buy_currency.clone();
let seller = order.seller.clone();
ledger.debit_currency(buyer, &buy_currency, total_cost)?;
ledger.credit_currency(&seller, &buy_currency, total_cost);
ledger.debit_currency(&seller, &sell_currency, sell_amount)?;
ledger.credit_currency(buyer, &sell_currency, sell_amount);
self.orders.remove(idx);
let new_balance_in = ledger.get_currency_balance(buyer, &buy_currency);
let new_balance_out = ledger.get_currency_balance(buyer, &sell_currency);
Ok(SwapResult {
amount_in: total_cost,
amount_out: sell_amount,
fee: 0,
new_balance_in,
new_balance_out,
})
}
}
impl Default for OrderBook {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AmmPool {
pub currency_a: String,
pub currency_b: String,
pub reserve_a: u64,
pub reserve_b: u64,
pub total_shares: u64,
pub fee_bps: u64,
shares: HashMap<String, u64>,
}
impl AmmPool {
pub const DEFAULT_FEE_BPS: u64 = 30;
pub fn new(currency_a: &str, currency_b: &str, fee_bps: u64) -> Self {
Self {
currency_a: currency_a.into(),
currency_b: currency_b.into(),
reserve_a: 0,
reserve_b: 0,
total_shares: 0,
fee_bps,
shares: HashMap::new(),
}
}
pub fn add_liquidity(
&mut self,
provider: &str,
amount_a: u64,
amount_b: u64,
ledger: &mut WebLedger,
) -> Result<u64, PaymentError> {
if amount_a == 0 || amount_b == 0 {
return Err(PaymentError::InvalidTxo(
"liquidity amounts must be non-zero".into(),
));
}
ledger.debit_currency(provider, &self.currency_a, amount_a)?;
ledger.debit_currency(provider, &self.currency_b, amount_b)?;
let shares = if self.total_shares == 0 {
let product = (amount_a as u128) * (amount_b as u128);
isqrt_u128(product) as u64
} else {
let share_a = (amount_a as u128) * (self.total_shares as u128)
/ (self.reserve_a as u128);
let share_b = (amount_b as u128) * (self.total_shares as u128)
/ (self.reserve_b as u128);
share_a.min(share_b) as u64
};
if shares == 0 {
return Err(PaymentError::InvalidTxo(
"liquidity too small to issue shares".into(),
));
}
self.reserve_a = self.reserve_a.saturating_add(amount_a);
self.reserve_b = self.reserve_b.saturating_add(amount_b);
self.total_shares = self.total_shares.saturating_add(shares);
*self.shares.entry(provider.into()).or_insert(0) += shares;
Ok(shares)
}
pub fn remove_liquidity(
&mut self,
provider: &str,
shares: u64,
ledger: &mut WebLedger,
) -> Result<(u64, u64), PaymentError> {
let provider_shares = self
.shares
.get(provider)
.copied()
.unwrap_or(0);
if provider_shares < shares {
return Err(PaymentError::InsufficientBalance {
balance: provider_shares,
cost: shares,
});
}
if self.total_shares == 0 {
return Err(PaymentError::InvalidTxo("pool has no shares".into()));
}
let amount_a =
((self.reserve_a as u128) * (shares as u128) / (self.total_shares as u128))
as u64;
let amount_b =
((self.reserve_b as u128) * (shares as u128) / (self.total_shares as u128))
as u64;
self.reserve_a = self.reserve_a.saturating_sub(amount_a);
self.reserve_b = self.reserve_b.saturating_sub(amount_b);
self.total_shares = self.total_shares.saturating_sub(shares);
let entry = self.shares.get_mut(provider).unwrap();
*entry -= shares;
if *entry == 0 {
self.shares.remove(provider);
}
ledger.credit_currency(provider, &self.currency_a, amount_a);
ledger.credit_currency(provider, &self.currency_b, amount_b);
Ok((amount_a, amount_b))
}
pub fn swap(
&mut self,
trader: &str,
from_currency: &str,
amount_in: u64,
ledger: &mut WebLedger,
) -> Result<SwapResult, PaymentError> {
if amount_in == 0 {
return Err(PaymentError::InvalidTxo(
"swap amount must be non-zero".into(),
));
}
let (reserve_in, reserve_out, to_currency) =
if from_currency == self.currency_a {
(self.reserve_a, self.reserve_b, self.currency_b.clone())
} else if from_currency == self.currency_b {
(self.reserve_b, self.reserve_a, self.currency_a.clone())
} else {
return Err(PaymentError::InvalidTxo(format!(
"currency {from_currency} not in pool ({}/{})",
self.currency_a, self.currency_b
)));
};
if reserve_in == 0 || reserve_out == 0 {
return Err(PaymentError::InvalidTxo("pool is empty".into()));
}
let fee_factor = 10_000u128 - (self.fee_bps as u128);
let numerator = (reserve_out as u128) * (amount_in as u128) * fee_factor;
let denominator =
(reserve_in as u128) * 10_000u128 + (amount_in as u128) * fee_factor;
let amount_out = (numerator / denominator) as u64;
if amount_out == 0 {
return Err(PaymentError::InvalidTxo(
"swap output rounds to zero".into(),
));
}
let effective_input =
((amount_in as u128) * fee_factor / 10_000u128) as u64;
let fee = amount_in - effective_input;
ledger.debit_currency(trader, from_currency, amount_in)?;
ledger.credit_currency(trader, &to_currency, amount_out);
if from_currency == self.currency_a {
self.reserve_a = self.reserve_a.saturating_add(amount_in);
self.reserve_b = self.reserve_b.saturating_sub(amount_out);
} else {
self.reserve_b = self.reserve_b.saturating_add(amount_in);
self.reserve_a = self.reserve_a.saturating_sub(amount_out);
}
let new_balance_in = ledger.get_currency_balance(trader, from_currency);
let new_balance_out = ledger.get_currency_balance(trader, &to_currency);
Ok(SwapResult {
amount_in,
amount_out,
fee,
new_balance_in,
new_balance_out,
})
}
pub fn pool_info(&self) -> serde_json::Value {
serde_json::json!({
"currency_a": self.currency_a,
"currency_b": self.currency_b,
"reserve_a": self.reserve_a,
"reserve_b": self.reserve_b,
"total_shares": self.total_shares,
"fee_bps": self.fee_bps,
"invariant_k": (self.reserve_a as u128) * (self.reserve_b as u128),
"providers": self.shares.len(),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Exchange {
pub order_book: OrderBook,
pub pools: HashMap<String, AmmPool>,
}
impl Exchange {
pub fn new() -> Self {
Self {
order_book: OrderBook::new(),
pools: HashMap::new(),
}
}
pub fn get_or_create_pool(
&mut self,
currency_a: &str,
currency_b: &str,
fee_bps: u64,
) -> &mut AmmPool {
let key = pool_key(currency_a, currency_b);
self.pools
.entry(key)
.or_insert_with(|| AmmPool::new(currency_a, currency_b, fee_bps))
}
pub fn get_pool(&self, currency_a: &str, currency_b: &str) -> Option<&AmmPool> {
let key = pool_key(currency_a, currency_b);
self.pools.get(&key)
}
}
impl Default for Exchange {
fn default() -> Self {
Self::new()
}
}
fn pool_key(a: &str, b: &str) -> String {
if a <= b {
format!("{a}/{b}")
} else {
format!("{b}/{a}")
}
}
fn isqrt_u128(n: u128) -> u128 {
if n == 0 {
return 0;
}
let mut x = n;
let mut y = (x + 1) / 2;
while y < x {
x = y;
y = (x + n / x) / 2;
}
x
}
fn now_secs() -> u64 {
#[cfg(target_arch = "wasm32")]
{
(js_sys::Date::now() / 1000.0) as u64
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payments::WebLedger;
fn setup_ledger() -> WebLedger {
let mut ledger = WebLedger::new("Test Exchange");
ledger.credit_currency("did:nostr:alice", "tbtc4", 10_000);
ledger.credit_currency("did:nostr:alice", "tbtc3", 5_000);
ledger.credit_currency("did:nostr:bob", "tbtc4", 8_000);
ledger.credit_currency("did:nostr:bob", "tbtc3", 12_000);
ledger
}
#[test]
fn test_order_create_and_list() {
let mut book = OrderBook::new();
book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
book.create_order("did:nostr:bob", "tbtc3", 50, "tbtc4", 1);
book.create_order("did:nostr:alice", "tbtc4", 200, "tbtc3", 3);
assert_eq!(book.list_offers(None).len(), 3);
let filtered = book.list_offers(Some(("tbtc4", "tbtc3")));
assert_eq!(filtered.len(), 2);
assert!(filtered.iter().all(|o| o.sell_currency == "tbtc4"));
let filtered2 = book.list_offers(Some(("tbtc3", "tbtc4")));
assert_eq!(filtered2.len(), 1);
assert_eq!(filtered2[0].seller, "did:nostr:bob");
}
#[test]
fn test_order_cancel_by_seller() {
let mut book = OrderBook::new();
let order = book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
let err = book.cancel_order(&order.id, "did:nostr:bob").unwrap_err();
assert!(
format!("{err}").contains("belongs to"),
"Expected ownership error, got: {err}"
);
let cancelled = book.cancel_order(&order.id, "did:nostr:alice").unwrap();
assert_eq!(cancelled.id, order.id);
assert_eq!(book.list_offers(None).len(), 0);
}
#[test]
fn test_order_execute_swap() {
let mut ledger = setup_ledger();
let mut book = OrderBook::new();
let order =
book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
let result =
book.execute_swap(&order.id, "did:nostr:bob", &mut ledger).unwrap();
assert_eq!(result.amount_in, 200); assert_eq!(result.amount_out, 100); assert_eq!(result.fee, 0);
assert_eq!(
ledger.get_currency_balance("did:nostr:bob", "tbtc3"),
12_000 - 200
);
assert_eq!(
ledger.get_currency_balance("did:nostr:bob", "tbtc4"),
8_000 + 100
);
assert_eq!(
ledger.get_currency_balance("did:nostr:alice", "tbtc3"),
5_000 + 200
);
assert_eq!(
ledger.get_currency_balance("did:nostr:alice", "tbtc4"),
10_000 - 100
);
assert_eq!(book.list_offers(None).len(), 0);
}
#[test]
fn test_order_swap_insufficient_balance() {
let mut ledger = setup_ledger();
let mut book = OrderBook::new();
let order =
book.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 200);
let err =
book.execute_swap(&order.id, "did:nostr:bob", &mut ledger).unwrap_err();
assert!(matches!(err, PaymentError::InsufficientBalance { .. }));
assert_eq!(book.list_offers(None).len(), 1);
}
#[test]
fn test_amm_add_liquidity_first() {
let mut ledger = setup_ledger();
let mut pool = AmmPool::new("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
let shares = pool
.add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
.unwrap();
assert_eq!(shares, isqrt_u128(2_000_000) as u64);
assert_eq!(pool.reserve_a, 1_000);
assert_eq!(pool.reserve_b, 2_000);
assert_eq!(pool.total_shares, shares);
assert_eq!(
ledger.get_currency_balance("did:nostr:alice", "tbtc4"),
10_000 - 1_000
);
assert_eq!(
ledger.get_currency_balance("did:nostr:alice", "tbtc3"),
5_000 - 2_000
);
}
#[test]
fn test_amm_add_liquidity_subsequent() {
let mut ledger = setup_ledger();
let mut pool = AmmPool::new("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
let shares_alice = pool
.add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
.unwrap();
let shares_bob = pool
.add_liquidity("did:nostr:bob", 500, 1_000, &mut ledger)
.unwrap();
assert_eq!(shares_bob, shares_alice / 2);
assert_eq!(pool.reserve_a, 1_500);
assert_eq!(pool.reserve_b, 3_000);
assert_eq!(pool.total_shares, shares_alice + shares_bob);
}
#[test]
fn test_amm_swap_constant_product() {
let mut ledger = setup_ledger();
let mut pool = AmmPool::new("tbtc4", "tbtc3", 0);
pool.add_liquidity("did:nostr:alice", 5_000, 5_000, &mut ledger)
.unwrap();
let k_before = (pool.reserve_a as u128) * (pool.reserve_b as u128);
let result = pool
.swap("did:nostr:bob", "tbtc4", 1_000, &mut ledger)
.unwrap();
let k_after = (pool.reserve_a as u128) * (pool.reserve_b as u128);
assert!(k_after >= k_before, "k decreased: {k_before} → {k_after}");
assert_eq!(result.amount_out, 833);
assert_eq!(result.amount_in, 1_000);
assert_eq!(pool.reserve_a, 6_000);
assert_eq!(pool.reserve_b, 5_000 - 833);
}
#[test]
fn test_amm_swap_fee_collection() {
let mut ledger = setup_ledger();
let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
pool.add_liquidity("did:nostr:alice", 5_000, 5_000, &mut ledger)
.unwrap();
let k_before = (pool.reserve_a as u128) * (pool.reserve_b as u128);
let result = pool
.swap("did:nostr:bob", "tbtc4", 1_000, &mut ledger)
.unwrap();
let k_after = (pool.reserve_a as u128) * (pool.reserve_b as u128);
assert!(k_after > k_before, "k should increase with fee: {k_before} → {k_after}");
assert_eq!(result.fee, 3);
assert_eq!(result.amount_out, 831);
}
#[test]
fn test_amm_remove_liquidity() {
let mut ledger = setup_ledger();
let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
let shares = pool
.add_liquidity("did:nostr:alice", 2_000, 4_000, &mut ledger)
.unwrap();
pool.swap("did:nostr:bob", "tbtc4", 500, &mut ledger).unwrap();
let (got_a, got_b) = pool
.remove_liquidity("did:nostr:alice", shares, &mut ledger)
.unwrap();
assert!(
got_a > 2_000 || got_b > 4_000 || (got_a >= 2_000 && got_b >= 3_500),
"Expected fee accrual: got ({got_a}, {got_b}) vs deposited (2000, 4000)"
);
assert_eq!(pool.reserve_a, 0);
assert_eq!(pool.reserve_b, 0);
assert_eq!(pool.total_shares, 0);
}
#[test]
fn test_amm_swap_empty_pool() {
let mut ledger = setup_ledger();
let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
let err = pool
.swap("did:nostr:bob", "tbtc4", 100, &mut ledger)
.unwrap_err();
assert!(
format!("{err}").contains("empty"),
"Expected empty pool error, got: {err}"
);
}
#[test]
fn test_exchange_multi_pool() {
let mut ledger = setup_ledger();
ledger.credit_currency("did:nostr:alice", "signet", 10_000);
let mut exchange = Exchange::new();
let pool1 =
exchange.get_or_create_pool("tbtc4", "tbtc3", AmmPool::DEFAULT_FEE_BPS);
pool1
.add_liquidity("did:nostr:alice", 1_000, 1_000, &mut ledger)
.unwrap();
let pool2 =
exchange.get_or_create_pool("tbtc4", "signet", AmmPool::DEFAULT_FEE_BPS);
pool2
.add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
.unwrap();
assert_eq!(exchange.pools.len(), 2);
let p1 = exchange.get_pool("tbtc4", "tbtc3").unwrap();
assert_eq!(p1.reserve_a, 1_000);
let p2 = exchange.get_pool("tbtc4", "signet").unwrap();
assert_eq!(p2.reserve_b, 2_000);
let p2_alt = exchange.get_pool("signet", "tbtc4").unwrap();
assert_eq!(p2_alt.reserve_b, 2_000);
}
#[test]
fn test_integer_overflow_safety() {
let mut ledger = WebLedger::new("Overflow Test");
let large = u64::MAX / 2;
ledger.credit_currency("did:nostr:whale", "tbtc4", large);
ledger.credit_currency("did:nostr:whale", "tbtc3", large);
let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
let shares = pool
.add_liquidity("did:nostr:whale", large, large, &mut ledger)
.unwrap();
assert!(shares > 0);
assert_eq!(pool.reserve_a, large);
assert_eq!(pool.reserve_b, large);
ledger.credit_currency("did:nostr:trader", "tbtc4", 1_000_000);
let result = pool
.swap("did:nostr:trader", "tbtc4", 1_000_000, &mut ledger)
.unwrap();
assert!(result.amount_out > 0);
assert!(result.amount_out < 1_000_000);
let k = (pool.reserve_a as u128) * (pool.reserve_b as u128);
let k_original = (large as u128) * (large as u128);
assert!(k >= k_original);
}
#[test]
fn test_exchange_serialization_roundtrip() {
let mut exchange = Exchange::new();
exchange
.order_book
.create_order("did:nostr:alice", "tbtc4", 100, "tbtc3", 2);
exchange.get_or_create_pool("tbtc4", "tbtc3", 30);
let json = serde_json::to_string(&exchange).unwrap();
let parsed: Exchange = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.order_book.list_offers(None).len(), 1);
assert_eq!(parsed.pools.len(), 1);
}
#[test]
fn test_pool_info() {
let mut ledger = setup_ledger();
let mut pool = AmmPool::new("tbtc4", "tbtc3", 30);
pool.add_liquidity("did:nostr:alice", 1_000, 2_000, &mut ledger)
.unwrap();
let info = pool.pool_info();
assert_eq!(info["currency_a"], "tbtc4");
assert_eq!(info["reserve_a"], 1_000);
assert_eq!(info["reserve_b"], 2_000);
assert_eq!(info["fee_bps"], 30);
assert_eq!(info["invariant_k"], 2_000_000u64);
assert_eq!(info["providers"], 1);
}
#[test]
fn test_isqrt() {
assert_eq!(isqrt_u128(0), 0);
assert_eq!(isqrt_u128(1), 1);
assert_eq!(isqrt_u128(4), 2);
assert_eq!(isqrt_u128(9), 3);
assert_eq!(isqrt_u128(10), 3);
assert_eq!(isqrt_u128(2_000_000), 1414);
let max = u64::MAX as u128;
assert_eq!(isqrt_u128(max * max), max);
}
}