Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ $ lofty orders list|get|create|cancel # mutations confirm, or --force
$ lofty quote recenter --property-id <ID> --bid <P> # move a quote safely (dry run by default)
$ lofty quote provision --property-id <ID> --bid <P> --ask <Q> # post a fresh two-sided quote
$ lofty quote pull --property-id <ID> --keep-above <P> # 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
Expand Down Expand Up @@ -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 <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
Expand Down
175 changes: 153 additions & 22 deletions src/commands/amm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
/// 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<f64>,
/// 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<f64>,
/// 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 } => {
Expand Down Expand Up @@ -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 <USD> or --max-slippage-pct <PCT>"
.into(),
)),
(false, _, None) => return Err(CliError::Usage(
"sells need a slippage bound: --min-usdc <USD> or --max-slippage-pct <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)?;
Expand All @@ -159,10 +235,65 @@ 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(())
} else {
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);
}
}
Loading