diff --git a/README.md b/README.md index ef0ba76..b1745cb 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ $ lofty orders list|get|create|cancel # mutations confirm, or --force $ lofty quote recenter --property-id --bid

# move a quote safely (dry run by default) $ lofty quote provision --property-id --bid

--ask # post a fresh two-sided quote $ lofty quote pull --property-id --keep-above

# stand down, keeping recovery asks -$ lofty amm pools|quote|swap # swap needs --max-usdc / --min-usdc +$ lofty amm pools|quote|swap # swap needs a slippage bound (see Safety) $ lofty api GET /public/v1/amm/pools # raw passthrough (Bearer attached) $ lofty api --internal GET /properties/v2/marketplace # website API (open reads) $ lofty catalog --group exchange # observed endpoint inventory @@ -267,6 +267,24 @@ cannot drift apart. ## Safety +**Swap slippage bounds are checked, not trusted.** Every `amm swap` prices a +fresh quote first and compares your bound against it, reporting the tolerance it +implies: + +```console +$ lofty amm swap --pool-id --side buy --tokens 1 --max-usdc 56.00 +error: bound $56.00 is 9.2% from the live quote $51.27 — that authorises far more +slippage than you likely intend. Use --max-slippage-pct for an exact bound, or +--allow-high-slippage to proceed. +``` + +A bound is **refused above 5%** from the quote — pinned to the platform's own fee +load (3% platform + 2% pool LP), not to taste: a tolerance wider than the entire +fee schedule is far likelier a typo or a rounded guess than an intention. Prefer +`--max-slippage-pct 1`, which derives the bound from the quote instead of asking +you to pick a number. The check applies **under `--force` too**, where no prompt +is shown and a loose bound would otherwise pass unseen. + Placing orders, cancelling, and swapping are **real trades on your real account.** They prompt for confirmation; in scripts and agents pass `--force` explicitly to proceed non-interactively (otherwise they exit `6`). Writes carry diff --git a/src/commands/amm.rs b/src/commands/amm.rs index 993e13e..a6c4e20 100644 --- a/src/commands/amm.rs +++ b/src/commands/amm.rs @@ -45,18 +45,37 @@ pub enum Cmd { /// Token amount to swap. #[arg(long)] tokens: f64, - /// Max USDC to pay (required on buys). + /// Max USDC to pay (required on buys unless --max-slippage-pct is given). #[arg(long)] max_usdc: Option, - /// Min USDC to receive (required on sells). + /// Min USDC to receive (required on sells unless --max-slippage-pct is given). #[arg(long)] min_usdc: Option, + /// Derive the bound from a FRESH quote, allowing this much slippage. + /// Safer than hand-picking a number: the bound is computed, not rounded. + #[arg(long, value_name = "PCT")] + max_slippage_pct: Option, + /// Proceed even when the bound implies more slippage than ABSURD_SLIPPAGE_PCT. + #[arg(long)] + allow_high_slippage: bool, /// Skip the confirmation prompt. #[arg(long)] force: bool, }, } +/// Refuse a bound further than this from the live quote unless said explicitly. +/// +/// Pinned to the platform's own buy-fee load (platform 3% + pool LP 2% = 5%), not +/// picked by taste: a slippage tolerance wider than the ENTIRE fee schedule cannot +/// plausibly be deliberate, so it is far more likely a typo or a rounded-up guess. +/// This is a "you did not mean this" backstop — express a real tolerance with +/// --max-slippage-pct, which derives the bound exactly. +/// +/// It matters most under --force, where no prompt is shown and a loose bound would +/// otherwise pass unseen (exactly how a 9.2% bound got submitted by hand). +const ABSURD_SLIPPAGE_PCT: f64 = 5.0; + pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { match cmd { Cmd::Pools { property_id } => { @@ -115,41 +134,98 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { tokens, max_usdc, min_usdc, + max_slippage_pct, + allow_high_slippage, force, } => { validate_side(side)?; - match side.as_str() { - "buy" if max_usdc.is_none() => { - return Err(CliError::Usage( - "--max-usdc is required on buys (slippage bound)".into(), - )) - } - "sell" if min_usdc.is_none() => { - return Err(CliError::Usage( - "--min-usdc is required on sells (slippage bound)".into(), - )) + if max_usdc.is_some() && min_usdc.is_some() { + return Err(CliError::Usage( + "pass only the bound for your side: --max-usdc on buys, --min-usdc on sells" + .into(), + )); + } + if max_slippage_pct.is_some_and(|p| !p.is_finite() || p < 0.0) { + return Err(CliError::Usage( + "--max-slippage-pct must be a non-negative percent".into(), + )); + } + let buying = side == "buy"; + if max_slippage_pct.is_none() { + match (buying, max_usdc, min_usdc) { + (true, None, _) => return Err(CliError::Usage( + "buys need a slippage bound: --max-usdc or --max-slippage-pct " + .into(), + )), + (false, _, None) => return Err(CliError::Usage( + "sells need a slippage bound: --min-usdc or --max-slippage-pct " + .into(), + )), + _ => {} } - _ => {} } - let bound = match side.as_str() { - "buy" => format!("max ${:.2}", max_usdc.unwrap()), - _ => format!("min ${:.2}", min_usdc.unwrap()), + + let client = ctx.client()?; + // Always price a FRESH quote before trading. Two reasons: it derives the + // bound for --max-slippage-pct, and it lets an explicit bound be checked + // against reality instead of accepted on faith — a bound passed straight + // through unexamined is how a hand-rounded number silently authorises + // far more slippage than intended. + let quote = client.get( + "/public/v1/amm/quote", + &[ + ("poolId", pool_id.to_string()), + ("side", side.clone()), + ("tokenAmount", tokens.to_string()), + ], + )?; + // Buys are bounded on the swap amount (fees land on top of it); sells are + // bounded on proceeds. Compare like with like. + let reference = quote + .get("usdcAmount") + .and_then(Value::as_f64) + .ok_or_else(|| CliError::Other("quote returned no usdcAmount".into()))?; + let total_debit = quote.get("totalDebit").and_then(Value::as_f64); + let fees = quote.pointer("/fees/total").and_then(Value::as_f64); + + let bound = match (max_slippage_pct, buying) { + (Some(pct), true) => reference * (1.0 + pct / 100.0), + (Some(pct), false) => reference * (1.0 - pct / 100.0), + (None, true) => max_usdc.unwrap(), + (None, false) => min_usdc.unwrap(), + }; + let implied = implied_slippage_pct(bound, reference, buying); + if implied > ABSURD_SLIPPAGE_PCT && !allow_high_slippage { + return Err(CliError::Usage(format!( + "bound ${bound:.2} is {implied:.1}% from the live quote ${reference:.2} — that authorises far more slippage than you likely intend. Use --max-slippage-pct for an exact bound, or --allow-high-slippage to proceed." + ))); + } + + let detail = match (buying, total_debit, fees) { + (true, Some(td), Some(f)) => format!( + "quote ${reference:.2} + ${f:.2} fees = ${td:.2} expected; bound ${bound:.2} ({implied:.1}% slippage allowed)" + ), + (true, _, _) => format!( + "quote ${reference:.2}; bound ${bound:.2} ({implied:.1}% slippage allowed)" + ), + (false, _, _) => format!( + "quote ${reference:.2} proceeds; floor ${bound:.2} ({implied:.1}% slippage allowed)" + ), }; confirm( ctx, *force, - &format!("swap ({side}) {tokens} token(s) on pool {pool_id} ({bound})"), + &format!("swap ({side}) {tokens} token(s) on pool {pool_id} — {detail}"), )?; let mut body = json!({ "poolId": pool_id, "side": side, "tokenAmount": tokens, }); - if let Some(m) = max_usdc { - body["maxUsdcAmount"] = json!(m); - } - if let Some(m) = min_usdc { - body["minUsdcAmount"] = json!(m); + if buying { + body["maxUsdcAmount"] = json!(round2(bound)); + } else { + body["minUsdcAmount"] = json!(round2(bound)); } let client = ctx.client()?; let payload = client.post("/public/v1/amm/swap", &body)?; @@ -159,6 +235,25 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { } } +/// How much worse than the live quote a bound permits, in percent. Buys are +/// bounded above (paying more), sells below (receiving less), so the sign flips. +/// A bound better than the quote is 0, not negative slippage. Pure (unit-tested). +fn implied_slippage_pct(bound: f64, reference: f64, buying: bool) -> f64 { + if reference <= 0.0 { + return 0.0; + } + let pct = if buying { + (bound / reference - 1.0) * 100.0 + } else { + (1.0 - bound / reference) * 100.0 + }; + pct.max(0.0) +} + +fn round2(n: f64) -> f64 { + (n * 100.0).round() / 100.0 +} + fn validate_side(side: &str) -> Result<(), CliError> { if matches!(side, "buy" | "sell") { Ok(()) @@ -166,3 +261,39 @@ fn validate_side(side: &str) -> Result<(), CliError> { Err(CliError::Usage("--side must be `buy` or `sell`".into())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slippage_is_measured_against_the_live_quote() { + // The bound that actually got submitted by hand: $56 on a ~$51.22 quote. + // It reads as 9.3% — comfortably over the 5% backstop, so it is refused. + let pct = implied_slippage_pct(56.00, 51.22, true); + assert!((pct - 9.33).abs() < 0.05, "{pct}"); + assert!(pct > ABSURD_SLIPPAGE_PCT); + // A 1% bound is well inside it. + assert!(implied_slippage_pct(51.73, 51.22, true) < 1.05); + } + + #[test] + fn sells_are_bounded_the_other_way() { + // On a sell the bound is a FLOOR on proceeds, so slippage is the shortfall. + assert!((implied_slippage_pct(49.00, 50.00, false) - 2.0).abs() < 1e-9); + // Buying and selling at the same numbers are not the same tolerance. + assert!(implied_slippage_pct(49.00, 50.00, true) < 1e-9); + } + + #[test] + fn a_bound_better_than_the_quote_is_zero_not_negative() { + // Never report "-3% slippage" and never let a favourable bound look absurd. + assert_eq!(implied_slippage_pct(48.00, 50.00, true), 0.0); + assert_eq!(implied_slippage_pct(52.00, 50.00, false), 0.0); + } + + #[test] + fn a_zero_reference_cannot_divide() { + assert_eq!(implied_slippage_pct(10.0, 0.0, true), 0.0); + } +}