From c9628a43ba95a038da3c6d40409b24130a08fb1a Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 4 Aug 2026 17:59:05 -0400 Subject: [PATCH 1/2] Qualify a side by minTwoSidedLiquidity, not minContracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LP-reward programs publish two different thresholds and `rewards eligibility` collapsed them into one: minContracts an order must be this big to accrue SCORE minTwoSidedLiquidity an order must be this big to QUALIFY its side `bid_shares`/`ask_shares` only accumulated for orders that had already passed `big_enough` (qty >= minContracts), so that stricter bar silently governed the two-sided test as well. Any position whose ask sat below minContracts was reported "not two-sided: one-sided liquidity earns nothing" and credited with no expected reward, while the program was in fact paying it every hour. Qualification only requires that the order be COVERED and meet minTwoSidedLiquidity, which is 1 on the programs observed. A 1-share ask parked far outside the band contributes zero score yet still establishes the ask side, which is what lets the bid's score earn. Band and minContracts continue to gate scoring, unchanged. Under-reporting eligibility this way is expensive in both directions: live income reads as nothing, and the natural "fix" is to buy inventory to reach minContracts on a side that was already qualified — inventory that adds no score, bought at the AMM fee. Adds a regression test for the qualifying-but-not-scoring shape, and corrects an existing test that asserted the conflated rule. --- src/commands/rewards.rs | 64 ++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/src/commands/rewards.rs b/src/commands/rewards.rs index a27dfc7..2abf507 100644 --- a/src/commands/rewards.rs +++ b/src/commands/rewards.rs @@ -260,15 +260,19 @@ fn score_order(dist_from_mid: f64, allowed_spread: f64, quantity: f64) -> f64 { /// Evaluate one property's LP-reward eligibility. Pure (unit-tested). /// /// The published rules all have to hold at once: the position is TWO-SIDED (a -/// bid AND an ask — one-sided liquidity earns nothing), each order is at least -/// `minContracts`, rests within `allowedSpread` of the book mid, and is covered -/// by live balances (bids by USDC, asks by held tokens). +/// bid AND an ask — one-sided liquidity earns nothing), and to SCORE, an order +/// is at least `minContracts`, rests within `allowedSpread` of the book mid, and +/// is covered by live balances (bids by USDC, asks by held tokens). /// -/// Qualifying a SIDE and SCORING are separate steps: an order that is sized and -/// covered establishes that side exists, while only in-band orders accrue score. -/// So a position can be two-sided and still score nothing if every in-band order -/// is undersized — which is why the two are reported separately rather than -/// collapsed into one boolean. +/// Qualifying a SIDE and SCORING are separate steps with DIFFERENT thresholds: +/// * qualifying — a covered order of at least `minTwoSidedLiquidity` shares +/// (1 on every live program) establishes that the side exists. Neither +/// `minContracts` nor the band applies here. +/// * scoring — only in-band orders of at least `minContracts` accrue score. +/// +/// So a 1-share ask parked far out of band contributes ZERO score yet still +/// makes the position two-sided, letting the bid's score earn. Collapsing the +/// two thresholds understates eligibility and hides live income. fn eligibility(program: &Value, mine: &[Value], book: &Value, usdc: f64, held: f64) -> Value { let f = |v: &Value, k: &str, d: f64| v.get(k).and_then(Value::as_f64).unwrap_or(d); let spread = f(program, "allowedSpread", 0.0); @@ -314,8 +318,11 @@ fn eligibility(program: &Value, mine: &[Value], book: &Value, usdc: f64, held: f let in_band = dist <= spread; let big_enough = qty >= min_contracts; let covered = if is_buy { bids_covered } else { asks_covered }; - // Sized + covered qualifies the SIDE; in-band additionally earns score. - if big_enough && covered { + // Being COVERED qualifies the side; the bar is `minTwoSidedLiquidity` + // (1 on every live program), NOT `minContracts`. Gating this on + // `big_enough` conflated the two thresholds and reported properties as + // one-sided that Lofty was actually paying — see the regression test. + if covered { if is_buy { bid_shares += qty; } else { @@ -731,6 +738,32 @@ mod eligibility_tests { assert!(r["expectedDaily"].as_f64().unwrap() > 0.0); } + #[test] + fn a_tiny_out_of_band_ask_still_qualifies_the_side() { + // Regression, confirmed against observed payouts: a 4-share in-band bid + // plus a 1-share ask parked far out of band — the ask failing BOTH + // minContracts and the band. This shape reports as "not two-sided, $0" + // if the two thresholds are conflated, yet the program pays it: the + // bid's score earns because minTwoSidedLiquidity (1), not minContracts, + // decides whether a side exists. + let mine = [order("buy", 50.0, 4.0), order("sell", 60.0, 1.0)]; + let r = eligibility(&program(), &mine, &book(vec![], vec![]), 1000.0, 10.0); + assert_eq!(r["twoSided"], true); + assert_eq!(r["earning"], true); + assert!(r["expectedDaily"].as_f64().unwrap() > 0.0); + // ...while contributing no score of its own. + let ask = r["orders"] + .as_array() + .unwrap() + .iter() + .find(|o| o["direction"] == "sell") + .cloned() + .unwrap(); + assert_eq!(ask["scoring"], false); + assert_eq!(ask["meetsMinContracts"], false); + assert_eq!(ask["inBand"], false); + } + #[test] fn a_bid_alone_earns_nothing_however_good_it_is() { // The single most expensive mistake: one-sided liquidity earns NOTHING, @@ -752,13 +785,18 @@ mod eligibility_tests { } #[test] - fn an_undersized_order_cannot_qualify_its_side() { - // 2 tokens against minContracts 4 → the ask side never qualifies. + fn an_undersized_order_qualifies_its_side_but_never_scores() { + // 2 tokens against minContracts 4. This previously asserted the side did + // NOT qualify. That was wrong and it was expensive: properties showed as + // earning $0 while Lofty was paying them hourly. minContracts gates + // SCORE; minTwoSidedLiquidity (1) gates whether the side exists. let mine = [order("buy", 50.0, 4.0), order("sell", 52.0, 2.0)]; let r = eligibility(&program(), &mine, &book(vec![], vec![]), 1000.0, 10.0); - assert_eq!(r["earning"], false); + assert_eq!(r["twoSided"], true); + assert_eq!(r["earning"], true); // the in-band bid scores let ask = &r["orders"][1]; assert_eq!(ask["meetsMinContracts"], false); + assert_eq!(ask["scoring"], false); // the undersized ask contributes none assert!(ask["issues"] .as_array() .unwrap() From d562d1f14a1d81e6f2d3dcbfc3e17086f4e97509 Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 5 Aug 2026 08:33:48 -0400 Subject: [PATCH 2/2] Release 0.1.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78ddae5..8ada25e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,7 +1148,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lofty-cli" -version = "0.1.4" +version = "0.1.5" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 525ce60..d791ace 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lofty-cli" -version = "0.1.4" +version = "0.1.5" edition = "2021" rust-version = "1.88" description = "Interact with the Lofty (lofty.ai) fractional real-estate marketplace API — market data, order books, and market-making analysis. Keychain-secured, agent-friendly."