From 7abd14b9de703e08f3985d107ca87e00b4a06993 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 19:12:20 +0000 Subject: [PATCH 1/2] fix(markets): fail closed when difficulty-adjustment is unreachable Product betting halt must use mempool remainingBlocks only. Drop the tip-height 2016-block fallback so a down height source cannot silently allow place_bet; UI already disables Place bet on command error. Signed-off-by: Cursor Agent Co-authored-by: Adrien Lacombe --- crates/buzz-core/src/markets.rs | 6 +- desktop/src-tauri/src/commands/markets.rs | 151 +++++++++++++--------- desktop/src/features/markets/lib/halt.ts | 3 +- docs/bitcoin-markets.md | 10 +- 4 files changed, 101 insertions(+), 69 deletions(-) diff --git a/crates/buzz-core/src/markets.rs b/crates/buzz-core/src/markets.rs index edf5c1c7279..5bc223a1654 100644 --- a/crates/buzz-core/src/markets.rs +++ b/crates/buzz-core/src/markets.rs @@ -133,9 +133,9 @@ pub fn halt_height(current_height: u64) -> u64 { /// Height-based helper (not wall-clock): once the tip reaches /// `next_retarget - 24`, the wallet refuses new bets until after the retarget. /// -/// Product path prefers [`betting_halted_by_remaining_blocks`] from the live -/// mempool.space difficulty-adjustment signal; keep this for unit tests and as -/// a fallback when only a tip height is available. +/// Product path uses [`betting_halted_by_remaining_blocks`] from the live +/// mempool.space difficulty-adjustment signal only. Keep this height helper for +/// unit tests — do not use it as a live betting fallback when mempool is down. #[must_use] pub fn betting_halted(current_height: u64) -> bool { current_height >= halt_height(current_height) diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs index f0e4dd30a9e..9b913bcf155 100644 --- a/desktop/src-tauri/src/commands/markets.rs +++ b/desktop/src-tauri/src/commands/markets.rs @@ -10,7 +10,7 @@ use crate::app_state::AppState; use buzz_core_pkg::markets::{ - assert_fee_is_first_call, assert_markets_signing_keyring, betting_halted, + assert_fee_is_first_call, assert_markets_signing_keyring, betting_halted_by_remaining_blocks, build_validated_bet_batch, markets_signing_keyring_name, resolve_avnu_proxy_url, resolve_indexer_url, BetCallHex, NOSTR_ACCOUNT_CLASS_HASH, }; @@ -66,72 +66,51 @@ pub struct DifficultyHaltStatus { pub next_retarget_height: Option, /// `true` when `remaining_blocks <= 24`. pub halted: bool, - /// Source used: `mempool` (live) or `height_fallback` (2016-block math). + /// Always `mempool` — product path has no tip-height fallback. pub source: String, } const MEMPOOL_DIFFICULTY_ADJUSTMENT_URL: &str = "https://mempool.space/api/v1/difficulty-adjustment"; -const MEMPOOL_TIP_HEIGHT_URL: &str = "https://mempool.space/api/blocks/tip/height"; -/// Fetch the product halt signal. Prefers mempool `remainingBlocks`; falls back -/// to tip-height 2016-block math only if the adjustment endpoint is unavailable. +/// Parse mempool difficulty-adjustment JSON into a halt status. +/// +/// Fail closed: missing/`null` `remainingBlocks` is an error (never "not halted"). +fn difficulty_halt_status_from_adjustment(value: &Value) -> Result { + let remaining = value + .get("remainingBlocks") + .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))) + .ok_or_else(|| format!("difficulty-adjustment missing remainingBlocks: {value}"))?; + let next = value + .get("nextRetargetHeight") + .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))); + Ok(DifficultyHaltStatus { + remaining_blocks: remaining, + next_retarget_height: next, + halted: betting_halted_by_remaining_blocks(remaining), + source: "mempool".into(), + }) +} + +/// Fetch the product halt signal from mempool `remainingBlocks` only. +/// +/// No tip-height / 2016-block fallback: if the adjustment endpoint is down or +/// the field is missing, return an error so `place_bet` aborts without signing. async fn fetch_difficulty_halt_status() -> Result { let client = reqwest::Client::new(); - match client.get(MEMPOOL_DIFFICULTY_ADJUSTMENT_URL).send().await { - Ok(resp) if resp.status().is_success() => { - let value: Value = resp - .json() - .await - .map_err(|e| format!("difficulty-adjustment JSON: {e}"))?; - let remaining = value - .get("remainingBlocks") - .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))) - .ok_or_else(|| format!("difficulty-adjustment missing remainingBlocks: {value}"))?; - let next = value - .get("nextRetargetHeight") - .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64))); - Ok(DifficultyHaltStatus { - remaining_blocks: remaining, - next_retarget_height: next, - halted: betting_halted_by_remaining_blocks(remaining), - source: "mempool".into(), - }) - } - Ok(resp) => Err(format!("difficulty-adjustment HTTP {}", resp.status())), - Err(primary) => { - // Fallback: tip height + 2016-block math (still wallet-fetched). - let tip_resp = client - .get(MEMPOOL_TIP_HEIGHT_URL) - .send() - .await - .map_err(|e| { - format!("difficulty-adjustment failed ({primary}); tip height also failed: {e}") - })?; - if !tip_resp.status().is_success() { - return Err(format!( - "difficulty-adjustment failed ({primary}); tip height HTTP {}", - tip_resp.status() - )); - } - let tip_text = tip_resp - .text() - .await - .map_err(|e| format!("tip height body: {e}"))?; - let tip: u64 = tip_text - .trim() - .parse() - .map_err(|_| format!("invalid tip height {tip_text:?}"))?; - let next = buzz_core_pkg::markets::next_retarget_height(tip); - let remaining = next.saturating_sub(tip); - Ok(DifficultyHaltStatus { - remaining_blocks: remaining, - next_retarget_height: Some(next), - halted: betting_halted(tip), - source: "height_fallback".into(), - }) - } + let resp = client + .get(MEMPOOL_DIFFICULTY_ADJUSTMENT_URL) + .send() + .await + .map_err(|e| format!("difficulty-adjustment unreachable: {e}"))?; + if !resp.status().is_success() { + return Err(format!("difficulty-adjustment HTTP {}", resp.status())); } + let value: Value = resp + .json() + .await + .map_err(|e| format!("difficulty-adjustment JSON: {e}"))?; + difficulty_halt_status_from_adjustment(&value) } fn felt_hex(v: &str) -> Result { @@ -362,7 +341,10 @@ pub async fn place_bet( calls: Vec, token_amount: String, ) -> Result { - let halt = fetch_difficulty_halt_status().await?; + // Fail closed: unreachable/malformed adjustment → Err, no signing. + let halt = fetch_difficulty_halt_status().await.map_err(|e| { + format!("Betting is unavailable (Bitcoin height source unreachable): {e}") + })?; if halt.halted { return Err(format!( "Betting is paused until after the next Bitcoin difficulty retarget ({} blocks remaining)", @@ -579,9 +561,10 @@ pub async fn markets_indexer_url() -> Result { mod tests { use super::*; use buzz_core_pkg::markets::{ - assert_markets_signing_keyring, is_human_keyring_name, MarketsError, - HUMAN_IDENTITY_KEYRING_NAME, + assert_markets_signing_keyring, betting_halted_by_remaining_blocks, + is_human_keyring_name, MarketsError, HUMAN_IDENTITY_KEYRING_NAME, }; + use serde_json::json; #[test] fn agent_keyring_slot_used_by_secret_store_is_rejected() { @@ -614,4 +597,50 @@ mod tests { assert_eq!(v["contractAddress"], "0x1"); assert_eq!(v["entrypoint"], "execute_trade"); } + + #[test] + fn remaining_blocks_signal_halts_at_24() { + // Import helper — do not reimplement the threshold. + assert!(!betting_halted_by_remaining_blocks(25)); + assert!(betting_halted_by_remaining_blocks(24)); + assert!(betting_halted_by_remaining_blocks(0)); + let open = difficulty_halt_status_from_adjustment(&json!({ + "remainingBlocks": 25, + "nextRetargetHeight": 963648, + })) + .expect("25 remaining must parse"); + assert!(!open.halted); + assert_eq!(open.remaining_blocks, 25); + assert_eq!(open.source, "mempool"); + let halted = difficulty_halt_status_from_adjustment(&json!({ + "remainingBlocks": 24, + })) + .expect("24 remaining must parse"); + assert!(halted.halted); + let at_retarget = difficulty_halt_status_from_adjustment(&json!({ + "remainingBlocks": 0, + })) + .expect("0 remaining must parse"); + assert!(at_retarget.halted); + } + + #[test] + fn missing_adjustment_remaining_blocks_fails_closed() { + // Product hole this PR closes: never treat a bad/missing adjustment + // payload as "not halted" (no tip-height green light). + let err = difficulty_halt_status_from_adjustment(&json!({ + "nextRetargetHeight": 963648, + })) + .expect_err("missing remainingBlocks must error"); + assert!( + err.contains("remainingBlocks"), + "error must name the field: {err}" + ); + let null_err = difficulty_halt_status_from_adjustment(&json!({ + "remainingBlocks": null, + })) + .expect_err("null remainingBlocks must error"); + assert!(null_err.contains("remainingBlocks")); + assert!(difficulty_halt_status_from_adjustment(&json!({})).is_err()); + } } diff --git a/desktop/src/features/markets/lib/halt.ts b/desktop/src/features/markets/lib/halt.ts index 43885d0fc72..e3a82e922f6 100644 --- a/desktop/src/features/markets/lib/halt.ts +++ b/desktop/src/features/markets/lib/halt.ts @@ -13,7 +13,8 @@ export function haltHeight(currentHeight: number): number { /** * Height-based betting halt helper (not wall-clock). - * Product path prefers {@link bettingHaltedByRemainingBlocks} from mempool. + * Product path uses {@link bettingHaltedByRemainingBlocks} from mempool only — + * do not use this as a live fallback when the adjustment endpoint is down. */ export function bettingHalted(currentHeight: number): boolean { return currentHeight >= haltHeight(currentHeight); diff --git a/docs/bitcoin-markets.md b/docs/bitcoin-markets.md index 05bc8fc2d98..0cde2ff37dd 100644 --- a/docs/bitcoin-markets.md +++ b/docs/bitcoin-markets.md @@ -35,10 +35,12 @@ screen uses Atomiq `@atomiqlabs/sdk` `FROM_BTCLN_AUTO` into that address (`agent:`) never receive a Starknet account. Halt: wallet-owned (not indexer). Product signal is mempool.space -`GET /api/v1/difficulty-adjustment` — disable betting when -`remainingBlocks <= 24` (next retarget − 24). Tauri -`difficulty_halt_status` feeds the UI; `place_bet` re-fetches and refuses. -2016-block tip-height math remains as a unit-test / fallback helper only. +`GET /api/v1/difficulty-adjustment` only — disable betting when +`remainingBlocks <= 24` (next retarget − 24). If that endpoint is +unreachable or the field is missing, fail closed (no tip-height +fallback): `place_bet` aborts without signing and the UI disables Place +bet. Tauri `difficulty_halt_status` feeds the UI; `place_bet` re-fetches. +2016-block tip-height math remains as a unit-test helper only. Operator settle/pause after retarget is out of scope here. ## INDEXER_URL From a8d3d30c359b521b321d7744fd784eecfb8bbb59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 19:30:36 +0000 Subject: [PATCH 2/2] style(markets): rustfmt desktop markets halt path Satisfy desktop Tauri fmt check on import wrap and map_err chain. Signed-off-by: Cursor Agent Co-authored-by: Adrien Lacombe --- desktop/src-tauri/src/commands/markets.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/commands/markets.rs b/desktop/src-tauri/src/commands/markets.rs index 9b913bcf155..f8b22911656 100644 --- a/desktop/src-tauri/src/commands/markets.rs +++ b/desktop/src-tauri/src/commands/markets.rs @@ -10,9 +10,9 @@ use crate::app_state::AppState; use buzz_core_pkg::markets::{ - assert_fee_is_first_call, assert_markets_signing_keyring, - betting_halted_by_remaining_blocks, build_validated_bet_batch, markets_signing_keyring_name, - resolve_avnu_proxy_url, resolve_indexer_url, BetCallHex, NOSTR_ACCOUNT_CLASS_HASH, + assert_fee_is_first_call, assert_markets_signing_keyring, betting_halted_by_remaining_blocks, + build_validated_bet_batch, markets_signing_keyring_name, resolve_avnu_proxy_url, + resolve_indexer_url, BetCallHex, NOSTR_ACCOUNT_CLASS_HASH, }; use buzz_core_pkg::outside_execution::{ any_caller, felt_from_hex, selector_from_name, Felt, OutsideCall, OutsideExecution, @@ -342,9 +342,9 @@ pub async fn place_bet( token_amount: String, ) -> Result { // Fail closed: unreachable/malformed adjustment → Err, no signing. - let halt = fetch_difficulty_halt_status().await.map_err(|e| { - format!("Betting is unavailable (Bitcoin height source unreachable): {e}") - })?; + let halt = fetch_difficulty_halt_status() + .await + .map_err(|e| format!("Betting is unavailable (Bitcoin height source unreachable): {e}"))?; if halt.halted { return Err(format!( "Betting is paused until after the next Bitcoin difficulty retarget ({} blocks remaining)", @@ -561,8 +561,8 @@ pub async fn markets_indexer_url() -> Result { mod tests { use super::*; use buzz_core_pkg::markets::{ - assert_markets_signing_keyring, betting_halted_by_remaining_blocks, - is_human_keyring_name, MarketsError, HUMAN_IDENTITY_KEYRING_NAME, + assert_markets_signing_keyring, betting_halted_by_remaining_blocks, is_human_keyring_name, + MarketsError, HUMAN_IDENTITY_KEYRING_NAME, }; use serde_json::json;